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
+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()