312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""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, 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 _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)
|
|
|
|
|
|
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"]})
|
|
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
|