842 lines
42 KiB
Python
842 lines
42 KiB
Python
"""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))
|
||
|
||
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)}]}
|
||
|
||
for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts),
|
||
("_levels_block", fake_levels), ("_relevance_block", fake_relevance),
|
||
("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts),
|
||
("_outline_block", fake_outline)]:
|
||
monkeypatch.setattr(ba, name, fn)
|
||
|
||
work = tmp_path / "arbeit"
|
||
work.mkdir()
|
||
files = {"arbeit": work, "final": tmp_path / "blocks.md",
|
||
"sub_roh": tmp_path / "sub_roh.json", "sidecar": tmp_path / "subblocks.json",
|
||
"facts": tmp_path / "facts.json", "question_pattern": tmp_path / "question_pattern.json",
|
||
"artefakte": tmp_path / "artefakte.json", "outline": tmp_path / "outline.json",
|
||
"outline_slots": tmp_path / "outline_slots"}
|
||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||
return testdb, ctx, files
|
||
|
||
|
||
async def _seed(db):
|
||
# 2 consensus titles (2 readers), 1 single find (kept by panel), 1 artifact (pre-reject)
|
||
await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s1", "r1")
|
||
await db.kanban_add_title(TOPIC, B, "alpha", "Alpha", "Grundkonzept", "s2", "r2")
|
||
await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s1", "r1")
|
||
await db.kanban_add_title(TOPIC, B, "beta", "Beta", "Zweites Konzept", "s2", "r2")
|
||
await db.kanban_add_title(TOPIC, B, "gamma", "Gamma", "Einzelfund", "s1", "r1")
|
||
await db.kanban_add_title(TOPIC, B, "aufgabe 3", "Aufgabe 3", "Übungszettel", "s1", "r1")
|
||
|
||
|
||
async def test_board1_full_flow(board_env):
|
||
db, ctx, files = board_env
|
||
await _seed(db)
|
||
import asyncio
|
||
ok = await asyncio.wait_for(
|
||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||
timeout=30)
|
||
assert ok
|
||
done = await db.kanban_cards(TOPIC, board=B, stage="done_block")
|
||
titles = sorted(c["payload"]["title"] for c in done)
|
||
# Alpha/Beta/Gamma survive; the supplement's Zeta flows through the whole board too
|
||
assert titles == ["Alpha", "Beta", "Gamma", "Zeta-Konzept"]
|
||
rejected = await db.kanban_cards(TOPIC, board=B, stage="rejected")
|
||
assert [c["payload"]["title"] for c in rejected] == ["Aufgabe 3"]
|
||
assert rejected[0]["payload"]["reason"] == "pre-reject"
|
||
# legacy mirror carries the survivors as consensus
|
||
legacy = {b["title"] for b in await db.list_blocks(TOPIC, status="consensus")}
|
||
assert legacy == set(titles)
|
||
# blocks.md written in flow order
|
||
lines = files["final"].read_text(encoding="utf-8").strip().splitlines()
|
||
assert len(lines) == 4
|
||
# reader union survived the pipeline (consensus evidence on the block card)
|
||
alpha = next(c for c in done if c["payload"]["title"] == "Alpha")
|
||
assert set(alpha["payload"]["readers"]) == {"r1", "r2"}
|
||
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
|
||
|
||
|
||
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, A–C kein Kandidat → complete-link merged nur ein Paar."""
|
||
# Winkel 0/30/60: A-B und B-C sind Kandidaten (cos .87), A-C nicht (cos .5 < Floor)
|
||
monkeypatch.setattr(bi, "_vec_rows", _angle_vecs({"alpha": 0, "beta": 30, "gamma": 60}))
|
||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||
("-dedup-", {"pairs": {"1": "ja", "2": "ja"}}),
|
||
]))
|
||
await _run_dedup(board_env[0], board_env[1], tmp_path, [
|
||
("b-1", {"title": "Konzept Alpha", "description": "a"}),
|
||
("b-2", {"title": "Konzept Beta", "description": "bb"}),
|
||
("b-3", {"title": "Konzept Gamma", "description": "c"}),
|
||
])
|
||
db = board_env[0]
|
||
stages = {cid: (await db.kanban_get_card(TOPIC, B, cid))["stage"]
|
||
for cid in ("b-1", "b-2", "b-3")}
|
||
assert sorted(stages.values()) == ["grouped", "grouping", "grouping"]
|
||
|
||
|
||
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
|
||
|
||
def test_agent_priority_order():
|
||
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
|
||
from agents import _agent_priority as p
|
||
t = "blocks-Markdown"
|
||
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-dedup-abc-c0") < p(f"{t}-supplement")
|
||
# Gruppierung-Keys heißen "gruppierung": ohne eigenen Eintrag fielen sie ans Ende
|
||
# und verhungerten hinter Board 2 (aak: 211 min Slot-Wartezeit)
|
||
assert p(f"{t}-dedup-abc-c0") < p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-supplement")
|
||
assert p(f"{t}-gruppierung-xyz-cTOP") < p(f"{t}-ns-subblock-c1-r2-1")
|
||
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
|
||
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
|
||
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
|
||
< p(f"{t}-ns-subblock-c1-r2-1"))
|
||
assert p(f"{t}-supplement") < p(f"{t}-outline-1")
|
||
assert p("guide-t-writer-k1") == 18 # unmatched → after everything
|
||
|
||
|
||
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||
"""Gliederung startet, sobald alle Karten die 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"
|