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
275 lines
10 KiB
Python
275 lines
10 KiB
Python
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
|