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
+19
View File
@@ -0,0 +1,19 @@
"""The templates API: the blueprints a document can be started from.
Reading is for everyone (the picker needs it), changing is admin-only, and the
two live on separate routers so the gate is structural rather than repeated per
endpoint. `catalog` is what ships with the product, `edit` what the customer
made of it.
**Route order matters.** The catalog's static paths are registered before
`/{template_id}`, or "catalog" would be parsed as a row id.
"""
from fastapi import APIRouter
from app.api.templates import browse, catalog, edit
router = APIRouter()
router.include_router(catalog.router)
router.include_router(edit.router)
router.include_router(browse.router)
+67
View File
@@ -0,0 +1,67 @@
"""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"
)
+45
View File
@@ -0,0 +1,45 @@
"""Reading templates. Any authenticated user: the picker needs them."""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.routing import reader_router
from app.api.templates.schemas import TemplateDetail, TemplateSummary
from app.api.templates.view import detail, summary
from app.auth.deps import get_current_user
from app.config import get_settings
from app.db import get_db
from app.errors import ApiError
from app.models import Template, User
router = reader_router()
@router.get("")
async def list_templates(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[TemplateSummary]:
"""Templates the picker offers. Templates in the reader's language come
first — they are customer content, so a mismatched one is still listed
rather than hidden."""
rows = (await db.execute(select(Template).order_by(Template.name))).scalars().all()
wanted = user.locale or get_settings().default_locale
ordered = sorted(rows, key=lambda row: row.config.get("locale") != wanted)
return [summary(row) for row in ordered]
@router.get("/{template_id}")
async def get_template(
template_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
row = await db.get(Template, template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
return detail(row)
+79
View File
@@ -0,0 +1,79 @@
"""The blueprints that ship with the product.
Nothing in the catalog is active. It is a shelf an admin takes from: adding a
blueprint copies it into an ordinary template row, which the customer then owns
and edits. The catalog never touches that row again.
"""
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.blueprints import parse_or_422, taken_config_ids
from app.api.templates.routing import editor_router
from app.api.templates.schemas import CatalogDetail, CatalogSummary, TemplateDetail
from app.api.templates.view import detail
from app.db import get_db
from app.errors import ApiError
from app.template_catalog import catalog_for_locale, get_catalog_entry
from app.template_import import upsert_template
router = editor_router()
@router.get("/catalog")
async def list_catalog(
db: Annotated[AsyncSession, Depends(get_db)],
) -> list[CatalogSummary]:
"""The blueprints shipped with Pablan, each marked with whether this
instance has already added it."""
added = await taken_config_ids(db)
return [
CatalogSummary(
id=entry.id,
name=entry.name,
description=entry.description,
sections=entry.sections,
added=entry.id in added,
)
for entry in catalog_for_locale()
]
@router.get("/catalog/{catalog_id}")
async def get_catalog_blueprint(catalog_id: str) -> CatalogDetail:
"""Read a blueprint before adding it — the whole point of "view" is that
an admin can see its structure before committing to it."""
entry = get_catalog_entry(catalog_id)
if entry is None:
raise ApiError(404, "Blueprint not found.", "not_found")
return CatalogDetail(
id=entry.id,
name=entry.name,
description=entry.description,
sections=entry.sections,
added=False,
yaml=entry.source,
)
@router.post("/catalog/{catalog_id}")
async def add_from_catalog(
catalog_id: str,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Copy a blueprint into this instance. The result is an ordinary
template row: editable, and never touched by the catalog again."""
entry = get_catalog_entry(catalog_id)
if entry is None:
raise ApiError(404, "Blueprint not found.", "not_found")
if entry.id in await taken_config_ids(db):
raise ApiError(
409,
"This template has already been added — edit or duplicate it instead.",
"already_added",
)
row, _created = await upsert_template(db, parse_or_422(entry.source))
await db.commit()
return detail(row)
+135
View File
@@ -0,0 +1,135 @@
"""Changing what this instance offers. Admin only, by the router it hangs on.
Two ways in, one guarantee: the form builder sends a structured config and the
YAML editor sends text, but both end as the same validated blueprint, so
neither path can save something the other would reject.
"""
import uuid
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.templates.blueprints import (
ensure_config_id_free,
parse_or_422,
taken_config_ids,
unique_config_id,
)
from app.api.templates.routing import editor_router
from app.api.templates.schemas import (
TemplateBuildRequest,
TemplateDetail,
TemplateImportRequest,
)
from app.api.templates.view import detail
from app.db import get_db
from app.errors import ApiError
from app.models import Template
router = editor_router()
# What a template's config id falls back to when the form has nothing to slug.
FALLBACK_ID = "vorlage"
async def _row_or_404(db: AsyncSession, template_id: uuid.UUID) -> Template:
row = await db.get(Template, template_id)
if row is None:
raise ApiError(404, "Template not found.", "not_found")
return row
@router.post("/build")
async def build_template(
body: TemplateBuildRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Save a template from the structured form builder. Creates a new row
(template_id null) or updates one in place."""
template = body.config
config = template.model_dump(mode="json")
if body.template_id is None:
# The config id is an internal slug the form derives from the name;
# make it unique so a second "Onboarding" never overwrites the first.
config["id"] = unique_config_id(
template.id or FALLBACK_ID, await taken_config_ids(db)
)
row = Template(name=template.name, version=template.version, config=config)
db.add(row)
else:
await ensure_config_id_free(db, template.id, except_row=body.template_id)
row = await _row_or_404(db, body.template_id)
row.name = template.name
row.version = template.version
row.config = config
await db.commit()
return detail(row)
@router.put("/{template_id}")
async def update_template(
template_id: uuid.UUID,
body: TemplateImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Replace a template's YAML. Validated against the schema on save."""
row = await _row_or_404(db, template_id)
template = parse_or_422(body.yaml)
await ensure_config_id_free(db, template.id, except_row=row.id)
row.name = template.name
row.version = template.version
row.config = template.model_dump(mode="json")
await db.commit()
return detail(row)
async def _copy_name(db: AsyncSession, name: str) -> str:
"""The next free "<name> (2)".
A number rather than a word, because this name is shown in the interface
and the backend never renders UI-language strings (CLAUDE.md) — a German
"(Kopie)" would sit untranslated in an English admin panel. It is also
what file managers do, so it needs no explaining.
"""
taken = set((await db.execute(select(Template.name))).scalars().all())
counter = 2
while f"{name} ({counter})" in taken:
counter += 1
return f"{name} ({counter})"
@router.post("/{template_id}/duplicate")
async def duplicate_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db)],
) -> TemplateDetail:
"""Fork a template — for trying a variant without losing the original.
The copy gets a fresh config id so the two never collide."""
row = await _row_or_404(db, template_id)
config_id = unique_config_id(
row.config.get("id", "template"), await taken_config_ids(db), suffix="-copy"
)
config = {**row.config, "id": config_id, "name": await _copy_name(db, row.name)}
copy = Template(name=config["name"], version=row.version, config=config)
db.add(copy)
await db.commit()
return detail(copy)
@router.delete("/{template_id}", status_code=204)
async def delete_template(
template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db)],
) -> None:
"""Remove a template. Documents created from it are independent and
survive (a template is only a starting point). If it came from the
catalog it can always be added back."""
row = await _row_or_404(db, template_id)
await db.delete(row)
await db.commit()
+21
View File
@@ -0,0 +1,21 @@
"""Two routers, because templates have two audiences.
Everyone may READ the templates (the picker needs them); only an admin may
change what the instance offers. Expressing that as two constructors means a
new endpoint is gated by which router it is added to, not by remembering to
repeat a dependency.
"""
from fastapi import APIRouter, Depends
from app.auth.deps import require_admin
def reader_router() -> APIRouter:
return APIRouter(prefix="/templates", tags=["templates"])
def editor_router() -> APIRouter:
return APIRouter(
prefix="/templates", tags=["templates"], dependencies=[Depends(require_admin)]
)
+61
View File
@@ -0,0 +1,61 @@
"""Request and response shapes for templates and the shipped catalog."""
import uuid
from typing import Any
from pydantic import BaseModel
from app.authoring.schema import AuthoringTemplate
class TemplateSummary(BaseModel):
id: uuid.UUID
# The blueprint id from the config (e.g. "onboarding-basis"): stable across
# installs, where the row id is not. Anything that wants to offer ONE known
# blueprint (the profile page's "write about yourself") finds it by this.
config_id: str
name: str
version: str
description: str = ""
class TemplateDetail(TemplateSummary):
config: dict[str, Any]
# The editable source. Serialized server-side because the frontend has
# no YAML library and must not gain one.
yaml: str
class CatalogSummary(BaseModel):
"""A blueprint on disk. `id` is the config id, NOT a row id — a catalog
entry has no row until someone adds it."""
id: str
name: str
description: str
# How many skeleton sections the blueprint carries hints for.
sections: int
# Whether a template with this config id already exists, so the UI can
# offer "View" instead of a second "Add".
added: bool
class CatalogDetail(CatalogSummary):
yaml: str
class TemplateImportRequest(BaseModel):
yaml: str
class TemplateBuildRequest(BaseModel):
"""A template assembled by the form builder. The config is the same schema
a pasted YAML parses into, so both paths get one validation guarantee —
the frontend has no YAML library and must not gain one, so it sends the
structured config instead of serializing it."""
# The row to update, or null to create a new template. Kept separate from
# the config id (a stable slug) so renaming the display name never forks
# the row.
template_id: uuid.UUID | None = None
config: AuthoringTemplate
+33
View File
@@ -0,0 +1,33 @@
"""Template rows to API shapes.
Both are built explicitly rather than validated from the row: the blueprint id
and the description live inside `config`, and `yaml` is rendered per request,
so there is nothing on the row to read them from.
"""
import yaml
from app.api.templates.schemas import TemplateDetail, TemplateSummary
from app.models import Template
def summary(row: Template) -> TemplateSummary:
return TemplateSummary(
id=row.id,
config_id=row.config.get("id", ""),
name=row.name,
version=row.version,
description=row.config.get("description", ""),
)
def detail(row: Template) -> TemplateDetail:
return TemplateDetail(
id=row.id,
config_id=row.config.get("id", ""),
name=row.name,
version=row.version,
description=row.config.get("description", ""),
config=row.config,
yaml=yaml.safe_dump(row.config, allow_unicode=True, sort_keys=False, width=80),
)