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
|
||||
|
||||
174
backend/tests/test_events.py
Normal file
174
backend/tests/test_events.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Event-Tracking (events-Tabelle) + Agenten-Labels."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import agents
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
async def _events(db, kind=None):
|
||||
conn = await db.get_db()
|
||||
q = "SELECT topic, kind, key, label, status, dur_ms, wait_ms FROM events WHERE topic = ?"
|
||||
args = [TOPIC]
|
||||
if kind:
|
||||
q += " AND kind = ?"
|
||||
args.append(kind)
|
||||
cur = await conn.execute(q, args)
|
||||
return [dict(zip(("topic", "kind", "key", "label", "status", "dur_ms", "wait_ms"), r))
|
||||
for r in await cur.fetchall()]
|
||||
|
||||
|
||||
async def test_advance_many_writes_stage_events(testdb):
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||
await db.kanban_advance_many(TOPIC, "inventory", [("a", "s2"), ("b", "s2")])
|
||||
evs = await _events(db, "stage")
|
||||
assert {(e["key"], e["status"]) for e in evs} == {("inventory:a", "s2"), ("inventory:b", "s2")}
|
||||
|
||||
|
||||
async def test_fail_card_events_retry_then_dead(testdb):
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is False
|
||||
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is True
|
||||
evs = await _events(db, "fail")
|
||||
assert [e["status"] for e in evs] == ["retry1", "dead"]
|
||||
|
||||
|
||||
async def test_guide_stage_event(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.set_guide_card(TOPIC, "Guide", "alpha", stage="writer")
|
||||
evs = await _events(db, "stage")
|
||||
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and evs[-1]["status"] == "writer"
|
||||
|
||||
|
||||
async def test_run_agent_emits_event_and_survives_broken_sink(testdb, monkeypatch):
|
||||
recorded = []
|
||||
|
||||
async def sink(**kw):
|
||||
recorded.append(kw)
|
||||
|
||||
async def fake_cli(agent_key, prompt, timeout, model, capabilities, label=""):
|
||||
return 0, "out", ""
|
||||
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
monkeypatch.setattr(agents, "_run_claude_cli", fake_cli)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("claude", "test-model"))
|
||||
rc, out, err = await agents.run_agent("blocks-t-x", "p", 5, provider="claude",
|
||||
role="judge", scope=TOPIC, label="Alpha · Judge")
|
||||
assert rc == 0
|
||||
assert recorded and recorded[0]["kind"] == "agent"
|
||||
assert recorded[0]["label"] == "Alpha · Judge" and recorded[0]["status"] == "ok"
|
||||
assert isinstance(recorded[0]["wait_ms"], int) and isinstance(recorded[0]["dur_ms"], int)
|
||||
|
||||
# broken sink never breaks the call; interactive/scope-less calls don't log
|
||||
async def broken(**kw):
|
||||
raise RuntimeError("sink down")
|
||||
monkeypatch.setattr(agents, "on_event", broken)
|
||||
rc, _, _ = await agents.run_agent("blocks-t-y", "p", 5, provider="claude", scope=TOPIC)
|
||||
assert rc == 0
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
recorded.clear()
|
||||
await agents.run_agent("chat-1", "p", 5, provider="claude", lane="interactive")
|
||||
assert recorded == []
|
||||
|
||||
|
||||
async def test_active_agents_carry_labels():
|
||||
async def run(key, label):
|
||||
return await agents._communicate(key, ["sleep", "0.4"], None, 5, label=label)
|
||||
|
||||
t1 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 1"))
|
||||
t2 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 2")) # key collision → ~2
|
||||
await asyncio.sleep(0.15)
|
||||
agents_now = agents.active_agents("blocks-t-")
|
||||
assert sorted(a["label"] for a in agents_now) == ["Alpha · Facts 1", "Alpha · Facts 2"]
|
||||
assert {a["key"] for a in agents_now} == {"blocks-t-x", "blocks-t-x~2"}
|
||||
await asyncio.gather(t1, t2)
|
||||
assert agents.active_agents("blocks-t-") == []
|
||||
|
||||
async def test_pull_prefers_bigger_blocks(testdb):
|
||||
"""LPT: Karten mit größerem subs_n werden zuerst gezogen; ohne Feld bleibt FIFO."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "klein", "ablock", "facts", {"subs_n": 5})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gross", "ablock", "facts", {"subs_n": 40})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "mittel", "ablock", "facts", {"subs_n": 15})
|
||||
pulled = await db.kanban_pull(TOPIC, "artefacts", "facts", 10)
|
||||
assert [c["card_id"] for c in pulled] == ["gross", "mittel", "klein"]
|
||||
# ohne subs_n: FIFO nach updated_at
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
|
||||
assert [c["card_id"] for c in pulled] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_learnstate_smoke(testdb):
|
||||
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
|
||||
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
|
||||
from rules import load_learnstate
|
||||
guides, progress, levels = await load_learnstate()
|
||||
assert isinstance(levels, dict)
|
||||
|
||||
|
||||
async def test_guide_error_event(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.set_guide_card(TOPIC, "Guide", "alpha", status="error", gate_info="Writer ohne Ergebnis")
|
||||
evs = await _events(db, "fail")
|
||||
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and "Writer" in evs[-1]["status"]
|
||||
|
||||
|
||||
def test_timeout_calibration_smoke():
|
||||
from pipeline import _timeout
|
||||
assert _timeout("subblock", 10) == 400 + 150
|
||||
assert _timeout("content", 10) == 450 + 300
|
||||
|
||||
|
||||
def test_env_file_wins(tmp_path, monkeypatch):
|
||||
"""Regression: geerbte (veraltete) Env-Werte dürfen die .env nicht mehr überstimmen."""
|
||||
import config
|
||||
monkeypatch.setenv("X_CREATOR_TESTKEY", "alt")
|
||||
p = tmp_path / ".env"
|
||||
p.write_text("X_CREATOR_TESTKEY=neu\n", encoding="utf-8")
|
||||
config._load_env(p)
|
||||
import os
|
||||
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
|
||||
|
||||
|
||||
async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
import board_inventory as bi
|
||||
db = testdb
|
||||
for norm in ("alpha", "beta"):
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
|
||||
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
|
||||
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
|
||||
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
||||
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
|
||||
assert await db.list_subblocks(TOPIC, "alpha") == []
|
||||
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
||||
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
||||
|
||||
|
||||
async def test_guide_reset_card_single(testdb):
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
for n in ("alpha", "beta"):
|
||||
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
|
||||
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
|
||||
writer_rounds=2, md="# SECTION Text", gate_info="x")
|
||||
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
|
||||
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
|
||||
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
|
||||
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
|
||||
# ab_stage 3 (fakten_gate) behält md
|
||||
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
|
||||
@@ -66,3 +66,96 @@ async def test_done_step(testdb):
|
||||
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
|
||||
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
|
||||
|
||||
|
||||
async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch):
|
||||
"""Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim
|
||||
ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
await db.upsert_guide_card("t", "Guide", "alpha", "Alpha")
|
||||
env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide")
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"}
|
||||
seen = {}
|
||||
|
||||
async def fake_stage(env2, card2):
|
||||
seen.update(dict(gb._live_info))
|
||||
card2["stage"] = "done"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage})
|
||||
await gb._run_card(env, card, asyncio.Semaphore(1))
|
||||
assert card["stage"] == "done"
|
||||
assert ("t", "Guide", "alpha") in seen # live info stand während der Stage
|
||||
assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt
|
||||
|
||||
|
||||
def test_merge_split_sections_one_section_all_markers():
|
||||
import guide_board as gb
|
||||
from textkit import _parse_fragment
|
||||
a = _parse_fragment("""<!-- section: Front Matter -->
|
||||
<!-- compact -->
|
||||
Kurzer Einstieg kompakt.
|
||||
<!-- sub: beginner | YAML-Basics -->
|
||||
YAML kompakt.
|
||||
<!-- ausführlich -->
|
||||
Einstieg ausführlich.
|
||||
<!-- sub: beginner | YAML-Basics -->
|
||||
YAML ausführlich.""")[0]
|
||||
b = _parse_fragment("""<!-- section: Front Matter (Teil 2) -->
|
||||
<!-- compact -->
|
||||
<!-- sub: advanced | TOML-Sektionen -->
|
||||
TOML kompakt.
|
||||
<!-- ausführlich -->
|
||||
Unerwünschter zweiter Einstieg.
|
||||
<!-- sub: advanced | TOML-Sektionen -->
|
||||
TOML ausführlich.""")[0]
|
||||
merged = gb._merge_split_sections(a, b)
|
||||
secs = _parse_fragment(merged)
|
||||
assert len(secs) == 1
|
||||
sec = secs[0]
|
||||
assert sec["title"] == "Front Matter"
|
||||
assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"]
|
||||
assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen
|
||||
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
|
||||
|
||||
|
||||
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
|
||||
import guide_board as gb
|
||||
from types import SimpleNamespace
|
||||
db = testdb
|
||||
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
|
||||
calls = []
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append(label)
|
||||
part = "2" if key.endswith("-b") else "1"
|
||||
p = tmp_path / f"out-{key[-1]}.md"
|
||||
p.write_text(f"<!-- section: Gross -->\n<!-- ausführlich -->\n"
|
||||
+ ("Einstieg.\n" if part == "1" else "")
|
||||
+ f"<!-- sub: beginner | Sub {part} -->\nText {part}.", encoding="utf-8")
|
||||
# payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad
|
||||
import re as _re
|
||||
m = _re.search(r"(/\S+\.md)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(p.read_text(encoding="utf-8"))
|
||||
return "ok", payload(None)
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)]
|
||||
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
|
||||
guide_id="g", topic="t", format="Guide", instructions="",
|
||||
subs_by_title={"Gross": subs}, spec="",
|
||||
slot=lambda name: tmp_path / name)
|
||||
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "")
|
||||
card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open",
|
||||
"writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"}
|
||||
ok = await gb._stage_writer(env, card)
|
||||
assert ok is True
|
||||
assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c]
|
||||
from textkit import _parse_fragment
|
||||
secs = _parse_fragment(card["md"])
|
||||
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
|
||||
assert card["stage"] == "fakten_gate"
|
||||
|
||||
295
backend/tests/test_subblocks.py
Normal file
295
backend/tests/test_subblocks.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks as blx
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||
|
||||
|
||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sims(pairs, n):
|
||||
m = np.eye(n)
|
||||
for i, j, v in pairs:
|
||||
m[i][j] = m[j][i] = v
|
||||
return m
|
||||
|
||||
|
||||
def test_variant_clusters_folds_paraphrases():
|
||||
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
|
||||
"Tabs werden expandiert"]
|
||||
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
|
||||
by_rep = {c["rep"]: c for c in cl}
|
||||
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
|
||||
assert by_rep[2]["mentions"] == 1
|
||||
|
||||
|
||||
def test_variant_clusters_negation_guard():
|
||||
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
|
||||
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
|
||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||
|
||||
|
||||
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||
|
||||
def _fake_sims(texts):
|
||||
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||
n = len(texts)
|
||||
m = np.eye(n)
|
||||
key = lambda t: t.split()[0].casefold()
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and key(texts[i]) == key(texts[j]):
|
||||
m[i][j] = 0.95
|
||||
return m
|
||||
|
||||
|
||||
def _mk_race(finder_by_agent):
|
||||
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||
prompts = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
key, prompt = slot["key"], slot["prompt"]
|
||||
prompts.append((key, prompt))
|
||||
text = None
|
||||
if "-subblock-final-" in key:
|
||||
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||
if l.startswith("- ") and l != "- (keiner)"]
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
elif "-r1-" in key:
|
||||
agent = int(key.rsplit("-", 1)[1])
|
||||
subs = finder_by_agent.get(agent) or []
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
if text is not None and (m := _MD_PATH.search(prompt)):
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
outs.append(slot["payload"](None))
|
||||
outs = [o for o in outs if o]
|
||||
return outs or None
|
||||
return fake_race, prompts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_env(testdb, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
files = {"arbeit": tmp_path}
|
||||
return testdb, ctx, files
|
||||
|
||||
|
||||
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||
fake, prompts = _mk_race(finder_by_agent)
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", seeds=seeds)
|
||||
return raw, prompts
|
||||
|
||||
|
||||
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||
nicht als „Unsicher" ins Panel."""
|
||||
db, ctx, files = sub_env
|
||||
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Umbruch braucht Marker"],
|
||||
2: ["Umbruch erfordert explizite Marker!"],
|
||||
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||
})
|
||||
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
status = sorted(r["status"] for r in rows)
|
||||
assert status == ["consensus", "variant", "variant"]
|
||||
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||
|
||||
|
||||
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||
2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Zeilenumbruch Regeln"])
|
||||
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Fußnoten Syntax"])
|
||||
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_title"] == "Fußnoten Syntax")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||
}, seeds=["Tabs"])
|
||||
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||
|
||||
|
||||
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||
db, ctx, files = sub_env
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert first == second
|
||||
|
||||
|
||||
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||
db, ctx, files = sub_env
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
base = fake
|
||||
hit = {"n": 0}
|
||||
|
||||
async def with_catchup(topic, label, slots, *a, **k):
|
||||
if any("-subblock-x" in s["key"] for s in slots):
|
||||
hit["n"] += 1
|
||||
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
|
||||
return [slot["payload"](None) for slot in slots[:2]]
|
||||
return None
|
||||
return await base(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||
|
||||
|
||||
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_outline_review_schema():
|
||||
valid = {1, 2, 3, 4, 5, 6}
|
||||
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
|
||||
assert ok == {3: 2}
|
||||
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
|
||||
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
|
||||
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
|
||||
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
|
||||
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
|
||||
|
||||
|
||||
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
|
||||
entries = {i: f"Block {i} — d" for i in range(1, 7)}
|
||||
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
|
||||
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
|
||||
for p in slots[:2]:
|
||||
p.write_text(json.dumps(plan_a), encoding="utf-8")
|
||||
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
|
||||
"facts": tmp_path / "facts.json"}
|
||||
|
||||
review_out = {"val": {"moves": {"6": 2}}}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
out = None
|
||||
if key.endswith("outline-prereqs"):
|
||||
out = {"prereqs": {}}
|
||||
elif key.endswith("outline-judge"):
|
||||
out = plan_a
|
||||
elif key.endswith("outline-review"):
|
||||
out = review_out["val"]
|
||||
if out is not None:
|
||||
m = re.search(r"(/\S+\.json)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f)
|
||||
return "ok", payload(None)
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan["chapters"][0]["numbers"] == [1, 2]
|
||||
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
|
||||
|
||||
# broken review (mass move) → schema rejects, plan unchanged
|
||||
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
|
||||
(tmp_path / "outline-review.json").unlink()
|
||||
(tmp_path / "outline.json").unlink()
|
||||
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||
|
||||
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||
db, ctx, files = sub_env
|
||||
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||
|
||||
async def with_r2(topic, label, slots, *a, **k):
|
||||
if any("-r2-" in s["key"] for s in slots):
|
||||
outs = []
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
return await base_fake(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_r2)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw["Alpha"] # Konsens steht
|
||||
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||
|
||||
|
||||
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||
db, ctx, files = sub_env
|
||||
_, prompts = _mk_race({})
|
||||
|
||||
async def endless(topic, label, slots, *a, **k):
|
||||
if "-subblock-final-" in slots[0]["key"]:
|
||||
return None # panel fails → consensus fallback
|
||||
outs = []
|
||||
import re as _re
|
||||
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||
n = rn.group(1) if rn else "x"
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
|
||||
prompts.append((slot["key"], slot["prompt"]))
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
|
||||
monkeypatch.setattr(blx, "_race", endless)
|
||||
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||
Reference in New Issue
Block a user