From fb3e967fdb09c8e571ffdee35c6a627b679d6f91 Mon Sep 17 00:00:00 2001 From: Team3 Date: Sun, 5 Jul 2026 13:07:12 +0200 Subject: [PATCH] update --- backend/config.py | 16 +++------- backend/guide_board.py | 48 ++++++++++++++-------------- backend/guide_qa.py | 53 +++++++++++++++++++++++++------ backend/pipeline.py | 13 +++++--- backend/tests/test_guide_board.py | 5 +-- backend/tests/test_guide_qa.py | 18 +++++++++-- backend/tests/test_race.py | 26 ++++++++++++--- 7 files changed, 122 insertions(+), 57 deletions(-) diff --git a/backend/config.py b/backend/config.py index 1e5eb88..7acee73 100644 --- a/backend/config.py +++ b/backend/config.py @@ -155,11 +155,6 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run -# Guide section length per relevant sub (ausführlich part) — QA detector AND the -# deterministic readability-stage trigger share these bounds (writers overshot 2.7–4.1×). -GUIDE_LAENGE_MIN = 150 -GUIDE_LAENGE_MAX = 1200 - # Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting # every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the # run's tokens were cache reads from those loops). @@ -177,10 +172,6 @@ RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2) RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema") RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard) RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live) -SUBBLOCK_CAP = 900 # subblock find loop per chunk (seconds) -SUBBLOCK_MIN = 5 # below this consensus count → focused catch-up rounds -SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds -SUBBLOCK_MAX_ROUNDS = 3 # hard round cap (rounds 4–5 burned 29 % of finders for ~0 gain) CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path) DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair DEDUP_PAIRS_CHUNK = 40 # pairs per judge package @@ -201,7 +192,7 @@ GEN_PANEL = 2 # unabhängige Generator-Calls pro Block VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall) ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck -GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below this → log only (falsch fixt immer) +GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer) WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count KANBAN_BATCH = 5 # cards a worker pulls per micro-batch MAX_CARD_RETRIES = 3 # failures per card → dead-letter @@ -209,8 +200,9 @@ RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1) MAX_RESTARTS = 2 # agent restart cap per race slot # Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling # (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160–230 s -# verlängerten den kritischen Pfad um ~5 min — gesunde Judge-Calls liegen bei p90 ≤ 105 s. -# 0 = aus. +# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt +# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden +# Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus. HEDGE_NACH_S = 90 JUDGE_CHUNK = 40 # repair: findings per judge call EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate diff --git a/backend/guide_board.py b/backend/guide_board.py index ddd0a76..611a9b3 100644 --- a/backend/guide_board.py +++ b/backend/guide_board.py @@ -22,8 +22,9 @@ import re import database as db import readability from blocks import _sink_json -from config import (FORMAT_PURPOSE, GUIDE_LAENGE_MAX, GUIDE_LAENGE_MIN, READABILITY_ACTIVE, +from config import (FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC) +from guide_qa import block_budget from fsutil import atomic_write_json from jsonio import read_json_file as _json_file from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt, @@ -274,12 +275,6 @@ def _merge_split_sections(sec_a: dict, sec_b: dict) -> str: return "\n\n".join(lines) -def _writer_budget(n_subs: int, sockel: int = 800) -> int: - """Length guideline (chars) for the detailed version — unguided sections measured 2–4× - too long (23k) or, after the readability fix, far too thin (180 chars/sub).""" - return sockel + 400 * max(n_subs, 1) - - async def _write_split(env: _Env, card: dict, ziele_text: str): """First draft in two halves (parallel), merged into one section. → merged text | None (failed) | False (cancelled).""" @@ -316,7 +311,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str): examples=await _card_examples(env, norm, parts[i], include_unmatched=(i == 0)), gaps="\n" + hints[i] + "\n", - budget=_writer_budget(len(parts[i]), sockel=400), + budget=block_budget(parts[i]), spec=env.spec, out_path=path, extra=_extra(env.instructions)), role="guide", capabilities="files", payload=_payload, timeout=_timeout("writer", 1)) @@ -360,7 +355,7 @@ async def _stage_writer(env: _Env, card: dict) -> bool: facts=_card_facts(env, card["block"]), examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])), gaps="", spec=env.spec, - budget=_writer_budget(len(env.subs_by_title.get(card["block"], []))), + budget=block_budget(env.subs_by_title.get(card["block"], [])), out_path=path, extra=_extra(env.instructions)), role="guide", capabilities="files", payload=_payload, timeout=_timeout("writer", 1)) @@ -373,22 +368,27 @@ async def _stage_writer(env: _Env, card: dict) -> bool: return True +def _n_rel(env: _Env, card: dict) -> int: + return sum(1 for s in env.subs_by_title.get(card["block"], []) + if s.get("relevance") != "peripheral") + + def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]: """Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell + - Längen-Rahmen — dieselbe Formel wie der QA-Detektor. Gehen direkt in den Fix + Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit + engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix und als „nicht wiederholen"-Notiz in den Prüfer-Prompt.""" out: list[str] = [] subs_all = env.subs_by_title.get(card["block"], []) - n_rel = max(sum(1 for s in subs_all if s.get("relevance") != "peripheral"), 1) + if not any(s.get("relevance") != "peripheral" for s in subs_all): + return out # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor) + budget = block_budget(subs_all) aus = re.split(r"", sec["md"], maxsplit=1) - pro_sub = len(aus[1] if len(aus) == 2 else sec["md"]) / n_rel - if not (GUIDE_LAENGE_MIN <= pro_sub <= GUIDE_LAENGE_MAX * 0.9): - # Ziel nach der QA-Formel (Zeichen ÷ relevante Subs ≤ MAX), nicht dem Writer-Budget — - # die zwei Formeln liefen auseinander und der Fix verfehlte den QA-Rahmen - ziel = n_rel * round(GUIDE_LAENGE_MAX * 0.75) + zeichen = len(aus[1] if len(aus) == 2 else sec["md"]) + if not (0.5 * budget <= zeichen <= 1.2 * budget): out.append( - f"Länge {round(pro_sub)} Zeichen/Sub (Rahmen {GUIDE_LAENGE_MIN}–{round(GUIDE_LAENGE_MAX * 0.9)}): " - f"schreibe den ausführlich-Teil auf etwa {ziel} Zeichen GESAMT um — Sockel-Prosa und " + f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(0.5 * budget)}–{round(1.2 * budget)}): " + f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und " f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten") return out @@ -400,13 +400,15 @@ async def _det_readability(sec: dict) -> list[str]: return [hints[1]] if hints.get(1) else [] -def _auftraege(verdict: dict, det: list[str]) -> tuple[list[str], bool]: +def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]: """Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken (nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern). - Claims-Schwelle wie beim alten Gate: 1–2 nur-„unbelegt" lohnen keinen Fix-Pass.""" + Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen + sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig).""" claims = verdict["claims"] falsch = [c for c in claims if c["urteil"] == "falsch"] - if claims and not falsch and len(claims) < GATE_FIX_MIN: + schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN + if claims and not falsch and len(claims) < schwelle: claims = [] zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f" — {c['grund']}" if c['grund'] else "") for c in claims] @@ -465,7 +467,7 @@ async def _stage_pruefer(env: _Env, card: dict) -> bool: # gehen trotzdem in den Fix _log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks") verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []} - zeilen, kritisch = _auftraege(verdict, det) + zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card)) if not zeilen: await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="") return True @@ -524,7 +526,7 @@ async def _stage_fix(env: _Env, card: dict) -> bool: if verdict is None and is_guide_cancelled(env.guide_id): return False if verdict: - zeilen, _k = _auftraege(verdict, []) + zeilen, _k = _auftraege(verdict, [], _n_rel(env, card)) if zeilen: rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen) _log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben") diff --git a/backend/guide_qa.py b/backend/guide_qa.py index 06e0989..3671f3e 100644 --- a/backend/guide_qa.py +++ b/backend/guide_qa.py @@ -10,6 +10,7 @@ Report: storage/qa//guide-.json + Konsolen-Digest. """ import asyncio +import json import re import sys from datetime import datetime, timezone @@ -20,8 +21,6 @@ import readability from fsutil import atomic_write_json from textkit import _norm_title -from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN - JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal LLM_SECTION_CHARS = 2500 # Section-Auszug je Judge-Item @@ -71,13 +70,40 @@ def ziel_ohne_anker(cards: list[dict], ziele: list[dict]) -> list[str]: return out -def laengen_ausreisser(cards: list[dict], subs_rel: dict[str, set]) -> list[dict]: +# Längenbudget je Sub aus der Inventar-Substanz — ersetzt den festen Rahmen 150–1200/Sub: +# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler. +# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem +# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar. +BUDGET_BASIS = 200 # Einstieg/Übergang je Sub +BUDGET_KEY_POINT = 160 # ~1–2 Sätze Erklärung je key_point +BUDGET_FAKT = 60 # zitierter Fakt, in den Text eingewoben +BUDGET_BEISPIEL = 250 # ausgearbeitetes Beispiel +LAENGE_BAND = (0.35, 1.5) # QA-Toleranz um das Blockbudget + + +def sub_budget(facts: dict) -> int: + """Zeichenbudget für den ausführlich-Teil EINES Subs (facts = Inventar-JSON des Subs).""" + kp = len(facts.get("key_points") or []) + cf = len(facts.get("cited_facts") or []) + ex = 1 if str(facts.get("example_idea") or "").strip() else 0 + return BUDGET_BASIS + BUDGET_KEY_POINT * kp + BUDGET_FAKT * cf + BUDGET_BEISPIEL * ex + + +def block_budget(subs: list[dict]) -> int: + """Budget einer Section: Summe über die relevanten Subs ({relevance, facts}-Dicts).""" + return max(BUDGET_BASIS, sum(sub_budget(s.get("facts") or {}) for s in subs + if s.get("relevance") != "peripheral")) + + +def laengen_ausreisser(cards: list[dict], budget_by_norm: dict[str, int]) -> list[dict]: out = [] for c in cards: - n = max(len(subs_rel.get(c["block_norm"], set())), 1) - pro_sub = len(_ausfuehrlich(c["md"])) / n - if not (LAENGE_MIN <= pro_sub <= LAENGE_MAX): - out.append({"block": c["block"], "zeichen_pro_sub": round(pro_sub)}) + budget = budget_by_norm.get(c["block_norm"]) + if not budget: + continue + zeichen = len(_ausfuehrlich(c["md"])) + if not (LAENGE_BAND[0] * budget <= zeichen <= LAENGE_BAND[1] * budget): + out.append({"block": c["block"], "zeichen": zeichen, "budget": budget}) return out @@ -148,14 +174,23 @@ async def guide_qa_report(topic: str, llm: bool = False) -> dict | None: print(f"Keine Guide-Karten für '{topic}' — Guide noch nicht gebaut?") return None subs_rel: dict[str, set] = {} + subs_by_norm: dict[str, list[dict]] = {} for r in await db.list_subblocks(topic): - if r["status"] == "consensus" and r["relevance"] != "peripheral": + if r["status"] != "consensus": + continue + try: + facts = json.loads(r["facts"]) if r["facts"] else {} + except (ValueError, TypeError): + facts = {} + subs_by_norm.setdefault(r["block_norm"], []).append( + {"relevance": r["relevance"], "facts": facts if isinstance(facts, dict) else {}}) + if r["relevance"] != "peripheral": subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"]) ziele = [dict(r) for r in await db.list_lernziele(topic)] mf = marker_fehlend(cards, subs_rel) za = ziel_ohne_anker(cards, ziele) - la = laengen_ausreisser(cards, subs_rel) + la = laengen_ausreisser(cards, {n: block_budget(s) for n, s in subs_by_norm.items()}) rd = redundanz(cards) lb = lesbarkeit(cards) falsch = await _fachlich_falsch(topic, cards) if llm else None diff --git a/backend/pipeline.py b/backend/pipeline.py index 7ef04b1..984fdb5 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -205,6 +205,9 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: born: dict[asyncio.Task, float] = {} hedged: set[int] = set() # slot got its one twin — no hedge cascades fertig: set[int] = set() # slot delivered a valid result (late twins are ignored) + # Hedge-Schwelle relativ zum Call-Timeout (HEDGE_NACH_S = Untergrenze): pauschale 90 s + # hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen. + hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0 loop = asyncio.get_running_loop() start = loop.time() min_deadline = start + min_runtime if min_runtime else None @@ -266,14 +269,14 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: # Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin # (key -h) — first valid result wins. Stalled provider calls burned the full # timeout cap before the restart even began (measured: 160–230 s per stall). - if _HEDGE_NACH_S: + if hedge_s: now = loop.time() for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig - and now - born[t] >= _HEDGE_NACH_S]: + and now - born[t] >= hedge_s]: i = tasks[t] hedged.add(i) spawn(i, suffix="-h") - _log(topic, f"{label} {i + 1}: {_HEDGE_NACH_S}s ohne Ergebnis — Hedge-Zwilling gestartet") + _log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet") # Wake up for the earliest relevant deadline (grace, min, max, or next hedge). waits = [] if deadline is not None and len(results) >= quorum: @@ -282,8 +285,8 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: waits.append(min_deadline - loop.time()) if max_deadline is not None: waits.append(max_deadline - loop.time()) - if _HEDGE_NACH_S: - naechste = [born[t] + _HEDGE_NACH_S - loop.time() for t in tasks + if hedge_s: + naechste = [born[t] + hedge_s - loop.time() for t in tasks if tasks[t] not in hedged | fertig] if naechste: waits.append(max(0.0, min(naechste))) diff --git a/backend/tests/test_guide_board.py b/backend/tests/test_guide_board.py index aea7400..3125dd9 100644 --- a/backend/tests/test_guide_board.py +++ b/backend/tests/test_guide_board.py @@ -318,7 +318,7 @@ async def test_writer_prompt_traegt_budget(testdb, tmp_path, monkeypatch): monkeypatch.setattr(gb, "run_single_slot", fake_slot) await gb._stage_writer(env, card) - assert str(gb._writer_budget(2)) in seen["prompt"] # 800 + 2×400 + assert str(gb.block_budget(env.subs_by_title["Alpha"])) in seen["prompt"] # 2× Basis-Budget async def test_pruefer_claims_schwelle(testdb, tmp_path, monkeypatch): @@ -416,7 +416,8 @@ async def test_laengen_trigger_startet_fix(testdb, tmp_path, monkeypatch): assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "fix" card.update(stage="fix", gate_info=(await db.list_guide_cards(TOPIC, FMT))[0]["gate_info"]) assert await gb._stage_fix(env, card) - assert "Länge" in seen["auftraege"] and str(round(gb.GUIDE_LAENGE_MAX * 0.75)) in seen["auftraege"] + budget = gb.block_budget(env.subs_by_title["Alpha"]) + assert "Länge" in seen["auftraege"] and f"etwa {budget} Zeichen GESAMT" in seen["auftraege"] async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, monkeypatch): diff --git a/backend/tests/test_guide_qa.py b/backend/tests/test_guide_qa.py index 8d89774..5b639a0 100644 --- a/backend/tests/test_guide_qa.py +++ b/backend/tests/test_guide_qa.py @@ -35,10 +35,24 @@ def test_ziel_ohne_anker(): def test_laengen_ausreisser(): + """Budget-Band statt Festrahmen: zu dünn und zu dick fallen auf, ohne Budget kein Urteil.""" duenn = _card("Alpha", "\nkurz") ok = _card("Beta", "\n" + "x" * 500) - out = gq.laengen_ausreisser([duenn, ok], {"alpha": {"s1"}, "beta": {"s1"}}) - assert [x["block"] for x in out] == ["Alpha"] + dick = _card("Gamma", "\n" + "x" * 2000) + ohne = _card("Delta", "\nkurz") + budgets = {"alpha": 500, "beta": 500, "gamma": 500} # delta: kein Inventar → übersprungen + out = gq.laengen_ausreisser([duenn, ok, dick, ohne], budgets) + assert [x["block"] for x in out] == ["Alpha", "Gamma"] + assert out[1]["zeichen"] >= 2000 and out[1]["budget"] == 500 + + +def test_budget_aus_substanz(): + """Dichte Subs bekommen mehr Budget; periphere zählen nicht.""" + dicht = {"relevance": "relevant", "facts": {"key_points": ["a", "b"], "cited_facts": [{}], "example_idea": "x"}} + duenn = {"relevance": "relevant", "facts": {}} + peripher = {"relevance": "peripheral", "facts": {"key_points": ["a"] * 9}} + assert gq.sub_budget(dicht["facts"]) > gq.sub_budget(duenn["facts"]) + assert gq.block_budget([dicht, duenn, peripher]) == gq.block_budget([dicht, duenn]) def test_redundanz_findet_absatz_doppel(): diff --git a/backend/tests/test_race.py b/backend/tests/test_race.py index 6fc4c96..e2f7395 100644 --- a/backend/tests/test_race.py +++ b/backend/tests/test_race.py @@ -25,7 +25,7 @@ async def test_hedge_zwilling_rettet_stall(monkeypatch): 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") + res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude") assert res == ["zwilling"] assert calls == ["k1", "k1-h"] assert "k1" in killed # das hängende Original läuft nicht weiter @@ -43,7 +43,7 @@ async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch): 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") + res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude") assert res == ["k1"] assert "k1-h" in killed @@ -59,7 +59,25 @@ async def test_hedge_aus_bei_null(monkeypatch): monkeypatch.setattr(pipeline, "run_agent", fake_agent) monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0) - res = await pipeline._race("t", "Test", [_slot()], 1, 60, "claude") + res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude") + assert res == ["ok"] + assert calls == ["k1"] + + +async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch): + """Die Schwelle ist max(HEDGE_NACH_S, timeout/2): ein gesunder Call, der länger als + die Untergrenze, aber kürzer als das halbe Timeout läuft, bekommt KEINEN Zwilling + (pauschale 90 s hedgten jeden normalen Fix-Call).""" + calls = [] + + async def fake_agent(key, prompt, timeout, **kw): + calls.append(key) + await asyncio.sleep(0.15) # > Untergrenze 0.05, < timeout/2 = 0.5 + return (0, "ok", "") + + monkeypatch.setattr(pipeline, "run_agent", fake_agent) + monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05) + res = await pipeline._race("t", "Test", [_slot()], 1, 1.0, "claude") assert res == ["ok"] assert calls == ["k1"] @@ -133,6 +151,6 @@ async def test_hedge_zwilling_ersetzt_restart(monkeypatch): 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") + res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude") assert res == ["zwilling"] assert calls == ["k1", "k1-h"] # kein dritter Spawn