This commit is contained in:
Team3
2026-07-04 23:47:04 +02:00
parent 2d9ca00b47
commit 4105146c59
40 changed files with 2053 additions and 3208 deletions

View File

@@ -0,0 +1,255 @@
"""Direkter Text-API-Pfad (MiniMax) + RAM-Gate für opencode-Spawns."""
import asyncio
import httpx
import pytest
import agents
TOPIC = "t"
# ── Routing: wann läuft ein Call über die API statt über opencode? ───────────────────
def test_use_text_api_routing(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.delenv("CREATOR_TEXT_API", raising=False)
api = agents._use_text_api
assert api("minimax", "minimax/MiniMax-M3", "none", None) is True
assert api("minimax", "minimax-kalt/MiniMax-M2.7-highspeed", "none", None) is True
# Tools, Streaming, andere Provider/Modelle → Prozess-Pfad
assert api("minimax", "minimax/MiniMax-M3", "files", None) is False
assert api("minimax", "minimax/MiniMax-M3", "none", lambda s: None) is False
assert api("claude", "claude-sonnet-4-6", "none", None) is False
assert api("lokal", "ollama/qwen3.5:9b", "none", None) is False
# Kill-Switch und fehlender Key → Fallback
monkeypatch.setenv("CREATOR_TEXT_API", "0")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
monkeypatch.delenv("CREATOR_TEXT_API")
monkeypatch.delenv("MINIMAX_API_KEY")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
async def test_run_agent_dispatches_to_api(monkeypatch):
"""none+minimax → API-Runner; opencode wird nicht angefasst (auch kein which-Check)."""
called = {}
async def fake_api(agent_key, prompt, timeout, model, label=""):
called["api"] = (agent_key, model)
return 0, "out", "", {"input": 1, "output": 1, "reasoning": 0, "cache_read": 0, "cache_write": 0}
async def fail_oc(*a, **kw):
raise AssertionError("opencode-Pfad darf nicht laufen")
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_run_opencode", fail_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: None) # API-Pfad braucht kein Binary
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, out, err = await agents.run_agent("blocks-t-a", "p", 5, provider="minimax", role="judge")
assert (rc, out) == (0, "out") and called["api"][1] == "minimax/MiniMax-M3"
async def test_run_agent_kill_switch_uses_opencode(monkeypatch):
called = {}
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
called["oc"] = agent_key
return 0, "out", ""
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setenv("CREATOR_TEXT_API", "0")
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-b", "p", 5, provider="minimax")
assert rc == 0 and called["oc"] == "blocks-t-b"
async def test_run_agent_api_tokens_in_event_meta(monkeypatch):
"""API-Tokens landen im Event-Meta; die OpenCode-Session-DB wird NICHT konsultiert."""
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_api(agent_key, prompt, timeout, model, label=""):
return 0, "out", "", {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_session_tokens", lambda k: pytest.fail("Session-DB-Lookup im API-Pfad"))
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
assert rc == 0
assert recorded and recorded[0]["meta"]["tokens"] == {
"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
# ── API-Runner: Request-Bau, Antwort-Extraktion, Fehlerfälle ─────────────────────────
class _FakeResp:
def __init__(self, status_code=200, data=None, text=""):
self.status_code = status_code
self._data = data or {}
self.text = text
def json(self):
return self._data
def _fake_client(monkeypatch, seen, resp=None, exc=None):
class _Client:
def __init__(self, **kw):
seen["client_kw"] = kw
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None, headers=None):
seen.update(url=url, body=json, headers=headers)
if exc is not None:
raise exc
return resp
monkeypatch.setattr(agents.httpx, "AsyncClient", _Client)
async def test_api_request_and_response(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "geheim")
seen = {}
resp = _FakeResp(data={
"content": [{"type": "thinking", "thinking": "hm"},
{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
"usage": {"input_tokens": 5, "output_tokens": 2,
"cache_read_input_tokens": 100, "cache_creation_input_tokens": 1},
"stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, tokens = await agents._run_text_api("k", "PROMPT", 5, "minimax-kalt/MiniMax-M3")
assert (rc, out, err) == (0, "AB", "") # Thinking-Block übersprungen
assert tokens == {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 1}
assert seen["url"] == agents._API_URL
assert seen["headers"]["x-api-key"] == "geheim"
assert seen["headers"]["anthropic-version"] == agents._API_VERSION
b = seen["body"]
assert b["model"] == "MiniMax-M3" and b["max_tokens"] == agents._API_MAX_TOKENS
assert b["messages"] == [{"role": "user", "content": "PROMPT"}]
assert b["temperature"] == 0.2 and b["thinking"] == {"type": "disabled"} # kalt-Route
async def test_api_model_options_per_route(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "x"}], "usage": {}})
_fake_client(monkeypatch, seen, resp=resp)
await agents._run_text_api("k", "p", 5, "minimax-kalt/MiniMax-M2.7-highspeed")
assert seen["body"]["temperature"] == 0.3 and "thinking" not in seen["body"]
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3") # nativ: Endpunkt-Defaults
assert "temperature" not in seen["body"] and "thinking" not in seen["body"]
async def test_api_errors(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
_fake_client(monkeypatch, seen, resp=_FakeResp(status_code=500, text="kaputt"))
rc, out, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and out == "" and "HTTP 500" in err and tokens is None
_fake_client(monkeypatch, seen, exc=httpx.ConnectError("down"))
rc, _, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "ConnectError" in err
_fake_client(monkeypatch, seen, exc=httpx.ReadTimeout("langsam"))
with pytest.raises(asyncio.TimeoutError):
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
# leere Antwort (nur Thinking) → rc 1, Tokens bleiben sichtbar
resp = _FakeResp(data={"content": [{"type": "thinking", "thinking": ""}],
"usage": {"input_tokens": 3}, "stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, _, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "empty response" in err and tokens["input"] == 3
async def test_api_truncation_flagged(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "halb"}],
"usage": {}, "stop_reason": "max_tokens"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 0 and out == "halb" and "max_tokens" in err
# ── RAM-Gate ─────────────────────────────────────────────────────────────────────────
@pytest.fixture
def ram_gate(monkeypatch):
monkeypatch.setattr(agents, "RAM_MIN_FREE_PCT", 20)
monkeypatch.setattr(agents, "_RAM_POLL_S", 0.01)
monkeypatch.setattr(agents, "_opencode_recent_starts", [])
return monkeypatch
async def test_ram_gate_admits_with_free_ram(ram_gate):
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
assert await agents._ram_gate("k") is True
assert len(agents._opencode_recent_starts) == 1 # Commit registriert
async def test_ram_gate_waits_when_low(ram_gate):
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
# RAM wird frei → Gate lässt nach ≥1 Poll durch
vals = iter([(800_000, 8_000_000)])
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
assert await agents._ram_gate("k") is True
async def test_ram_gate_floor_and_fail_open(ram_gate):
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
ram_gate.setattr(agents, "_opencode_running", 1) # unter Floor
assert await agents._ram_gate("k") is True
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
assert await agents._ram_gate("k") is True
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
assert await agents._ram_gate("k") is True
async def test_ram_gate_commit_accounting(ram_gate):
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt."""
import time as _time
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
agents._opencode_recent_starts.extend([_time.monotonic(), _time.monotonic()])
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
async def test_ram_gate_cancelled_scope(ram_gate):
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
agents.cancel_scope("blocks-cxl-")
try:
assert await agents._ram_gate("blocks-cxl-x") is False
finally:
agents.clear_scope("blocks-cxl-")
def test_meminfo_reads_proc():
mem = agents._meminfo()
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung

View File

@@ -0,0 +1,538 @@
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
import json
import pytest
import block_calls as bc
import blocks as blx
import board_artefacts as ba
from pipeline import FAILED, OK, GenContext
from textkit import _norm_title
TOPIC = "t"
def _ctx():
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
return {"title": title, "level": level, "relevance": relevance,
"key_points": [f"kp {title}"] if kp is None else kp,
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
# ── Schemas ─────────────────────────────────────────────────────────────────────────
def test_gen_schema_normalisiert():
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
out = bc._gen_schema({"subs": [
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
{"text": ""}, "quatsch"]},
{"title": "B", "level": "profi", "relevance": "mittel"},
{"title": " "},
]})
assert [e["title"] for e in out] == ["A", "B"]
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
assert out[0]["key_points"] == ["k"]
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
assert out[1]["level"] == "" and out[1]["relevance"] == ""
def test_gen_schema_kaputt_ist_none():
assert bc._gen_schema(None) is None
assert bc._gen_schema({"subs": "x"}) is None
assert bc._gen_schema({"subs": []}) is None
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
assert bc._verify_schema({}, 3) is None
assert bc._verify_schema({"irgendwas": 1}, 3) is None
v = bc._verify_schema({"gruppen": []}, 3)
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
def test_verify_schema_grenzen_und_normalisierung():
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
v = bc._verify_schema({
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
"fremd": [True, 1, "2", 9],
"luecken": [" x ", "", 7],
"uebernehmen": {"3": " JA ", "9": "ja"},
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
"levels": {"1": "expert", "2": "quatsch"},
"relevanz": {"1": "peripheral"},
}, 3)
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
assert v["fremd"] == {1, 2}
assert v["luecken"] == ["x"]
assert v["uebernehmen"] == {3: "ja"}
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
def test_art_gen_schema_pattern_ist_pflicht():
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
cards/examples sind best-effort und werden einzeln validiert."""
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
assert bc._art_gen_schema("x") is None
out = bc._art_gen_schema({
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
{"block": "B", "subblock": "", "question": "F?"}],
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
{"block": "B", "subblock": "S", "question": "F?"}],
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
})
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
"steps": ["s1"], "result": ""}]
def test_art_check_schema_varianten():
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
1-basiert, bools/0 zählen nicht."""
ok = bc._art_check_schema({"ok": True})
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
assert bc._art_check_schema({"foo": 1}) is None
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
assert v["examples_probleme"] == {1, 2, 3}
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
# ── Generate ────────────────────────────────────────────────────────────────────────
@pytest.fixture
def env(testdb, tmp_path, monkeypatch):
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
monkeypatch.setattr(bc, "material_folder", lambda t: None)
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
return testdb, _ctx(), {"arbeit": tmp_path}
def _mk_gen_race(outputs):
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
fehlender Eintrag = Ausfall."""
calls = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, **kw):
outs = []
for slot in slots:
calls.append(slot["key"])
g = int(slot["key"].rsplit("-g", 1)[1])
out = outputs.get(g)
if out is not None:
outs.append(slot["payload"]((0, json.dumps(out), "")))
return [o for o in outs if o] or None
fake_race.calls = calls
return fake_race
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
werden unsicher und gehen zum Prüfer."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus"
assert rows["sub b"] == rows["sub c"] == "candidate"
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
der Prüfer entscheidet mit Material."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": []}
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
async def test_generate_seed_garantie(env, monkeypatch):
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "d",
seeds=["Escaping Regeln", "Sub"])
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
db, ctx, files = env
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
monkeypatch.setattr(bc, "_race", fake)
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
n = len(fake.calls)
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
assert len(fake.calls) == n # alles resumed
assert gen2["raw"] == gen1["raw"]
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
def _gen_von(title, subs, unsicher=None, votes=None):
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
return {"raw": {title: list(subs)},
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
"hurdles": "", "cited_facts": [], "example_idea": ""}
for s in subs}},
"unsicher": unsicher or [], "votes": votes or {}}
def _judge_slot(antworten, fix=None):
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
fehlender Eintrag = FAILED."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-sb-fix-" in key:
if fix is None:
return FAILED, None
return OK, payload((0, json.dumps(fix), ""))
j = key.rsplit("-j", 1)[-1]
antwort = antworten.get(j)
if antwort is None:
return FAILED, None
return OK, payload((0, json.dumps(antwort), ""))
fake.calls = calls
return fake
async def _seed_rows(db, bnorm, titles, status="consensus"):
for t in titles:
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
db, ctx, files = env
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
await _seed_rows(db, "alpha", subs)
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
{"haupt": 3, "weitere": [2]}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title(subs[0])] == "variant"
assert rows[_norm_title(subs[1])] == "consensus"
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
async def test_verify_uebernahme_braucht_beide(env, monkeypatch):
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
sonst discarded."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A", "Unsicher B"]}
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
# der Prüfer-Prompt weist die Unsicher-Nummern aus
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 23" in fake.calls[0]["prompt"]
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
löst auch keinen Fix aus."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
{"nr": 2, "discard": True}]},
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub B"]}
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
beanstandeten Subs; die Einstufung bleibt."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
"2": {"gruppen": []}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert any("-sb-fix-" in c["key"] for c in fake.calls)
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
_sub("Unbelegte Behauptung", kp=[])]}
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
assert _norm_title("Unbelegte Behauptung") not in rows
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B")]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A"]} # fremd-Einzelstimme wirkt NICHT
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
advanced/relevant (heutige Defaults)."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
votes = {"sub a": {"level": ["beginner"], "relevance": []},
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
n = len(fake.calls)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert len(fake.calls) == n
# ── Artefakte ───────────────────────────────────────────────────────────────────────
def _sidecar(titles):
return [{"title": t, "level": "beginner", "relevance": "relevant",
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
def _art_slot(gen_out, check_out):
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
check_out fürs Prüfer-Verdikt."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-art-gen-" in key:
out = gen_out(prompt) if callable(gen_out) else gen_out
return OK, payload((0, json.dumps(out), ""))
if "-art-check-" in key:
if check_out is None:
return FAILED, None
return OK, payload((0, json.dumps(check_out), ""))
raise AssertionError(f"unerwarteter Call {key}")
fake.calls = calls
return fake
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
"steps": ["s1"], "result": "R"}]}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
"question": "F?", "answer": "A"}]
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [],
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
check = {"examples_probleme": [{"index": 1}],
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
fake = _art_slot(gen_out, check)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
{"subblock": "Sub B", "question": "F B?"}]
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
Hälfte; die Ergebnisse werden zusammengeführt."""
db, ctx, files = env
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
def gen_out(prompt):
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
"cards": [], "examples": []}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [], "examples": []}
fake = _art_slot(gen_out, None)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
async def test_artefakte_leerer_block(env):
db, ctx, files = env
res = await bc._artefakte_block(ctx, files, "Alpha", [])
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
async def test_migriere_alt_karten(testdb):
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
Neu-Struktur-Karten bleiben unangetastet."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
{"title": "Alpha", "description": "d", "n_size": 3,
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
"facts": {"Alpha": {}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
{"title": "Beta", "sidecar": {}})
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
{"title": "Delta", "raw": {"Delta": ["neu"]}})
n = await ba.migriere_alt_karten(TOPIC)
assert n == 2
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["stage"] == "generate"
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}

View File

@@ -70,39 +70,36 @@ 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="", seeds=None, lbl="", sources=None):
title = list(entries.values())[0].split("")[0]
return {title: ["Sub Eins", "Sub Zwei"]}
async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
sources=None, seeds=None):
subs = ["Sub Eins", "Sub Zwei"]
return {"raw": {title: list(subs)},
"facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
for s in subs}},
"unsicher": [], "votes": {}}
async def fake_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_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
subs = gen["raw"].get(title) or []
bfacts = gen["facts"].get(title) or {}
sidecar = [{"title": s, "level": "beginner",
"relevance": "relevant" if i == 0 else "peripheral",
"facts": bfacts.get(_norm_title(s)) or {}}
for i, s in enumerate(subs)]
return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}}
async def fake_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_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl=""):
if not sidecar_subs:
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
first = sidecar_subs[0]["title"]
return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
"artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
"example": []}}
async def fake_outline(ctx, set_p, files, entries, instructions):
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
return None
for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts),
("_levels_block", fake_levels), ("_relevance_block", fake_relevance),
("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts),
("_outline_block", fake_outline), ("_konsolidiere_subblocks", fake_konsolidierung)]:
for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
monkeypatch.setattr(ba, name, fn)
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
@@ -238,16 +235,17 @@ async def test_filter_judges_run_parallel(board_env, monkeypatch):
async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch):
"""Legitim leere Subbausteine ({} statt None) → Karte läuft bis done_artefact durch."""
"""Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch."""
import asyncio
import board_artefacts as ba
import blocks as blx
db, ctx, files = board_env
async def empty_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",
async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
sources=None, seeds=None):
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
monkeypatch.setattr(ba, "_generate_block", empty_gen)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
{"title": "Leerer Block", "description": "d"})
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
@@ -712,18 +710,18 @@ def test_agent_priority_order():
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
"""Gliederung startet, sobald alle Karten die generate-Stage passiert haben —
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
import asyncio
import board_artefacts as ba
db, ctx, files = board_env
await _seed(db)
base_levels = ba._levels_block
base_verify = ba._verify_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)
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None):
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl, sources=sources)
base_outline = ba._outline_block
@@ -732,7 +730,7 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
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, "_verify_block", slow_verify)
monkeypatch.setattr(ba, "_outline_block", spy_outline)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
@@ -750,7 +748,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
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",
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify",
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
@@ -764,16 +762,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
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,
r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "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"
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
assert v["status"] == "active" and v["info"] == "Fix 1/2…"
assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
# legacy plain-string live info → no stepper, no crash
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
assert v2["info"] == "Verify 0/1…" and "step_n" not in v2
# step outside the card's stage group (e.g. supplement note) → no stepper
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}})
assert "step_n" not in v3
@@ -808,17 +806,17 @@ async def test_seed_map_resolves_cascade(testdb):
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)."""
"""Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
die Signatur)."""
import inspect
import 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
import block_calls as bc
for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
params = inspect.signature(getattr(bc, fn)).parameters
assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(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
assert "seeds" in inspect.signature(bc._generate_block).parameters
for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
@@ -906,7 +904,7 @@ async def _run_flow(ctx, files, timeout=30, **kw):
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate."""
import qa as qa_mod
db, ctx, files = board_env
@@ -918,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="subblocks")
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
@@ -981,7 +979,7 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
(tmp_path / TOPIC).mkdir()
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
{"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
counts = {"inventory": {"done_block": 3}, "artefacts": {"subblocks": 4}}
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
v = bi._qa_view(TOPIC, counts, None)
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
from types import SimpleNamespace
@@ -1115,11 +1113,11 @@ def test_hat_anker_ziffern_suffix():
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
"""Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
"""Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"})
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
arbeit = tmp_path / "arbeit"
(arbeit / "ab-alpha").mkdir(parents=True)
@@ -1128,7 +1126,7 @@ async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
files[k] = tmp_path / f"{k}.json"
files[k].write_text("{}", encoding="utf-8")
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "subblocks", files)
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
assert moved == 1
assert not await db.list_subblocks(TOPIC)
assert not (arbeit / "ab-alpha").exists()

View File

@@ -84,8 +84,9 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
@pytest.mark.parametrize("stoerung", [
{"muster": r"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
{"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt
{"muster": r"-facts-c\d+$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
{"muster": r"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
])
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):

View File

@@ -156,7 +156,7 @@ async def test_restart_artefact_card_wipes_only_that_block(testdb):
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.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
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

View File

@@ -1,10 +1,10 @@
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
Judges gefaked, gegen Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-
Umbau in block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
import json
import numpy as np
import pytest
import blocks
import board_artefacts as ba
@@ -39,218 +39,6 @@ async def _seed_block(db, bnorm, subs):
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
# ── In-Block ────────────────────────────────────────────────────────────────────────
async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
"""Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
Verlierer wird DB-variant und fliegt aus raw/facts_map."""
db = testdb
subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
await _seed_block(db, "betonung", subs)
raw = {"Betonung": list(subs)}
facts = {"Betonung": {
blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
}}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
"j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1
wf = facts["Betonung"][blocks._norm_title(subs[1])]
assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel
assert blocks._norm_title(subs[0]) not in facts["Betonung"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
assert rows[blocks._norm_title(subs[0])] == "variant"
assert rows[blocks._norm_title(subs[1])] == "consensus"
journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
# Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]
async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
"""Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
"j2": {"gruppen": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == subs
assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))
async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
"""Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == subs
assert len(fake.calls) == 3 # j1, j2, Ersatz j3
async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
"""j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
db = testdb
subs = ["Kurz", "Deutlich längerer Eintrag"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
"j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == ["Deutlich längerer Eintrag"]
async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
"""Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
db = testdb
subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
await _seed_block(db, "tabs", subs)
raw = {"Tabs": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
"j2": {"gruppen": [[1, 2]], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Tabs"] == subs
async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
"""Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei lang"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
import hashlib
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
for j in (1, 2):
(tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")
async def kein_agent(*a, **kw):
raise AssertionError("Resume darf keinen Agenten starten")
monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == ["Eintrag zwei lang"]
def test_schema_accepts_both_group_forms():
"""Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
neu = blocks._konsolidierung_schema(
{"gruppen": [{"haupt": 4, "weitere": [1]}],
"kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
"fremd": [5], "luecken": ["x"]}, 5)
assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
{"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range
async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
"""Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
db = testdb
subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
"j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Block"] == ["Basis"]
assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt
async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
"""Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
db = testdb
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
await _seed_block(db, "mathe", subs)
raw = {"Mathe": list(subs)}
facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
"j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
assert rows[kn] == "consensus"
assert rows[blocks._norm_title(subs[0])] == "variant"
async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
db = testdb
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
await _seed_block(db, "mathe", subs)
raw = {"Mathe": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
"j2": {"gruppen": [], "kataloge": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Mathe"] == subs
async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
"""Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
db = testdb
subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
"j2": {"gruppen": [], "fremd": [1], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == [subs[1], subs[2]]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
assert rows[blocks._norm_title(subs[0])] == "discarded"
assert rows[blocks._norm_title(subs[2])] == "consensus"
assert luecken == {}
async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
"""Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
"j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert luecken == {"Block": ["Inline-HTML fehlt"]}
async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
"""Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
prompt = fake.calls[0]["prompt"]
assert "kp3" in prompt and "kp4" not in prompt
def test_luecken_schnitt_cap():
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
@@ -266,91 +54,6 @@ def test_neg_set_lemmatisiert():
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────
async def _nachfass_env(db, monkeypatch, facts_result):
subs = ["Eintrag eins"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts_map = {"Block": {}}
async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]
async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
assert slim is True # Nachfass nutzt die schlanke Facts-Variante
assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate
return facts_result
monkeypatch.setattr(blocks, "_race", fake_race)
monkeypatch.setattr(blocks, "_facts_block", fake_facts)
monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
return raw, facts_map
async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
db = testdb
nn = blocks._norm_title("Neuer Aspekt")
raw, facts_map = await _nachfass_env(db, monkeypatch,
({"Block": {nn: {"key_points": ["kp"]}}}, {}))
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
assert facts_map["Block"][nn]["key_points"] == ["kp"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
assert rows[nn] == "consensus"
async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
db = testdb
nn = blocks._norm_title("Neuer Aspekt")
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 0 and raw["Block"] == ["Eintrag eins"]
assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))
async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
"""HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
db = testdb
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 0 and raw["Block"] == ["Eintrag eins"]
async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
"""Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
deren Lücken lösen KEINEN weiteren Nachfass aus."""
db = testdb
payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
calls = {"kons": 0, "nf": 0}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
return {"Alpha": {}}, {}
async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
calls["kons"] += 1
return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen
async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
instructions="", ns="", lbl="", sources=None):
calls["nf"] += 1
return 2
monkeypatch.setattr(ba, "_facts_block", fake_facts)
monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
flow = Flow(TOPIC, work_dir=tmp_path)
await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
[{"card_id": "alpha", "payload": payload}])
assert calls == {"kons": 2, "nf": 1}
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"
async def test_finalize_purges_stale_rows(testdb, tmp_path):
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
db = testdb
@@ -486,11 +189,13 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkeypatch):
"""Resume-Karte aus der alten Stage-Position (kein pattern im Payload) → zurück nach
question_pattern, KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
db = testdb
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
async def kein_agent(*a, **kw):
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
@@ -498,8 +203,8 @@ async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkey
monkeypatch.setattr(ba, "embedding", _FakeEmb)
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
for cid in ("alpha", "beta"):
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
@@ -558,47 +263,3 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
"""Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
gesehen = {}
async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
sources=None, slim=False):
gesehen["raw"] = raw
gesehen["slim"] = slim
return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
"mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
{"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert
monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
assert n == 1
assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf
async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
"""Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
(eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
db = testdb
payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
"facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
{"key_points": ["kp"]}}}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
cards = [{"card_id": "alpha", "payload": payload}]
async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel
monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}

View File

@@ -1,16 +1,14 @@
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
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) ─────────────────────────────────────────────────────────
@@ -37,157 +35,6 @@ def test_variant_clusters_negation_guard():
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, late=None):
outs = []
for slot in slots:
key, prompt = slot["key"], slot["prompt"]
prompts.append((key, prompt))
fake_race.slots_seen.append(slot)
if "-subblock-final-" in key:
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
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)
outs.append(slot["payload"]((0, text, "")))
continue
if "-r1-" in key:
agent = int(key.rsplit("-", 1)[1])
subs = finder_by_agent.get(agent) or []
if subs: # Finder antworten als TEXT (Marker-Format), kein out_path mehr
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
outs.append(slot["payload"]((0, text, "")))
outs = [o for o in outs if o]
return outs or None
fake_race.slots_seen = []
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
text = "<!-- block: Alpha -->\n- Vertiefung der Konzepte"
return [slot["payload"]((0, text, "")) 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():
@@ -238,52 +85,8 @@ async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
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):
text = "<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!"
return [slot["payload"]((0, text, "")) for slot in slots[:2]]
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]:
prompts.append((slot["key"], slot["prompt"]))
outs.append(slot["payload"]((0, f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig", "")))
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
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
def _corpus(tmp_path):
d = tmp_path / "korpus"
@@ -348,51 +151,6 @@ def test_sink_json_writes_only_valid(tmp_path):
assert bad is None and not (tmp_path / "x.json").exists()
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Mit Korpus: Judges UND Finder bekommen Auszüge inline und laufen ohne Tools
(Text-Antwort); die Dateien schreibt die Engine. Der Finder verlor vorher 313
Tool-Runden pro Call mit der Material-Suche via glob/grep/bash."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", sources=["Skript.txt"])
assert raw == {"Alpha": ["Alpha Grundlagen"]}
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert judges and all(s["capabilities"] == "none" for s in judges)
assert "── Skript.txt" in judges[0]["prompt"]
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
assert finders and all(s["capabilities"] == "none" for s in finders)
assert "── Skript.txt" in finders[0]["prompt"] # Auszüge inline statt Dateisystem-Suche
assert "web search" not in finders[0]["prompt"]
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
async def test_thema_nutzt_research_material_inline(sub_env, monkeypatch, tmp_path):
"""thema mit Research-Fundstellen (arbeit/material/*.txt): Finder bekommt sie inline
und läuft ohne Tools — vorher eigene Websuche pro Call (Reasoning-Schleifen, Retries)."""
db, ctx, files = sub_env
monkeypatch.setattr(blx, "source_folder", lambda t: None)
md = tmp_path / "arbeit" / "material"
md.mkdir(parents=True)
(md / "research-1.txt").write_text(
"https://example.org/alpha\nAlpha Grundlagen: der Kernbegriff, gut belegt.\n",
encoding="utf-8")
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
assert raw == {"Alpha": ["Alpha Grundlagen"]}
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert finders and all(s["capabilities"] == "none" for s in finders)
assert "── research-1.txt" in finders[0]["prompt"] # Fundstellen inline
def test_material_folder_fallbacks(monkeypatch, tmp_path):
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
@@ -407,115 +165,6 @@ def test_material_folder_fallbacks(monkeypatch, tmp_path):
assert blx.material_folder("t") == md
async def test_panel_2of3_kehrt_bei_einigkeit_zurueck(tmp_path):
"""Zwei übereinstimmende Verdicts → Rückkehr ohne den langsamen Dritten; sein
Ergebnis wird detached nachpersistiert (Resume)."""
import asyncio as aio
gesunken = {}
async def judge(j, delay, antwort):
await aio.sleep(delay)
return (0, antwort, "")
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
aio.create_task(judge(2, 0.02, "a")): 2,
aio.create_task(judge(3, 5.0, "b")): 3}
def sink(j, r):
gesunken[j] = r[1]
import time
t0 = time.monotonic()
await blx._panel_2of3(tasks, sink, lambda: list(gesunken.values()), lambda s: s)
assert time.monotonic() - t0 < 1.0 # nicht auf j3 gewartet
assert gesunken == {1: "a", 2: "a"}
async def test_panel_2of3_dissens_wartet_auf_dritten():
"""Uneinige erste zwei → der dritte wird abgewartet (Mehrheit braucht ihn)."""
import asyncio as aio
gesunken = {}
async def judge(j, delay, antwort):
await aio.sleep(delay)
return (0, antwort, "")
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
aio.create_task(judge(2, 0.02, "b")): 2,
aio.create_task(judge(3, 0.1, "a")): 3}
await blx._panel_2of3(tasks, lambda j, r: gesunken.__setitem__(j, r[1]),
lambda: list(gesunken.values()), lambda s: s)
assert gesunken == {1: "a", 2: "b", 3: "a"}
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
die Check-Datei schreibt die Engine aus der Text-Antwort."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
sh = blx._subs_hash({"Alpha": ["Sub Eins"]}) # Resume-Dateien tragen den Sub-Satz-Hash
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
if "-facts-erg-" in key:
return blx.FAILED, None
(tmp_path / f"facts-{sh}-c0.json").write_text(json.dumps(facts), encoding="utf-8")
return blx.OK, None
seen = []
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
seen.append((key, capabilities, prompt))
return (0, '{"ok": true}', "")
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
monkeypatch.setattr(blx, "run_agent", fake_agent)
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
assert res is not None
facts_map, discarded = res
assert "Alpha" in facts_map and not discarded
assert len(seen) == blx.FACTS_CHECK_PANEL
key, caps, prompt = seen[0]
assert caps == "none" and "── Skript.txt · Z." in prompt
assert (tmp_path / f"facts-check-{sh}-c0-j1.json").exists() # Engine persistiert die Antwort
async def test_facts_find_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Facts find/erg mit Korpus: Auszüge inline, Agent ohne Tools — Tool-Agenten
verloren sich in Reasoning-Schleifen und endeten mit leerem Turn (Retry-Wellen)."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
seen = []
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
seen.append((key, capabilities, prompt))
return blx.OK, payload((0, json.dumps(facts), ""))
async def fake_agent(key, prompt, timeout, **kw): # Check-Panel
return (0, '{"ok": true}', "")
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
monkeypatch.setattr(blx, "run_agent", fake_agent)
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
assert res is not None
finder = [s for s in seen if "-facts-c0" in s[0] or "-facts-erg-" in s[0]]
assert finder and all(caps == "none" for _, caps, _ in finder)
assert all("── Skript.txt" in prompt for _, _, prompt in finder) # Auszüge inline
assert all("ls/find" not in prompt for _, _, prompt in finder)
def test_sub_key_resolves_short_titles():
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""

View File

@@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw):
async def test_aco_konvergiert_auf_optimum(tmp_path):
"""Gepflanztes Optimum (FACTS_CHUNK_SUBS=6) wird gefunden und bestätigt übernommen;
"""Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen;
die Pheromon-Spur konzentriert sich dort."""
def bewertung(params):
return _metrics(note=9.5, dauer=7.0) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=120)
best = await t.run()
assert best.get("FACTS_CHUNK_SUBS") == 6
taus = t.pheromon["FACTS_CHUNK_SUBS"]
assert max(taus, key=lambda k: taus[k]) == "6"
assert best.get("GEN_PANEL") == 3
taus = t.pheromon["GEN_PANEL"]
assert max(taus, key=lambda k: taus[k]) == "3"
async def test_uebernahme_braucht_bestaetigung(tmp_path):
@@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
zustand = {"mal": 0}
def bewertung(params):
if params.get("FACTS_CHUNK_SUBS") == 6:
if params.get("GEN_PANEL") == 3:
zustand["mal"] += 1
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
return _metrics()
@@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=40)
best = await t.run()
assert best.get("FACTS_CHUNK_SUBS") != 6
assert best.get("GEN_PANEL") != 3
async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
@@ -111,16 +111,16 @@ async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
async def test_resume_laedt_pheromon_und_cache(tmp_path):
def bewertung(params):
return _metrics(note=9.5) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=60)
await t.run()
best, tau = t.best_params, dict(t.pheromon["FACTS_CHUNK_SUBS"])
best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"])
runner2, f02 = _stub(bewertung)
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
assert t2.best_params == best
assert t2.pheromon["FACTS_CHUNK_SUBS"] == tau
assert t2.pheromon["GEN_PANEL"] == tau
async def test_budget_stoppt(tmp_path):