"""Ebene 0, Konsens: Vollständigkeits-Invariante (kein Kandidat verschwindet still), explizite Ablehnung, uni-Beleg-Regel (1 Beleg reicht).""" import re import db import fake_agents import korpus import llm from conftest import run_anlegen, topic_anlegen def _seed(art="uni", n_quellen=1): topic = topic_anlegen("k-test", art=art) quellen = [db.insert("quellen", topic=topic, art="datei", titel=f"q{i}.txt", snapshot=f"q{i}.txt", hash=f"h{i}", status="extrahiert", rolle="stoff") for i in range(n_quellen)] ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") ctx.ebene = "korpus" return topic, quellen, ctx def _kandidat(topic, punkt, quelle): return db.insert("soll", topic=topic, punkt=punkt, status="kandidat", belege=db.j([{"quelle": quelle, "zitat": f"Zitat {punkt}"}])) def _ids(prompt): return [int(i) for i in re.findall(r"^(\d+): ", prompt.split("KANDIDATEN:")[1], re.MULTILINE)] async def test_nachrunde_ordnet_vergessene_zu(monkeypatch): topic, (q,), ctx = _seed() a = _kandidat(topic, "Alpha", q) b = _kandidat(topic, "Beta", q) c = _kandidat(topic, "Gamma", q) def judge(prompt): if "NACHRUNDE" in prompt: # nur die fehlende id wird angeboten assert _ids(prompt) == [c] and "Alpha" in prompt return [{"punkt": "Alpha", "kandidaten": [c]}] # wörtlich → Merge return [{"punkt": "Alpha", "kandidaten": [a]}, {"punkt": "Beta", "kandidaten": [b]}] # c stillschweigend vergessen monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) assert await korpus._konsens(ctx) == 2 # uni: 1 Quelle reicht als Beleg alpha = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'" " AND punkt='Alpha'", (topic,)) assert len(db.uj(alpha["belege"])) == 2 # Gamma-Beleg in Alpha aufgegangen for i in (a, b, c): assert db.one("SELECT status FROM soll WHERE id=?", (i,))["status"] == "gefaltet" assert korpus.messen(ctx) == [] async def test_ablehnung_ist_explizit(monkeypatch): topic, (q,), ctx = _seed() a = _kandidat(topic, "Alpha", q) m = _kandidat(topic, "Einführung ins Thema", q) def judge(prompt): return [{"punkt": "Alpha", "kandidaten": [a]}, {"abgelehnt": True, "grund": "Meta-Überschrift", "kandidaten": [m]}] monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) assert await korpus._konsens(ctx) == 1 assert db.one("SELECT status FROM soll WHERE id=?", (m,))["status"] == "abgelehnt" assert korpus.messen(ctx) == [] # abgelehnt ist erledigt, kein Befund async def test_hartnaeckig_vergessene_wird_befund(monkeypatch): topic, (q,), ctx = _seed() a = _kandidat(topic, "Alpha", q) c = _kandidat(topic, "Gamma", q) def judge(prompt): if "NACHRUNDE" in prompt: return [] # Judge verweigert die Zuordnung dauerhaft return [{"punkt": "Alpha", "kandidaten": [a]}] monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) await korpus._konsens(ctx) assert db.one("SELECT status FROM soll WHERE id=?", (c,))["status"] == "kandidat" befunde = korpus.messen(ctx) assert [b["art"] for b in befunde] == ["soll_kandidat_offen"] assert befunde[0]["item"] == str(c) async def test_konsens_erhaelt_nachbelege_geprueft_und_id(monkeypatch): """Neu-Konsens darf bestätigte Zeilen nicht löschen: id, geprueft-Cache und nachgesuchte Belege bleiben, Belege werden vereint.""" topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2) best = db.insert("soll", topic=topic, punkt="Alpha", status="bestaetigt", belege=db.j([{"quelle": q1, "zitat": "Zitat Alpha"}]), geprueft=db.j([q2])) a1 = _kandidat(topic, "Alpha", q1) a2 = _kandidat(topic, "Alpha", q2) def judge(prompt): if "NACHRUNDE" in prompt: return [] return [{"punkt": "Alpha", "kandidaten": [a1, a2]}] monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) assert await korpus._konsens(ctx) == 1 row = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,)) assert row["id"] == best # id stabil (kein DELETE+INSERT) assert db.uj(row["geprueft"]) == [q2] # geprueft-Cache erhalten assert {b["quelle"] for b in db.uj(row["belege"])} == {q1, q2} # Belege-Union async def test_soll_resume_ohne_doppelkandidaten(monkeypatch, tmp_path): """Abgebrochener Extraktions-Pass: der Resume darf keine Doppel-Kandidaten legen.""" topic = topic_anlegen("resume-soll", art="uni") snap = tmp_path / "q.md" snap.write_text("Der wichtige Punkt steht hier im Text.", encoding="utf-8") q = db.insert("quellen", topic=topic, art="datei", titel="q", snapshot=str(snap), hash="h", status="neu") db.insert("soll", topic=topic, punkt="Der wichtige Punkt", status="kandidat", belege=db.j([{"quelle": q, "zitat": "Der wichtige Punkt"}])) ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") ctx.ebene = "korpus" monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", lambda p: [{"punkt": "Der wichtige Punkt", "zitat": "Der wichtige Punkt steht hier"}]) await korpus._soll_extrahieren(ctx) kand = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'", (topic,)) assert len(kand) == 1 # Leiche gelöscht, genau ein Kandidat async def test_uni_soll_leer_faellt_zu(monkeypatch, tmp_path): """uni-Topic ohne bestätigten Punkt: kein Auto-Freeze, Gate blockt E1.""" topic = topic_anlegen("unileer", art="uni") snap = tmp_path / "q.md" snap.write_text("Nur Fülltext ohne lernbaren Punkt.", encoding="utf-8") db.insert("quellen", topic=topic, art="datei", titel="q", snapshot=str(snap), hash="h", status="neu") ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") ctx.ebene = "korpus" monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", lambda p: []) monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", lambda p: []) await korpus.bauen(ctx) assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "korpus" assert korpus.gate(ctx) == "kein bestätigter Soll-Punkt" async def test_thema_braucht_zwei_quellen(monkeypatch): topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2) x1 = _kandidat(topic, "X", q1) x2 = _kandidat(topic, "X (Synonym)", q2) y = _kandidat(topic, "Y", q1) def judge(prompt): if "NACHRUNDE" in prompt: return [] return [{"punkt": "X", "kandidaten": [x1, x2]}, {"punkt": "Y", "kandidaten": [y]}] monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) 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" def test_tex_makro_expansion(): """Parameterlose Präambel-Makros werden im Rumpf ersetzt (aak: \\PP → P).""" tex = ("\\newcommand{\\PP}{\\mathrm{P}}\n\\newcommand{\\P}{\\mathbf{P}}\n" "\\newcommand{\\half}[1]{\\frac{#1}{2}}\n\\begin{document}\n" "Klasse $\\PP$ und $\\P$ sowie $\\PP=\\NP$? Und \\half{x} bleibt.\n" "\\end{document}\n") e = korpus._tex_normalisieren(tex) assert "$\\mathrm{P}$" in e # \PP expandiert assert "$\\mathbf{P}$" in e # \P getrennt (längster Name zuerst) assert "\\mathrm{P}=\\NP" in e # \NP unbekannt → bleibt assert "\\half{x}" in e # Makro mit Parameter: unangetastet # ohne \begin{document} keine Expansion (kein Präambel-Kontext) assert korpus._tex_normalisieren("\\PP pur") == "\\PP pur" 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)