"""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 DURCHGANG, LEVEL_RANG, 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"") # ganze Marker-Zeile (für Text-Checks) # Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix # (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem) KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch") def _bausteine(topic: str) -> list[dict]: return db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) def _atome_von(baustein_id: int) -> list[dict]: return db.query("SELECT * FROM atome WHERE baustein_id=? AND status NOT IN" " ('gemerged','verworfen') ORDER BY ord", (baustein_id,)) def _zitate(atom_id: int, max_n: int = 3) -> list[str]: rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?", (atom_id, max_n)) return [r["zitat"] for r in rows] def _beispiel(atom_id: int) -> str: row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'" " AND status='verifiziert' LIMIT 1", (atom_id,)) return db.uj(row["inhalt"], {}).get("text", "") if row else "" def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]: rows = db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='flashcard'" " AND status='verifiziert' LIMIT ?", (atom_id, max_n)) return [f for r in rows if (f := db.uj(r["inhalt"], {}).get("frage", "").strip())] def _atom_paket(a: dict) -> str: zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)" beispiel = _beispiel(a["id"]) teil = (f"MARKER (exakt so übernehmen): \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 def _anknuepf_kontext(b: dict) -> str: """Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an, statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen).""" rang = LEVEL_RANG.get(b["level"], 1) atome = _atome_von(b["id"]) ids = {a["id"] for a in atome} kontext: dict[int, dict] = {} for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN" " ('gemerged','verworfen')", (b["ziel_id"],)): if LEVEL_RANG.get(a["level"], 1) < rang: kontext[a["id"]] = a if ids: marks = ",".join("?" * len(ids)) for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom" f" WHERE k.art='braucht' AND k.status='aktiv'" f" AND k.von_atom IN ({marks})", tuple(ids)): if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang: kontext[a["id"]] = a return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values()) # ── 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)) werte = {"titel": b["titel"], "ziel": ziel["text"], "atome": "\n\n".join(_atom_paket(a) for a in atome), "min_woerter": lo, "max_woerter": hi, "durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]), "kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"} res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte, 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=werte, 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 _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]: """Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen).""" eigene = {a["id"] for a in atome} return sorted({int(m) for m in _MARKER.findall(text)} - eigene) _FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))" r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE) _LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)" r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b") _EN_STOP = frozenset("the and of is that this are with for as by be from which were" " has have not can will or an it its when each such".split()) def _stil_auftraege(text: str, wo: str) -> list[str]: """Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln (der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären), Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript) und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen). Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop).""" text = _MARKER_VOLL.sub("", text) auftraege = [] for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}): auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff" f" stattdessen kurz mit Bekanntem einordnen oder die Stelle" f" entfernen.") for m in sorted({m.group(0) for m in _LEAK.finditer(text)}): auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein" f" nummeriertes Skript und keine Aufgabenstellung; Inhalt" f" eigenständig formulieren.") for absatz in text.split("\n\n"): toks = re.findall(r"[a-zA-Z']+", absatz.lower()) if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15: kurz = " ".join(absatz.split())[:60] auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche" f" übersetzen — etablierte Fachtermini dürfen bleiben.") return auftraege 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 = "", qa: bool = False) -> list[str]: """Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise, Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks.""" atome = _atome_von(b["id"]) auftraege = [] if not qa: auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen." for i in _marker_fehlend(lang, atome)] auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein" f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen." for i in _marker_fremd_ids(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):" f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz" f" erklären oder die Verwendung entfernen — nie unerklärt lassen," f" NICHT vertrösten." 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.") for nr, absatz in enumerate(lang.split("\n\n"), 1): # Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz zeilen = [z for z in absatz.splitlines() if z.strip() and not z.strip().startswith("