"""Ebene 4: Guide. Eine Section pro Baustein, Stages seriell je Karte: writer → pruefer → fix → done (persistiert in sections.stage, resume-fähig). Der Writer bekommt die komplette Faktenbasis INLINE (Definitionen, Anker-Zitate, verifizierte Beispiele — Lektion 48) und muss Marker als unsichtbare Invariante setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage, Lektion 52); deterministische Checks laufen davor im Code.""" import asyncio import json import logging import os import re import subprocess from pathlib import Path import db import llm import textkit from config import SECTION_WOERTER_PRO_ATOM log = logging.getLogger("creator2.guide") _KATEX_SKRIPT = Path(__file__).resolve().parent / "katex_check.mjs" _KATEX_BASE = Path(__file__).resolve().parent.parent / "frontend" _katex_gewarnt = False def _katex_fehler(formeln: list[dict]) -> list[str]: """Parse-Gate: Formeln durch DENSELBEN Renderer wie das Frontend jagen. Regex-Checks jagen eine offene Menge (heute \\textsc, morgen der nächste unbekannte Befehl) — das Gate schließt die Klasse. Fake-Modus überspringt (deterministisch + schnell); Unit-Test ruft direkt.""" global _katex_gewarnt if not formeln or os.getenv("CREATOR_FAKE_AGENTS"): return [] try: res = subprocess.run( ["node", str(_KATEX_SKRIPT)], input=json.dumps(formeln), capture_output=True, text=True, timeout=30, env={**os.environ, "KATEX_BASE": str(_KATEX_BASE)}) if res.returncode: raise RuntimeError(res.stderr.strip()[:200]) return json.loads(res.stdout) except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip) if not _katex_gewarnt: _katex_gewarnt = True log.warning("KaTeX-Gate nicht verfügbar (%s) — Formel-Validierung fällt aus", e) return [] EBENE = "guide" _MARKER = re.compile(r"\n" f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}") if beispiel: teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}" fragen = _fragen_pool(a["id"]) if fragen: teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen) return teil def _laenge_band(n_atome: int) -> tuple[int, int]: lo, hi = SECTION_WOERTER_PRO_ATOM return lo * n_atome, hi * n_atome # ── Stages ──────────────────────────────────────────────────────────────────── async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} lo, hi = _laenge_band(len(atome)) res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte={"titel": b["titel"], "ziel": ziel["text"], "atome": "\n\n".join(_atom_paket(a) for a in atome), "min_woerter": lo, "max_woerter": hi}, role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict) if res is None: return "writer" # Stage bleibt, nächster Lauf versucht erneut kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", "")) fehlend = _marker_fehlend(lang, atome) if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer", schritt="writer", role="guide", n=len(atome), item=f"b{b['id']}-2", werte={"titel": b["titel"], "ziel": ziel["text"], "atome": "\n\n".join(_atom_paket(a) for a in atome), "min_woerter": lo, "max_woerter": hi}, erwartet=dict) if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome): kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", "")) db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang) return "pruefer" def _marker_fehlend(text: str, atome: list[dict]) -> list[int]: da = {int(m) for m in _MARKER.findall(text)} return [a["id"] for a in atome if a["id"] not in da] def _mathe_auftraege(text: str, wo: str) -> list[str]: """Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle, Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6 Lang-Fassungen betroffen).""" auftraege = [] for nr, absatz in enumerate(text.split("\n\n"), 1): if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel auftraege.append(f"{wo}, Absatz {nr}: ungerade Anzahl $-Zeichen — jede" f" Formel braucht öffnendes UND schließendes $.") ohne = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", text) nackt = sorted(set(re.findall( r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists" r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne))) if nackt: # KaTeX rendert nur innerhalb von $…$ auftraege.append(f"{wo}: LaTeX ohne $-Delimiter ({', '.join(nackt[:5])}…):" f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.") reste = sorted(set(re.findall( r"\w+_\{[^}]*\}|\w+\^\{[^}]*\}|\w+\^\([^)]*\)|\b\w+_[a-z0-9]\b", ohne))) if reste: auftraege.append(f"{wo}: Formeln im Klartext ({', '.join(reste[:4])}…):" f" auch Indizes/Potenzen gehören in $…$ (x_i → $x_i$).") betraege = sorted(set(re.findall(r"\|[A-Za-z][^|\n]{0,8}\|\*?", ohne))) if betraege: # |C*| im Klartext: Markdown deutet * als Kursiv-Start auftraege.append(f"{wo}: Beträge im Klartext ({', '.join(betraege[:4])}…):" f" in $…$ setzen ($|C^*|$) — sonst kollidiert * mit Markdown.") for z in ohne.splitlines(): # einzelnes * = Kursiv-Unfall (nur **fett** erlaubt) z = re.sub(r"^\s*[*-]\s", "", z) if re.search(r"(? 60] if lange: # Inline-Formeln brechen nie um — auf schmalen Screens Überlauf auftraege.append(f"{wo}: {len(lange)} überlange Inline-Formel(n)" f" (>60 Zeichen, z. B. „{lange[0][:40]}…“): abgesetzt" f" als $$…$$ setzen.") formeln = ([{"tex": m, "display": True} for m in re.findall(r"\$\$([\s\S]+?)\$\$", text)] + [{"tex": m, "display": False} for m in re.findall(r"\$([^$\n]+?)\$", text)]) for f in _katex_fehler(formeln): auftraege.append(f"{wo}: Formel rendert nicht — {f}. In gültiges KaTeX" f" umschreiben (Standardbefehle, kein LaTeX-Textsatz).") return auftraege def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]: """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise.""" atome = _atome_von(b["id"]) auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." for i in _marker_fehlend(lang, atome)] lo, hi = _laenge_band(len(atome)) woerter = len(lang.split()) if woerter > hi: auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).") elif woerter < lo: auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).") auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim" f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)" f" oder entfernen — nie unerklärt verwenden." for t in _vorwaertsverweise(topic, b, lang)] zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">")) if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen" f" Worten in den Fließtext einarbeiten, Zitat-Format entfernen.") if "6=" in lang: auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —" " durch $\\neq$ ersetzen.") auftraege += _mathe_auftraege(lang, "Langtext") if kompakt: auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung") return auftraege def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]: """Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen.""" kap_ord = {k["id"]: k["ord"] for k in db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))} je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)} mein_kapitel = je_baustein.get(b["id"], 0) text = textkit.norm(_MARKER.sub("", lang)) treffer = [] for a in db.query( "SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id" " WHERE bb.topic=? AND a.status NOT IN ('gemerged','verworfen')", (topic,)): if je_baustein.get(a["bid"], 0) <= mein_kapitel: continue t = textkit.norm(a["titel"]) if len(t) < 6 and len(t.split()) < 2: continue if re.search(rf"(? str: sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"]) fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n" + "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome) res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer", schritt="pruefer", role="judge", n=len(atome), item=f"b{b['id']}{tag}", werte={"ziel": ziel["text"], "fakten": fakten, "kompakt": sec["text_kompakt"], "lang": sec["text_lang"]}, erwartet=dict) for e in (res or {}).get("befunde", []): art = str(e.get("art", "")).strip() detail = str(e.get("detail", "")).strip() if art in ("falsch", "luecke"): auftraege.append(f"KRITISCH ({art}): {detail}") elif art == "stil" and detail: auftraege.append(detail) db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege)) return "fix" if auftraege else "done" async def _stage_fix(ctx: llm.Kontext, b: dict) -> str: sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) auftraege = db.uj(sec["befunde"]) if not auftraege: return "done" atome = _atome_von(b["id"]) fakten = "\n\n".join(_atom_paket(a) for a in atome) res = await llm.call(ctx, stage="fix", template="Guide-Fix", role="guide", n=len(auftraege), item=f"b{b['id']}", werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"], "auftraege": "\n".join(f"- {a}" for a in auftraege), "fakten": fakten}, erwartet=dict) kritisch = any(a.startswith("KRITISCH") for a in auftraege) if res: lang = str(res.get("lang", "")) if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen db.update("sections", "baustein_id", b["id"], text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([])) if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA await _stage_pruefer(ctx, b, tag="-re") return "done" db.update("sections", "baustein_id", b["id"], befunde=db.j([])) return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten) _STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix} async def _karte(ctx: llm.Kontext, b: dict) -> None: db.execute("INSERT OR IGNORE INTO sections(baustein_id) VALUES(?)", (b["id"],)) for _ in range(6): # Stages seriell; Schutz gegen Stage-Schleifen sec = db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) if sec["stage"] == "done": db.update("bausteine", "id", b["id"], status="fertig") return naechste = await _STAGES[sec["stage"]](ctx, b) if naechste == sec["stage"]: return # kein Fortschritt (Writer erschöpft) → Karte offen lassen db.update("sections", "baustein_id", b["id"], stage=naechste) db.update("bausteine", "id", b["id"], status="fertig") async def _kapitel_intros(ctx: llm.Kontext) -> None: """Advance Organizer je Kapitel: 2–4 Sätze Landkarte, KEINE Inhalte (Redundancy-Effekt). Idempotent — gefüllte Intros bleiben.""" ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (ctx.topic,))} alle = _bausteine(ctx.topic) async def einer(kap: dict) -> None: bausteine = [b for b in alle if b["kapitel_id"] == kap["id"]] if not bausteine: return liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}" for b in bausteine) res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro", werte={"titel": kap["titel"], "bausteine": liste}, role="judge", n=len(bausteine), item=f"k{kap['id']}", erwartet=dict) intro = str((res or {}).get("intro", "")).strip() if intro: db.update("kapitel", "id", kap["id"], intro=intro) offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,)) await asyncio.gather(*(einer(k) for k in offen)) async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig" or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or {"stage": "writer"})["stage"] != "done"] await asyncio.gather(*(_karte(ctx, b) for b in offen)) await _kapitel_intros(ctx) def kapitel_struktur(topic: str) -> list[dict]: """Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen.""" kaps = {k["id"]: k for k in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))} ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} kapitel: list[dict] = [] for b in _bausteine(topic): sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None if not kapitel or kapitel[-1]["kapitel_id"] != k_id: k = kaps.get(k_id, {}) kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"), "intro": k.get("intro", ""), "sections": []}) kapitel[-1]["sections"].append({ "baustein": b["id"], "titel": b["titel"], "ziel": ziele.get(b["ziel_id"], {}).get("text", ""), "kompakt": sec["text_kompakt"] if sec else "", "lang": sec["text_lang"] if sec else ""}) return kapitel def guide_markdown(topic: str) -> str: t = db.one("SELECT titel FROM topics WHERE name=?", (topic,)) teile = [f"# {t['titel'] if t else topic}"] for kap in kapitel_struktur(topic): teile.append(f"## {kap['titel']}") if kap["intro"]: teile.append(kap["intro"]) for s in kap["sections"]: if s["lang"]: teile.append(f"### {s['titel']}\n\n{s['lang']}") return "\n\n".join(teile) # ── QA + Repair ─────────────────────────────────────────────────────────────── def _stopfrei(text: str) -> set[str]: stop = {"der", "die", "das", "und", "oder", "kann", "eine", "einen", "für", "von", "mit", "den", "dem", "sich", "auf", "aus", "sind", "wird", "werden", "lernende"} return {t for t in textkit.tokens(text) if len(t) >= 4 and t not in stop} def messen(ctx: llm.Kontext) -> list[dict]: befunde = [] for b in _bausteine(ctx.topic): sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) if not sec or not sec["text_lang"]: befunde.append({"art": "section_fehlt", "item": str(b["id"]), "detail": b["titel"]}) continue lang = sec["text_lang"] atome = _atome_von(b["id"]) for i in _marker_fehlend(lang, atome): befunde.append({"art": "marker_fehlend", "item": str(b["id"]), "detail": f"Atom {i}"}) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) if ziel: noetig = _stopfrei(ziel["text"]) # Writer formuliert per Design frei — Schwelle 1/3 statt 1/2, und # kompakt + Titel zählen mit (Ziel-Wörter erscheinen paraphrasiert). da = textkit.tokens(f"{b['titel']} {sec['text_kompakt']} {lang}") if noetig and len(noetig & da) / len(noetig) < 0.34: befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]), "detail": ziel["text"][:120]}) lo, hi = _laenge_band(len(atome)) w = len(lang.split()) if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75) befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"}) for t in _vorwaertsverweise(ctx.topic, b, lang): befunde.append({"art": "vorwaerts", "item": str(b["id"]), "detail": t}) return befunde async def messen_llm(ctx: llm.Kontext) -> list[dict]: """Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger — nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections werden übersprungen (qa_hash) — die Prüfung lief sonst jede Repair-Iteration über ALLE Sections (gemessen: 806 QA-Calls für 50 Sections).""" import hashlib ctx.ebene = EBENE befunde = [] offene_falsch = {b["item"] for b in db.query( "SELECT item FROM befunde WHERE run_id=? AND ebene=? AND art='fachlich_falsch'" " AND status='offen'", (ctx.run_id, EBENE))} async def eine(b: dict) -> None: sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) if not sec or not sec["text_lang"]: return h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12] if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch: return # unverändert und sauber — kein Re-Check db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, b["id"])) atome = _atome_von(b["id"]) fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome) verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch", schritt="qa_judge", role="judge", item=f"b{b['id']}", werte={"fakten": fakten, "text": sec["text_lang"]}, erwartet=list) claims = [str(c.get("claim", "")).strip() for c in verdacht or [] if isinstance(c, dict) and c.get("claim")] if not claims: return stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check", schritt="qa_judge", role="judge", item=f"b{b['id']}", werte={"fakten": fakten, "claims": "\n".join(f"{i + 1}: {c}" for i, c in enumerate(claims))}, erwartet=list) for i, claim in enumerate(claims, 1): ja = sum(1 for s in stimmen for e in s if isinstance(e, dict) and e.get("claim") == i and e.get("falsch")) if ja >= 2: befunde.append({"art": "fachlich_falsch", "item": str(b["id"]), "detail": claim[:200]}) await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic))) return befunde async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: """Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check); Stil-Befunde → direkt in den Fix (genau ein Rewrite).""" ctx.ebene = EBENE betroffen: dict[int, list[str]] = {} for b in befunde: try: b_id = int(b["item"]) except (ValueError, TypeError): continue betroffen.setdefault(b_id, []).append(b["art"]) for b_id, arten in betroffen.items(): if any(a in KRITISCH or a == "section_fehlt" for a in arten): stage = "writer" if "section_fehlt" in arten else "pruefer" db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id)) else: auftraege = [f"Behebe: {a}" for a in arten] db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege)) db.update("bausteine", "id", b_id, status="repair") if betroffen: await bauen(ctx) return bool(betroffen)