update
This commit is contained in:
206
backend/guide.py
206
backend/guide.py
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
import db
|
||||
import llm
|
||||
import textkit
|
||||
from config import SECTION_WOERTER_PRO_ATOM
|
||||
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
@@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
|
||||
|
||||
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", "ziel_ohne_anker", "fachlich_falsch")
|
||||
KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch")
|
||||
|
||||
|
||||
def _bausteine(topic: str) -> list[dict]:
|
||||
@@ -86,7 +87,7 @@ def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
|
||||
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"
|
||||
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}"
|
||||
@@ -101,16 +102,40 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
|
||||
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))
|
||||
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},
|
||||
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
|
||||
@@ -119,11 +144,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
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)
|
||||
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)
|
||||
@@ -135,6 +156,46 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
|
||||
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
|
||||
@@ -182,21 +243,30 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||
return auftraege
|
||||
|
||||
|
||||
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]:
|
||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
||||
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 = [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)]
|
||||
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"
|
||||
@@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st
|
||||
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). Nur signifikante
|
||||
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
|
||||
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.sub("", lang))
|
||||
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"
|
||||
@@ -279,8 +377,10 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
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)
|
||||
# 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}
|
||||
@@ -313,7 +413,9 @@ async def _kapitel_intros(ctx: llm.Kontext) -> None:
|
||||
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},
|
||||
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()
|
||||
@@ -333,6 +435,22 @@ async def bauen(ctx: llm.Kontext) -> None:
|
||||
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."""
|
||||
@@ -346,12 +464,15 @@ def kapitel_struktur(topic: str) -> list[dict]:
|
||||
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": []})
|
||||
"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": sec["text_lang"] if sec else ""})
|
||||
"lang": lang,
|
||||
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
|
||||
return kapitel
|
||||
|
||||
|
||||
@@ -388,6 +509,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
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"])
|
||||
@@ -457,19 +584,22 @@ 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]] = {}
|
||||
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["art"])
|
||||
for b_id, arten in betroffen.items():
|
||||
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:
|
||||
auftraege = [f"Behebe: {a}" for a in arten]
|
||||
# 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:
|
||||
|
||||
Reference in New Issue
Block a user