Files
creator2/tests/test_struktur.py
2026-07-14 00:15:03 +02:00

364 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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"
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