"""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\n\nMehr." out = guide._beispiel_einsetzen(b, lang) assert md in out and "\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"\n\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)}