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
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user