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
75 lines
2.6 KiB
Python
75 lines
2.6 KiB
Python
"""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 []
|