"""Struktur-Ebene: Band-Schnitt, Judge-Ordnung mit Quellpositions-Prior, Kapitel — mit synthetischen Atomen.""" import db import fake_agents 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) 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)) return sorted(c.values()) async def test_band_split_43_atome(): """Große Gruppe gleichverteilt splitten: jeder Baustein im Band 4–8.""" 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" await 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" await 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" await struktur._bausteine_schneiden(ctx) assert "band" not in {b["art"] for b in struktur.messen(ctx)} 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") await 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")] 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 for i in (0, 1, 2, 3): _atom(topic, f"B{i}", z2, s2) ctx = llm.Kontext(run, topic, "minimax") ctx.ebene = "struktur" await struktur._bausteine_schneiden(ctx) atome = struktur._atome(topic) assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll 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) == [] async def test_ordnung_judge_permutation(monkeypatch): """Der Ordnungs-Judge bestimmt die Lehr-Reihenfolge; Prior ist die Quellposition (Eingabe-Nummerierung).""" topic = topic_anlegen("ordjudge") run = run_anlegen(topic) soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) ziele = [db.insert("lernziele", topic=topic, text=f"Kann Z{i}", soll_id=soll, status="aktiv") for i in range(2)] q = db.insert("quellen", topic=topic, art="datei", titel="s.txt", snapshot="s", rolle="stoff", status="atome") for zi, ziel in enumerate(ziele): for i in range(4): a = _atom(topic, f"Z{zi}A{i}", ziel, soll) db.insert("anker", atom_id=a, quelle_id=q, start=zi * 1000 + i, ende=0, zitat="x") monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", lambda p: list(reversed(fake_agents._baustein_ordnung(p)))) ctx = llm.Kontext(run, topic, "minimax") ctx.ebene = "struktur" await struktur._bausteine_schneiden(ctx) bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) # Prior wäre Z0 vor Z1 — der Judge hat umgedreht, und das gilt assert [b["ziel_id"] for b in bausteine] == [ziele[1], ziele[0]] assert all(b["ordnung"] == "judge" for b in bausteine) assert "ordnung_fallback" not in {x["art"] for x in struktur.messen(ctx)} async def test_ordnung_retry_und_fallback(monkeypatch): topic = topic_anlegen("ordfall") 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 ziel in (z1, z2): for i in range(4): _atom(topic, f"{ziel}A{i}", ziel, soll) aufrufe = {"n": 0} def judge(prompt): aufrufe["n"] += 1 if aufrufe["n"] == 1: return [1, 1] # keine Permutation return [2, 1] monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", judge) ctx = llm.Kontext(run, topic, "minimax") ctx.ebene = "struktur" await struktur._bausteine_schneiden(ctx) assert aufrufe["n"] == 2 # Retry hat gegriffen assert all(b["ordnung"] == "judge" for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))) # beide Versuche ungültig → Prior-Ordnung + Befund monkeypatch.setitem(fake_agents._HANDLER, "Baustein-Ordnung", lambda p: ["x"]) await struktur._bausteine_schneiden(ctx) assert all(b["ordnung"] == "fallback" for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))) assert "ordnung_fallback" in {x["art"] for x in struktur.messen(ctx)} def test_baustein_rang_median_gegen_merge_gift(): """ETH-Muster: EIN importiertes Atom vom Quellanfang darf den Baustein nicht nach vorn ziehen — der Median liegt bei den echten Mitgliedern.""" bausteine = [{"id": 1}, {"id": 2}] atome = [{"id": 10, "baustein_id": 1}, {"id": 11, "baustein_id": 1}, {"id": 12, "baustein_id": 1}, # Merge-Import mit Rang vom Dateianfang {"id": 20, "baustein_id": 2}, {"id": 21, "baustein_id": 2}] rang = {10: (1, 47000), 11: (1, 48000), 12: (1, 464), 20: (1, 5000), 21: (1, 6000)} b_rang = struktur._baustein_rang(bausteine, atome, rang) assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten 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") 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): topic = topic_anlegen("level") run = run_anlegen(topic) ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv") a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d", level="M", status="neu", ziel_id=ziel) a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d", level="M", status="neu", ziel_id=ziel) def judge(prompt): return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"}, {"atom": 99999, "level": "E"}, {"atom": a2, "level": "X"}] # Müll ignorieren monkeypatch.setitem(fake_agents._HANDLER, "Level-Kalibrierung", judge) ctx = llm.Kontext(run, topic, "minimax") ctx.ebene = "struktur" await struktur._level_kalibrieren(ctx) assert db.one("SELECT level FROM atome WHERE id=?", (a1,))["level"] == "E" assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S" 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", level="E", status="neu", soll_id=soll, ziel_id=ziel) db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d", level="M", status="neu", soll_id=soll, ziel_id=ziel) ctx = llm.Kontext(run, topic, "minimax") 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"] # Tie 4/4 → niedrigeres struktur._kapitel_bilden(topic) arten = {b["art"] for b in struktur.messen(ctx)} 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_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) 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): _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) 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