This commit is contained in:
team3
2026-07-13 12:31:25 +02:00
parent 32d7ad9ea1
commit 9cd8e02e22
34 changed files with 747 additions and 606 deletions

View File

@@ -31,9 +31,9 @@ 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([]))
status="neu", soll_id=1)
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
status="neu", soll_id=1, braucht=db.j([]))
status="neu", soll_id=1)
# a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"text": "Beispiel"}))
@@ -50,7 +50,7 @@ async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
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([]))
status="neu")
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
@@ -68,7 +68,7 @@ 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([]))
status="neu")
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")
@@ -103,7 +103,7 @@ def test_referenziert_quelle_ignoriert_code():
def test_formen_gate_verwirft_kaputte_tabelle():
topic = topic_anlegen("formgate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -118,7 +118,7 @@ def test_beispiel_einsetzen_tabelle():
topic = topic_anlegen("bsp-tab")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
md = "| A | B |\n|---|---|\n| 1 | 2 |"
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": md}))
@@ -141,7 +141,7 @@ def test_code_syntax_gate():
def test_formen_gate_verwirft_kaputten_code():
topic = topic_anlegen("codegate")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
status="neu")
gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"}))
schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat",
@@ -156,7 +156,7 @@ async def test_guide_messen_toter_beispiel_marker():
topic = topic_anlegen("bspqa")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv")
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang=f"<!-- atom: {a} | X -->\n<!-- beispiel: 777 -->\n" + "Wort " * 60)

View File

@@ -14,7 +14,7 @@ def _baustein(topic, lang_extra=""):
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([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() + lang_extra
db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang,
text_kompakt="- p")

View File

@@ -177,7 +177,7 @@ def test_det_auftraege_blockquote_und_artefakt():
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([]))
status="neu", baustein_id=b_id)
lang = (f"<!-- atom: {a} | A -->\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$.")
@@ -197,7 +197,7 @@ def test_det_auftraege_neue_checks():
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([]))
status="neu", baustein_id=b_id)
wand = "Wort " * 130
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
@@ -231,7 +231,7 @@ async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch):
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")
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
@@ -256,9 +256,9 @@ def test_marker_titel_erzeugen_keine_auftraege():
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([]))
status="neu", baustein_id=b_id)
a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
status="neu", baustein_id=b_id)
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, ""))
@@ -293,7 +293,7 @@ async def test_auftrag_offen_in_messen():
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([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
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")
@@ -320,7 +320,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
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([]))
status="neu", baustein_id=b_id)
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p")
@@ -413,7 +413,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = 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, braucht=db.j([]))
status="neu", baustein_id=b)
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
@@ -433,7 +433,7 @@ async def test_marker_platzieren_deterministisch():
topic = topic_anlegen("platz")
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
@@ -444,3 +444,90 @@ async def test_marker_platzieren_deterministisch():
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("<!-- beispiel:") == 1
def test_finde_zitat_latex_gerendert():
"""tex-Quellen: das Modell zitiert die gerenderte Lesart (k ∈ ), die Quelle
trägt Markup ($k \\in \\mathbb{N}$) — die Faltung macht beide gleich."""
text = ("Für das \\textsc{VertexCover} Problem ist neben einem Graphen $G$ eine"
" Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und es wird gefragt, ob ein"
" Vertex Cover mit Größe höchstens $k$ in $G$ existiert.")
zitat = ("Für das VertexCover Problem ist neben einem Graphen G eine Zahl k ∈ "
" gegeben und es wird gefragt, ob ein Vertex Cover mit Größe höchstens k"
" in G existiert.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Für das")
# griechische Befehle ↔ Unicode-Buchstaben
text2 = ("Ein Wort $w \\in \\Sigma^*$ über dem Alphabet $\\Sigma$ ist eine"
" endliche Folge von Zeichen aus dem Alphabet.")
zitat2 = ("Ein Wort w ∈ Σ* über dem Alphabet Σ ist eine endliche Folge von"
" Zeichen aus dem Alphabet.")
assert textkit.finde_zitat(text2, zitat2) is not None
# LaTeX-Zitat gegen LaTeX-Quelle bleibt symmetrisch matchbar
assert textkit.finde_zitat(text, "Zahl $k \\in \\mathbb{N}_{\\geq 0}$ gegeben und"
" es wird gefragt, ob ein Vertex Cover") is not None
def test_finde_zitat_kurz_nur_bei_eindeutigkeit():
"""Kurze Zitate (gefaltet 1019 Zeichen) matchen nur bei GENAU EINEM
Vorkommen — Eindeutigkeit direkt geprüft statt Proxy-Länge."""
text = ("Vorher steht Text. Definiere $\\alpha_{\\text{Anfang}} = z_{00} \\wedge"
" s_{00}$ als Startbelegung. Nachher steht mehr.")
zitat = "α_Anfang = z_00 ∧ s_00" # gerendert, gefaltet 13 Zeichen
span = textkit.finde_zitat(text, zitat)
assert span is not None and "alpha" in text[span[0]:span[1]]
# zweites Vorkommen → mehrdeutig → None
assert textkit.finde_zitat(
text + " Wieder $\\alpha_{\\text{Anfang}} = z_{00} \\wedge s_{00}$.", zitat) is None
# unter 10 gefalteten Zeichen: nie
assert textkit.finde_zitat("Es gilt x=y hier.", "x = y") is None
def test_jsonx_repariert_zeilenumbruch_in_string():
# rohes mehrzeiliges Zitat (Markup-Zitat-Regel) → Kontrollzeichen-Reparatur
assert jsonx.parse('[{"zitat": "\\\\item Zeile eins\nZeile zwei"}]') == [
{"zitat": "\\item Zeile eins\nZeile zwei"}]
assert jsonx.parse('{"a": "x\n\ny\tz"}') == {"a": "x\n\ny\tz"}
# intaktes JSON bleibt unverändert
assert jsonx.parse('{"a": "x\\nb"}') == {"a": "x\nb"}
def test_finde_zitat_ueber_listenstart():
"""\\begin{itemize} darf kein „itemize"-Residuum in der Faltung lassen."""
text = ("Ein Algorithmus löst das Problem, wenn für alle $x \\in U$ gilt:\n"
"\\begin{itemize}\n\\item $x \\in L$ impliziert $A(x) = 1$,\n"
"\\item $x \\notin L$ impliziert $A(x) = 0$.\n\\end{itemize}")
zitat = ("Ein Algorithmus löst das Problem, wenn für alle x ∈ U gilt: x ∈ L"
" impliziert A(x) = 1, x ∉ L impliziert A(x) = 0.")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_wortalignment_verbalisierung():
"""Stufe 5: Modell verbalisiert Mathe (\\cdot → „mal", \\sqrt → sqrt()) —
auf Wort-Ebene wenige Edits → Treffer trotz Zeichen-Chaos."""
text = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"$2^{o(|V'|)} \\cdot |I|^{O(1)}$ löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
zitat = ("Angenommen es gäbe einen Algorithmus, der VertexCover in "
"2^{o(|V'|)} mal |I|^{O(1)} löst. Durch die Kombination der "
"Reduktionen entsteht ein Widerspruch zur ETH und damit die Aussage.")
span = textkit.finde_zitat(text, zitat)
assert span is not None and text[span[0]:span[1]].startswith("Angenommen")
def test_finde_zitat_wortalignment_paraphrase_bleibt_draussen():
"""Echte Umformulierung: Wörter selbst anders → Wort-Distanz über Schranke."""
text = ("Dann wird geprüft, ob $|X| \\leq k$ gilt und es wird abgelehnt, "
"falls es nicht gilt. Danach wird ein Graph konstruiert, in dem alle "
"Knoten entfernt wurden und die Suche neu beginnt.")
zitat = ("Prüfe, ob |X| ≤ k, sonst ablehnen. Konstruiere den Graphen ohne "
"diese Knoten und starte die Suche von vorn im Verfahren.")
assert textkit.finde_zitat(text, zitat) is None
def test_finde_zitat_wortalignment_negations_guard():
text = ("Die Sprache ist in diesem Modell regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
zitat = ("Die Sprache ist in diesem Modell nicht regulär und wird von einem "
"endlichen Automaten mit wenigen Zuständen ohne Keller akzeptiert.")
assert textkit.finde_zitat(text, zitat) is None

View File

@@ -14,7 +14,7 @@ def _quelle(topic, tmp_path, text, name="q.md"):
def _atom(topic):
return db.insert("atome", topic=topic, titel="T", typ="verfahren",
definition="d", status="neu", braucht=db.j([]))
definition="d", status="neu")
def test_fenster_umgibt_anker(tmp_path):

View File

@@ -184,3 +184,101 @@ async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
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
async def test_stop_cancelt_verwaisten_haupt_task(monkeypatch):
"""Stop cancelt den Wartenden — der ensure_future-Haupt-Task muss MIT
sterben, sonst hält er Slot + Tokens bis zum Timeout (aak: 10 Waisen)."""
import asyncio
import agents
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("orphan")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "inventar"
gestartet = asyncio.Event()
gecancelt = asyncio.Event()
async def langsamer_agent(key, prompt, timeout, **kw):
gestartet.set()
try:
await asyncio.sleep(60)
except asyncio.CancelledError:
gecancelt.set()
raise
return agents.AgentErgebnis(0, "nie", "")
monkeypatch.setattr(agents, "run_agent", langsamer_agent)
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0) # Pfad: return await haupt
task = asyncio.ensure_future(llm._roher_call("k", "p", 10, ctx, "judge", "none"))
await gestartet.wait()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await asyncio.wait_for(gecancelt.wait(), timeout=2) # Waise wurde mitgecancelt
async def test_pause_drainiert_statt_zu_canceln(monkeypatch):
"""ManuellePause in llm.alle: Laufende laufen zu Ende (Ergebnis zählt),
nichts wird gecancelt."""
import asyncio
import llm
fertig = {"langsam": False}
async def schnell_pausiert():
raise llm.ManuellePause("manuell pausiert")
async def langsam():
await asyncio.sleep(0.05)
fertig["langsam"] = True
import pytest
with pytest.raises(llm.ManuellePause):
await llm.alle([schnell_pausiert(), langsam()])
assert fertig["langsam"] is True # NICHT gecancelt — ausgelaufen
async def test_pause_beendet_lauf_als_paused():
"""Pause-Flag → nächster Agent liefert „pausiert" → Lauf endet 'paused';
Start setzt normal fort (Flag wird aufgehoben)."""
import db
import pipeline
from conftest import topic_anlegen
topic = topic_anlegen("pausetest")
run_id = pipeline.lauf_starten(topic)
pipeline.lauf_pausieren(topic) # vor dem ersten Call — deterministisch
await pipeline._laeufe[topic]
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "paused" and "pausiert" in run["grund"]
# Resume: Start hebt die Pause auf, Lauf läuft durch
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
async def test_output_cap_ohne_neuversuch(monkeypatch):
"""stop=max_tokens ist deterministisch — sofort None statt 2 Neuversuche
(der Aufrufer halbiert den Chunk)."""
import agents
import db
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("cap")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "inventar"
aufrufe = {"n": 0}
async def cap_agent(key, prompt, timeout, **kw):
aufrufe["n"] += 1
return agents.AgentErgebnis(1, "", "leere Antwort (stop=max_tokens)",
{"input": 100, "output": 32000})
monkeypatch.setattr(agents, "run_agent", cap_agent)
monkeypatch.setattr(llm, "HEDGE_NACH_S", 0)
res = await llm.call(ctx, stage="extraktion", template="Atom-Extraktion",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res is None and aufrufe["n"] == 1 # genau EIN Versuch
ev = db.one("SELECT status, tok_out FROM events WHERE run_id=? AND stage='extraktion'",
(ctx.run_id,))
assert ev["status"] == "cap" and ev["tok_out"] == 32000

View File

@@ -10,18 +10,18 @@ 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([]))
status="neu")
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
status="neu", braucht=db.j([]))
status="neu")
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
status="neu", braucht=db.j([]))
status="neu")
# 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')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(topic, a, c))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(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')",
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'verwandt')",
(topic, b, a))
inventar._merge(topic, a, b)
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
@@ -39,7 +39,7 @@ async def test_anker_rematch_ohne_llm(tmp_path):
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([]))
status="ohne_anker")
# 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.")
@@ -67,7 +67,7 @@ async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path):
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([]))
status="neu")
db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
@@ -94,7 +94,7 @@ async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch):
def neu_atom():
return db.insert("atome", topic=topic, titel="X", typ="begriff",
definition="d", status="neu", braucht=db.j([]))
definition="d", status="neu")
def panel_mit(votes):
async def _p(ctx, groesse, **kw):
@@ -127,7 +127,7 @@ async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path):
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([]))
status="ohne_anker")
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: {})
@@ -174,14 +174,20 @@ async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
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([]))
status="neu")
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([]))
status="neu")
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"]
def test_titel_kaputt_flaggt_latex():
assert inventar._titel_kaputt("Variablen in $\\alpha_k$")
assert inventar._titel_kaputt("Der \\textsc{VC}-Beweis")
assert not inventar._titel_kaputt("Vertex Cover und Entscheidungsvariante")

View File

@@ -8,7 +8,7 @@ from conftest import topic_anlegen
def _setup(topic):
b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=0, braucht=db.j([]))
status="neu", baustein_id=b, ord=0)
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
inhalt=db.j({"frage": "F1", "antwort": "A1"}))
db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat", # unverifiziert

View File

@@ -1,7 +1,8 @@
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
"""Struktur-Ebene: Band-Schnitt, Judge-Ordnung mit Quellpositions-Prior,
Kapitel — mit synthetischen Atomen."""
import db
import inventar
import fake_agents
import llm
import struktur
from conftest import run_anlegen, topic_anlegen
@@ -10,7 +11,7 @@ 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([]))
soll_id=soll_id, ziel_id=ziel_id)
def _baustein_groessen(topic):
@@ -29,7 +30,7 @@ async def test_band_split_43_atome():
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await struktur._bausteine_schneiden(ctx)
groessen = _baustein_groessen(topic)
assert sum(groessen) == 43
assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME
@@ -51,7 +52,7 @@ async def test_band_messen_konsistent_mit_schnitt():
_atom(topic, f"G{i}", z2, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
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)}
@@ -65,52 +66,10 @@ async def test_einsame_kleingruppe_kein_band():
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
await 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
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)
@@ -119,34 +78,31 @@ async def test_band_split():
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)
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", "zyklus")]
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 Kante);
# Kapitel entstehen danach als kontiguierliche Segmente
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
# Quell-Nähe); 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, "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)
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)
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)
@@ -155,8 +111,79 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
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
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")
@@ -185,14 +212,13 @@ async def test_kapitel_retry_und_fallback(monkeypatch):
async def test_level_kalibrierung(monkeypatch):
import fake_agents
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, braucht=db.j([]))
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, braucht=db.j([]))
level="M", status="neu", ziel_id=ziel)
def judge(prompt):
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
@@ -205,50 +231,6 @@ async def test_level_kalibrierung(monkeypatch):
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
def test_level_konflikt_absenkung():
# v(E) braucht z(S), z braucht w(M) → Kaskade senkt z UND w auf E
topic = topic_anlegen("konflikt")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
def atom(titel, level):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition="d", level=level, status="neu", ziel_id=ziel,
braucht=db.j([]))
v, z, w = atom("V", "E"), atom("Z", "S"), atom("W", "M")
for von, zu in ((v, z), (z, w)):
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, von, zu))
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (z,))["level"] == "E"
assert db.one("SELECT level FROM atome WHERE id=?", (w,))["level"] == "E"
ctx = llm.Kontext(run, topic, "minimax")
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
async def test_braucht_fallback_kompositum():
# braucht-Titel "Approximationsalgorithmus" (Kompositum) trifft weder norm noch
# titel_kern das Atom "Approximativer Algorithmus" (Phrase). Der Fallback-Judge
# legt die Kante; _level_konflikte_loesen senkt die Voraussetzung von M auf E.
topic = topic_anlegen("brfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
a = db.insert("atome", topic=topic, titel="Absolute Güte", typ="begriff",
definition="d", level="E", status="neu", ziel_id=ziel,
braucht=db.j(["Approximationsalgorithmus"]))
b = db.insert("atome", topic=topic, titel="Approximativer Algorithmus", typ="begriff",
definition="d", level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
inventar._kanten_aufloesen(topic) # deterministisch: verfehlt das Kompositum
assert db.query("SELECT * FROM kanten WHERE topic=? AND von_atom=?", (topic, a)) == []
await inventar._braucht_fallback(ctx)
kante = db.one("SELECT * FROM kanten WHERE topic=? AND von_atom=? AND zu_atom=?",
(topic, a, b))
assert kante and kante["art"] == "braucht"
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (b,))["level"] == "E"
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
@@ -258,12 +240,12 @@ async def test_level_split_ordnung_kapitel():
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, braucht=db.j([]))
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, braucht=db.j([]))
level="M", status="neu", soll_id=soll, ziel_id=ziel)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
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)
@@ -273,7 +255,7 @@ async def test_level_split_ordnung_kapitel():
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
def test_kein_merge_ueber_level():
async def test_kein_merge_ueber_level():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
topic = topic_anlegen("levelmerge")
run = run_anlegen(topic)
@@ -282,11 +264,11 @@ def test_kein_merge_ueber_level():
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, 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, braucht=db.j([]))
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, braucht=db.j([]))
level="M", status="neu", soll_id=soll, ziel_id=z2)
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
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"]}

View File

@@ -63,11 +63,18 @@ async def test_import_remappt_auftrag_atom_ids():
db.insert("auftraege", baustein_id=b["id"], art="falsch",
detail=f"Atom {a['id']} widerspricht dem Text")
d = json.loads(json.dumps(transfer.export(topic)))
# Alt-Export simulieren: befunde-Freitext an einer Section
# Alt-Export simulieren: befunde-Freitext an einer Section, braucht-Spalte
# an Atomen, braucht-Kante — alles muss crashfrei toleriert werden
d["sections"][0]["befunde"] = db.j([
f"KRITISCH (luecke): Atom {a['id']} fehlt inhaltlich",
"Langtext, Absatz 3 hat 120 Wörter (Regel: 4090): teilen."]) # → verwerfen
for z in d["atome"]:
z["braucht"] = "[]"
d["kanten"].append({"id": 999, "topic": topic, "von_atom": d["atome"][0]["id"],
"zu_atom": d["atome"][-1]["id"], "art": "braucht",
"status": "aktiv"})
transfer.importieren(d)
assert db.query("SELECT * FROM kanten WHERE art='braucht'") == []
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
zeilen = db.query("SELECT au.* FROM auftraege au JOIN bausteine b"
" ON b.id=au.baustein_id WHERE b.topic=?", (topic,))