diff --git a/backend/artefakte.py b/backend/artefakte.py index f66dd6c..3f7f408 100644 --- a/backend/artefakte.py +++ b/backend/artefakte.py @@ -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 diff --git a/backend/config.py b/backend/config.py index 1321ace..1d042d6 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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.68–0.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 diff --git a/backend/korpus.py b/backend/korpus.py index 475edd9..73beb2f 100644 --- a/backend/korpus.py +++ b/backend/korpus.py @@ -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) diff --git a/backend/main.py b/backend/main.py index ce503ee..dd80b87 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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( diff --git a/backend/qa.py b/backend/qa.py index e829fb9..b4ac69b 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -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, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 187eca0..e2b779a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,7 @@ const neuName = ref('') const neuArt = ref('thema') const fehler = ref('') const theme = ref(localStorage.getItem('theme') || 'auto') +const guideRef = ref(null) // Header-Play-Button startet den Lernmodus im Guide // mobil (≤768px) startet die Navigation eingeklappt, sofern nie manuell gesetzt const navAuf = ref(localStorage.getItem('navAuf') !== null ? localStorage.getItem('navAuf') !== '0' @@ -209,6 +210,8 @@ function runBadge(run) { {{ state.run.grund }} {{ fehler }} +