Files
creator/backend/tests/test_board_inventory.py
2026-07-04 02:32:31 +02:00

1066 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
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="", lbl="", sources=None):
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="", lbl=""):
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="", lbl=""):
return {1: "relevant", 2: "peripheral"}
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
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="", lbl=""):
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)}]}
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
return None
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), ("_konsolidiere_subblocks", fake_konsolidierung)]:
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_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="", seeds=None, lbl="", sources=None):
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"
# ── 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, AC 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 facts-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_levels = ba._levels_block
snapshot = {}
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
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, "_levels_block", slow_levels)
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", "levels",
{"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": "facts", "retries": 0,
"payload": {"title": "Alpha"}}
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
# legacy plain-string live info → no stepper, no crash
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
assert v2["info"] == "Facts find 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": "Subblocks find"}})
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-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
import inspect
import blocks as blx
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
"_question_pattern_block", "_artefacts_block"):
params = inspect.signature(getattr(blx, fn)).parameters
assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(blx, 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 subblocks."""
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="subblocks")
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": {"subblocks": 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):
(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": "", "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 → 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"})
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_fail_open(testdb, tmp_path, monkeypatch):
"""Judge-Ausfall → Titel bleibt (2-Reader-Rückhalt)."""
db = testdb
ctx, cards = await _anker_env(db, tmp_path, monkeypatch, {"c9": "Königsberger Brückenproblem"})
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