This commit is contained in:
team3
2026-07-10 15:43:11 +02:00
commit 0a41166cca
72 changed files with 7772 additions and 0 deletions

408
backend/guide.py Normal file
View File

@@ -0,0 +1,408 @@
"""Ebene 4: Guide. Eine Section pro Baustein, Stages seriell je Karte:
writer → pruefer → fix → done (persistiert in sections.stage, resume-fähig).
Der Writer bekommt die komplette Faktenbasis INLINE (Definitionen, Anker-Zitate,
verifizierte Beispiele — Lektion 48) und muss Marker als unsichtbare Invariante
setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage,
Lektion 52); deterministische Checks laufen davor im Code."""
import asyncio
import logging
import re
import db
import llm
import textkit
from config import SECTION_WOERTER_PRO_ATOM
log = logging.getLogger("creator2.guide")
EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
# 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", "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']} | {a['level']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
if beispiel:
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
fragen = _fragen_pool(a["id"])
if fragen:
teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen)
return teil
def _laenge_band(n_atome: int) -> tuple[int, int]:
lo, hi = SECTION_WOERTER_PRO_ATOM
return lo * n_atome, hi * n_atome
# ── Stages ────────────────────────────────────────────────────────────────────
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
lo, hi = _laenge_band(len(atome))
res = await llm.call(ctx, stage="writer", template="Guide-Writer",
werte={"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
if res is None:
return "writer" # Stage bleibt, nächster Lauf versucht erneut
kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", ""))
fehlend = _marker_fehlend(lang, atome)
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
schritt="writer", role="guide", n=len(atome),
item=f"b{b['id']}-2",
werte={"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
erwartet=dict)
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
return "pruefer"
def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
da = {int(m) for m in _MARKER.findall(text)}
return [a["id"] for a in atome if a["id"] not in da]
def _det_auftraege(topic: str, b: dict, lang: str) -> list[str]:
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
atome = _atome_von(b["id"])
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
for i in _marker_fehlend(lang, atome)]
lo, hi = _laenge_band(len(atome))
woerter = len(lang.split())
if woerter > hi:
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
elif woerter < lo:
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
f" oder entfernen — nie unerklärt verwenden."
for t in _vorwaertsverweise(topic, b, lang)]
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
f" Worten in den Fließtext einarbeiten, Zitat-Format entfernen.")
if "6=" in lang:
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
" durch $\\neq$ ersetzen.")
for nr, absatz in enumerate(lang.split("\n\n"), 1):
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
auftraege.append(f"Absatz {nr}: ungerade Anzahl $-Zeichen — jede Formel"
f" braucht öffnendes UND schließendes $ (Beispiel-Fehler:"
f" „$[.“ statt „$[$.“).")
ohne_mathe = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", lang)
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_mathe)))
if nackt: # KaTeX rendert nur innerhalb von $…$ — nackte Befehle bleiben Rohtext
auftraege.append(f"LaTeX ohne $-Delimiter im Text ({', '.join(nackt[:5])}…):"
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
return auftraege
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
kap_ord = {k["id"]: k["ord"] for k in
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
mein_kapitel = je_baustein.get(b["id"], 0)
text = textkit.norm(_MARKER.sub("", lang))
treffer = []
for a in db.query(
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
" WHERE bb.topic=? AND a.status NOT IN ('gemerged','verworfen')", (topic,)):
if je_baustein.get(a["bid"], 0) <= mein_kapitel:
continue
t = textkit.norm(a["titel"])
if len(t) < 6 and len(t.split()) < 2:
continue
if re.search(rf"(?<![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"])
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}",
werte={"ziel": ziel["text"], "fakten": fakten,
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]},
erwartet=dict)
for e in (res or {}).get("befunde", []):
art = str(e.get("art", "")).strip()
detail = str(e.get("detail", "")).strip()
if art in ("falsch", "luecke"):
auftraege.append(f"KRITISCH ({art}): {detail}")
elif art == "stil" and detail:
auftraege.append(detail)
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
return "fix" if auftraege else "done"
async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
auftraege = db.uj(sec["befunde"])
if not auftraege:
return "done"
atome = _atome_von(b["id"])
fakten = "\n\n".join(_atom_paket(a) for a in atome)
res = await llm.call(ctx, stage="fix", template="Guide-Fix",
role="guide", n=len(auftraege), item=f"b{b['id']}",
werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": "\n".join(f"- {a}" for a in auftraege),
"fakten": fakten},
erwartet=dict)
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
if res:
lang = str(res.get("lang", ""))
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
db.update("sections", "baustein_id", b["id"],
text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([]))
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re")
return "done"
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
_STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
async def _karte(ctx: llm.Kontext, b: dict) -> None:
db.execute("INSERT OR IGNORE INTO sections(baustein_id) VALUES(?)", (b["id"],))
for _ in range(6): # Stages seriell; Schutz gegen Stage-Schleifen
sec = db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],))
if sec["stage"] == "done":
db.update("bausteine", "id", b["id"], status="fertig")
return
naechste = await _STAGES[sec["stage"]](ctx, b)
if naechste == sec["stage"]:
return # kein Fortschritt (Writer erschöpft) → Karte offen lassen
db.update("sections", "baustein_id", b["id"], stage=naechste)
db.update("bausteine", "id", b["id"], status="fertig")
async def _kapitel_intros(ctx: llm.Kontext) -> None:
"""Advance Organizer je Kapitel: 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},
role="judge", n=len(bausteine), item=f"k{kap['id']}",
erwartet=dict)
intro = str((res or {}).get("intro", "")).strip()
if intro:
db.update("kapitel", "id", kap["id"], intro=intro)
offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,))
await asyncio.gather(*(einer(k) for k in offen))
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig"
or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or
{"stage": "writer"})["stage"] != "done"]
await asyncio.gather(*(_karte(ctx, b) for b in offen))
await _kapitel_intros(ctx)
def kapitel_struktur(topic: str) -> list[dict]:
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
kaps = {k["id"]: k for k in
db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))}
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
kapitel: list[dict] = []
for b in _bausteine(topic):
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
k = kaps.get(k_id, {})
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
"intro": k.get("intro", ""), "sections": []})
kapitel[-1]["sections"].append({
"baustein": b["id"], "titel": b["titel"],
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
"kompakt": sec["text_kompakt"] if sec else "",
"lang": sec["text_lang"] if sec else ""})
return kapitel
def guide_markdown(topic: str) -> str:
t = db.one("SELECT titel FROM topics WHERE name=?", (topic,))
teile = [f"# {t['titel'] if t else topic}"]
for kap in kapitel_struktur(topic):
teile.append(f"## {kap['titel']}")
if kap["intro"]:
teile.append(kap["intro"])
for s in kap["sections"]:
if s["lang"]:
teile.append(f"### {s['titel']}\n\n{s['lang']}")
return "\n\n".join(teile)
# ── QA + Repair ───────────────────────────────────────────────────────────────
def _stopfrei(text: str) -> set[str]:
stop = {"der", "die", "das", "und", "oder", "kann", "eine", "einen", "für", "von",
"mit", "den", "dem", "sich", "auf", "aus", "sind", "wird", "werden", "lernende"}
return {t for t in textkit.tokens(text) if len(t) >= 4 and t not in stop}
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
for b in _bausteine(ctx.topic):
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
befunde.append({"art": "section_fehlt", "item": str(b["id"]), "detail": b["titel"]})
continue
lang = sec["text_lang"]
atome = _atome_von(b["id"])
for i in _marker_fehlend(lang, atome):
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
"detail": f"Atom {i}"})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
# Writer formuliert per Design frei — Schwelle 1/3 statt 1/2, und
# kompakt + Titel zählen mit (Ziel-Wörter erscheinen paraphrasiert).
da = textkit.tokens(f"{b['titel']} {sec['text_kompakt']} {lang}")
if noetig and len(noetig & da) / len(noetig) < 0.34:
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
"detail": ziel["text"][:120]})
lo, hi = _laenge_band(len(atome))
w = len(lang.split())
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
for t in _vorwaertsverweise(ctx.topic, b, lang):
befunde.append({"art": "vorwaerts", "item": str(b["id"]), "detail": t})
return befunde
async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections
werden übersprungen (qa_hash) — die Prüfung lief sonst jede Repair-Iteration
über ALLE Sections (gemessen: 806 QA-Calls für 50 Sections)."""
import hashlib
ctx.ebene = EBENE
befunde = []
offene_falsch = {b["item"] for b in db.query(
"SELECT item FROM befunde WHERE run_id=? AND ebene=? AND art='fachlich_falsch'"
" AND status='offen'", (ctx.run_id, EBENE))}
async def eine(b: dict) -> None:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
return
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
return # unverändert und sauber — kein Re-Check
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, b["id"]))
atome = _atome_von(b["id"])
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome)
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten, "text": sec["text_lang"]},
erwartet=list)
claims = [str(c.get("claim", "")).strip() for c in verdacht or []
if isinstance(c, dict) and c.get("claim")]
if not claims:
return
stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten,
"claims": "\n".join(f"{i + 1}: {c}"
for i, c in enumerate(claims))},
erwartet=list)
for i, claim in enumerate(claims, 1):
ja = sum(1 for s in stimmen for e in s
if isinstance(e, dict) and e.get("claim") == i and e.get("falsch"))
if ja >= 2:
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
"detail": claim[:200]})
await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic)))
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
ctx.ebene = EBENE
betroffen: dict[int, list[str]] = {}
for b in befunde:
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b["art"])
for b_id, arten in betroffen.items():
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
stage = "writer" if "section_fehlt" in arten else "pruefer"
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
else:
auftraege = [f"Behebe: {a}" for a in arten]
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
db.update("bausteine", "id", b_id, status="repair")
if betroffen:
await bauen(ctx)
return bool(betroffen)