"""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