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:
@@ -0,0 +1,181 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user