This commit is contained in:
team3
2026-07-13 06:07:09 +02:00
parent 8926f87185
commit 32d7ad9ea1
15 changed files with 552 additions and 115 deletions

186
tests/test_auftraege.py Normal file
View File

@@ -0,0 +1,186 @@
"""Auftrags-Lebenszyklus (Guide): eingefrorener Wortlaut, Urteil je Runde,
Fehlalarm-Tod (einstimmig), Runden-Cap → eskaliert, Fund nur bei leerer Liste."""
import db
import fake_agents
import guide
import llm
import pytest
from config import AUFTRAG_RUNDEN_MAX
from conftest import run_anlegen, topic_anlegen
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([]))
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")
return db.one("SELECT * FROM bausteine WHERE id=?", (b_id,))
def _ctx(topic):
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
return ctx
def test_migration_befunde_json_zu_auftraegen():
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE sections(baustein_id INTEGER PRIMARY KEY,"
" stage TEXT NOT NULL DEFAULT 'writer', text_kompakt TEXT DEFAULT '',"
" text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',"
" qa_hash TEXT DEFAULT '', fix_versuche INTEGER NOT NULL DEFAULT 0)")
con.execute("INSERT INTO sections(baustein_id, befunde) VALUES(7, ?)", (db.j([
"KRITISCH (falsch): Zahl stimmt nicht",
"KRITISCH (luecke): Atom 5 fehlt",
"KRITISCH (fachlich_falsch): Claim X",
"KRITISCH (marker_fehlend): Marker 5 fehlt", # messen-Art → verwerfen
"KRITISCH: Marker für Atom 5 fehlt — exakt einfügen.", # det → verwerfen
"Langtext, Absatz 2 hat 130 Wörter (Regel: 4090): teilen."]),))
db._init_schema(con)
zeilen = con.execute("SELECT art, detail, quelle FROM auftraege").fetchall()
assert sorted(z[0] for z in zeilen) == ["falsch", "falsch", "luecke"]
assert ("falsch", "Claim X", "qa") in zeilen # fachlich_falsch → falsch, quelle qa
# Spalte weg + idempotent (zweiter Lauf legt nichts doppelt an)
assert not any(s[1] == "befunde" for s in con.execute("PRAGMA table_info(sections)"))
db._init_schema(con)
assert con.execute("SELECT COUNT(*) FROM auftraege").fetchone()[0] == 3
async def test_urteil_statusuebergaenge_und_keine_neuen_funde(monkeypatch):
topic = topic_anlegen("urteil")
b = _baustein(topic)
a1 = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D1")
a2 = db.insert("auftraege", baustein_id=b["id"], art="stil", detail="D2")
a3 = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D3")
urteile = {1: "behoben", 2: "kein_mangel", 3: "faktenbasis"}
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": i, "urteil": u} for i, u in urteile.items()])
# Fund-Modus darf bei offenen Aufträgen NIE laufen
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer",
lambda p: pytest.fail("Fund-Modus trotz offener Aufträge"))
await guide._auftraege_urteilen(_ctx(topic), b,
db.one("SELECT * FROM sections WHERE baustein_id=?",
(b["id"],)),
guide._offene_auftraege(b["id"]))
stat = {r["id"]: r["status"] for r in db.query("SELECT * FROM auftraege")}
assert stat == {a1: "behoben", a2: "kein_mangel", a3: "eskaliert"}
async def test_kein_mangel_nur_einstimmig(monkeypatch):
topic = topic_anlegen("split")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D")
votes = iter(["kein_mangel", "offen", "offen"]) # Stimme 1 ≠ Stimme 2 (+Ersatz)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": next(votes)}])
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"] == "offen" and r["runden"] == 1 # uneins → offen, Runde zählt
async def test_runden_cap_eskaliert_und_wird_nicht_geroutet(monkeypatch):
topic = topic_anlegen("cap")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="zäh",
runden=AUFTRAG_RUNDEN_MAX - 1)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
ctx = _ctx(topic)
await guide._auftraege_urteilen(ctx, b,
db.one("SELECT * FROM sections WHERE baustein_id=?",
(b["id"],)),
guide._offene_auftraege(b["id"]))
assert db.one("SELECT status FROM auftraege WHERE id=?", (au,))["status"] == "eskaliert"
# fakten_konflikt wird in reparieren nicht geroutet → kein Fortschritt = False
db.update("sections", "baustein_id", b["id"], stage="done")
bewegt = await guide.reparieren(ctx, [{"art": "fakten_konflikt",
"item": str(b["id"]), "detail": "zäh"}])
assert bewegt is False
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
(b["id"],))["stage"] == "done"
async def test_fund_dedup_gegen_kein_mangel(monkeypatch):
"""Ein totgestimmter Fehlalarm darf nicht als frische Zeile auferstehen."""
topic = topic_anlegen("wiedergaenger")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Fehlalarm X",
status="kein_mangel")
gesehen = {}
def finder(prompt):
gesehen["negativ"] = "Fehlalarm X" in prompt
return {"befunde": [{"art": "falsch", "detail": "Fehlalarm X"},
{"art": "luecke", "detail": "Neu Y"}]}
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer", finder)
await guide._auftraege_finden(_ctx(topic), b,
db.one("SELECT * FROM sections WHERE baustein_id=?",
(b["id"],)))
assert gesehen["negativ"] # Negativ-Liste stand im Prompt
offen = guide._offene_auftraege(b["id"])
assert [a["detail"] for a in offen] == ["Neu Y"]
async def test_fix_stil_optimistisch_falsch_via_urteil(monkeypatch):
topic = topic_anlegen("fixarten")
b = _baustein(topic)
stil = db.insert("auftraege", baustein_id=b["id"], art="stil", detail="glätten")
falsch = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Zahl prüfen")
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "- p", "lang": sec["text_lang"] + " neu"})
# Re-Check-Urteil lässt den falsch-Auftrag OFFEN → nicht optimistisch geschlossen
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
db.update("sections", "baustein_id", b["id"], stage="fix")
assert await guide._stage_fix(_ctx(topic), b) == "done"
assert db.one("SELECT status FROM auftraege WHERE id=?", (stil,))["status"] == "behoben"
assert db.one("SELECT status FROM auftraege WHERE id=?", (falsch,))["status"] == "offen"
def test_qa_auftrag_dedup_und_eskalation():
topic = topic_anlegen("qapfad")
b = _baustein(topic)
guide._qa_auftrag(b["id"], "Claim A")
guide._qa_auftrag(b["id"], "Claim A") # exakter Dedup
zeilen = db.query("SELECT * FROM auftraege WHERE baustein_id=?", (b["id"],))
assert len(zeilen) == 1 and zeilen[0]["quelle"] == "qa"
# Re-Bestätigung auf kein_mangel-Zeile: Richter uneins → eskaliert
db.update("auftraege", "id", zeilen[0]["id"], status="kein_mangel")
guide._qa_auftrag(b["id"], "Claim A")
assert db.one("SELECT status FROM auftraege WHERE id=?",
(zeilen[0]["id"],))["status"] == "eskaliert"
async def test_urteilsrunde_ohne_text_ist_bewegt(monkeypatch):
"""Statusübergänge zählen als Fortschritt — sonst bricht auto_loop reine
Urteilsrunden als Stillstand ab, obwohl Aufträge geschlossen wurden."""
topic = topic_anlegen("bewegt2")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D")
db.update("sections", "baustein_id", b["id"], stage="done")
# Fake-Urteil: behoben (Default-Handler); Text bleibt unverändert
bewegt = await guide.reparieren(_ctx(topic), [
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}])
assert bewegt is True
assert db.query("SELECT * FROM auftraege WHERE status='offen'") == []
async def test_freeze_urteilt_weiter(monkeypatch):
"""Fix-Cap friert nur Stil-Fixes ein — Aufträge werden trotzdem geurteilt."""
topic = topic_anlegen("freeze")
b = _baustein(topic)
db.update("sections", "baustein_id", b["id"],
stage="done", fix_versuche=guide.FIX_MAX_VERSUCHE)
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D")
bewegt = await guide.reparieren(_ctx(topic), [
{"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

View File

@@ -220,7 +220,7 @@ def test_det_auftraege_neue_checks():
assert "Vertröstungs-Floskel" in qa_text
async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch):
import db
import fake_agents
import guide
@@ -232,8 +232,8 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
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([]))
db.insert("sections", baustein_id=b_id, stage="fix",
text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"]))
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
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "", "lang": "kein Marker"})
@@ -242,7 +242,8 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,)))
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,))
assert stage == "done" and sec["text_lang"] == "alt"
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
# Auftrag bleibt offen und sichtbar — kein unsichtbar gescheiterter Fix
assert db.one("SELECT status FROM auftraege WHERE id=?", (au,))["status"] == "offen"
def test_marker_titel_erzeugen_keine_auftraege():
@@ -281,8 +282,9 @@ def test_persistieren_dedup_mit_detail():
assert ("A", "offen") in stat and ("B", "repariert") in stat
async def test_fix_offen_in_messen():
"""Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr."""
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."""
import db
import guide
import llm
@@ -293,11 +295,17 @@ async def test_fix_offen_in_messen():
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang,
text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"]))
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")
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld)
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
assert "fix_offen" in {b["art"] for b in guide.messen(ctx)}
arten = [b["art"] for b in guide.messen(ctx)]
assert arten.count("auftrag_offen") == 1
assert arten.count("fakten_konflikt") == 1
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
@@ -315,7 +323,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
status="neu", baustein_id=b_id, braucht=db.j([]))
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", befunde=db.j(["Behebe (laenge): kürzen"]))
text_kompakt="- p")
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "- p", "lang": lang}) # identisch
ctx = llm.Kontext(run, topic, "minimax")
@@ -408,7 +416,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
status="neu", baustein_id=b, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p",
befunde=db.j([]), fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
bewegt = await guide.reparieren(

View File

@@ -161,3 +161,33 @@ async def test_thema_braucht_zwei_quellen(monkeypatch):
assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt
assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"}
def test_tex_normalisieren():
tex = ("\\documentclass{article}\n\\usepackage{tikz}\n\\begin{document}\n"
"Einf\\\"uhrung: Gr\\\"o\\ss e $n$, \\\"Ubung hei\\ss t \\emph{T}, "
"\\\"{a} bleibt $\\Sigma$\n\\end{document}\n")
ergebnis = korpus._tex_normalisieren(tex)
assert "documentclass" not in ergebnis and "end{document}" not in ergebnis
assert "Einführung: Größe $n$, Übung heißt \\emph{T}, ä bleibt $\\Sigma$" in ergebnis
# ohne Präambel/Escapes: unverändert
assert korpus._tex_normalisieren("Plain ü Text $x$") == "Plain ü Text $x$"
# \ss als Präfix eines anderen Kontrollworts bleibt stehen
assert korpus._tex_normalisieren("\\ssname bleibt") == "\\ssname bleibt"
async def test_uni_quellen_vorrang_tex(tmp_path):
topic = topic_anlegen("k-tex", art="uni")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ordner = korpus.TOPICS_DIR / topic
ordner.mkdir(parents=True)
(ordner / "a.tex").write_text("\\begin{document}Gr\\\"o\\ss e A\\end{document}")
(ordner / "a.txt").write_text("Groesse A kaputt")
(ordner / "a.pdf").write_bytes(b"%PDF-1.4 egal")
(ordner / "b.txt").write_text("Nur als txt da")
assert await korpus._uni_quellen(ctx) == 2 # a.tex + b.txt, Rest übersprungen
quellen = db.query("SELECT titel, snapshot FROM quellen WHERE topic=? AND art='datei'",
(topic,))
assert {q["titel"] for q in quellen} == {"a.tex", "b.txt"}
snap = next(q for q in quellen if q["titel"] == "a.tex")
assert "Größe A" in korpus.quelltext(snap)

View File

@@ -17,8 +17,8 @@ async def test_export_import_roundtrip():
await pipeline._laeufe[topic]
tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "runs",
"befunde", "events", "leitner")
"lernziele", "bausteine", "kapitel", "sections", "auftraege",
"runs", "befunde", "events", "leitner")
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen}
md_vorher = guide.guide_markdown(topic)
@@ -51,24 +51,30 @@ async def test_export_import_roundtrip():
assert int(m) in atom_ids, f"toter Marker {m}"
async def test_import_remappt_befunde_atom_ids():
"""Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie
auf die neu vergebenen IDs zeigen, nicht auf tote."""
topic = topic_anlegen("befunde-remap", art="thema")
async def test_import_remappt_auftrag_atom_ids():
"""Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie auf die neu
vergebenen IDs zeigen. Alt-Exporte mit befunde-Key: KRITISCH-Arten werden
Zeilen, Rest fliegt (wie die Schema-Migration)."""
topic = topic_anlegen("auftrag-remap", art="thema")
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,))
a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],))
db.update("sections", "baustein_id", b["id"],
befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."]))
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
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
transfer.importieren(d)
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,))
assert {z["art"] for z in zeilen} == {"falsch", "luecke"} # det-Zeile verworfen
treffer = 0
for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
" WHERE b.topic=?", (topic,)):
for auftrag in db.uj(s["befunde"]):
for m in re.findall(r"Atom (\d+)", str(auftrag)):
assert int(m) in atom_ids, f"toter Befund-Marker {m}"
treffer += 1
assert treffer >= 1 # der eingefügte Auftrag wurde geprüft
for z in zeilen:
for m in re.findall(r"Atom (\d+)", z["detail"]):
assert int(m) in atom_ids, f"toter Auftrag-Verweis {m}"
treffer += 1
assert treffer == 2