Files
creator/backend/guide_board.py
2026-07-08 21:14:33 +02:00

812 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Board 3 „Guide": one card per block, linear stages with gates between them.
lernziele judge-Rolle Backward Design — objectives BEFORE writing
zuweisung code chapter/order from the outline artefact + facts grounding
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
pruefer judge-Rolle EIN Call: CoVe-Fakten + Coverage + Lesbarkeit (lasen vorher
denselben Text in 3 seriellen Calls) + deterministische Gates
fix guide-Rolle EIN Rewrite unter allen Auflagen; bei falsch/Lücken danach
genau ein Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
needed); stage transitions are persisted in guide_cards, so the board is live and
cancel/resume just picks cards up at their stored stage. Assembly keeps the exact
legacy content format → content_fuer_level / TopicDetail stay untouched.
"""
import asyncio
import json
import logging
import re
import database as db
import readability
from blocks import _sink_json
from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
from guide_qa import block_budget
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
_timeout, is_guide_cancelled, run_single_slot)
from textkit import _norm_title, _parse_fragment, _title
log = logging.getLogger("creator.guide_board")
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "pruefer", "fix")
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
"pruefer": "Prüfen", "fix": "Fix", "done": "Fertig"}
from config import GATE_FIX_MIN, WRITER_SPLIT_SUBS # zentral tunebar
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
def _safe(norm: str) -> str:
return re.sub(r"\W+", "_", norm)[:50] or "block"
def _ziele_schema(data):
"""{"ziele":[{id,text,sub}]} → list of dicts · None on invalid structure."""
if not isinstance(data, dict) or not isinstance(data.get("ziele"), list) or not data["ziele"]:
return None
out, seen = [], set()
for z in data["ziele"]:
if not isinstance(z, dict):
return None
zid = str(z.get("id", "")).strip()
text = str(z.get("text", "")).strip()
if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
continue
seen.add(zid)
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
return out or None
def _gate_schema(data):
"""{"ok":true} → [] · {"claims":[{text,grund,urteil}]} → list · None invalid.
urteil "falsch" (contradicts the facts/itself) vs "unbelegt" (true but underivable) —
default unbelegt. Entries whose grund starts with "belegt" are dropped: one judge
returned a 65-entry full inventory including SUPPORTED claims."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
claims = data.get("claims")
if not isinstance(claims, list) or not claims:
return None
out = []
for c in claims:
if isinstance(c, dict) and str(c.get("text", "")).strip():
grund = str(c.get("grund", "")).strip()
if grund.casefold().startswith("belegt"):
continue
urteil = str(c.get("urteil", "")).strip().casefold()
out.append({"text": str(c["text"]).strip(), "grund": grund,
"urteil": urteil if urteil == "falsch" else "unbelegt"})
return out
def _pruefer_schema(data, ziel_ids: set[str]):
"""Verschmolzenes Prüfer-Verdikt: Claims (Fakten-Gate-Semantik via _gate_schema) +
Coverage (ziele/luecken/ballast) + Lesbarkeit (lese_probleme). {"ok":true} = leeres
Verdikt. `ziele` muss alle ids abdecken, wenn Ziele existieren — sonst optional."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
if not any(k in data for k in ("claims", "ziele", "luecken", "ballast", "lese_probleme")):
return None
claims = _gate_schema({"claims": data["claims"]}) if data.get("claims") else []
if claims is None:
return None
ziele = {}
for k, v in (data.get("ziele") or {}).items() if isinstance(data.get("ziele"), dict) else []:
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
if ziel_ids and not ziel_ids <= set(ziele):
return None
luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
"lese_probleme": lese}
def _first_section(md: str) -> dict | None:
secs = _parse_fragment(md) if md else []
return secs[0] if secs else None
class _Env:
"""Shared per-run context for the card tasks."""
def __init__(self, ctx, guide_id, topic, format_name, instructions, content_path,
subs_by_title, chapter_map, fallback_facts, spec):
self.ctx = ctx
self.guide_id = guide_id
self.topic = topic
self.format = format_name
self.instructions = instructions
self.content_path = content_path
self.subs_by_title = subs_by_title # block title → [sub dicts]
self.chapter_map = chapter_map # block_norm → (chapter title, ord)
self.fallback_facts = fallback_facts # generic source hint (legacy topics without facts)
self.spec = spec
def slot(self, name: str):
return self.content_path.parent / f"{self.content_path.stem}.{name}"
def _memo(env, attr: str) -> dict:
"""Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
23× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
laufen ihre Stages seriell → kein Race."""
d = env.__dict__.get(attr)
if d is None:
d = env.__dict__[attr] = {}
return d
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
cache = _memo(env, "_ziele_cache")
if block_norm not in cache:
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
return cache[block_norm]
def _ziele_text(ziele: list[dict]) -> str:
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
def _card_facts(env: _Env, block_title: str) -> str:
cache = _memo(env, "_facts_cache")
if block_title not in cache:
from guide import _facts_grounding # lazy: guide imports this module
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
cache[block_title] = grounding or env.fallback_facts
return cache[block_title]
async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
include_unmatched: bool = True) -> str:
"""Verified worked examples of the block as writer input, matched to `subs` via
sub_norm (a split half gets only its own). Rows whose sub does not match (generation
mismatch) go to the full writer / split part 1 so they never vanish silently."""
cache = _memo(env, "_example_rows")
if block_norm not in cache:
cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
rows = cache[block_norm]
if not rows:
return ""
wanted = {_norm_title(s["title"]) for s in subs}
out = []
for r in rows:
matched = r["sub_norm"] in wanted
if not matched and not include_unmatched:
continue
data = json.loads(r["data"]) if isinstance(r["data"], str) else (r["data"] or {})
steps = " ".join(f"{i}) {s}" for i, s in enumerate(data.get("steps") or [], 1))
where = (f"Subbaustein „{r['sub_title']}" if matched
else "Subbaustein unklar — dort einweben, wo es fachlich passt")
out.append(f"- {where}:\n Problem: {data.get('problem', '')}\n"
f" Schritte: {steps}\n Ergebnis: {data.get('result', '')}")
if not out:
return ""
return ("VERIFIED WORKED EXAMPLES (already fact-checked; each belongs to ONE subblock):\n"
+ "\n".join(out) + "\n"
"Weave each example into the ausführlich text of EXACTLY its subblock, right "
"after the concept it applies has been explained — as a short worked-through "
"passage (problem → steps → result recognizable, flowing prose or a compact "
"numbered list). Take all values and results over VERBATIM, never recompute "
"or alter them. NEVER put examples into the compact layer. Subblocks without "
"an example get none.")
def _card_assignment(env: _Env, card: dict) -> str:
from guide import _level_label
lines = [f"- {card['block']}"]
for s in env.subs_by_title.get(card["block"], []):
lines.append(f" [{_level_label(s)}] {s['title']}")
return "\n".join(lines)
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
# board_snapshot shows it as the info line while status == active.
_live_info: dict[tuple[str, str, str], str] = {}
def _live(env: _Env, card: dict, msg: str) -> None:
_live_info[(env.topic, env.format, card["block_norm"])] = msg
async def _set(env: _Env, card: dict, **fields):
card.update(fields)
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
# ── Stages ─────────────────────────────────────────────────────────────────────────
async def _stage_lernziele(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
if not await db.list_lernziele(env.topic, norm):
subs = "\n".join(f"- [{s.get('level', 'beginner')}] {s['title']}"
for s in env.subs_by_title.get(card["block"], [])) or "(keine)"
async def _versuch(suffix: str):
path = env.slot(f"ziele-{_safe(norm)}{suffix}.json")
return await run_single_slot(
env.ctx, f"Lernziele {card['block']}", key=f"{env.guide_id}-ziele-{_safe(norm)}{suffix}",
prompt=_prompt("Guide-Lernziele", topic=env.topic, block=card["block"],
subs=subs, facts=_card_facts(env, card["block"]),
out_path=path, extra=_extra(env.instructions)),
role="judge", capabilities="files",
payload=lambda result, p=path: _ziele_schema(_json_file(p)),
timeout=_timeout("lernziele", len(env.subs_by_title.get(card["block"], []))))
status, ziele = await _versuch("")
if status == CANCELLED:
return False
if status == FAILED:
await _set(env, card, status="error", gate_info="Lernziele ohne Ergebnis")
return False
if not ziele: # ein Ersatz-Versuch — leere Liste heißt: das Coverage-Gate läuft leer
status, ziele = await _versuch("-2")
if status == CANCELLED:
return False
if not isinstance(ziele, list):
ziele = []
if not ziele:
_log(env.topic, f"Lernziele {card['block']}: zweimal leer — Block ohne Coverage-Gate")
for z in ziele:
await db.put_lernziel(env.topic, norm, z["id"], z["text"], _norm_title(z["sub"]))
await _set(env, card, stage="zuweisung", status="open")
return True
async def _stage_zuweisung(env: _Env, card: dict) -> bool:
chapter, ord_ = env.chapter_map.get(card["block_norm"], ("Weitere Inhalte", 10_000))
await _set(env, card, chapter=chapter, ord=ord_, stage="writer")
return True
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
# halves and merged back into ONE canonical section (all gates/assembly read one section).
# WRITER_SPLIT_SUBS: siehe config.py
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
lines = []
if sec_a.get("chapters"):
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
lines.append(f"<!-- section: {sec_a['title']} -->")
lines.append("<!-- compact -->")
if sec_a.get("anker_compact"):
lines.append(sec_a["anker_compact"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("compact"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["compact"])
lines.append("<!-- ausführlich -->")
if sec_a.get("anchor"):
lines.append(sec_a["anchor"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("md"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["md"])
return "\n\n".join(lines)
async def _write_split(env: _Env, card: dict, ziele_text: str):
"""First draft in two halves (parallel), merged into one section.
→ merged text | None (failed) | False (cancelled)."""
from guide import _level_label
norm = card["block_norm"]
subs = env.subs_by_title.get(card["block"], [])
half = (len(subs) + 1) // 2
parts = (subs[:half], subs[half:])
hints = (
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
)
async def _one(i):
assignment = "\n".join([f"- {card['block']}"]
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
path.unlink(missing_ok=True)
def _payload(result, p=path):
t = p.read_text(encoding="utf-8") if p.exists() else ""
sec = _first_section(t)
return t if sec and sec.get("md", "").strip() else None
return await run_single_slot(
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte",
assignment=assignment, ziele=ziele_text,
facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, parts[i],
include_unmatched=(i == 0)),
gaps="\n" + hints[i] + "\n",
budget=block_budget(parts[i]),
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
results = await asyncio.gather(_one(0), _one(1))
if any(s == CANCELLED for s, _ in results):
return False
if any(s == FAILED for s, _ in results):
return None
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
ziele_text = _ziele_text(await _ziele(env, norm))
# oversized first drafts: two halves, merged into one canonical section
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
text = await _write_split(env, card, ziele_text)
if text is False:
return False
if text is None:
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
return False
await _set(env, card, md=text, stage="pruefer", status="open")
return True
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
path.unlink(missing_ok=True)
def _payload(result):
text = path.read_text(encoding="utf-8") if path.exists() else ""
sec = _first_section(text)
return text if sec and sec.get("md", "").strip() else None
status, text = await run_single_slot(
env.ctx, f"Writer {card['block']}", key=f"{env.guide_id}-w-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte",
assignment=_card_assignment(env, card), ziele=ziele_text,
facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])),
gaps="", spec=env.spec,
budget=block_budget(env.subs_by_title.get(card["block"], [])),
out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
if status == CANCELLED:
return False
if status == FAILED:
await _set(env, card, status="error", gate_info="Writer ohne Ergebnis")
return False
await _set(env, card, md=text, stage="pruefer", status="open")
return True
def _n_rel(env: _Env, card: dict) -> int:
return sum(1 for s in env.subs_by_title.get(card["block"], [])
if s.get("relevance") != "peripheral")
def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
"""Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell +
Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit
engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix
und als „nicht wiederholen"-Notiz in den Prüfer-Prompt."""
out: list[str] = []
subs_all = env.subs_by_title.get(card["block"], [])
if not any(s.get("relevance") != "peripheral" for s in subs_all):
return out # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor)
budget = block_budget(subs_all)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
lo, hi = FIX_LAENGE_BAND
if not (lo * budget <= zeichen <= hi * budget):
out.append(
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}{round(hi * budget)}): "
f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
return out
async def _det_readability(sec: dict) -> list[str]:
if not READABILITY_ACTIVE:
return []
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
return [hints[1]] if hints.get(1) else []
def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]:
"""Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken
(nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern).
Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen
sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig)."""
claims = verdict["claims"]
falsch = [c for c in claims if c["urteil"] == "falsch"]
schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN
if claims and not falsch and len(claims) < schwelle:
claims = []
zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f"{c['grund']}" if c['grund'] else "")
for c in claims]
zeilen += [f"- LÜCKE ({l['ziel']}): {l['fehlt']}" for l in verdict["luecken"]]
zeilen += [f"- BALLAST (kürzen): {b}" for b in verdict["ballast"]]
zeilen += [f"- LESBARKEIT: {p}" for p in verdict["lese_probleme"]]
zeilen += [f"- LESBARKEIT: {p}" for p in det]
return zeilen, bool(falsch or verdict["luecken"])
async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[str]) -> dict | None:
"""EIN Judge-Call prüft Fakten + Coverage + Lesbarkeit (die drei lasen vorher denselben
Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
norm = card["block_norm"]
ziele = await _ziele(env, norm)
ziele_text = _ziele_text(ziele)
ids = {z["ziel_id"] for z in ziele}
facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
if ex: # der Fix sieht dieselben Facts — Beispiele überleben den Fix-Pass
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
hinweise = ("\nALREADY NOTED deterministically (do NOT repeat, they go to the fix anyway):\n"
+ "\n".join(f"- {d}" for d in det) + "\n") if det else "\n"
path = env.slot(f"pruefer-{_safe(norm)}-{tag}.json")
status, verdict = await run_single_slot(
env.ctx, f"Prüfer {card['block']}", key=f"{env.guide_id}-pruef-{_safe(norm)}-{tag}",
prompt=_prompt("Guide-Pruefer", topic=env.topic, block=card["block"],
section=sec["md"], facts=facts, ziele=ziele_text, spec=env.spec,
hinweise=hinweise, extra=_extra(env.instructions)),
role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
timeout=_timeout("pruefer", 1))
if status != OK or verdict is None:
return None
for zid, ok in verdict["ziele"].items():
if zid in ids:
await db.set_ziel_covered(env.topic, norm, zid, ok)
return verdict
async def _stage_pruefer(env: _Env, card: dict) -> bool:
"""Verschmolzener Qualitäts-Pass: Fakten-Gate + Coverage + Lese-Check in EINEM Call
(vorher 3 serielle Judges + bis zu 3 Edit-Pässe, die einander überschrieben und deren
letzter ungeprüft blieb). Befunde → Fix-Stage; ohne Befund → done."""
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
return False
det = (await _det_readability(sec)) + _det_hinweise(env, card, sec)
verdict = await _pruefer_call(env, card, sec, f"r{card['writer_rounds']}", det)
if verdict is None:
if is_guide_cancelled(env.guide_id):
return False
# fail-open wie das alte Gate: Karte nie blockieren — deterministische Befunde
# gehen trotzdem in den Fix
_log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks")
verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card))
if not zeilen:
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
return True
_log(env.topic, f"Prüfer {card['block']}: {len(zeilen)} Befund(e){' (kritisch)' if kritisch else ''} → Fix")
await _set(env, card, gate_info=("KRITISCH\n" if kritisch else "") + "\n".join(zeilen),
stage="fix", status="open")
return True
async def _stage_fix(env: _Env, card: dict) -> bool:
"""EIN kompletter Section-Rewrite unter allen Auflagen (ersetzt Fakten-Fix +
Writer-Revision + Lese-Fix). Danach GENAU EIN Re-Prüfer-Pass, wenn der Fix wegen
falsch-Claims/Lücken lief — der alte Lese-Fix blieb ungeprüft. Rest-Befunde bleiben
sichtbar (gate_info), keine weitere Fix-Runde."""
from guide import _level_label
norm = card["block_norm"]
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Fragment unlesbar")
return False
info = card.get("gate_info") or ""
kritisch = info.startswith("KRITISCH\n")
auftraege = info.removeprefix("KRITISCH\n")
subs = env.subs_by_title.get(card["block"], [])
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
fixp.unlink(missing_ok=True)
def _fixload(result):
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
return text if _first_section(text) else None
fstatus, fixed = await run_single_slot(
env.ctx, f"Fix {card['block']}", key=f"{env.guide_id}-gfix-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fix", topic=env.topic, format_name=env.format, block=card["block"],
section=card["md"], facts=_card_facts(env, card["block"]), spec=env.spec,
auftraege=auftraege, sub_list=sub_list, out_path=fixp,
extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload,
timeout=_timeout("writer", 1))
if fstatus == CANCELLED:
return False
angewandt = False
if fstatus == OK and fixed:
new_sec = _first_section(fixed)
# marker invariant: a fix that loses the sub markers kills the level filter → discard
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
_log(env.topic, f"Fix {card['block']} ohne Sub-Marker — verworfen")
else:
card["md"] = fixed
angewandt = True
rest = ""
if not angewandt:
# Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
_log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
if kritisch and angewandt:
sec2 = _first_section(card["md"])
verdict = await _pruefer_call(env, card, sec2, "re", [])
if verdict is None and is_guide_cancelled(env.guide_id):
return False
if verdict is None:
rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
_log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
else:
zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
if zeilen:
rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
_log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben")
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info=rest)
return True
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
async with sem:
try:
await _run_card_inner(env, card)
finally:
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
async def _run_card_inner(env: _Env, card: dict) -> None:
while card["stage"] != "done":
if is_guide_cancelled(env.guide_id):
await _set(env, card, status="open") # no longer being worked
return
fn = _STAGE_FN.get(card["stage"])
if fn is None: # unknown stage → park as error
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
return
if card["status"] != "active":
await _set(env, card, status="active") # live board: this card is being worked
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "")
try:
if not await fn(env, card):
return
except Exception as e:
log.exception("[%s] guide card %s failed", env.topic, card["block"])
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
return
# ── Orchestration ──────────────────────────────────────────────────────────────────
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
"""Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
(der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
zuletzt = None
while True:
try:
counts = await db.guide_stage_counts(topic, format_name)
stand = (counts.get("done", 0), sum(counts.values()))
if stand != zuletzt:
zuletzt = stand
await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
except Exception:
log.exception("[%s] guide progress reporter", topic)
await asyncio.sleep(takt)
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
"""block_norm → (chapter title, global order) from the outline artefact."""
from guide import _outline_from_db, _fallback_outline, _with_remainder
plan = await _outline_from_db(topic, entries) or _fallback_outline(entries)
plan = _with_remainder(plan, entries)
out: dict[str, tuple[str, int]] = {}
i = 0
for ch in plan:
for num in ch.get("nums", []):
if num in entries:
out[_norm_title(_title(entries[num]))] = (ch.get("title") or "Kapitel", i)
i += 1
return out
async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: dict[int, str],
instructions: str, provider: str, content_path) -> list[dict] | None:
"""Seed one card per block (existing cards keep their stage — resume), run all cards,
assemble the chapters in the legacy content format. → chapters | None (cancel/empty)."""
from blocks import source_folder
from guide import _load_subblocks
ctx = GenContext(topic=topic, provider=provider,
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
import uuid
from datetime import datetime, timezone
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
try:
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
subs_raw = await _load_subblocks(topic)
project = source_folder(topic)
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
else _prompt("Guide-Facts-Thema"))
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
subs_raw, await _chapter_map(topic, entries), fallback, spec)
await db.upsert_guide_cards_many(
topic, format_name,
[(_norm_title(_title(line)), _title(line)) for line in entries.values()])
cards = await db.list_guide_cards(topic, format_name)
open_cards = [c for c in cards if c["stage"] != "done"]
if open_cards:
sem = asyncio.Semaphore(CARD_CONCURRENCY)
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
try:
ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
return_exceptions=True)
for c, r in zip(open_cards, ergebnisse):
if isinstance(r, BaseException):
log.error("[%s] guide card task %s: %r", topic, c["block"], r)
finally:
reporter.cancel()
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
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)
async def done_step(topic: str, format_name: str) -> int:
"""Sidebar dots: highest fully completed stage index. -1 = nothing, len(stages) at done."""
counts = await db.guide_stage_counts(topic, format_name)
if not counts:
return -1
if set(counts) == {"done"}:
return len(GUIDE_STAGES)
lowest = min(GUIDE_STAGES.index(s) for s in counts if s in GUIDE_STAGES)
return lowest - 1 if lowest > 0 else -1
async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
"""Live guide board: columns with counts + cards (title, rounds, covered objectives)."""
cards = await db.list_guide_cards(topic, format_name)
ziele = {}
for z in await db.list_lernziele(topic):
d = ziele.setdefault(z["block_norm"], [0, 0])
d[1] += 1
d[0] += 1 if z["covered"] else 0
columns = []
for stage in (*GUIDE_STAGES, "done"):
in_stage = [c for c in cards if c["stage"] == stage]
views = []
for c in in_stage[:limit]:
zc = ziele.get(c["block_norm"])
info = c["gate_info"][:200] if c["status"] == "error" else ""
if c["status"] == "active":
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
views.append({"title": c["block"], "card_id": c["block_norm"],
"status": c["status"] if c["status"] in ("error", "active") else "open",
"rounds": c["writer_rounds"],
"info": info,
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
columns.append({"key": stage, "label": STAGE_LABELS[stage],
"total": len(in_stage), "cards": views})
import qa as qa_mod # lazy wie in board_inventory
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
return {"columns": columns, "qa_guide": note_guide}
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
import qa as qa_mod
from guide_qa import block_budget, LAENGE_BAND
reports = qa_mod.report_paths(topic, guide=True)
rep = _json_file(reports[-1]) if reports else None
if not rep:
return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
kritisch: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
kritisch.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
kritisch.add(str(e).split(" · ")[0])
for t in rep.get("fachlich_falsch", []) or []:
kritisch.add(_norm_title(str(t)))
stil: dict[str, list[str]] = {}
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
a, b = str(e.get("a", "")), str(e.get("b", ""))
bn = _norm_title(b.split(":")[0])
auszug = b.split(":", 1)[1].strip() if ":" in b else b
stil.setdefault(bn, []).append(
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}\") — "
"hier straffen, der Inhalt bleibt im anderen Block")
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
if not isinstance(e, dict):
continue
bn = _norm_title(e.get("block", ""))
budget = e.get("budget") or block_budget([])
lo, hi = LAENGE_BAND
stil.setdefault(bn, []).append(
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
f"{round(lo * budget)}{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
"alle Sub-Marker und Beispiele behalten")
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
bn = _norm_title(str(e).split(":")[0])
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
betroffen = []
for n in sorted((kritisch | set(stil)) & set(cards)):
if n in kritisch:
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
else:
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
gate_info="\n".join(stil[n]))
betroffen.append(cards[n]["block"])
return betroffen