update
This commit is contained in:
@@ -57,26 +57,26 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
return False
|
||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
||||
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||
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=""):
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""):
|
||||
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=""):
|
||||
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=""):
|
||||
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=""):
|
||||
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=""):
|
||||
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": []}
|
||||
|
||||
@@ -185,7 +185,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
|
||||
import blocks as blx
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||
return {}
|
||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
||||
@@ -208,3 +208,304 @@ async def test_reader_union_folds_exact_dupes(testdb):
|
||||
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"] == "grouping"
|
||||
|
||||
|
||||
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"] == "grouping"
|
||||
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"] == "grouping" # 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
|
||||
|
||||
|
||||
# ── 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}-supplement")
|
||||
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") == 16 # 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
|
||||
|
||||
Reference in New Issue
Block a user