"""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 asyncio import logging import re import db import llm from config import ARTEFAKT_CHUNK_ATOME, 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 _referenziert_quelle(inhalt: dict) -> bool: return _QUELLEN_REFERENZ.search(" ".join(str(v) for v in inhalt.values())) 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 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 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: inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort", "text")} 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") 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) 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