This commit is contained in:
team3
2026-07-10 15:43:11 +02:00
commit 0a41166cca
72 changed files with 7772 additions and 0 deletions

33
tests/conftest.py Normal file
View File

@@ -0,0 +1,33 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
import db # noqa: E402
import embedding # noqa: E402
import korpus # noqa: E402
@pytest.fixture(autouse=True)
def fake_welt(tmp_path, monkeypatch):
"""Jeder Test: frische In-Memory-DB, Fake-Agenten, Korpus/Topics in tmp.
Embedding fest auf Jaccard-Fallback — kein Modell-Download, deterministisch."""
monkeypatch.setenv("CREATOR_FAKE_AGENTS", "1")
monkeypatch.setattr(embedding, "_geladen", True)
monkeypatch.setattr(embedding, "_modell", None)
monkeypatch.setattr(korpus, "KORPUS_DIR", tmp_path / "korpus")
monkeypatch.setattr(korpus, "TOPICS_DIR", tmp_path / "topics")
db.on_change = None
db.reset_for_tests(":memory:")
yield tmp_path
def topic_anlegen(name="fake-thema", art="thema"):
db.insert("topics", name=name, titel=name, art=art, provider="minimax")
return name
def run_anlegen(topic, budget=0):
return db.insert("runs", topic=topic, status="running", budget_tokens=budget)

44
tests/test_artefakte.py Normal file
View File

@@ -0,0 +1,44 @@
"""Ebene 2: Quellen-Referenz-Guard — Karten müssen ohne den Quelltext
funktionieren (Lauf 8: 17 verifizierte Karten fragten „was steht im Beleg")."""
import artefakte
import db
import llm
from conftest import run_anlegen, topic_anlegen
def test_referenz_muster_trifft_nur_referenzen():
schlecht = [
{"frage": "Welchen Namen trägt die Technik, die im Beleg erwähnt wird?",
"antwort": "k-Enumeration.", "text": ""},
{"frage": "Was ist MAX-3-SAT?",
"antwort": "Aus dem Beleg geht nur hervor, dass es in Hausaufgabe 13.1 vorkommt.",
"text": ""},
{"frage": "Welche Aufgabe wird in Aufgabe 1 gestellt?", "antwort": "x", "text": ""},
{"frage": "Wie groß ist |V'|?", "antwort": "Laut Musterlösung genau |V|.", "text": ""},
]
gut = [ # Fachwörter dürfen NICHT matchen (generisch bleiben)
{"frage": "Was ist eine aussagenlogische Belegung?",
"antwort": "Eine Zuordnung von Wahrheitswerten zu Variablen.", "text": ""},
{"frage": "Was verbindet ein Fluss mit Quelle und Senke?",
"antwort": "Er belegt jede Kante mit einem Wert unter der Kapazität.", "text": ""},
]
assert all(artefakte._referenziert_quelle(k) for k in schlecht)
assert not any(artefakte._referenziert_quelle(k) for k in gut)
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
topic = topic_anlegen("guard")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._verifizieren(ctx, [atom]) # Guard greift VOR dem Panel
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
# verworfene zählen nicht als versorgt: Repair generiert für das Atom nach
chunks = artefakte._chunks(topic, nur_ohne=True)
assert [x["id"] for c in chunks for x in c] == [a]

187
tests/test_bausteine.py Normal file
View File

@@ -0,0 +1,187 @@
"""Deterministische Bausteine: textkit, jsonx, auto_loop, QA-Note."""
import auto_loop
import jsonx
import qa
import textkit
def test_finde_zitat_whitespace_tolerant():
text = "Der Automat\nbesteht aus Zuständen."
span = textkit.finde_zitat(text, "Der Automat besteht aus Zuständen.")
assert span is not None
start, ende = span
assert text[start:ende].split() == ["Der", "Automat", "besteht", "aus", "Zuständen."]
def test_finde_zitat_case_fallback_und_fehlschlag():
assert textkit.finde_zitat("DER AUTOMAT LÄUFT", "der Automat läuft") is not None
assert textkit.finde_zitat("völlig anderer Text", "der Automat läuft") is None
def test_finde_zitat_locker_interpunktion():
# PDF-Extrakte variieren Interpunktion/Dashes/Quotes — Wortlaut gleich → Treffer
text = "Es gilt: „Ein Automat mit Zuständen akzeptiert reguläre Sprachen."
zitat = 'Ein Automat, mit Zuständen, akzeptiert reguläre Sprachen'
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert "Automat" in text[span[0]:span[1]]
# Paraphrase (andere Wörter) bleibt draußen
assert textkit.finde_zitat(text, "Ein DFA mit Zuständen akzeptiert alle Sprachen") is None
# zu kurze Zitate matchen locker nicht (Eindeutigkeit)
assert textkit.finde_zitat("abc def", "ab, c") is None
def test_finde_zitat_dekomponierte_umlaute():
"""Lektion 82: pdftotext liefert dekomponierte Umlaute (a+U+0308), das LLM-Zitat
präkomponierte (ä) — die Locker-Stufe faltet beide auf den Basisbuchstaben.
Vorher scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
import unicodedata
text = unicodedata.normalize("NFD", "Satz 6.25. 3-SAT ist NP-vollständig. Beweis folgt später.")
span = textkit.finde_zitat(text, "Satz 6.25. 3-SAT ist NP-vollständig.")
assert span is not None
assert text[span[0]:span[1]].startswith("Satz 6.25")
# umgekehrt: präkomponierter Text, dekomponiertes Zitat
assert textkit.finde_zitat(
"Die Erfüllbarkeit boolescher Ausdrücke ist zentral.",
unicodedata.normalize("NFD", "Erfüllbarkeit boolescher Ausdrücke ist zentral")) is not None
def test_snapshot_schreiben_normalisiert_nfc():
"""Korpus-Snapshots werden am Import NFC-normalisiert — die exakte Zitat-Stufe
darf nicht an unsichtbar anderen Bytes scheitern."""
import unicodedata
import korpus
roh = unicodedata.normalize("NFD", "NP-vollständig und erfüllbar")
assert not unicodedata.is_normalized("NFC", roh)
pfad, _h = korpus._snapshot_schreiben("nfc-test", roh)
inhalt = open(pfad, encoding="utf-8").read()
assert unicodedata.is_normalized("NFC", inhalt)
assert inhalt == unicodedata.normalize("NFC", roh)
def test_snapshot_schreiben_ersetzt_kontrollzeichen():
"""PDF-Schriften mappen Sonderglyphen (ε) auf Steuerbytes — der Reader echot sie
als Müll und das Zitat wird unmatchbar. Import ersetzt sie durch Leerzeichen;
\\n und \\t bleiben (Layout)."""
import korpus
pfad, _h = korpus._snapshot_schreiben("ctrl-test", "Algorithmus (A\x0f )\nZeile\tzwei\x07!")
inhalt = open(pfad, encoding="utf-8").read()
assert inhalt == "Algorithmus (A )\nZeile\tzwei !"
def test_finde_zitat_fuzzy_listing_zeilennummern():
"""Stufe 4 (Lektion 83): pdftotext streut Listing-Zeilennummern in den Text,
das Reader-Zitat lässt sie weg — fuzzy mit harter Distanzschranke matcht trotzdem."""
text = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m)\n 1 for i=1 to m do\n"
" 2 Ei = 0; Bi = ∅;\n 3 od\n 4 for j=1 to n do\n"
" 5 wähle i mit Ei minimal;\n 6 od\n")
zitat = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m) for i=1 to m do Ei = 0; "
"Bi = ∅; od for j=1 to n do wähle i mit Ei minimal; od")
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert text[span[0]:span[1]].startswith("Algorithmus ListScheduling")
def test_finde_zitat_fuzzy_rekonstruierte_glyphe():
"""PDF verlor die ε-Glyphe („(A )"), der Reader zitiert „(Aε)" — 1 Zeichen
Differenz auf langem Zitat → Treffer."""
text = ("Man nennt eine solche Familie von Algorithmen (A ) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
zitat = ("Man nennt eine solche Familie von Algorithmen (Aε) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_fuzzy_mehrdeutig_bleibt_draussen():
"""Eindeutigkeits-Guard: zwei fast identische Definitionen (SubSetSum/Partition-
Muster) → kein Anker statt Falsch-Anker."""
a = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl K. Entscheide: Gibt es eine Teilmenge S?"
b = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl X. Entscheide: Gibt es eine Teilmenge T?"
zitat = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl Q. Entscheide: Gibt es eine Teilmenge U?"
assert textkit.finde_zitat(a + "\n\nDazwischen steht anderer Text.\n\n" + b, zitat) is None
def test_finde_zitat_fuzzy_grenzen():
"""Paraphrase über der Distanzschranke bleibt draußen; Kurz-Zitate nie fuzzy."""
text = "Der List-Scheduling-Algorithmus verteilt Jobs der Reihe nach auf die am wenigsten belastete Maschine."
assert textkit.finde_zitat(text, "List Scheduling weist jeden Job der aktuell "
"günstigsten Maschine in Reihenfolge zu und stoppt") is None
assert textkit.finde_zitat("kurzer Text über ähm Dinge", "kurzer Test über ähm Dinge") is None
def test_snapshot_schreiben_ftfy_mojibake():
"""ftfy am Import: Mojibake und Ligaturen werden repariert (Lektion 83)."""
import importlib.util
import pytest
if importlib.util.find_spec("ftfy") is None:
pytest.skip("ftfy nicht installiert — NFC-Fallback deckt test_snapshot_schreiben_normalisiert_nfc")
import korpus
pfad, _h = korpus._snapshot_schreiben("ftfy-test", "effiziente NP-vollständige Suffixe")
inhalt = open(pfad, encoding="utf-8").read()
assert "vollständige" in inhalt # Mojibake ä → ä
assert "Suffixe" in inhalt # Ligatur ffi → ffi
def test_abschnitte_und_ueberlappung():
text = ("Absatz eins.\n\n" * 50) + ("Absatz zwei.\n\n" * 50)
teile = textkit.abschnitte(text, max_chars=300)
assert all(len(t) <= 300 for _, t in teile)
assert "".join(t for _, t in teile) == text
assert textkit.ueberlappung((0, 10), (5, 15)) == 0.5
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0
def test_negations_guard():
assert textkit.negations_menge("L ist nicht regulär") != textkit.negations_menge("L ist regulär")
def test_jsonx_fences_und_prosa():
assert jsonx.parse('```json\n{"a": 1}\n```') == {"a": 1}
assert jsonx.parse('Hier das Ergebnis: [{"b": "x — y"}] Danke!') == [{"b": "x — y"}]
assert jsonx.parse("kein json") is None
assert jsonx.parse('{"s": "mit \\"quote\\" und }"}') == {"s": 'mit "quote" und }'}
async def test_auto_loop_bedingungen():
note, grund = await auto_loop.auto_repair_loop("t", 10.0, None)
assert grund == "fertig"
async def stillstand():
return 8.0, False
note, grund = await auto_loop.auto_repair_loop("t", 8.0, stillstand)
assert grund == "stillstand"
zaehler = {"n": 0}
async def livelock():
zaehler["n"] += 1
return 8.0, True # bewegt, aber nie besser → hartes Limit greift
note, grund = await auto_loop.auto_repair_loop("t", 7.0, livelock, max_iter=4)
assert grund == "limit" and zaehler["n"] == 4
def test_qa_note_formel():
assert qa.note([], 10) == 10.0
n = qa.note([{"art": "marker_fehlend", "item": "1", "detail": ""}], 10)
assert n < 10.0
viele = [{"art": "atom_ohne_anker", "item": str(i), "detail": ""} for i in range(50)]
assert 0.0 <= qa.note(viele, 10) <= 9.9
def test_det_auftraege_blockquote_und_artefakt():
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("detcheck")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang)
text = " ".join(auftraege)
assert "Blockquote" in text and "6=" in text
assert "ungerade Anzahl $-Zeichen" in text

View File

@@ -0,0 +1,54 @@
"""Budget-Stopp (hart) und Infra-Fail-closed (Lauf-Pause statt Teilergebnis)."""
import asyncio
import agents
import ledger
import llm
import pytest
from conftest import run_anlegen, topic_anlegen
async def test_budget_stoppt_hart(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic, budget=120) # Fake-Call kostet 150 Tokens
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
with pytest.raises(ledger.BudgetErschoepft):
await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert ledger.verbraucht(run) >= 120 # der Verbrauch bleibt sichtbar
async def test_infra_pause_statt_fail_open(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
async def immer_429(key, prompt, timeout, **kw):
return agents.AgentErgebnis(1, "", "HTTP 429: rate limited")
monkeypatch.setattr(agents, "run_agent", immer_429)
monkeypatch.setattr(llm, "INFRA_BACKOFF_BASE", 0.01)
with pytest.raises(llm.LaufPause):
await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
stati = [e["status"] for e in
__import__("db").query("SELECT status FROM events WHERE run_id=?", (run,))]
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
async def unsinn(key, prompt, timeout, **kw):
return agents.AgentErgebnis(0, "kein json", "")
monkeypatch.setattr(agents, "run_agent", unsinn)
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause

167
tests/test_e2e.py Normal file
View File

@@ -0,0 +1,167 @@
"""Fake-E2E: kompletter Generierungspfad in Sekunden. Thema (Web-Recherche) und
Uni (Dateien), plus Resume-Idempotenz."""
import asyncio
import itertools
import db
import guide
import korpus
import pipeline
from conftest import topic_anlegen
from fake_agents import FAKE_TEXT
async def _lauf_komplett(topic):
run_id = pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
return run_id
def _pruefe_endzustand(topic, run_id):
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "done"
offen = db.query("SELECT * FROM befunde WHERE run_id=? AND status='offen'", (run_id,))
assert offen == [], f"offene Befunde: {offen}"
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen')", (topic,))
assert len(atome) == 3 # die Fake-Welt hat exakt drei Atome
assert all(a["soll_id"] and a["ziel_id"] and a["baustein_id"] for a in atome)
for a in atome:
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
karten = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status='verifiziert'", (a["id"],))
assert karten
md = guide.guide_markdown(topic)
for a in atome:
assert f"<!-- atom: {a['id']} |" in md
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
assert "\n## " in "\n" + md and "### " in md
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert kaps, "keine Kapitel gebildet"
for k in kaps:
assert k["intro"], f"Kapitel-Intro fehlt: {k['titel']}"
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert all(b["kapitel_id"] for b in bausteine)
folge = [b["kapitel_id"] for b in bausteine]
segmente = [k for k, _ in itertools.groupby(folge)]
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
assert events and all(e["template_hash"] for e in events if e["template"])
async def test_e2e_thema():
topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert len(bausteine) == 1
async def test_e2e_uni(tmp_path):
ordner = korpus.TOPICS_DIR / "fake-uni"
ordner.mkdir(parents=True)
(ordner / "skript.txt").write_text(FAKE_TEXT, encoding="utf-8")
(ordner / "uebung.txt").write_text(FAKE_TEXT + "\nSerie 1.\n", encoding="utf-8")
topic = topic_anlegen("fake-uni", art="uni")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (topic,))
assert len(quellen) == 2 and all(q["art"] == "datei" for q in quellen)
rollen = {q["titel"]: q["rolle"] for q in quellen}
assert rollen["skript.txt"] == "stoff" and rollen["uebung.txt"] == "aufgaben"
async def test_voll_reset_extrahiert_neu():
topic = topic_anlegen("fake-vollreset", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
pipeline.voll_reset(topic)
assert db.query("SELECT * FROM atome WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM artefakte WHERE atom_id IN"
" (SELECT id FROM atome WHERE topic=?)", (topic,)) == []
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2) # kompletter Neuaufbau inkl. neuer Atome
async def test_soll_reset_kein_doppel():
topic = topic_anlegen("fake-reset", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
pipeline.soll_reset(topic)
assert db.query("SELECT * FROM soll WHERE topic=?", (topic,)) == []
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # Extraktions-Guard: keine Doppel-Atome
async def test_auto_stopp_und_fortsetzen():
topic = topic_anlegen("fake-auto", art="thema")
db.update("topics", "name", topic, auto=db.j({"inventar": False}))
run1 = await _lauf_komplett(topic)
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "inventar_fertig"
r = db.one("SELECT * FROM runs WHERE id=?", (run1,))
assert r["status"] == "done" and "Auto-Stopp" in r["grund"]
db.update("topics", "name", topic, auto=db.j({})) # Auto wieder an → durchlaufen
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
async def test_ebenen_entfernen_kaskade():
topic = topic_anlegen("fake-entfernen", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
pipeline.ebenen_entfernen(topic, "struktur")
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
assert set(resets) == {"struktur", "guide"}
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # Atome blieben erhalten
async def test_topic_loeschen():
topic = topic_anlegen("fake-weg", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
pipeline.topic_loeschen(topic)
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
"kapitel", "runs"):
feld = "name" if tabelle == "topics" else "topic"
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle
async def test_resume_idempotent():
topic = topic_anlegen("fake-resume", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
calls_vorher = len(db.query("SELECT id FROM events", ()))
run2 = pipeline.lauf_starten(topic) # zweiter Lauf auf fertigem Topic
await pipeline._laeufe[topic]
assert db.one("SELECT status FROM runs WHERE id=?", (run2,))["status"] == "done"
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # nichts dupliziert
assert len(db.query("SELECT id FROM events", ())) == calls_vorher # keine neuen LLM-Calls

94
tests/test_inventar.py Normal file
View File

@@ -0,0 +1,94 @@
"""Anker-Repair: unverankerte Zitate (start=-1) werden erst deterministisch
neu gematcht — ohne LLM-Call."""
import db
import inventar
import llm
from conftest import run_anlegen, topic_anlegen
def test_merge_kollision_und_kette():
topic = topic_anlegen("mergekante")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
status="neu", braucht=db.j([]))
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
status="neu", braucht=db.j([]))
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
status="neu", braucht=db.j([]))
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, c))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, c))
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, a))
inventar._merge(topic, a, b)
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
assert len(kanten) == 1 and kanten[0]["von_atom"] == a and kanten[0]["zu_atom"] == c
# Merge-Kette: (B, C) — B ist schon gemerged, Wurzel A übernimmt
inventar._merge(topic, b, c)
assert db.one("SELECT merged_into FROM atome WHERE id=?", (c,))["merged_into"] == a
async def test_anker_rematch_ohne_llm(tmp_path):
topic = topic_anlegen("rematch")
run = run_anlegen(topic)
snap = tmp_path / "q.md"
snap.write_text("Vorher. Der Satz steht hier drin. Nachher.", encoding="utf-8")
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="atome")
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
status="ohne_anker", braucht=db.j([]))
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
zitat="Der Satz steht hier drin.")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
assert await inventar._anker_fixen_batch(ctx, [a]) is True
row = db.one("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a,))
assert row is not None
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "neu"
assert db.query("SELECT id FROM events WHERE run_id=?", (run,)) == [] # kein LLM-Call
assert inventar.messen(ctx) == [] or all(
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
def test_titel_kern_faltet_schreibvarianten():
import textkit
assert textkit.titel_kern("ΔTSP2 TourLänge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
assert textkit.titel_kern("FeedbackVertexSet ∈ NP") == textkit.titel_kern("Feedback Vertex Set ∈ NP")
assert textkit.titel_kern("!!!") == ""
def test_fallback_nutzt_jaccard_schwelle(monkeypatch):
import embedding
a = "Polynomialzeitreduktionen sind transitiv wenn L1 auf L2 und L2 auf L3 reduzierbar sind"
b = ("Polynomialzeitreduktionen sind transitiv wenn eine Sprache L1 auf L2"
" und L2 auf L3 in Polynomialzeit reduzierbar ist")
# Kosinus-Schwelle allein: Paraphrase rutscht durch (aak Lauf 8)
assert embedding.kandidaten_paare([a, b], 0.75) == []
paare = embedding.kandidaten_paare([a, b], 0.75, 0.3)
assert [(i, j) for i, j, _ in paare] == [(0, 1)]
async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
topic = topic_anlegen("titeldup")
run = run_anlegen(topic)
# identischer Titel, Definitionen lexikalisch fern (Jaccard < 0.3):
# nur der Titel-Kern-Generator bringt das Paar ans Panel
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
" der Größe höchstens k enthält.",
status="neu", braucht=db.j([]))
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
" abdeckt und maximal k Elemente hat.",
status="neu", braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
await inventar._judge_dedup(ctx)
stati = {r["id"]: r["status"] for r in db.query(
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
assert sorted(stati.values()) == ["gemerged", "neu"]

105
tests/test_korpus.py Normal file
View File

@@ -0,0 +1,105 @@
"""Ebene 0, Konsens: Vollständigkeits-Invariante (kein Kandidat verschwindet
still), explizite Ablehnung, uni-Beleg-Regel (1 Beleg reicht)."""
import re
import db
import fake_agents
import korpus
import llm
from conftest import run_anlegen, topic_anlegen
def _seed(art="uni", n_quellen=1):
topic = topic_anlegen("k-test", art=art)
quellen = [db.insert("quellen", topic=topic, art="datei", titel=f"q{i}.txt",
snapshot=f"q{i}.txt", hash=f"h{i}",
status="extrahiert", rolle="stoff")
for i in range(n_quellen)]
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "korpus"
return topic, quellen, ctx
def _kandidat(topic, punkt, quelle):
return db.insert("soll", topic=topic, punkt=punkt, status="kandidat",
belege=db.j([{"quelle": quelle, "zitat": f"Zitat {punkt}"}]))
def _ids(prompt):
return [int(i) for i in re.findall(r"^(\d+): ", prompt.split("KANDIDATEN:")[1],
re.MULTILINE)]
async def test_nachrunde_ordnet_vergessene_zu(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
b = _kandidat(topic, "Beta", q)
c = _kandidat(topic, "Gamma", q)
def judge(prompt):
if "NACHRUNDE" in prompt: # nur die fehlende id wird angeboten
assert _ids(prompt) == [c] and "Alpha" in prompt
return [{"punkt": "Alpha", "kandidaten": [c]}] # wörtlich → Merge
return [{"punkt": "Alpha", "kandidaten": [a]},
{"punkt": "Beta", "kandidaten": [b]}] # c stillschweigend vergessen
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 2 # uni: 1 Quelle reicht als Beleg
alpha = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" AND punkt='Alpha'", (topic,))
assert len(db.uj(alpha["belege"])) == 2 # Gamma-Beleg in Alpha aufgegangen
for i in (a, b, c):
assert db.one("SELECT status FROM soll WHERE id=?", (i,))["status"] == "gefaltet"
assert korpus.messen(ctx) == []
async def test_ablehnung_ist_explizit(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
m = _kandidat(topic, "Einführung ins Thema", q)
def judge(prompt):
return [{"punkt": "Alpha", "kandidaten": [a]},
{"abgelehnt": True, "grund": "Meta-Überschrift", "kandidaten": [m]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 1
assert db.one("SELECT status FROM soll WHERE id=?", (m,))["status"] == "abgelehnt"
assert korpus.messen(ctx) == [] # abgelehnt ist erledigt, kein Befund
async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
c = _kandidat(topic, "Gamma", q)
def judge(prompt):
if "NACHRUNDE" in prompt:
return [] # Judge verweigert die Zuordnung dauerhaft
return [{"punkt": "Alpha", "kandidaten": [a]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
await korpus._konsens(ctx)
assert db.one("SELECT status FROM soll WHERE id=?", (c,))["status"] == "kandidat"
befunde = korpus.messen(ctx)
assert [b["art"] for b in befunde] == ["soll_kandidat_offen"]
assert befunde[0]["item"] == str(c)
async def test_thema_braucht_zwei_quellen(monkeypatch):
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
x1 = _kandidat(topic, "X", q1)
x2 = _kandidat(topic, "X (Synonym)", q2)
y = _kandidat(topic, "Y", q1)
def judge(prompt):
if "NACHRUNDE" in prompt:
return []
return [{"punkt": "X", "kandidaten": [x1, x2]},
{"punkt": "Y", "kandidaten": [y]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt
assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"}

127
tests/test_struktur.py Normal file
View File

@@ -0,0 +1,127 @@
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
import db
import llm
import struktur
from conftest import run_anlegen, topic_anlegen
def _atom(topic, titel, ziel_id=None, soll_id=1):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition=f"Definition {titel}", status="neu",
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
def test_topo_ordnung():
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
def test_topo_fremde_kanten_bleiben_draussen():
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
assert out == [1, 2]
def test_zyklus_finden():
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
async def test_zyklen_brechen_und_schneiden():
topic = topic_anlegen()
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
a = _atom(topic, "A", ziel, soll)
b = _atom(topic, "B", ziel, soll)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, a))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._zyklen_brechen(ctx)
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
assert len(aktiv) == 1 # genau eine Kante gebrochen
struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert all(x["baustein_id"] for x in atome)
async def test_band_split():
topic = topic_anlegen("band")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert len(bausteine) >= 3
for bs in bausteine:
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
assert 4 <= n <= 8
befunde = struktur.messen(ctx)
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
async def test_merge_ohne_soll_schranke_und_kapitel():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
# Kapitel entstehen danach als kontiguierliche Segmente
topic = topic_anlegen("kapitel")
run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
b0 = _atom(topic, "B0", z2, s2)
for i in (1, 2, 3):
_atom(topic, f"B{i}", z2, s2)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b0))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
await struktur._kapitel_bilden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert bausteine and all(x["kapitel_id"] for x in bausteine)
assert struktur.messen(ctx) == []
async def test_kapitel_retry_und_fallback(monkeypatch):
import fake_agents
topic = topic_anlegen("kapfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = [db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{i}", ord=i,
status="neu") for i in range(4)]
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1:
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
lambda p: [{"titel": "x", "bis": 999999}])
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert kaps and all(k["art"] == "fallback" for k in kaps)
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}