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
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""The catalog must never write over a customer's templates.
|
|
|
|
These tests exist because the opposite behaviour shipped first: the catalog
|
|
used to be re-imported on every start, which would silently discard an
|
|
admin's edits the next time we improved a shipped blueprint.
|
|
"""
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models import Template
|
|
from app.template_catalog import (
|
|
STARTER_TEMPLATE_IDS,
|
|
catalog_for_locale,
|
|
load_catalog,
|
|
seed_starter_templates,
|
|
)
|
|
from app.template_import import parse_template, upsert_template
|
|
|
|
|
|
async def test_starter_set_seeds_only_an_empty_table(db: AsyncSession) -> None:
|
|
seeded = await seed_starter_templates(db)
|
|
assert seeded == len(STARTER_TEMPLATE_IDS)
|
|
|
|
ids = set((await db.execute(select(Template.config["id"].astext))).scalars().all())
|
|
assert ids == set(STARTER_TEMPLATE_IDS)
|
|
|
|
# A second start adds nothing — the admin's curation is the truth.
|
|
assert await seed_starter_templates(db) == 0
|
|
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
|
assert count == len(STARTER_TEMPLATE_IDS)
|
|
|
|
|
|
async def test_seeding_never_overwrites_an_edited_template(db: AsyncSession) -> None:
|
|
"""The regression this module is named for: an admin edits a starter
|
|
template, the server restarts, and the edit survives."""
|
|
await seed_starter_templates(db)
|
|
row = (
|
|
await db.execute(
|
|
select(Template).where(Template.config["id"].astext == "prozess")
|
|
)
|
|
).scalar_one()
|
|
row.name = "Unser Ablauf"
|
|
row.config = {**row.config, "persona": "Komplett umgeschrieben."}
|
|
await db.commit()
|
|
|
|
await seed_starter_templates(db)
|
|
|
|
await db.refresh(row)
|
|
assert row.name == "Unser Ablauf"
|
|
assert row.config["persona"] == "Komplett umgeschrieben."
|
|
|
|
|
|
async def test_deleting_a_starter_template_keeps_it_deleted(db: AsyncSession) -> None:
|
|
"""Deleting all but one must not resurrect the rest on restart — the
|
|
table is non-empty, so seeding stays out."""
|
|
await seed_starter_templates(db)
|
|
rows = (await db.execute(select(Template))).scalars().all()
|
|
for row in rows[1:]:
|
|
await db.delete(row)
|
|
await db.commit()
|
|
|
|
await seed_starter_templates(db)
|
|
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
|
assert count == 1
|
|
|
|
|
|
async def test_an_empty_table_after_deleting_everything_reseeds(
|
|
db: AsyncSession,
|
|
) -> None:
|
|
"""The flip side, and the honest consequence of the empty-table rule: an
|
|
admin who removes every template gets the starter set back on restart
|
|
rather than an instance nobody can capture with."""
|
|
await seed_starter_templates(db)
|
|
for row in (await db.execute(select(Template))).scalars().all():
|
|
await db.delete(row)
|
|
await db.commit()
|
|
|
|
assert await seed_starter_templates(db) == len(STARTER_TEMPLATE_IDS)
|
|
|
|
|
|
async def test_importing_a_customer_template_is_untouched_by_seeding(
|
|
db: AsyncSession,
|
|
) -> None:
|
|
own = parse_template(
|
|
"\n".join(
|
|
[
|
|
"id: unser-eigenes",
|
|
'name: "Unser eigenes"',
|
|
'version: "1.0"',
|
|
"kind: authoring",
|
|
"persona: |",
|
|
" Du bist ein Fachredakteur.",
|
|
'title_template: "X: {{user.name}} ({{date}})"',
|
|
"skeleton: |",
|
|
" ## Thema",
|
|
"sections:",
|
|
' - heading: "Thema"',
|
|
' hint: "Worum es geht."',
|
|
]
|
|
)
|
|
)
|
|
await upsert_template(db, own)
|
|
await db.commit()
|
|
|
|
# The table is not empty, so nothing is seeded over it.
|
|
assert await seed_starter_templates(db) == 0
|
|
count = (await db.execute(select(func.count(Template.id)))).scalar_one()
|
|
assert count == 1
|
|
|
|
|
|
def test_every_shipped_blueprint_parses_and_declares_its_language() -> None:
|
|
"""A broken blueprint is skipped at load time, so a silent typo would
|
|
quietly shrink the catalog instead of failing loudly."""
|
|
entries = load_catalog()
|
|
assert len(entries) >= len(STARTER_TEMPLATE_IDS)
|
|
assert all(entry.locale in ("de", "en") for entry in entries)
|
|
assert all(entry.description for entry in entries)
|
|
# A blueprint may have no sections at all (`notiz` opens an empty
|
|
# document on purpose), but one that HAS a skeleton must hint at it.
|
|
assert all(entry.sections >= 1 for entry in entries if entry.id != "notiz")
|
|
|
|
|
|
def test_catalog_collapses_language_variants_to_one_entry_per_id() -> None:
|
|
per_locale = load_catalog()
|
|
collapsed = catalog_for_locale("de")
|
|
assert len(collapsed) == len({entry.id for entry in per_locale})
|
|
assert all(entry.locale == "de" for entry in collapsed)
|