This commit is contained in:
team3
2026-07-13 16:11:00 +02:00
parent 9cd8e02e22
commit a8e8e077bd
14 changed files with 454 additions and 62 deletions

View File

@@ -13,7 +13,8 @@ import tempfile
import belege
import db
import llm
from config import ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN, VERIFY_PANEL
from config import (ARTEFAKT_CHUNK_ATOME, AUSSAGEN_JE_ATOM, BEISPIEL_FORMEN,
NACHFUELL_CAP, VERIFY_PANEL)
log = logging.getLogger("creator2.artefakte")
@@ -51,17 +52,78 @@ def _atom_block(a: dict) -> str:
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 ohne Artefakte."""
"""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:
# 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"],))]
atome = [a for a in atome if _gen_bedarf(a["id"])]
gruppen: dict = {}
for a in atome:
gruppen.setdefault(a["soll_id"], []).append(a)
@@ -91,6 +153,9 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
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()
@@ -103,12 +168,14 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
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
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")
@@ -117,8 +184,10 @@ async def _verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
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"],)):
# 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:
@@ -280,7 +349,14 @@ async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
role="guide", n=len(chunk), item=f"as{chunk[0]['id']}",
erwartet=list)
gueltig = {a["id"] for a in chunk}
zaehler: dict[int, int] = {}
# 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
@@ -290,8 +366,11 @@ async def _aussagen_generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
continue
if _referenziert_quelle({"text": f"{wahr} {falsch}"}):
continue
i = zaehler.get(atom_id, 0)
zaehler[atom_id] = i + 1
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,
@@ -334,28 +413,37 @@ async def _aussagen_verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
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
db.update("artefakte", "id", k["id"],
status="verifiziert" if all(votes) else "verworfen")
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 not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
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)
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"],))]
_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)
@@ -367,19 +455,37 @@ async def bauen(ctx: llm.Kontext) -> None:
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:
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"]),
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:
@@ -388,9 +494,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
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 befunde:
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