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,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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user