"""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 2–3× 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"") lines.append(f"") lines.append("") 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"") lines.append(sub["compact"]) lines.append("") 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"") 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"", 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