Files
creator2/backend/artefakte.py
2026-07-13 16:11:00 +02:00

509 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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