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
97 lines
2.5 KiB
Python
97 lines
2.5 KiB
Python
"""The Mode protocol.
|
|
|
|
Every interaction type implements `Mode` and yields `ModeEvent`s; the
|
|
conversations router converts them 1:1 into SSE. Modes know nothing about
|
|
HTTP; routers know nothing about mode logic.
|
|
|
|
Capture is NOT a Mode: it is writing into a Document directly (see
|
|
`app/authoring/`), not a conversation. The only core Mode is query (RAG
|
|
Q&A); EE registers the insights mode.
|
|
"""
|
|
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from dataclasses import dataclass, field
|
|
from typing import Protocol, runtime_checkable
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models import Conversation
|
|
|
|
|
|
@dataclass
|
|
class Token:
|
|
text: str
|
|
|
|
|
|
@dataclass
|
|
class SourceChunk:
|
|
document_id: uuid.UUID
|
|
title: str
|
|
heading_path: str
|
|
excerpt: str = ""
|
|
# Whether this passage was actually passed to the model (grounding the
|
|
# answer), or only retrieved and then dropped as too weak (the no-answer
|
|
# path). Drives the "?" context inspector; the cited-source badges show
|
|
# only `used` chunks.
|
|
used: bool = True
|
|
# The document has an unanswered request to check it: readable, but not
|
|
# settled. Marked on the citation, because trusting an answer means
|
|
# trusting what it leaned on.
|
|
review_pending: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Sources:
|
|
chunks: list[SourceChunk] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class StateChanged:
|
|
"""Progress signal for the UI — metadata only.
|
|
|
|
Query mode reports a phase (e.g. "searching" / "no_answer") and a count
|
|
(documents found). No content ever rides this frame.
|
|
"""
|
|
|
|
phase: str
|
|
count: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class Done:
|
|
"""Emitted by the ROUTER after persisting the assistant message.
|
|
Modes normally end their iterator instead of yielding this."""
|
|
|
|
message_id: uuid.UUID | None = None
|
|
|
|
|
|
@dataclass
|
|
class Error:
|
|
"""Why the turn failed, as a code the frontend phrases (CLAUDE.md: the
|
|
backend never renders UI-language strings)."""
|
|
|
|
code: str
|
|
|
|
|
|
@dataclass
|
|
class Degraded:
|
|
"""No model could be reached, so this turn has no generated answer: the
|
|
accompanying `Sources` are what a plain full-text search found, for the
|
|
user to read themselves. `code` is the endpoint failure that caused it
|
|
(`LLMError.code`); the frontend says what it means."""
|
|
|
|
code: str
|
|
|
|
|
|
ModeEvent = Token | Sources | StateChanged | Done | Error | Degraded
|
|
|
|
|
|
@runtime_checkable
|
|
class Mode(Protocol):
|
|
name: str
|
|
|
|
def handle_turn(
|
|
self, conversation: Conversation, user_message: str, db: AsyncSession
|
|
) -> AsyncIterator[ModeEvent]: ...
|