This commit is contained in:
team3
2026-07-12 16:13:50 +02:00
parent a284e03225
commit 31a42bbf1d
48 changed files with 3625 additions and 253 deletions

View File

@@ -27,6 +27,43 @@ def test_referenz_muster_trifft_nur_referenzen():
assert not any(artefakte._referenziert_quelle(k) for k in gut)
async def test_nur_ohne_sieht_fehlende_flashcard():
"""Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren."""
topic = topic_anlegen("fcfehlt")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", soll_id=1, braucht=db.j([]))
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
status="neu", soll_id=1, braucht=db.j([]))
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"}))
# b: verifizierte Flashcard → versorgt, nicht mehr in nur_ohne
db.insert("artefakte", atom_id=b, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "F", "antwort": "A"}))
ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c]
assert ids == [a]
async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
"""Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen."""
import fake_agents
topic = topic_anlegen("reverify")
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 ist X?", "antwort": "Y", "text": ""}))
# Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Fix",
lambda p: {"frage": "Was ist X?", "antwort": "Y", "text": ""})
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Verify", lambda p: [])
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
await artefakte._fixen(ctx, db.one("SELECT * FROM artefakte WHERE id=?", (k,)),
["mangel"], {"id": a})
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "kandidat"
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
topic = topic_anlegen("guard")
run = run_anlegen(topic)

View File

@@ -245,6 +245,110 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
def test_marker_titel_erzeugen_keine_auftraege():
"""Atom-Titel im Marker (a_n, A*-Suche) dürfen keine Mathe-/Stil-Aufträge
erzeugen — der Fix darf Marker nie ändern, das wäre ein Endlos-Loop."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("markerimmun")
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")
a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\n"
+ "Sauberer Fließtext ohne Formeln. " * 12)
joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, ""))
assert "a_n" not in joined and "A*" not in joined
assert "Formel" not in joined and "$-Zeichen" not in joined and "2^" not in joined
def test_persistieren_dedup_mit_detail():
"""Mehrere Befunde gleicher (art,item) mit verschiedenem detail = je eine Zeile."""
import db
import llm
import qa
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("persist")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"},
{"art": "det_check", "item": "5", "detail": "B"}])
assert len(db.query("SELECT id FROM befunde WHERE status='offen'")) == 2
qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"}])
stat = {(b["detail"], b["status"]) for b in db.query("SELECT detail, status FROM befunde")}
assert ("A", "offen") in stat and ("B", "repariert") in stat
async def test_fix_offen_in_messen():
"""Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr."""
import db
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("fixoffen")
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 -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang,
text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"]))
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
assert "fix_offen" in {b["art"] for b in guide.messen(ctx)}
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
"""Fix ändert den Text nicht → reparieren meldet ehrlich False (Stillstand)."""
import db
import fake_agents
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("bewegt")
run = run_anlegen(topic)
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 -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p", befunde=db.j(["Behebe (laenge): kürzen"]))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "- p", "lang": lang}) # identisch
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
bewegt = await guide.reparieren(
ctx, [{"art": "det_check", "item": str(b_id), "detail": "kürzen"}])
assert bewegt is False
def test_stil_kein_fehlalarm_deutsch():
"""Deutscher Satz mit Homographen an/will/Not → keine „englische Passage"."""
import guide
text = ("Wir passen die Kurve an und lesen den Wert an der Stelle ab, denn der"
" Algorithmus will nicht in Not geraten und braucht das an dieser Stelle.")
assert not any("englische Passage" in x for x in guide._stil_auftraege(text, "Langtext"))
def test_pruefe_dich_norm_match():
"""Frage-Matching ist Case/Whitespace-tolerant (Fix darf umformatieren)."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("pruefe")
ziel = db.insert("lernziele", topic=topic, text="Z", 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([]))
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "Was ist ein Graph?", "antwort": "Knoten und Kanten."}))
fragen = guide._pruefe_dich(b_id, "Prüfe dich: Was ist ein Graph?")
assert len(fragen) == 1 and fragen[0]["antwort"] == "Knoten und Kanten."
def test_katex_gate_und_markdown_hygiene(monkeypatch):
import guide
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen

View File

@@ -39,6 +39,138 @@ async def test_infra_pause_statt_fail_open(monkeypatch):
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
async def test_budget_vorab_stoppt_ohne_neuen_call(monkeypatch):
"""Erschöpftes Budget → nächster call() wirft, BEVOR ein Agent-Call startet."""
import db
topic = topic_anlegen()
run = run_anlegen(topic, budget=100)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
# Budget künstlich überschreiten (ohne Agent-Call), Event-Zahl merken
ledger.log_call(run, ebene="test", stage="seed", status="ok",
tokens={"input": 200, "output": 0})
vorher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
async def darf_nicht_laufen(key, prompt, timeout, **kw):
raise AssertionError("Agent-Call trotz erschöpftem Budget gestartet")
monkeypatch.setattr(agents, "run_agent", darf_nicht_laufen)
with pytest.raises(ledger.BudgetErschoepft):
await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
nachher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"]
assert nachher == vorher # keine neue Ledger-Zeile = kein Call
async def test_hedge_gewinner_trotz_haupt_timeout(monkeypatch):
"""Haupt-Call läuft in Timeout, Zwilling (-h) liefert → Ergebnis statt Pause."""
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
async def haupt_stallt(key, prompt, timeout, **kw):
if key.endswith("-h"): # Zwilling liefert gültiges JSON
return agents.AgentErgebnis(0, "[]", "")
await asyncio.sleep(10) # Haupt hängt → wird nach Timeout gecancelt
return agents.AgentErgebnis(0, "[]", "")
monkeypatch.setattr(agents, "run_agent", haupt_stallt)
# timeout klein halten, damit der Haupt-Task zügig als Timeout endet
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.2)
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res == [] # Zwilling hat geliefert, kein LaufPause
async def test_hedge_verlierer_zaehlt_im_budget(monkeypatch):
"""Beide Hedge-Calls liefern → zwei Ledger-Zeilen, Verlierer-Tokens im Budget."""
import db
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05)
monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.4) # schwelle=0.2
async def haupt_leer_zwilling_ok(key, prompt, timeout, **kw):
if key.endswith("-h"): # Zwilling startet bei 0.2, liefert bei ~0.35
await asyncio.sleep(0.15)
return agents.AgentErgebnis(0, "[]", "")
await asyncio.sleep(0.25) # Haupt: fertig, aber leer (nicht ok) → Verlierer
return agents.AgentErgebnis(1, "", "leer", {"input": 40, "output": 20})
monkeypatch.setattr(agents, "run_agent", haupt_leer_zwilling_ok)
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res == []
zeilen = db.query("SELECT status FROM events WHERE run_id=?", (run,))
assert any(z["status"] == "hedge" for z in zeilen) # Verlierer geloggt
assert ledger.verbraucht(run) >= 60 # Verlierer-Tokens (40+20) sichtbar
async def test_spawn_kill_bei_cancel(monkeypatch):
"""Task-Cancel (Hedge-Verlierer/Pause) killt den CLI-Prozess, kein Zombie."""
import os
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setattr(agents, "_ram_gate", lambda key: _true())
task = asyncio.ensure_future(
agents._spawn("t-cancel", ["sleep", "30"], None, 30))
for _ in range(200): # warten bis der Prozess registriert ist
if agents._prozesse:
break
await asyncio.sleep(0.01)
proc = next(iter(agents._prozesse.values()))
pid = proc.pid
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
for _ in range(200): # Kill ist async
try:
os.kill(pid, 0)
except ProcessLookupError:
break
await asyncio.sleep(0.01)
with pytest.raises(ProcessLookupError):
os.kill(pid, 0) # Prozess ist tot
assert not agents._prozesse # aus der Registry entfernt
async def _true():
return True
async def test_opencode_timeout_loggt_tokens(monkeypatch):
"""opencode-Timeout: Tokens der Session werden geloggt, nicht als 0 verworfen."""
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
async def spawn_timeout(*a, **kw):
raise asyncio.TimeoutError
monkeypatch.setattr(agents, "_spawn", spawn_timeout)
monkeypatch.setattr(agents, "_opencode_tokens",
lambda key: {"input": 5000, "output": 3000})
res = await agents._opencode("t-oc", "prompt", 10, "minimax/M", "none")
assert res.err == "timeout" and not res.ok
assert res.tokens == {"input": 5000, "output": 3000} # nicht 0
def test_gather_nur_in_llm():
"""Rückfall-Guard: bare asyncio.gather darf NUR in llm.py stehen (sonst
laufen Geschwister-Tasks bei LaufPause/Budget weiter — nutze llm.alle)."""
from pathlib import Path
backend = Path(__file__).resolve().parent.parent / "backend"
treffer = []
for p in backend.glob("*.py"):
if p.name == "llm.py":
continue
for i, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
if "asyncio.gather" in zeile:
treffer.append(f"{p.name}:{i}")
assert not treffer, f"bare asyncio.gather außerhalb llm.py: {treffer}"
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic)

130
tests/test_diagramme.py Normal file
View File

@@ -0,0 +1,130 @@
"""Ebene 3.5 Diagramme: deterministische DAG-Diagramme (kein LLM), Spec→Mermaid."""
import db
import diagramme
import llm
from conftest import run_anlegen, topic_anlegen
def _baustein_mit_kette(topic, n=4):
"""Baustein mit n Atomen und einer braucht-Kette a0←a1←…←a(n-1) (n-1 Kanten)."""
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")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
atome = [db.insert("atome", topic=topic, titel=f"Atom {i}", typ="begriff",
definition="d", status="neu", baustein_id=b, ord=i, braucht=db.j([]))
for i in range(n)]
for i in range(1, n): # Atom i braucht Atom i-1
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, atome[i], atome[i - 1]))
return b, atome
def test_spec_zu_mermaid_flowchart():
spec = {"typ": "dependency",
"knoten": [{"id": "1", "label": "A"}, {"id": "2", "label": 'B "x" [y]'}],
"kanten": [{"von": "1", "zu": "2", "label": ""}]}
out = diagramme._spec_zu_mermaid(spec)
assert out.startswith("flowchart TD")
assert 'n1["A"]' in out and "n1 --> n2" in out
assert '"' not in out.split("\n", 1)[1].replace('["', "").replace('"]', "") # Label entschärft
async def test_dag_diagramm_wird_gebaut_und_verifiziert():
topic = topic_anlegen("diag-dag")
b, atome = _baustein_mit_kette(topic, n=4) # 3 Kanten ≥ DIAGRAMM_MIN_KANTEN
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,))
assert d and d["status"] == "verifiziert" and d["quelle"] == "dag"
assert d["mermaid"].startswith("flowchart TD")
assert all(f"n{a}" in d["mermaid"] for a in atome) # jeder Knoten drin
assert diagramme.messen(ctx) == [] # keine offenen Kandidaten
assert diagramme.gate(ctx) is None
async def test_wenig_kanten_kein_diagramm():
topic = topic_anlegen("diag-leer")
b, _ = _baustein_mit_kette(topic, n=2) # nur 1 Kante < Schwelle
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
# kein GEBAUTES Diagramm (Fake-Judge sagt noetig=False → nur eine 'kein'-Merkzeile)
assert db.query("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'",
(topic,)) == []
assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "kein"
async def test_llm_diagramm_plan_spec_verify(monkeypatch):
"""LLM-Zweig: Judge sagt nötig → Spec → Mermaid → Grounding-Panel verifiziert."""
import fake_agents
topic = topic_anlegen("diag-llm")
# Baustein OHNE genug Kanten (kein DAG) → LLM-Zweig entscheidet
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")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="Automat", ord=0, status="neu")
for i in range(3):
db.insert("atome", topic=topic, titel=f"Zustand {i}", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
lambda p: {"noetig": True, "typ": "state"})
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,))
assert d["status"] == "verifiziert" and d["quelle"] == "llm" and d["typ"] == "state"
assert d["mermaid"].startswith("stateDiagram-v2")
async def test_llm_diagramm_grounding_verwirft(monkeypatch):
"""Grounding-Panel einstimmig ok=false → Diagramm verworfen (fail-closed)."""
import fake_agents
topic = topic_anlegen("diag-verwirf")
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")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="X", ord=0, status="neu")
for i in range(3):
db.insert("atome", topic=topic, titel=f"K{i}", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
lambda p: {"noetig": True, "typ": "flow"})
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Verify",
lambda p: [{"ok": False, "mangel": "erfundener Knoten"}])
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "verworfen"
async def test_marker_wird_zu_fence():
"""Assembly: platzierter <!-- diagramm: id --> → ```mermaid-Fence."""
import guide
topic = topic_anlegen("diag-place")
b, _ = _baustein_mit_kette(topic, n=4)
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
d = db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b,))
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang=f"Text.\n\n<!-- diagramm: {d['id']} -->\n\nMehr Text.")
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
assert "```mermaid" in lang and "flowchart TD" in lang
assert "<!-- diagramm:" not in lang # Marker ersetzt
async def test_toter_diagramm_marker_wird_entfernt():
import guide
topic = topic_anlegen("diag-tot")
b, _ = _baustein_mit_kette(topic, n=2) # kein Diagramm gebaut
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang="Text.\n\n<!-- diagramm: 999 -->\n\nMehr.")
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
assert "<!-- diagramm:" not in lang and "```mermaid" not in lang
async def test_guide_messen_meldet_toten_marker():
"""Ein Marker ohne verifiziertes Diagramm → diagramm_marker_tot in guide.messen."""
import guide
topic = topic_anlegen("diag-qa")
b, _ = _baustein_mit_kette(topic, n=4)
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang="Text.\n\n<!-- diagramm: 777 -->\n\n" + "Wort " * 60)
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
assert "diagramm_marker_tot" in {x["art"] for x in guide.messen(ctx)}

View File

@@ -142,7 +142,7 @@ async def test_ebenen_entfernen_kaskade():
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 set(resets) == {"struktur", "diagramme", "guide"} # diagramme hängt an struktur
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)

View File

@@ -55,6 +55,99 @@ async def test_anker_rematch_ohne_llm(tmp_path):
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path):
"""Abbruch mitten in der Extraktion (atome_stand leer): der Resume liest die
Quelle komplett neu, statt sie als fertig zu überspringen."""
topic = topic_anlegen("resume-ext")
run = run_anlegen(topic)
snap = tmp_path / "q.md"
snap.write_text("Absatz eins über Kompaktheit.\n\nAbsatz zwei über Vollständigkeit.",
encoding="utf-8")
# Quelle hat schon EIN verankertes Atom, aber atome_stand ist leer (Abbruch)
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="extrahiert", atome_stand="")
a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
await inventar._extrahiere_quelle(ctx, db.one("SELECT * FROM quellen WHERE id=?", (q,)))
# neu gelesen → Extraktions-Events da, atome_stand jetzt gesetzt
assert db.query("SELECT id FROM events WHERE run_id=? AND stage='extraktion'", (run,))
assert db.one("SELECT atome_stand FROM quellen WHERE id=?", (q,))["atome_stand"] == "atome"
# Gegenprobe: mit gesetztem Merker wird NICHT neu gelesen
run2 = run_anlegen(topic)
ctx2 = llm.Kontext(run2, topic, "minimax")
ctx2.ebene = "inventar"
await inventar._extrahiere_quelle(ctx2, db.one("SELECT * FROM quellen WHERE id=?", (q,)))
assert db.query("SELECT id FROM events WHERE run_id=?", (run2,)) == []
async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch):
"""Verwerfen (destruktiv) nur bei vollzähligem einstimmigem „fremd"-Panel."""
topic = topic_anlegen("stich")
run = run_anlegen(topic)
db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
def neu_atom():
return db.insert("atome", topic=topic, titel="X", typ="begriff",
definition="d", status="neu", braucht=db.j([]))
def panel_mit(votes):
async def _p(ctx, groesse, **kw):
return list(votes)
return _p
a1 = neu_atom() # 1× fremd → nicht vollzählig → Atom bleibt
monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}]))
await inventar._soll_stichentscheid(ctx, a1)
assert db.one("SELECT status FROM atome WHERE id=?", (a1,))["status"] == "neu"
a2 = neu_atom() # 2× fremd → verworfen
monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}, {"soll": "fremd"}]))
await inventar._soll_stichentscheid(ctx, a2)
assert db.one("SELECT status FROM atome WHERE id=?", (a2,))["status"] == "verworfen"
a3 = neu_atom() # 2× ohne soll-Feld → kein fremd-Votum → bleibt
monkeypatch.setattr(llm, "panel", panel_mit([{}, {}]))
await inventar._soll_stichentscheid(ctx, a3)
assert db.one("SELECT status FROM atome WHERE id=?", (a3,))["status"] == "neu"
async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path):
"""Call-Ausfall (None) ≠ „keine Quellstelle" → Atom bleibt ohne_anker, bewegt=False."""
import fake_agents
topic = topic_anlegen("ankerfail")
run = run_anlegen(topic)
snap = tmp_path / "q.md"
snap.write_text("Ganz anderer Text ohne die gesuchte Stelle.", 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([]))
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.")
monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {})
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
bewegt = await inventar._anker_fixen_batch(ctx, [a])
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "ohne_anker"
assert bewegt is False
def test_finde_zitat_casefold_sz_offsets():
"""casefold-Stufe: ß→ss-Expansionen vor dem Treffer dürfen den Span nicht
verschieben; Treffer am Textende darf nicht in einen IndexError laufen."""
import textkit
text = "Straße und Fußweg. " * 30 + "DER KERNSATZ STEHT AM ENDE."
span = textkit.finde_zitat(text, "der kernsatz steht am ende.")
assert span is not None
assert text[span[0]:span[1]] == "DER KERNSATZ STEHT AM ENDE."
def test_titel_kern_faltet_schreibvarianten():
import textkit
assert textkit.titel_kern("ΔTSP2 TourLänge") == textkit.titel_kern("ΔTSP2 Tour-Länge")

View File

@@ -87,6 +87,64 @@ async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
assert befunde[0]["item"] == str(c)
async def test_konsens_erhaelt_nachbelege_geprueft_und_id(monkeypatch):
"""Neu-Konsens darf bestätigte Zeilen nicht löschen: id, geprueft-Cache und
nachgesuchte Belege bleiben, Belege werden vereint."""
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
best = db.insert("soll", topic=topic, punkt="Alpha", status="bestaetigt",
belege=db.j([{"quelle": q1, "zitat": "Zitat Alpha"}]),
geprueft=db.j([q2]))
a1 = _kandidat(topic, "Alpha", q1)
a2 = _kandidat(topic, "Alpha", q2)
def judge(prompt):
if "NACHRUNDE" in prompt:
return []
return [{"punkt": "Alpha", "kandidaten": [a1, a2]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 1
row = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,))
assert row["id"] == best # id stabil (kein DELETE+INSERT)
assert db.uj(row["geprueft"]) == [q2] # geprueft-Cache erhalten
assert {b["quelle"] for b in db.uj(row["belege"])} == {q1, q2} # Belege-Union
async def test_soll_resume_ohne_doppelkandidaten(monkeypatch, tmp_path):
"""Abgebrochener Extraktions-Pass: der Resume darf keine Doppel-Kandidaten legen."""
topic = topic_anlegen("resume-soll", art="uni")
snap = tmp_path / "q.md"
snap.write_text("Der wichtige Punkt steht hier im Text.", encoding="utf-8")
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="neu")
db.insert("soll", topic=topic, punkt="Der wichtige Punkt", status="kandidat",
belege=db.j([{"quelle": q, "zitat": "Der wichtige Punkt"}]))
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "korpus"
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll",
lambda p: [{"punkt": "Der wichtige Punkt",
"zitat": "Der wichtige Punkt steht hier"}])
await korpus._soll_extrahieren(ctx)
kand = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'", (topic,))
assert len(kand) == 1 # Leiche gelöscht, genau ein Kandidat
async def test_uni_soll_leer_faellt_zu(monkeypatch, tmp_path):
"""uni-Topic ohne bestätigten Punkt: kein Auto-Freeze, Gate blockt E1."""
topic = topic_anlegen("unileer", art="uni")
snap = tmp_path / "q.md"
snap.write_text("Nur Fülltext ohne lernbaren Punkt.", encoding="utf-8")
db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="neu")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "korpus"
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", lambda p: [])
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", lambda p: [])
await korpus.bauen(ctx)
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "korpus"
assert korpus.gate(ctx) == "kein bestätigter Soll-Punkt"
async def test_thema_braucht_zwei_quellen(monkeypatch):
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
x1 = _kandidat(topic, "X", q1)

View File

@@ -12,6 +12,62 @@ def _atom(topic, titel, ziel_id=None, soll_id=1):
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
def _baustein_groessen(topic):
from collections import Counter
c = Counter(a["baustein_id"] for a in struktur._atome(topic))
return sorted(c.values())
async def test_band_split_43_atome():
"""Große Gruppe gleichverteilt splitten: jeder Baustein im Band 48."""
topic = topic_anlegen("split43")
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(43):
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
groessen = _baustein_groessen(topic)
assert sum(groessen) == 43
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
for n in groessen), groessen
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
async def test_band_messen_konsistent_mit_schnitt():
"""Kleine Gruppe mergt trotz Summe > MAX (Split re-balanciert) → kein band-Befund;
eine einsame Kleingruppe ohne Level-Partner meldet ebenfalls nichts."""
topic = topic_anlegen("bandkon")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Z1", soll_id=soll, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Z2", soll_id=soll, status="aktiv")
for i in range(3):
_atom(topic, f"K{i}", z1, soll)
for i in range(13):
_atom(topic, f"G{i}", z2, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
assert all(4 <= n <= 8 for n in _baustein_groessen(topic))
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
async def test_einsame_kleingruppe_kein_band():
topic = topic_anlegen("lone")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=soll, status="aktiv")
for i in range(2):
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
def test_topo_ordnung():
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
# 3 braucht 1, 2 braucht 3 → 1, 3, 2

49
tests/test_topics.py Normal file
View File

@@ -0,0 +1,49 @@
"""Sicherheit + Infra-Kleinkram: Topic-Namen-Validierung, jsonx-Fences,
Ledger-Timeouts, db.now-Format."""
import re
import config
import db
import jsonx
import ledger
import pytest
import transfer
from conftest import run_anlegen, topic_anlegen
def test_topic_name_ok():
assert config.topic_name_ok("aak")
assert config.topic_name_ok("algo-1_test")
assert config.topic_name_ok("Größe")
for boese in ("..", "a/b", "x;curl evil|sh", "-flag", "", "a.b", "../x", "a b", "a\tb"):
assert not config.topic_name_ok(boese), boese
def test_import_lehnt_boesen_topic_ab():
"""Manipulierter Export mit topic='../evil' → Abbruch vor rmtree, kein Eintrag."""
d = {"topic": "../evil", "topics": [{"name": "../evil"}]}
with pytest.raises(ValueError):
transfer.importieren(d)
assert not db.one("SELECT name FROM topics WHERE name=?", ("../evil",))
def test_jsonx_findet_json_im_zweiten_fence():
text = ("Beispiel:\n```python\nprint('hi')\n```\n"
"Antwort:\n```json\n[{\"a\": 1}]\n```")
assert jsonx.parse(text) == [{"a": 1}]
def test_ledger_timeouts_und_parse():
topic = topic_anlegen("led")
run = run_anlegen(topic)
ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "timeout"})
ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "HTTP 429"})
ledger.log_call(run, ebene="e", stage="s", status="parse", meta={"err": "kaputt"})
z = ledger.kennzahlen(run)[0]
assert z["timeouts"] == 1 # nur der echte Timeout
assert z["fehler"] == 3 # infra(2) + parse(1)
def test_db_now_ist_zeitstempel():
assert re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", db.now())

View File

@@ -49,3 +49,26 @@ async def test_export_import_roundtrip():
for feld in ("text_lang", "text_kompakt"):
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
assert int(m) in atom_ids, f"toter Marker {m}"
async def test_import_remappt_befunde_atom_ids():
"""Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie
auf die neu vergebenen IDs zeigen, nicht auf tote."""
topic = topic_anlegen("befunde-remap", art="thema")
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,))
a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],))
db.update("sections", "baustein_id", b["id"],
befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."]))
d = json.loads(json.dumps(transfer.export(topic)))
transfer.importieren(d)
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
treffer = 0
for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
" WHERE b.topic=?", (topic,)):
for auftrag in db.uj(s["befunde"]):
for m in re.findall(r"Atom (\d+)", str(auftrag)):
assert int(m) in atom_ids, f"toter Befund-Marker {m}"
treffer += 1
assert treffer >= 1 # der eingefügte Auftrag wurde geprüft