Files
creator2/backend/guide.py
2026-07-12 04:20:07 +02:00

608 lines
31 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 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"<!--\s*atom:\s*(\d+)\s*\|")
_MARKER_VOLL = re.compile(r"<!--\s*atom:[^>]*-->") # 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): <!-- 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}"
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"(?<!\*)\*(?!\*)", 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(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("<!--")]
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(_MARKER_VOLL.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"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
treffer.append(a["titel"])
return treffer
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> 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"
# Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen:
# 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 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 _pruefe_dich(baustein_id: int, lang: str) -> list[dict]:
"""Antworten zu den „Prüfe dich"-Fragen: die Fragen stammen wörtlich aus dem
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
out = []
for a in _atome_von(baustein_id):
for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND"
" typ='flashcard' AND status='verifiziert'", (a["id"],)):
fc = db.uj(r["inhalt"], {})
frage = str(fc.get("frage", "")).strip()
antwort = str(fc.get("antwort", "")).strip()
if frage and antwort and frage in lang:
out.append({"frage": frage, "antwort": antwort})
return out
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", ""),
"level": k.get("level", b["level"]), "sections": []})
lang = sec["text_lang"] if sec else ""
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": lang,
"fragen": _pruefe_dich(b["id"], lang) if lang 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}"})
for i in _marker_fremd_ids(lang, atome):
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
"detail": f"Atom {i}"})
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]})
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[dict]] = {}
for b in befunde:
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b)
for b_id, liste in betroffen.items():
arten = [x["art"] for x in liste]
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:
# Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix
# raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen)
auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste]
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)