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:
co-authored by
Claude Opus 5
parent
68d3a43191
commit
784b76baf7
@@ -0,0 +1,274 @@
|
||||
import re
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Template, User
|
||||
from app.template_import import parse_template, upsert_template
|
||||
|
||||
VALID_YAML = """\
|
||||
id: import-test
|
||||
name: "Import-Test"
|
||||
version: "1.0"
|
||||
kind: authoring
|
||||
persona: |
|
||||
Du bist ein Fachredakteur.
|
||||
title_template: "Import: {{user.name}} ({{date}})"
|
||||
skeleton: |
|
||||
## Thema
|
||||
sections:
|
||||
- heading: "Thema"
|
||||
hint: "Worum es geht."
|
||||
metadata:
|
||||
visibility: department
|
||||
"""
|
||||
|
||||
# The structured config the form builder posts — the same shape parse_template
|
||||
# produces, so /build and /import share one validation guarantee.
|
||||
VALID_CONFIG = {
|
||||
"id": "gebaut",
|
||||
"name": "Gebaute Vorlage",
|
||||
"version": "1.0",
|
||||
"kind": "authoring",
|
||||
"locale": "de",
|
||||
"description": "Aus dem Formular gebaut.",
|
||||
"model": {"temperature": 0.4, "min_class_hint": None},
|
||||
"persona": "Du bist ein Fachredakteur.",
|
||||
"skeleton": "## Thema\n",
|
||||
"sections": [{"heading": "Thema", "hint": "Worum es geht."}],
|
||||
"title_template": "Gebaut: {{user.name}}",
|
||||
"metadata": {"visibility": "department"},
|
||||
}
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, email: str) -> None:
|
||||
response = await client.post(
|
||||
"/api/auth/login", json={"email": email, "password": "secret123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def _seed(db: AsyncSession, yaml: str = VALID_YAML) -> Template:
|
||||
"""Put a template in the table directly, for tests that need one to exist
|
||||
rather than to exercise a create endpoint."""
|
||||
row, _ = await upsert_template(db, parse_template(yaml))
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def test_endpoints_require_auth(client: AsyncClient) -> None:
|
||||
assert (await client.get("/api/templates")).status_code == 401
|
||||
assert (
|
||||
await client.post("/api/templates/build", json={"config": VALID_CONFIG})
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_list_get_roundtrip(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
row = await _seed(db)
|
||||
|
||||
# Members can browse templates (they pick one to write a document from).
|
||||
await _login(client, "pablo@test.dev")
|
||||
listing = (await client.get("/api/templates")).json()
|
||||
assert [t["name"] for t in listing] == ["Import-Test"]
|
||||
detail = (await client.get(f"/api/templates/{row.id}")).json()
|
||||
assert detail["config"]["sections"][0]["heading"] == "Thema"
|
||||
assert detail["config"]["persona"].startswith("Du bist")
|
||||
|
||||
|
||||
async def test_every_template_is_editable_and_deletable(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""There is no read-only template: a template describes how a company
|
||||
documents its own knowledge, so the company owns it."""
|
||||
row, _ = await upsert_template(db, parse_template(VALID_YAML))
|
||||
await db.commit()
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
edited = VALID_YAML.replace('version: "1.0"', 'version: "1.1"')
|
||||
saved = await client.put(f"/api/templates/{row.id}", json={"yaml": edited})
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["version"] == "1.1"
|
||||
|
||||
# A fork gets its own config id, so the two never collide.
|
||||
forked = await client.post(f"/api/templates/{row.id}/duplicate")
|
||||
assert forked.status_code == 200
|
||||
fork = forked.json()
|
||||
assert fork["config"]["id"].endswith("-copy")
|
||||
# Numbered, not worded: the name is shown in the UI, and the backend
|
||||
# renders no UI-language strings.
|
||||
assert fork["name"].endswith(" (2)")
|
||||
|
||||
assert (await client.delete(f"/api/templates/{fork['id']}")).status_code == 204
|
||||
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 204
|
||||
|
||||
|
||||
async def test_editing_returns_the_yaml_and_rejects_invalid_input(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
row = await _seed(db)
|
||||
await _login(client, "florian@test.dev")
|
||||
|
||||
# The detail carries editable YAML, not just the parsed config.
|
||||
detail = (await client.get(f"/api/templates/{row.id}")).json()
|
||||
assert "skeleton:" in detail["yaml"]
|
||||
|
||||
# Saving invalid YAML is rejected: a missing required field, or malformed
|
||||
# syntax, both surface as 422 invalid_template rather than corrupting the row.
|
||||
incomplete = await client.put(
|
||||
f"/api/templates/{row.id}", json={"yaml": "id: x\nname: y"}
|
||||
)
|
||||
assert incomplete.status_code == 422
|
||||
assert incomplete.json()["code"] == "invalid_template"
|
||||
|
||||
malformed = await client.put(f"/api/templates/{row.id}", json={"yaml": ":\n - ]["})
|
||||
assert malformed.status_code == 422
|
||||
|
||||
|
||||
async def test_template_management_requires_an_admin(
|
||||
client: AsyncClient, db: AsyncSession, seeded_user: User
|
||||
) -> None:
|
||||
row, _ = await upsert_template(db, parse_template(VALID_YAML))
|
||||
await db.commit()
|
||||
await client.post(
|
||||
"/api/auth/login", json={"email": "pablo@test.dev", "password": "secret123"}
|
||||
)
|
||||
assert (
|
||||
await client.put(f"/api/templates/{row.id}", json={"yaml": VALID_YAML})
|
||||
).status_code == 403
|
||||
assert (await client.post(f"/api/templates/{row.id}/duplicate")).status_code == 403
|
||||
assert (await client.delete(f"/api/templates/{row.id}")).status_code == 403
|
||||
|
||||
|
||||
async def test_catalog_lists_blueprints_and_marks_what_is_added(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The catalog is what ships in templates/ — inert until someone adds
|
||||
an entry."""
|
||||
await _login(client, "florian@test.dev")
|
||||
catalog = (await client.get("/api/templates/catalog")).json()
|
||||
by_id = {entry["id"]: entry for entry in catalog}
|
||||
|
||||
assert "notiz" in by_id
|
||||
assert "anlage" in by_id
|
||||
assert by_id["anlage"]["sections"] >= 1
|
||||
assert by_id["anlage"]["description"]
|
||||
# Nothing is in the instance yet.
|
||||
assert all(entry["added"] is False for entry in catalog)
|
||||
|
||||
added = await client.post("/api/templates/catalog/anlage")
|
||||
assert added.status_code == 200
|
||||
assert added.json()["config"]["id"] == "anlage"
|
||||
|
||||
catalog = (await client.get("/api/templates/catalog")).json()
|
||||
assert {e["id"]: e["added"] for e in catalog}["anlage"] is True
|
||||
|
||||
# Adding twice would silently overwrite an edited template.
|
||||
again = await client.post("/api/templates/catalog/anlage")
|
||||
assert again.status_code == 409
|
||||
assert again.json()["code"] == "already_added"
|
||||
|
||||
|
||||
async def test_a_template_added_from_the_catalog_is_fully_editable(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The whole point of the catalog: what you add becomes yours."""
|
||||
await _login(client, "florian@test.dev")
|
||||
added = (await client.post("/api/templates/catalog/person")).json()
|
||||
|
||||
# The detail serializes the config back to YAML, so quoting is safe_dump's.
|
||||
changed = re.sub(r"^version:.*$", 'version: "0.9"', added["yaml"], flags=re.M)
|
||||
saved = await client.put(f"/api/templates/{added['id']}", json={"yaml": changed})
|
||||
assert saved.status_code == 200, saved.json()
|
||||
assert saved.json()["version"] == "0.9"
|
||||
|
||||
assert (await client.delete(f"/api/templates/{added['id']}")).status_code == 204
|
||||
|
||||
|
||||
async def test_unknown_blueprint_is_404(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
await _login(client, "florian@test.dev")
|
||||
assert (await client.get("/api/templates/catalog/nope")).status_code == 404
|
||||
assert (await client.post("/api/templates/catalog/nope")).status_code == 404
|
||||
|
||||
|
||||
async def test_catalog_requires_an_admin(
|
||||
client: AsyncClient, seeded_user: User
|
||||
) -> None:
|
||||
"""Blueprints are an administration concern — a member picks from what
|
||||
the admin enabled, not from the whole catalogue."""
|
||||
await _login(client, "pablo@test.dev")
|
||||
assert (await client.get("/api/templates/catalog")).status_code == 403
|
||||
assert (await client.post("/api/templates/catalog/freies-thema")).status_code == 403
|
||||
|
||||
|
||||
async def test_build_creates_from_config_then_updates_in_place(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""The form builder posts structured config instead of YAML. Creating with
|
||||
template_id null makes a row; posting the row id updates it in place."""
|
||||
await _login(client, "florian@test.dev")
|
||||
created = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert created.status_code == 200, created.json()
|
||||
body = created.json()
|
||||
assert body["config"]["id"] == "gebaut"
|
||||
assert body["config"]["skeleton"].startswith("## Thema")
|
||||
row_id = body["id"]
|
||||
|
||||
reordered = {
|
||||
**VALID_CONFIG,
|
||||
"sections": [{"heading": "Ablauf", "hint": "Die Schritte."}],
|
||||
"skeleton": "## Ablauf\n",
|
||||
}
|
||||
saved = await client.post(
|
||||
"/api/templates/build", json={"template_id": row_id, "config": reordered}
|
||||
)
|
||||
assert saved.status_code == 200, saved.json()
|
||||
assert saved.json()["id"] == row_id
|
||||
assert saved.json()["config"]["sections"][0]["heading"] == "Ablauf"
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 1
|
||||
|
||||
|
||||
async def test_build_uniquifies_config_id_for_new_templates(
|
||||
client: AsyncClient, db: AsyncSession, seeded_admin: User
|
||||
) -> None:
|
||||
"""A second new template with the same name must not silently overwrite
|
||||
the first: the server suffixes the derived config id instead."""
|
||||
await _login(client, "florian@test.dev")
|
||||
first = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert first.json()["config"]["id"] == "gebaut"
|
||||
assert second.json()["config"]["id"] == "gebaut-2"
|
||||
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
||||
assert count == 2
|
||||
|
||||
|
||||
async def test_build_requires_admin(client: AsyncClient, seeded_user: User) -> None:
|
||||
await _login(client, "pablo@test.dev")
|
||||
response = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": VALID_CONFIG}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_build_rejects_invalid_config(
|
||||
client: AsyncClient, seeded_admin: User
|
||||
) -> None:
|
||||
"""The nested config is a Pydantic model, so a missing required field is
|
||||
rejected before it can reach the table."""
|
||||
await _login(client, "florian@test.dev")
|
||||
without_persona = {k: v for k, v in VALID_CONFIG.items() if k != "persona"}
|
||||
response = await client.post(
|
||||
"/api/templates/build", json={"template_id": None, "config": without_persona}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user