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

View File

@@ -83,7 +83,9 @@ ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch m
FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig)
FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
MERGE_KANDIDAT_COS = 0.65 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
# (0.75 verpasste echte Dubletten bei cos 0.680.71, aak;
# Fehlurteile fängt das einstimmige Panel)
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
@@ -93,6 +95,7 @@ CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: p
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
AUSSAGEN_JE_ATOM = 2 # Prüfungs-PAARE je Atom (je Paar: 1 wahre + 1 falsche Aussage)
NACHFUELL_CAP = 2 # Nachfüllen endet bei verworfen ≥ Cap × Ziel (Anti-Churn)
# Faktenbasis = Quelltext-FENSTER um den Anker (nicht nur das Zitat): der Inhalt
# eines Verfahrens folgt meist NACH seiner Nennung (Pseudo-Code, Ablauf).
BELEG_FENSTER_VOR = 300 # Zeichen vor dem Anker-Span

View File

@@ -90,10 +90,22 @@ _TEX_UMLAUTE = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"
# \ss ist ein Kontrollwort und frisst das Folge-Leerzeichen: hei\ss t → heißt
_TEX_SZ = re.compile(r"\\ss(?![a-zA-Z])(?:\{\})?[ \t]?")
# Parameterlose Textmakros aus der Präambel: \newcommand{\PP}{\mathrm{P}}.
# Ohne Expansion bleibt \PP roh im Rumpf stehen — der Reader las daraus die
# „Klasse PP" (aak: P-Definition falsch betitelt, falsches Level, falscher
# Baustein). Eine Klammer-Schachtelungsebene reicht für \mathrm{P}-Fälle.
_TEX_MAKRO = re.compile(
r"\\(?:re)?newcommand\*?\{?\\([A-Za-z]+)\}?\{((?:[^{}]|\{[^{}]*\})*)\}")
def _tex_normalisieren(inhalt: str) -> str:
_, sep, rumpf = inhalt.partition(r"\begin{document}")
praeambel, sep, rumpf = inhalt.partition(r"\begin{document}")
if sep:
makros = {m.group(1): m.group(2) for m in _TEX_MAKRO.finditer(praeambel)
if "#" not in m.group(2)} # nur parameterlos
for name in sorted(makros, key=len, reverse=True): # \PP vor \P
rumpf = re.sub(rf"\\{name}(?![a-zA-Z])(?:\{{\}})?",
makros[name].replace("\\", "\\\\"), rumpf)
inhalt = rumpf
inhalt = inhalt.replace(r"\end{document}", "")
inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt)

View File

@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import agents
import artefakte
import db
import guide
import ledger
@@ -156,11 +157,17 @@ def state(topic: str):
raise HTTPException(404, "unbekanntes Topic")
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,))
atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,))
# Nenner = SOLL je Atom (2 Karten + 1 Beispiel + 4 Aussagen), Zähler =
# Verifizierte, je Typ aufs Ziel gekappt — so liest sich die Karte als
# Fortschritt Richtung Vollständigkeit, nicht als Zeilenzählung.
ziel_gesamt = sum(artefakte.ZIEL_ZEILEN.values())
fuer_atom = {}
for a in db.query(
"SELECT atom_id, SUM(status='verifiziert') AS ok, COUNT(*) AS n FROM artefakte"
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?) GROUP BY atom_id", (topic,)):
fuer_atom[a["atom_id"]] = {"verifiziert": a["ok"], "gesamt": a["n"]}
"SELECT atom_id, typ, SUM(status='verifiziert') AS ok FROM artefakte"
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)"
" GROUP BY atom_id, typ", (topic,)):
e = fuer_atom.setdefault(a["atom_id"], {"verifiziert": 0, "gesamt": ziel_gesamt})
e["verifiziert"] += min(a["ok"], artefakte.ZIEL_ZEILEN.get(a["typ"], 0))
return {
"topic": t, "run": run,
"ebenen": _ebenen_info(t, run),
@@ -171,7 +178,8 @@ def state(topic: str):
"soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" ORDER BY id", (topic,))
or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)),
"atome": [{**a, "artefakte": fuer_atom.get(a["id"], {"verifiziert": 0, "gesamt": 0})}
"atome": [{**a, "artefakte": fuer_atom.get(a["id"],
{"verifiziert": 0, "gesamt": ziel_gesamt})}
for a in atome],
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
"bausteine": db.query(

View File

@@ -26,6 +26,8 @@ GEWICHTE = {
"soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "atom_ohne_aussage": 1.5, "artefakt_unentschieden": 0.5,
"flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5,
"paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5,
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
"level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,