update
This commit is contained in:
@@ -31,7 +31,7 @@ from jsonio import parse_json_text, read_json_file as _json_file
|
|||||||
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
|
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
|
||||||
from crawl import crawl
|
from crawl import crawl
|
||||||
from pipeline import (
|
from pipeline import (
|
||||||
CANCELLED, FAILED, OK, GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
|
CANCELLED, FAILED, OK, GenContext, _detached, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
|
||||||
_relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot,
|
_relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot,
|
||||||
)
|
)
|
||||||
from textkit import (
|
from textkit import (
|
||||||
@@ -597,6 +597,39 @@ def _sink_json(result, path: Path, schema):
|
|||||||
return val
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
async def _panel_2of3(tasks: dict, sink, outs_now, norm) -> None:
|
||||||
|
"""Panel-Welle „first 2 agree": kehrt zurück, sobald zwei vorliegende Verdicts
|
||||||
|
übereinstimmen — die dritte Stimme kann die Mehrheit dann nicht mehr kippen. Sonst
|
||||||
|
(Dissens) wird weiter gewartet. Der Langsamste bestimmte jede Welle (gemessen: 98 s
|
||||||
|
bei ok-p50 ~50 s). Nachzügler laufen detached weiter; ihr File dient nur dem Resume.
|
||||||
|
tasks: {Task: judge_nr} · sink(j, result) persistiert · outs_now() liest Verdicts ·
|
||||||
|
norm(verdict) macht sie vergleichbar."""
|
||||||
|
offen = dict(tasks)
|
||||||
|
while offen:
|
||||||
|
done, _rest = await asyncio.wait(list(offen), return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for t in done:
|
||||||
|
j = offen.pop(t)
|
||||||
|
try:
|
||||||
|
r = t.result()
|
||||||
|
except Exception: # noqa: BLE001 — Panel ist fail-open, Ausfall = fehlende Stimme
|
||||||
|
continue
|
||||||
|
if isinstance(r, tuple):
|
||||||
|
sink(j, r)
|
||||||
|
outs = [norm(s) for s in outs_now()]
|
||||||
|
if len(outs) >= 2 and any(outs[a] == outs[b]
|
||||||
|
for a in range(len(outs)) for b in range(a + 1, len(outs))):
|
||||||
|
break
|
||||||
|
for t, j in offen.items(): # Dritter läuft weiter — sein File zählt fürs Resume
|
||||||
|
async def _warte(t=t, j=j):
|
||||||
|
try:
|
||||||
|
r = await t
|
||||||
|
if isinstance(r, tuple):
|
||||||
|
sink(j, r)
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
||||||
|
pass
|
||||||
|
_detached(asyncio.create_task(_warte()))
|
||||||
|
|
||||||
|
|
||||||
def _sink_subs(result, path: Path):
|
def _sink_subs(result, path: Path):
|
||||||
"""Finder reply as TEXT (marker format), persisted to `path` for audit/diagnosis.
|
"""Finder reply as TEXT (marker format), persisted to `path` for audit/diagnosis.
|
||||||
File fallback: a tool-capable agent (thema web mode) that wrote the file despite the
|
File fallback: a tool-capable agent (thema web mode) that wrote the file despite the
|
||||||
@@ -796,7 +829,23 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
|||||||
"role": "quick", "capabilities": round_caps,
|
"role": "quick", "capabilities": round_caps,
|
||||||
"payload": (lambda result, p=p: _sink_subs(result, p)),
|
"payload": (lambda result, p=p: _sink_subs(result, p)),
|
||||||
} for k, p in zip(keys, paths)]
|
} for k, p in zip(keys, paths)]
|
||||||
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
|
||||||
|
async def _fold_late(d: dict) -> None:
|
||||||
|
"""Dritte Stimme nachbuchen statt warten (ersetzte grace=300, gemessen 73 s/Runde):
|
||||||
|
Mentions sind additiv; ein Fund, den nur der Nachzügler hat, bleibt Einzelfund
|
||||||
|
und läuft durchs Clarify-Quellen-Gate — verfälscht wird nichts."""
|
||||||
|
for marker, subs in d.items():
|
||||||
|
num = _resolve_title(chunk_idx, marker)
|
||||||
|
if num is None:
|
||||||
|
continue
|
||||||
|
seen_late = set()
|
||||||
|
for sub in subs:
|
||||||
|
sn = _norm_title(sub)
|
||||||
|
if sn and sn not in seen_late:
|
||||||
|
seen_late.add(sn)
|
||||||
|
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
|
||||||
|
|
||||||
|
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, late=_fold_late)
|
||||||
if is_cancelled() or not agent_texts:
|
if is_cancelled() or not agent_texts:
|
||||||
return None
|
return None
|
||||||
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
|
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
|
||||||
@@ -1846,16 +1895,29 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
fk[f] = ek[f]
|
fk[f] = ek[f]
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
def _inline_source(queries: list[str]) -> tuple[str, str]:
|
||||||
|
"""Korpus-Auszüge INLINE statt Datei-Recherche → (source, capabilities). Die
|
||||||
|
Tool-Agenten (bash/read) verloren sich messbar in Reasoning-Schleifen (bis 39k
|
||||||
|
Zeichen) und endeten mit leerem Turn — Retry-Wellen à 60–90 s seriell pro Block.
|
||||||
|
No-Tool-Calls mit Inline-Material hatten 0 solcher Fälle (Muster: Facts-Check).
|
||||||
|
Fail-open: ohne Treffer bleibt die alte Selbst-Recherche."""
|
||||||
|
ev = _evidence_pack(folder, sources, queries) if folder else ""
|
||||||
|
if ev:
|
||||||
|
return _prompt("Blocks-Source-Inline", excerpts=ev), "none"
|
||||||
|
return source, caps
|
||||||
|
|
||||||
# Phase "Facts find": 1 generator per chunk.
|
# Phase "Facts find": 1 generator per chunk.
|
||||||
async def _find(ci, idxs):
|
async def _find(ci, idxs):
|
||||||
fp = raw_path(ci)
|
fp = raw_path(ci)
|
||||||
if _facts_schema(_json_file(fp)):
|
if _facts_schema(_json_file(fp)):
|
||||||
return True
|
return True
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
|
f_source, f_caps = await asyncio.to_thread(
|
||||||
|
_inline_source, [blocks[i][0] for i in idxs] + [s for i in idxs for s in blocks[i][1]])
|
||||||
status, _r = await run_single_slot(
|
status, _r = await run_single_slot(
|
||||||
ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
||||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
prompt=_prompt("Facts-Research", topic=topic, source=f_source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=f_caps,
|
||||||
payload=lambda result, p=fp: _sink_or_file(result, p, _facts_schema),
|
payload=lambda result, p=fp: _sink_or_file(result, p, _facts_schema),
|
||||||
timeout=_timeout("content", subs_total))
|
timeout=_timeout("content", subs_total))
|
||||||
return status != FAILED and _facts_schema(_json_file(fp)) is not None
|
return status != FAILED and _facts_schema(_json_file(fp)) is not None
|
||||||
@@ -1883,10 +1945,12 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
for fk in fm.values())
|
for fk in fm.values())
|
||||||
for bt, fm in per.items())
|
for bt, fm in per.items())
|
||||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||||
|
e_source, e_caps = await asyncio.to_thread(
|
||||||
|
_inline_source, [blocks[i][0] for i in idxs] + [s for i in idxs for s in blocks[i][1]])
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
||||||
prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
|
prompt=_prompt("Facts-Supplement", topic=topic, source=e_source, blocks=block, out_path=ep, extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=e_caps,
|
||||||
payload=lambda result, p=ep: _sink_or_file(result, p, _facts_schema),
|
payload=lambda result, p=ep: _sink_or_file(result, p, _facts_schema),
|
||||||
timeout=_timeout("content", subs_total))
|
timeout=_timeout("content", subs_total))
|
||||||
|
|
||||||
@@ -1913,16 +1977,17 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
ev = _cited_evidence(folder, sources, cites, fallback) if folder else ""
|
ev = _cited_evidence(folder, sources, cites, fallback) if folder else ""
|
||||||
c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source
|
c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source
|
||||||
pending = [j for j in panel if _facts_check_schema(_json_file(chk_path(ci, j))) is None]
|
pending = [j for j in panel if _facts_check_schema(_json_file(chk_path(ci, j))) is None]
|
||||||
rs = await asyncio.gather(*[
|
tmap = {asyncio.create_task(
|
||||||
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
||||||
_prompt("Facts-Check", topic=topic, source=c_source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
_prompt("Facts-Check", topic=topic, source=c_source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
||||||
_timeout("content_check", len(per)), provider=provider, role="judge",
|
_timeout("content_check", len(per)), provider=provider, role="judge",
|
||||||
capabilities="none" if ev else caps,
|
capabilities="none" if ev else caps,
|
||||||
scope=topic, label=f"{lbl}Facts check {ci}/{j}")
|
scope=topic, label=f"{lbl}Facts check {ci}/{j}")): j
|
||||||
for j in pending], return_exceptions=True)
|
for j in pending}
|
||||||
for j, r in zip(pending, rs):
|
await _panel_2of3(tmap, lambda j, r: _sink_json(r, chk_path(ci, j), _facts_check_schema),
|
||||||
if isinstance(r, tuple):
|
lambda: [s for j in panel
|
||||||
_sink_json(r, chk_path(ci, j), _facts_check_schema)
|
if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None],
|
||||||
|
lambda s: {tuple(x) for x in s})
|
||||||
outs = [s for j in panel if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
outs = [s for j in panel if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
||||||
bvotes: dict[str, int] = {}
|
bvotes: dict[str, int] = {}
|
||||||
vvotes: dict[str, int] = {}
|
vvotes: dict[str, int] = {}
|
||||||
@@ -1970,10 +2035,13 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
|||||||
goal.append(f"BLOCK: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in affected_subs))
|
goal.append(f"BLOCK: {bt}\nSUBBAUSTEINE:\n" + "\n".join(f"- {s}" for s in affected_subs))
|
||||||
if not goal:
|
if not goal:
|
||||||
return
|
return
|
||||||
|
x_source, x_caps = await asyncio.to_thread(
|
||||||
|
_inline_source, [bt for bt in rel_by] + [s for subs in rel_by.values()
|
||||||
|
for s in subs if _norm_title(s) in subs_norm])
|
||||||
await run_single_slot(
|
await run_single_slot(
|
||||||
ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
||||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
|
prompt=_prompt("Facts-Research", topic=topic, source=x_source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
|
||||||
role="quick", capabilities=caps,
|
role="quick", capabilities=x_caps,
|
||||||
payload=lambda result, p=fix_path(ci): _sink_or_file(result, p, _facts_schema),
|
payload=lambda result, p=fix_path(ci): _sink_or_file(result, p, _facts_schema),
|
||||||
timeout=_timeout("content", len(subs_norm)))
|
timeout=_timeout("content", len(subs_norm)))
|
||||||
await _gather_progress([_fix(ci) for ci in correctable], len(correctable), _report_p(set_p, topic, "Facts fix"))
|
await _gather_progress([_fix(ci) for ci in correctable], len(correctable), _report_p(set_p, topic, "Facts fix"))
|
||||||
@@ -3266,15 +3334,16 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
|||||||
pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _example_check_schema(_json_file(cpath(j))) is None]
|
pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _example_check_schema(_json_file(cpath(j))) is None]
|
||||||
if pending:
|
if pending:
|
||||||
# ground truth (facts) is fully inline → no tools, text reply, engine persists
|
# ground truth (facts) is fully inline → no tools, text reply, engine persists
|
||||||
rs = await asyncio.gather(*[
|
tmap = {asyncio.create_task(
|
||||||
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
|
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
|
||||||
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
|
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
|
||||||
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities="none",
|
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities="none",
|
||||||
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
|
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")): j
|
||||||
for j in pending], return_exceptions=True)
|
for j in pending}
|
||||||
for j, r in zip(pending, rs):
|
await _panel_2of3(tmap, lambda j, r: _sink_json(r, cpath(j), _example_check_schema),
|
||||||
if isinstance(r, tuple):
|
lambda: [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL]
|
||||||
_sink_json(r, cpath(j), _example_check_schema)
|
if (s := _example_check_schema(_json_file(cpath(j)))) is not None],
|
||||||
|
lambda s: frozenset(s))
|
||||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
|
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
|
||||||
if not outs:
|
if not outs:
|
||||||
return items # no exam possible → keep (best-effort)
|
return items # no exam possible → keep (best-effort)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.
|
"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.
|
||||||
|
|
||||||
A card is spawned by board 1's `done` column per mirrored block and runs through:
|
A card is spawned by board 1's `done` column per mirrored block and runs through:
|
||||||
subblocks → facts → levels → relevance → question_pattern → artefacts → finalize
|
subblocks → facts → levels → relevance → question_pattern (+artefacts parallel) → finalize
|
||||||
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
|
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
|
||||||
files + the DB tables. `outline` is a topic-wide BARRIER singleton at the very end
|
files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
|
||||||
(prerequisite graph → chapter order), re-run once per generation run.
|
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
|
||||||
|
re-run once per generation run.
|
||||||
|
|
||||||
The heavy lifting is the existing per-block functions in blocks.py — each card gets its own
|
The heavy lifting is the existing per-block functions in blocks.py — each card gets its own
|
||||||
work subdirectory + facts/artefakte paths, so their slot files never collide across blocks."""
|
work subdirectory + facts/artefakte paths, so their slot files never collide across blocks."""
|
||||||
@@ -274,61 +275,56 @@ def _cross_schema(data) -> dict[int, str] | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||||
"""BARRIER/drain — cross-block sub dedup: the SAME statement carried by two blocks
|
"""BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two
|
||||||
(measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes — the
|
blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes —
|
||||||
in-block paths never see these). Embedding candidates (block≠block, cos ≥
|
the in-block paths never see these). Embedding candidates (block≠block, cos ≥
|
||||||
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
|
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
|
||||||
statement. The loser leaves its card's raw/facts and turns DB `variant` — before
|
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
|
||||||
questions/artefacts exist, so no orphans. Fail-open on judge failure/dissent."""
|
Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
|
||||||
|
Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
|
||||||
|
die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
|
||||||
|
Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
|
||||||
|
from repair import falte_sub
|
||||||
topic = flow.topic
|
topic = flow.topic
|
||||||
work_dir = flow.work_dir
|
work_dir = flow.work_dir
|
||||||
package_norms = {c["card_id"] for c in cards}
|
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
|
||||||
entries: list[tuple[int, str, str]] = [] # (card idx, block title, sub title); idx -1 = context
|
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
|
||||||
for ci, c in enumerate(cards):
|
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
|
||||||
for bt, subs in (c["payload"].get("raw") or {}).items():
|
nachzuegler = [(c["card_id"], "question_pattern") for c in cards
|
||||||
for s in subs:
|
if "pattern" not in c["payload"]]
|
||||||
entries.append((ci, bt, s))
|
if nachzuegler:
|
||||||
n_pkg = len(entries)
|
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
||||||
# Context: consensus subs of blocks already PAST this barrier (late spawns via the
|
flow.wake.set()
|
||||||
# gap-check feedback would otherwise never be compared). Context never folds —
|
return
|
||||||
# its card payload lives downstream (board-1 rule: confirmed context always wins).
|
|
||||||
for r in await db.list_subblocks(topic):
|
|
||||||
if r["status"] == "consensus" and r["block_norm"] not in package_norms:
|
|
||||||
entries.append((-1, r["block"], r["sub_title"]))
|
|
||||||
ctx_facts: dict[str, dict] = {} # facts of downstream cards (DB rows carry none yet)
|
|
||||||
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
|
||||||
if bc["card_id"] not in package_norms:
|
|
||||||
for bt, fm in (bc["payload"].get("facts") or {}).items():
|
|
||||||
ctx_facts[_norm_title(bt)] = fm
|
|
||||||
|
|
||||||
async def _advance_all():
|
async def _advance_all():
|
||||||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], "question_pattern") for c in cards])
|
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
|
||||||
flow.wake.set()
|
flow.wake.set()
|
||||||
|
|
||||||
if n_pkg < 1 or len(entries) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
|
rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
|
||||||
|
if len(rows) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
|
||||||
await _advance_all()
|
await _advance_all()
|
||||||
return
|
return
|
||||||
sims = await asyncio.to_thread(embedding.embed_sims, [s for _, _, s in entries])
|
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||||||
if sims is None:
|
if sims is None:
|
||||||
await _advance_all()
|
await _advance_all()
|
||||||
return
|
return
|
||||||
negs = [_neg_set(s) for _, _, s in entries]
|
negs = [_neg_set(r["sub_title"]) for r in rows]
|
||||||
pairs = [(i, j) for i in range(len(entries)) for j in range(i + 1, len(entries))
|
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
|
||||||
if entries[i][0] != entries[j][0] and negs[i] == negs[j]
|
if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j]
|
||||||
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
|
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
|
||||||
if not pairs:
|
if not pairs:
|
||||||
await _advance_all()
|
await _advance_all()
|
||||||
return
|
return
|
||||||
|
|
||||||
def _kp(ci: int, bt: str, s: str) -> list:
|
def _kp(r: dict) -> list:
|
||||||
if ci < 0:
|
try:
|
||||||
f = ctx_facts.get(_norm_title(bt)) or {}
|
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
|
||||||
else:
|
except ValueError:
|
||||||
f = (cards[ci]["payload"].get("facts") or {}).get(bt) or {}
|
return []
|
||||||
return (f.get(_norm_title(s)) or {}).get("key_points") or []
|
|
||||||
|
|
||||||
def _side(tag: str, ci: int, bt: str, s: str) -> str:
|
def _side(tag: str, r: dict) -> str:
|
||||||
return f"{tag}: [Block: {bt}] {s}" + "".join(f"\n - {p}" for p in _kp(ci, bt, s))
|
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
|
||||||
|
|
||||||
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
|
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
|
||||||
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
|
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
|
||||||
@@ -338,7 +334,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
|||||||
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
|
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
|
||||||
empty dict = fail-open (pairs stay)."""
|
empty dict = fail-open (pairs stay)."""
|
||||||
lines = "\n\n".join(
|
lines = "\n\n".join(
|
||||||
f"{k}.\n{_side('A', *entries[i])}\n{_side('B', *entries[j])}"
|
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}"
|
||||||
for k, (i, j) in enumerate(chunk, 1))
|
for k, (i, j) in enumerate(chunk, 1))
|
||||||
h = hashlib.md5(lines.encode()).hexdigest()[:8]
|
h = hashlib.md5(lines.encode()).hexdigest()[:8]
|
||||||
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
|
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
|
||||||
@@ -374,7 +370,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
|||||||
disputed = [k for k, v in final.items() if v == "uneinig"]
|
disputed = [k for k, v in final.items() if v == "uneinig"]
|
||||||
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
|
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
|
||||||
d_lines = "\n\n".join(
|
d_lines = "\n\n".join(
|
||||||
f"{x}.\n{_side('A', *entries[chunk[k - 1][0]])}\n{_side('B', *entries[chunk[k - 1][1]])}"
|
f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[chunk[k - 1][1]])}"
|
||||||
for x, k in enumerate(disputed, 1))
|
for x, k in enumerate(disputed, 1))
|
||||||
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
|
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
|
||||||
await _judge(3, p3, d_lines, len(disputed))
|
await _judge(3, p3, d_lines, len(disputed))
|
||||||
@@ -397,39 +393,23 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
|
|||||||
for k, v in fin.items():
|
for k, v in fin.items():
|
||||||
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
|
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
|
||||||
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
|
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
|
||||||
gone: set[int] = set()
|
gone: set[tuple] = set()
|
||||||
touched: set[int] = set()
|
|
||||||
for k, (i, j) in enumerate(pairs, 1):
|
for k, (i, j) in enumerate(pairs, 1):
|
||||||
verdict = final_all.get(k, "nein")
|
verdict = final_all.get(k, "nein")
|
||||||
journal["verdicts"].append({"a": f"{entries[i][1]} · {entries[i][2]}",
|
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
|
||||||
"b": f"{entries[j][1]} · {entries[j][2]}",
|
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
|
||||||
"verdict": verdict})
|
"verdict": verdict})
|
||||||
if verdict not in ("a", "b"):
|
if verdict not in ("a", "b"):
|
||||||
continue
|
continue
|
||||||
lose = j if verdict == "a" else i
|
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
|
||||||
if entries[lose][0] < 0: # context never folds — the package side goes instead
|
wk = (win["block_norm"], win["sub_norm"])
|
||||||
lose = i if lose == j else j
|
lk = (lose["block_norm"], lose["sub_norm"])
|
||||||
keep = i if lose == j else j
|
if lk in gone or wk in gone: # keeper already folded → don't chain away the content
|
||||||
if lose in gone or keep in gone: # keeper already folded → don't chain away the content
|
|
||||||
continue
|
continue
|
||||||
ci, bt, s = entries[lose]
|
await falte_sub(topic, files, win, lose)
|
||||||
p = cards[ci]["payload"]
|
gone.add(lk)
|
||||||
if s in (p.get("raw") or {}).get(bt, []):
|
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
|
||||||
p["raw"][bt].remove(s)
|
"bleibt": f"{win['block']} · {win['sub_title']}"})
|
||||||
(p.get("facts") or {}).get(bt, {}).pop(_norm_title(s), None)
|
|
||||||
sc = (p.get("sidecar") or {}).get(bt)
|
|
||||||
if isinstance(sc, list): # questions/artefacts consume the sidecar downstream
|
|
||||||
p["sidecar"][bt] = [e for e in sc
|
|
||||||
if _norm_title(str((e or {}).get("title", ""))) != _norm_title(s)]
|
|
||||||
await db.set_subblock_fields(topic, _norm_title(bt), _norm_title(s), status="variant")
|
|
||||||
gone.add(lose)
|
|
||||||
touched.add(ci)
|
|
||||||
journal["gefaltet"].append({"weg": f"{bt} · {s}",
|
|
||||||
"bleibt": f"{entries[keep][1]} · {entries[keep][2]}"})
|
|
||||||
for ci in touched:
|
|
||||||
p = cards[ci]["payload"]
|
|
||||||
p["raw"] = {bt: subs for bt, subs in (p.get("raw") or {}).items() if subs}
|
|
||||||
await db.kanban_set_payload(topic, BOARD, cards[ci]["card_id"], p)
|
|
||||||
if journal["gefaltet"]:
|
if journal["gefaltet"]:
|
||||||
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
|
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
|
||||||
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
|
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
|
||||||
@@ -483,25 +463,33 @@ async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions
|
|||||||
sub["relevance"] = rel.get(gid, "relevant")
|
sub["relevance"] = rel.get(gid, "relevant")
|
||||||
p["sidecar"] = sidecar
|
p["sidecar"] = sidecar
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||||
await db.kanban_advance(topic, BOARD, norm, "konsolidierung")
|
await db.kanban_advance(topic, BOARD, norm, "question_pattern")
|
||||||
|
|
||||||
await _gather_cards(ctx, flow, cards, one)
|
await _gather_cards(ctx, flow, cards, one)
|
||||||
|
|
||||||
|
|
||||||
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||||
|
"""Fragen UND Artefakte im Fächer: beide brauchen nur den sidecar, nichts voneinander —
|
||||||
|
als Stage-Treppe kosteten sie zwei serielle Call-Segmente auf dem kritischen Pfad.
|
||||||
|
Die artefacts-Stage bleibt für Resume-Karten alter Läufe registriert."""
|
||||||
topic = flow.topic
|
topic = flow.topic
|
||||||
|
|
||||||
async def one(c):
|
async def one(c):
|
||||||
p = c["payload"]
|
p = c["payload"]
|
||||||
norm = c["card_id"]
|
norm = c["card_id"]
|
||||||
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
pattern, artefacts = await asyncio.gather(
|
||||||
p.get("sidecar") or {}, instructions,
|
_question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
p.get("sidecar") or {}, instructions,
|
||||||
|
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · "),
|
||||||
|
_artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||||
|
p.get("sidecar") or {}, instructions,
|
||||||
|
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · "))
|
||||||
if pattern is None:
|
if pattern is None:
|
||||||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
||||||
p["pattern"] = pattern
|
p["pattern"] = pattern
|
||||||
|
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||||
await db.kanban_advance(topic, BOARD, norm, "artefacts")
|
await db.kanban_advance(topic, BOARD, norm, "finalize")
|
||||||
|
|
||||||
await _gather_cards(ctx, flow, cards, one)
|
await _gather_cards(ctx, flow, cards, one)
|
||||||
|
|
||||||
@@ -594,7 +582,7 @@ async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
|
|||||||
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
|
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
|
||||||
ensure_ascii=False)
|
ensure_ascii=False)
|
||||||
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
|
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
|
||||||
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
|
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
|
||||||
_log(topic, f"Artefakte fertig: {title}")
|
_log(topic, f"Artefakte fertig: {title}")
|
||||||
flow.wake.set()
|
flow.wake.set()
|
||||||
|
|
||||||
@@ -647,15 +635,17 @@ def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
|||||||
Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)),
|
Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)),
|
||||||
Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)),
|
Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)),
|
||||||
Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
|
Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
|
||||||
# Barrier sits AFTER the sub-local stages: cards used to idle here median 36 min
|
Stage(BOARD, "question_pattern",
|
||||||
# while levels/relevance work was still ahead of them
|
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
|
||||||
|
# Resume-Pfad: Karten alter Läufe, die noch in artefacts stehen
|
||||||
|
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
|
||||||
|
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
||||||
|
# Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
|
||||||
|
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
|
||||||
|
# per repair.falte_sub — spät gefundene Dubletten kosten Artefakt-Tokens, keine Wandzeit
|
||||||
Stage(BOARD, "konsolidierung",
|
Stage(BOARD, "konsolidierung",
|
||||||
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
|
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
|
||||||
barrier=True, drain=True),
|
barrier=True, drain=True),
|
||||||
Stage(BOARD, "question_pattern",
|
|
||||||
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
|
|
||||||
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
|
|
||||||
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
|
|
||||||
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
||||||
barrier=True, drain=True, gate=research_done),
|
barrier=True, drain=True, gate=research_done),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1699,19 +1699,23 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
|||||||
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
|
||||||
if artefacts and QA_GATE_NOTE > 0 else None)
|
if artefacts and QA_GATE_NOTE > 0 else None)
|
||||||
try:
|
try:
|
||||||
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
try:
|
||||||
|
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
|
||||||
|
finally:
|
||||||
|
stopper.cancel()
|
||||||
|
if watcher:
|
||||||
|
watcher.cancel()
|
||||||
|
if ctx.is_cancelled():
|
||||||
|
return False
|
||||||
|
if flow.state.get("qa_paused"):
|
||||||
|
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
|
||||||
|
await _write_final(topic, files)
|
||||||
|
await _write_run_summary(topic, flow)
|
||||||
|
return True
|
||||||
finally:
|
finally:
|
||||||
stopper.cancel()
|
# erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf —
|
||||||
if watcher:
|
# vorher fielen sie ohne run_id aus jeder Run-Aggregation (Lauf 20260704-1452-b223)
|
||||||
watcher.cancel()
|
|
||||||
db.set_current_run(topic, None)
|
db.set_current_run(topic, None)
|
||||||
if ctx.is_cancelled():
|
|
||||||
return False
|
|
||||||
if flow.state.get("qa_paused"):
|
|
||||||
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
|
|
||||||
await _write_final(topic, files)
|
|
||||||
await _write_run_summary(topic, flow)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
|
||||||
@@ -1741,7 +1745,7 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
|
|||||||
flow.state["qa_note"] = note
|
flow.state["qa_note"] = note
|
||||||
if report:
|
if report:
|
||||||
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
|
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
|
||||||
await asyncio.to_thread(qa._write_report, report)
|
await qa.write_report(report)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
|
||||||
if note >= QA_GATE_NOTE:
|
if note >= QA_GATE_NOTE:
|
||||||
@@ -1781,7 +1785,7 @@ async def _write_run_summary(topic: str, flow: Flow):
|
|||||||
summary["note"] = report["note"]
|
summary["note"] = report["note"]
|
||||||
summary["note_artefakte"] = report.get("note_artefakte")
|
summary["note_artefakte"] = report.get("note_artefakte")
|
||||||
summary["artefakte"] = report.get("artefakte", {})
|
summary["artefakte"] = report.get("artefakte", {})
|
||||||
await asyncio.to_thread(qa._write_report, report)
|
await qa.write_report(report)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("[%s] Abschluss-QA fehlgeschlagen", topic)
|
log.exception("[%s] Abschluss-QA fehlgeschlagen", topic)
|
||||||
atomic_write_json(flow.work_dir / "lauf-summary.json", summary, indent=1)
|
atomic_write_json(flow.work_dir / "lauf-summary.json", summary, indent=1)
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
|
|||||||
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
|
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
|
||||||
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
|
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
|
||||||
MAX_RESTARTS = 2 # agent restart cap per race slot
|
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.
|
||||||
|
HEDGE_NACH_S = 90
|
||||||
JUDGE_CHUNK = 40 # repair: findings per judge call
|
JUDGE_CHUNK = 40 # repair: findings per judge call
|
||||||
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
|
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
|
||||||
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
|
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
|
||||||
|
|||||||
@@ -618,73 +618,75 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
|
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
|
||||||
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
try:
|
||||||
subs_raw = await _load_subblocks(topic)
|
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
|
||||||
project = source_folder(topic)
|
subs_raw = await _load_subblocks(topic)
|
||||||
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
|
project = source_folder(topic)
|
||||||
else _prompt("Guide-Facts-Thema"))
|
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
|
||||||
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
|
else _prompt("Guide-Facts-Thema"))
|
||||||
subs_raw, await _chapter_map(topic, entries), fallback, spec)
|
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
|
||||||
for num, line in entries.items():
|
subs_raw, await _chapter_map(topic, entries), fallback, spec)
|
||||||
title = _title(line)
|
for num, line in entries.items():
|
||||||
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
|
title = _title(line)
|
||||||
cards = await db.list_guide_cards(topic, format_name)
|
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
|
||||||
open_cards = [c for c in cards if c["stage"] != "done"]
|
cards = await db.list_guide_cards(topic, format_name)
|
||||||
if open_cards:
|
open_cards = [c for c in cards if c["stage"] != "done"]
|
||||||
sem = asyncio.Semaphore(CARD_CONCURRENCY)
|
if open_cards:
|
||||||
|
sem = asyncio.Semaphore(CARD_CONCURRENCY)
|
||||||
|
|
||||||
async def _progress():
|
async def _progress():
|
||||||
while True:
|
while True:
|
||||||
counts = await db.guide_stage_counts(topic, format_name)
|
counts = await db.guide_stage_counts(topic, format_name)
|
||||||
done = counts.get("done", 0)
|
done = counts.get("done", 0)
|
||||||
total = sum(counts.values())
|
total = sum(counts.values())
|
||||||
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
|
||||||
await asyncio.sleep(2.0)
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
reporter = asyncio.create_task(_progress())
|
reporter = asyncio.create_task(_progress())
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
|
||||||
finally:
|
finally:
|
||||||
reporter.cancel()
|
reporter.cancel()
|
||||||
db.set_current_run(topic, None)
|
if is_guide_cancelled(guide_id):
|
||||||
else:
|
return None
|
||||||
|
# assembly — identical shape to the legacy pipeline
|
||||||
|
cards = await db.list_guide_cards(topic, format_name)
|
||||||
|
chapters: list[dict] = []
|
||||||
|
by_chapter: dict[str, list[dict]] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
|
||||||
|
if c["stage"] != "done":
|
||||||
|
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
|
||||||
|
continue
|
||||||
|
sec = _first_section(c["md"])
|
||||||
|
if sec is None:
|
||||||
|
continue
|
||||||
|
ch = c["chapter"] or "Inhalte"
|
||||||
|
if ch not in by_chapter:
|
||||||
|
by_chapter[ch] = []
|
||||||
|
order.append(ch)
|
||||||
|
by_chapter[ch].append({
|
||||||
|
"num": c["ord"], "title": c["block"], "md": sec["md"],
|
||||||
|
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
|
||||||
|
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
|
||||||
|
"checkable": format_name == "Guide" or bool(
|
||||||
|
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
|
||||||
|
})
|
||||||
|
for ch in order:
|
||||||
|
chapters.append({"title": ch, "sections": by_chapter[ch]})
|
||||||
|
if chapters:
|
||||||
|
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
|
||||||
|
import guide_qa
|
||||||
|
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
||||||
|
if rep:
|
||||||
|
await asyncio.to_thread(guide_qa._write_report, rep)
|
||||||
|
except Exception:
|
||||||
|
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
|
||||||
|
return chapters or None
|
||||||
|
finally:
|
||||||
|
# erst NACH der Abschluss-Guide-QA leeren: deren Judge-Events gehören zum
|
||||||
|
# Lauf — vorher fielen sie ohne run_id aus jeder Run-Aggregation
|
||||||
db.set_current_run(topic, None)
|
db.set_current_run(topic, None)
|
||||||
if is_guide_cancelled(guide_id):
|
|
||||||
return None
|
|
||||||
# assembly — identical shape to the legacy pipeline
|
|
||||||
cards = await db.list_guide_cards(topic, format_name)
|
|
||||||
chapters: list[dict] = []
|
|
||||||
by_chapter: dict[str, list[dict]] = {}
|
|
||||||
order: list[str] = []
|
|
||||||
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
|
|
||||||
if c["stage"] != "done":
|
|
||||||
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
|
|
||||||
continue
|
|
||||||
sec = _first_section(c["md"])
|
|
||||||
if sec is None:
|
|
||||||
continue
|
|
||||||
ch = c["chapter"] or "Inhalte"
|
|
||||||
if ch not in by_chapter:
|
|
||||||
by_chapter[ch] = []
|
|
||||||
order.append(ch)
|
|
||||||
by_chapter[ch].append({
|
|
||||||
"num": c["ord"], "title": c["block"], "md": sec["md"],
|
|
||||||
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
|
|
||||||
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
|
|
||||||
"checkable": format_name == "Guide" or bool(
|
|
||||||
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
|
|
||||||
})
|
|
||||||
for ch in order:
|
|
||||||
chapters.append({"title": ch, "sections": by_chapter[ch]})
|
|
||||||
if chapters:
|
|
||||||
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
|
|
||||||
import guide_qa
|
|
||||||
rep = await guide_qa.guide_qa_report(topic, llm=True)
|
|
||||||
if rep:
|
|
||||||
await asyncio.to_thread(guide_qa._write_report, rep)
|
|
||||||
except Exception:
|
|
||||||
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
|
|
||||||
return chapters or None
|
|
||||||
|
|
||||||
|
|
||||||
async def done_step(topic: str, format_name: str) -> int:
|
async def done_step(topic: str, format_name: str) -> int:
|
||||||
|
|||||||
@@ -164,10 +164,18 @@ _relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈
|
|||||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||||
|
|
||||||
|
|
||||||
from config import MAX_RESTARTS as _MAX_RESTARTS # noqa: E402 — zentral tunebar
|
from config import MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S # noqa: E402 — zentral tunebar
|
||||||
|
|
||||||
|
# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback.
|
||||||
|
_NACHZUEGLER: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
|
def _detached(task: asyncio.Task) -> None:
|
||||||
|
_NACHZUEGLER.add(task)
|
||||||
|
task.add_done_callback(_NACHZUEGLER.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None, late=None) -> list | None:
|
||||||
"""Starts all slots in parallel and collects `quorum` valid results.
|
"""Starts all slots in parallel and collects `quorum` valid results.
|
||||||
|
|
||||||
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
|
||||||
@@ -185,24 +193,59 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
elapses while agents are still running — gives them time to search thoroughly.
|
elapses while agents are still running — gives them time to search thoroughly.
|
||||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||||
|
|
||||||
|
`late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern
|
||||||
|
laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`.
|
||||||
|
Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen,
|
||||||
|
nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde).
|
||||||
"""
|
"""
|
||||||
attempts = {i: 0 for i in range(len(slots))}
|
attempts = {i: 0 for i in range(len(slots))}
|
||||||
tasks: dict[asyncio.Task, int] = {}
|
tasks: dict[asyncio.Task, int] = {}
|
||||||
|
keys: dict[asyncio.Task, str] = {}
|
||||||
|
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)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
start = loop.time()
|
start = loop.time()
|
||||||
min_deadline = start + min_runtime if min_runtime else None
|
min_deadline = start + min_runtime if min_runtime else None
|
||||||
max_deadline = start + max_runtime if max_runtime else None
|
max_deadline = start + max_runtime if max_runtime else None
|
||||||
deadline: float | None = None
|
deadline: float | None = None
|
||||||
|
|
||||||
def spawn(i: int) -> None:
|
def spawn(i: int, suffix: str = "") -> None:
|
||||||
slot = slots[i]
|
slot = slots[i]
|
||||||
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
|
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
|
||||||
|
key = slot["key"] + suffix
|
||||||
task = asyncio.create_task(run_agent(
|
task = asyncio.create_task(run_agent(
|
||||||
slot["key"], slot["prompt"], timeout,
|
key, slot["prompt"], timeout,
|
||||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||||
scope=topic, on_line=slot.get("on_line"), label=lbl,
|
scope=topic, on_line=slot.get("on_line"), label=lbl,
|
||||||
))
|
))
|
||||||
tasks[task] = i
|
tasks[task] = i
|
||||||
|
keys[task] = key
|
||||||
|
born[task] = loop.time()
|
||||||
|
|
||||||
|
spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo)
|
||||||
|
|
||||||
|
def _detach_rest() -> None:
|
||||||
|
"""Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return)."""
|
||||||
|
if late is None:
|
||||||
|
return
|
||||||
|
for t, i in list(tasks.items()):
|
||||||
|
tasks.pop(t)
|
||||||
|
keys.pop(t, None)
|
||||||
|
born.pop(t, None)
|
||||||
|
|
||||||
|
async def _warte(t=t, i=i):
|
||||||
|
try:
|
||||||
|
r = await t
|
||||||
|
if i in spaet:
|
||||||
|
return
|
||||||
|
if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None:
|
||||||
|
spaet.add(i)
|
||||||
|
await late(val)
|
||||||
|
except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort
|
||||||
|
pass
|
||||||
|
_detached(asyncio.create_task(_warte()))
|
||||||
|
|
||||||
for i in range(len(slots)):
|
for i in range(len(slots)):
|
||||||
spawn(i)
|
spawn(i)
|
||||||
@@ -218,8 +261,20 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
return results or None
|
return results or None
|
||||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||||
|
_detach_rest()
|
||||||
return results
|
return results
|
||||||
# Wake up for the earliest relevant deadline (grace, min, or max).
|
# 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:
|
||||||
|
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]:
|
||||||
|
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")
|
||||||
|
# Wake up for the earliest relevant deadline (grace, min, max, or next hedge).
|
||||||
waits = []
|
waits = []
|
||||||
if deadline is not None and len(results) >= quorum:
|
if deadline is not None and len(results) >= quorum:
|
||||||
waits.append(deadline - loop.time())
|
waits.append(deadline - loop.time())
|
||||||
@@ -227,12 +282,21 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
waits.append(min_deadline - loop.time())
|
waits.append(min_deadline - loop.time())
|
||||||
if max_deadline is not None:
|
if max_deadline is not None:
|
||||||
waits.append(max_deadline - loop.time())
|
waits.append(max_deadline - loop.time())
|
||||||
|
if _HEDGE_NACH_S:
|
||||||
|
naechste = [born[t] + _HEDGE_NACH_S - loop.time() for t in tasks
|
||||||
|
if tasks[t] not in hedged | fertig]
|
||||||
|
if naechste:
|
||||||
|
waits.append(max(0.0, min(naechste)))
|
||||||
wait_timeout = max(0.0, min(waits)) if waits else None
|
wait_timeout = max(0.0, min(waits)) if waits else None
|
||||||
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
|
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
|
||||||
if not done:
|
if not done:
|
||||||
continue
|
continue
|
||||||
for task in done:
|
for task in done:
|
||||||
i = tasks.pop(task)
|
i = tasks.pop(task)
|
||||||
|
keys.pop(task, None)
|
||||||
|
born.pop(task, None)
|
||||||
|
if i in fertig:
|
||||||
|
continue # späte Zwillinge eines bereits gewerteten Slots
|
||||||
payload, err = None, None
|
payload, err = None, None
|
||||||
try:
|
try:
|
||||||
result = task.result()
|
result = task.result()
|
||||||
@@ -249,6 +313,10 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
|
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
results.append(payload)
|
results.append(payload)
|
||||||
|
fertig.add(i)
|
||||||
|
for t2 in [t2 for t2, i2 in tasks.items() if i2 == i]: # Zwilling killen
|
||||||
|
kill_process(keys.get(t2, slots[i]["key"]))
|
||||||
|
t2.cancel()
|
||||||
if grace is not None and deadline is None:
|
if grace is not None and deadline is None:
|
||||||
deadline = loop.time() + grace
|
deadline = loop.time() + grace
|
||||||
_log(topic, f"{label}: first result — grace {grace}s running")
|
_log(topic, f"{label}: first result — grace {grace}s running")
|
||||||
@@ -256,23 +324,26 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
|||||||
on_update(len(results))
|
on_update(len(results))
|
||||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||||
|
_detach_rest()
|
||||||
return results
|
return results
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
|
||||||
attempts[i] += 1
|
attempts[i] += 1
|
||||||
# If the minimum already stands, restarts are pointless — the restart
|
# If the minimum already stands, restarts are pointless — the restart
|
||||||
# would be killed at the grace end anyway.
|
# would be killed at the grace end anyway. A still-running twin IS the retry.
|
||||||
enough = grace is not None and len(results) >= quorum
|
enough = grace is not None and len(results) >= quorum
|
||||||
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
|
zwilling = any(i2 == i for i2 in tasks.values())
|
||||||
|
if attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
|
||||||
spawn(i)
|
spawn(i)
|
||||||
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
|
||||||
|
_detach_rest()
|
||||||
return results
|
return results
|
||||||
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
for task, i in tasks.items():
|
for task, i in tasks.items():
|
||||||
kill_process(slots[i]["key"])
|
kill_process(keys.get(task, slots[i]["key"]))
|
||||||
task.cancel()
|
task.cancel()
|
||||||
if tasks:
|
if tasks:
|
||||||
await asyncio.gather(*tasks.keys(), return_exceptions=True)
|
await asyncio.gather(*tasks.keys(), return_exceptions=True)
|
||||||
|
|||||||
@@ -412,6 +412,21 @@ def _write_report(report: dict) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def write_report(report: dict) -> Path:
|
||||||
|
"""_write_report + kompaktes kind='qa'-Event. Die Report-JSONs liegen nur auf der
|
||||||
|
Lauf-Maschine (storage/qa/) — ein DB-Pull reichte nicht, um Note/Quoten eines Runs
|
||||||
|
zu rekonstruieren (Analyse 20260704-1452-b223). Nur die Kennzahlen, kein Volltext;
|
||||||
|
run_id stempelt add_event aus der Registry (gesetzt im Lauf, leer bei manueller QA)."""
|
||||||
|
path = await asyncio.to_thread(_write_report, report)
|
||||||
|
try: # Event ist Komfort — ein DB-Fehler darf den Report nicht kosten (fail-open)
|
||||||
|
await db.add_event(report["topic"], "qa", key=path.stem, meta={
|
||||||
|
"note": report["note"], "note_artefakte": report.get("note_artefakte"),
|
||||||
|
"quoten": report["quoten"], "quoten_artefakte": report.get("quoten_artefakte", {})})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _digest(report: dict, path: Path):
|
def _digest(report: dict, path: Path):
|
||||||
na = report.get("note_artefakte")
|
na = report.get("note_artefakte")
|
||||||
print(f"QA {report['topic']} — {report['bloecke']} Blöcke (run {report['run_id'] or '—'})"
|
print(f"QA {report['topic']} — {report['bloecke']} Blöcke (run {report['run_id'] or '—'})"
|
||||||
@@ -443,7 +458,7 @@ async def main(topic: str, llm: bool):
|
|||||||
report = await qa_report(topic, llm=llm)
|
report = await qa_report(topic, llm=llm)
|
||||||
if report is None:
|
if report is None:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
_digest(report, _write_report(report))
|
_digest(report, await write_report(report))
|
||||||
finally:
|
finally:
|
||||||
await db.close_db()
|
await db.close_db()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur n
|
|||||||
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
|
||||||
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -47,7 +46,7 @@ async def repair_befunde(topic: str) -> dict:
|
|||||||
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
|
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
|
||||||
neu = await qa.qa_report(topic, llm=True)
|
neu = await qa.qa_report(topic, llm=True)
|
||||||
if neu:
|
if neu:
|
||||||
await asyncio.to_thread(qa._write_report, neu)
|
await qa.write_report(neu)
|
||||||
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
|
||||||
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
|
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
|
||||||
|
|
||||||
@@ -135,6 +134,31 @@ def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
|
|||||||
return (a, b) if score(a) >= score(b) else (b, a)
|
return (a, b) if score(a) >= score(b) else (b, a)
|
||||||
|
|
||||||
|
|
||||||
|
async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None:
|
||||||
|
"""Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder
|
||||||
|
löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern
|
||||||
|
von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows."""
|
||||||
|
await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
|
||||||
|
w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
|
||||||
|
for r in await db.list_question_pattern(topic, lose["block_norm"]):
|
||||||
|
if r["sub_norm"] != lose["sub_norm"]:
|
||||||
|
continue
|
||||||
|
if win["sub_norm"] not in w_fragen:
|
||||||
|
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
|
||||||
|
win["block"], win["sub_title"], r["question"])
|
||||||
|
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
|
||||||
|
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
|
||||||
|
if r["sub_norm"] == win["sub_norm"]}
|
||||||
|
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
|
||||||
|
if r["sub_norm"] != lose["sub_norm"]:
|
||||||
|
continue
|
||||||
|
if r["type"] not in w_typen:
|
||||||
|
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
|
||||||
|
r["data"], win["block"], win["sub_title"])
|
||||||
|
await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
|
||||||
|
_entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])
|
||||||
|
|
||||||
|
|
||||||
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[str]:
|
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[str]:
|
||||||
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
|
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
|
||||||
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
|
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
|
||||||
@@ -161,27 +185,8 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
|
|||||||
wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
|
wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
|
||||||
if v.get(i) != "ja" or wk in gone or lk in gone:
|
if v.get(i) != "ja" or wk in gone or lk in gone:
|
||||||
continue
|
continue
|
||||||
await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
|
await falte_sub(topic, files, win, lose)
|
||||||
gone.add(lk)
|
gone.add(lk)
|
||||||
# Fragen/Artefakte des Verlierers: umhängen, wenn der Gewinner den Typ nicht hat
|
|
||||||
w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
|
|
||||||
for r in await db.list_question_pattern(topic, lose["block_norm"]):
|
|
||||||
if r["sub_norm"] != lose["sub_norm"]:
|
|
||||||
continue
|
|
||||||
if win["sub_norm"] not in w_fragen:
|
|
||||||
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
|
|
||||||
win["block"], win["sub_title"], r["question"])
|
|
||||||
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
|
|
||||||
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
|
|
||||||
if r["sub_norm"] == win["sub_norm"]}
|
|
||||||
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
|
|
||||||
if r["sub_norm"] != lose["sub_norm"]:
|
|
||||||
continue
|
|
||||||
if r["type"] not in w_typen:
|
|
||||||
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
|
|
||||||
r["data"], win["block"], win["sub_title"])
|
|
||||||
await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
|
|
||||||
_entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])
|
|
||||||
merged.append(f"{lose['sub_title'][:40]} → {win['sub_title'][:40]}")
|
merged.append(f"{lose['sub_title'][:40]} → {win['sub_title'][:40]}")
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ async def run_qa_route(req: QaRunRequest):
|
|||||||
report = await qa.qa_report(req.topic, llm=req.llm)
|
report = await qa.qa_report(req.topic, llm=req.llm)
|
||||||
if report is None:
|
if report is None:
|
||||||
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
|
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
|
||||||
await asyncio.to_thread(qa._write_report, report)
|
await qa.write_report(report)
|
||||||
note_guide = None
|
note_guide = None
|
||||||
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
|
||||||
import guide_qa
|
import guide_qa
|
||||||
|
|||||||
@@ -176,6 +176,38 @@ async def test_board1_full_flow(board_env):
|
|||||||
assert summary["boards"].get("inventory", {}).get("done_block") == 4
|
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):
|
async def test_filter_judges_run_parallel(board_env, monkeypatch):
|
||||||
"""40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
|
"""40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -388,85 +388,88 @@ class _FakeEmb:
|
|||||||
return arr @ arr.T
|
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)
|
flow = Flow(TOPIC, work_dir=tmp_path)
|
||||||
cards = []
|
cards = []
|
||||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
|
||||||
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
("beta", ["Gleiche Aussage", "Nur in Beta"])):
|
||||||
payload = {"title": bnorm.title(),
|
payload = {"title": bnorm.title()}
|
||||||
"raw": {bnorm.title(): list(subs)},
|
if finalisiert:
|
||||||
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
|
payload.update(pattern={}, artefacts={})
|
||||||
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
|
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||||
await _seed_block(db, bnorm, subs)
|
await _seed_block(db, bnorm, subs)
|
||||||
cards.append({"card_id": bnorm, "payload": payload})
|
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):
|
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
|
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)
|
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
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"]
|
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
|
||||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
for cid in ("alpha", "beta"):
|
||||||
assert beta["stage"] == "question_pattern"
|
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
|
||||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
|
assert beta_rows[sn] == "variant"
|
||||||
# Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
|
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||||
assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
|
assert alpha_rows[sn] == "consensus"
|
||||||
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
|
fragen = await db.list_question_pattern(TOPIC)
|
||||||
assert alpha["stage"] == "question_pattern"
|
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
|
||||||
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
|
||||||
db = testdb
|
db = testdb
|
||||||
flow, cards = await _cross_env(db, tmp_path)
|
flow, cards, files = await _cross_env(db, tmp_path)
|
||||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
|
||||||
"j3": {"pairs": {"1": "a"}}})
|
"j3": {"pairs": {"1": "a"}}})
|
||||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
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
|
assert len(fake.calls) == 3
|
||||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||||
|
|
||||||
|
|
||||||
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
|
||||||
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
|
||||||
db = testdb
|
db = testdb
|
||||||
flow, cards = await _cross_env(db, tmp_path)
|
flow, cards, files = await _cross_env(db, tmp_path)
|
||||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
|
||||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
|
||||||
assert beta["stage"] == "question_pattern"
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]
|
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
|
||||||
|
|
||||||
|
|
||||||
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
|
||||||
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
|
||||||
db = testdb
|
db = testdb
|
||||||
flow, cards = await _cross_env(db, tmp_path)
|
flow, cards, files = await _cross_env(db, tmp_path)
|
||||||
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
monkeypatch.setattr(ba, "embedding", _FakeEmb)
|
||||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
|
||||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
monkeypatch.setattr(ba, "run_single_slot", fake)
|
||||||
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
|
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
|
||||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
|
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
|
||||||
|
|
||||||
|
|
||||||
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
|
||||||
db = testdb
|
db = testdb
|
||||||
flow, cards = await _cross_env(db, tmp_path)
|
flow, cards, files = await _cross_env(db, tmp_path)
|
||||||
|
|
||||||
class _Aus:
|
class _Aus:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -478,33 +481,27 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
|
|||||||
|
|
||||||
monkeypatch.setattr(ba, "embedding", _Aus)
|
monkeypatch.setattr(ba, "embedding", _Aus)
|
||||||
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
|
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"):
|
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", cid))["stage"] == "question_pattern"
|
||||||
|
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_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
|
|
||||||
|
|
||||||
|
|
||||||
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
|
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 = []
|
cards = []
|
||||||
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
|
||||||
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
|
||||||
payload = {"title": bnorm.title(),
|
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
|
||||||
"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}}}
|
|
||||||
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
|
||||||
await _seed_block(db, bnorm, subs)
|
await _seed_block(db, bnorm, subs)
|
||||||
cards.append({"card_id": bnorm, "payload": payload})
|
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, "embedding", _FakeEmb)
|
||||||
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
|
||||||
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
|
||||||
monkeypatch.setattr(ba, "run_single_slot", fake)
|
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
|
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
|
||||||
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
|
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
|
||||||
assert beta["payload"]["raw"].get("Beta", []) == [] # beide Dubletten global gefaltet
|
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
|
||||||
|
|
||||||
|
|
||||||
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
|
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
|
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import qa
|
import qa
|
||||||
|
|
||||||
|
|
||||||
@@ -238,6 +240,25 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
|
|||||||
assert report["unecht"] == ["Wackelkandidat"]
|
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):
|
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."""
|
"""DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
|
||||||
import routes
|
import routes
|
||||||
|
|||||||
138
backend/tests/test_race.py
Normal file
138
backend/tests/test_race.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
|
||||||
|
abzuwarten (gemessen: 4 Panel-Stalls à 160–230 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
|
||||||
@@ -57,7 +57,7 @@ def _mk_race(finder_by_agent):
|
|||||||
prompts = []
|
prompts = []
|
||||||
|
|
||||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
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 = []
|
outs = []
|
||||||
for slot in slots:
|
for slot in slots:
|
||||||
key, prompt = slot["key"], slot["prompt"]
|
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
|
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):
|
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,
|
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
|
||||||
die Check-Datei schreibt die Engine aus der Text-Antwort."""
|
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
|
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():
|
def test_sub_key_resolves_short_titles():
|
||||||
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
|
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
|
||||||
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
|
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
|
||||||
|
|||||||
Reference in New Issue
Block a user