"""Board 1 end-to-end through the real engine — agents faked, no LLM, no embedding model.""" import json import re import pytest import board_inventory as bi import kanban from pipeline import GenContext TOPIC = "t" B = bi.BOARD _PATH_RE = re.compile(r"(/\S+\.json)") def _fake_single_slot(tmp_path): """Deterministic judge stand-in: writes the expected JSON to the out_path found in the prompt and returns it — keyed off the agent-key naming convention.""" async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None): m = _PATH_RE.search(prompt) out = None if "-pair-" in key: pairs = prompt.count("\nA: ") out = {"pairs": {str(i + 1): "ja" for i in range(pairs)}} elif "-dedup-" in key: pairs = prompt.count("\nA: ") out = {"pairs": {str(i + 1): "nein" for i in range(pairs)}} elif "-clarify-" in key: keep = [line[2:].split(" — ")[0] for line in prompt.splitlines() if line.startswith("- ")] out = {"keep": keep, "rest": []} elif "-naming-" in key: out = {"best": 1} elif "-filter-recheck-" in key or "-filter-" in key: out = {"fragments": {}, "drop": []} elif "-gruppierung-completion-" in key: out = {"additions": []} elif "-gruppierung-" in key: out = {"umbrellas": []} elif "-supplement" in key: out = {"blocks": [{"title": "Zeta-Konzept", "description": "kanonisch fehlend"}]} if m and out is not None: with open(m.group(1), "w", encoding="utf-8") as f: json.dump(out, f) return "ok", payload(None) return fake @pytest.fixture async def board_env(testdb, tmp_path, monkeypatch): import board_artefacts as ba from textkit import _norm_title monkeypatch.setattr(bi, "run_single_slot", _fake_single_slot(tmp_path)) # QA-Gate: standardmäßig saubere Fake-Note (kein Embedding-Load in Tests); # Gate-Tests überschreiben qa_report gezielt. import qa as qa_mod async def _fake_qa(topic, llm=False): return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {"status": "nicht generiert"}} monkeypatch.setattr(qa_mod, "qa_report", _fake_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) async def no_emb(flow): return False monkeypatch.setattr(bi, "_emb_ok", no_emb) async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="", sources=None, seeds=None): subs = ["Sub Eins", "Sub Zwei"] return {"raw": {title: list(subs)}, "facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []} for s in subs}}, "unsicher": [], "votes": {}} async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None): subs = gen["raw"].get(title) or [] bfacts = gen["facts"].get(title) or {} sidecar = [{"title": s, "level": "beginner", "relevance": "relevant" if i == 0 else "peripheral", "facts": bfacts.get(_norm_title(s)) or {}} for i, s in enumerate(subs)] return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}} async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl=""): if not sidecar_subs: return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}} first = sidecar_subs[0]["title"] return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]}, "artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}], "example": []}} async def fake_outline(ctx, set_p, files, entries, instructions): return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]} for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify), ("_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", "sub_roh": tmp_path / "sub_roh.json", "sidecar": tmp_path / "subblocks.json", "facts": tmp_path / "facts.json", "question_pattern": tmp_path / "question_pattern.json", "artefakte": tmp_path / "artefakte.json", "outline": tmp_path / "outline.json", "outline_slots": tmp_path / "outline_slots"} ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) return testdb, ctx, files async def _seed(db): # 2 consensus titles (2 readers), 1 single find (kept by panel), 1 artifact (pre-reject) await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s1", "r1") await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s2", "r2") await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s1", "r1") await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s2", "r2") await db.kanban_add_title(TOPIC, B, "gamma", "Gamma", "Einzelfund", "s1", "r1") await db.kanban_add_title(TOPIC, B, "aufgabe 3", "Aufgabe 3", "Übungszettel", "s1", "r1") async def test_board1_full_flow(board_env): db, ctx, files = board_env await _seed(db) import asyncio ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), timeout=30) assert ok done = await db.kanban_cards(TOPIC, board=B, stage="done_block") titles = sorted(c["payload"]["title"] for c in done) # Alpha/Beta/Gamma survive; the supplement's Zeta flows through the whole board too assert titles == ["Alpha", "Beta", "Gamma", "Zeta-Konzept"] rejected = await db.kanban_cards(TOPIC, board=B, stage="rejected") assert [c["payload"]["title"] for c in rejected] == ["Aufgabe 3"] assert rejected[0]["payload"]["reason"] == "pre-reject" # legacy mirror carries the survivors as consensus legacy = {b["title"] for b in await db.list_blocks(TOPIC, status="consensus")} assert legacy == set(titles) # blocks.md written in flow order lines = files["final"].read_text(encoding="utf-8").strip().splitlines() assert len(lines) == 4 # reader union survived the pipeline (consensus evidence on the block card) alpha = next(c for c in done if c["payload"]["title"] == "Alpha") assert set(alpha["payload"]["readers"]) == {"r1", "r2"} assert alpha["payload"]["n_size"] == 2 # LPT estimate travels with the card # board 2: one artefact card per block ran through to done_artefact (+ outline singleton) art_done = await db.kanban_cards(TOPIC, board="artefacts", stage="done_artefact") assert len(art_done) == 5 # 4 blocks + outline card sidecar = json.loads(files["sidecar"].read_text(encoding="utf-8")) assert set(sidecar) == set(titles) assert sidecar["Alpha"][0]["facts"]["key_points"] == ["Fakt zu Sub Eins"] assert sidecar["Alpha"][0]["relevance"] == "relevant" assert sidecar["Alpha"][1]["relevance"] == "peripheral" # DB mirrors: subblocks, question pattern, artefacts, outline subs = await db.list_subblocks(TOPIC, "alpha") assert {s["sub_title"] for s in subs} == {"Sub Eins", "Sub Zwei"} outline = await db.get_outline(TOPIC) assert outline and "Kapitel 1" in outline # Lauf-Summary am Flow-Ende: run_id + Zähler (QA diffed dagegen) summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8")) assert summary["run_id"] and summary["topic"] == TOPIC assert summary["boards"].get("inventory", {}).get("done_block") == 4 async def test_abschluss_qa_events_tragen_run_id(board_env, monkeypatch): """Abschluss-QA läuft NACH run_flow — ihre Judge-Events müssen trotzdem die run_id des Laufs tragen (Lauf 20260704-1452-b223: run_id leer → aus jeder Aggregation gefallen).""" import asyncio import qa as qa_mod db, ctx, files = board_env await _seed(db) async def qa_mit_judge_event(topic, llm=False): # wie die echten LLM-Judges: run_agent schreibt ein agent-Event await db.add_event(topic, "agent", key=f"qa-{topic}-bausteine-0", status="ok") return {"note": 10.0, "topic": topic, "quoten": {}, "fremd": [], "artefakte": {"status": "nicht generiert"}} monkeypatch.setattr(qa_mod, "qa_report", qa_mit_judge_event) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), timeout=30) assert ok summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8")) conn = await db.get_db() rows = await (await conn.execute( "SELECT run_id FROM events WHERE topic=? AND key=?", (TOPIC, f"qa-{TOPIC}-bausteine-0"))).fetchall() assert rows and all(r[0] == summary["run_id"] for r in rows) # Registry nach dem Lauf geleert: manuelle QA bleibt korrekt ohne run_id await db.add_event(TOPIC, "agent", key="qa-manuell", status="ok") row = await (await conn.execute( "SELECT run_id FROM events WHERE topic=? AND key='qa-manuell'", (TOPIC,))).fetchone() assert row[0] == "" async def test_filter_judges_run_parallel(board_env, monkeypatch): """40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix).""" import asyncio db, ctx, files = board_env state = {"cur": 0, "max": 0} base = bi.run_single_slot # instant fake from the fixture async def slow(*a, **k): state["cur"] += 1 state["max"] = max(state["max"], state["cur"]) try: await asyncio.sleep(0.05) return await base(*a, **k) finally: state["cur"] -= 1 monkeypatch.setattr(bi, "run_single_slot", slow) for i in range(40): await db.kanban_upsert_card(TOPIC, B, f"b-x{i}", "block", "fragment_filter", {"title": f"Block {i}", "description": "d"}) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), timeout=30) assert ok # 40 seeded + 1 supplement candidate (Zeta) flow through to done_block assert await db.kanban_count(TOPIC, "done_block", board=B) == 41 assert state["max"] >= 2 # chunk judges ran as one wave, not sequentially async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch): """Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch.""" import asyncio import board_artefacts as ba import blocks as blx db, ctx, files = board_env async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="", sources=None, seeds=None): return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}} monkeypatch.setattr(ba, "_generate_block", empty_gen) await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate", {"title": "Leerer Block", "description": "d"}) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), timeout=30) assert ok card = await db.kanban_get_card(TOPIC, "artefacts", "leer") assert card["stage"] == "done_artefact" assert card["retries"] == 0 and not card.get("last_error") assert TOPIC not in blx._blocks_errors # kein globales Banner async def test_reader_union_folds_exact_dupes(testdb): db = testdb assert await db.kanban_add_title(TOPIC, B, "x", "X", "d", "s1", "r1") is True assert await db.kanban_add_title(TOPIC, B, "x", "X", "d länger", "s2", "r2") is False card = await db.kanban_get_card(TOPIC, B, "x") assert set(card["payload"]["readers"]) == {"r1", "r2"} assert set(card["payload"]["sources"]) == {"s1", "s2"} assert card["payload"]["description"] == "d länger" # ── Fragment-Filter: Zweitmeinung, Containment, Floor, Supplement-Reopen ──────────── def _slot_router(handlers, counter=None): """Fully scripted judge: first matching key-substring wins, its JSON lands at out_path.""" async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None): m = _PATH_RE.search(prompt) out = None for pat, h in handlers: if pat in key: if counter is not None: counter[pat] = counter.get(pat, 0) + 1 out = h(key) if callable(h) else h break if m and out is not None: with open(m.group(1), "w", encoding="utf-8") as f: json.dump(out, f) return "ok", payload(None) return fake def _mk_flow(tmp_path): import asyncio from types import SimpleNamespace return SimpleNamespace(topic=TOPIC, work_dir=tmp_path, state={}, wake=asyncio.Event()) async def _run_filter(db, ctx, tmp_path, cards): """Seed block cards into fragment_filter and run ONE barrier pass over them.""" for cid, p in cards: await db.kanban_upsert_card(TOPIC, B, cid, "block", "fragment_filter", p) rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards] await bi._proc_fragment_filter(ctx, _mk_flow(tmp_path), rows) def _confirm_votes(votes, verdict): """Recheck judge j∈votes returns `verdict`, the rest keep everything.""" return lambda key: verdict if key.rsplit("-j", 1)[1] in votes else {"fragments": {}, "drop": []} async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch): """Judge-Demote ist nur Vorschlag — 2 Panel-Stimmen bestätigen → rejected.""" db, ctx, files = board_env monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})), ("-filter-", {"fragments": {"1": 2}, "drop": []}), ])) await _run_filter(db, ctx, tmp_path, [ ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}), ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}), ]) c1 = await db.kanban_get_card(TOPIC, B, "b-1") assert c1["stage"] == "rejected" assert c1["payload"]["reason"] == "fragment" assert c1["payload"]["parent_norm"] == "codeblock" assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup" async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch): """Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal).""" db, ctx, files = board_env monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})), ("-filter-", {"fragments": {"1": 2}, "drop": []}), ])) await _run_filter(db, ctx, tmp_path, [ ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}), ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}), ]) assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup" journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8")) assert journal["ueberstimmt"] == ["Blockzitat"] assert journal["degradiert"] == 0 async def test_containment_autoconfirm_skips_panel(board_env, tmp_path, monkeypatch): """Proposal mit Namens-Containment wird deterministisch committet — ohne Recheck-Call.""" db, ctx, files = board_env counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-filter-recheck-", {"fragments": {}, "drop": []}), ("-filter-", {"fragments": {"1": 2}, "drop": []}), ], counter)) await _run_filter(db, ctx, tmp_path, [ ("b-1", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}), ("b-2", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}), ]) c1 = await db.kanban_get_card(TOPIC, B, "b-1") assert c1["stage"] == "rejected" assert c1["payload"]["parent_norm"] == "aufgabenlisten" assert "-filter-recheck-" not in counter # no panel needed async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatch): """Orthogonale Titel-Vektoren: bestätigter Judge-Demote ohne Containment wird vetot, der Containment-Demote nicht.""" import numpy as np db, ctx, files = board_env async def emb_on(flow): return True async def ortho_vecs(flow, texts): uniq = list(dict.fromkeys(texts)) eye = np.eye(max(2, len(uniq))) pos = {t: eye[i] for i, t in enumerate(uniq)} return np.vstack([pos[t] for t in texts]) monkeypatch.setattr(bi, "_emb_ok", emb_on) monkeypatch.setattr(bi, "_vec_rows", ortho_vecs) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})), ("-filter-", {"fragments": {"1": 2, "3": 4}, "drop": []}), ])) await _run_filter(db, ctx, tmp_path, [ ("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}), ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}), ("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}), ("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}), ]) assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup" # floor veto assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8")) assert journal["floor_veto"] == ["Blockzitat"] async def test_filter_resume_no_new_calls(board_env, tmp_path, monkeypatch): """Zweiter Lauf über identischem Zustand resumed alle Judge-Dateien: 0 neue Calls.""" db, ctx, files = board_env counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})), ("-filter-", {"fragments": {"1": 2}, "drop": []}), ], counter)) cards = [("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}), ("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"})] await _run_filter(db, ctx, tmp_path, cards) first = dict(counter) assert first["-filter-"] == 1 and first["-filter-recheck-"] == 3 await _run_filter(db, ctx, tmp_path, cards) assert counter == first async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch): """Vorschlag trifft einen wegdegradierten Titel → Lineage wird wiedereröffnet; failed-quorum bleibt dedupt; frische Titel landen normal im ingest.""" db, ctx, files = board_env monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-supplement", {"blocks": [ {"title": "Blockzitate", "description": "Zitat-Syntax"}, {"title": "Leerzeilen", "description": "Trenner"}, {"title": "Neu-Konzept", "description": "fehlt kanonisch"}, ]}), ])) # dead lineage: title → cluster cl-1 → block b-cl-1 demoted as fragment await db.kanban_add_title(TOPIC, B, "blockzitate", "Blockzitate", "d", "s1", "r1") await db.kanban_advance(TOPIC, B, "blockzitate", "clustered") await db.kanban_set_member(TOPIC, "blockzitate", "cl-1") await db.kanban_upsert_card(TOPIC, B, "cl-1", "cluster", "done_cluster", {"title": "Blockzitate"}) await db.kanban_upsert_card(TOPIC, B, "b-cl-1", "block", "rejected", {"title": "Blockzitate (Blockquotes)", "reason": "fragment", "cluster": "cl-1", "parent_norm": "codeblöcke"}) # failed-quorum lineage: deliberately rejected as non-block → must stay deduped await db.kanban_add_title(TOPIC, B, "leerzeilen", "Leerzeilen", "d", "s1", "r1") await db.kanban_advance(TOPIC, B, "leerzeilen", "clustered") await db.kanban_set_member(TOPIC, "leerzeilen", "cl-2") await db.kanban_upsert_card(TOPIC, B, "cl-2", "cluster", "rejected", {"title": "Leerzeilen", "reason": "failed-quorum"}) flow = _mk_flow(tmp_path) flow.state["instructions"] = "" await bi._supplement_producer(ctx, flow, ["Codeblöcke"]) reopened = await db.kanban_get_card(TOPIC, B, "blockzitate") assert reopened["stage"] == "cluster" assert reopened["payload"]["supplement"] is True assert (await db.kanban_get_card(TOPIC, B, "leerzeilen"))["stage"] == "clustered" fresh = await db.kanban_get_card(TOPIC, B, "neu-konzept") assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True # ── Dedup-Stage: globaler Paar-Abgleich nach dem Naming ───────────────────────────── def _angle_vecs(mapping): """Vector fake with controllable cosine: mapped substring → angle (degrees) in a shared 2D plane; unmapped texts get their own orthogonal axis (cos 0 to everything).""" import math as m import numpy as np async def fake(flow, texts): dim = 2 + len(texts) rows = [] for i, t in enumerate(texts): v = np.zeros(dim) for key, deg in mapping.items(): if key in t: v[0], v[1] = m.cos(m.radians(deg)), m.sin(m.radians(deg)) break else: v[2 + i] = 1.0 rows.append(v) return np.vstack(rows) return fake async def _run_dedup(db, ctx, tmp_path, cards): """Seed block cards into dedup and run ONE barrier pass over them.""" for cid, p in cards: await db.kanban_upsert_card(TOPIC, B, cid, "block", "dedup", p) rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards] await bi._proc_dedup(ctx, _mk_flow(tmp_path), rows) @pytest.fixture def emb_on(monkeypatch): async def yes(flow): return True monkeypatch.setattr(bi, "_emb_ok", yes) async def test_dedup_merges_confirmed_pair(board_env, tmp_path, monkeypatch, emb_on): """Judge-„ja" merged: Verlierer → grouped (reason/merged_into), Champion sammelt reader.""" # Titel-Cos ~0.77 (unter Auto-0.95) → Kandidat, der Judge entscheidet monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40})) counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja"}}), ], counter)) await _run_dedup(db_ := board_env[0], ctx := board_env[1], tmp_path, [ ("b-1", {"title": "SAT", "description": "kurz", "readers": ["r1"]}), ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "Erfüllbarkeit Boolescher Ausdrücke", "readers": ["r2"]}), ]) loser = await db_.kanban_get_card(TOPIC, B, "b-1") champ = await db_.kanban_get_card(TOPIC, B, "b-2") assert loser["stage"] == "grouped" assert loser["payload"]["reason"] == "merged" assert loser["payload"]["merged_into"] == "SAT (Satisfiability Problem)" assert champ["stage"] == "grouping" assert set(champ["payload"]["readers"]) == {"r1", "r2"} journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8")) assert journal["merged"] == [{"dublette": "SAT", "in": "SAT (Satisfiability Problem)"}] assert journal["paare_detail"][0]["verdict"] == "ja" assert counter["-dedup-"] == 2 # Zwei-Judge-Panel async def test_dedup_judge_nein_keeps_both(board_env, tmp_path, monkeypatch, emb_on): monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"modifizierter": 0, "greedy": 40})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "nein"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "Greedy-Algorithmus", "description": "Basisverfahren"}), ("b-2", {"title": "Modifizierter Greedy-Algorithmus", "description": "Variante"}), ]) assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping" async def test_dedup_relation_guard_blocks_identical_tokens(board_env, tmp_path, monkeypatch, emb_on): """Gleiche Tokens, andere Richtung: Judge sagt „ja", Titel-Cos 1.0 (Auto-Kante) — der Relation-Guard blockt beides, beide Karten überleben.""" monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"hamiltonian": 0})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", lambda key: {"pairs": {"1": "ja"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "Hamiltonian Cycle ≤ Hamiltonian Path", "description": "Reduktion"}), ("b-2", {"title": "Hamiltonian Path ≤ Hamiltonian Cycle", "description": "Reduktion"}), ]) assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping" journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8")) assert journal["paare_detail"][0]["verdict"] == "guard_veto" async def test_dedup_panel_disagreement_keeps_both(board_env, tmp_path, monkeypatch, emb_on): """Merge braucht Einstimmigkeit: j1 ja + j2 nein → beide überleben (Journal: uneinig).""" monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", lambda key: {"pairs": {"1": "ja" if "-j1" in key else "nein"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "SAT", "description": "d1"}), ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"}), ]) assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping" journal = json.loads(next(tmp_path.glob("inventar-dedup-*.json")).read_text(encoding="utf-8")) assert journal["paare_detail"][0]["verdict"] == "uneinig" async def test_dedup_title_only_candidate(board_env, tmp_path, monkeypatch, emb_on): """Titel-Cos über dem Floor reicht als Kandidat — auch wenn der Mittelwert (verschiedene Beschreibungs-Facetten) darunter liegt.""" # Beschreibungen fast orthogonal (0° vs 80°), Titel ähnlich (0° vs 40°) monkeypatch.setattr(bi, "_vec_rows", _angle_vecs( {"völlig": 0, "ganz": 80, "alpha kern": 0, "alpha zentrum": 40})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "Alpha Kern", "description": "völlig anderes"}), ("b-2", {"title": "Alpha Zentrum", "description": "ganz anders zwei"}), ]) stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"] for c in ("b-1", "b-2")]) assert stages == ["grouped", "grouping"] # Paar wurde gejudged und merged def test_canonical_key_camel_and_catalogue(): """CamelCase-Split + Katalog-Phrasen-Strip; Varianten-Ziffern bleiben erhalten.""" from blocks import _canonical_key as k assert k("Set Cover") == k("SetCover-Problem") != "" assert k("Definition 6.19 (NP)") == "np" assert k("SAT") != k("3-SAT") # Varianten-Ziffer ist Signal, keine Katalognummer assert k("ModifiedGreedy") == k("Modified Greedy") # englische Katalog-Phrasen gleichwertig (Quellen sind nicht immer deutsch) assert k("Corollary 3.2 (VC)") == "vc" assert k("Chapter 7: Vertex Cover") == k("Vertex Cover") assert k("Section 2.1 Matching") == k("Matching") def test_relation_guard_ignores_trailing_scaffolding(): """Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard den korrekten Merge; Richtungs-Konflikte bleiben erkannt.""" from blocks import _relation_conflict as c assert not c("SetCover ≤ HittingSet", "SetCover ≤ HittingSet Reduktion") assert not c("A → B", "A → B Transformation") assert c("Hamiltonian Cycle ≤ Hamiltonian Path", "Hamiltonian Path ≤ Hamiltonian Cycle") def test_relation_guard_english_and_operator_suffix(): """Englisches „Reduction" ist Scaffolding wie „Reduktion"; ein angehängtes p/m am Operator („≤p") gehört zum Operator, nicht zum Operanden. Beides waren Fehl-Vetos im aak-Lauf. Varianten-Konflikte bleiben.""" from blocks import _relation_conflict as c assert not c("3-Exact Cover ≤ SubSet Sum", "Reduction 3-EXACT COVER ≤ SUBSET SUM") assert not c("k-CLIQUE ≤ k-INDEPENDENT SET", "Reduction k-CLIQUE ≤ k-INDEPENDENT SET") assert not c("3-SAT ≤ 3-Färbung", "3-SAT ≤p 3-Färbung") assert c("SAT ≤ Clique", "3-SAT ≤ Clique") # Variante als Operand bleibt Konflikt async def test_dedup_casefold_title_candidate(board_env, tmp_path, monkeypatch, emb_on): """GROSSSCHREIBUNG darf den Titel-Kanal nicht brechen: Titel werden casefolded eingebettet („VERTEX COVER" vs. „Vertex Cover (VC)" lag real bei Cos 0.55).""" monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"vertex cover": 0})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "VERTEX COVER", "description": "knapp"}), ("b-2", {"title": "Vertex Cover (VC)", "description": "Knotenüberdeckung ausführlich"}), ]) stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"] for c in ("b-1", "b-2")]) assert stages == ["grouped", "grouping"] async def test_dedup_no_embedding_passes_through(board_env, tmp_path, monkeypatch): """Ohne Embedding-Modell winkt die Stage durch — 0 Agent-Calls (kein n²-Fallback).""" counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([("-dedup-", {"pairs": {}})], counter)) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "SAT", "description": "d"}), ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d"}), ]) assert (await board_env[0].kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" assert (await board_env[0].kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping" assert counter == {} async def test_dedup_second_wave_merges_into_context(board_env, tmp_path, monkeypatch, emb_on): """Supplement-Welle: Neuling merged in den bestätigten Block; der bleibt unberührt.""" db, ctx, files = board_env monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja"}}), ])) await db.kanban_upsert_card(TOPIC, B, "b-old", "block", "done_block", {"title": "SAT", "description": "Erfüllbarkeitsproblem", "readers": ["r1"], "mirrored_norm": "sat"}) await db.upsert_block(TOPIC, "sat", "SAT", "Erfüllbarkeitsproblem", []) await _run_dedup(db, ctx, tmp_path, [ ("b-new", {"title": "SAT (Satisfiability Problem)", "description": "kurz", "readers": ["r9"]}), ]) new = await db.kanban_get_card(TOPIC, B, "b-new") old = await db.kanban_get_card(TOPIC, B, "b-old") assert new["stage"] == "grouped" assert new["payload"]["merged_into"] == "SAT" assert old["stage"] == "done_block" # context never demoted assert set(old["payload"]["readers"]) == {"r1", "r9"} async def test_dedup_resume_no_new_calls(board_env, tmp_path, monkeypatch, emb_on): monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"satisfiability": 0, "sat": 40})) counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "nein"}}), ], counter)) cards = [("b-1", {"title": "SAT", "description": "d1"}), ("b-2", {"title": "SAT (Satisfiability Problem)", "description": "d2"})] await _run_dedup(board_env[0], board_env[1], tmp_path, cards) assert counter["-dedup-"] == 2 # zwei Panel-Judges await _run_dedup(board_env[0], board_env[1], tmp_path, cards) assert counter["-dedup-"] == 2 # judge files reused async def test_dedup_complete_link_no_chaining(board_env, tmp_path, monkeypatch, emb_on): """A≈B ja, B≈C ja, A–C kein Kandidat → complete-link merged nur ein Paar.""" # Winkel 0/30/60: A-B und B-C sind Kandidaten (cos .87), A-C nicht (cos .5 < Floor) monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"alpha": 0, "beta": 30, "gamma": 60})) monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja", "2": "ja"}}), ])) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "Konzept Alpha", "description": "a"}), ("b-2", {"title": "Konzept Beta", "description": "bb"}), ("b-3", {"title": "Konzept Gamma", "description": "c"}), ]) db = board_env[0] stages = {cid: (await db.kanban_get_card(TOPIC, B, cid))["stage"] for cid in ("b-1", "b-2", "b-3")} assert sorted(stages.values()) == ["grouped", "grouping", "grouping"] # ── Makespan: Slot-Priorität, vorgezogene Gliederung ──────────────────────────────── def test_agent_priority_order(): """Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub).""" from agents import _agent_priority as p t = "blocks-Markdown" assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-dedup-abc-c0") < p(f"{t}-supplement") # Gruppierung-Keys heißen "gruppierung": ohne eigenen Eintrag fielen sie ans Ende # und verhungerten hinter Board 2 (aak: 211 min Slot-Wartezeit) assert p(f"{t}-dedup-abc-c0") < p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-supplement") assert p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-ns-subblock-c1-r2-1") assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0") < p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0") < p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0") < p(f"{t}-ns-subblock-c1-r2-1")) assert p(f"{t}-supplement") < p(f"{t}-outline-1") assert p("guide-t-writer-k1") == 18 # unmatched → after everything async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch): """Gliederung startet, sobald alle Karten die generate-Stage passiert haben — parallel zu den restlichen Artefakt-Stages des langsamsten Blocks.""" import asyncio import board_artefacts as ba db, ctx, files = board_env await _seed(db) base_verify = ba._verify_block snapshot = {} async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None): await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources) base_outline = ba._outline_block async def spy_outline(ctx, set_p, files, entries, instructions): cards = await db.kanban_cards(TOPIC, board="artefacts", kind="ablock") snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact") return await base_outline(ctx, set_p, files, entries, instructions) monkeypatch.setattr(ba, "_verify_block", slow_verify) monkeypatch.setattr(ba, "_outline_block", spy_outline) ok = await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False), timeout=30) assert ok assert snapshot["unfinished"] > 0 # outline ran while blocks were still in levels+ outline = await db.get_outline(TOPIC) assert outline and "Kapitel 1" in outline async def test_outline_facts_from_payloads(board_env, tmp_path): """_proc_outline speist die Prereq-Hints aus den Karten-Payloads — unabhängig vom globalen facts.json (das erst finalize schreibt).""" import board_artefacts as ba db, ctx, files = board_env await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block", {"title": "Alpha", "description": "d"}) await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify", {"title": "Alpha", "facts": {"Alpha": {"sub eins": { "sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}}) await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline", {"title": "Gliederung"}) flow = _mk_flow(tmp_path) await ba._proc_outline(ctx, flow, files, "", [{"card_id": "outline", "payload": {}}]) merged = json.loads((tmp_path / "outline-facts.json").read_text(encoding="utf-8")) assert merged["Alpha"]["sub eins"]["prerequisites"] == "Beta zuerst" assert (await db.kanban_get_card(TOPIC, "artefacts", "outline"))["stage"] == "done_artefact" async def test_card_view_stepper(testdb): """Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar.""" r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0, "payload": {"title": "Alpha"}} v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}}) assert v["status"] == "active" and v["info"] == "Fix 1/2…" assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify" # legacy plain-string live info → no stepper, no crash v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"}) assert v2["info"] == "Verify 0/1…" and "step_n" not in v2 # step outside the card's stage group (e.g. supplement note) → no stepper v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}}) assert "step_n" not in v3 async def test_seed_map_resolves_cascade(testdb): """Seeds folgen der Redirect-Kette bis zum lebenden Block; Zyklen/Dead-Ends verfallen.""" import board_artefacts as ba db = testdb # chain: fragment → grouped member → living umbrella (with self-edge Listen→Listen) await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "rejected", {"title": "Blockzitate (Blockquotes)", "reason": "fragment", "parent_norm": "eingerückte codeblöcke"}) await db.kanban_upsert_card(TOPIC, B, "b-2", "block", "grouped", {"title": "Eingerückte Codeblöcke", "merged_into": "Codeblöcke"}) await db.kanban_upsert_card(TOPIC, B, "b-3", "block", "done_block", {"title": "Codeblöcke", "mirrored_norm": "codeblöcke"}) await db.kanban_upsert_card(TOPIC, B, "b-4", "block", "grouped", {"title": "Listen", "merged_into": "Listen"}) await db.kanban_upsert_card(TOPIC, B, "b-5", "block", "done_block", {"title": "Listen", "mirrored_norm": "listen"}) await db.kanban_upsert_card(TOPIC, B, "b-6", "block", "rejected", {"title": "Aufgabenlisten", "reason": "fragment", "parent_norm": "listen"}) # cycle: a → b → a, neither alive await db.kanban_upsert_card(TOPIC, B, "b-7", "block", "rejected", {"title": "A-Ding", "reason": "fragment", "parent_norm": "b-ding"}) await db.kanban_upsert_card(TOPIC, B, "b-8", "block", "rejected", {"title": "B-Ding", "reason": "fragment", "parent_norm": "a-ding"}) seeds = await ba._seed_map(TOPIC) # grouped umbrella members become seeds of their living target too (whole absorbed topics) assert {k: sorted(v) for k, v in seeds.items()} == { "codeblöcke": ["Blockzitate (Blockquotes)", "Eingerückte Codeblöcke"], "listen": ["Aufgabenlisten", "Listen"]} def test_per_block_functions_accept_wrapper_kwargs(): """Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) — ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur).""" import inspect import block_calls as bc for fn in ("_generate_block", "_verify_block", "_artefakte_block"): params = inspect.signature(getattr(bc, fn)).parameters assert "ns" in params and "lbl" in params, fn assert "seeds" in inspect.signature(bc._generate_block).parameters for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn # ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ───────────────────── def test_clean_title_strips_markdown_only(): """`**` und Backticks fliegen; Math-Zeichen (|, _, einzelnes *) bleiben.""" from textkit import clean_title assert clean_title("**ListScheduling**") == "ListScheduling" assert clean_title("**LPT** - Algo") == "LPT - Algo" assert clean_title("`code` doppelt") == "code doppelt" assert clean_title("2|prec, pi∈{1,2}|Cmax") == "2|prec, pi∈{1,2}|Cmax" assert clean_title("x_i und P*") == "x_i und P*" def test_title_variants_acronym_expansion(): from blocks import _title_variants assert _title_variants("Satisfiability Problem (SAT)") == {"satisfiability problem", "sat"} assert _title_variants("DEA (Deterministischer Endlicher Automat)") == { "dea", "deterministischer endlicher automat"} assert _title_variants("SAT") == set() assert _title_variants("2|prec, pi∈{1,2}|Cmax") == set() async def test_dedup_acronym_pair_judged(board_env, tmp_path, monkeypatch, emb_on): """Kurzform vs. Langform liegt unterm Embedding-Floor (real: Cos 0.53) — die Akronym-Regel macht das Paar trotzdem zum Kandidaten, das Panel merged bei 2× ja.""" monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({})) # alles orthogonal counter = {} monkeypatch.setattr(bi, "run_single_slot", _slot_router([ ("-dedup-", {"pairs": {"1": "ja"}}), ], counter)) await _run_dedup(board_env[0], board_env[1], tmp_path, [ ("b-1", {"title": "SAT", "description": "kurz"}), ("b-2", {"title": "Satisfiability Problem (SAT)", "description": "lang"}), ]) stages = sorted([(await board_env[0].kanban_get_card(TOPIC, B, c))["stage"] for c in ("b-1", "b-2")]) assert stages == ["grouped", "grouping"] assert counter["-dedup-"] == 2 # Panel lief — kein Auto-Merge async def test_supplement_beleg_gate(board_env, tmp_path, monkeypatch): """Ohne Auszugs-Treffer fällt ein Vorschlag sofort; der Judge verwirft „nein"; nur belegte Vorschläge überleben. Antwort ist Text (no-tool), Engine persistiert.""" db, ctx, files = board_env korpus = tmp_path / "korpus" korpus.mkdir() (korpus / "Skript.txt").write_text( "Kapitel 1\nVertex Cover Definition und Übung dazu.\nMatching Grundlagen kurz.\n", encoding="utf-8") monkeypatch.setattr(bi, "source_folder", lambda t: korpus) calls = {} async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None): calls["key"], calls["caps"], calls["prompt"] = key, capabilities, prompt return bi.OK, payload((0, '{"relevant": {"1": "ja", "2": "nein"}}', "")) monkeypatch.setattr(bi, "run_single_slot", fake_slot) kept = await bi._supplement_beleg(ctx, _mk_flow(tmp_path), [ ("Vertex Cover", "Knotenüberdeckung"), ("Matching", "Paarung"), ("Quantencomputer", "nicht im Material"), ]) assert kept == [("Vertex Cover", "Knotenüberdeckung")] assert calls["caps"] == "none" and "-supplement-beleg" in calls["key"] assert "Quantencomputer" not in calls["prompt"] # fiel schon am Auszugs-Filter assert (tmp_path / "supplement-beleg.json").exists() # Resume-Guard async def test_ingest_strips_markdown_title(testdb, tmp_path): flow = _mk_flow(tmp_path) n = await bi._ingest_titles(flow, "1. **ListScheduling** — Greedy-Verfahren", "r1") assert n == 1 card = await testdb.kanban_get_card(TOPIC, B, "listscheduling") assert card["payload"]["title"] == "ListScheduling" # ── QA-Gate: Inventar-Prüfung vor Board 2 ──────────────────────────────────────────── async def _run_flow(ctx, files, timeout=30, **kw): import asyncio return await asyncio.wait_for( bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False, **kw), timeout=timeout) async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch): """Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate.""" import qa as qa_mod db, ctx, files = board_env async def bad_qa(topic, llm=False): return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) await _seed(db) ok = await _run_flow(ctx, files) assert ok warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate") assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 async def test_qa_gate_force_overrides(board_env, monkeypatch): """qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note.""" import qa as qa_mod db, ctx, files = board_env called = {"n": 0} async def bad_qa(topic, llm=False): called["n"] += 1 return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} monkeypatch.setattr(qa_mod, "qa_report", bad_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) await _seed(db) ok = await _run_flow(ctx, files, qa_force=True) assert ok assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5 assert called["n"] <= 1 # Gate-Lauf übersprungen; höchstens Abschluss-QA async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch): import qa as qa_mod db, ctx, files = board_env called = {"n": 0} async def spy_qa(topic, llm=False): called["n"] += 1 return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}} monkeypatch.setattr(qa_mod, "qa_report", spy_qa) monkeypatch.setattr(qa_mod, "_write_report", lambda r: None) monkeypatch.setattr(bi, "QA_GATE_NOTE", 0) await _seed(db) ok = await _run_flow(ctx, files) assert ok assert called["n"] == 1 # kein Gate-Lauf; nur die Abschluss-QA der Lauf-Summary async def test_qa_gate_fail_open(board_env, monkeypatch): """QA crasht → Gate öffnet, Flow läuft komplett durch (fail-open).""" import qa as qa_mod db, ctx, files = board_env async def broken_qa(topic, llm=False): raise RuntimeError("kaputt") monkeypatch.setattr(qa_mod, "qa_report", broken_qa) monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05) await _seed(db) ok = await _run_flow(ctx, files) assert ok assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5 def test_qa_view_pausiert_logic(tmp_path, monkeypatch): import qa as qa_mod import json as _json monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path) (tmp_path / TOPIC).mkdir() (tmp_path / TOPIC / "r1.json").write_text(_json.dumps( {"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8") 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"] from types import SimpleNamespace laufend = SimpleNamespace(state={}) assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch # Bausteine gelöscht → kein Badge, obwohl der Report noch existiert assert bi._qa_view(TOPIC, {}, None) is None def test_qa_view_picks_newest_by_mtime(tmp_path, monkeypatch): """Run-id-Namen (…-1311-5e5c) sortieren lexikographisch VOR Zeitstempel-Namen — ein Re-Run überschreibt die run-id-Datei, das Badge muss trotzdem sie zeigen.""" import os import qa as qa_mod import json as _json monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path) (tmp_path / TOPIC).mkdir() alt = tmp_path / TOPIC / "20260703-141649.json" alt.write_text(_json.dumps({"note": 10.0, "quoten": {}}), encoding="utf-8") os.utime(alt, (1000, 1000)) neu = tmp_path / TOPIC / "20260703-1311-5e5c.json" neu.write_text(_json.dumps({"note": 8.9, "quoten": {}}), encoding="utf-8") os.utime(neu, (2000, 2000)) v = bi._qa_view(TOPIC, {"inventory": {"done_block": 3}}, None) assert v["note"] == 8.9 async def test_supplement_material_mode_for_source_topics(board_env, tmp_path, monkeypatch): """Quellen-Thema: Supplement vergleicht gegen das MATERIAL (files, kein Web); thema-Modus behält die Websuche (voller Zugriff).""" db, ctx, files = board_env seen = {} async def spy_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None): seen[key] = (capabilities, prompt) m = _PATH_RE.search(prompt) if m and "-supplement" in key and "-beleg" not in key: with open(m.group(1), "w", encoding="utf-8") as f: json.dump({"blocks": []}, f) return "ok", payload(None) monkeypatch.setattr(bi, "run_single_slot", spy_slot) flow = _mk_flow(tmp_path) korpus = tmp_path / "korpus" korpus.mkdir() monkeypatch.setattr(bi, "source_folder", lambda t: korpus) await bi._supplement_producer(ctx, flow, ["Alpha"]) caps, prompt = seen[f"blocks-{TOPIC}-supplement"] assert caps == "files" assert "LEARNING MATERIAL" in prompt and "Do NOT search the web" in prompt seen.clear() (tmp_path / "supplement.json").unlink() # Resume-Guard zurücksetzen monkeypatch.setattr(bi, "source_folder", lambda t: None) await bi._supplement_producer(ctx, flow, ["Alpha"]) caps, prompt = seen[f"blocks-{TOPIC}-supplement"] assert caps == "full" assert "Research the subject area" in prompt # ── Anker-Gate: Quorum-Titel ohne Korpus-Beleg (Reader-Ko-Halluzination) ──────────── async def _anker_env(db, tmp_path, monkeypatch, titel_map, desc_map=None): (tmp_path / "korpus.txt").write_text("Der Graph ist zusammenhängend und endlich.", encoding="utf-8") monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path) async def fake_members(topic, cid): return [{"title": titel_map[cid], "description": (desc_map or {}).get(cid, ""), "readers": ["r1", "r2"], "supplement": False}] monkeypatch.setattr(bi, "_member_rows", fake_members) monkeypatch.setattr(bi, "_rep", lambda rows: rows[0]) for cid in titel_map: await db.kanban_upsert_card(TOPIC, B, cid, "cluster", "consensus_gate", {}) ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) return ctx, [{"card_id": c, "payload": {}} for c in titel_map] async def test_anker_gate_rejects_unbelegtes(testdb, tmp_path, monkeypatch): """Titel ohne Korpus-Anker, aber mit Evidenz → Beleg-Judge; „nein" → rejected/kein-beleg. Titel MIT Anker geht ohne Judge nach naming.""" db = testdb ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c1": "Graph Zusammenhang", "c2": "Königsberger Brückenproblem"}, {"c2": "Der Graph ist endlich."}) # Evidenz da → Judge entscheidet async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout): assert "Brückenproblem" in prompt and "Zusammenhang" not in prompt # nur der Anker-lose return "ok", payload((0, json.dumps({"relevant": {"1": "nein"}}), "")) monkeypatch.setattr(bi, "run_single_slot", fake_slot) await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards) assert (await db.kanban_get_card(TOPIC, B, "c1"))["stage"] == "naming" c2 = await db.kanban_get_card(TOPIC, B, "c2") assert c2["stage"] == "rejected" and c2["payload"]["reason"] == "kein-beleg" async def test_anker_gate_leeres_pack_hart_nein(testdb, tmp_path, monkeypatch): """KEIN distinktives Token im Korpus → deterministisch rejected, Judge läuft NICHT (der Judge winkte 3 Kanon-Titel auf Schein-Auszügen durch).""" db = testdb ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c9": "Königsberger Brückenproblem"}) async def never_slot(*a, **kw): raise AssertionError("Judge darf bei leerem Evidence-Pack nicht laufen") monkeypatch.setattr(bi, "run_single_slot", never_slot) await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards) c9 = await db.kanban_get_card(TOPIC, B, "c9") assert c9["stage"] == "rejected" and c9["payload"]["reason"] == "kein-beleg" async def test_anker_gate_fail_open(testdb, tmp_path, monkeypatch): """Judge-Ausfall bei VORHANDENER Evidenz → Titel bleibt (2-Reader-Rückhalt).""" db = testdb ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c9": "Königsberger Brückenproblem"}, {"c9": "Der Graph ist endlich."}) async def broken_slot(*a, **kw): return "failed", None monkeypatch.setattr(bi, "run_single_slot", broken_slot) await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), cards) assert (await db.kanban_get_card(TOPIC, B, "c9"))["stage"] == "naming" def test_hat_anker_ziffern_suffix(): ctoks = {"tsp", "graph", "kanten"} assert bi._hat_anker("ΔTSP1-Algorithmus", ctoks) # tsp1 → tsp assert not bi._hat_anker("Königsberger Brückenproblem", ctoks) assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path): """Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen.""" db = testdb await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"}) await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus") arbeit = tmp_path / "arbeit" (arbeit / "ab-alpha").mkdir(parents=True) (arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8") files = {"arbeit": arbeit} for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"): files[k] = tmp_path / f"{k}.json" files[k].write_text("{}", encoding="utf-8") moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files) assert moved == 1 assert not await db.list_subblocks(TOPIC) assert not (arbeit / "ab-alpha").exists() assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte")) # ── Naming-Abstraktion: freier Name nur mit Anker ─────────────────────────────────── def test_naming_schema_varianten(): assert bi._naming_schema({"best": 2}, 3) == (2, None, False) assert bi._naming_schema({"best": 1, "name": "Kurzer Titel"}, 3) == (1, "Kurzer Titel", False) assert bi._naming_schema({"ok": True}, 3) == (None, None, True) assert bi._naming_schema({"best": 9}, 3) is None assert bi._naming_schema({"best": 1, "name": "x" * 90}, 3) == (1, None, False) # zu lang def test_name_verankert_thema_subset(): rows = [{"title": "Bubblesort-Schleife und Tauschoperation", "description": "innere Schleife"}, {"title": "Bubblesort Durchläufe", "description": ""}] assert bi._name_verankert("Bubblesort Tauschoperation", rows, None) assert not bi._name_verankert("Königsberger Brückenproblem", rows, None) # fremde Begriffe assert not bi._name_verankert("und der", rows, None) # nur Stopwörter → kein Anker def test_name_verankert_korpus(): ctoks = {"partition", "problem", "vollständigkeit"} rows = [{"title": "irrelevant", "description": ""}] assert bi._name_verankert("Partition-Problem", rows, ctoks) assert not bi._name_verankert("Rucksackproblem Optimierung", rows, ctoks) async def test_naming_vergibt_verankerten_namen(testdb, tmp_path, monkeypatch): """Judge liefert best+name; verankerter Name gewinnt, unverankerter fällt auf Member zurück.""" db = testdb antwort = {"val": {"best": 1, "name": "Bubblesort Grundprinzip"}} async def fake_members(topic, cid): return [{"norm": "bubblesort - grundprinzip und ablauf (kap. 2)", "title": "Bubblesort - Grundprinzip und Ablauf (Kap. 2)", "description": "Sortieren durch Tauschen", "readers": ["r1"], "sources": []}, {"norm": "sortieren durch tauschen", "title": "Sortieren durch Tauschen", "description": "", "readers": ["r2"], "sources": []}] async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout): import json as _json return "ok", payload((0, _json.dumps(antwort["val"]), "")) monkeypatch.setattr(bi, "_member_rows", fake_members) monkeypatch.setattr(bi, "run_single_slot", fake_slot) await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", {}) ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": {}}) card = await db.kanban_get_card(TOPIC, B, "c1") assert card["payload"]["title"] == "Bubblesort Grundprinzip" # unverankerter Name → Member-Titel gewinnt antwort["val"] = {"best": 2, "name": "Vergleichsbasierte Sortierverfahren"} await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", {}) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": {}}) card = await db.kanban_get_card(TOPIC, B, "c2") assert card["payload"]["title"] == "Sortieren durch Tauschen" async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch): """Check-Judge bestätigt mit ok:true → Titel und Beschreibung bleiben unverändert.""" db = testdb async def fake_members(topic, cid): return [{"norm": "a", "title": "A", "description": "da", "readers": ["r1"], "sources": []}, {"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}] async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout): assert "Eigener Titel" in prompt # current_title steht im Prompt return "ok", payload((0, '{"ok": true}', "")) monkeypatch.setattr(bi, "_member_rows", fake_members) monkeypatch.setattr(bi, "run_single_slot", fake_slot) payload = {"title": "Eigener Titel", "description": "Eigene Beschreibung", "main_norm": "a"} await db.kanban_upsert_card(TOPIC, B, "c9", "cluster", "naming_check", payload) ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c9", "payload": payload}) block = await db.kanban_get_card(TOPIC, B, "b-c9") assert block["payload"]["title"] == "Eigener Titel" assert block["payload"]["description"] == "Eigene Beschreibung" # ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ────── def test_sanierung_schema_varianten(): assert bi._sanierung_schema({"title": " k-Color ", "description": "d"}) == ("k-Color", "d") assert bi._sanierung_schema({"title": "", "description": "nur Beschreibung"}) == ("", "nur Beschreibung") assert bi._sanierung_schema({"title": "x" * 90, "description": "d"}) == ("", "d") # zu lang assert bi._sanierung_schema({"title": "", "description": ""}) is None assert bi._sanierung_schema("quatsch") is None def test_sanierung_noetig(): ctoks = {"color", "graph"} assert bi._sanierung_noetig({"title": "k-Color", "description": ""}, ctoks) # leere Beschreibung assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks) # kein Korpus-Anker assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks) assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None) # thema: kein Korpus def _sanierung_env(tmp_path, monkeypatch, antwort): """Korpus mit 'k-Color'-Oberflächenform; Judge antwortet mit `antwort`.""" (tmp_path / "korpus.txt").write_text( "Das k-Color Problem: Kann der Graph mit k Farben gefärbt werden? NP-vollständig.", encoding="utf-8") monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path) seen = {} async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout): seen[key] = prompt return "ok", payload((0, json.dumps(antwort["val"]), "")) monkeypatch.setattr(bi, "run_single_slot", fake_slot) return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False), seen async def test_singleton_unverankert_wird_saniert(testdb, tmp_path, monkeypatch): """Singleton-Cluster mit Reader-Titel ohne Korpus-Anker: Naming wird nicht mehr übersprungen — der Titel wird auf die Korpus-Oberflächenform umgeschrieben (gemessener aak-Fall 'k-Coloring' statt 'k-Color').""" db = testdb antwort = {"val": {"title": "k-Color", "description": "Kann der Graph mit k Farben gefärbt werden?"}} ctx, seen = _sanierung_env(tmp_path, monkeypatch, antwort) async def fake_members(topic, cid): return [{"norm": "k-coloring", "title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?", "readers": ["r1", "r2"], "sources": []}] monkeypatch.setattr(bi, "_member_rows", fake_members) payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"} await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", payload) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": payload}) card = await db.kanban_get_card(TOPIC, B, "c1") assert card["stage"] == "naming_check" assert card["payload"]["title"] == "k-Color" assert any("-sanierung-" in k for k in seen) async def test_leere_beschreibung_wird_gefuellt(testdb, tmp_path, monkeypatch): """Verankerter Titel, leere Beschreibung (gemessener aak-Fall der SetCover-Fragmente): Beschreibung wird belegt nachgefasst, Titel bleibt.""" db = testdb antwort = {"val": {"title": "k-Color", "description": "Färbbarkeit mit k Farben."}} ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort) async def fake_members(topic, cid): return [{"norm": "k-color", "title": "k-Color", "description": "", "readers": ["r1"], "sources": []}] monkeypatch.setattr(bi, "_member_rows", fake_members) payload = {"title": "k-Color", "description": ""} await db.kanban_upsert_card(TOPIC, B, "c5", "cluster", "naming", payload) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c5", "payload": payload}) card = await db.kanban_get_card(TOPIC, B, "c5") assert card["payload"]["title"] == "k-Color" assert card["payload"]["description"] == "Färbbarkeit mit k Farben." async def test_sanierung_unverankerter_vorschlag_verfaellt(testdb, tmp_path, monkeypatch): """Judge-Vorschlag ohne Korpus-Anker wird verworfen (dieselbe Messlatte wie QA-fremd); die Beschreibung wird trotzdem übernommen.""" db = testdb antwort = {"val": {"title": "Graphfärbungsproblem", "description": "Färbbarkeit mit k Farben."}} ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort) async def fake_members(topic, cid): return [{"norm": "k-coloring", "title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?", "readers": ["r1"], "sources": []}] monkeypatch.setattr(bi, "_member_rows", fake_members) payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"} await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", payload) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": payload}) card = await db.kanban_get_card(TOPIC, B, "c2") assert card["payload"]["title"] == "k-Coloring" # unverankert → verfällt assert card["stage"] == "naming_check" async def test_sanierung_fail_open(testdb, tmp_path, monkeypatch): """Judge-Ausfall → Karte läuft unverändert weiter (kein Deadletter am Naming).""" db = testdb (tmp_path / "korpus.txt").write_text("Der Graph ist endlich.", encoding="utf-8") monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path) async def broken_slot(*a, **kw): return "failed", None async def fake_members(topic, cid): return [{"norm": "x", "title": "Graph", "description": "", "readers": ["r1"], "sources": []}] monkeypatch.setattr(bi, "run_single_slot", broken_slot) monkeypatch.setattr(bi, "_member_rows", fake_members) payload = {"title": "Graph", "description": ""} await db.kanban_upsert_card(TOPIC, B, "c3", "cluster", "naming", payload) ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False) await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c3", "payload": payload}) card = await db.kanban_get_card(TOPIC, B, "c3") assert card["stage"] == "naming_check" and card["payload"]["title"] == "Graph" async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path, monkeypatch): """Verankerter Titel + Beschreibung vorhanden → kein Sanierungs-Call.""" db = testdb (tmp_path / "korpus.txt").write_text("Das k-Color Problem im Graph.", encoding="utf-8") monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path) async def never_slot(*a, **kw): raise AssertionError("Sanierung darf ohne Befund-Form nicht laufen") async def fake_members(topic, cid): return [{"norm": "k-color", "title": "k-Color", "description": "d", "readers": ["r1"], "sources": []}] monkeypatch.setattr(bi, "run_single_slot", never_slot) monkeypatch.setattr(bi, "_member_rows", fake_members) payload = {"title": "k-Color", "description": "d"} await db.kanban_upsert_card(TOPIC, B, "c4", "cluster", "naming", payload) 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"