This commit is contained in:
Team3
2026-07-05 13:07:12 +02:00
parent 6a765b7d89
commit fb3e967fdb
7 changed files with 122 additions and 57 deletions

View File

@@ -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.74.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 45 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 à 160230 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 110135 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

View File

@@ -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 24×
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"<!--\s*ausführlich\s*-->", 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: 12 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")

View File

@@ -10,6 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.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 1501200/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 # ~12 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

View File

@@ -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 110135 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: 160230 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)))

View File

@@ -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):

View File

@@ -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", "<!-- ausführlich -->\nkurz")
ok = _card("Beta", "<!-- ausführlich -->\n" + "x" * 500)
out = gq.laengen_ausreisser([duenn, ok], {"alpha": {"s1"}, "beta": {"s1"}})
assert [x["block"] for x in out] == ["Alpha"]
dick = _card("Gamma", "<!-- ausführlich -->\n" + "x" * 2000)
ohne = _card("Delta", "<!-- ausführlich -->\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():

View File

@@ -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