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

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]: ...