194 lines
8.7 KiB
Python
194 lines
8.7 KiB
Python
"""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"
|
|
|
|
|
|
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)
|