Files
creator/backend/tests/test_board_inventory.py
2026-07-06 14:44:20 +02:00

1460 lines
73 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_generate(ctx, files, title, description, instructions="", ns="", lbl="",
sources=None, seeds=None, melde=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, melde=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="", melde=None):
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, melde=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_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
(15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
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"}),
])
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"
async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes(set(), {"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_canonical_key_glued_problem_suffix():
"""Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
„Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
from blocks import _canonical_key as k
assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
assert k("Set Cover") == k("SetCover-Problem") # bestehender Hyphen-Pfad bleibt
assert k("SAT") != k("3-SAT") # Varianten-Ziffer bleibt Signal
assert k("Problem") != k("Clique") # bloßes „Problem" wird nicht zum Stamm
def test_reference_strip_and_is_reference():
"""Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
from blocks import _reference_strip as strip, _is_reference as isref
assert strip("Satz 7.13 (Christofides)") == "Christofides"
assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
== "N P via nicht-deterministische Turingmaschine"
assert strip("Bemerkung 7.22") == ""
assert strip("Vertex Cover") == "Vertex Cover" # kein Katalog-Gerüst → unverändert
assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
assert isref("Bedingung (**)")
assert not isref("Satz 7.13 (Christofides)") # hat Konzept → keine Referenz
assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
assert not isref("P⊆NP") and not isref("Σ*") # kurze Symbole bleiben echt
def test_is_named_statement_construction_suffix():
"""Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
from blocks import _is_named_statement as named
assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
assert named("3-SAT ≤ Clique") # saubere Reduktion bleibt geschützt
assert named("Clique → Vertex Cover")
assert not named("X ist NP-vollständig") # unäre Aussage bleibt demotable
assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …") # benanntes Ergebnis bleibt
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 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, melde=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"
async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
"""M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
zum Konzept gestrippt ('Satz 7.13 (Christofides)''Christofides')."""
db = testdb
async def fake_members(topic, cid):
return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
"description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
{"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
return "ok", payload((0, '{"best": 1}', "")) # Judge wählt den numerierten Member-Titel
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
"main_norm": "satz 7.13 (christofides)"}
await db.kanban_upsert_card(TOPIC, B, "c7", "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": "c7", "payload": payload})
block = await db.kanban_get_card(TOPIC, B, "b-c7")
assert block["payload"]["title"] == "Christofides"
async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
"""M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
db = testdb
monkeypatch.setattr(bi, "source_folder", lambda t: None) # thema: kein Korpus
async def fake_members(topic, cid):
return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
"readers": ["r1"], "supplement": False}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
card = await db.kanban_get_card(TOPIC, B, "cs")
assert card["stage"] == "clarify"
assert card["payload"]["quorum"] == "majority"
# ── 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
# M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)
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"