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
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""The shipped template catalog.
|
|
|
|
`templates/` holds blueprints, not active templates. A blueprint is inert
|
|
product content: it sits in the catalog until an admin adds it, and adding
|
|
it produces an ordinary row in `templates` that is theirs, editable,
|
|
renameable, deletable, and never overwritten by a later deploy.
|
|
|
|
That is the difference to the built-in help documents (`help_import.py`),
|
|
which ARE re-imported on every start and stay read-only: those describe how
|
|
Pablan works, so the product owns them. A template describes how a company
|
|
documents its own knowledge, so the company owns it.
|
|
|
|
**The only automatic write to `templates` is `seed_starter_templates`, and
|
|
it runs exclusively against an empty table.** Everything else goes through
|
|
an explicit admin action in `api/templates.py`. This is load-bearing: a
|
|
startup upsert from the catalog would silently discard an admin's edits the
|
|
next time we improved a shipped blueprint.
|
|
|
|
File naming: `<id>.<locale>.yaml` (`prozess.de.yaml`). A file
|
|
without a locale suffix is treated as belonging to the default locale, so
|
|
a customer can drop their own YAML into the directory without learning the
|
|
convention. See docs/authoring-templates.md.
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import get_settings
|
|
from app.models import Template
|
|
from app.template_import import TemplateImportError, parse_template, upsert_template
|
|
|
|
logger = logging.getLogger("pablan.templates")
|
|
|
|
SUPPORTED_LOCALES = ("de", "en")
|
|
|
|
# What a brand-new instance starts with, in its default language: the four
|
|
# occasions on which anyone actually writes something down. Write it down
|
|
# (no structure at all — a blank page beats three generic headings), how we
|
|
# do this, what broke, and who you are. Everything more specific — a
|
|
# machine, a decision, a project review — is a deliberate add from the
|
|
# catalog, because a picker of ten options is a picker nobody reads.
|
|
STARTER_TEMPLATE_IDS = (
|
|
"notiz",
|
|
"prozess",
|
|
"stoerung",
|
|
"person",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CatalogEntry:
|
|
"""A blueprint as it sits on disk, never a DB row."""
|
|
|
|
id: str
|
|
locale: str
|
|
name: str
|
|
description: str
|
|
version: str
|
|
sections: int
|
|
source: str
|
|
|
|
|
|
def _split_stem(stem: str) -> tuple[str, str]:
|
|
"""`prozess.de` -> (`prozess`, `de`); a stem without a
|
|
known locale suffix belongs to the default locale."""
|
|
base, _, suffix = stem.rpartition(".")
|
|
if base and suffix in SUPPORTED_LOCALES:
|
|
return base, suffix
|
|
return stem, get_settings().default_locale
|
|
|
|
|
|
def load_catalog() -> list[CatalogEntry]:
|
|
"""Parse every blueprint in the catalog directory.
|
|
|
|
Read per call rather than cached: the directory is small, and a
|
|
self-hosted admin who drops a YAML file in it should not have to
|
|
restart the server to see it.
|
|
"""
|
|
directory = Path(get_settings().templates_dir)
|
|
if not directory.is_dir():
|
|
logger.info(
|
|
"no template catalog directory",
|
|
extra={"event": "catalog_missing", "directory": str(directory)},
|
|
)
|
|
return []
|
|
|
|
entries: list[CatalogEntry] = []
|
|
for path in sorted(directory.glob("*.yaml")):
|
|
source = path.read_text()
|
|
try:
|
|
template = parse_template(source)
|
|
except TemplateImportError as exc:
|
|
# A broken blueprint must not take the catalog down with it.
|
|
logger.error(
|
|
"catalog blueprint invalid",
|
|
extra={
|
|
"event": "catalog_invalid",
|
|
"file": path.name,
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
continue
|
|
_base, from_name = _split_stem(path.stem)
|
|
entries.append(
|
|
CatalogEntry(
|
|
id=template.id,
|
|
# The YAML says what language it is written in; the filename
|
|
# suffix is the human-facing convention and the fallback.
|
|
locale=template.locale or from_name,
|
|
name=template.name,
|
|
description=template.description or "",
|
|
version=template.version,
|
|
sections=len(template.sections),
|
|
source=source,
|
|
)
|
|
)
|
|
return entries
|
|
|
|
|
|
def catalog_for_locale(locale: str | None = None) -> list[CatalogEntry]:
|
|
"""One entry per blueprint id, in `locale` where a variant exists.
|
|
|
|
A blueprint with no variant in the requested language still shows up in
|
|
whatever language it has — a missing translation must not hide a
|
|
template from the admin who needs it.
|
|
"""
|
|
wanted = locale or get_settings().default_locale
|
|
best: dict[str, CatalogEntry] = {}
|
|
for entry in load_catalog():
|
|
current = best.get(entry.id)
|
|
if current is None or (entry.locale == wanted and current.locale != wanted):
|
|
best[entry.id] = entry
|
|
return sorted(best.values(), key=lambda entry: entry.name)
|
|
|
|
|
|
def get_catalog_entry(
|
|
catalog_id: str, locale: str | None = None
|
|
) -> CatalogEntry | None:
|
|
return next(
|
|
(entry for entry in catalog_for_locale(locale) if entry.id == catalog_id),
|
|
None,
|
|
)
|
|
|
|
|
|
async def seed_starter_templates(db: AsyncSession) -> int:
|
|
"""Give a brand-new instance something to capture with.
|
|
|
|
Only ever runs against an EMPTY templates table. Once an admin has
|
|
curated the list, added blueprints, deleted a starter, renamed things,
|
|
that curation is the truth and startup must not re-litigate it. This
|
|
is the ONLY automatic write to the table.
|
|
"""
|
|
existing = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
|
if existing:
|
|
return 0
|
|
|
|
locale = get_settings().default_locale
|
|
by_id = {entry.id: entry for entry in catalog_for_locale(locale)}
|
|
|
|
seeded = 0
|
|
for catalog_id in STARTER_TEMPLATE_IDS:
|
|
entry = by_id.get(catalog_id)
|
|
if entry is None:
|
|
logger.error(
|
|
"starter template missing from catalog",
|
|
extra={"event": "catalog_starter_missing", "template": catalog_id},
|
|
)
|
|
continue
|
|
await upsert_template(db, parse_template(entry.source))
|
|
seeded += 1
|
|
|
|
await db.commit()
|
|
logger.info(
|
|
"starter templates seeded",
|
|
extra={"event": "templates_seeded", "count": seeded, "locale": locale},
|
|
)
|
|
return seeded
|