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:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
"""Section refinement for the writing editor.
The user writes Markdown; after a pause the client asks the model to refine the
section the cursor is in. The whole document is context, but the model
regenerates ONLY that section (FIM-style), streamed back as SSE so the
suggestion appears progressively and can be aborted the moment the user resumes
typing.
The request body and the streamed response carry document text. That is fine on
this owner-scoped endpoint — the same trust boundary as
`GET /api/documents/{id}` — but nothing here logs content: metadata only.
"""
import asyncio
import logging
import time
import uuid
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.authoring import grounding
from app.api.authoring.routing import authoring_router
from app.api.documents import readable_document, require_editor
from app.api.sse import sse
from app.auth.deps import get_current_user
from app.authoring.prompts import render_refine_prompt
from app.authoring.schema import AuthoringTemplate
from app.authoring.sections import ActiveSection, active_section, slice_lines
from app.db import get_db
from app.llm.client import NO_THINKING, chat_stream
from app.llm.errors import LLMError
from app.models import Template, User
router = authoring_router()
logger = logging.getLogger("pablan.authoring")
class RefineRequest(BaseModel):
content_md: str = Field(max_length=100_000)
cursor_line: int = Field(ge=1)
async def _template_for(
db: AsyncSession, template_config_id: str | None
) -> AuthoringTemplate | None:
"""The blueprint a document was started from, if it still exists and still
parses — it carries the persona, the temperature and the per-section hints
that shape a refinement."""
if not template_config_id:
return None
row = (
await db.execute(
select(Template).where(Template.config["id"].astext == template_config_id)
)
).scalar_one_or_none()
if row is None:
return None
try:
return AuthoringTemplate.model_validate(row.config)
except ValidationError:
return None
@router.post("/{document_id}/refine")
async def refine_section(
document_id: uuid.UUID,
body: RefineRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> StreamingResponse:
"""Stream a matured version of the section at the cursor.
SSE frames: one `section` frame with the exact line range the suggestion
replaces, then `token` frames, then `done` (or `error`)."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
section = active_section(body.content_md, body.cursor_line)
prefix, section_text, suffix = slice_lines(
body.content_md, section.start_line, section.end_line
)
meta = document.meta or {}
persona: str | None = None
hint: str | None = None
temperature = 0.4
template = await _template_for(db, meta.get("template"))
if template is not None:
persona = template.persona
temperature = template.model.temperature
heading = grounding.leading_heading(section_text)
if heading:
hint = template.hint_for(heading)
# Related, already-published knowledge the author may read. Rendered into
# the prompt as grounding, AND surfaced to the editor's "?" inspector so the
# author can see where a suggestion drew from.
chunks = await grounding.for_section(db, section_text, user, document.id)
messages = render_refine_prompt(
section_text,
prefix=prefix,
suffix=suffix,
persona=persona,
hint=hint,
# Background from the chat this capture came from, if any. Like the
# document text, it travels only on this owner-scoped call and is
# never logged.
context=meta.get("context"),
knowledge=[
grounding.reference(chunk.title, chunk.heading_path, chunk.content)
for chunk in chunks
],
)
references = [
{"title": chunk.title, "heading_path": chunk.heading_path} for chunk in chunks
]
return StreamingResponse(
_stream_refine(messages, section, temperature, str(document.id), references),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
async def _stream_refine(
messages: list[dict[str, str]],
section: ActiveSection,
temperature: float,
document_id: str,
references: list[dict[str, str]],
) -> AsyncIterator[str]:
started = time.monotonic()
outcome = "ok"
token_events = 0
# First: which lines "Accept" will overwrite, so the client can bind the
# suggestion to an exact range even as the model streams.
yield sse(
"section", {"start_line": section.start_line, "end_line": section.end_line}
)
# What the suggestion is grounding on (the author's own readable material) —
# titles + heading paths only, for the "?" inspector. Content-safe.
if references:
yield sse("grounding", {"references": references})
try:
async for token in chat_stream(
messages, role="chat", temperature=temperature, extra_body=NO_THINKING
):
token_events += 1
yield sse("token", {"text": token})
yield sse("done", {})
except LLMError as exc:
outcome = "error"
yield sse("error", {"code": exc.code})
except (asyncio.CancelledError, GeneratorExit):
outcome = "aborted"
raise
finally:
logger.info(
"refine finished",
extra={
"event": "refine",
"outcome": outcome,
"duration_ms": round((time.monotonic() - started) * 1000),
"token_events": token_events,
"document_id": document_id,
},
)