This commit is contained in:
Team3
2026-07-04 19:20:48 +02:00
parent 92c69c1561
commit c05421a8c1
14 changed files with 695 additions and 277 deletions

View File

@@ -176,6 +176,38 @@ async def test_board1_full_flow(board_env):
assert summary["boards"].get("inventory", {}).get("done_block") == 4
async def test_abschluss_qa_events_tragen_run_id(board_env, monkeypatch):
"""Abschluss-QA läuft NACH run_flow — ihre Judge-Events müssen trotzdem die run_id
des Laufs tragen (Lauf 20260704-1452-b223: run_id leer → aus jeder Aggregation gefallen)."""
import asyncio
import qa as qa_mod
db, ctx, files = board_env
await _seed(db)
async def qa_mit_judge_event(topic, llm=False):
# wie die echten LLM-Judges: run_agent schreibt ein agent-Event
await db.add_event(topic, "agent", key=f"qa-{topic}-bausteine-0", status="ok")
return {"note": 10.0, "topic": topic, "quoten": {}, "fremd": [],
"artefakte": {"status": "nicht generiert"}}
monkeypatch.setattr(qa_mod, "qa_report", qa_mit_judge_event)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
timeout=30)
assert ok
summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8"))
conn = await db.get_db()
rows = await (await conn.execute(
"SELECT run_id FROM events WHERE topic=? AND key=?",
(TOPIC, f"qa-{TOPIC}-bausteine-0"))).fetchall()
assert rows and all(r[0] == summary["run_id"] for r in rows)
# Registry nach dem Lauf geleert: manuelle QA bleibt korrekt ohne run_id
await db.add_event(TOPIC, "agent", key="qa-manuell", status="ok")
row = await (await conn.execute(
"SELECT run_id FROM events WHERE topic=? AND key='qa-manuell'", (TOPIC,))).fetchone()
assert row[0] == ""
async def test_filter_judges_run_parallel(board_env, monkeypatch):
"""40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
import asyncio

View File

@@ -388,85 +388,88 @@ class _FakeEmb:
return arr @ arr.T
async def _cross_env(db, tmp_path):
async def _cross_env(db, tmp_path, finalisiert=True):
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
flow = Flow(TOPIC, work_dir=tmp_path)
cards = []
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
("beta", ["Gleiche Aussage", "Nur in Beta"])):
payload = {"title": bnorm.title(),
"raw": {bnorm.title(): list(subs)},
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
payload = {"title": bnorm.title()}
if finalisiert:
payload.update(pattern={}, artefacts={})
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
await _seed_block(db, bnorm, subs)
cards.append({"card_id": bnorm, "payload": payload})
return flow, cards
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
return flow, cards, files
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
Gewinner (falte_sub), Karten gehen auf DONE."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
sn = blocks._norm_title("Gleiche Aussage")
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["stage"] == "question_pattern"
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
# Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["stage"] == "question_pattern"
assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
for cid in ("alpha", "beta"):
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert beta_rows[sn] == "variant"
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert alpha_rows[sn] == "consensus"
fragen = await db.list_question_pattern(TOPIC)
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
"j3": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert len(fake.calls) == 3
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["stage"] == "question_pattern"
assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
class _Aus:
@staticmethod
@@ -478,33 +481,27 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
monkeypatch.setattr(ba, "embedding", _Aus)
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
for cid in ("alpha", "beta"):
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."""
db = testdb
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
async def kein_agent(*a, **kw):
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
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"
async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch):
"""Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b"
die Paket-Seite fällt, der Kontext bleibt unangetastet."""
db = testdb
flow = Flow(TOPIC, work_dir=tmp_path)
payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload)
await _seed_block(db, "alpha", ["Gleiche Aussage"])
cards = [{"card_id": "alpha", "payload": payload}]
# Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels",
{"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}})
await _seed_block(db, "gamma", ["Gleiche Aussage"])
monkeypatch.setattr(ba, "embedding", _FakeEmb)
# Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["payload"]["raw"].get("Alpha", []) == [] # Paket-Seite gefaltet
gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")}
assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus" # Kontext unberührt
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
@@ -547,21 +544,20 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
cards = []
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
payload = {"title": bnorm.title(),
"raw": {bnorm.title(): list(subs)},
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": []} for s in subs}}}
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
await _seed_block(db, bnorm, subs)
cards.append({"card_id": bnorm, "payload": payload})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
monkeypatch.setattr(ba, "embedding", _FakeEmb)
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"].get("Beta", []) == [] # beide Dubletten global gefaltet
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):

View File

@@ -1,5 +1,7 @@
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
import json
import qa
@@ -238,6 +240,25 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
assert report["unecht"] == ["Wackelkandidat"]
async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch):
"""Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten
als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht."""
db = testdb
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0,
"quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}}
path = await qa.write_report(report)
assert path.stem == "20260704-1452-b223"
conn = await db.get_db()
row = await (await conn.execute(
"SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone()
assert row and row[0] == "20260704-1452-b223"
meta = json.loads(row[1])
assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0
assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0}
assert row[2] == "" # manuelle QA ohne Lauf → leeres run_id ist korrekt
async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
"""DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
import routes

138
backend/tests/test_race.py Normal file
View File

@@ -0,0 +1,138 @@
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
abzuwarten (gemessen: 4 Panel-Stalls à 160230 s pro Lauf auf dem kritischen Pfad)."""
import asyncio
import pipeline
def _slot(payload=lambda r: r[1]):
return {"key": "k1", "prompt": "p", "role": "judge", "capabilities": "none", "payload": payload}
async def test_hedge_zwilling_rettet_stall(monkeypatch):
"""Original stallt → nach HEDGE_NACH_S startet der Zwilling (key -h), sein Ergebnis
gewinnt, das hängende Original wird gekillt."""
calls, killed = [], []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
return (0, "zwilling", "")
await asyncio.sleep(30) # Stall — würde sonst den ganzen Cap verbrennen
return (0, "original", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 60, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"]
assert "k1" in killed # das hängende Original läuft nicht weiter
async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch):
"""Kommt das Original doch noch vor dem Zwilling an, wird der Zwilling gekillt
und sein spätes Ergebnis nicht gewertet."""
killed = []
async def fake_agent(key, prompt, timeout, **kw):
await asyncio.sleep(0.3 if key.endswith("-h") else 0.15)
return (0, key, "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 60, "claude")
assert res == ["k1"]
assert "k1-h" in killed
async def test_hedge_aus_bei_null(monkeypatch):
"""HEDGE_NACH_S=0 → kein Zwilling, Verhalten wie zuvor."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.1)
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
res = await pipeline._race("t", "Test", [_slot()], 1, 60, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_late_fold_nachzuegler_zaehlt_nach(monkeypatch):
"""Quorum 2 kehrt sofort zurück; der dritte Slot wird nicht gekillt, sein Ergebnis
geht an `late` (ersetzt den grace-Timer der Finder-Runden)."""
import time
killed, spaet = [], []
async def fake_agent(key, prompt, timeout, **kw):
if key == "k3":
await asyncio.sleep(0.2)
return (0, "dritter", "")
return (0, key, "")
async def late(val):
spaet.append(val)
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
"payload": lambda r: r[1]} for i in (1, 2, 3)]
t0 = time.monotonic()
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
assert time.monotonic() - t0 < 0.15 # kein Warten auf k3
assert sorted(res) == ["k1", "k2"]
assert "k3" not in killed
await asyncio.sleep(0.3)
assert spaet == ["dritter"]
async def test_late_fold_invalider_nachzuegler_ignoriert(monkeypatch):
"""Nachzügler mit invalidem Payload löst late NICHT aus (best-effort)."""
spaet = []
async def fake_agent(key, prompt, timeout, **kw):
if key == "k3":
await asyncio.sleep(0.1)
return (1, "", "kaputt")
return (0, key, "")
async def late(val):
spaet.append(val)
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
"payload": lambda r: r[1]} for i in (1, 2, 3)]
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
assert res is not None
await asyncio.sleep(0.25)
assert spaet == []
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
zusätzlichen Restart — der Zwilling ist der Retry."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
await asyncio.sleep(0.2)
return (0, "zwilling", "")
await asyncio.sleep(0.1)
return (1, "", "kaputt") # Fehler NACH dem Hedge-Start
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 60, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"] # kein dritter Spawn

View File

@@ -57,7 +57,7 @@ def _mk_race(finder_by_agent):
prompts = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None, late=None):
outs = []
for slot in slots:
key, prompt = slot["key"], slot["prompt"]
@@ -372,6 +372,47 @@ async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
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."""
@@ -409,6 +450,35 @@ async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path)
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)
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."""