update
This commit is contained in:
207
backend/guide.py
207
backend/guide.py
@@ -15,9 +15,10 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import db
|
||||
import jsonx
|
||||
import llm
|
||||
import textkit
|
||||
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
@@ -55,11 +56,15 @@ _MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
||||
# 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*-->")
|
||||
_MARKER_BEISPIEL = re.compile(r"<!--\s*beispiel:\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"}
|
||||
_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich",
|
||||
"diagramm": "Diagramm-Beispiel"}
|
||||
_VERBATIM_FORMEN = ("code", "tabelle", "diagramm")
|
||||
|
||||
|
||||
def _ohne_marker(text: str) -> str:
|
||||
@@ -67,8 +72,77 @@ def _ohne_marker(text: str) -> str:
|
||||
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))
|
||||
Diagramm- und Beispiel-Marker gehören ebenso raus."""
|
||||
return _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text)))
|
||||
|
||||
|
||||
def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]:
|
||||
"""Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle/diagramm) —
|
||||
(artefakt_id, inhalt). Woven-Formen (text/mathe) gehen über `_beispiel`."""
|
||||
out = []
|
||||
for r in db.query("SELECT id, inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status='verifiziert'", (atom_id,)):
|
||||
inh = db.uj(r["inhalt"], {})
|
||||
if inh.get("form") in _VERBATIM_FORMEN:
|
||||
out.append((r["id"], inh))
|
||||
return out
|
||||
|
||||
|
||||
def _beispiel_render(inh: dict) -> str:
|
||||
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle / Mermaid-Fence)."""
|
||||
form = inh.get("form")
|
||||
if form == "code" and inh.get("code"):
|
||||
return f"```{inh.get('sprache', '')}\n{inh['code']}\n```"
|
||||
if form == "tabelle" and inh.get("tabelle"):
|
||||
return inh["tabelle"]
|
||||
if form == "diagramm" and inh.get("mermaid"):
|
||||
return f"```mermaid\n{inh['mermaid']}\n```"
|
||||
return ""
|
||||
|
||||
|
||||
def _marker_platzieren(b: dict, lang: str) -> str:
|
||||
"""Verbatim-Marker DETERMINISTISCH setzen (der Writer tut es nicht mehr — er
|
||||
ignorierte die Regel, 2/13 platziert). Beispiel-Marker hinter den Atom-Absatz
|
||||
(atom-genau via Atom-Marker); Diagramme (baustein-scoped) ans Section-Ende.
|
||||
Idempotent: alte Verbatim-Marker erst raus."""
|
||||
lang = _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", lang))
|
||||
je_atom = {}
|
||||
for a in _atome_von(b["id"]):
|
||||
vs = _beispiel_verbatim(a["id"])
|
||||
if vs:
|
||||
je_atom[a["id"]] = vs[0][0] # erstes Verbatim-Beispiel des Atoms
|
||||
if je_atom:
|
||||
out, pending = [], None
|
||||
for z in lang.split("\n"):
|
||||
out.append(z)
|
||||
m = _MARKER.search(z)
|
||||
if m and int(m.group(1)) in je_atom:
|
||||
pending = je_atom.pop(int(m.group(1)))
|
||||
elif pending is not None and z.strip() == "": # Absatzende → Marker als Block
|
||||
out += [f"<!-- beispiel: {pending} -->", ""]
|
||||
pending = None
|
||||
if pending is not None:
|
||||
out += ["", f"<!-- beispiel: {pending} -->"]
|
||||
lang = "\n".join(out)
|
||||
diags = _diagramme_von(b["id"])
|
||||
if diags:
|
||||
block = "\n".join(f"<!-- diagramm: {d['id']} -->" for d in diags)
|
||||
lang = lang.rstrip() + "\n\n" + block
|
||||
return lang
|
||||
|
||||
|
||||
def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
|
||||
"""Assembly: `<!-- beispiel: id -->` → gerendertes Verbatim-Beispiel; tote Marker weg."""
|
||||
if "<!-- beispiel:" not in lang and "<!--beispiel:" not in lang:
|
||||
return lang
|
||||
je_id = {aid: inh for a in _atome_von(baustein_id)
|
||||
for aid, inh in _beispiel_verbatim(a["id"])}
|
||||
|
||||
def sub(m: re.Match) -> str:
|
||||
inh = je_id.get(int(m.group(1)))
|
||||
return _beispiel_render(inh) if inh else ""
|
||||
|
||||
return _MARKER_BEISPIEL.sub(sub, lang)
|
||||
|
||||
|
||||
def _diagramme_von(baustein_id: int) -> list[dict]:
|
||||
@@ -76,13 +150,6 @@ def _diagramme_von(baustein_id: int) -> list[dict]:
|
||||
" 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."""
|
||||
@@ -117,15 +184,14 @@ def _zitate(atom_id: int, max_n: int = 3) -> list[str]:
|
||||
|
||||
|
||||
def _beispiel(atom_id: int) -> str:
|
||||
"""Nur die WOVEN-Formen (text/mathe) als Prosa für den Writer. Verbatim-Formen
|
||||
(code/tabelle/diagramm) kommen über den `<!-- beispiel: id -->`-Marker rein."""
|
||||
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status='verifiziert' LIMIT 1", (atom_id,))
|
||||
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())]
|
||||
if not row:
|
||||
return ""
|
||||
inh = db.uj(row["inhalt"], {})
|
||||
return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else ""
|
||||
|
||||
|
||||
def _atom_paket(a: dict) -> str:
|
||||
@@ -135,9 +201,6 @@ def _atom_paket(a: dict) -> str:
|
||||
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
|
||||
|
||||
|
||||
@@ -170,6 +233,25 @@ def _anknuepf_kontext(b: dict) -> str:
|
||||
|
||||
# ── Stages ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _split_writer(text: str) -> tuple[str, str]:
|
||||
"""Delimited-Ausgabe des Writers/Fix parsen: ===LANG===… / ===KOMPAKT===….
|
||||
Grund: rohe LaTeX-Backslashes im Text sind invalides JSON (34 % Parse-Fehler).
|
||||
Robust: Blöcke unabhängig von der Reihenfolge; fehlt der Delimiter, jsonx-Fallback
|
||||
(alt-JSON), sonst ganzer Text = lang."""
|
||||
def block(name: str) -> str | None:
|
||||
m = re.search(rf"===\s*{name}\s*===\s*(.*?)(?=\n===\s*[A-ZÄÖÜ]+\s*===|$)",
|
||||
text or "", re.S)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
lang, kompakt = block("LANG"), block("KOMPAKT")
|
||||
if lang is not None or kompakt is not None:
|
||||
return kompakt or "", lang or ""
|
||||
daten = jsonx.parse(text or "") # Fallback: altes JSON-Format
|
||||
if isinstance(daten, dict):
|
||||
return str(daten.get("kompakt", "")), str(daten.get("lang", ""))
|
||||
return "", (text or "").strip()
|
||||
|
||||
|
||||
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"]}
|
||||
@@ -178,20 +260,22 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
"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"])}
|
||||
"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)
|
||||
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=str)
|
||||
if res is None:
|
||||
return "writer" # Stage bleibt, nächster Lauf versucht erneut
|
||||
kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", ""))
|
||||
kompakt, lang = _split_writer(res)
|
||||
fehlend = _marker_fehlend(lang, atome)
|
||||
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
|
||||
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
||||
schritt="writer", role="guide", n=len(atome),
|
||||
item=f"b{b['id']}-2", werte=werte, erwartet=dict)
|
||||
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
||||
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
||||
item=f"b{b['id']}-2", werte=werte, erwartet=str)
|
||||
if res2:
|
||||
k2, l2 = _split_writer(res2)
|
||||
if not _marker_fehlend(l2, atome):
|
||||
kompakt, lang = k2, l2
|
||||
lang = _marker_platzieren(b, lang) # Beispiel-/Diagramm-Marker deterministisch setzen
|
||||
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
|
||||
return "pruefer"
|
||||
|
||||
@@ -378,7 +462,9 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
||||
if je_baustein.get(a["bid"], 0) <= mein_kapitel:
|
||||
continue
|
||||
t = textkit.norm(a["titel"])
|
||||
if len(t) < 6 and len(t.split()) < 2:
|
||||
# nur SIGNIFIKANTE Titel flaggen: ≥2 Token UND ≥8 Zeichen. Kurze Fachwörter
|
||||
# (oft Homonyme/gängige Wörter) produzierten unschließbare Dauer-Flags.
|
||||
if len(t.split()) < 2 or len(t) < 8:
|
||||
continue
|
||||
if re.search(rf"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
|
||||
treffer.append(a["titel"])
|
||||
@@ -428,13 +514,14 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
|
||||
"auftraege": "\n".join(f"- {a}" for a in auftraege),
|
||||
"fakten": fakten},
|
||||
erwartet=dict)
|
||||
erwartet=str)
|
||||
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
|
||||
if res:
|
||||
lang = str(res.get("lang", ""))
|
||||
kompakt, lang = _split_writer(res)
|
||||
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
|
||||
lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen
|
||||
db.update("sections", "baustein_id", b["id"],
|
||||
text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([]))
|
||||
text_kompakt=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"
|
||||
@@ -496,23 +583,6 @@ 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 = []
|
||||
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."""
|
||||
@@ -534,15 +604,16 @@ def kapitel_struktur(topic: str) -> list[dict]:
|
||||
"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);
|
||||
# Diagramm- und Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly);
|
||||
# Atom-Marker bleiben — die strippt erst das Frontend.
|
||||
lang = _diagramm_einsetzen(b["id"], sec["text_lang"]) if sec else ""
|
||||
lang = ""
|
||||
if sec:
|
||||
lang = _beispiel_einsetzen(b["id"], _diagramm_einsetzen(b["id"], sec["text_lang"]))
|
||||
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 []})
|
||||
"lang": lang})
|
||||
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
|
||||
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
|
||||
for i, kap in enumerate(kapitel):
|
||||
@@ -593,6 +664,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
|
||||
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
|
||||
f" entfernen (kein solches verifiziertes Diagramm)"})
|
||||
gueltige_b = {aid for a in atome for aid, _ in _beispiel_verbatim(a["id"])}
|
||||
for m in _MARKER_BEISPIEL.findall(lang):
|
||||
if int(m) not in gueltige_b: # Marker ohne verifiziertes Verbatim-Beispiel
|
||||
befunde.append({"art": "beispiel_marker_tot", "item": str(b["id"]),
|
||||
"detail": f"Stray Beispiel-Marker <!-- beispiel: {m} -->"
|
||||
f" entfernen (kein solches verifiziertes Beispiel)"})
|
||||
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
|
||||
befunde.append({"art": "det_check", "item": str(b["id"]),
|
||||
"detail": auftrag[:300]})
|
||||
@@ -611,7 +688,7 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
|
||||
"detail": ziel["text"][:120]})
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
w = len(lang.split())
|
||||
w = len(_ohne_marker(lang).split()) # marker-frei — konsistent mit dem Fix-Band
|
||||
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
|
||||
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
|
||||
for t in _vorwaertsverweise(ctx.topic, b, lang):
|
||||
@@ -703,12 +780,21 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
betroffen.setdefault(b_id, []).append(b)
|
||||
if not betroffen:
|
||||
return False
|
||||
geroutet: list[int] = []
|
||||
for b_id, liste in betroffen.items():
|
||||
arten = [x["art"] for x in liste]
|
||||
# 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:
|
||||
kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail
|
||||
if not kritisch:
|
||||
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird
|
||||
# eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts-
|
||||
# Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn.
|
||||
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
|
||||
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
|
||||
continue
|
||||
if kritisch:
|
||||
if "section_fehlt" in arten:
|
||||
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
|
||||
else:
|
||||
@@ -730,6 +816,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
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}
|
||||
geroutet.append(b_id)
|
||||
vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
|
||||
await bauen(ctx)
|
||||
return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen)
|
||||
bewegt = False
|
||||
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
|
||||
if _text_sig(b_id) != vorher[b_id]:
|
||||
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
|
||||
bewegt = True
|
||||
else:
|
||||
db.execute("UPDATE sections SET fix_versuche=fix_versuche+1 WHERE baustein_id=?",
|
||||
(b_id,))
|
||||
return bewegt
|
||||
|
||||
Reference in New Issue
Block a user