"""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 "-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)) async def no_emb(flow): return False monkeypatch.setattr(bi, "_emb_ok", no_emb) async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns=""): title = list(entries.values())[0].split(" — ")[0] return {title: ["Sub Eins", "Sub Zwei"]} async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns=""): facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []} for s in subs} for t, subs in raw.items()} return facts, {} async def fake_levels(ctx, set_p, files, raw, instructions, ns=""): return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()} async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns=""): return {1: "relevant", 2: "peripheral"} async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns=""): return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}] for t, subs in sidecar.items()} async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns=""): return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"} for t, subs in sidecar.items()], "example": []} async def fake_outline(ctx, set_p, files, entries, instructions): return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]} for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts), ("_levels_block", fake_levels), ("_relevance_block", fake_relevance), ("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts), ("_outline_block", fake_outline)]: monkeypatch.setattr(ba, name, fn) 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"} # 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 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 ({} 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_subs(ctx, set_p, files, entries, instructions, wipe=True, ns=""): return {} monkeypatch.setattr(ba, "_subblocks_block", empty_subs) await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks", {"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"