@@ -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 ' ] } \n Belege: \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 [ " tit el " ] } )
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 " ] , " beispi el" ) :
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