Files
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
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
2026-09-04 09:21:37 +02:00

227 lines
8.2 KiB
Python

"""One turn: a question in, an answer streamed out, both persisted.
This is the only place that knows about SSE. A mode yields `ModeEvent`s and
knows nothing about HTTP; here they become frames on the wire. Persistence is
deliberately asymmetric: the user message is committed BEFORE streaming starts
so it survives anything the endpoint does, while the assistant message is
written at the end — complete, partial after an abort, or source-list-only when
no model could answer.
"""
import asyncio
import logging
import time
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import Depends
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.api.conversations.access import own_conversation
from app.api.conversations.routing import conversations_router
from app.api.conversations.schemas import SendMessage
from app.api.sse import sse
from app.auth.deps import get_current_user
from app.db import get_db
from app.errors import ApiError
from app.llm.errors import LLMError
from app.log import conversation_id as conversation_id_var
from app.models import Conversation, Message, MessageRole, User
from app.modes import get_mode
from app.modes.base import Degraded, Done, Error, Mode, Sources, StateChanged, Token
router = conversations_router()
logger = logging.getLogger("pablan.conversations")
@router.post("/{conversation_id}/messages")
async def send_message(
conversation_id: uuid.UUID,
body: SendMessage,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
conversation = await own_conversation(db, conversation_id, user, with_messages=True)
mode = get_mode(conversation.mode.value)
if mode is None:
raise ApiError(
400, f"Mode '{conversation.mode.value}' is not available.", "unknown_mode"
)
# The user message is committed before streaming starts — it survives
# whatever happens to the LLM call.
db.add(
Message(
conversation_id=conversation.id,
role=MessageRole.user,
content=body.content,
)
)
conversation.updated_at = datetime.now(UTC)
await db.commit()
return StreamingResponse(
stream_turn(conversation, body.content, mode, db),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
def _source_payload(chunks: Any) -> list[dict[str, Any]]:
"""The wire shape of a citation — the same dicts are snapshotted onto the
message, so a reload shows exactly what was streamed."""
return [
{
"document_id": str(chunk.document_id),
"title": chunk.title,
"heading_path": chunk.heading_path,
"excerpt": chunk.excerpt,
"used": chunk.used,
"review_pending": chunk.review_pending,
}
for chunk in chunks
]
async def stream_turn(
conversation: Conversation, content: str, mode: Mode, db: AsyncSession
) -> AsyncIterator[str]:
"""Convert ModeEvents to SSE frames; persist the assistant reply —
complete on normal end, partial on client abort or endpoint failure."""
context_token = conversation_id_var.set(str(conversation.id))
started = time.monotonic()
parts: list[str] = []
sources: list[dict[str, Any]] = []
# Set when the mode gave up on the model: the turn still has a reply (the
# retrieved documents), so it is persisted and replayed like any other.
fallback: str | None = None
outcome = "ok"
try:
try:
async for event in mode.handle_turn(conversation, content, db):
match event:
case Token(text=text):
parts.append(text)
yield sse("token", {"text": text})
case Sources(chunks=chunks):
sources = _source_payload(chunks)
yield sse("sources", {"chunks": sources})
case StateChanged(phase=phase, count=count):
yield sse("state", {"phase": phase, "count": count})
case Error(code=code):
outcome = "error"
yield sse("error", {"code": code})
case Degraded(code=code):
outcome = "degraded"
fallback = code
yield sse("fallback", {"code": code})
case Done():
pass # the router emits the final done after persisting
except LLMError as exc:
# Every endpoint failure inside a mode ends the turn the same way,
# wherever it happened. Retrieval embeds before the model is ever
# called, so an escaping error would reach the browser as a
# truncated stream ("connection lost") instead of the reason.
outcome = "error"
logger.warning(
"turn failed",
extra={
"event": "turn_error",
"mode": mode.name,
"code": exc.code,
"cause_type": exc.cause_type,
"status_code": exc.status_code,
},
)
yield sse("error", {"code": exc.code})
except (asyncio.CancelledError, GeneratorExit):
# Client aborted (stop button): keep what was already streamed.
outcome = "aborted"
if parts:
await asyncio.shield(
_persist_partial(db.bind, conversation.id, "".join(parts), sources)
)
raise
# Whatever arrived before the end is the reply, complete or not — for a
# fallback turn that is the source list alone.
if parts or fallback:
message_id = await _persist_assistant(
db, conversation, "".join(parts), sources, fallback=fallback
)
yield sse("done", {"message_id": str(message_id)})
finally:
conversation_id_var.reset(context_token)
logger.info(
"turn finished",
extra={
"event": "turn",
"mode": mode.name,
"outcome": outcome,
"duration_ms": round((time.monotonic() - started) * 1000),
"token_events": len(parts),
},
)
def _assistant_meta(
sources: list[dict[str, Any]], fallback: str | None
) -> dict[str, Any]:
meta: dict[str, Any] = {}
if sources:
meta["sources"] = sources
if fallback:
# Why there is no generated text, kept so a reload replays the turn as
# what it was rather than as an empty reply.
meta["fallback"] = fallback
return meta
async def _persist_assistant(
db: AsyncSession,
conversation: Conversation,
content: str,
sources: list[dict[str, Any]],
*,
fallback: str | None = None,
) -> uuid.UUID:
message = Message(
conversation_id=conversation.id,
role=MessageRole.assistant,
content=content,
meta=_assistant_meta(sources, fallback),
)
db.add(message)
conversation.updated_at = datetime.now(UTC)
await db.commit()
return message.id
async def _persist_partial(
bind: Any,
conversation_id: uuid.UUID,
content: str,
sources: list[dict[str, Any]],
) -> None:
"""Write what was streamed before the client hung up.
On a FRESH session on the same engine as the request session: the request
session is being torn down mid-cancel, so it cannot be used to commit, and
binding to the same engine keeps this working under the test overrides.
"""
async with async_sessionmaker(bind, expire_on_commit=False)() as db:
db.add(
Message(
conversation_id=conversation_id,
role=MessageRole.assistant,
content=content,
meta=_assistant_meta(sources, None),
)
)
conversation = await db.get(Conversation, conversation_id)
if conversation is not None:
conversation.updated_at = datetime.now(UTC)
await db.commit()