update
This commit is contained in:
@@ -273,12 +273,12 @@ def test_meminfo_reads_proc():
|
||||
|
||||
|
||||
def test_agent_ebene_klassifiziert_board():
|
||||
"""Board-2-Marker (art-/sb-/crossblock/outline) → artefacts; alles andere → inventory."""
|
||||
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
|
||||
T = "shopware"
|
||||
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
|
||||
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
|
||||
assert agents.agent_ebene(key) == "inventory", key
|
||||
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
|
||||
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
|
||||
f"blocks-{T}-sub-crossblock-h-j1", f"blocks-{T}-outline-judge"):
|
||||
f"blocks-{T}-outline-judge"):
|
||||
assert agents.agent_ebene(key) == "artefacts", key
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand, Limit."""
|
||||
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
|
||||
import auto_loop
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ async def test_verbessert_bis_voll():
|
||||
noten = iter([9.0, 10.0])
|
||||
|
||||
async def rep():
|
||||
return next(noten)
|
||||
return next(noten), True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
|
||||
assert r["grund"] == "fertig" and r["runden"] == 2
|
||||
@@ -22,19 +22,31 @@ async def test_verbessert_bis_voll():
|
||||
|
||||
async def test_stillstand():
|
||||
async def rep():
|
||||
return 8.0 # Repair bewegt nichts
|
||||
return 8.0, False # nichts getan, Note steht
|
||||
|
||||
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 1
|
||||
assert r["note"] == 8.0
|
||||
|
||||
|
||||
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
|
||||
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
|
||||
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
|
||||
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
|
||||
|
||||
async def rep():
|
||||
return next(laeufe)
|
||||
|
||||
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
|
||||
assert r["grund"] == "stillstand" and r["runden"] == 3
|
||||
|
||||
|
||||
async def test_limit():
|
||||
stand = {"n": 1.0}
|
||||
|
||||
async def rep():
|
||||
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
|
||||
return stand["n"]
|
||||
return stand["n"], True
|
||||
|
||||
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
|
||||
assert r["grund"] == "limit" and r["runden"] == 10
|
||||
|
||||
@@ -103,12 +103,6 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
|
||||
monkeypatch.setattr(ba, name, fn)
|
||||
|
||||
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
monkeypatch.setattr(ba, "embedding", _EmbOff)
|
||||
|
||||
work = tmp_path / "arbeit"
|
||||
work.mkdir()
|
||||
files = {"arbeit": work, "final": tmp_path / "blocks.md",
|
||||
@@ -973,8 +967,8 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def stuck_repair(topic, ebene=None):
|
||||
return {"note": 5.0} # bewegt nichts
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 5.0, "aktionen": 0} # bewegt nichts
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
@@ -988,6 +982,45 @@ async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
|
||||
|
||||
|
||||
async def test_qa_gate_luecken_research_runden(board_env, monkeypatch):
|
||||
"""Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden
|
||||
(Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause."""
|
||||
import qa as qa_mod
|
||||
import repair as repair_mod
|
||||
db, ctx, files = board_env
|
||||
gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {},
|
||||
"luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}],
|
||||
"konzept_luecken": []}
|
||||
|
||||
async def bad_qa(topic, llm=False):
|
||||
return dict(gap_report)
|
||||
|
||||
research_calls = []
|
||||
|
||||
async def fake_producer(ctx2, flow, rep):
|
||||
research_calls.append(1)
|
||||
return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt
|
||||
|
||||
urteile = []
|
||||
|
||||
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
|
||||
urteile.append(luecken_urteil)
|
||||
return {"note": 8.0, "aktionen": 0}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report))
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
monkeypatch.setattr(bi, "_luecken_producer", fake_producer)
|
||||
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
|
||||
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
|
||||
await _seed(db)
|
||||
ok = await _run_flow(ctx, files)
|
||||
assert ok
|
||||
assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX
|
||||
assert urteile[:2] == [False, False] and urteile[2] is True
|
||||
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert
|
||||
|
||||
|
||||
async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
|
||||
"""Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
|
||||
import qa as qa_mod
|
||||
@@ -997,8 +1030,8 @@ async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
|
||||
async def bad_qa(topic, llm=False):
|
||||
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def good_repair(topic, ebene=None):
|
||||
return {"note": 10.0}
|
||||
async def good_repair(topic, ebene=None, luecken_urteil=True):
|
||||
return {"note": 10.0, "aktionen": 1}
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
|
||||
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
|
||||
@@ -1019,7 +1052,7 @@ async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
|
||||
async def good_qa(topic, llm=False):
|
||||
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
|
||||
|
||||
async def no_repair(topic, ebene=None):
|
||||
async def no_repair(topic, ebene=None, luecken_urteil=True):
|
||||
raise AssertionError("Auto aus darf nicht reparieren")
|
||||
|
||||
monkeypatch.setattr(qa_mod, "qa_report", good_qa)
|
||||
@@ -1094,6 +1127,11 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
|
||||
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
|
||||
v = bi._qa_view(TOPIC, counts, None)
|
||||
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
|
||||
# 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL)
|
||||
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
|
||||
{"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8")
|
||||
qa_mod._latest_cache.clear()
|
||||
assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True
|
||||
from types import SimpleNamespace
|
||||
laufend = SimpleNamespace(state={})
|
||||
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch
|
||||
@@ -1515,3 +1553,27 @@ async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path,
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
|
||||
assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
|
||||
|
||||
|
||||
def test_themen_zielband_skaliert_mit_atomzahl():
|
||||
"""√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap."""
|
||||
lo30, hi30 = bi._themen_zielband(30)
|
||||
assert lo30 >= 2 and hi30 < 15
|
||||
lo285, hi285 = bi._themen_zielband(285)
|
||||
assert hi285 > 18
|
||||
lo4, hi4 = bi._themen_zielband(4)
|
||||
assert lo4 >= 2 and hi4 > lo4
|
||||
# Monotonie: mehr Items → nie kleineres Band
|
||||
prev = (0, 0)
|
||||
for n in (4, 30, 100, 285, 1000):
|
||||
band = bi._themen_zielband(n)
|
||||
assert band >= prev
|
||||
prev = band
|
||||
|
||||
|
||||
def test_gruppierung_prompt_rendert_zielband(tmp_path):
|
||||
"""Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt."""
|
||||
from pipeline import _prompt
|
||||
p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a",
|
||||
out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9)
|
||||
assert "~4–8 themes" in p and "(9 items)" in p
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden.
|
||||
|
||||
Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles
|
||||
bis run_agent echt — _race, Quorum, Panels, Konsolidierung, Cross-Block, QA-Gate.
|
||||
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
@@ -301,6 +301,33 @@ async def test_events_run_summary_per_board(testdb):
|
||||
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
|
||||
|
||||
|
||||
async def test_latest_board_runs_je_ebene(testdb):
|
||||
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
|
||||
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
|
||||
db = testdb
|
||||
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
|
||||
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
|
||||
await db.add_event(TOPIC, "agent", key="i1", status="ok",
|
||||
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
|
||||
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
|
||||
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
|
||||
await db.add_event(TOPIC, "agent", key="a2", status="ok",
|
||||
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
|
||||
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
|
||||
await db.add_event(TOPIC, "agent", key="g1", status="ok",
|
||||
meta={"tokens": {"input": 3, "output": 4}})
|
||||
latest = await db.latest_board_runs(TOPIC)
|
||||
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
|
||||
assert latest["inventory"]["tokens"]["input"] == 10
|
||||
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
|
||||
assert latest["artefacts"]["tokens"]["output"] == 7
|
||||
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
|
||||
assert latest["guide"]["tokens"]["input"] == 3
|
||||
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
|
||||
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
|
||||
db.set_current_run(TOPIC, None)
|
||||
|
||||
|
||||
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
|
||||
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
|
||||
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
|
||||
@@ -351,6 +378,7 @@ async def test_runs_endpoint_liefert_bilanz(testdb):
|
||||
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
|
||||
res = await routes.get_runs(TOPIC)
|
||||
runs = res["runs"]
|
||||
assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
|
||||
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
|
||||
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
|
||||
assert runs[0]["tokens"]["output"] == 20
|
||||
|
||||
80
backend/tests/test_finalize.py
Normal file
80
backend/tests/test_finalize.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
|
||||
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
|
||||
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "finalize"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
@@ -427,6 +427,36 @@ async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, mo
|
||||
assert cards["gamma"]["stage"] == "done"
|
||||
|
||||
|
||||
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
|
||||
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
|
||||
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
|
||||
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
|
||||
import json as _json
|
||||
import qa as qa_mod
|
||||
db = testdb
|
||||
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
|
||||
for n in ("alpha", "beta", "gamma"):
|
||||
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
|
||||
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
|
||||
tdir = tmp_path / "qa" / TOPIC
|
||||
tdir.mkdir(parents=True)
|
||||
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
|
||||
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
|
||||
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
|
||||
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
|
||||
"lesbarkeit": ["Alpha: Sätze kürzen"],
|
||||
}), encoding="utf-8")
|
||||
betroffen = await gb.repair_karten(TOPIC, FMT)
|
||||
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
|
||||
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
|
||||
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
|
||||
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
|
||||
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
|
||||
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
|
||||
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
|
||||
|
||||
|
||||
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
|
||||
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
|
||||
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
|
||||
Judges gefaked, gegen Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-
|
||||
Umbau in block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
import blocks
|
||||
import board_artefacts as ba
|
||||
from kanban import Flow
|
||||
from pipeline import FAILED, OK, GenContext
|
||||
|
||||
TOPIC = "konsolidierung"
|
||||
|
||||
|
||||
def _ctx():
|
||||
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
|
||||
|
||||
|
||||
def _fake_slot(antworten):
|
||||
"""run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
|
||||
calls = []
|
||||
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
|
||||
calls.append({"key": key, "prompt": prompt})
|
||||
j = key.rsplit("-", 1)[-1] # "j1"/"j2"
|
||||
antwort = antworten.get(j)
|
||||
if antwort is None:
|
||||
return FAILED, None
|
||||
return OK, payload((0, json.dumps(antwort), ""))
|
||||
|
||||
fake.calls = calls
|
||||
return fake
|
||||
|
||||
|
||||
async def _seed_block(db, bnorm, subs):
|
||||
for s in subs:
|
||||
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
|
||||
|
||||
|
||||
def test_luecken_schnitt_cap():
|
||||
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
|
||||
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
|
||||
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
|
||||
|
||||
|
||||
def test_neg_set_lemmatisiert():
|
||||
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
|
||||
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
|
||||
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
|
||||
assert a == b == frozenset({"kein"})
|
||||
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
|
||||
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
|
||||
|
||||
|
||||
async def test_finalize_purges_stale_rows(testdb, tmp_path):
|
||||
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
|
||||
db = testdb
|
||||
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
|
||||
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
|
||||
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
await ba._proc_finalize(_ctx(), flow, files, [card])
|
||||
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
|
||||
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
|
||||
|
||||
|
||||
# ── Cross-Block ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeEmb:
|
||||
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
|
||||
|
||||
@staticmethod
|
||||
def available():
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def embed_sims(texts):
|
||||
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
|
||||
arr = np.zeros((len(texts), max(len(uniq), 1)))
|
||||
for r, t in enumerate(texts):
|
||||
arr[r, uniq[t]] = 1.0
|
||||
return arr @ arr.T
|
||||
|
||||
|
||||
async def _cross_env(db, tmp_path, finalisiert=True):
|
||||
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
|
||||
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
||||
payload = {"title": bnorm.title()}
|
||||
if finalisiert:
|
||||
payload.update(pattern={}, artefacts={})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
return flow, cards, files
|
||||
|
||||
|
||||
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
|
||||
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
|
||||
Gewinner (falte_sub), Karten gehen auf DONE."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
sn = blocks._norm_title("Gleiche Aussage")
|
||||
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert beta_rows[sn] == "variant"
|
||||
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert alpha_rows[sn] == "consensus"
|
||||
fragen = await db.list_question_pattern(TOPIC)
|
||||
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
|
||||
|
||||
|
||||
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
||||
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
||||
"j3": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert len(fake.calls) == 3
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||
|
||||
|
||||
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path)
|
||||
|
||||
class _Aus:
|
||||
@staticmethod
|
||||
def available():
|
||||
return False
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("ohne Embedding kein Judge")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
for cid in ("alpha", "beta"):
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||
|
||||
|
||||
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
|
||||
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
|
||||
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
|
||||
db = testdb
|
||||
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
|
||||
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
|
||||
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
|
||||
|
||||
async def kein_agent(*a, **kw):
|
||||
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
|
||||
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||
|
||||
|
||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
||||
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
|
||||
"sidecar": {}, "pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
|
||||
assert row["level"] == "advanced" and row["relevance"] == "relevant"
|
||||
|
||||
|
||||
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
|
||||
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
|
||||
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
|
||||
db = testdb
|
||||
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
|
||||
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
card = {"card_id": "alpha", "payload": {
|
||||
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
|
||||
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
|
||||
"pattern": {}, "artefacts": {}}}
|
||||
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert rows == {"neu": "consensus", "alte-variante": "variant"}
|
||||
|
||||
|
||||
async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
|
||||
"""Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
|
||||
die Verdicts falten global über alle Chunks."""
|
||||
db = testdb
|
||||
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||
cards = []
|
||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
||||
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
||||
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||
await _seed_block(db, bnorm, subs)
|
||||
cards.append({"card_id": bnorm, "payload": payload})
|
||||
files = {k: tmp_path / f"{k}.json" for k in
|
||||
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
|
||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||||
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|
||||
@@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path):
|
||||
|
||||
def test_cache_skips_fresh_txt(tmp_path):
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
|
||||
marker = tmp_path / "a.txt"
|
||||
marker.write_text("MARKER", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
@@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path):
|
||||
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
|
||||
|
||||
|
||||
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
|
||||
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
|
||||
_mini_pdf(tmp_path / "a.pdf")
|
||||
stale = tmp_path / "a.txt"
|
||||
stale.write_text("ALT", encoding="utf-8")
|
||||
now = time.time() + 60
|
||||
os.utime(stale, (now, now))
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
|
||||
blx._convert_pdfs(tmp_path)
|
||||
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
|
||||
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
|
||||
stale.write_text("BLEIBT", encoding="utf-8")
|
||||
os.utime(stale, (now, now))
|
||||
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
|
||||
assert stale.read_text(encoding="utf-8") == "BLEIBT"
|
||||
|
||||
|
||||
def test_entzerre_pdf_woerter():
|
||||
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
|
||||
# Regel A inkl. Kette
|
||||
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
|
||||
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
|
||||
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
|
||||
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
|
||||
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
|
||||
belegt = "N P ist zentral. " + "NP NP NP."
|
||||
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
|
||||
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
|
||||
# kein Match mitten im Wort
|
||||
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
|
||||
|
||||
|
||||
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
|
||||
_mini_pdf(tmp_path / "b.pdf")
|
||||
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
|
||||
|
||||
@@ -313,3 +313,23 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
|
||||
(qdir / "alt.json").write_text("{}", encoding="utf-8")
|
||||
await routes.remove_topic("t")
|
||||
assert not qdir.exists()
|
||||
|
||||
|
||||
def test_luecken_key_stabil_gegen_whitespace():
|
||||
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
|
||||
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
|
||||
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
|
||||
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
|
||||
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
|
||||
assert qa.luecken_key(a) == qa.luecken_key(b)
|
||||
assert qa.luecken_key(a) != qa.luecken_key(c)
|
||||
assert qa.luecken_key(a).startswith("f.txt||")
|
||||
|
||||
|
||||
def test_offene_luecken_filtert_freispruch_und_nein():
|
||||
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
|
||||
{"datei": "f", "vorschau": "b", "llm": "nein"},
|
||||
{"datei": "f", "vorschau": "c", "freispruch": True}],
|
||||
"konzept_luecken": ["Satz von Foo"]}
|
||||
lk, kl = qa.offene_luecken(rep)
|
||||
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]
|
||||
|
||||
@@ -298,3 +298,135 @@ async def test_waisen_cleanup(env, monkeypatch):
|
||||
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) == []
|
||||
|
||||
Reference in New Issue
Block a user