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
+18
View File
@@ -0,0 +1,18 @@
"""The authoring API: the model's help while someone writes.
Three endpoints under `/documents`, all owner-scoped: refine the section at the
cursor (streamed), suggest a title for a finished draft, and suggest which
existing document a capture should extend. What they share is `grounding` — the
permission-filtered look at what the company already wrote.
`suggest-similar` has a static path and is registered before the refine module
so it cannot be read as a document id.
"""
from fastapi import APIRouter
from app.api.authoring import refine, suggest
router = APIRouter()
router.include_router(suggest.router)
router.include_router(refine.router)
+74
View File
@@ -0,0 +1,74 @@
"""What the company already wrote about this.
Before refining a section, the server looks for related published material the
author may read and hands it to the prompt as a reference — so a suggestion
stays consistent with the rest of the knowledge base instead of inventing a
parallel version of it. The same chunks are surfaced to the editor's "?"
inspector, so the author can see where a suggestion drew from.
"""
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.llm.errors import LLMError
from app.models import User
from app.rag.chunking import HEADING_RE
from app.rag.similarity import (
CAPTURE_CONTEXT_MAX_DISTANCE,
SimilarChunk,
similar_chunks,
)
# A section needs at least this much of its own text (beyond the heading)
# before it is worth searching the knowledge base to ground the refinement:
# a bare heading matches nothing useful and only adds noise.
MIN_CHARS = 40
TOP_K = 3
# Cap each grounding excerpt so a few long ones cannot crowd out the section.
EXCERPT_CHARS = 600
def leading_heading(section_text: str) -> str | None:
"""The heading text a section starts with, to match a template hint."""
first = section_text.lstrip().splitlines()[0] if section_text.strip() else ""
match = HEADING_RE.match(first)
return match.group(2).strip() if match else None
def reference(title: str, heading_path: str, content: str) -> str:
"""One retrieved chunk, rendered for the prompt: where it comes from, then
a bounded excerpt."""
excerpt = content.strip()
if len(excerpt) > EXCERPT_CHARS:
excerpt = excerpt[:EXCERPT_CHARS].rstrip() + " ..."
where = f'"{title}" ({heading_path})' if heading_path else f'"{title}"'
return f"From {where}:\n{excerpt}"
async def for_section(
db: AsyncSession, section_text: str, user: User, document_id: uuid.UUID
) -> list[SimilarChunk]:
"""Related knowledge for one section, permission-filtered by construction.
Returns nothing when the section is still too thin to match on, when the
current document is the only match, or when the embedding endpoint is
unavailable — the refinement then simply proceeds without grounding.
"""
query = section_text.strip()
_, _, after_heading = query.partition("\n")
body = after_heading.strip() if leading_heading(query) is not None else query
if len(body) < MIN_CHARS:
return []
try:
return await similar_chunks(
db,
query,
user=user,
top_k=TOP_K,
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
exclude_builtin=True,
exclude_document_id=document_id,
)
except LLMError:
return []
+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,
},
)
+12
View File
@@ -0,0 +1,12 @@
"""The one router constructor the authoring modules share.
The prefix is `/documents`: authoring acts ON a document the caller owns, so
its endpoints live under the document they belong to rather than in a
namespace of their own.
"""
from fastapi import APIRouter
def authoring_router() -> APIRouter:
return APIRouter(prefix="/documents", tags=["authoring"])
+101
View File
@@ -0,0 +1,101 @@
"""Two small suggestions the editor asks for: a title, and what to extend.
Both are one-shot calls rather than streams, and both are advisory: the author
keeps the generic title or starts a new document if the suggestion does not fit.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.api.authoring.routing import authoring_router
from app.api.documents import readable_document, require_editor
from app.auth.deps import get_current_user
from app.authoring.context import summarize_conversation
from app.authoring.prompts import render_title_prompt
from app.db import get_db
from app.errors import ApiError
from app.llm.client import NO_THINKING, chat_json
from app.llm.errors import LLMError
from app.models import Conversation, User
from app.rag.similarity import CAPTURE_CONTEXT_MAX_DISTANCE, similar_documents
router = authoring_router()
class TitleSuggestion(BaseModel):
title: str
@router.post("/{document_id}/suggest-title")
async def suggest_title(
document_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> TitleSuggestion:
"""Suggest a concise title from the document's content (review step for a
new document). Owner-scoped; content in, title out, nothing logged."""
document = await readable_document(db, document_id, user)
require_editor(document, user)
if not document.content_md.strip():
return TitleSuggestion(title=document.title)
try:
return await chat_json(
render_title_prompt(document.content_md),
TitleSuggestion,
extra_body=NO_THINKING,
)
except LLMError as exc:
raise ApiError(503, "The model endpoint did not answer.", exc.code) from None
class SuggestSimilarRequest(BaseModel):
conversation_id: uuid.UUID
class SimilarDocumentOut(BaseModel):
document_id: uuid.UUID
title: str
@router.post("/suggest-similar")
async def suggest_similar(
body: SuggestSimilarRequest,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[SimilarDocumentOut]:
"""Existing documents that match the conversation a capture is starting
from — found over an LLM TOPIC SUMMARY of the chat (not the raw last
message), permission-filtered, help pages excluded. Empty list when the
conversation is unknown, empty, or nothing is close enough."""
conversation = (
await db.execute(
select(Conversation)
.where(
Conversation.id == body.conversation_id,
Conversation.user_id == user.id,
)
.options(selectinload(Conversation.messages))
)
).scalar_one_or_none()
if conversation is None:
return []
topic = await summarize_conversation(conversation)
if not topic:
return []
matches = await similar_documents(
db,
topic,
user=user,
max_distance=CAPTURE_CONTEXT_MAX_DISTANCE,
exclude_builtin=True,
)
return [
SimilarDocumentOut(document_id=match.document_id, title=match.title)
for match in matches
]