Files
creator/backend/tests/test_repair.py
2026-07-08 21:14:33 +02:00

433 lines
21 KiB
Python

"""Befund-Repair: gezielte Aktionen aus dem QA-Report (repair.py) — ohne Flow, gegen Test-DB."""
import json
import pytest
import repair
import qa as qa_mod
TOPIC = "reparatur"
def _report(**over):
r = {"topic": TOPIC, "note": 9.0, "quoten": {}, "hygiene": [], "dubletten": [],
"fremd": [], "unecht": [], "luecken": [], "artefakte": {"verwaiste": []}}
r.update(over)
return r
@pytest.fixture
async def env(testdb, tmp_path, monkeypatch):
"""Zwei fertige Blöcke auf beiden Boards + Subs/Artefakte + Sidecar-Dateien."""
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
files = {"sidecar": tmp_path / "sidecar.json", "facts": tmp_path / "facts.json",
"question_pattern": tmp_path / "qp.json", "sub_roh": tmp_path / "roh.json",
"artefakte": tmp_path / "artefakte.json"}
monkeypatch.setattr(repair, "_blocks_files", lambda t: files)
# frisches Abschluss-QA im Repair stumm schalten (eigener Test deckt qa_report ab)
async def _no_qa(topic, llm=False):
return None
monkeypatch.setattr(qa_mod, "qa_report", _no_qa)
async def _seed(title, desc, subs=1):
norm = repair._norm_title(title)
cid = "b-" + norm.replace(" ", "")[:10]
await db.kanban_upsert_card(TOPIC, "inventory", cid, "block", "done_block",
{"title": title, "description": desc, "sources": [f"{title}.txt"],
"readers": ["r1"], "mirrored_norm": norm})
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact", {"title": title})
await db.upsert_block(TOPIC, norm, title, desc, [f"{title}.txt"])
await db.set_block_status(TOPIC, norm, "consensus")
for i in range(subs):
await db.put_subblock(TOPIC, norm, f"sub{i}", title, f"Sub {i}")
await db.put_sub_artifact(TOPIC, norm, "sub0", "flashcard", "{}", title, "Sub 0")
return cid
for p in files.values():
p.write_text("{}", encoding="utf-8")
(tmp_path / "qa" / TOPIC).mkdir(parents=True)
def write_report(r):
(tmp_path / "qa" / TOPIC / "r.json").write_text(json.dumps(r), encoding="utf-8")
return db, _seed, files, write_report
async def test_merge_confirmed_duplicate(env, monkeypatch):
db, seed, files, write_report = env
cid_a = await seed("Alpha", "kurz")
cid_b = await seed("Alpha Problem", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Problem", "llm": "ja"},
{"a": "Alpha", "b": "Beta", "llm": "nein"}]))
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(prompt)
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Problem"]
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge
verlierer = await db.kanban_get_card(TOPIC, "inventory", cid_a)
assert verlierer["stage"] == "grouped" and verlierer["payload"]["merged_into"] == "Alpha Problem"
gewinner = await db.kanban_get_card(TOPIC, "inventory", cid_b)
assert "Alpha.txt" in gewinner["payload"]["sources"] # Union
assert await db.kanban_get_card(TOPIC, "artefacts", "alpha") is None
assert not [r for r in await db.list_subblocks(TOPIC, "alpha")]
async def test_fremd_removed_only_on_nein(env, monkeypatch):
db, seed, files, write_report = env
cid_f = await seed("Fremdling", "gehört nicht rein")
cid_e = await seed("Echter", "belegt")
write_report(_report(fremd=["Fremdling", "Echter"]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid über den strittigen „Echter": behalten
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
monkeypatch.setattr(repair, "source_folder", lambda t: None)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == ["Fremdling"]
weg = await db.kanban_get_card(TOPIC, "inventory", cid_f)
assert weg["stage"] == "rejected" and weg["payload"]["reason"] == "qa-fremd"
bleibt = await db.kanban_get_card(TOPIC, "inventory", cid_e)
assert bleibt["stage"] == "done_block"
async def test_judge_failure_keeps_everything(env, monkeypatch):
db, seed, files, write_report = env
cid = await seed("Wackelig", "unsicher")
write_report(_report(unecht=["Wackelig"]))
async def broken_agent(key, prompt, timeout, **kw):
raise RuntimeError("boom")
import agents; monkeypatch.setattr(agents, "run_agent", broken_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["stage"] == "done_block" # fail-open
async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch):
db, seed, files, write_report = env
cid = await seed("**Fetter Titel**", "beschreibung")
files["sidecar"].write_text(json.dumps({"**Fetter Titel**": ["s"]}), encoding="utf-8")
write_report(_report(hygiene=[{"titel": "**Fetter Titel**", "probleme": ["markdown"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Hygiene braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Fetter Titel"
assert json.loads(files["sidecar"].read_text()) == {"Fetter Titel": ["s"]}
rows = await db.list_blocks(TOPIC)
assert any(r["title"] == "Fetter Titel" and r["status"] == "consensus" for r in rows)
async def test_no_report_is_clean_error(env):
db, seed, files, write_report = env
res = await repair.repair_befunde("gibtsnicht")
assert "fehler" in res
async def test_abschluss_qa_misst_mit_llm(env, monkeypatch):
"""Repair-Abschlussreport misst mit LLM — der llm=False-Report blendete
sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln."""
db, seed, files, write_report = env
write_report(_report())
import qa as qa_mod
seen = {}
async def spy(topic, llm=False):
seen["llm"] = llm
return None
monkeypatch.setattr(qa_mod, "qa_report", spy)
await repair.repair_befunde(TOPIC)
assert seen["llm"] is True
async def test_sub_dubletten_merge(env, monkeypatch):
"""Bestätigtes Sub-Paar + Zweitmeinung ja → Verlierer variant, Frage/Artefakt
wandern zum Gewinner (bzw. fallen weg, wenn er den Typ schon hat)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "gewinner sub", "Alpha", "Gewinner Sub",
facts='{"key_points": ["a", "b"]}', status="consensus")
await db.put_subblock(TOPIC, norm, "verlierer sub", "Alpha", "Verlierer Sub",
facts='{"key_points": ["x"]}', status="consensus")
await db.put_sub_artifact(TOPIC, norm, "verlierer sub", "example", "{}", "Alpha", "Verlierer Sub")
await db.upsert_question_pattern(TOPIC, norm, "verlierer sub", "Alpha", "Verlierer Sub", "Frage V?")
write_report(_report(sub_dubletten=[
{"a": "[Alpha] Gewinner Sub", "b": "[Alpha] Verlierer Sub", "llm": "ja"},
{"a": "[Alpha] Gibtsnicht", "b": "[Alpha] Verlierer Sub", "llm": "ja"}])) # tote Zeile → skip
async def fake_agent(key, prompt, timeout, **kw):
assert "Gibtsnicht" not in prompt
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["verlierer sub"] == "variant" and rows["gewinner sub"] == "consensus"
arts = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert ("gewinner sub", "example") in arts and ("verlierer sub", "example") not in arts
fragen = {r["sub_norm"]: r["question"] for r in await db.list_question_pattern(TOPIC)}
assert fragen.get("gewinner sub") == "Frage V?" and "verlierer sub" not in fragen
async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == []
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch):
"""Dissens QA (Befund) vs. Zweitmeinung (behalten) → Stichentscheid-Judge (Key -st)
entscheidet mit 2:1 für den Befund → Merge. Vorher pendelte die Note dauerhaft
unter 10 ohne Fix-Pfad („keine behebbaren Befunde" trotz Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A",
facts='{"key_points": ["a"]}', status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid bestätigt den QA-Befund
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Sub B → Sub A"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub b"] == "variant" and rows["sub a"] == "consensus"
async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch):
"""2:1 „behalten" (Zweitmeinung + j3 einig gegen den QA-Befund) → Freispruch wird
persistiert und der Report des nächsten qa_report zählt das Paar nicht mehr —
vorher pendelte die Note dauerhaft unter 10 ohne Fix-Pfad."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
frei = qa_mod.lade_freispruch(TOPIC)
key = qa_mod._paar_key("[Alpha] Sub A", "[Alpha] Sub B")
assert key in set(frei.get("sub_dubletten") or [])
# QA-Seite: bestätigtes, aber freigesprochenes Paar zählt nicht in die Quote
sd = [{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]
frei_sub = set(frei["sub_dubletten"])
zaehlt = sum(1 for p in sd if p.get("llm") == "ja"
and qa_mod._paar_key(p["a"], p["b"]) not in frei_sub)
assert zaehlt == 0
async def test_fremd_stichentscheid_behalten(env, monkeypatch):
"""Dissens bei fremd, Stichentscheid sagt ebenfalls behalten (ja) → Block bleibt
(fail-open bei 1:2 gegen den Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
write_report(_report(fremd=["Alpha"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
async def test_waisen_cleanup(env, monkeypatch):
"""Artefakte/Fragen auf verworfene oder fehlende Subs fliegen; lebende und
mehrdeutig-präfixige bleiben."""
db, seed, files, write_report = env
await seed("Alpha", "beschreibung") # legt sub0 (consensus) + flashcard auf sub0 an
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "weg", "Alpha", "Weg", status="discarded")
await db.put_subblock(TOPIC, norm, "doppel: eins", "Alpha", "Doppel eins")
await db.put_subblock(TOPIC, norm, "doppel: zwei", "Alpha", "Doppel zwei")
await db.put_sub_artifact(TOPIC, norm, "weg", "flashcard", "{}", "Alpha", "Weg") # tot
await db.put_sub_artifact(TOPIC, norm, "fehlt", "example", "{}", "Alpha", "Fehlt") # tot
await db.put_sub_artifact(TOPIC, norm, "doppel", "example", "{}", "Alpha", "Doppel") # mehrdeutig → bleibt
await db.upsert_question_pattern(TOPIC, norm, "fehlt", "Alpha", "Fehlt", "Frage?") # tot
write_report(_report())
async def no_agent(*a, **kw):
raise AssertionError("Aufräumen braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["aufgeraeumt"] == 3
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
assert not [r for r in await db.list_question_pattern(TOPIC)]
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
await seed("Alpha Kreis", "beschr")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
Merge mit 2:1 gegen das QA-Urteil."""
db, seed, files, write_report = env
await seed("Alpha", "kurz")
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
if "-beleg-st-" in key:
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "artefacts")
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
assert rows["ohne beleg"]["status"] == "discarded"
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
Auszügen; Titel bleibt unverändert."""
db, seed, files, write_report = env
cid = await seed("Leerling", "")
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
assert "-sanierung-" in key
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "beschrieben: Leerling" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["description"] == "Aus dem Material."
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
from invarianten import pruefe_invarianten
db, seed, files, write_report = env
cid = await seed("Gamma (2)", "beschr")
norm_alt = repair._norm_title("Gamma (2)")
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
assert not await db.list_subblocks(TOPIC, norm_alt)
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
assert not [b for b in befunde if "Waise" in b]
async def test_pruefe_luecken_freispruch(env, monkeypatch):
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert len(res["freigesprochen"]) == 2
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
lk[0]["freispruch"] = True
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []