diff --git a/DESIGN.md b/DESIGN.md index 931de23..b95136c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -19,15 +19,19 @@ E0 Korpus Themen: N Recherche-Sweeps (Curricula/Lehrbuch/Syllabus/Übungen) E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit Verbatim-Anker Dedup: Anker-Overlap (auto) → Embedding-Kandidaten → 2er-Panel (einstimmig) Soll-Zuordnung je Atom; Soll-Punkt ohne Atom → gezielte Nachextraktion -E2 Artefakte je Soll-Punkt-Gruppe: Flashcards+Beispiel pro Atom → Verify-Panel gegen Anker → Fix +E2 Artefakte je Soll-Punkt-Gruppe: Flashcards + 1 typisiertes Beispiel pro Atom + (Form: text|mathe|code|tabelle|diagramm, beste Passung) → Form-Gate + (Syntax/Struktur parse-only, kein Ausführen) → Verify-Panel gegen Anker → Fix E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen → Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge) → topologische Ordnung (Bausteine + Atome darin) -E3.5 Diagramme je Baustein: planen (welche+Typ: DAG-deterministisch | Judge) → Spec-JSON - (LLM, kein rohes Mermaid) → deterministisch → Mermaid → Parse-Gate → - Grounding-Panel. Optional; Platzierung macht der Guide-Writer per Marker. -E4 Guide je Baustein: Writer (Facts inline, Atom-Marker, ``) - → det. Checks → Prüfer → Fix; Assembly tauscht Marker gegen ```mermaid-Fence +E3.5 Diagramme je Baustein: Judge plant welche+Typ (state/flow/tree — Term-DAGs waren + nutzlos, raus) → Spec-JSON (LLM, kein rohes Mermaid) → deterministisch → + Mermaid → Parse-Gate → Grounding-Panel. Optional. +E4 Guide je Baustein: Writer (Facts inline, Atom-Marker; Ausgabe DELIMITED, nicht JSON + — LaTeX-Backslashes) → det. Checks → Prüfer → Fix (Fix-Cap gegen Churn). + Beispiel-/Diagramm-Marker setzt der Code DETERMINISTISCH (nicht der Writer); + Assembly tauscht Marker gegen ```-Fence/Tabelle. ``` Jede Ebene endet mit `qa.messen(ebene)`; `auto_loop` fährt QA→Repair bis Note 10,0, @@ -44,6 +48,8 @@ Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene. - `anker(atom_id, quelle_id, start, ende, zitat)` - `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)` - `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)` + — beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle|spec+mermaid); + code/tabelle/diagramm kommen verbatim via `` in den Guide (Assembly-Swap) - `lernziele(id, topic, text, soll_id, status)` - `bausteine(id, topic, ziel_id, titel, ord, status)` - `diagramme(id, topic, baustein_id, typ, quelle dag|llm, spec JSON, mermaid, status geplant|kandidat|verifiziert|verworfen|kein, befunde JSON)` diff --git a/backend/artefakte.py b/backend/artefakte.py index e213628..e7dbe08 100644 --- a/backend/artefakte.py +++ b/backend/artefakte.py @@ -3,13 +3,17 @@ Soll-Punkt-Gruppen (Kontext teilen spart Tokens — Lektion 52), verifiziert von 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 db +import diagramme import llm -from config import ARTEFAKT_CHUNK_ATOME, VERIFY_PANEL +from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL log = logging.getLogger("creator2.artefakte") @@ -25,8 +29,14 @@ _QUELLEN_REFERENZ = re.compile( re.IGNORECASE) +def _prosa_teile(inhalt: dict) -> str: + """Nur menschenlesbare Prosa fürs Quellen-Referenz-Gate — NICHT Code/Mermaid/ + 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(" ".join(str(v) for v in inhalt.values())) is not None + return _QUELLEN_REFERENZ.search(_prosa_teile(inhalt)) is not None def _zitate(atom_id: int) -> str: @@ -60,6 +70,21 @@ def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]: 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", "")), + "spec": e.get("spec") if isinstance(e.get("spec"), dict) else {}, + "mermaid": ""} + + 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", @@ -71,7 +96,10 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None: typ = str(e.get("typ", "")).strip() if atom_id not in gueltig or typ not in ("flashcard", "beispiel"): continue - inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")} + 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 @@ -129,7 +157,16 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N "belege": _zitate(k["atom_id"])}, erwartet=dict) if res: - inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort", "text")} + 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 @@ -151,10 +188,100 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N status="verifiziert" if eintrag.get("ok") else "verworfen") +_VERBATIM_FORMEN = ("code", "tabelle", "diagramm") # 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); der Diagramm-Zweig füllt `mermaid`. 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 + if form == "diagramm": + # Struktur-JSON → Mermaid (deterministisch) → Parse-Gate; wiederverwendet die + # Diagramm-Ebene. Der LLM schreibt NIE rohes Mermaid — nur die Spec. + spec = diagramme._spec_normalisieren(inh.get("spec") or {}, + (inh.get("spec") or {}).get("typ", "flow")) + if not spec["knoten"]: + return False, inh + mermaid = diagramme._spec_zu_mermaid(spec) + if not mermaid or 0 in diagramme._mermaid_fehler([{"id": 0, "mermaid": mermaid}]): + return False, inh + return True, {**inh, "spec": spec, "mermaid": mermaid} + return True, inh + + +def _formen_gate(topic: str) -> None: + """Kandidat-Beispiele durch ihr Form-Gate: Syntax/Struktur ungültig → verworfen + (fail-closed für den Inhalt; ein fehlendes Beispiel ist erlaubt, optional).""" + for b in db.query( + "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'", (topic,)): + 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 bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE neu = _chunks(ctx.topic, nur_ohne=True) await llm.alle(_generieren(ctx, c) for c in neu) + _formen_gate(ctx.topic) # form-spezifische Syntax/Struktur-Gates vor dem Panel alle = _chunks(ctx.topic, nur_ohne=False) await llm.alle(_verifizieren(ctx, c) for c in alle) diff --git a/backend/config.py b/backend/config.py index 5b52434..6e8c904 100644 --- a/backend/config.py +++ b/backend/config.py @@ -84,8 +84,13 @@ MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmi MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus) LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke +# braucht-Titel, die norm/kern nicht auflösen (Kompositum↔Expansion misst ~0.53 Cosinus, +# Embedding schlägt sie NIE vor — Lektion 28): Substring-Prefilter → 1 Judge (n=1, reversibel). +BRAUCHT_KANDIDATEN = 8 # Kandidaten-Cap je unaufgelöstem braucht-Titel (Lektion 34) # ── Ebene 2: Artefakte ──────────────────────────────────────────────────────── +BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle", "diagramm") # Worked-Example-Formen +CODE_SPRACHEN = ("python", "javascript") # Syntax-Gate parse-only (Container: python3, node) ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt) VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert @@ -102,15 +107,15 @@ BAUSTEIN_MAX_ATOME = 8 ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge) -# ── Ebene 3.5: Diagramme ────────────────────────────────────────────────────── -DIAGRAMM_MIN_KANTEN = 3 # ab so vielen internen braucht-Kanten → Abhängigkeitsdiagramm -DIAGRAMM_TYPEN = ("dependency", "state", "flow", "tree") +# ── Ebene 3.5: Diagramme (nur LLM: Automat/Ablauf/Baum — Term-DAGs waren nutzlos) ── +DIAGRAMM_TYPEN = ("state", "flow", "tree") # ── Ebene 4: Guide ──────────────────────────────────────────────────────────── SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom # ── Auto-Loop ───────────────────────────────────────────────────────────────── LOOP_MAX_ITER = 10 +FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung # ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ───────────────── TIMEOUTS = { @@ -119,6 +124,7 @@ TIMEOUTS = { "soll_konsens": (300, 2), "extraktion": (450, 0), "merge": (150, 8), + "braucht": (200, 0), "soll_zuordnung": (300, 8), "luecke": (450, 0), "artefakt": (450, 20), diff --git a/backend/db.py b/backend/db.py index c883e34..16b0079 100644 --- a/backend/db.py +++ b/backend/db.py @@ -105,7 +105,7 @@ CREATE INDEX IF NOT EXISTS idx_diagramme_baustein ON diagramme(baustein_id); CREATE TABLE IF NOT EXISTS sections( baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer', text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]', - qa_hash TEXT DEFAULT ''); + qa_hash TEXT DEFAULT '', fix_versuche INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS leitner( artefakt_id INTEGER PRIMARY KEY, box INTEGER NOT NULL DEFAULT 1, faellig TEXT DEFAULT (date('now')), historie TEXT DEFAULT '[]'); @@ -133,7 +133,8 @@ def _init_schema(con: sqlite3.Connection) -> None: "ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", "ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'", - "ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''"): + "ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''", + "ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0"): try: con.execute(zusatz) except sqlite3.OperationalError: diff --git a/backend/diagramme.py b/backend/diagramme.py index b78ba31..a67adf5 100644 --- a/backend/diagramme.py +++ b/backend/diagramme.py @@ -18,7 +18,7 @@ from pathlib import Path import db import llm -from config import DIAGRAMM_MIN_KANTEN, DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL +from config import DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL log = logging.getLogger("creator2.diagramme") @@ -61,26 +61,6 @@ def _atome_von(baustein_id: int) -> list[dict]: " ('gemerged','verworfen') ORDER BY ord", (baustein_id,)) -def _interne_kanten(topic: str, ids: set[int]) -> list[dict]: - """aktive braucht-Kanten, deren BEIDE Enden im Baustein liegen.""" - if len(ids) < 2: - return [] - return [k for k in db.query( - "SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'", (topic,)) - if k["von_atom"] in ids and k["zu_atom"] in ids] - - -# ── Spec (JSON-Zwischenrepräsentation) ──────────────────────────────────────── - -def _dag_spec(atome: list[dict], kanten: list[dict]) -> dict: - """Abhängigkeitsdiagramm direkt aus dem braucht-Graph. `von braucht zu` ⇒ Pfeil - zu→von (Voraussetzung zeigt auf das, was sie ermöglicht). Kein LLM.""" - knoten = [{"id": str(a["id"]), "label": a["titel"]} for a in atome] - kanten_spec = [{"von": str(k["zu_atom"]), "zu": str(k["von_atom"]), "label": ""} - for k in kanten] - return {"typ": "dependency", "knoten": knoten, "kanten": kanten_spec} - - # ── Kompilieren (deterministisch, kein LLM) ─────────────────────────────────── def _sid(raw) -> str: @@ -129,21 +109,6 @@ def _kompilieren(topic: str) -> None: # ── Planung (welche + Typ) ──────────────────────────────────────────────────── -def _planen(topic: str) -> None: - """Deterministischer Zweig: Baustein mit ≥DIAGRAMM_MIN_KANTEN internen - braucht-Kanten → Abhängigkeitsdiagramm aus dem DAG. Idempotent: Bausteine mit - bestehender Diagramm-Zeile werden übersprungen. Kein LLM, null Halluzination.""" - for b in _bausteine(topic): - if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)): - continue - atome = _atome_von(b["id"]) - ids = {a["id"] for a in atome} - kanten = _interne_kanten(topic, ids) - if len(kanten) >= DIAGRAMM_MIN_KANTEN: - db.insert("diagramme", topic=topic, baustein_id=b["id"], typ="dependency", - quelle="dag", spec=db.j(_dag_spec(atome, kanten)), status="geplant") - - def _atom_liste(atome: list[dict]) -> str: return "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in atome) @@ -175,7 +140,7 @@ async def _plan_judge(ctx: llm.Kontext) -> None: if res is None: return # Ausfall → nächste Runde erneut (kein 'kein'-Eintrag) typ = str(res.get("typ", "")).strip() - if res.get("noetig") and typ in DIAGRAMM_TYPEN and typ != "dependency": + if res.get("noetig") and typ in DIAGRAMM_TYPEN: db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ=typ, quelle="llm", status="geplant") else: @@ -254,16 +219,11 @@ async def _verifizieren(ctx: llm.Kontext) -> None: async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE - _planen(ctx.topic) # welche/Typ: deterministisch (DAG) - await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume) + await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume) — kein DAG mehr await _spec_bauen(ctx) # LLM → Struktur-JSON (kein rohes Mermaid) _kompilieren(ctx.topic) # Spec → Mermaid (deterministisch) _gate_kandidaten(ctx.topic) # Parse-Gate: ungültige Mermaid-Quelle → verworfen - # dag-Diagramme sind per Konstruktion gegroundet → nach bestandenem Gate verifiziert - for d in db.query("SELECT id FROM diagramme WHERE topic=? AND status='kandidat'" - " AND quelle='dag'", (ctx.topic,)): - db.update("diagramme", "id", d["id"], status="verifiziert") - await _verifizieren(ctx) # LLM-Diagramme: Grounding-Panel gegen die Atome + await _verifizieren(ctx) # Grounding-Panel gegen die Atome def _gate_kandidaten(topic: str) -> None: diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 65084c4..b9f4c84 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -137,14 +137,24 @@ def _luecke(prompt: str): def _artefakt_generate(prompt: str): - ids = [int(i) for i in re.findall(r"ATOM (\d+):", prompt)] + # Form je Atom-Typ [in Klammern] wählen (wie die typgesteuerte Regel im Prompt) + paare = re.findall(r"ATOM (\d+):[^\[\n]*\[(\w+)\]", prompt) out = [] - for i in ids: + for raw, typ in paare: + i = int(raw) out += [{"atom": i, "typ": "flashcard", "frage": f"Was besagt Atom {i}?", "antwort": "Siehe Beleg — deterministische Fake-Antwort."}, {"atom": i, "typ": "flashcard", "frage": f"Wozu dient Atom {i}?", - "antwort": "Zum Lernen — deterministische Fake-Antwort."}, - {"atom": i, "typ": "beispiel", "text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."}] + "antwort": "Zum Lernen — deterministische Fake-Antwort."}] + if typ == "verfahren": + out.append({"atom": i, "typ": "beispiel", "form": "code", + "sprache": "python", "code": f"print({i} + 1)"}) + elif typ == "begriff": + out.append({"atom": i, "typ": "beispiel", "form": "tabelle", + "tabelle": f"| Fall | Ergebnis |\n|---|---|\n| Atom {i} | passt |"}) + else: # aussage → Text-Beispiel + out.append({"atom": i, "typ": "beispiel", "form": "text", + "text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."}) return out @@ -196,24 +206,40 @@ def _zyklus(prompt: str): return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {} +def _braucht_aufloesung(prompt: str): + # je ANGABE den Kandidaten mit den meisten geteilten Tokens (Substring, ≥4 Zeichen) + # wählen, sonst null — spiegelt den Substring-Prefilter der Auflösung. + def toks(s): + return {t for t in re.findall(r"\w+", s.lower()) if len(t) >= 4} + out = [] + bloecke = re.split(r"ANGABE (\d+):", prompt)[1:] + for i in range(0, len(bloecke), 2): + n, body = int(bloecke[i]), bloecke[i + 1] + mp = re.search(r"Voraussetzung „([^“]+)", body) + ph = mp.group(1) if mp else "" + pl, pt = ph.lower(), toks(ph) + ranked = [] + for kid, ktitel in re.findall(r"- atom (\d+): (.+?) —", body): + geteilt = {t for t in toks(ktitel) if t in pl} | {t for t in pt if t in ktitel.lower()} + if geteilt: + ranked.append((-len(geteilt), len(ktitel), int(kid))) + ranked.sort() + out.append({"phrase": n, "atom": ranked[0][2] if ranked else None}) + return out + + def _writer(prompt: str): ziel = re.search(r"Lernziel: (.+)", prompt) marker = re.findall(r"MARKER \(exakt so übernehmen\): ()", prompt) defs = re.findall(r"Definition: (.+)", prompt) - fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2] lo = int(re.search(r"hat (\d+)–", prompt).group(1)) if re.search(r"hat (\d+)–", prompt) else 40 absaetze = [f"Ziel dieser Einheit: {ziel.group(1) if ziel else 'Lernen'}. Formal: $n \\ge 0$."] fuellung = "Daran anknüpfend gehen wir das Schritt für Schritt am Beispiel durch. " * 5 - diag = re.findall(r"()", prompt) # angebotene Diagramme - for n, ((mk, titel), d) in enumerate(zip(marker, defs)): - block = f"#### {titel} verstehen\n{mk}\n{d} {fuellung}" - if n == 0 and diag: # erstes Diagramm beim ersten Absatz platzieren - block += f"\n\n{diag[0]}" - absaetze.append(block) - absaetze.append("**Kernpunkte:**\n" + "\n".join(f"- {t} sitzt" for _, t in marker)) - if fragen: - absaetze.append("**Prüfe dich:**\n" + "\n".join(f"- {f}" for f in fragen)) + for (mk, titel), d in zip(marker, defs): + # Beispiel-/Diagramm-Marker setzt jetzt _marker_platzieren deterministisch, + # nicht mehr der Writer. + absaetze.append(f"#### {titel} verstehen\n{mk}\n{d} {fuellung}") lang = "\n\n".join(absaetze) while len(lang.split()) < lo: lang += "\nNoch ein Übungssatz zur Vertiefung des Gelernten." @@ -222,8 +248,8 @@ def _writer(prompt: str): def _diagramm_plan(prompt: str): - # deterministisch: die Fake-Welt braucht kein LLM-Diagramm (DAG-Pfad wird - # getestet). Unit-Tests für den LLM-Zweig monkeypatchen diesen Handler. + # deterministisch: die Fake-Welt braucht kein Diagramm. Unit-Tests für den + # LLM-Diagramm-Zweig monkeypatchen diesen Handler auf noetig=True. return {"noetig": False, "typ": ""} @@ -269,7 +295,7 @@ _HANDLER = { "Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify, "Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, - "Kanten-Zyklus": _zyklus, + "Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check, "Diagramm-Plan": _diagramm_plan, "Diagramm-Spec": _diagramm_spec, diff --git a/backend/guide.py b/backend/guide.py index 79fbc75..8a21496 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -15,9 +15,10 @@ import subprocess from pathlib import Path import db +import jsonx import llm import textkit -from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM +from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM log = logging.getLogger("creator2.guide") @@ -55,11 +56,15 @@ _MARKER = re.compile(r"") _MARKER_DIAGRAMM = re.compile(r"") +_MARKER_BEISPIEL = re.compile(r"") # Kurzbeschreibung je Diagramm-Typ (Writer-Angebot) _DIAGRAMM_BESCHR = {"dependency": "Abhängigkeitsgraph der Begriffe dieses Bausteins", "state": "Zustandsdiagramm/Automat", "flow": "Ablaufdiagramm", "tree": "Baumstruktur"} +_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich", + "diagramm": "Diagramm-Beispiel"} +_VERBATIM_FORMEN = ("code", "tabelle", "diagramm") def _ohne_marker(text: str) -> str: @@ -67,8 +72,77 @@ def _ohne_marker(text: str) -> str: ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26" unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop). - Diagramm-Marker gehören ebenso raus.""" - return _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text)) + Diagramm- und Beispiel-Marker gehören ebenso raus.""" + return _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text))) + + +def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]: + """Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle/diagramm) — + (artefakt_id, inhalt). Woven-Formen (text/mathe) gehen über `_beispiel`.""" + out = [] + for r in db.query("SELECT id, inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'" + " AND status='verifiziert'", (atom_id,)): + inh = db.uj(r["inhalt"], {}) + if inh.get("form") in _VERBATIM_FORMEN: + out.append((r["id"], inh)) + return out + + +def _beispiel_render(inh: dict) -> str: + """Verbatim-Beispiel → Markdown (Code-Fence / Tabelle / Mermaid-Fence).""" + form = inh.get("form") + if form == "code" and inh.get("code"): + return f"```{inh.get('sprache', '')}\n{inh['code']}\n```" + if form == "tabelle" and inh.get("tabelle"): + return inh["tabelle"] + if form == "diagramm" and inh.get("mermaid"): + return f"```mermaid\n{inh['mermaid']}\n```" + return "" + + +def _marker_platzieren(b: dict, lang: str) -> str: + """Verbatim-Marker DETERMINISTISCH setzen (der Writer tut es nicht mehr — er + ignorierte die Regel, 2/13 platziert). Beispiel-Marker hinter den Atom-Absatz + (atom-genau via Atom-Marker); Diagramme (baustein-scoped) ans Section-Ende. + Idempotent: alte Verbatim-Marker erst raus.""" + lang = _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", lang)) + je_atom = {} + for a in _atome_von(b["id"]): + vs = _beispiel_verbatim(a["id"]) + if vs: + je_atom[a["id"]] = vs[0][0] # erstes Verbatim-Beispiel des Atoms + if je_atom: + out, pending = [], None + for z in lang.split("\n"): + out.append(z) + m = _MARKER.search(z) + if m and int(m.group(1)) in je_atom: + pending = je_atom.pop(int(m.group(1))) + elif pending is not None and z.strip() == "": # Absatzende → Marker als Block + out += [f"", ""] + pending = None + if pending is not None: + out += ["", f""] + lang = "\n".join(out) + diags = _diagramme_von(b["id"]) + if diags: + block = "\n".join(f"" for d in diags) + lang = lang.rstrip() + "\n\n" + block + return lang + + +def _beispiel_einsetzen(baustein_id: int, lang: str) -> str: + """Assembly: `` → gerendertes Verbatim-Beispiel; tote Marker weg.""" + if " — {_DIAGRAMM_BESCHR.get(d['typ'], d['typ'])}" - for d in _diagramme_von(baustein_id)] - return "\n".join(zeilen) or "(keine)" - - def _diagramm_einsetzen(baustein_id: int, lang: str) -> str: """Assembly: `` → ```mermaid-Fence des verifizierten Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg.""" @@ -117,15 +184,14 @@ def _zitate(atom_id: int, max_n: int = 3) -> list[str]: def _beispiel(atom_id: int) -> str: + """Nur die WOVEN-Formen (text/mathe) als Prosa für den Writer. Verbatim-Formen + (code/tabelle/diagramm) kommen über den ``-Marker rein.""" row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'" " AND status='verifiziert' LIMIT 1", (atom_id,)) - return db.uj(row["inhalt"], {}).get("text", "") if row else "" - - -def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]: - rows = db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='flashcard'" - " AND status='verifiziert' LIMIT ?", (atom_id, max_n)) - return [f for r in rows if (f := db.uj(r["inhalt"], {}).get("frage", "").strip())] + if not row: + return "" + inh = db.uj(row["inhalt"], {}) + return inh.get("text", "") if inh.get("form", "text") in ("text", "mathe") else "" def _atom_paket(a: dict) -> str: @@ -135,9 +201,6 @@ def _atom_paket(a: dict) -> str: f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}") if beispiel: teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}" - fragen = _fragen_pool(a["id"]) - if fragen: - teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen) return teil @@ -170,6 +233,25 @@ def _anknuepf_kontext(b: dict) -> str: # ── Stages ──────────────────────────────────────────────────────────────────── +def _split_writer(text: str) -> tuple[str, str]: + """Delimited-Ausgabe des Writers/Fix parsen: ===LANG===… / ===KOMPAKT===…. + Grund: rohe LaTeX-Backslashes im Text sind invalides JSON (34 % Parse-Fehler). + Robust: Blöcke unabhängig von der Reihenfolge; fehlt der Delimiter, jsonx-Fallback + (alt-JSON), sonst ganzer Text = lang.""" + def block(name: str) -> str | None: + m = re.search(rf"===\s*{name}\s*===\s*(.*?)(?=\n===\s*[A-ZÄÖÜ]+\s*===|$)", + text or "", re.S) + return m.group(1).strip() if m else None + + lang, kompakt = block("LANG"), block("KOMPAKT") + if lang is not None or kompakt is not None: + return kompakt or "", lang or "" + daten = jsonx.parse(text or "") # Fallback: altes JSON-Format + if isinstance(daten, dict): + return str(daten.get("kompakt", "")), str(daten.get("lang", "")) + return "", (text or "").strip() + + async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} @@ -178,20 +260,22 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str: "atome": "\n\n".join(_atom_paket(a) for a in atome), "min_woerter": lo, "max_woerter": hi, "durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]), - "kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)", - "diagramme": _diagramm_angebot(b["id"])} + "kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"} res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte, - role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict) + role="guide", n=len(atome), item=f"b{b['id']}", erwartet=str) if res is None: return "writer" # Stage bleibt, nächster Lauf versucht erneut - kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", "")) + kompakt, lang = _split_writer(res) fehlend = _marker_fehlend(lang, atome) if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer", schritt="writer", role="guide", n=len(atome), - item=f"b{b['id']}-2", werte=werte, erwartet=dict) - if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome): - kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", "")) + item=f"b{b['id']}-2", werte=werte, erwartet=str) + if res2: + k2, l2 = _split_writer(res2) + if not _marker_fehlend(l2, atome): + kompakt, lang = k2, l2 + lang = _marker_platzieren(b, lang) # Beispiel-/Diagramm-Marker deterministisch setzen db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang) return "pruefer" @@ -378,7 +462,9 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]: if je_baustein.get(a["bid"], 0) <= mein_kapitel: continue t = textkit.norm(a["titel"]) - if len(t) < 6 and len(t.split()) < 2: + # nur SIGNIFIKANTE Titel flaggen: ≥2 Token UND ≥8 Zeichen. Kurze Fachwörter + # (oft Homonyme/gängige Wörter) produzierten unschließbare Dauer-Flags. + if len(t.split()) < 2 or len(t) < 8: continue if re.search(rf"(? str: werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"], "auftraege": "\n".join(f"- {a}" for a in auftraege), "fakten": fakten}, - erwartet=dict) + erwartet=str) kritisch = any(a.startswith("KRITISCH") for a in auftraege) if res: - lang = str(res.get("lang", "")) + kompakt, lang = _split_writer(res) if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen + lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen db.update("sections", "baustein_id", b["id"], - text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([])) + text_kompakt=kompakt, text_lang=lang, befunde=db.j([])) if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA await _stage_pruefer(ctx, b, tag="-re") return "done" @@ -496,23 +583,6 @@ async def bauen(ctx: llm.Kontext) -> None: await _kapitel_intros(ctx) -def _pruefe_dich(baustein_id: int, lang: str) -> list[dict]: - """Antworten zu den „Prüfe dich"-Fragen: die Fragen stammen wörtlich aus dem - Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten - (Retrieval mit Feedback wirkt ~doppelt so stark wie ohne).""" - out = [] - lang_norm = textkit.norm(lang) # Case/Whitespace-tolerant — ein Stil-Fix an der - for a in _atome_von(baustein_id): # Frage ließ das rohe Substring-Matching scheitern - for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND" - " typ='flashcard' AND status='verifiziert'", (a["id"],)): - fc = db.uj(r["inhalt"], {}) - frage = str(fc.get("frage", "")).strip() - antwort = str(fc.get("antwort", "")).strip() - if frage and antwort and textkit.norm(frage) in lang_norm: - out.append({"frage": frage, "antwort": antwort}) - return out - - def kapitel_struktur(topic: str) -> list[dict]: """Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen.""" @@ -534,15 +604,16 @@ def kapitel_struktur(topic: str) -> list[dict]: "titel": k.get("titel", "Weitere Themen"), "intro": k.get("intro", ""), "level": k.get("level", b["level"]), "sections": []}) - # Diagramm-Marker gegen den ```mermaid-Fence tauschen (Assembly-Schritt); + # Diagramm- und Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly); # Atom-Marker bleiben — die strippt erst das Frontend. - lang = _diagramm_einsetzen(b["id"], sec["text_lang"]) if sec else "" + lang = "" + if sec: + lang = _beispiel_einsetzen(b["id"], _diagramm_einsetzen(b["id"], sec["text_lang"])) kapitel[-1]["sections"].append({ "baustein": b["id"], "titel": b["titel"], "ziel": ziele.get(b["ziel_id"], {}).get("text", ""), "kompakt": sec["text_kompakt"] if sec else "", - "lang": lang, - "fragen": _pruefe_dich(b["id"], lang) if lang else []}) + "lang": lang}) # NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere # kapitellose Gruppen kollidierten sonst im Vue-:key (null == null). for i, kap in enumerate(kapitel): @@ -593,6 +664,12 @@ def messen(ctx: llm.Kontext) -> list[dict]: befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]), "detail": f"Stray Diagramm-Marker " f" entfernen (kein solches verifiziertes Diagramm)"}) + gueltige_b = {aid for a in atome for aid, _ in _beispiel_verbatim(a["id"])} + for m in _MARKER_BEISPIEL.findall(lang): + if int(m) not in gueltige_b: # Marker ohne verifiziertes Verbatim-Beispiel + befunde.append({"art": "beispiel_marker_tot", "item": str(b["id"]), + "detail": f"Stray Beispiel-Marker " + f" entfernen (kein solches verifiziertes Beispiel)"}) for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True): befunde.append({"art": "det_check", "item": str(b["id"]), "detail": auftrag[:300]}) @@ -611,7 +688,7 @@ def messen(ctx: llm.Kontext) -> list[dict]: befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]), "detail": ziel["text"][:120]}) lo, hi = _laenge_band(len(atome)) - w = len(lang.split()) + w = len(_ohne_marker(lang).split()) # marker-frei — konsistent mit dem Fix-Band if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75) befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"}) for t in _vorwaertsverweise(ctx.topic, b, lang): @@ -703,12 +780,21 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: betroffen.setdefault(b_id, []).append(b) if not betroffen: return False + geroutet: list[int] = [] for b_id, liste in betroffen.items(): arten = [x["art"] for x in liste] # ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch kritisch_detail = any(x["art"] == "fix_offen" and str(x["detail"]).startswith("KRITISCH") for x in liste) - if any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail: + kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail + if not kritisch: + # Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird + # eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts- + # Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn. + sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,)) + if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE: + continue + if kritisch: if "section_fehlt" in arten: db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,)) else: @@ -730,6 +816,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: auftraege = list(dict.fromkeys(vorhanden + neu)) db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege)) db.update("bausteine", "id", b_id, status="repair") - vorher = {b_id: _text_sig(b_id) for b_id in betroffen} + geroutet.append(b_id) + vorher = {b_id: _text_sig(b_id) for b_id in geroutet} await bauen(ctx) - return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen) + bewegt = False + for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1 + if _text_sig(b_id) != vorher[b_id]: + db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,)) + bewegt = True + else: + db.execute("UPDATE sections SET fix_versuche=fix_versuche+1 WHERE baustein_id=?", + (b_id,)) + return bewegt diff --git a/backend/inventar.py b/backend/inventar.py index 561eb77..79f43c4 100644 --- a/backend/inventar.py +++ b/backend/inventar.py @@ -14,8 +14,8 @@ import embedding import korpus import llm import textkit -from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS, - MERGE_KANDIDAT_JACCARD, +from config import (ANKER_OVERLAP_MERGE, BRAUCHT_KANDIDATEN, LUECKEN_RUNDEN_MAX, + MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD, MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME) log = logging.getLogger("creator2.inventar") @@ -266,6 +266,101 @@ def _kanten_aufloesen(topic: str) -> None: " VALUES(?,?,?,'braucht')", (topic, a["id"], ziel)) +def _braucht_unaufgeloest(atome: list[dict]) -> list[tuple[dict, str]]: + """(Atom, braucht-Titel), die _kanten_aufloesen (norm/kern) NICHT traf. Selbe + Auflösungslogik — was sie fand, ist erledigt und bleibt außen vor.""" + je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome} + je_kern: dict[str, int] = {} + for a in atome: + if kern := textkit.titel_kern(a["titel"]): + je_kern.setdefault(kern, a["id"]) + offen = [] + for a in atome: + for titel in db.uj(a["braucht"]): + ziel = je_norm.get(textkit.norm(titel)) + if not ziel and (kern := textkit.titel_kern(titel)): + ziel = je_kern.get(kern) + if not ziel or ziel == a["id"]: + offen.append((a, titel)) + return offen + + +def _trigramm(s: str) -> set[str]: + s = textkit.norm(s) + return {s[i:i + 3] for i in range(len(s) - 2)} or {s} + + +def _tri_dice(a: str, b: str) -> float: + """Zeichen-Trigramm-Dice — fuzzy Titel-Ähnlichkeit, die geteilte Wortstämme UND + Kopfnomen erfasst („Approximationsalgorithmus" ~ „Approximativer Algorithmus").""" + A, B = _trigramm(a), _trigramm(b) + return 2 * len(A & B) / (len(A) + len(B)) if A or B else 0.0 + + +def _braucht_kandidaten(titel: str, atome: list[dict], selbst_id: int, cap: int) -> list[dict]: + """Expliziter Kandidatengenerator für Kompositum↔Expansion (Lektion 28: Cosinus + ~0.53, Embedding schlägt sie nie vor). Zulassung (hohe Trefferquote): ein + signifikantes Token (≥5 Zeichen) ist Substring des anderen normierten Titels + („algorithmus" ⊂ „approximationsalgorithmus"). Rang nach Trigramm-Dice — der + ganze Titel zählt, nicht nur das Kopfnomen, so steht der Grundbegriff vor + generischen Distraktoren („Algorithmus"). Der Judge trennt dann fein.""" + nt = textkit.norm(titel) + bt = {t for t in textkit.tokens(titel) if len(t) >= 5} + treffer = [] + for a in atome: + if a["id"] == selbst_id: + continue + na = textkit.norm(a["titel"]) + at = {t for t in textkit.tokens(a["titel"]) if len(t) >= 5} + if {t for t in at if t in nt} | {t for t in bt if t in na}: + treffer.append((_tri_dice(titel, a["titel"]), a["id"], a)) + treffer.sort(key=lambda x: (-x[0], x[1])) + return [a for _, _, a in treffer[:cap]] + + +async def _braucht_fallback(ctx: llm.Kontext) -> None: + """braucht-Titel, die norm/kern nicht auflösten, per Judge auf Atome mappen. + Deterministischer Substring-Prefilter (Kandidaten) → EIN Judge (n=1) als + Präzisions-Gate. braucht-Kanten sind reversibel/billiger als Merges → kein Panel + (Lektion 78). Im Zweifel null: eine Falsch-Kante verschiebt Ordnung + Level. + Idempotent (INSERT OR IGNORE); läuft nur in bauen, damit ein Bausteine-Reset ihn + ohne Re-Extraktion mitnimmt.""" + topic = ctx.topic + atome = aktive_atome(topic) + offen = _braucht_unaufgeloest(atome) + if not offen: + return + angaben = [(a, titel, kand) for a, titel in offen + if (kand := _braucht_kandidaten(titel, atome, a["id"], BRAUCHT_KANDIDATEN))] + if not angaben: + log.info("braucht-Fallback: %d unaufgelöste Titel, keine Kandidaten", len(offen)) + return + geloest = 0 + + async def chunk_loesen(chunk: list) -> None: + nonlocal geloest + liste = "\n\n".join( + f"ANGABE {n}: Voraussetzung „{titel}“ (gebraucht von Atom {a['id']}" + f" „{a['titel']}“)\nKandidaten:\n" + + "\n".join(f" - atom {k['id']}: {k['titel']} — {k['definition']}" for k in kand) + for n, (a, titel, kand) in enumerate(chunk, 1)) + res = await llm.call(ctx, stage="braucht", template="Braucht-Aufloesung", + werte={"angaben": liste}, role="judge", + n=len(chunk), item=f"br{chunk[0][0]['id']}", erwartet=list) + wahl = {e.get("phrase"): e.get("atom") for e in res or [] if isinstance(e, dict)} + for n, (a, titel, kand) in enumerate(chunk, 1): + zid = wahl.get(n) + if zid in {k["id"] for k in kand} and zid != a["id"]: + db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)" + " VALUES(?,?,?,'braucht')", (topic, a["id"], zid)) + geloest += 1 + + chunks = [angaben[i:i + PAAR_CHUNK] for i in range(0, len(angaben), PAAR_CHUNK)] + await llm.alle(chunk_loesen(c) for c in chunks) + log.info("braucht-Fallback: %d/%d Titel aufgelöst (%d ohne Kandidaten)", + geloest, len(offen), len(offen) - len(angaben)) + + # ── Soll-Zuordnung ──────────────────────────────────────────────────────────── async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None: diff --git a/backend/qa.py b/backend/qa.py index a4f8af9..b485db1 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -36,6 +36,7 @@ GEWICHTE = { "det_check": 0.5, "fix_offen": 1.5, "diagramm_parse_fehler": 3.0, "diagramm_ungegroundet": 3.0, "diagramm_unverifiziert": 0.5, "diagramm_marker_tot": 3.0, + "beispiel_marker_tot": 3.0, } diff --git a/backend/struktur.py b/backend/struktur.py index 2cdbfbd..c2c7788 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -9,6 +9,7 @@ import logging from collections import defaultdict import db +import inventar import llm from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG, KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME) @@ -465,6 +466,8 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None: async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE await _ziele_bilden(ctx) + await inventar._braucht_fallback(ctx) # Kompositum↔Expansion-braucht-Titel nachziehen, + # bevor Zyklen/Level auf dem Graph rechnen await _zyklen_brechen(ctx) await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine _level_konflikte_loesen(ctx.topic) diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue index d77abc5..9f23cb2 100644 --- a/frontend/src/components/Guide.vue +++ b/frontend/src/components/Guide.vue @@ -46,14 +46,6 @@ function html(s) { return htmlCache.get(key) } -// „Prüfe dich"-Antworten ebenfalls cachen — sonst rechnet jeder Scroll-Tick -// Markdown+KaTeX für alle sichtbaren Antworten neu (genau der Kostenpfad oben). -function htmlAntwort(s, fi) { - const key = `a|${s.baustein}|${fi}` - if (!htmlCache.has(key)) htmlCache.set(key, render(s.fragen[fi].antwort)) - return htmlCache.get(key) -} - // Mermaid rendert asynchron ins DOM — nach jedem Einblenden (Scroll/Level/Ansicht) // die noch nicht verarbeiteten ```mermaid-Blöcke im sichtbaren Bereich rendern. async function renderMermaid() { @@ -124,7 +116,7 @@ watch([level, ansicht, daten], () => { nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = 0 }) }) -// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text) +// Lesezeit des SICHTBAREN Umfangs: 100 Wörter/min (technischer Text) // + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe) const stats = computed(() => { const je = anzeige.value.map((kap) => { @@ -135,7 +127,7 @@ const stats = computed(() => { woerter += st.woerter formeln += st.displayFormeln } - return { woerter, minuten: woerter / 150 + (formeln * 5) / 60 } + return { woerter, minuten: woerter / 100 + (formeln * 5) / 60 } }) return { je, @@ -219,13 +211,6 @@ function onScroll(e) {