363 lines
18 KiB
Python
363 lines
18 KiB
Python
"""Ebene 2: Quellen-Referenz-Guard — Karten müssen ohne den Quelltext
|
||
funktionieren (Lauf 8: 17 verifizierte Karten fragten „was steht im Beleg")."""
|
||
|
||
import artefakte
|
||
import db
|
||
import llm
|
||
from conftest import run_anlegen, topic_anlegen
|
||
|
||
|
||
def test_referenz_muster_trifft_nur_referenzen():
|
||
schlecht = [
|
||
{"frage": "Welchen Namen trägt die Technik, die im Beleg erwähnt wird?",
|
||
"antwort": "k-Enumeration.", "text": ""},
|
||
{"frage": "Was ist MAX-3-SAT?",
|
||
"antwort": "Aus dem Beleg geht nur hervor, dass es in Hausaufgabe 13.1 vorkommt.",
|
||
"text": ""},
|
||
{"frage": "Welche Aufgabe wird in Aufgabe 1 gestellt?", "antwort": "x", "text": ""},
|
||
{"frage": "Wie groß ist |V'|?", "antwort": "Laut Musterlösung genau |V|.", "text": ""},
|
||
]
|
||
gut = [ # Fachwörter dürfen NICHT matchen (generisch bleiben)
|
||
{"frage": "Was ist eine aussagenlogische Belegung?",
|
||
"antwort": "Eine Zuordnung von Wahrheitswerten zu Variablen.", "text": ""},
|
||
{"frage": "Was verbindet ein Fluss mit Quelle und Senke?",
|
||
"antwort": "Er belegt jede Kante mit einem Wert unter der Kapazität.", "text": ""},
|
||
]
|
||
assert all(artefakte._referenziert_quelle(k) for k in schlecht)
|
||
assert not any(artefakte._referenziert_quelle(k) for k in gut)
|
||
|
||
|
||
def _voll_versorgen(atom_id: int) -> None:
|
||
"""2 Flashcards + 1 Beispiel — Atom ohne Generate-Bedarf."""
|
||
for i in range(artefakte.FC_ZIEL):
|
||
db.insert("artefakte", atom_id=atom_id, typ="flashcard", status="verifiziert",
|
||
inhalt=db.j({"frage": f"F{i}?", "antwort": "A"}))
|
||
db.insert("artefakte", atom_id=atom_id, typ="beispiel", status="verifiziert",
|
||
inhalt=db.j({"form": "text", "text": "B"}))
|
||
|
||
|
||
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)
|
||
b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d",
|
||
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"}))
|
||
# b: voll versorgt (2 Karten + Beispiel) → kein Bedarf
|
||
_voll_versorgen(b)
|
||
ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c]
|
||
assert ids == [a]
|
||
|
||
|
||
async def test_nachfuellen_bei_einer_lebenden_flashcard():
|
||
"""1 überlebende Karte blockierte früher die zweite für immer (Ziel: 2)."""
|
||
topic = topic_anlegen("fcauffuell")
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
|
||
inhalt=db.j({"frage": "F?", "antwort": "A"}))
|
||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
|
||
inhalt=db.j({"form": "text", "text": "B"}))
|
||
assert artefakte._gen_bedarf(a)
|
||
run = run_anlegen(topic)
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||
await artefakte._generieren(ctx, [atom]) # Fake liefert 2 Karten + Beispiel
|
||
fc = artefakte._lebende(a, "flashcard")
|
||
assert len(fc) == artefakte.FC_ZIEL # Guard nimmt nur die EINE fehlende
|
||
assert len(artefakte._lebende(a, "beispiel")) == 1 # kein zweites Beispiel
|
||
|
||
|
||
async def test_beispiel_refill_nach_verwurf():
|
||
"""Ein verworfenes Beispiel wurde nie ersetzt — jetzt Pflicht je Atom."""
|
||
topic = topic_anlegen("bsprefill")
|
||
a = db.insert("atome", topic=topic, titel="X", typ="verfahren", definition="d",
|
||
status="neu", soll_id=1)
|
||
for i in range(artefakte.FC_ZIEL):
|
||
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
|
||
inhalt=db.j({"frage": f"F{i}?", "antwort": "A"}))
|
||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
|
||
inhalt=db.j({"form": "text", "text": "kaputt"}))
|
||
assert artefakte._gen_bedarf(a)
|
||
run = run_anlegen(topic)
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
await artefakte._generieren(ctx, [db.one("SELECT * FROM atome WHERE id=?", (a,))])
|
||
assert len(artefakte._lebende(a, "beispiel")) == 1
|
||
assert len(artefakte._lebende(a, "flashcard")) == artefakte.FC_ZIEL
|
||
|
||
|
||
def test_cap_stoppt_nachfuellen():
|
||
"""Ab NACHFUELL_CAP × Ziel Verwürfen gibt das Atom auf (Anti-Churn)."""
|
||
from config import NACHFUELL_CAP
|
||
topic = topic_anlegen("cap")
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
_voll_versorgen(a)
|
||
for _ in range(NACHFUELL_CAP * 1): # Beispiel-Ziel = 1
|
||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
|
||
inhalt=db.j({"form": "text", "text": "x"}))
|
||
# lebendes Beispiel löschen → fehlt, aber aufgegeben
|
||
db.execute("DELETE FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||
" AND status='verifiziert'", (a,))
|
||
assert artefakte._aufgegeben(a, "beispiel")
|
||
assert not artefakte._gen_bedarf(a)
|
||
|
||
|
||
async def test_reverify_ausfall_laesst_kandidat(monkeypatch):
|
||
"""Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen."""
|
||
import fake_agents
|
||
topic = topic_anlegen("reverify")
|
||
run = run_anlegen(topic)
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
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
|
||
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Fix",
|
||
lambda p: {"frage": "Was ist X?", "antwort": "Y", "text": ""})
|
||
monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Verify", lambda p: [])
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
await artefakte._fixen(ctx, db.one("SELECT * FROM artefakte WHERE id=?", (k,)),
|
||
["mangel"], {"id": a})
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "kandidat"
|
||
|
||
|
||
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")
|
||
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")
|
||
ctx.ebene = "artefakte"
|
||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||
await artefakte._verifizieren(ctx, [atom]) # Guard greift VOR dem Panel
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
|
||
# verworfene zählen nicht als versorgt: Repair generiert für das Atom nach
|
||
chunks = artefakte._chunks(topic, nur_ohne=True)
|
||
assert [x["id"] for c in chunks for x in c] == [a]
|
||
|
||
|
||
def _paar_einfuegen(atom_id: int, i: int, status_wahr: str, status_falsch: str) -> tuple[int, int]:
|
||
paar = f"{atom_id}-{i}"
|
||
w = db.insert("artefakte", atom_id=atom_id, typ="aussage", status=status_wahr,
|
||
inhalt=db.j({"text": f"wahr {i}", "wahr": True, "paar": paar,
|
||
"erklaerung": "e"}))
|
||
f = db.insert("artefakte", atom_id=atom_id, typ="aussage", status=status_falsch,
|
||
inhalt=db.j({"text": f"falsch {i}", "wahr": False, "paar": paar,
|
||
"erklaerung": "e"}))
|
||
return w, f
|
||
|
||
|
||
def test_paare_bereinigen_verwirft_restseite():
|
||
topic = topic_anlegen("paarrest")
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
w1, f1 = _paar_einfuegen(a, 0, "verifiziert", "verworfen") # einseitig → fällt
|
||
w2, f2 = _paar_einfuegen(a, 1, "verifiziert", "verifiziert") # komplett → bleibt
|
||
artefakte._paare_bereinigen([a])
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (w1,))["status"] == "verworfen"
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (w2,))["status"] == "verifiziert"
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (f2,))["status"] == "verifiziert"
|
||
|
||
|
||
def test_paare_bereinigen_verwirft_deformierte_gruppe():
|
||
"""Gruppen ohne {wahr,falsch}-Seiten fallen KOMPLETT — auch 2 gleiche Seiten
|
||
ohne paar-Key (Altlast des fehlenden Typ-Filters in _verifizieren)."""
|
||
topic = topic_anlegen("paardeform")
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
k1 = db.insert("artefakte", atom_id=a, typ="aussage", status="verifiziert",
|
||
inhalt=db.j({"text": "f1", "wahr": False})) # kein paar-Key
|
||
k2 = db.insert("artefakte", atom_id=a, typ="aussage", status="verifiziert",
|
||
inhalt=db.j({"text": "f2", "wahr": False}))
|
||
artefakte._paare_bereinigen([a])
|
||
for k in (k1, k2):
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
|
||
|
||
|
||
async def test_verifizieren_ignoriert_aussage_kandidaten():
|
||
"""Aussage-Kandidaten gehören NUR ins Aussagen-Panel — das Flashcard-Verify
|
||
überschrieb ihren Inhalt sonst mit dem frage/antwort-Schema (leere Hülle)."""
|
||
topic = topic_anlegen("typfilter")
|
||
run = run_anlegen(topic)
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
k = db.insert("artefakte", atom_id=a, typ="aussage", status="kandidat",
|
||
inhalt=db.j({"text": "t", "wahr": True, "paar": f"{a}-0",
|
||
"erklaerung": "t"}))
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||
await artefakte._verifizieren(ctx, [atom])
|
||
zeile = db.one("SELECT * FROM artefakte WHERE id=?", (k,))
|
||
assert zeile["status"] == "kandidat" # unangetastet
|
||
assert db.uj(zeile["inhalt"])["paar"] == f"{a}-0" # Inhalt intakt
|
||
|
||
|
||
async def test_aussagen_verify_verwirft_paarweise(monkeypatch):
|
||
"""Kippt eine Seite im Panel, fällt auch die frisch bestätigte Partnerseite."""
|
||
import fake_agents
|
||
topic = topic_anlegen("paarvote")
|
||
run = run_anlegen(topic)
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
w, f = _paar_einfuegen(a, 0, "kandidat", "kandidat")
|
||
|
||
def verify(prompt):
|
||
import re
|
||
ids = [int(m) for m in re.findall(r"AUSSAGE (\d+) \[", prompt)]
|
||
return [{"aussage": i, "ok": i != f} for i in ids] # falsche Seite fällt
|
||
|
||
monkeypatch.setitem(fake_agents._HANDLER, "Aussagen-Verify", verify)
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||
await artefakte._aussagen_verifizieren(ctx, [atom])
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (w,))["status"] == "verworfen"
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (f,))["status"] == "verworfen"
|
||
|
||
|
||
async def test_aussagen_nachfuellen_ohne_paar_kollision():
|
||
"""Neue Paare setzen den Index HINTER allen bestehenden Zeilen fort."""
|
||
from config import AUSSAGEN_JE_ATOM
|
||
topic = topic_anlegen("paaridx")
|
||
run = run_anlegen(topic)
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
_paar_einfuegen(a, 0, "verworfen", "verworfen") # alter Index 0 bleibt belegt
|
||
_paar_einfuegen(a, 1, "verifiziert", "verifiziert")
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||
await artefakte._aussagen_generieren(ctx, [atom]) # Bedarf: 1 Paar
|
||
lebend = artefakte._lebende(a, "aussage")
|
||
paare = {db.uj(k["inhalt"])["paar"] for k in lebend}
|
||
assert f"{a}-0" not in paare # kein Recycling des alten Keys
|
||
komplett, einseitig, _ = artefakte._paar_lage(a)
|
||
assert (komplett, einseitig) == (AUSSAGEN_JE_ATOM, 0)
|
||
|
||
|
||
def test_messen_meldet_vollstaendigkeit_und_cap():
|
||
from config import NACHFUELL_CAP
|
||
topic = topic_anlegen("messneu")
|
||
run = run_anlegen(topic)
|
||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||
status="neu", soll_id=1)
|
||
db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert",
|
||
inhalt=db.j({"frage": "F?", "antwort": "A"})) # 1 von 2
|
||
_paar_einfuegen(a, 0, "verifiziert", "verworfen") # einseitig
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
ctx.ebene = "artefakte"
|
||
arten = {b["art"] for b in artefakte.messen(ctx)}
|
||
assert {"flashcard_unvollstaendig", "atom_ohne_beispiel",
|
||
"paar_unvollstaendig"} <= arten
|
||
# Cap: genug Beispiel-Verwürfe → aufgegeben statt fehlt
|
||
for _ in range(NACHFUELL_CAP):
|
||
db.insert("artefakte", atom_id=a, typ="beispiel", status="verworfen",
|
||
inhalt=db.j({"form": "text", "text": "x"}))
|
||
arten = {b["art"] for b in artefakte.messen(ctx)}
|
||
assert "artefakt_aufgegeben" in arten and "atom_ohne_beispiel" not in arten
|
||
|
||
|
||
async def test_reparieren_false_bei_nur_aufgegeben():
|
||
topic = topic_anlegen("repfalse")
|
||
run = run_anlegen(topic)
|
||
ctx = llm.Kontext(run, topic, "minimax")
|
||
befunde = [{"art": "artefakt_aufgegeben", "item": "1-beispiel", "detail": "X"},
|
||
{"art": "artefakt_unentschieden", "item": "2", "detail": "Y"}]
|
||
assert await artefakte.reparieren(ctx, befunde) is False
|
||
befunde.append({"art": "atom_ohne_beispiel", "item": "1", "detail": "X"})
|
||
assert await artefakte.reparieren(ctx, befunde) is True
|
||
|
||
|
||
def test_tabelle_struktur_check():
|
||
# wohlgeformt
|
||
ok = "| A | B |\n|---|---|\n| 1 | 2 |"
|
||
assert artefakte._tabelle_ok(ok)
|
||
# keine Datenzeile / kein Trenner / inkonsistente Spalten
|
||
assert not artefakte._tabelle_ok("| A | B |\n|---|---|")
|
||
assert not artefakte._tabelle_ok("A B\n1 2\n3 4")
|
||
assert not artefakte._tabelle_ok("| A | B |\n|---|---|\n| 1 |")
|
||
|
||
|
||
def test_referenziert_quelle_ignoriert_code():
|
||
# „aufgabe 3" in Code/Kommentar darf NICHT als Quellen-Referenz matchen
|
||
inhalt = {"form": "code", "text": "", "code": "# aufgabe 3: sum\nprint(sum([1,2]))",
|
||
"sprache": "python", "tabelle": ""}
|
||
assert not artefakte._referenziert_quelle(inhalt)
|
||
# in der Prosa (text) matcht es weiterhin
|
||
assert artefakte._referenziert_quelle({"text": "siehe aufgabe 3", "frage": "", "antwort": ""})
|
||
|
||
|
||
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")
|
||
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",
|
||
inhalt=db.j({"form": "tabelle", "tabelle": "kaputt ohne pipes"}))
|
||
artefakte._formen_gate(topic)
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (gut,))["status"] == "kandidat"
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (schlecht,))["status"] == "verworfen"
|
||
|
||
|
||
def test_beispiel_einsetzen_tabelle():
|
||
import guide
|
||
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)
|
||
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}))
|
||
lang = f"Text.\n\n<!-- beispiel: {art} -->\n\nMehr."
|
||
out = guide._beispiel_einsetzen(b, lang)
|
||
assert md in out and "<!-- beispiel:" not in out
|
||
# toter Marker verschwindet
|
||
assert "<!-- beispiel:" not in guide._beispiel_einsetzen(b, "x\n<!-- beispiel: 999 -->\ny")
|
||
|
||
|
||
def test_code_syntax_gate():
|
||
assert artefakte._code_ok("python", "x = sum([1, 2])\nprint(x)")
|
||
assert not artefakte._code_ok("python", "def f(: pass") # SyntaxError
|
||
assert artefakte._code_ok("javascript", "const x = [1,2].map(v => v*2)")
|
||
assert not artefakte._code_ok("javascript", "const x = ;;;(")
|
||
assert not artefakte._code_ok("python", "") # leer
|
||
assert artefakte._code_ok("ruby", "puts 1") # unbekannt → kein 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")
|
||
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",
|
||
inhalt=db.j({"form": "code", "sprache": "python", "code": "def f(: pass"}))
|
||
artefakte._formen_gate(topic)
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (gut,))["status"] == "kandidat"
|
||
assert db.one("SELECT status FROM artefakte WHERE id=?", (schlecht,))["status"] == "verworfen"
|
||
|
||
|
||
async def test_guide_messen_toter_beispiel_marker():
|
||
import guide
|
||
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)
|
||
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)
|
||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||
ctx.ebene = "guide"
|
||
assert "beispiel_marker_tot" in {x["art"] for x in guide.messen(ctx)}
|