"""Ebene 2: Artefakte. Pro Atom 2 Flashcards + 1 Worked Example, generiert in Soll-Punkt-Gruppen (Kontext teilen spart Tokens — Lektion 52), verifiziert von einem 2er-Panel GEGEN DIE ANKER-ZITATE (inline, Lektion 48). Einstimmig ok → verifiziert; sonst ein Fix + Re-Verify durch einen Judge; danach verifiziert oder verworfen.""" import ast import asyncio import logging import re import subprocess import tempfile import db import llm from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL log = logging.getLogger("creator2.artefakte") EBENE = "artefakte" # Karten müssen ohne den Quelltext funktionieren (aak Lauf 8: 17 verifizierte # Karten fragten „was steht im Beleg"). Eng gefasst, damit Fachwörter nicht # matchen: „Belegung" (Aussagenlogik), „Quelle" (Flussnetzwerke), „belegen". _QUELLEN_REFERENZ = re.compile( r"\bbeleg(e|s)?\b|musterlösung|quelltext|laut (quelle|text|skript)" r"|(aus|in) de[rm] (quelle|text|skript)|aufgabenkontext|hausaufgabe" r"|präsenzaufgabe|klausur\w*|\baufgabe \d|\bserie \d|\bblatt \d", re.IGNORECASE) def _prosa_teile(inhalt: dict) -> str: """Nur menschenlesbare Prosa fürs Quellen-Referenz-Gate — NICHT Code/Tabelle (dort matchen Bezeichner/Kommentare den Regex falsch, z. B. „blatt 3").""" return " ".join(str(inhalt.get(k, "")) for k in ("frage", "antwort", "text")) def _referenziert_quelle(inhalt: dict) -> bool: return _QUELLEN_REFERENZ.search(_prosa_teile(inhalt)) is not None def _zitate(atom_id: int) -> str: rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,)) return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)" def _atom_block(a: dict) -> str: return (f"ATOM {a['id']}: {a['titel']} [{a['typ']}]\n" f"Definition: {a['definition']}\nBelege:\n{_zitate(a['id'])}") def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]: """Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome ohne Artefakte.""" atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN" " ('gemerged','verworfen') ORDER BY soll_id, id", (topic,)) if nur_ohne: # Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht). # Prüfte man „irgendein lebendes Artefakt", blockierte ein überlebendes # Beispiel die Flashcard-Nachgenerierung dauerhaft (Lauf 8: 5 Atome ohne). atome = [a for a in atome if not db.one( "SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'" " AND status!='verworfen' LIMIT 1", (a["id"],))] gruppen: dict = {} for a in atome: gruppen.setdefault(a["soll_id"], []).append(a) out = [] for gruppe in gruppen.values(): out += [gruppe[i:i + ARTEFAKT_CHUNK_ATOME] for i in range(0, len(gruppe), ARTEFAKT_CHUNK_ATOME)] return out def _beispiel_inhalt(e: dict) -> dict: """Typisiertes Beispiel: die Form lebt im JSON `inhalt` (keine DB-Migration). Nur relevante Felder gefüllt; unbekannte Form → Default text.""" form = str(e.get("form", "text")).strip().lower() if form not in BEISPIEL_FORMEN: form = "text" return {"form": form, "text": str(e.get("text", "")), "code": str(e.get("code", "")), "sprache": str(e.get("sprache", "")).strip().lower(), "tabelle": str(e.get("tabelle", ""))} async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None: bloecke = "\n\n".join(_atom_block(a) for a in chunk) res = await llm.call(ctx, stage="artefakt", template="Artefakt-Generate", werte={"atome": bloecke}, role="guide", n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list) gueltig = {a["id"] for a in chunk} for e in res or []: atom_id = e.get("atom") typ = str(e.get("typ", "")).strip() if atom_id not in gueltig or typ not in ("flashcard", "beispiel"): continue if typ == "beispiel": inhalt = _beispiel_inhalt(e) else: inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")} if _referenziert_quelle(inhalt): log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id) continue # Nachgenerierung (nur_ohne) läuft auch, wenn nur die Flashcard fehlt — # dann kein zweites Beispiel anlegen (Verify-Tokens/Leitner-Dubletten). if typ == "beispiel" and db.one( "SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'" " AND status!='verworfen' LIMIT 1", (atom_id,)): continue db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat") async def _verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None: """Panel prüft alle Kandidaten des Chunks gegen die Belege. Verwerfen ist destruktiv → nur nach Fix + Re-Verify (im Zweifel behalten, Lektion 67).""" kandidaten = [] for a in chunk: for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'", (a["id"],)): if _referenziert_quelle(db.uj(k["inhalt"])): # deterministisch, vor dem Panel db.update("artefakte", "id", k["id"], status="verworfen") else: kandidaten.append(k) if not kandidaten: return je_atom = {a["id"]: a for a in chunk} liste = "\n\n".join( f"ARTEFAKT {k['id']} (Atom {k['atom_id']}, {k['typ']}): {k['inhalt']}\n" f"Belege:\n{_zitate(k['atom_id'])}" for k in kandidaten) stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="verify", template="Artefakt-Verify", werte={"artefakte": liste}, role="judge", n=len(kandidaten), item=f"v{chunk[0]['id']}", erwartet=list) for k in kandidaten: urteile = [] maengel = [] for stimme in stimmen: for e in stimme: if isinstance(e, dict) and e.get("artefakt") == k["id"]: urteile.append(bool(e.get("ok"))) if e.get("mangel"): maengel.append(str(e["mangel"])) break if len(urteile) >= VERIFY_PANEL and all(urteile): db.update("artefakte", "id", k["id"], status="verifiziert") elif urteile: await _fixen(ctx, k, maengel, je_atom.get(k["atom_id"], {})) # keine gültige Stimme (Panel-Ausfall) → Kandidat bleibt, nächste Runde prüft async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> None: res = await llm.call(ctx, stage="artefakt_fix", template="Artefakt-Fix", schritt="fix", role="guide", item=f"f{k['id']}", werte={"artefakt": k["inhalt"], "typ": k["typ"], "maengel": "\n".join(f"- {m}" for m in maengel) or "-", "belege": _zitate(k["atom_id"])}, erwartet=dict) if res: if k["typ"] == "beispiel": # Form behalten (aus Alt-inhalt), Felder aus dem Fix übernehmen, DANN # form-spezifisch neu gaten — ein kaputter Fix darf nicht durchrutschen. inhalt = _beispiel_inhalt({**db.uj(k["inhalt"], {}), **res}) ok, inhalt = _beispiel_pruefen(inhalt) if not ok: db.update("artefakte", "id", k["id"], status="verworfen") return else: inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort")} if _referenziert_quelle(inhalt): db.update("artefakte", "id", k["id"], status="verworfen") return db.update("artefakte", "id", k["id"], inhalt=db.j(inhalt)) urteil = await llm.call(ctx, stage="reverify", template="Artefakt-Verify", schritt="verify", role="judge", item=f"rv{k['id']}", werte={"artefakte": f"ARTEFAKT {k['id']} (Atom {k['atom_id']}," f" {k['typ']}): {db.j(res) if res else k['inhalt']}\n" f"Belege:\n{_zitate(k['atom_id'])}"}, erwartet=list) # Verwerfen nur bei EXPLIZITEM Negativurteil. Call-Ausfall (urteil None) oder # ausgelassene id → kein Entscheid, Kandidat bleibt (messen meldet unentschieden, # nächste Runde prüft per Panel neu) — sonst fail-open destruktiv. eintrag = next((e for e in (urteil or []) if isinstance(e, dict) and e.get("artefakt") == k["id"]), None) if eintrag is None: return db.update("artefakte", "id", k["id"], status="verifiziert" if eintrag.get("ok") else "verworfen") _VERBATIM_FORMEN = ("code", "tabelle") # kommen verbatim in den Guide def _tabelle_ok(md: str) -> bool: """Wohlgeformte Markdown-Tabelle: Kopf, Trenner (---), ≥1 Datenzeile, konsistente Spaltenzahl. Deterministisch, kein LLM.""" zeilen = [z for z in md.strip().splitlines() if z.strip()] if len(zeilen) < 3 or not all("|" in z for z in zeilen): return False if not re.fullmatch(r"[\s|:\-]+", zeilen[1]) or "-" not in zeilen[1]: return False spalten = zeilen[0].count("|") return spalten >= 2 and all(z.count("|") == spalten for z in zeilen) _node_gewarnt = False def _node_check(code: str) -> bool: """`node --check` parst JS OHNE es auszuführen. fail-open, wenn node fehlt.""" global _node_gewarnt try: with tempfile.NamedTemporaryFile("w", suffix=".js", delete=True) as f: f.write(code) f.flush() res = subprocess.run(["node", "--check", f.name], capture_output=True, text=True, timeout=15) return res.returncode == 0 except Exception as e: # node fehlt → nicht verwerfen (nur Judge prüft dann) if not _node_gewarnt: _node_gewarnt = True log.warning("node-Syntax-Gate nicht verfügbar (%s) — JS-Beispiele ungeprüft", e) return True def _code_ok(sprache: str, code: str) -> bool: """Syntax-Gate, PARSE-ONLY (führt NIE aus — keine Sandbox nötig). Python via ast.parse in-process, JavaScript via `node --check`. Unbekannte Sprache → kein Gate (nur der Judge prüft).""" if not code.strip(): return False if sprache == "python": try: ast.parse(code) return True except SyntaxError: return False if sprache == "javascript": return _node_check(code) return True def _beispiel_pruefen(inh: dict) -> tuple[bool, dict]: """Form-spezifisches Gate (deterministisch, parse-only — nie Ausführung). → (ok, inhalt). Unbekannte/woven Formen passieren (der Judge prüft inhaltlich).""" form = inh.get("form", "text") if form == "tabelle": return _tabelle_ok(inh.get("tabelle", "")), inh if form == "code": return _code_ok(inh.get("sprache", ""), inh.get("code", "")), inh return True, inh def _formen_gate(topic: str) -> None: """Kandidat-Beispiele durch ihr Form-Gate: Syntax/Struktur ungültig → verworfen (fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional).""" for b in db.query( "SELECT ar.* FROM artefakte ar JOIN atome a ON a.id=ar.atom_id" " WHERE a.topic=? AND ar.typ='beispiel' AND ar.status='kandidat'", (topic,)): inh = db.uj(b["inhalt"], {}) ok, inh_neu = _beispiel_pruefen(inh) if not ok: db.update("artefakte", "id", b["id"], status="verworfen") elif inh_neu is not inh: db.update("artefakte", "id", b["id"], inhalt=db.j(inh_neu)) async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE neu = _chunks(ctx.topic, nur_ohne=True) await llm.alle(_generieren(ctx, c) for c in neu) _formen_gate(ctx.topic) # form-spezifische Syntax/Struktur-Gates vor dem Panel alle = _chunks(ctx.topic, nur_ohne=False) await llm.alle(_verifizieren(ctx, c) for c in alle) def messen(ctx: llm.Kontext) -> list[dict]: befunde = [] atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN" " ('gemerged','verworfen')", (ctx.topic,)) for a in atome: karten = db.query("SELECT status FROM artefakte WHERE atom_id=? AND typ='flashcard'", (a["id"],)) if not any(k["status"] == "verifiziert" for k in karten): befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]), "detail": a["titel"]}) offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'", (a["id"],)) for k in offen: befunde.append({"art": "artefakt_unentschieden", "item": str(k["id"]), "detail": a["titel"]}) return befunde async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: ctx.ebene = EBENE if not befunde: return False await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes return True