This commit is contained in:
team3
2026-07-14 00:15:03 +02:00
parent af0e3c0629
commit 3f6114681c
22 changed files with 757 additions and 307 deletions

View File

@@ -184,3 +184,131 @@ async def test_freeze_urteilt_weiter(monkeypatch):
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}])
assert bewegt is True # geurteilt (behoben) trotz Freeze
assert db.one("SELECT COUNT(*) n FROM auftraege WHERE status='behoben'")["n"] == 1
# ── Klärungs-Stichentscheid (eskalierte falsch/luecke) ────────────────────────
async def test_klaerung_text_falsch_wiedereroeffnet(monkeypatch):
"""Mehrheit text_falsch → Auftrag wieder offen (runden=0, geklaert=1); der
Fix bekommt das Streich-Mandat als [KLÄRUNG]-Vermerk."""
topic = topic_anlegen("klaer1")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Zahl falsch",
status="eskaliert", runden=AUFTRAG_RUNDEN_MAX)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": "text_falsch"}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["runden"], r["geklaert"]) == ("offen", 0, 1)
# der Fix-Prompt trägt das Streich-Mandat
gesehen = {}
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
def fix(prompt):
gesehen["klaerung"] = "[KLÄRUNG" in prompt and "STREICHEN" in prompt
return {"kompakt": "- p", "lang": sec["text_lang"] + " korrigiert"}
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", fix)
db.update("sections", "baustein_id", b["id"], stage="fix")
await guide._stage_fix(_ctx(topic), b)
assert gesehen["klaerung"]
async def test_klaerung_unbegruendet_schliesst():
"""Default-Fake stimmt unbegruendet → kein_mangel, geklaert=1, bewegt True."""
topic = topic_anlegen("klaer2")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="luecke", detail="Fehlalarm",
status="eskaliert")
db.update("sections", "baustein_id", b["id"], stage="done")
bewegt = await guide.reparieren(_ctx(topic), [
{"art": "klaerung_offen", "item": str(b["id"]), "detail": "Fehlalarm"}])
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("kein_mangel", 1)
assert bewegt is True
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
(b["id"],))["stage"] == "done" # keine Stage-Route nötig
async def test_klaerung_patt_terminal(monkeypatch):
"""Voll-Patt (1/1/1) → geklaert=1, bleibt eskaliert; messen zeigt
fakten_konflikt; zweiter reparieren-Lauf ruft die Klärung nie wieder."""
topic = topic_anlegen("klaer3")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="strittig",
status="eskaliert")
votes = iter(["text_falsch", "unbegruendet", "quelle_unklar"])
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": next(votes)}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
ctx = _ctx(topic)
arten = [x["art"] for x in guide.messen(ctx)]
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: pytest.fail("geklärter Auftrag erneut geklärt"))
assert await guide.reparieren(ctx, [{"art": "fakten_konflikt",
"item": str(b["id"]), "detail": "strittig"}]) is False
async def test_klaerung_ohne_gueltige_stimmen_fail_closed(monkeypatch):
"""Liefern die Richter keine gültigen Urteile, bleibt geklaert=0 —
nächste Runde klärt erneut (fail-closed)."""
topic = topic_anlegen("klaer4")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
status="eskaliert")
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": "quatsch"}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 0)
async def test_runden_inkrement_ist_nicht_bewegt(monkeypatch):
"""Identisches Panel-Patt auf identischem Text = Leer-Churn → reparieren
meldet ehrlich False (runden zählt nicht mehr als Bewegung)."""
topic = topic_anlegen("leerchurn")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Patt")
db.update("sections", "baustein_id", b["id"], stage="done")
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]})
bewegt = await guide.reparieren(_ctx(topic), [
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) Patt"}])
assert bewegt is False # nur runden+1, kein Status-/Text-Fortschritt
async def test_stil_rest_keine_route():
topic = topic_anlegen("stilrest")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="stil", detail="zäh",
status="eskaliert")
db.update("sections", "baustein_id", b["id"], stage="done")
assert await guide.reparieren(_ctx(topic), [
{"art": "stil_rest", "item": str(b["id"]), "detail": "zäh"}]) is False
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
(b["id"],))["stage"] == "done"
async def test_reeskalation_nach_klaerung_terminal(monkeypatch):
"""Wiedereröffneter (geklärter) Auftrag, der erneut am Cap eskaliert, ist
terminal unauflösbar — keine zweite Klärung."""
topic = topic_anlegen("reesk")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
geklaert=1, runden=AUFTRAG_RUNDEN_MAX - 1)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
await guide._auftraege_urteilen(_ctx(topic), b,
db.one("SELECT * FROM sections WHERE baustein_id=?",
(b["id"],)),
guide._offene_auftraege(b["id"]))
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
ctx = _ctx(topic)
arten = [x["art"] for x in guide.messen(ctx)]
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
assert guide._klaerung_kandidaten(b["id"]) == []

View File

@@ -284,7 +284,8 @@ def test_persistieren_dedup_mit_detail():
async def test_auftrag_offen_in_messen():
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
eskalierte falsch/luecke-Aufträge werden als fakten_konflikt sichtbar."""
eskalierte dreiteilig: ungeklärt → klaerung_offen, geklärt → fakten_konflikt,
Stil → stil_rest (war vorher stumm)."""
import db
import guide
import llm
@@ -298,14 +299,18 @@ async def test_auftrag_offen_in_messen():
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch",
status="eskaliert")
status="eskaliert") # geklaert=0 → wartet auf Stichentscheid
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Unauflösbar",
status="eskaliert", geklaert=1)
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld)
status="eskaliert")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
arten = [b["art"] for b in guide.messen(ctx)]
assert arten.count("auftrag_offen") == 1
assert arten.count("klaerung_offen") == 1
assert arten.count("fakten_konflikt") == 1
assert arten.count("stil_rest") == 1
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):

View File

@@ -53,15 +53,21 @@ def _pruefe_endzustand(topic, run_id):
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
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord
# Level je Baustein = dominantes Atom-Level; Themenreinheit: die Soll-Punkte
# aller Atome gehören zum Baustein-Thema; Kapitel-Level monoton E→M→S
rang = {"E": 0, "M": 1, "S": 2}
kap_level = {k["id"]: k["level"] for k in kaps}
soll_thema = {p["id"]: p["thema_id"] for p in
db.query("SELECT id, thema_id FROM soll WHERE topic=?", (topic,))}
from collections import Counter
for b in bausteine:
assert kap_level[b["kapitel_id"]] == b["level"]
for a in atome:
if a["baustein_id"] == b["id"]:
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"])
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
zaehler = Counter(a["level"] for a in eigene)
dominant = min(zaehler, key=lambda lv: (-zaehler[lv], rang.get(lv, 9)))
assert b["level"] == dominant, (b["titel"], b["level"], dict(zaehler))
for a in eigene:
assert soll_thema.get(a["soll_id"]) == b["thema_id"], (a["titel"], b["titel"])
level_folge = [rang[b["level"]] for b in bausteine]
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet"
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
@@ -74,9 +80,11 @@ async def test_e2e_thema():
topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
# Fake-Welt: 3 Atome mit Level E/M/S → je Level ein eigener Baustein-Durchgang
# Fake-Welt: 3 Atome E/M/S im selben Thema → EIN Baustein, Level = E
# (Gleichstand 1/1/1 → niedrigstes; kein Level-Split mehr)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M", "S"]
assert [b["level"] for b in bausteine] == ["E"]
assert all(b["thema_id"] for b in bausteine)
async def test_e2e_uni(tmp_path):
@@ -143,6 +151,9 @@ async def test_ebenen_entfernen_kaskade():
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.query("SELECT * FROM themen WHERE topic=?", (topic,)) == []
assert all(p["thema_id"] is None for p in
db.query("SELECT thema_id FROM soll 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"], {})
@@ -161,7 +172,7 @@ async def test_topic_loeschen():
_pruefe_endzustand(topic, run1)
pipeline.topic_loeschen(topic)
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
"kapitel", "runs"):
"kapitel", "themen", "runs"):
feld = "name" if tabelle == "topics" else "topic"
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle

View File

@@ -14,6 +14,14 @@ def _atom(topic, titel, ziel_id=None, soll_id=1):
soll_id=soll_id, ziel_id=ziel_id)
def _thema(topic, soll_ids, titel="T", ord=0):
"""Themen-Seed: eine themen-Zeile, Soll-Punkte darauf zeigen lassen."""
t = db.insert("themen", topic=topic, titel=titel, ord=ord, art="judge")
for s in soll_ids:
db.update("soll", "id", s, thema_id=t)
return t
def _baustein_groessen(topic):
from collections import Counter
c = Counter(a["baustein_id"] for a in struktur._atome(topic))
@@ -88,13 +96,14 @@ async def test_band_split():
assert not [x for x in befunde if x["art"] in ("band", "partition")]
async def test_merge_ohne_soll_schranke_und_kapitel():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente
async def test_merge_im_thema_ueber_soll_grenzen():
# kleines Ziel merged über Soll-Punkt-Grenzen, solange BEIDE Punkte im
# selben Thema liegen; Kapitel = (Level, Thema) deterministisch
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([]))
t = _thema(topic, [s1, s2], "Gemeinsames Thema")
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")
_atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
@@ -105,9 +114,12 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
await 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)
struktur._kapitel_bilden(topic)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert bausteine and all(x["kapitel_id"] for x in bausteine)
assert all(x["thema_id"] == t for x in bausteine)
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert [k["titel"] for k in kaps] == ["Gemeinsames Thema"]
assert struktur.messen(ctx) == []
@@ -183,32 +195,30 @@ def test_baustein_rang_median_gegen_merge_gift():
assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
async def test_kapitel_retry_und_fallback(monkeypatch):
topic = topic_anlegen("kapfall")
run = run_anlegen(topic)
def test_kapitel_deterministisch(monkeypatch):
"""Kapitel = (Level, Thema)-Segment ohne LLM: Titel = Thema-Titel, art='det',
Reihenfolge folgt der globalen Baustein-ord."""
topic = topic_anlegen("kapdet")
t1 = db.insert("themen", topic=topic, titel="Thema Eins", ord=0, art="judge")
t2 = db.insert("themen", topic=topic, titel="Thema Zwei", ord=1, art="judge")
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)}
ordn = 0
for level in ("E", "M"):
for thema in (t1, t2):
db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{ordn}",
ord=ordn, status="neu", level=level, thema_id=thema)
ordn += 1
def explodiert(prompt): # beweist: kein LLM-Call nötig
raise AssertionError("Kapitel-Bildung darf keinen Judge rufen")
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
struktur._kapitel_bilden(topic)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["titel"] for k in kaps] == ["Thema Eins", "Thema Zwei"] * 2
assert [k["level"] for k in kaps] == ["E", "E", "M", "M"]
assert all(k["art"] == "det" for k in kaps)
zu_kapitel = {b["ord"]: b["kapitel_id"] for b in
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))}
assert len(set(zu_kapitel.values())) == 4 # je (Level, Thema) ein Kapitel
async def test_level_kalibrierung(monkeypatch):
@@ -231,12 +241,13 @@ async def test_level_kalibrierung(monkeypatch):
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
async def test_level_split_ordnung_kapitel():
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
# Kapitel je Durchgang, kein level_mix/kapitel_level
topic = topic_anlegen("split")
async def test_dominantes_level():
# Ein Ziel mit 4×E + 4×M → EIN Baustein (kein Level-Split mehr);
# Level = Mehrheit, Gleichstand → niedrigeres (E). 3×E + 5×M → M.
topic = topic_anlegen("dominant")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
_thema(topic, [soll])
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(4):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
@@ -247,29 +258,106 @@ async def test_level_split_ordnung_kapitel():
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M"]
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["level"] for k in kaps] == ["E", "M"]
assert [b["level"] for b in bausteine] == ["E"] # Tie 4/4 → niedrigeres
struktur._kapitel_bilden(topic)
arten = {b["art"] for b in struktur.messen(ctx)}
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
assert not arten & {"kapitel_level", "band", "partition", "baustein_thema_mix"}
# Mehrheit gewinnt: reine Funktion
assert struktur._dominantes_level([{"level": "E"}] * 3 + [{"level": "M"}] * 5) == "M"
assert struktur._dominantes_level([{"level": "S"}]) == "S"
async def test_kein_merge_ueber_level():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
topic = topic_anlegen("levelmerge")
async def test_kein_merge_ueber_thema():
# kleine Ziele in VERSCHIEDENEN Themen mergen NICHT — zwei Bausteine,
# und kein band-Befund (kein Partner im eigenen Thema)
topic = topic_anlegen("themamerge")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
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([]))
_thema(topic, [s1], "T1", 0)
_thema(topic, [s2], "T2", 1)
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")
for i in range(2):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=z1)
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=z2)
_atom(topic, f"A{i}", z1, s1)
_atom(topic, f"B{i}", z2, s2)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
levels = {a["level"] for a in struktur._atome(topic)
if a["baustein_id"] == b["id"]}
assert levels == {b["level"]}
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert len(bausteine) == 2 and {b["ziel_id"] for b in bausteine} == {z1, z2}
struktur._kapitel_bilden(topic)
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
async def test_themen_bilden_gate_retry_fallback(monkeypatch):
topic = topic_anlegen("themengate")
run = run_anlegen(topic)
punkte = [db.insert("soll", topic=topic, punkt=f"P{i}", status="bestaetigt",
belege=db.j([])) for i in range(4)]
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1: # unvollständig → Gate schlägt zu, Retry
return [{"titel": "T", "punkte": [1, 2]}]
return [{"titel": "Grundlagen", "punkte": [1, 3]},
{"titel": "Vertiefung", "punkte": [2, 4]}]
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", judge)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._themen_bilden(ctx)
assert aufrufe["n"] == 2 # Retry hat gegriffen
themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,))
assert [t["titel"] for t in themen] == ["Grundlagen", "Vertiefung"]
zu = {p: db.one("SELECT thema_id FROM soll WHERE id=?", (p,))["thema_id"]
for p in punkte}
assert zu[punkte[0]] == zu[punkte[2]] == themen[0]["id"]
assert zu[punkte[1]] == zu[punkte[3]] == themen[1]["id"]
assert "thema_partition" not in {b["art"] for b in struktur.messen(ctx)}
# beide Versuche Müll → √n-Fallback + Befund
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
db.execute("UPDATE soll SET thema_id=NULL WHERE topic=?", (topic,))
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", lambda p: [{"x": 1}])
await struktur._themen_bilden(ctx)
themen = db.query("SELECT * FROM themen WHERE topic=?", (topic,))
assert themen and all(t["art"] == "fallback" for t in themen)
assert "themen_fallback" in {b["art"] for b in struktur.messen(ctx)}
offen = db.query("SELECT * FROM soll WHERE topic=? AND thema_id IS NULL", (topic,))
assert offen == [] # Fallback partitioniert vollständig
async def test_themen_skip_wenn_vollstaendig(monkeypatch):
"""Gültige Partition wird NIE neu gewürfelt (Churn-Schutz je Repair-Runde)."""
topic = topic_anlegen("themenskip")
run = run_anlegen(topic)
s = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
_thema(topic, [s])
def explodiert(prompt):
raise AssertionError("vollständige Partition darf keinen Call auslösen")
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._themen_bilden(ctx) # kein Call → kein Raise
async def test_thema_befunde():
topic = topic_anlegen("themabefund")
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([]))
t = _thema(topic, [s1], "T1")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
arten = {b["art"] for b in struktur.messen(ctx)}
assert "thema_partition" in arten # s2 hat kein Thema
# Baustein trägt T1, enthält aber ein Atom mit Soll-Punkt ohne dieses Thema
ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=s1, status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0,
status="neu", level="E", thema_id=t)
db.insert("atome", topic=topic, titel="fremd", typ="begriff", definition="d",
status="neu", soll_id=s2, ziel_id=ziel, baustein_id=b)
arten = {x["art"] for x in struktur.messen(ctx)}
assert "baustein_thema_mix" in arten

View File

@@ -16,7 +16,7 @@ async def test_export_import_roundtrip():
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte",
tabellen = ("quellen", "soll", "themen", "atome", "anker", "kanten", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "auftraege",
"runs", "befunde", "events", "leitner")
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen}
@@ -35,6 +35,11 @@ async def test_export_import_roundtrip():
" ('gemerged','verworfen')", (topic,)):
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
assert a["soll_id"] and db.one("SELECT id FROM soll WHERE id=?", (a["soll_id"],))
# Themen-Verweise remappt: bestätigte Punkte und Bausteine zeigen auf echte Themen
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,)):
assert p["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (p["thema_id"],))
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
assert b["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (b["thema_id"],))
for s in db.query("SELECT belege FROM soll WHERE topic=? AND status='bestaetigt'",
(topic,)):
for b in db.uj(s["belege"]):