652 lines
32 KiB
Python
652 lines
32 KiB
Python
"""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:
|
||
subblocks → facts → levels → relevance → question_pattern (+artefacts parallel) → finalize
|
||
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
|
||
files + the DB tables. Danach zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
|
||
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
|
||
work subdirectory + facts/artefakte paths, so their slot files never collide across blocks."""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import re
|
||
|
||
import database as db
|
||
import blocks
|
||
import embedding
|
||
from blocks import (
|
||
ARTEFACT_TYPES, _artefacts_block, _facts_block, _facts_nachfass, _konsolidiere_subblocks,
|
||
_levels_block, _luecken_runde, _match_sub, _neg_set, _question_pattern_block, _relevance_block,
|
||
_sink_json, _subblocks_block, _outline_block,
|
||
)
|
||
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
|
||
from fsutil import atomic_write_json
|
||
from jsonio import read_json_file as _json_file
|
||
from kanban import Flow, Stage
|
||
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
|
||
from textkit import _norm_title, _title
|
||
|
||
log = logging.getLogger("creator.board_artefacts")
|
||
|
||
BOARD = "artefacts"
|
||
DONE = "done_artefact"
|
||
|
||
|
||
def _nset(msg: str, step: int | None = None) -> None:
|
||
"""Progress no-op — the kanban board itself is the progress display."""
|
||
|
||
|
||
def _safe(norm: str) -> str:
|
||
return re.sub(r"\W+", "-", norm).strip("-")[:24] or "block"
|
||
|
||
|
||
def _sub_key(existing: set[str], sn: str) -> str:
|
||
"""Agents echo the short sub title while the sub row is keyed 'kurztitel: beschreibung' —
|
||
resolve to the stored key: exact, unambiguous prefix, then unambiguous substring
|
||
containment either way (agents paraphrase/truncate, measured 23 orphans of ~560 rows).
|
||
Ambiguous or unresolvable echoes stay unchanged (visible as QA orphan)."""
|
||
if sn in existing:
|
||
return sn
|
||
hits = [s for s in existing if s.startswith(sn + ":")]
|
||
if len(hits) == 1:
|
||
return hits[0]
|
||
if not hits:
|
||
hits = [s for s in sorted(existing) if sn in s or s in sn]
|
||
if len(hits) == 1:
|
||
return hits[0]
|
||
return sn
|
||
|
||
|
||
def _card_set_p(flow: Flow, norm: str):
|
||
"""Per-card progress: the inner step messages land in-memory on the flow —
|
||
board_snapshot shows them as the card's info line + phase stepper while active.
|
||
The step INDEX is resolved to its NAME at write time (indices shift with the
|
||
source type, names are stable)."""
|
||
info = flow.state.setdefault("card_info", {})
|
||
|
||
def set_p(msg: str, step: int | None = None) -> None:
|
||
name = ""
|
||
if step is not None:
|
||
steps = flow.state.get("blocks_steps")
|
||
if steps is None:
|
||
steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
|
||
if 0 <= step < len(steps):
|
||
name = steps[step]
|
||
info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
|
||
return set_p
|
||
|
||
|
||
def _pfiles(files: dict, norm: str) -> dict:
|
||
"""Per-block file namespace: own work dir + facts/artefakte paths, global rest."""
|
||
sub = files["arbeit"] / f"ab-{_norm_title(norm).replace(' ', '_')[:60]}"
|
||
sub.mkdir(parents=True, exist_ok=True)
|
||
return {**files, "arbeit": sub, "facts": sub / "facts.json", "artefakte": sub / "artefakte.json"}
|
||
|
||
|
||
def _entry_line(p: dict) -> str:
|
||
d = p.get("description")
|
||
return f"{p['title']} — {d}" if d else p["title"]
|
||
|
||
|
||
def make_spawner(topic: str, files: dict):
|
||
"""Hook for board 1's `done` column: one artefact card per mirrored block."""
|
||
|
||
async def spawn(block_card_id: str, payload: dict):
|
||
norm = payload.get("mirrored_norm")
|
||
if not norm:
|
||
return
|
||
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", {
|
||
"title": payload.get("title", ""),
|
||
"description": payload.get("description", ""),
|
||
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
|
||
})
|
||
return spawn
|
||
|
||
|
||
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
|
||
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
|
||
errs = [r for r in results if isinstance(r, Exception)]
|
||
if errs:
|
||
raise errs[0]
|
||
flow.wake.set()
|
||
|
||
|
||
def _fail_or_cancel(ctx: GenContext, what: str):
|
||
# A per-card failure belongs on the card (last_error/dead-letter), never in the
|
||
# topic banner — the inner block functions may have set it there.
|
||
blocks._blocks_errors.pop(ctx.topic, None)
|
||
if ctx.is_cancelled():
|
||
return None # leave the card where it is
|
||
raise RuntimeError(f"{what} ohne Ergebnis")
|
||
|
||
|
||
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
|
||
async def _seed_map(topic: str) -> dict[str, list[str]]:
|
||
"""Demoted fragments become seed candidates of their SURVIVING parent block.
|
||
parent_norm may point at a block that itself got grouped/merged/renamed — follow the
|
||
redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
|
||
to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
|
||
alive: set[str] = set()
|
||
redirect: dict[str, str] = {}
|
||
rejected: list[dict] = []
|
||
for r in await db.kanban_cards(topic, board="inventory", kind="block"):
|
||
p = r["payload"]
|
||
tn = _norm_title(p.get("title", ""))
|
||
if not tn:
|
||
continue
|
||
if r["stage"] in ("done", "done_block"):
|
||
mn = p.get("mirrored_norm") or tn
|
||
alive.add(mn)
|
||
if tn != mn:
|
||
redirect.setdefault(tn, mn)
|
||
elif r["stage"] == "grouped" and p.get("merged_into"):
|
||
redirect.setdefault(tn, _norm_title(p["merged_into"]))
|
||
# umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
|
||
# without a seed the umbrella's finders may simply miss them (measured).
|
||
rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
|
||
elif r["stage"] == "rejected":
|
||
if p.get("parent_norm"):
|
||
redirect.setdefault(tn, p["parent_norm"])
|
||
rejected.append(p)
|
||
|
||
def _resolve(norm: str) -> str | None:
|
||
seen: set[str] = set()
|
||
cur = norm
|
||
while cur and cur not in seen:
|
||
if cur in alive: # alive check BEFORE following (self-edges like „Listen"→„Listen")
|
||
return cur
|
||
seen.add(cur)
|
||
cur = redirect.get(cur, "")
|
||
return None
|
||
|
||
seeds: dict[str, list[str]] = {}
|
||
for p in rejected:
|
||
pn = p.get("parent_norm")
|
||
if pn and (target := _resolve(pn)):
|
||
seeds.setdefault(target, []).append(p.get("title", ""))
|
||
return seeds
|
||
|
||
|
||
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
topic = flow.topic
|
||
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
|
||
seeds = await _seed_map(topic)
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
instr = instructions
|
||
sd = [s for s in seeds.get(norm, []) if s]
|
||
if sd:
|
||
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
||
"Blocks (prüfen; wenn belegt UND noch nicht durch einen anderen Eintrag "
|
||
"abgedeckt, aufnehmen — nicht wörtlich übernehmen, sondern als eigenständige "
|
||
"Aussage formulieren):\n"
|
||
+ "\n".join(f"- {s}" for s in sd))
|
||
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
|
||
seeds=sd or None, lbl=f"{p.get('title', norm)} · ",
|
||
sources=p.get("sources"))
|
||
if raw is None:
|
||
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
|
||
p["raw"] = raw
|
||
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "facts")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||
instructions: str, cards):
|
||
topic = flow.topic
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
raw = p.get("raw") or {}
|
||
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
||
folder, instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||
if res is None:
|
||
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
||
facts_map, discarded = res
|
||
if discarded: # unsupportable subs vanish from raw too (guide never sees them)
|
||
for bt, sns in discarded.items():
|
||
if bt in raw:
|
||
raw[bt] = [s for s in raw[bt] if _norm_title(s) not in sns]
|
||
raw = {bt: subs for bt, subs in raw.items() if subs}
|
||
# In-block consolidation: two-judge panel folds same-statement/subset subs, bundles
|
||
# catalogs, drops off-topic ones — the facts are in hand (key points as evidence),
|
||
# questions/artefacts not yet built. Reported gaps get ONE follow-up finder round.
|
||
luecken = await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
|
||
instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ")
|
||
if ctx.is_cancelled():
|
||
return None
|
||
nachgefasst = 0
|
||
for bt, lk in (luecken or {}).items():
|
||
nachgefasst += await _luecken_runde(ctx, _pfiles(files, norm), bt, lk, raw, facts_map,
|
||
q, folder, instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||
if ctx.is_cancelled():
|
||
return None
|
||
if nachgefasst: # close the loop: follow-up finds get the SAME duplicate test as the
|
||
# rest (new subs-hash → fresh judge files); their gap report is deliberately ignored
|
||
await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
|
||
instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ")
|
||
if ctx.is_cancelled():
|
||
return None
|
||
raw = {bt: subs for bt, subs in raw.items() if subs}
|
||
# consolidation renames/catalogs can leave consensus subs without grounding — the
|
||
# guide fact gate then flags their correct statements wholesale (measured: 74/210)
|
||
await _facts_nachfass(ctx, _pfiles(files, norm), raw, facts_map, q, folder,
|
||
instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
|
||
if ctx.is_cancelled():
|
||
return None
|
||
p["raw"], p["facts"] = raw, facts_map
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "levels")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
def _cross_schema(data) -> dict[int, str] | None:
|
||
"""{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
|
||
if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
|
||
return None
|
||
out: dict[int, str] = {}
|
||
for k, v in data["pairs"].items():
|
||
try:
|
||
nr = int(k)
|
||
except (ValueError, TypeError):
|
||
continue
|
||
s = str(v).strip().casefold()
|
||
if s in ("a", "b", "nein"):
|
||
out[nr] = s
|
||
return out or None
|
||
|
||
|
||
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
"""BARRIER/drain am RUN-ENDE — cross-block sub dedup: the SAME statement carried by two
|
||
blocks (measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes —
|
||
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
|
||
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
|
||
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
|
||
work_dir = flow.work_dir
|
||
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
|
||
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
|
||
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
|
||
nachzuegler = [(c["card_id"], "question_pattern") for c in cards
|
||
if "pattern" not in c["payload"]]
|
||
if nachzuegler:
|
||
await db.kanban_advance_many(topic, BOARD, nachzuegler)
|
||
flow.wake.set()
|
||
return
|
||
|
||
async def _advance_all():
|
||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
|
||
flow.wake.set()
|
||
|
||
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()
|
||
return
|
||
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||
if sims is None:
|
||
await _advance_all()
|
||
return
|
||
negs = [_neg_set(r["sub_title"]) for r in rows]
|
||
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
|
||
if rows[i]["block_norm"] != rows[j]["block_norm"] and negs[i] == negs[j]
|
||
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
|
||
if not pairs:
|
||
await _advance_all()
|
||
return
|
||
|
||
def _kp(r: dict) -> list:
|
||
try:
|
||
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
|
||
except ValueError:
|
||
return []
|
||
|
||
def _side(tag: str, r: dict) -> str:
|
||
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
|
||
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
|
||
chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]
|
||
|
||
async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
|
||
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
|
||
empty dict = fail-open (pairs stay)."""
|
||
lines = "\n\n".join(
|
||
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[j])}"
|
||
for k, (i, j) in enumerate(chunk, 1))
|
||
h = hashlib.md5(lines.encode()).hexdigest()[:8]
|
||
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
|
||
|
||
async def _judge(j, path, plines, n):
|
||
if _cross_schema(_json_file(path)) is not None:
|
||
return # resume
|
||
status, _v = await run_single_slot(
|
||
ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
|
||
prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
|
||
role="judge", capabilities="none",
|
||
payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
|
||
timeout=_timeout("subblock_check", n))
|
||
if status == FAILED:
|
||
_log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")
|
||
|
||
await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
|
||
if ctx.is_cancelled():
|
||
return {}
|
||
outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
|
||
if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall
|
||
ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
|
||
await _judge("E", ersatz, lines, len(chunk))
|
||
if ctx.is_cancelled():
|
||
return {}
|
||
outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
|
||
if len(outs) != 2:
|
||
if outs:
|
||
_log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
|
||
return {}
|
||
final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
|
||
else "uneinig") for k in range(1, len(chunk) + 1)}
|
||
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
|
||
d_lines = "\n\n".join(
|
||
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))
|
||
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
|
||
await _judge(3, p3, d_lines, len(disputed))
|
||
if ctx.is_cancelled():
|
||
return {}
|
||
v3 = _cross_schema(_json_file(p3)) or {}
|
||
if not v3:
|
||
_log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
|
||
for x, k in enumerate(disputed, 1):
|
||
t = v3.get(x, "nein")
|
||
if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
|
||
final[k] = t # majority 2/3; anything else stays disputed → no fold
|
||
return final
|
||
|
||
chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
|
||
if ctx.is_cancelled():
|
||
return
|
||
final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict
|
||
for cnr, fin in enumerate(chunk_finals):
|
||
for k, v in fin.items():
|
||
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
|
||
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
|
||
gone: set[tuple] = set()
|
||
for k, (i, j) in enumerate(pairs, 1):
|
||
verdict = final_all.get(k, "nein")
|
||
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
|
||
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
|
||
"verdict": verdict})
|
||
if verdict not in ("a", "b"):
|
||
continue
|
||
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
|
||
wk = (win["block_norm"], win["sub_norm"])
|
||
lk = (lose["block_norm"], lose["sub_norm"])
|
||
if lk in gone or wk in gone: # keeper already folded → don't chain away the content
|
||
continue
|
||
await falte_sub(topic, files, win, lose)
|
||
gone.add(lk)
|
||
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
|
||
"bleibt": f"{win['block']} · {win['sub_title']}"})
|
||
if journal["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]
|
||
atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
|
||
await _advance_all()
|
||
|
||
|
||
async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
topic = flow.topic
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ")
|
||
if sidecar is None:
|
||
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
|
||
facts_map = p.get("facts") or {}
|
||
for btitle, subs in sidecar.items(): # merge facts into the subs (mirror + guide)
|
||
fm = facts_map.get(btitle, {})
|
||
for sub in subs:
|
||
# level agents paraphrase titles — exact miss falls back to the unique
|
||
# prefix/containment match, else the sub silently loses its grounding
|
||
sn = _sub_key(set(fm), _norm_title(sub["title"]))
|
||
if (fk := fm.get(sn)):
|
||
sub["facts"] = fk
|
||
p["sidecar"] = sidecar
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "relevance")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
topic = flow.topic
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
sidecar = p.get("sidecar") or {}
|
||
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||
sidecar, instructions, ns=f"{_safe(norm)}-",
|
||
lbl=f"{p.get('title', norm)} · ")
|
||
if rel is None:
|
||
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
|
||
gid = 0
|
||
for subs in sidecar.values():
|
||
for sub in subs:
|
||
gid += 1
|
||
sub["relevance"] = rel.get(gid, "relevant")
|
||
p["sidecar"] = sidecar
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "question_pattern")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
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
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
pattern, artefacts = await asyncio.gather(
|
||
_question_pattern_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)} · "),
|
||
_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:
|
||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
||
p["pattern"] = pattern
|
||
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "finalize")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
topic = flow.topic
|
||
|
||
async def one(c):
|
||
p = c["payload"]
|
||
norm = c["card_id"]
|
||
artefacts = await _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 artefacts is None and ctx.is_cancelled():
|
||
return None
|
||
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||
await db.kanban_advance(topic, BOARD, norm, "finalize")
|
||
|
||
await _gather_cards(ctx, flow, cards, one)
|
||
|
||
|
||
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
|
||
def _merge_json(path, block_keys: dict) -> None:
|
||
data = _json_file(path)
|
||
if not isinstance(data, dict):
|
||
data = {}
|
||
data.update(block_keys)
|
||
atomic_write_json(path, data, indent=1)
|
||
|
||
|
||
async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
|
||
topic = flow.topic
|
||
for c in cards:
|
||
p = c["payload"]
|
||
title = p.get("title", "")
|
||
sidecar = p.get("sidecar") or {}
|
||
pattern = p.get("pattern") or {}
|
||
artefacts = p.get("artefacts") or {}
|
||
# global sidecar files (the legacy read path of guide/frontend/resume)
|
||
_merge_json(files["sub_roh"], {t: subs for t, subs in (p.get("raw") or {}).items()})
|
||
_merge_json(files["facts"], p.get("facts") or {})
|
||
_merge_json(files["sidecar"], sidecar)
|
||
_merge_json(files["question_pattern"], pattern)
|
||
art_global = _json_file(files["artefakte"])
|
||
if not isinstance(art_global, dict):
|
||
art_global = {}
|
||
for typ in ARTEFACT_TYPES:
|
||
kept = [e for e in art_global.get(typ, [])
|
||
if _norm_title(_title(str(e.get("block", "")))) != _norm_title(title)]
|
||
art_global[typ] = kept + list(artefacts.get(typ, []))
|
||
atomic_write_json(files["artefakte"], art_global, indent=1)
|
||
# DB mirrors — per block only (no global deletes)
|
||
await blocks._mirror_sidecar_db(topic, sidecar)
|
||
# stale question/artefact rows of a PREVIOUS run keyed to gone subs: finalize only
|
||
# upserts, so re-runs left orphans (measured: 28).
|
||
await db.delete_question_pattern(topic, _norm_title(title))
|
||
await db.delete_sub_artefakte(topic, _norm_title(title))
|
||
# consensus rows of a PREVIOUS run that this run's sidecar no longer carries would
|
||
# linger without facts/questions/artefacts (measured: 25) — drop them per block;
|
||
# variant/discarded stay for QA. Then default-level the mirror's own stragglers.
|
||
for btitle, subs in sidecar.items():
|
||
keep = {_norm_title(str(s.get("title", ""))) for s in subs if isinstance(s, dict)}
|
||
await db.delete_stale_consensus(topic, _norm_title(btitle), keep - {""})
|
||
await db.default_subblock_levels(topic, _norm_title(title))
|
||
sub_keys: dict[str, set[str]] = {}
|
||
|
||
async def _keys(bnorm: str) -> set[str]:
|
||
if bnorm not in sub_keys:
|
||
sub_keys[bnorm] = {r["sub_norm"] for r in await db.list_subblocks(topic, bnorm)}
|
||
return sub_keys[bnorm]
|
||
|
||
for btitle, entries in pattern.items():
|
||
bnorm = _norm_title(btitle)
|
||
for e in entries if isinstance(entries, list) else []:
|
||
sub = str(e.get("subblock", "")).strip()
|
||
sn = _norm_title(sub)
|
||
question = str(e.get("question", "")).strip()
|
||
if bnorm and sn and question:
|
||
sn = _sub_key(await _keys(bnorm), sn)
|
||
await db.upsert_question_pattern(topic, bnorm, sn, btitle, sub, question)
|
||
btitles = list(sidecar.keys())
|
||
for typ in ARTEFACT_TYPES:
|
||
for e in artefacts.get(typ, []):
|
||
bt = _match_sub(str(e.get("block", "")), btitles)
|
||
bnorm, sn = _norm_title(bt), _norm_title(str(e.get("subblock", "")))
|
||
if not bnorm or not sn:
|
||
continue
|
||
sn = _sub_key(await _keys(bnorm), sn)
|
||
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
|
||
ensure_ascii=False)
|
||
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
|
||
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
|
||
_log(topic, f"Artefakte fertig: {title}")
|
||
flow.wake.set()
|
||
|
||
|
||
# ── Outline (topic-wide barrier singleton) ─────────────────────────────────────────
|
||
OUTLINE_CARD = "outline"
|
||
|
||
|
||
async def ensure_outline_card(topic: str) -> None:
|
||
"""(Re-)queue the outline singleton — run once per generation run, after everything."""
|
||
await db.kanban_upsert_card(topic, BOARD, OUTLINE_CARD, "outline", "outline",
|
||
{"title": "Gliederung"})
|
||
|
||
|
||
async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||
topic = flow.topic
|
||
done = await db.kanban_cards(topic, board="inventory", stage="done_block")
|
||
done.sort(key=lambda c: c["updated_at"])
|
||
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
|
||
if c["payload"].get("title")}
|
||
if entries:
|
||
# The outline may run BEFORE finalize has merged the global facts.json — feed the
|
||
# prereq hints of _learning_order from the card payloads instead (complete as soon
|
||
# as every block passed the facts stage, which the trimmed barrier guarantees).
|
||
facts_map: dict = {}
|
||
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
||
facts_map.update(bc["payload"].get("facts") or {})
|
||
fp = flow.work_dir / "outline-facts.json"
|
||
atomic_write_json(fp, facts_map, indent=1)
|
||
plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
|
||
if ctx.is_cancelled():
|
||
return
|
||
if isinstance(plan, dict) and plan.get("chapters"):
|
||
chapters = [
|
||
{"title": ch.get("title", "Kapitel"),
|
||
"blocks": [_title(entries[n]) for n in ch.get("numbers", []) if n in entries]}
|
||
for ch in plan["chapters"]
|
||
]
|
||
await db.set_outline(topic, json.dumps({"chapters": chapters}, ensure_ascii=False))
|
||
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
|
||
flow.wake.set()
|
||
|
||
|
||
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
|
||
def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||
instructions: str) -> list[Stage]:
|
||
research_done = lambda: flow.research_done # noqa: E731
|
||
return [
|
||
Stage(BOARD, "subblocks", lambda cs: _proc_subblocks(ctx, flow, files, 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, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
|
||
Stage(BOARD, "question_pattern",
|
||
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",
|
||
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
|
||
barrier=True, drain=True),
|
||
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
|
||
barrier=True, drain=True, gate=research_done),
|
||
]
|