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:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+181
View File
@@ -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