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

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"
)