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
68 lines
2.2 KiB
Python
68 lines
2.2 KiB
Python
"""A template's blueprint: parsing it, and keeping its id unique.
|
|
|
|
The config id is a slug, not a row id — it is what documents record as their
|
|
origin and what the catalog matches on, so two rows must never share one.
|
|
Three endpoints need that rule (add from catalog, save from the builder,
|
|
duplicate), which is why it is written once here.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.authoring.schema import AuthoringTemplate
|
|
from app.errors import ApiError
|
|
from app.models import Template
|
|
from app.template_import import TemplateImportError, parse_template
|
|
|
|
|
|
def parse_or_422(source: str) -> AuthoringTemplate:
|
|
"""YAML in, validated blueprint out. The parse error is the message: it
|
|
already says which field is wrong, and an admin is the one reading it."""
|
|
try:
|
|
return parse_template(source)
|
|
except TemplateImportError as exc:
|
|
raise ApiError(422, str(exc), "invalid_template") from None
|
|
|
|
|
|
async def taken_config_ids(db: AsyncSession) -> set[str]:
|
|
return set((await db.execute(select(Template.config["id"].astext))).scalars().all())
|
|
|
|
|
|
def unique_config_id(base: str, taken: set[str], *, suffix: str = "") -> str:
|
|
"""`base`, or `base-2`, `base-3` … until it is free.
|
|
|
|
`suffix` marks derived ids (a duplicate becomes `base-kopie`), so a copy
|
|
reads as a copy in the one place ids are visible.
|
|
"""
|
|
stem = f"{base}{suffix}"
|
|
candidate = stem
|
|
counter = 2
|
|
while candidate in taken:
|
|
candidate = f"{stem}-{counter}"
|
|
counter += 1
|
|
return candidate
|
|
|
|
|
|
async def ensure_config_id_free(
|
|
db: AsyncSession, config_id: str, *, except_row: uuid.UUID
|
|
) -> None:
|
|
"""Refuse an edit that would move a config id onto a DIFFERENT row.
|
|
|
|
Editing keeps the id stable, so a clash is never the row's own id — it
|
|
means someone would silently steal another template's identity.
|
|
"""
|
|
clash = (
|
|
await db.execute(
|
|
select(Template.id).where(
|
|
Template.config["id"].astext == config_id,
|
|
Template.id != except_row,
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if clash is not None:
|
|
raise ApiError(
|
|
409, f"Another template already uses the id '{config_id}'.", "id_taken"
|
|
)
|