From cde1721d10b1d28aba452006753310d496477757 Mon Sep 17 00:00:00 2001 From: team3 Date: Sun, 12 Jul 2026 20:02:13 +0200 Subject: [PATCH] update --- DESIGN.md | 18 ++- backend/artefakte.py | 135 ++++++++++++++++++- backend/config.py | 12 +- backend/db.py | 5 +- backend/diagramme.py | 48 +------ backend/fake_agents.py | 60 ++++++--- backend/guide.py | 207 ++++++++++++++++++++++-------- backend/inventar.py | 99 +++++++++++++- backend/qa.py | 1 + backend/struktur.py | 3 + frontend/src/components/Guide.vue | 19 +-- frontend/src/markdown.js | 2 +- frontend/src/style.css | 4 +- templates/Artefakt-Fix.md | 7 +- templates/Artefakt-Generate.md | 21 ++- templates/Braucht-Aufloesung.md | 23 ++++ templates/Guide-Fix.md | 13 +- templates/Guide-Writer.md | 32 +++-- tests/test_artefakte.py | 101 +++++++++++++++ tests/test_bausteine.py | 55 +++++--- tests/test_diagramme.py | 54 ++++---- tests/test_e2e.py | 6 +- tests/test_struktur.py | 25 ++++ 23 files changed, 731 insertions(+), 219 deletions(-) create mode 100644 templates/Braucht-Aufloesung.md 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) {

{{ s.titel }}

-
- Antworten zu „Prüfe dich" -
-
{{ f.frage }}
-
-
-
diff --git a/frontend/src/markdown.js b/frontend/src/markdown.js index 22ff1d4..c0eb0e4 100644 --- a/frontend/src/markdown.js +++ b/frontend/src/markdown.js @@ -64,7 +64,7 @@ const MARKER = //g export function lesestat(text) { let displayFormeln = 0 const t = (text || '') - .replace(/```mermaid[\s\S]*?```/g, ' ') // Diagramm-Quelltext zählt nicht als Text + .replace(/```[\s\S]*?```/g, ' ') // Code-/Diagramm-Fences zählen nicht als Text .replace(MARKER, ' ') .replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' }) .replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' }) diff --git a/frontend/src/style.css b/frontend/src/style.css index 5d1ac62..6dde314 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -232,8 +232,10 @@ body.nav-zu .hauptbereich { padding-top: 38px; } .markdown blockquote p { margin: 4px 0; } .markdown :is(code):not(.hljs) { background: var(--karte); padding: 1px 5px; border-radius: 4px; } .markdown pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; } -.markdown table { border-collapse: collapse; } +.markdown table { border-collapse: collapse; display: block; overflow-x: auto; max-width: 100%; } .markdown td, .markdown th { border: 1px solid var(--rand); padding: 4px 10px; } +.markdown thead th { background: var(--karte); text-align: left; } +.markdown tbody tr:nth-child(even) { background: color-mix(in srgb, var(--karte) 45%, transparent); } .flash { background: var(--panel); border: 1px solid var(--rand); border-radius: 10px; diff --git a/templates/Artefakt-Fix.md b/templates/Artefakt-Fix.md index 474646c..184d4d8 100644 --- a/templates/Artefakt-Fix.md +++ b/templates/Artefakt-Fix.md @@ -12,5 +12,8 @@ BELEGE (einzige erlaubte Faktenbasis — nichts erfinden): Antworte NUR mit JSON (keine Code-Fences). Für flashcard: {{"frage": "…", "antwort": "…"}} -Für beispiel: -{{"text": "…"}} +Für beispiel: BEHALTE die Form aus dem Artefakt oben und gib die passenden Felder zurück: +- form "text"/"mathe": {{"text": "…"}} +- form "code": {{"sprache": "python", "code": "…"}} +- form "tabelle": {{"tabelle": "| … | … |\n|---|---|\n| … | … |"}} +- form "diagramm": {{"spec": {{"knoten": […], "kanten": […]}}}} diff --git a/templates/Artefakt-Generate.md b/templates/Artefakt-Generate.md index 5b44370..e4dba31 100644 --- a/templates/Artefakt-Generate.md +++ b/templates/Artefakt-Generate.md @@ -4,10 +4,21 @@ Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate). Erzeuge pro Atom: - 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren. -- 1 Worked Example (typ "beispiel", Feld "text"): ein EINFACHES durchgerechnetes - Beispiel — die kleinste Instanz, die das Atom klärt. Keine mehrstufigen oder - verschachtelten Konstruktionen. Alle Werte/Aussagen müssen aus den Belegen ableitbar - sein. Wenn die Belege KEIN sinnvolles einfaches Beispiel hergeben, weglassen. +- Ggf. 1 Worked Example (typ "beispiel"): die kleinste Instanz, die das Atom klärt. + Alle Werte/Aussagen aus den Belegen ableitbar. Concreteness Fading: erst konkret, + dann kurz verallgemeinern. Auswahl EINDEUTIG und MINIMAL — keine Oberflächen- + Überlappung zwischen Aufgabe und Lösung (sonst Fehlvorstellungen). + + OB und WELCHE Form richtet sich nach dem Atom-Typ [in eckigen Klammern]: + - [verfahren] → FAST IMMER ein Beispiel. Form "code" (Felder "sprache" ∈ + {{python, javascript}}, "code") bei Programmier-/Algorithmus-Konzepten, sonst + "text" als durchgerechneter Schritt-für-Schritt-Ablauf. + - [begriff] → NUR wenn abstrakt/schwer greifbar. Dann "text"/"mathe" (konkrete + Instanz) oder "tabelle" (Fälle/Vergleich). Simpler, selbsterklärender Begriff → KEIN Beispiel. + - [aussage] → NUR wenn nicht trivial. Dann "mathe"/"text" (Anwendung an Mini-Instanz) + oder "diagramm" (Feld "spec": {{knoten:[…], kanten:[…]}}) für Automat/Ablauf/Baum. + Nur EINE Form. Im Zweifel WEGLASSEN — ein schwaches/redundantes Beispiel schadet + (Forschung: die Auswahl ist kritisch). Code selbsterklärend, ohne I/O-Tricks. Regeln: - Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen (außer Beispiel s. o.). @@ -23,4 +34,4 @@ ATOME: Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei): [{{"atom": 12, "typ": "flashcard", "frage": "…", "antwort": "…"}}, - {{"atom": 12, "typ": "beispiel", "text": "…"}}] + {{"atom": 12, "typ": "beispiel", "form": "text", "text": "…"}}] diff --git a/templates/Braucht-Aufloesung.md b/templates/Braucht-Aufloesung.md new file mode 100644 index 0000000..74feafc --- /dev/null +++ b/templates/Braucht-Aufloesung.md @@ -0,0 +1,23 @@ + +Unten Voraussetzungs-Angaben aus Lern-Atomen. Jede nennt eine PHRASE (ein +Konzept, das ein Atom voraussetzt) und eine Liste KANDIDATEN-Atome (id, Titel, +Definition), die dieses Konzept meinen KÖNNTEN. + +Entscheide je Angabe: Welches Kandidaten-Atom DEFINIERT genau das Konzept, das die +Phrase bezeichnet? Antworte mit dessen `atom`-id — oder `null`, wenn keines eindeutig +passt. + +Regeln: +- Es geht um KONZEPT-Gleichheit, nicht Wortgleichheit. „Approximationsalgorithmus" + (Kompositum) und „Approximativer Algorithmus" (Phrase) sind dasselbe Konzept. +- Wähle das Atom, das die Phrase als eigenen GEGENSTAND definiert — nicht ein Atom, + das sie nur benutzt oder einen Spezialfall behandelt. +- Genau EIN Treffer je Phrase. Passt keiner klar, oder passen mehrere gleich gut → + `null`. Eine falsche Kante verschiebt Reihenfolge und Level; im Zweifel `null`. +- Antworte für JEDE Angabe. + +ANGABEN: +{angaben} + +Antworte NUR mit JSON (keine Code-Fences): +[{{"phrase": 1, "atom": 2065}}, {{"phrase": 2, "atom": null}}] diff --git a/templates/Guide-Fix.md b/templates/Guide-Fix.md index f24536a..8441b05 100644 --- a/templates/Guide-Fix.md +++ b/templates/Guide-Fix.md @@ -16,13 +16,18 @@ SECTION (lang): Regeln: - Alle Marker-Zeilen () EXAKT erhalten bzw. fehlende exakt einfügen — je Atom genau eine, unsichtbar, keine Überschrift. -- Struktur erhalten: `####`-Zwischenüberschriften, „**Kernpunkte:**"- und - „**Prüfe dich:**"-Block am Ende; andere Überschriften-Ebenen bleiben verboten. +- Struktur erhalten: `####`-Zwischenüberschriften; andere Überschriften-Ebenen + bleiben verboten. Kein Kernpunkte-/Prüfe-dich-Block. - Mathe in KaTeX (`$…$` / `$$…$$`) setzen bzw. belassen. - Nur die beauftragten Stellen ändern; den Rest wörtlich erhalten. - Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…". - Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis. -Antworte NUR mit JSON (keine Code-Fences): -{{"kompakt": "…", "lang": "…"}} +Antworte in GENAU ZWEI Blöcken mit diesen Trennern (KEIN JSON, keine Code-Fences). +LaTeX-Backslashes bleiben roh (kein Escaping): +===LANG=== + +===KOMPAKT=== + +===== diff --git a/templates/Guide-Writer.md b/templates/Guide-Writer.md index 6e438e1..e6d0429 100644 --- a/templates/Guide-Writer.md +++ b/templates/Guide-Writer.md @@ -8,7 +8,7 @@ BEREITS BEHANDELT (frühere Durchgänge — NICHT neu erklären, nur bei Bedarf einem Halbsatz anknüpfen; dafür KEINE Marker setzen): {kontext} -Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool. +Unten die Atome mit Marker, Definition, Belegen und ggf. Beispiel. Regeln (hart): 1. LEHREN, NICHT ABSCHREIBEN: Formuliere frei und didaktisch in eigenen Worten — @@ -38,9 +38,8 @@ Regeln (hart): mehrstufige Beispiele weglassen oder auf ihren Kern vereinfachen. - Kohäsion: Jeder Absatz knüpft explizit an den vorigen an (Konnektor oder Übergangssatz). Fachbegriffe konsistent wiederverwenden — keine Synonym-Variation. - - Abschluss: „**Kernpunkte:**" mit 3–5 Stichpunkten (nur Verdichtung, KEINE neuen - Formulierungen) und „**Prüfe dich:**" mit genau 2 Fragen aus dem FRAGEN-POOL - (wörtlich übernehmen, ohne Antworten). + - Kein Zusammenfassungs- oder Frageblock am Ende — die Section endet mit dem + letzten inhaltlichen Absatz. 4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$` (z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen @@ -55,6 +54,11 @@ Regeln (hart): auf schmalen Bildschirmen aus dem Text. PDF-Artefakte aus den Belegen in saubere Notation übersetzen: „u 6= v" ist $u \neq v$, „V 0" ist $V'$ — du lehrst, du zitierst nicht. + JEDE Variable/jedes Symbol bei ERSTER Nutzung erklären (was ist `$m$`? etwa + „die Anzahl der Maschinen"). Konkrete Ergebnisse mit Variablen aus anderen Atomen + (z. B. eine Schranke $2-1/m$) nur zitieren, wenn deren Setting UND Variablen hier + eingeführt sind — sonst weglassen oder das Setting zuerst in einem Halbsatz nennen. + Kein nacktes Symbol, das der Leser dieses Durchgangs nicht kennt. 5. LÄNGE: „lang" hat {min_woerter}–{max_woerter} Wörter. Die Obergrenze ist ein HARTES Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel ein, kein Fülltext, keine Wiederholungen. @@ -63,17 +67,17 @@ Regeln (hart): 7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht — auch nicht auf spätere Durchgänge. Unbekannte Begriffe beim ersten Auftreten kurz mit Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN). -8. DIAGRAMME: Unten stehen fertige Diagramme (Marker + Beschreibung). Setze den - passenden ``-Marker in eine EIGENE Zeile direkt bei dem - Absatz, der die gezeigte Relation einführt (Nähe zum Text). Nur die - angebotenen Marker verwenden, keine erfinden, jeden höchstens einmal. Passt - ein Diagramm nicht zum Fluss, lass seinen Marker weg. „(keine)" → ignorieren. - -VERFÜGBARE DIAGRAMME: -{diagramme} + (Code-/Tabellen-/Diagramm-Beispiele werden NACH dir automatisch beim passenden + Atom eingefügt — du musst KEINE Beispiel-Marker setzen. Text-Beispiele unter den + Atomen webst du wie gehabt in die Prosa ein.) ATOME: {atome} -Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei): -{{"kompakt": "…", "lang": "…"}} +Antworte in GENAU ZWEI Blöcken mit diesen Trennern (KEIN JSON, keine Code-Fences, +keine Datei). LaTeX-Backslashes bleiben roh (kein Escaping): +===LANG=== + +===KOMPAKT=== + +===== diff --git a/tests/test_artefakte.py b/tests/test_artefakte.py index 39691e0..0bf9245 100644 --- a/tests/test_artefakte.py +++ b/tests/test_artefakte.py @@ -79,3 +79,104 @@ async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen(): # verworfene zählen nicht als versorgt: Repair generiert für das Atom nach chunks = artefakte._chunks(topic, nur_ohne=True) assert [x["id"] for c in chunks for x in c] == [a] + + +def test_tabelle_struktur_check(): + # wohlgeformt + ok = "| A | B |\n|---|---|\n| 1 | 2 |" + assert artefakte._tabelle_ok(ok) + # keine Datenzeile / kein Trenner / inkonsistente Spalten + assert not artefakte._tabelle_ok("| A | B |\n|---|---|") + assert not artefakte._tabelle_ok("A B\n1 2\n3 4") + assert not artefakte._tabelle_ok("| A | B |\n|---|---|\n| 1 |") + + +def test_referenziert_quelle_ignoriert_code(): + # „aufgabe 3" in Code/Kommentar darf NICHT als Quellen-Referenz matchen + inhalt = {"form": "code", "text": "", "code": "# aufgabe 3: sum\nprint(sum([1,2]))", + "sprache": "python", "tabelle": "", "mermaid": ""} + assert not artefakte._referenziert_quelle(inhalt) + # in der Prosa (text) matcht es weiterhin + assert artefakte._referenziert_quelle({"text": "siehe aufgabe 3", "frage": "", "antwort": ""}) + + +def test_formen_gate_verwirft_kaputte_tabelle(): + topic = topic_anlegen("formgate") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", braucht=db.j([])) + gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"})) + schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "tabelle", "tabelle": "kaputt ohne pipes"})) + artefakte._formen_gate(topic) + assert db.one("SELECT status FROM artefakte WHERE id=?", (gut,))["status"] == "kandidat" + assert db.one("SELECT status FROM artefakte WHERE id=?", (schlecht,))["status"] == "verworfen" + + +def test_beispiel_einsetzen_tabelle(): + import guide + topic = topic_anlegen("bsp-tab") + b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", baustein_id=b, ord=0, braucht=db.j([])) + md = "| A | B |\n|---|---|\n| 1 | 2 |" + art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert", + inhalt=db.j({"form": "tabelle", "tabelle": md})) + lang = f"Text.\n\n\n\nMehr." + out = guide._beispiel_einsetzen(b, lang) + assert md in out and "\ny") + + +def test_code_syntax_gate(): + assert artefakte._code_ok("python", "x = sum([1, 2])\nprint(x)") + assert not artefakte._code_ok("python", "def f(: pass") # SyntaxError + assert artefakte._code_ok("javascript", "const x = [1,2].map(v => v*2)") + assert not artefakte._code_ok("javascript", "const x = ;;;(") + assert not artefakte._code_ok("python", "") # leer + assert artefakte._code_ok("ruby", "puts 1") # unbekannt → kein Gate + + +def test_formen_gate_verwirft_kaputten_code(): + topic = topic_anlegen("codegate") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", braucht=db.j([])) + gut = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "code", "sprache": "python", "code": "print(1+1)"})) + schlecht = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "code", "sprache": "python", "code": "def f(: pass"})) + artefakte._formen_gate(topic) + assert db.one("SELECT status FROM artefakte WHERE id=?", (gut,))["status"] == "kandidat" + assert db.one("SELECT status FROM artefakte WHERE id=?", (schlecht,))["status"] == "verworfen" + + +def test_formen_gate_diagramm_beispiel(): + topic = topic_anlegen("diagbsp") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", braucht=db.j([])) + spec = {"knoten": [{"id": "q0", "label": "Start"}, {"id": "q1", "label": "Ende"}], + "kanten": [{"von": "q0", "zu": "q1", "label": "a"}]} + d = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "diagramm", "spec": spec})) + leer = db.insert("artefakte", atom_id=a, typ="beispiel", status="kandidat", + inhalt=db.j({"form": "diagramm", "spec": {"knoten": []}})) + artefakte._formen_gate(topic) + d_row = db.one("SELECT status, inhalt FROM artefakte WHERE id=?", (d,)) + assert d_row["status"] == "kandidat" + assert db.uj(d_row["inhalt"])["mermaid"].startswith("flowchart") # kompiliert + assert db.one("SELECT status FROM artefakte WHERE id=?", (leer,))["status"] == "verworfen" + + +async def test_guide_messen_toter_beispiel_marker(): + import guide + topic = topic_anlegen("bspqa") + b = db.insert("bausteine", topic=topic, ziel_id=1, titel="B", ord=0, status="neu") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", baustein_id=b, ord=0, braucht=db.j([])) + db.insert("lernziele", topic=topic, id=1, text="Z", status="aktiv") + db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k", + text_lang=f"\n\n" + "Wort " * 60) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "guide" + assert "beispiel_marker_tot" in {x["art"] for x in guide.messen(ctx)} diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index 110b1cc..24c765b 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -333,22 +333,6 @@ def test_stil_kein_fehlalarm_deutsch(): assert not any("englische Passage" in x for x in guide._stil_auftraege(text, "Langtext")) -def test_pruefe_dich_norm_match(): - """Frage-Matching ist Case/Whitespace-tolerant (Fix darf umformatieren).""" - import db - import guide - from conftest import topic_anlegen - topic = topic_anlegen("pruefe") - ziel = db.insert("lernziele", topic=topic, text="Z", status="aktiv") - b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") - a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", - status="neu", baustein_id=b_id, braucht=db.j([])) - db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert", - inhalt=db.j({"frage": "Was ist ein Graph?", "antwort": "Knoten und Kanten."})) - fragen = guide._pruefe_dich(b_id, "Prüfe dich: Was ist ein Graph?") - assert len(fragen) == 1 and fragen[0]["antwort"] == "Knoten und Kanten." - - def test_katex_gate_und_markdown_hygiene(monkeypatch): import guide monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen @@ -365,3 +349,42 @@ def test_katex_gate_und_markdown_hygiene(monkeypatch): assert "literales" in auftraege lang_formel = "Es gilt $" + "x + ".join(["y"] * 30) + "$ hier." assert "überlange Inline-Formel" in " ".join(guide._mathe_auftraege(lang_formel, "T")) + + +def test_split_writer_delimited_und_fallback(): + import guide + # delimited: roher LaTeX-Backslash bleibt (kein JSON-Escaping) + k, l = guide._split_writer("===LANG===\nText $a \\leq b$\n===KOMPAKT===\n- p") + assert l == "Text $a \\leq b$" and k == "- p" + # umgekehrte Reihenfolge der Blöcke + k2, l2 = guide._split_writer("===KOMPAKT===\n- x\n===LANG===\nY") + assert l2 == "Y" and k2 == "- x" + # Fallback: altes JSON + k3, l3 = guide._split_writer('{"kompakt": "- a", "lang": "b"}') + assert l3 == "b" and k3 == "- a" + # Fallback: nackter Text → alles lang + assert guide._split_writer("nur text")[1] == "nur text" + + +async def test_fix_cap_friert_section_ein(monkeypatch): + """Nach FIX_MAX_VERSUCHE erfolglosen Fixes (Text unverändert) wird die Section + für Stil-Befunde eingefroren → reparieren meldet bewegt=False (Stillstand).""" + import db + import fake_agents + import guide + import llm + from conftest import run_anlegen, topic_anlegen + topic = topic_anlegen("fixcap") + run = run_anlegen(topic) + ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv") + b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") + a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", + status="neu", baustein_id=b, braucht=db.j([])) + lang = f"\n" + "Wort " * 60 + db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p", + befunde=db.j([]), fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "guide" + bewegt = await guide.reparieren( + ctx, [{"art": "det_check", "item": str(b), "detail": "Kürzen"}]) + assert bewegt is False # eingefroren → nicht geroutet → kein Fortschritt diff --git a/tests/test_diagramme.py b/tests/test_diagramme.py index 67bbc3b..9bd3247 100644 --- a/tests/test_diagramme.py +++ b/tests/test_diagramme.py @@ -1,4 +1,4 @@ -"""Ebene 3.5 Diagramme: deterministische DAG-Diagramme (kein LLM), Spec→Mermaid.""" +"""Ebene 3.5 Diagramme: LLM-Diagramme (Automat/Ablauf/Baum), Spec→Mermaid, Assembly.""" import db import diagramme @@ -21,7 +21,7 @@ def _baustein_mit_kette(topic, n=4): def test_spec_zu_mermaid_flowchart(): - spec = {"typ": "dependency", + spec = {"typ": "flow", "knoten": [{"id": "1", "label": "A"}, {"id": "2", "label": 'B "x" [y]'}], "kanten": [{"von": "1", "zu": "2", "label": ""}]} out = diagramme._spec_zu_mermaid(spec) @@ -30,25 +30,11 @@ def test_spec_zu_mermaid_flowchart(): assert '"' not in out.split("\n", 1)[1].replace('["', "").replace('"]', "") # Label entschärft -async def test_dag_diagramm_wird_gebaut_und_verifiziert(): - topic = topic_anlegen("diag-dag") - b, atome = _baustein_mit_kette(topic, n=4) # 3 Kanten ≥ DIAGRAMM_MIN_KANTEN - ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") - await diagramme.bauen(ctx) - d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,)) - assert d and d["status"] == "verifiziert" and d["quelle"] == "dag" - assert d["mermaid"].startswith("flowchart TD") - assert all(f"n{a}" in d["mermaid"] for a in atome) # jeder Knoten drin - assert diagramme.messen(ctx) == [] # keine offenen Kandidaten - assert diagramme.gate(ctx) is None - - -async def test_wenig_kanten_kein_diagramm(): +async def test_fake_judge_nein_keine_diagramme(): topic = topic_anlegen("diag-leer") - b, _ = _baustein_mit_kette(topic, n=2) # nur 1 Kante < Schwelle + b, _ = _baustein_mit_kette(topic, n=2) ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") - await diagramme.bauen(ctx) - # kein GEBAUTES Diagramm (Fake-Judge sagt noetig=False → nur eine 'kein'-Merkzeile) + await diagramme.bauen(ctx) # Fake-Judge sagt noetig=False → nur 'kein'-Merkzeile assert db.query("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'", (topic,)) == [] assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "kein" @@ -97,12 +83,12 @@ async def test_marker_wird_zu_fence(): """Assembly: platzierter → ```mermaid-Fence.""" import guide topic = topic_anlegen("diag-place") - b, _ = _baustein_mit_kette(topic, n=4) - ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") - await diagramme.bauen(ctx) - d = db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b,)) + b, _ = _baustein_mit_kette(topic, n=2) + mermaid = "flowchart TD\n n1[\"A\"]\n n2[\"B\"]\n n1 --> n2" + d = db.insert("diagramme", topic=topic, baustein_id=b, typ="flow", quelle="llm", + spec=db.j({}), mermaid=mermaid, status="verifiziert") db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k", - text_lang=f"Text.\n\n\n\nMehr Text.") + text_lang=f"Text.\n\n\n\nMehr Text.") lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"] assert "```mermaid" in lang and "flowchart TD" in lang assert "\nErklärung von X.\n\nWeiterer Absatz zum Thema." + out = guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)), lang) + assert f"" in out + # der Marker steht NACH dem Atom-Absatz + assert out.index("" not in md and "| M -->" not in md # Marker tragen kein Level mehr # Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein assert "\n## " in "\n" + md and "### " in md - assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md + assert "**Kernpunkte:**" not in md and "**Prüfe dich:**" not in md # Blöcke entfernt + # Typisiertes Beispiel: die Fake-Welt liefert Tabellen-Beispiele → verbatim im Guide, + # Marker durch die Assembly ersetzt (kein toter Beispiel-Marker übrig) + assert "| Fall | Ergebnis |" in md + assert "