736 lines
39 KiB
Python
736 lines
39 KiB
Python
"""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*\|")
|
||
# 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_DIAGRAMM = re.compile(r"<!--\s*diagramm:\s*(\d+)\s*-->")
|
||
|
||
# Kurzbeschreibung je Diagramm-Typ (Writer-Angebot)
|
||
_DIAGRAMM_BESCHR = {"dependency": "Abhängigkeitsgraph der Begriffe dieses Bausteins",
|
||
"state": "Zustandsdiagramm/Automat", "flow": "Ablaufdiagramm",
|
||
"tree": "Baumstruktur"}
|
||
|
||
|
||
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).
|
||
Diagramm-Marker gehören ebenso raus."""
|
||
return _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text))
|
||
|
||
|
||
def _diagramme_von(baustein_id: int) -> list[dict]:
|
||
return db.query("SELECT * FROM diagramme WHERE baustein_id=? AND status='verifiziert'"
|
||
" ORDER BY id", (baustein_id,))
|
||
|
||
|
||
def _diagramm_angebot(baustein_id: int) -> str:
|
||
"""Verfügbare Diagramme für den Writer — er platziert den Marker beim Absatz."""
|
||
zeilen = [f"<!-- diagramm: {d['id']} --> — {_DIAGRAMM_BESCHR.get(d['typ'], d['typ'])}"
|
||
for d in _diagramme_von(baustein_id)]
|
||
return "\n".join(zeilen) or "(keine)"
|
||
|
||
|
||
def _diagramm_einsetzen(baustein_id: int, lang: str) -> str:
|
||
"""Assembly: `<!-- diagramm: id -->` → ```mermaid-Fence des verifizierten
|
||
Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg."""
|
||
if "<!-- diagramm:" not in lang and "<!--diagramm:" not in lang:
|
||
return lang
|
||
je_id = {d["id"]: d for d in _diagramme_von(baustein_id)}
|
||
|
||
def sub(m: re.Match) -> str:
|
||
d = je_id.get(int(m.group(1)))
|
||
return f"```mermaid\n{d['mermaid']}\n```" if d and d["mermaid"] else ""
|
||
|
||
return _MARKER_DIAGRAMM.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 = 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)",
|
||
"diagramme": _diagramm_angebot(b["id"])}
|
||
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")
|
||
# 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 40–90, hart <110 — Textwände töten den Lesefluss
|
||
auftraege.append(f"Langtext, Absatz {nr} hat {w} Wörter (Regel: 40–90):"
|
||
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"])
|
||
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"])
|
||
# bereits offene KRITISCH-Aufträge übernehmen (z. B. ein aus der Ebenen-QA
|
||
# bestätigter fachlich_falsch-Claim) — der In-Card-Prüfer findet sie nicht
|
||
# immer selbst wieder, ohne dies verpuffte der Befund im Ping-Pong.
|
||
for alt in db.uj(sec["befunde"]):
|
||
if str(alt).startswith("KRITISCH") and alt not in auftraege:
|
||
auftraege.append(alt)
|
||
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)
|
||
auftraege = list(dict.fromkeys(auftraege)) # Duplikate (übernommene + neue) raus
|
||
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: 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,
|
||
"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"]
|
||
await llm.alle(_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 = []
|
||
lang_norm = textkit.norm(lang) # Case/Whitespace-tolerant — ein Stil-Fix an der
|
||
for a in _atome_von(baustein_id): # Frage ließ das rohe Substring-Matching scheitern
|
||
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 textkit.norm(frage) in lang_norm:
|
||
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
|
||
# 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": []})
|
||
# Diagramm-Marker gegen den ```mermaid-Fence tauschen (Assembly-Schritt);
|
||
# Atom-Marker bleiben — die strippt erst das Frontend.
|
||
lang = _diagramm_einsetzen(b["id"], 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 []})
|
||
# 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_d = {d["id"] for d in _diagramme_von(b["id"])}
|
||
for m in _MARKER_DIAGRAMM.findall(lang):
|
||
if int(m) not in gueltige_d: # Marker ohne verifiziertes Diagramm → entfernen
|
||
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
|
||
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
|
||
f" entfernen (kein solches verifiziertes Diagramm)"})
|
||
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]})
|
||
# Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht
|
||
# geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate.
|
||
for auftrag in db.uj(sec["befunde"]):
|
||
befunde.append({"art": "fix_offen", "item": str(b["id"]),
|
||
"detail": str(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 = []
|
||
# topic-scoped (nicht run-scoped): ein in Run N bestätigter Fakten-Fehler bleibt
|
||
# nach Pause/Resume (Run N+1) sichtbar, sonst greift der qa_hash-Skip fälschlich.
|
||
offene_falsch = {b["item"] for b in db.query(
|
||
"SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id"
|
||
" WHERE r.topic=? AND b.ebene=? AND b.art='fachlich_falsch' AND b.status='offen'",
|
||
(ctx.topic, EBENE))}
|
||
|
||
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"] and str(b["id"]) not in offene_falsch:
|
||
return # unverändert und sauber — kein Re-Check
|
||
# 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(_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]})
|
||
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]
|
||
|
||
|
||
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). Rückgabe ist ehrlich:
|
||
True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt
|
||
auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch."""
|
||
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)
|
||
if not betroffen:
|
||
return False
|
||
for b_id, liste in betroffen.items():
|
||
arten = [x["art"] for x in liste]
|
||
# ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch
|
||
kritisch_detail = any(x["art"] == "fix_offen"
|
||
and str(x["detail"]).startswith("KRITISCH") for x in liste)
|
||
if any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail:
|
||
if "section_fehlt" in arten:
|
||
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
|
||
else:
|
||
# KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie
|
||
# sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix)
|
||
vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?",
|
||
(b_id,))["befunde"])
|
||
neu = [f"KRITISCH ({x['art']}): {x['detail']}" for x in liste
|
||
if x["art"] in KRITISCH]
|
||
zusammen = list(dict.fromkeys(vorhanden + neu))
|
||
db.update("sections", "baustein_id", b_id, stage="pruefer",
|
||
befunde=db.j(zusammen))
|
||
else:
|
||
# bestehende Fix-Aufträge (fix_offen) behalten, Stil-Details anhängen
|
||
vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?",
|
||
(b_id,))["befunde"])
|
||
neu = [f"Behebe ({x['art']}): {x['detail']}" for x in liste
|
||
if x["art"] != "fix_offen"]
|
||
auftraege = list(dict.fromkeys(vorhanden + neu))
|
||
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||
db.update("bausteine", "id", b_id, status="repair")
|
||
vorher = {b_id: _text_sig(b_id) for b_id in betroffen}
|
||
await bauen(ctx)
|
||
return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen)
|