"""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 logging import re import db import llm import textkit from config import SECTION_WOERTER_PRO_ATOM log = logging.getLogger("creator2.guide") 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$).") 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)