509 lines
23 KiB
Python
509 lines
23 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 belege
|
||
import db
|
||
import llm
|
||
from config import (ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN,
|
||
NACHFUELL_CAP, 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'])}")
|
||
|
||
|
||
FC_ZIEL = 2 # Flashcards je Atom; Beispiel-Ziel ist 1, Aussagen-Ziel AUSSAGEN_JE_ATOM Paare
|
||
|
||
# Zeilen-Ziel je Typ (Aussagen: 2 Zeilen pro Paar) — Basis für den Nachfüll-Cap
|
||
ZIEL_ZEILEN = {"flashcard": FC_ZIEL, "beispiel": 1, "aussage": 2 * AUSSAGEN_JE_ATOM}
|
||
|
||
|
||
def _lebende(atom_id: int, typ: str) -> list[dict]:
|
||
return db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ=?"
|
||
" AND status!='verworfen'", (atom_id, typ))
|
||
|
||
|
||
def _aufgegeben(atom_id: int, typ: str) -> bool:
|
||
"""Anti-Churn: ab NACHFUELL_CAP × Ziel Verwürfen nicht mehr nachfüllen —
|
||
messen meldet dann `artefakt_aufgegeben` (sichtbar, aber kein Repair)."""
|
||
n = db.one("SELECT COUNT(*) AS n FROM artefakte WHERE atom_id=? AND typ=?"
|
||
" AND status='verworfen'", (atom_id, typ))["n"]
|
||
return n >= NACHFUELL_CAP * ZIEL_ZEILEN[typ]
|
||
|
||
|
||
def _gen_bedarf(atom_id: int) -> bool:
|
||
"""Braucht das Atom einen Artefakt-Generate-Call? (Flashcards unterm Ziel
|
||
oder Beispiel fehlt — jeweils solange nicht aufgegeben.)"""
|
||
if (len(_lebende(atom_id, "flashcard")) < FC_ZIEL
|
||
and not _aufgegeben(atom_id, "flashcard")):
|
||
return True
|
||
return not _lebende(atom_id, "beispiel") and not _aufgegeben(atom_id, "beispiel")
|
||
|
||
|
||
def _paar_lage(atom_id: int) -> tuple[int, int, int]:
|
||
"""→ (komplette lebende Paare, einseitig lebende Paare, höchster Paar-Index).
|
||
Der Index zählt über ALLE Zeilen (auch verworfene) — neue Paare dürfen alte
|
||
`paar`-Keys nie wiederverwenden."""
|
||
hoechster = -1
|
||
seiten: dict[str, set[bool]] = {}
|
||
for k in db.query("SELECT inhalt, status FROM artefakte WHERE atom_id=?"
|
||
" AND typ='aussage'", (atom_id,)):
|
||
inh = db.uj(k["inhalt"], {})
|
||
paar = str(inh.get("paar", ""))
|
||
try:
|
||
hoechster = max(hoechster, int(paar.rsplit("-", 1)[1]))
|
||
except (IndexError, ValueError):
|
||
pass
|
||
if k["status"] != "verworfen":
|
||
seiten.setdefault(paar, set()).add(bool(inh.get("wahr")))
|
||
komplett = sum(1 for s in seiten.values() if s == {True, False})
|
||
return komplett, len(seiten) - komplett, hoechster
|
||
|
||
|
||
def _paare_bereinigen(atom_ids: list[int]) -> None:
|
||
"""Unvollständige Paar-Gruppen verwerfen: die lebenden Seiten müssen genau
|
||
{wahr, falsch} ergeben. Fängt halbe Paare UND Deformierte (fehlender
|
||
paar-Key, doppelt gleiche Seite). Nachfüllen erzeugt nur KOMPLETTE Paare."""
|
||
for atom_id in atom_ids:
|
||
gruppen: dict[str, list[dict]] = {}
|
||
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='aussage'",
|
||
(atom_id,)):
|
||
paar = str(db.uj(k["inhalt"], {}).get("paar", ""))
|
||
gruppen.setdefault(paar, []).append(k)
|
||
for zeilen in gruppen.values():
|
||
lebend = [z for z in zeilen if z["status"] != "verworfen"]
|
||
seiten = {bool(db.uj(z["inhalt"], {}).get("wahr")) for z in lebend}
|
||
if lebend and seiten != {True, False}:
|
||
for z in lebend:
|
||
db.update("artefakte", "id", z["id"], status="verworfen")
|
||
|
||
|
||
def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
|
||
"""Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome mit Bedarf."""
|
||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
|
||
if nur_ohne:
|
||
atome = [a for a in atome if _gen_bedarf(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}
|
||
# Nachfüllen bis zum Ziel, nie darüber: das Template liefert immer voll
|
||
# (2 Karten + Beispiel), der Guard nimmt nur, was dem Atom noch fehlt.
|
||
fc_frei = {a["id"]: FC_ZIEL - len(_lebende(a["id"], "flashcard")) 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
|
||
if typ == "beispiel" and db.one(
|
||
"SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||
" AND status!='verworfen' LIMIT 1", (atom_id,)):
|
||
continue
|
||
if typ == "flashcard":
|
||
if fc_frei.get(atom_id, 0) <= 0:
|
||
continue
|
||
fc_frei[atom_id] -= 1
|
||
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:
|
||
# Aussagen haben ihr EIGENES Panel (+Paar-Logik) — landeten sie hier,
|
||
# überschrieb _fixen ihren Inhalt mit dem Flashcard-Schema (leere Hülle)
|
||
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'"
|
||
" AND typ!='aussage'", (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}
|
||
# Nachfüllen: nur fehlende Paare übernehmen; Index hinter ALLEN bestehenden
|
||
# Zeilen fortsetzen (alte `paar`-Keys — auch verworfene — nie wiederverwenden)
|
||
bedarf: dict[int, int] = {}
|
||
naechster: dict[int, int] = {}
|
||
for a in chunk:
|
||
komplett, _, hoechster = _paar_lage(a["id"])
|
||
bedarf[a["id"]] = max(0, AUSSAGEN_JE_ATOM - komplett)
|
||
naechster[a["id"]] = hoechster + 1
|
||
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
|
||
if bedarf.get(atom_id, 0) <= 0:
|
||
continue
|
||
bedarf[atom_id] -= 1
|
||
i = naechster[atom_id]
|
||
naechster[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
|
||
|
||
entschieden: dict[int, str] = {}
|
||
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
|
||
entschieden[k["id"]] = "verifiziert" if all(votes) else "verworfen"
|
||
# Paare fallen GANZ: kippt eine Seite, fällt auch die andere (frisch
|
||
# verifiziert oder unentschieden) — ein halbes Paar prüft schief.
|
||
paar_von = {k["id"]: str(db.uj(k["inhalt"], {}).get("paar", "")) for k in kandidaten}
|
||
gefallen = {paar_von[kid] for kid, st in entschieden.items() if st == "verworfen"}
|
||
for k in kandidaten:
|
||
status = entschieden.get(k["id"])
|
||
if paar_von[k["id"]] in gefallen:
|
||
status = "verworfen"
|
||
if status:
|
||
db.update("artefakte", "id", k["id"], status=status)
|
||
|
||
|
||
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 _gen_bedarf(a["id"])]
|
||
if ohne:
|
||
await _generieren(ctx, ohne)
|
||
_formen_gate(ctx.topic, [a["id"] for a in chunk])
|
||
await _verifizieren(ctx, chunk)
|
||
_paare_bereinigen([a["id"] for a in chunk])
|
||
ohne_aussage = [a for a in chunk
|
||
if _paar_lage(a["id"])[0] < AUSSAGEN_JE_ATOM
|
||
and not _aufgegeben(a["id"], "aussage")]
|
||
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]:
|
||
"""Vollständigkeit lebend-basiert (kandidat zählt mit — der ist unterwegs und
|
||
wird als `artefakt_unentschieden` separat gemeldet). Ziel: 2 Flashcards,
|
||
1 Beispiel, AUSSAGEN_JE_ATOM komplette Paare je Atom; am Cap stattdessen
|
||
`artefakt_aufgegeben` (sichtbar, aber reparieren fasst es nicht mehr an)."""
|
||
befunde = []
|
||
|
||
def fehlt(atom: dict, typ: str, art: str) -> None:
|
||
if _aufgegeben(atom["id"], typ):
|
||
befunde.append({"art": "artefakt_aufgegeben", "item": f"{atom['id']}-{typ}",
|
||
"detail": atom["titel"]})
|
||
else:
|
||
befunde.append({"art": art, "item": str(atom["id"]), "detail": atom["titel"]})
|
||
|
||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||
" ('gemerged','verworfen')", (ctx.topic,))
|
||
for a in atome:
|
||
fc = len(_lebende(a["id"], "flashcard"))
|
||
if fc < FC_ZIEL:
|
||
fehlt(a, "flashcard",
|
||
"atom_ohne_flashcard" if fc == 0 else "flashcard_unvollstaendig")
|
||
if not _lebende(a["id"], "beispiel"):
|
||
fehlt(a, "beispiel", "atom_ohne_beispiel")
|
||
komplett, einseitig, _ = _paar_lage(a["id"])
|
||
if einseitig:
|
||
# immer reparierbar: _paare_bereinigen ist deterministisch (kein LLM),
|
||
# der Cap begrenzt nur die NACHgenerierung (ohne_aussage-Filter in _kette)
|
||
befunde.append({"art": "paar_unvollstaendig", "item": str(a["id"]),
|
||
"detail": a["titel"]})
|
||
elif komplett < AUSSAGEN_JE_ATOM:
|
||
fehlt(a, "aussage",
|
||
"atom_ohne_aussage" if komplett == 0 else "paar_unvollstaendig")
|
||
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
|
||
|
||
|
||
# Arten, bei denen bauen() etwas bewegen kann. aufgegeben (Cap erreicht) und
|
||
# unentschieden (Panel-Ausfall) allein → kein Lauf, sonst churnt der Auto-Loop leer.
|
||
_REPARIERBAR = {"atom_ohne_flashcard", "flashcard_unvollstaendig", "atom_ohne_beispiel",
|
||
"atom_ohne_aussage", "paar_unvollstaendig"}
|
||
|
||
|
||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||
ctx.ebene = EBENE
|
||
if not any(b["art"] in _REPARIERBAR for b in befunde):
|
||
return False
|
||
await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes
|
||
return True
|