Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
68 lines
2.3 KiB
Python
68 lines
2.3 KiB
Python
"""Conversation context for a capture.
|
|
|
|
When a document is written out of a chat, that chat's subject is useful twice:
|
|
to find existing documents the user might extend, and as background for section
|
|
refinement. Both use a short LLM topic summary of the conversation. All LLM
|
|
traffic goes through `llm/client.py`; nothing here logs content — a failure
|
|
degrades quietly rather than breaking the capture.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from app.authoring.prompts import render_topic_summary_prompt
|
|
from app.llm.client import chat_json
|
|
from app.llm.errors import LLMError
|
|
from app.models import Conversation, MessageRole
|
|
|
|
logger = logging.getLogger("pablan.authoring")
|
|
|
|
# How many recent turns feed the summary — enough for the subject, bounded so
|
|
# a long thread cannot blow up the utility prompt.
|
|
MAX_CONTEXT_MESSAGES = 12
|
|
|
|
# Skip the reasoning model's hidden thinking: this is a short, latency-
|
|
# sensitive utility call.
|
|
_NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}}
|
|
|
|
|
|
class _TopicSummary(BaseModel):
|
|
topic: str
|
|
|
|
|
|
def conversation_transcript(conversation: Conversation) -> str:
|
|
"""The recent user/assistant turns as a plain transcript."""
|
|
turns = [
|
|
message
|
|
for message in conversation.messages
|
|
if message.role in (MessageRole.user, MessageRole.assistant)
|
|
][-MAX_CONTEXT_MESSAGES:]
|
|
return "\n".join(
|
|
f"{'User' if message.role == MessageRole.user else 'Assistant'}: "
|
|
f"{message.content}"
|
|
for message in turns
|
|
)
|
|
|
|
|
|
async def summarize_transcript(transcript: str) -> str:
|
|
"""A short topic summary of a conversation transcript, or '' if none can
|
|
be made. Failures degrade quietly."""
|
|
if not transcript.strip():
|
|
return ""
|
|
try:
|
|
result = await chat_json(
|
|
render_topic_summary_prompt(transcript),
|
|
_TopicSummary,
|
|
extra_body=_NO_THINKING,
|
|
)
|
|
except LLMError:
|
|
logger.info("topic summary failed", extra={"event": "topic_summary_failed"})
|
|
return ""
|
|
return result.topic.strip()
|
|
|
|
|
|
async def summarize_conversation(conversation: Conversation) -> str:
|
|
"""A short topic summary of the conversation, or '' if none can be made."""
|
|
return await summarize_transcript(conversation_transcript(conversation))
|