Files
creator2/backend/guide.py
2026-07-13 12:31:25 +02:00

889 lines
46 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.
"""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 jsonx
import llm
import belege
import textkit
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, 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"<!--\s*atom:\s*(\d+)\s*\|")
# ganze Marker-Zeile — non-greedy statt [^>]*, sonst bricht ein „>" im Atom-Titel
# den Match und die Marker-Zeile bliebe in den Text-Checks stehen (Marker sind
# einzeilig, also kein DOTALL). Muster spiegelt frontend/src/markdown.js.
_MARKER_VOLL = re.compile(r"<!--\s*atom:.*?-->")
_MARKER_BEISPIEL = re.compile(r"<!--\s*beispiel:\s*(\d+)\s*-->")
_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich"}
_VERBATIM_FORMEN = ("code", "tabelle")
def _ohne_marker(text: str) -> str:
"""Marker-Zeilen entfernen: sie sind Backend-Schnittstelle, kein Fließtext.
ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem
gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26"
unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop).
Beispiel-Marker gehören ebenso raus."""
return _MARKER_BEISPIEL.sub("", _MARKER_VOLL.sub("", text))
def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]:
"""Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle) —
(artefakt_id, inhalt). Woven-Formen (text/mathe) gehen über `_beispiel`."""
out = []
for r in db.query("SELECT id, inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert'", (atom_id,)):
inh = db.uj(r["inhalt"], {})
if inh.get("form") in _VERBATIM_FORMEN:
out.append((r["id"], inh))
return out
def _beispiel_render(inh: dict) -> str:
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle)."""
form = inh.get("form")
if form == "code" and inh.get("code"):
return f"```{inh.get('sprache', '')}\n{inh['code']}\n```"
if form == "tabelle" and inh.get("tabelle"):
return inh["tabelle"]
return ""
def _marker_platzieren(b: dict, lang: str) -> str:
"""Verbatim-Marker DETERMINISTISCH setzen (der Writer tut es nicht mehr — er
ignorierte die Regel, 2/13 platziert). Beispiel-Marker hinter den Atom-Absatz
(atom-genau via Atom-Marker). Idempotent: alte Verbatim-Marker erst raus."""
lang = _MARKER_BEISPIEL.sub("", lang)
je_atom = {}
for a in _atome_von(b["id"]):
vs = _beispiel_verbatim(a["id"])
if vs:
je_atom[a["id"]] = vs[0][0] # erstes Verbatim-Beispiel des Atoms
if je_atom:
out, pending = [], None
for z in lang.split("\n"):
out.append(z)
m = _MARKER.search(z)
if m and int(m.group(1)) in je_atom:
pending = je_atom.pop(int(m.group(1)))
elif pending is not None and z.strip() == "": # Absatzende → Marker als Block
out += [f"<!-- beispiel: {pending} -->", ""]
pending = None
if pending is not None:
out += ["", f"<!-- beispiel: {pending} -->"]
lang = "\n".join(out)
return lang
def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
"""Assembly: `<!-- beispiel: id -->` → gerendertes Verbatim-Beispiel; tote Marker weg."""
if "<!-- beispiel:" not in lang and "<!--beispiel:" not in lang:
return lang
je_id = {aid: inh for a in _atome_von(baustein_id)
for aid, inh in _beispiel_verbatim(a["id"])}
def sub(m: re.Match) -> str:
inh = je_id.get(int(m.group(1)))
return _beispiel_render(inh) if inh else ""
return _MARKER_BEISPIEL.sub(sub, lang)
# 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 = 2) -> list[str]:
# Quelltext-FENSTER um die Anker (belege.py): mit bloßen Kurz-Zitaten schrieb
# der Writer „Details sind technisch"-Prosa, weil ihm die Substanz fehlte.
return belege.fenster_liste(atom_id, max_fenster=max_n)
def _beispiel(atom_id: int) -> str:
"""Nur die WOVEN-Formen (text/mathe) als Prosa für den Writer. Verbatim-Formen
(code/tabelle) kommen über den `<!-- beispiel: id -->`-Marker rein."""
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert' LIMIT 1", (atom_id,))
if not row:
return ""
inh = db.uj(row["inhalt"], {})
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
def _quote(block: str) -> str:
return "\n".join(f"> {l}" for l in block.splitlines())
def _atom_paket(a: dict) -> str:
zitate = "\n".join(_quote(z) for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
if beispiel:
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
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. Der Writer knüpft an, statt neu zu erklären
(Dopplungs-Schutz zwischen den Durchgängen)."""
rang = LEVEL_RANG.get(b["level"], 1)
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
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
# ── Stages ────────────────────────────────────────────────────────────────────
def _writer_saeubern(s: str) -> str:
"""Geleakte Writer-Delimiter/Modelltokens entfernen. Der Writer beendet seine
Ausgabe mit `=====`; steht diese Zeile unter einem Bullet, ist sie eine
Setext-H1-Unterstreichung → marked macht daraus ein riesiges <h1>. Reine
`===…`-Zeilen und `<s>`/`</s>`-Tokens fallen daher ganz raus."""
s = re.sub(r"</?s>", "", s or "")
s = re.sub(r"(?m)^\s*={3,}\s*$", "", s)
return s.strip()
def _split_writer(text: str) -> tuple[str, str]:
"""Delimited-Ausgabe des Writers/Fix parsen: ===LANG===… / ===KOMPAKT===….
Grund: rohe LaTeX-Backslashes im Text sind invalides JSON (34 % Parse-Fehler).
Robust: Blöcke unabhängig von der Reihenfolge; fehlt der Delimiter, jsonx-Fallback
(alt-JSON), sonst ganzer Text = lang. Beide Blöcke werden von geleakten
Terminatoren (`=====`/`</s>`) gesäubert."""
def block(name: str) -> str | None:
m = re.search(rf"===\s*{name}\s*===\s*(.*?)(?=\n===\s*[A-ZÄÖÜ]+\s*===|$)",
text or "", re.S)
return _writer_saeubern(m.group(1)) if m else None
lang, kompakt = block("LANG"), block("KOMPAKT")
if lang is not None or kompakt is not None:
return kompakt or "", lang or ""
daten = jsonx.parse(text or "") # Fallback: altes JSON-Format
if isinstance(daten, dict):
return (_writer_saeubern(str(daten.get("kompakt", ""))),
_writer_saeubern(str(daten.get("lang", ""))))
return "", _writer_saeubern(text or "")
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=str)
if res is None:
return "writer" # Stage bleibt, nächster Lauf versucht erneut
kompakt, lang = _split_writer(res)
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=str)
if res2:
k2, l2 = _split_writer(res2)
if not _marker_fehlend(l2, atome):
kompakt, lang = k2, l2
lang = _marker_platzieren(b, lang) # Beispiel-Marker deterministisch setzen
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")
# ohne deutsche Homographen (an/will/not/or/is) — die flaggten deutschen Text
_EN_STOP = frozenset("the and of that this are with for as by be from which were"
" has have can 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 = _ohne_marker(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())
treffer = {t for t in toks if t in _EN_STOP} # verschiedene Stopwörter
# ≥3 VERSCHIEDENE Treffer zusätzlich zur Quote — ein deutscher Satz mit
# zufällig zwei englischen Wörtern reicht nicht mehr für einen Fehlalarm
if len(toks) >= 12 and len(treffer) >= 3 \
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). Marker vorher strippen — Atom-Titel wie „a_n" oder
„A*-Suche" trafen sonst die Formelreste-Checks unfixierbar (der Fix darf den
Marker nicht ändern → Endlos-Loop)."""
text = _ohne_marker(text)
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"(?<!\*)\*(?!\*)", z):
auftraege.append(f"{wo}: einzelnes „*“ im Fließtext startet ungewollt"
f" Kursivschrift — Sterne nur als **fett** oder in $…$.")
break
if re.search(r"\\n(?![a-zA-Z])", text): # \neq etc. bleiben unberührt
auftraege.append(f"{wo}: literales „\\n“ im Text — durch echten"
f" Zeilenumbruch bzw. Leerzeile ersetzen.")
lange = [m for m in re.findall(r"\$([^$\n]+?)\$", text) if len(m) > 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(_ohne_marker(lang).split()) # Marker-Tokens zählen nicht mit
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}).")
# Auftrag an den Detektor angleichen: der prüft nur Titel-PRÄSENZ, also kann
# „erklären + behalten" den Befund nie schließen — nur Entfernen/Umschreiben.
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
f" den späteren Fachbegriff entfernen oder die Stelle ohne ihn"
f" umschreiben (mit bereits eingeführten Begriffen)."
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("<!--")]
if not zeilen or any(z.lstrip().startswith(("-", "*", "#", "$$", ">"))
for z in zeilen):
continue # Listen/Überschriften/Display-Formeln sind kein Fließtext
w = sum(len(z.split()) for z in zeilen)
if w > 110: # Writer-Regel 4090, hart <110 — Textwände töten den Lesefluss
auftraege.append(f"Langtext, Absatz {nr} hat {w} Wörter (Regel: 4090):"
f" in kürzere Absätze teilen.")
inhalt = [z.strip() for z in lang.splitlines()
if z.strip() and not z.strip().startswith("<!--")]
for i, z in enumerate(inhalt):
if z.startswith("####") and (not z.lstrip("# ").strip() or i + 1 == len(inhalt)
or inhalt[i + 1].startswith("#")):
auftraege.append(f"Überschrift „{z[:50]}“ ohne folgenden Text: entfernen"
f" oder den zugehörigen Absatz ergänzen.")
auftraege += _mathe_auftraege(lang, "Langtext")
auftraege += _stil_auftraege(lang, "Langtext")
if kompakt:
if _MARKER.search(kompakt):
auftraege.append("Atom-Marker in der Kompakt-Fassung: Marker gehören nur"
" in den Langtext — aus der Kompakt-Fassung entfernen.")
if any(z.lstrip().startswith(">") for z in kompakt.splitlines()):
auftraege.append("Blockquote („>“) in der Kompakt-Fassung: Inhalt in"
" eigene Stichpunkte umformulieren.")
auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung")
auftraege += _stil_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). Die Kapitel-ord
läuft global über die Durchgänge (E<M<S) — frühere Durchgänge gelten damit
automatisch als bekannt, spätere als Vorgriff. 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(_ohne_marker(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"])
# nur SIGNIFIKANTE Titel flaggen: ≥2 Token UND ≥8 Zeichen. Kurze Fachwörter
# (oft Homonyme/gängige Wörter) produzierten unschließbare Dauer-Flags.
if len(t.split()) < 2 or len(t) < 8:
continue
if re.search(rf"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
treffer.append(a["titel"])
return treffer
def _offene_auftraege(b_id: int) -> list[dict]:
return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='offen'"
" ORDER BY id", (b_id,))
def _fakten_von(atome: list[dict]) -> str:
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict,
offene: list[dict], tag: str = "") -> None:
"""Richter über bestehende Aufträge — Wortlaut bleibt eingefroren, damit
Paraphrasen-Dubletten unmöglich sind. Abschluss (behoben/kein_mangel/
faktenbasis) nur einstimmig im 2er-Panel (destruktiv — Lektion 20/67);
sonst offen + Runde gezählt, am Runden-Cap → eskaliert (terminiert immer)."""
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(offene, 1))
stimmen = await llm.panel(ctx, 2, stage="pruefer_urteil", template="Guide-Pruefer-Urteil",
schritt="pruefer", role="judge", n=len(offene),
item=f"b{b['id']}{tag}-urteil",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": liste},
erwartet=list)
if len(stimmen) < 2:
return # Panel unvollständig → nichts ändern (fail-closed, Lektion 20)
je: dict[int, list[str]] = {}
for s in stimmen:
for e in s:
if isinstance(e, dict):
try:
je.setdefault(int(e.get("auftrag")), []).append(
str(e.get("urteil", "")).strip())
except (TypeError, ValueError):
continue
for i, a in enumerate(offene, 1):
urteile = je.get(i, [])
einstimmig = len(urteile) >= 2 and len(set(urteile)) == 1
if einstimmig and urteile[0] in ("behoben", "kein_mangel"):
db.update("auftraege", "id", a["id"], status=urteile[0])
elif einstimmig and urteile[0] == "faktenbasis":
db.update("auftraege", "id", a["id"], status="eskaliert")
elif a["runden"] + 1 >= AUFTRAG_RUNDEN_MAX:
db.update("auftraege", "id", a["id"], status="eskaliert")
else:
db.update("auftraege", "id", a["id"], runden=a["runden"] + 1)
async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene
— sonst aufersteht ein totgestimmter Fehlalarm als frische Zeile."""
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
verworfen = db.query("SELECT detail FROM auftraege WHERE baustein_id=?"
" AND status IN ('kein_mangel','eskaliert')", (b["id"],))
res = await llm.call(ctx, stage="pruefer", template="Guide-Pruefer",
schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"geprueft": "\n".join(f"- {v['detail']}" for v in verworfen)
or "(keine)"},
erwartet=dict)
bekannte = {a["detail"] for a in db.query(
"SELECT detail FROM auftraege WHERE baustein_id=? AND status != 'behoben'",
(b["id"],))}
for e in (res or {}).get("befunde", []):
art = str(e.get("art", "")).strip()
detail = str(e.get("detail", "")).strip()
if art in ("falsch", "luecke", "stil") and detail and detail not in bekannte:
db.insert("auftraege", baustein_id=b["id"], art=art, detail=detail)
bekannte.add(detail)
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"])
if offene:
await _auftraege_urteilen(ctx, b, sec, offene, tag)
offene = _offene_auftraege(b["id"])
# Fund erst bei leerer Liste — Wachstum ist damit hart gedeckelt. Der Re-Check
# nach einem Fix urteilt nur (Rest fängt die Ebenen-QA, wie bisher).
if not offene and tag != "-re":
await _auftraege_finden(ctx, b, sec)
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
return "fix" if offene or det else "done"
async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
auftraege = det + [f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"]
for a in offene]
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=str)
kritisch = any(a["art"] in ("falsch", "luecke") for a in offene)
if res:
kompakt, lang = _split_writer(res)
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen
db.update("sections", "baustein_id", b["id"],
text_kompakt=kompakt, text_lang=lang)
# stil: genau ein Rewrite-Versuch, dann optimistisch zu — Rückfälle
# fangen det-Checks/Fund. falsch/luecke schließt NUR das Re-Check-Urteil.
for a in offene:
if a["art"] == "stil":
db.update("auftraege", "id", a["id"], status="behoben")
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re")
return "done"
# Fix fehlgeschlagen → Original behalten, Aufträge bleiben offen:
# unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60
# offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen.
return "done"
_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: 24 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,
"durchgang": DURCHGANG.get(kap["level"],
DURCHGANG["M"])},
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 llm.alle(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"]
# Intros brauchen nur Kapitel+Ziele (keine Sections) → parallel zu den Karten
await llm.alle([*(_karte(ctx, b) for b in offen), _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
# neue Gruppe bei Kapitelwechsel — bei einer kapitellosen (NULL-)Gruppe
# zusätzlich bei Level-Wechsel, sonst mischt ein „Weitere Themen"-Block
# E-, M- und S-Bausteine und erbt fälschlich das Level des ersten.
neu = (not kapitel or kapitel[-1]["_kid"] != k_id
or (k_id is None and kapitel[-1]["level"] != b["level"]))
if neu:
k = kaps.get(k_id, {})
kapitel.append({"kapitel_id": k_id, "_kid": k_id,
"titel": k.get("titel", "Weitere Themen"),
"intro": k.get("intro", ""),
"level": k.get("level", b["level"]), "sections": []})
# Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly);
# Atom-Marker bleiben — die strippt erst das Frontend. _writer_saeubern hier
# AUCH beim Lesen: bestehende Sections mit geleaktem `=====`/`</s>` (Setext-H1
# → riesiger Bullet) werden ohne Neu-Generierung sauber ausgeliefert.
lang = ""
if sec:
lang = _beispiel_einsetzen(b["id"], _writer_saeubern(sec["text_lang"]))
kapitel[-1]["sections"].append({
"baustein": b["id"], "titel": b["titel"],
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
"kompakt": _writer_saeubern(sec["text_kompakt"]) if sec else "",
"lang": lang})
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
for i, kap in enumerate(kapitel):
if kap.pop("_kid") is None:
kap["kapitel_id"] = f"frei-{i}"
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}"})
for i in _marker_fremd_ids(lang, atome):
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
"detail": f"Atom {i}"})
gueltige_b = {aid for a in atome for aid, _ in _beispiel_verbatim(a["id"])}
for m in _MARKER_BEISPIEL.findall(lang):
if int(m) not in gueltige_b: # Marker ohne verifiziertes Verbatim-Beispiel
befunde.append({"art": "beispiel_marker_tot", "item": str(b["id"]),
"detail": f"Stray Beispiel-Marker <!-- beispiel: {m} -->"
f" entfernen (kein solches verifiziertes Beispiel)"})
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND"
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)):
befunde.append({"art": "fakten_konflikt", "item": str(b["id"]),
"detail": a["detail"][:300]})
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(_ohne_marker(lang).split()) # marker-frei — konsistent mit dem Fix-Band
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
def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong)."""
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim))
if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
elif alt["status"] == "kein_mangel":
db.update("auftraege", "id", alt["id"], status="eskaliert")
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 = []
def hash_setzen(bid: int, h: str) -> None:
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid))
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"]:
return # unverändert und sauber geprüft — die auftraege-Tabelle ist
# das persistente Gedächtnis bestätigter Claims (kein Re-Check nötig)
# qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"])
fakten = "\n".join(f"- {a['titel']}: "
+ " | ".join(z.replace("\n", " ") for z in _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)
if verdacht is None:
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
claims = [str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")]
if not claims:
hash_setzen(b["id"], h) # sauber geprüft
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)
if not stimmen:
return # Panel-Ausfall → kein Hash, nächste Runde prüft erneut
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]})
# sofort persistieren (resume-sicher: nur DIESER Moment kennt den
# bestätigten Claim; qa_hash ist danach gesetzt → nie re-geprüft)
_qa_auftrag(b["id"], claim)
hash_setzen(b["id"], h) # vollständig geprüft
await llm.alle(eine(b) for b in _bausteine(ctx.topic))
return befunde
def _text_sig(b_id: int) -> str:
import hashlib
s = db.one("SELECT text_kompakt, text_lang FROM sections WHERE baustein_id=?", (b_id,))
if not s:
return ""
roh = (s["text_kompakt"] or "") + "\x00" + (s["text_lang"] or "")
return hashlib.sha256(roh.encode()).hexdigest()[:16]
def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt,
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)-
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab)."""
if not b_ids:
return set()
marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["runden"]) for r in db.query(
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))}
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritisches + offene Aufträge → zurück auf pruefer (Urteil/Re-Check mit
vollem Kontext); reine det-Stil-Befunde → direkt in den Fix (genau ein
Rewrite). Rückgabe ist ehrlich: True nur bei Textänderung ODER Auftrag-
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
for b in befunde:
if b["art"] == "fakten_konflikt":
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b)
if not betroffen:
return False
geroutet: list[int] = []
for b_id, liste in betroffen.items():
arten = {x["art"] for x in liste}
if "section_fehlt" in arten:
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
elif arten & ({"auftrag_offen"} | set(KRITISCH)):
# Urteile sind der Fortschrittsmechanismus — der Fix-Freeze gilt hier
# NIE, sonst wären Aufträge gefrorener Sections wieder unsterblich.
db.update("sections", "baustein_id", b_id, stage="pruefer")
else:
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird für
# det-Stil-Befunde eingefroren. Rest-Schuld (Gewicht 0.5) akzeptiert.
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
continue
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
stand_vorher = _auftrag_stand(geroutet)
await bauen(ctx)
bewegt = _auftrag_stand(geroutet) != stand_vorher
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
bewegt = True
else:
db.execute("UPDATE sections SET fix_versuche=fix_versuche+1 WHERE baustein_id=?",
(b_id,))
return bewegt