"""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 belege import db import llm from config import ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, 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: # Quelltext-FENSTER um die Anker (belege.py) — nur das Zitat war zu schmal: # inhaltliche Karten zu Verfahren galten als unbelegt und churnten endlos. bloecke = belege.fenster_liste(atom_id) return "\n---\n".join(bloecke) 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, atom_ids: list[int] | None = None) -> None: """Kandidat-Beispiele durch ihr Form-Gate: Syntax/Struktur ungültig → verworfen (fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional). atom_ids: optional auf einen Chunk beschränken (Pipeline pro Kette).""" sql = ("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'") params: tuple = (topic,) if atom_ids: sql += f" AND ar.atom_id IN ({','.join('?' * len(atom_ids))})" params += tuple(atom_ids) for b in db.query(sql, params): 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 _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None: """Prüfungs-PAARE je Atom: wahre Aussage (ankerprüfbare Paraphrase) + falsche (GENAU EIN Aspekt gekippt — Mutations-Muster auf Satzebene). Der Code baut je Paar 2 Zeilen; die wahre Aussage ist gratis die Feedback-Erklärung der falschen.""" bloecke = "\n\n".join(_atom_block(a) for a in chunk) res = await llm.call(ctx, stage="aussagen_gen", template="Aussagen-Generate", werte={"atome": bloecke, "paare": AUSSAGEN_JE_ATOM}, role="guide", n=len(chunk), item=f"as{chunk[0]['id']}", erwartet=list) gueltig = {a["id"] for a in chunk} zaehler: dict[int, int] = {} for e in res or []: if not isinstance(e, dict) or e.get("atom") not in gueltig: continue atom_id = e["atom"] wahr, falsch = str(e.get("wahr", "")).strip(), str(e.get("falsch", "")).strip() if not wahr or not falsch or wahr == falsch: continue if _referenziert_quelle({"text": f"{wahr} {falsch}"}): continue i = zaehler.get(atom_id, 0) zaehler[atom_id] = i + 1 paar = f"{atom_id}-{i}" db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat", inhalt=db.j({"text": wahr, "wahr": True, "paar": paar, "erklaerung": wahr})) db.insert("artefakte", atom_id=atom_id, typ="aussage", status="kandidat", inhalt=db.j({"text": falsch, "wahr": False, "paar": paar, "erklaerung": wahr})) async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None: """Getrennter Maßstab je SOLL (Lehre aus der Fehlersuche): wahre müssen aus den Belegen folgen, falsche EINDEUTIG widersprechen. Panel einstimmig → verifiziert; unvollständiges Panel → kandidat lassen (Lektion 20).""" bloecke, kandidaten = [], [] for a in chunk: rows = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='aussage'" " AND status='kandidat'", (a["id"],)) if not rows: continue zeilen = [f"ATOM {a['id']} Belege:\n{_zitate(a['id'])}"] for k in rows: inh = db.uj(k["inhalt"], {}) soll = "wahr" if inh.get("wahr") else "falsch" zeilen.append(f"AUSSAGE {k['id']} [SOLL: {soll}]: {inh.get('text', '')}") kandidaten.append(k) bloecke.append("\n".join(zeilen)) if not kandidaten: return stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="aussagen_verify", template="Aussagen-Verify", role="judge", werte={"aussagen": "\n\n".join(bloecke)}, n=len(kandidaten), item=f"asv{chunk[0]['id']}", erwartet=list) def urteil_fuer(kid: int): def urteil(stimme): for e in stimme: if isinstance(e, dict) and e.get("aussage") == kid: return bool(e.get("ok")) return None return urteil for k in kandidaten: votes = [u for s in stimmen if (u := urteil_fuer(k["id"])(s)) is not None] if len(votes) < VERIFY_PANEL: continue db.update("artefakte", "id", k["id"], status="verifiziert" if all(votes) else "verworfen") async def _kette(ctx: llm.Kontext, chunk: list[dict]) -> None: """gen→gate→verify EINES Chunks verkettet — der Verify braucht nur die eigenen Kandidaten, nicht die Generierung fremder Chunks (Stage-Barriere kostete Wall-Clock: 1 Nachzügler blockierte alle Verifies).""" ohne = [a for a in chunk if not db.one( "SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'" " AND status!='verworfen' LIMIT 1", (a["id"],))] if ohne: await _generieren(ctx, ohne) _formen_gate(ctx.topic, [a["id"] for a in chunk]) await _verifizieren(ctx, chunk) ohne_aussage = [a for a in chunk if not db.one( "SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'" " AND status!='verworfen' LIMIT 1", (a["id"],))] if ohne_aussage: await _aussagen_generieren(ctx, ohne_aussage) await _aussagen_verifizieren(ctx, chunk) async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE await llm.alle(_kette(ctx, c) for c in _chunks(ctx.topic, nur_ohne=False)) 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"]}) if not db.one("SELECT id FROM artefakte WHERE atom_id=? AND typ='aussage'" " AND status='verifiziert' LIMIT 1", (a["id"],)): befunde.append({"art": "atom_ohne_aussage", "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