"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB.""" import json import numpy as np import pytest 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") # ── In-Block ──────────────────────────────────────────────────────────────────────── async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch): """Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union, Verlierer wird DB-variant und fliegt aus raw/facts_map.""" db = testdb subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"] await _seed_block(db, "betonung", subs) raw = {"Betonung": list(subs)} facts = {"Betonung": { blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]}, blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]}, }} fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]}, "j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1 wf = facts["Betonung"][blocks._norm_title(subs[1])] assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"] assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel assert blocks._norm_title(subs[0]) not in facts["Betonung"] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")} assert rows[blocks._norm_title(subs[0])] == "variant" assert rows[blocks._norm_title(subs[1])] == "consensus" journale = list(tmp_path.glob("sub-konsolidierung-*.json")) j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text()) # Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"] async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch): """Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge.""" db = testdb subs = ["Eintrag eins", "Eintrag zwei"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}, "j2": {"gruppen": [], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Block"] == subs assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block")) async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch): """Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich.""" db = testdb subs = ["Eintrag eins", "Eintrag zwei"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Block"] == subs assert len(fake.calls) == 3 # j1, j2, Ersatz j3 async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch): """j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet. Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten).""" db = testdb subs = ["Kurz", "Deutlich längerer Eintrag"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []}, "j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Block"] == ["Deutlich längerer Eintrag"] async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch): """Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet.""" db = testdb subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"] await _seed_block(db, "tabs", subs) raw = {"Tabs": list(subs)} fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}, "j2": {"gruppen": [[1, 2]], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Tabs"] == subs async def test_resume_skips_judges(testdb, tmp_path, monkeypatch): """Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen.""" db = testdb subs = ["Eintrag eins", "Eintrag zwei lang"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} import hashlib h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8] for j in (1, 2): (tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text( json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8") async def kein_agent(*a, **kw): raise AssertionError("Resume darf keinen Agenten starten") monkeypatch.setattr(blocks, "run_single_slot", kein_agent) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Block"] == ["Eintrag zwei lang"] def test_schema_accepts_both_group_forms(): """Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional.""" alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5) assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set() neu = blocks._konsolidierung_schema( {"gruppen": [{"haupt": 4, "weitere": [1]}], "kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}], "fremd": [5], "luecken": ["x"]}, 5) assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}] assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}] assert neu["fremd"] == {5} and neu["luecken"] == ["x"] assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \ {"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch): """Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points.""" db = testdb subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}} fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}, "j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) assert raw["Block"] == ["Basis"] assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch): """Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant.""" db = testdb subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"] await _seed_block(db, "mathe", subs) raw = {"Mathe": list(subs)} facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]}, blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}} kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]} fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []}, "j2": {"gruppen": [], "kataloge": [kat], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"] kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen") assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")} assert rows[kn] == "consensus" assert rows[blocks._norm_title(subs[0])] == "variant" async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch): db = testdb subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"] await _seed_block(db, "mathe", subs) raw = {"Mathe": list(subs)} fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []}, "j2": {"gruppen": [], "kataloge": [], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Mathe"] == subs async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch): """Einstimmig fremd → discarded + raus; einseitig fremd → bleibt.""" db = testdb subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []}, "j2": {"gruppen": [], "fremd": [1], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert raw["Block"] == [subs[1], subs[2]] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")} assert rows[blocks._norm_title(subs[0])] == "discarded" assert rows[blocks._norm_title(subs[2])] == "consensus" assert luecken == {} async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch): """Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg.""" db = testdb subs = ["Eintrag eins", "Eintrag zwei"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]}, "j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}}) monkeypatch.setattr(blocks, "run_single_slot", fake) luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {}) assert luecken == {"Block": ["Inline-HTML fehlt"]} async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch): """Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll.""" db = testdb subs = ["Eintrag eins", "Eintrag zwei"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}} fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}}) monkeypatch.setattr(blocks, "run_single_slot", fake) await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts) prompt = fake.calls[0]["prompt"] assert "kp3" in prompt and "kp4" not in prompt 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") # ── Lücken-Nachfass ───────────────────────────────────────────────────────────────── async def _nachfass_env(db, monkeypatch, facts_result): subs = ["Eintrag eins"] await _seed_block(db, "block", subs) raw = {"Block": list(subs)} facts_map = {"Block": {}} async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0): return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}] async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False): assert slim is True # Nachfass nutzt die schlanke Facts-Variante assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate return facts_result monkeypatch.setattr(blocks, "_race", fake_race) monkeypatch.setattr(blocks, "_facts_block", fake_facts) monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False) return raw, facts_map async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch): db = testdb nn = blocks._norm_title("Neuer Aspekt") raw, facts_map = await _nachfass_env(db, monkeypatch, ({"Block": {nn: {"key_points": ["kp"]}}}, {})) n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], raw, facts_map, {"type": "thema"}, None) assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"] assert facts_map["Block"][nn]["key_points"] == ["kp"] rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")} assert rows[nn] == "consensus" async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch): db = testdb nn = blocks._norm_title("Neuer Aspekt") raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}})) n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], raw, facts_map, {"type": "thema"}, None) assert n == 0 and raw["Block"] == ["Eintrag eins"] assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block")) async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch): """HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch).""" db = testdb raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"], raw, facts_map, {"type": "thema"}, None) assert n == 0 and raw["Block"] == ["Eintrag eins"] async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch): """Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal; deren Lücken lösen KEINEN weiteren Nachfass aus.""" db = testdb payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}} await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload) calls = {"kons": 0, "nf": 0} async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False): return {"Alpha": {}}, {} async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""): calls["kons"] += 1 return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder, instructions="", ns="", lbl="", sources=None): calls["nf"] += 1 return 2 monkeypatch.setattr(ba, "_facts_block", fake_facts) monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons) monkeypatch.setattr(ba, "_luecken_runde", fake_nf) flow = Flow(TOPIC, work_dir=tmp_path) await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "", [{"card_id": "alpha", "payload": payload}]) assert calls == {"kons": 2, "nf": 1} assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels" 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): 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(), "raw": {bnorm.title(): list(subs)}, "sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]}, "facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}} await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload) await _seed_block(db, bnorm, subs) cards.append({"card_id": bnorm, "payload": payload}) return flow, cards async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch): """Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels.""" db = testdb flow, cards = await _cross_env(db, tmp_path) 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, {}, "", cards) assert "Gleiche Aussage" in fake.calls[0]["prompt"] beta = await db.kanban_get_card(TOPIC, "artefacts", "beta") assert beta["stage"] == "question_pattern" assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"] assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"] # Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"] alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha") assert alpha["stage"] == "question_pattern" assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"] 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_tiebreaker_folds(testdb, tmp_path, monkeypatch): """j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert.""" db = testdb flow, cards = 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, {}, "", cards) assert len(fake.calls) == 3 beta = await db.kanban_get_card(TOPIC, "artefacts", "beta") assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"] 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 = 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, {}, "", cards) beta = await db.kanban_get_card(TOPIC, "artefacts", "beta") assert beta["stage"] == "question_pattern" assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"] async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch): """Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet.""" db = testdb flow, cards = 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, {}, "", cards) beta = await db.kanban_get_card(TOPIC, "artefacts", "beta") assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"] async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch): db = testdb flow, cards = 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, {}, "", cards) for cid in ("alpha", "beta"): assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern" async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch): """Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b" — die Paket-Seite fällt, der Kontext bleibt unangetastet.""" db = testdb flow = Flow(TOPIC, work_dir=tmp_path) payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}} await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload) await _seed_block(db, "alpha", ["Gleiche Aussage"]) cards = [{"card_id": "alpha", "payload": payload}] # Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels", {"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}}) await _seed_block(db, "gamma", ["Gleiche Aussage"]) monkeypatch.setattr(ba, "embedding", _FakeEmb) # Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}}) monkeypatch.setattr(ba, "run_single_slot", fake) await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards) alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha") assert alpha["payload"]["raw"].get("Alpha", []) == [] # Paket-Seite gefaltet gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")} assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus" # Kontext unberührt 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(), "raw": {bnorm.title(): list(subs)}, "sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]}, "facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": []} for s in subs}}} await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload) await _seed_block(db, bnorm, subs) cards.append({"card_id": bnorm, "payload": payload}) 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, {}, "", cards) assert len(fake.calls) == 4 # 2 Chunks × j1/j2 beta = await db.kanban_get_card(TOPIC, "artefacts", "beta") assert beta["payload"]["raw"].get("Beta", []) == [] # beide Dubletten global gefaltet async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch): """Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde; Vorhandenes bleibt unberührt, Subs werden nie verworfen.""" gesehen = {} async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False): gesehen["raw"] = raw gesehen["slim"] = slim return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]}, "mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}}, {"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert monkeypatch.setattr(blocks, "_facts_block", fake_facts_block) raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]} facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}} ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None) assert n == 1 assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]} assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"] assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"] assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch): """Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag (eindeutiger Präfix-Match statt stillem Grounding-Verlust).""" db = testdb payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]}, "facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"): {"key_points": ["kp"]}}}} await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload) cards = [{"card_id": "alpha", "payload": payload}] async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""): return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel monkeypatch.setattr(ba, "_levels_block", fake_levels_block) await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards) card = await db.kanban_get_card(TOPIC, "artefacts", "alpha") assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}