This commit is contained in:
team3
2026-07-12 21:21:41 +02:00
parent cde1721d10
commit a9b2f5ab9e
28 changed files with 106 additions and 706 deletions

View File

@@ -20,17 +20,14 @@ E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit
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 + 1 typisiertes Beispiel pro Atom
(Form: text|mathe|code|tabelle|diagramm, beste Passung) → Form-Gate
(Form: text|mathe|code|tabelle, 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: 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);
Beispiel-Marker setzt der Code DETERMINISTISCH (nicht der Writer);
Assembly tauscht Marker gegen ```-Fence/Tabelle.
```
@@ -48,11 +45,10 @@ 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 `<!-- beispiel: id -->` in den Guide (Assembly-Swap)
— beispiel-`inhalt` trägt `form` + formspezifische Felder (text|code+sprache|tabelle);
code/tabelle kommen verbatim via `<!-- beispiel: id -->` 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)`
- `sections(baustein_id, stage, text_kompakt, text_lang, befunde JSON)`
- `leitner(artefakt_id, box, faellig, historie)`
- `befunde(run_id, ebene, art, item, detail, status offen|repariert|freigesprochen)`

View File

@@ -29,9 +29,8 @@ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/wh
&& pip install --no-cache-dir -r /app/backend/requirements.txt
COPY --chown=app:app backend/ /app/backend/
# katex + mermaid (+ jsdom fürs headless-DOM, das Mermaid zum Parsen braucht) fürs
# Parse-Gate (backend/{katex,mermaid}_check.mjs prüfen mit dem echten Renderer)
RUN cd /app/backend && npm install --no-audit --no-fund katex mermaid jsdom && chown -R app:app node_modules
# katex fürs Parse-Gate (backend/katex_check.mjs prüft mit dem echten Renderer)
RUN cd /app/backend && npm install --no-audit --no-fund katex && chown -R app:app node_modules
COPY --chown=app:app templates/ /app/templates/
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json

View File

@@ -11,7 +11,6 @@ import subprocess
import tempfile
import db
import diagramme
import llm
from config import ARTEFAKT_CHUNK_ATOME, BEISPIEL_FORMEN, VERIFY_PANEL
@@ -30,8 +29,8 @@ _QUELLEN_REFERENZ = re.compile(
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")."""
"""Nur menschenlesbare Prosa fürs Quellen-Referenz-Gate — NICHT Code/Tabelle
(dort matchen Bezeichner/Kommentare den Regex falsch, z. B. „blatt 3")."""
return " ".join(str(inhalt.get(k, "")) for k in ("frage", "antwort", "text"))
@@ -80,9 +79,7 @@ def _beispiel_inhalt(e: dict) -> dict:
"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": ""}
"tabelle": str(e.get("tabelle", ""))}
async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
@@ -188,7 +185,7 @@ 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
_VERBATIM_FORMEN = ("code", "tabelle") # kommen verbatim in den Guide
def _tabelle_ok(md: str) -> bool:
@@ -242,24 +239,12 @@ def _code_ok(sprache: str, code: str) -> bool:
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)."""
→ (ok, inhalt). 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

View File

@@ -89,7 +89,7 @@ LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
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
BEISPIEL_FORMEN = ("text", "mathe", "code", "tabelle") # 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
@@ -107,9 +107,6 @@ 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 (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
@@ -137,9 +134,6 @@ TIMEOUTS = {
"writer": (450, 60),
"pruefer": (600, 5),
"qa_judge": (600, 0),
"diagramm_plan": (200, 0),
"diagramm_spec": (300, 0),
"diagramm_verify": (200, 5),
}

View File

@@ -96,12 +96,6 @@ CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
CREATE TABLE IF NOT EXISTS diagramme(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, baustein_id INTEGER NOT NULL,
typ TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'dag',
spec TEXT NOT NULL DEFAULT '{}', mermaid TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'geplant', befunde TEXT NOT NULL DEFAULT '[]');
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 '[]',
@@ -116,7 +110,7 @@ CREATE TABLE IF NOT EXISTS befunde(
# Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "befunde", "diagramme"}
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
def _init_schema(con: sqlite3.Connection) -> None:

View File

@@ -1,264 +0,0 @@
"""Ebene 3.5: Diagramme. Pro Baustein ein Mermaid-Diagramm, wenn es Struktur oder
Prozess sichtbar macht (Multimedia-Prinzip: instruktive Bilder heben Transfer,
Deko schadet). Zuverlässigkeits-Prinzip: der LLM liefert NIE rohes Mermaid, nur
Struktur-JSON; Python kompiliert deterministisch; ein Parse-Gate (mermaid_check.mjs)
ist das Sicherheitsnetz. Abhängigkeitsdiagramme kommen direkt aus dem braucht-DAG,
ganz ohne LLM — korrekt per Konstruktion, null Halluzination.
Phasen von bauen(): planen (welche + Typ) → Spec (nur LLM-Zweig) → kompilieren →
Gate → verifizieren (Grounding). Platziert werden Diagramme erst im Guide-Writer
(E4) über `<!-- diagramm: id -->`; das Assembly tauscht den Marker gegen den Fence."""
import json
import logging
import os
import re
import subprocess
from pathlib import Path
import db
import llm
from config import DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL
log = logging.getLogger("creator2.diagramme")
EBENE = "diagramme"
_MERMAID_SKRIPT = Path(__file__).resolve().parent / "mermaid_check.mjs"
_MERMAID_BASE = Path(__file__).resolve().parent.parent / "frontend"
_mermaid_gewarnt = False
def _mermaid_fehler(diagramme: list[dict]) -> dict[int, str]:
"""Parse-Gate: Diagramme durch DENSELBEN Parser wie das Frontend jagen (Zwilling
zu guide._katex_fehler). → {id: fehlermeldung} nur der fehlerhaften. Fake-Modus
überspringt (deterministisch); fail-open MIT Warnung, wenn node/mermaid fehlt."""
global _mermaid_gewarnt
if not diagramme or os.getenv("CREATOR_FAKE_AGENTS"):
return {}
try:
res = subprocess.run(
["node", str(_MERMAID_SKRIPT)],
input=json.dumps([{"id": d["id"], "code": d["mermaid"]} for d in diagramme]),
capture_output=True, text=True, timeout=60,
env={**os.environ, "MERMAID_BASE": str(_MERMAID_BASE)})
if res.returncode:
raise RuntimeError(res.stderr.strip()[:200])
return {e["id"]: e["fehler"] for e in json.loads(res.stdout)}
except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip)
if not _mermaid_gewarnt:
_mermaid_gewarnt = True
log.warning("Mermaid-Gate nicht verfügbar (%s) — Diagramm-Validierung fällt aus", e)
return {}
def _bausteine(topic: str) -> list[dict]:
return db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
def _atome_von(baustein_id: int) -> list[dict]:
return db.query("SELECT * FROM atome WHERE baustein_id=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
# ── Kompilieren (deterministisch, kein LLM) ───────────────────────────────────
def _sid(raw) -> str:
"""Mermaid-Knoten-ID: nur alnum/_ erlaubt, führender Buchstabe."""
return "n" + re.sub(r"[^A-Za-z0-9]", "_", str(raw))
def _lbl(text) -> str:
"""Label für Mermaid entschärfen: keine Anführungszeichen/Klammern/Zeilenumbrüche,
die die Syntax brechen. Gekürzt (Lesbarkeit)."""
t = str(text).replace('"', "'").replace("\n", " ")
t = t.replace("[", "(").replace("]", ")").replace("{", "(").replace("}", ")")
return t.strip()[:80] or "?"
def _spec_zu_mermaid(spec: dict) -> str:
"""Struktur-JSON → Mermaid-Quelltext. state → stateDiagram-v2, sonst flowchart."""
knoten = spec.get("knoten") or []
kanten = spec.get("kanten") or []
if not knoten:
return ""
if spec.get("typ") == "state":
zeilen = ["stateDiagram-v2"]
for k in kanten:
lab = _lbl(k.get("label", ""))
pfeil = f": {lab}" if lab and lab != "?" else ""
zeilen.append(f" {_sid(k['von'])} --> {_sid(k['zu'])}{pfeil}")
return "\n".join(zeilen)
zeilen = ["flowchart TD"]
for n in knoten:
zeilen.append(f' {_sid(n["id"])}["{_lbl(n["label"])}"]')
for k in kanten:
lab = _lbl(k.get("label", ""))
pfeil = f"-- {lab} -->" if lab and lab != "?" else "-->"
zeilen.append(f' {_sid(k["von"])} {pfeil} {_sid(k["zu"])}')
return "\n".join(zeilen)
def _kompilieren(topic: str) -> None:
for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'"
" AND spec!='{}'", (topic,)):
mermaid = _spec_zu_mermaid(db.uj(d["spec"], {}))
if mermaid:
db.update("diagramme", "id", d["id"], mermaid=mermaid, status="kandidat")
# ── Planung (welche + Typ) ────────────────────────────────────────────────────
def _atom_liste(atome: list[dict]) -> str:
return "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in atome)
async def _plan_judge(ctx: llm.Kontext) -> None:
"""LLM-Zweig, Schritt „welche + Typ": je Baustein OHNE Diagramm-Zeile entscheidet
ein Judge, ob ein Automaten-/Ablauf-/Baum-Diagramm nötig ist (Multimedia-Regel
im Prompt). „noetig" → status='geplant'/quelle='llm'; sonst eine 'kein'-Zeile,
damit der Judge nicht jede Repair-Iteration erneut fragt (idempotent)."""
alle = _bausteine(ctx.topic)
je_kap: dict = {}
for b in alle:
je_kap.setdefault(b["kapitel_id"], []).append(b["titel"])
async def einer(b: dict) -> None:
if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)):
return
atome = _atome_von(b["id"])
if not atome:
return
nachbarn = "\n".join(f"- {t}" for t in je_kap.get(b["kapitel_id"], [])
if t != b["titel"]) or "(keine)"
res = await llm.call(ctx, stage="diagramm_plan", template="Diagramm-Plan",
role="judge", n=len(atome), item=f"dp{b['id']}",
werte={"titel": b["titel"], "atome": _atom_liste(atome),
"nachbarn": nachbarn,
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"])},
erwartet=dict)
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:
db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ=typ,
quelle="llm", status="geplant")
else:
db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ="",
quelle="llm", status="kein") # kein Diagramm — nicht erneut fragen
await llm.alle(einer(b) for b in alle)
def _spec_normalisieren(res: dict, typ: str) -> dict:
"""LLM-Spec säubern: nur Knoten mit id+label, nur Kanten zwischen echten Knoten."""
knoten = [{"id": str(n.get("id", "")).strip(), "label": str(n.get("label", "")).strip()}
for n in (res.get("knoten") or []) if isinstance(n, dict) and n.get("id")]
gueltig = {n["id"] for n in knoten}
kanten = [{"von": str(k.get("von", "")).strip(), "zu": str(k.get("zu", "")).strip(),
"label": str(k.get("label", "")).strip()}
for k in (res.get("kanten") or [])
if isinstance(k, dict) and str(k.get("von")) in gueltig
and str(k.get("zu")) in gueltig]
return {"typ": typ, "knoten": knoten, "kanten": kanten}
async def _spec_bauen(ctx: llm.Kontext) -> None:
"""LLM liefert NUR Struktur-JSON (kein Mermaid) — die Hauptfehlerquelle
(roh geschriebenes Mermaid) fällt so weg. Kompiliert wird deterministisch."""
offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'"
" AND quelle='llm' AND spec='{}'", (ctx.topic,))
async def einer(d: dict) -> None:
b = db.one("SELECT * FROM bausteine WHERE id=?", (d["baustein_id"],))
atome = _atome_von(d["baustein_id"])
res = await llm.call(ctx, stage="diagramm_spec", template="Diagramm-Spec",
role="guide", n=len(atome), item=f"ds{d['id']}",
werte={"titel": b["titel"] if b else "", "typ": d["typ"],
"atome": _atom_liste(atome)}, erwartet=dict)
if res is None:
return # Ausfall → bleibt 'geplant', nächste Runde erneut
spec = _spec_normalisieren(res, d["typ"])
if spec["knoten"]:
db.update("diagramme", "id", d["id"], spec=db.j(spec))
await llm.alle(einer(d) for d in offen)
async def _verifizieren(ctx: llm.Kontext) -> None:
"""Grounding-Panel für LLM-Diagramme: jeder Knoten muss aus einem Atom ableitbar
sein, keine erfundene Struktur. Verwerfen (destruktiv) nur einstimmig
(llm.einstimmig) — Panel-Ausfall lässt den Kandidaten stehen."""
offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'"
" AND quelle='llm'", (ctx.topic,))
def urteil(stimme):
if stimme and isinstance(stimme[0], dict) and "ok" in stimme[0]:
return bool(stimme[0]["ok"])
return None
async def einer(d: dict) -> None:
atome = _atome_von(d["baustein_id"])
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="diagramm_verify",
template="Diagramm-Verify", schritt="diagramm_verify",
role="judge", n=len(atome), item=f"dv{d['id']}",
werte={"atome": _atom_liste(atome), "mermaid": d["mermaid"]},
erwartet=list)
if llm.einstimmig(stimmen, VERIFY_PANEL, urteil):
db.update("diagramme", "id", d["id"], status="verifiziert")
elif llm.einstimmig(stimmen, VERIFY_PANEL,
lambda s: None if urteil(s) is None else not urteil(s)):
db.update("diagramme", "id", d["id"], status="verworfen",
befunde=db.j(["Grounding-Panel: nicht aus den Atomen ableitbar"]))
# sonst: Kandidat bleibt (Panel-Ausfall / uneins) — nächste Runde erneut
await llm.alle(einer(d) for d in offen)
# ── Ebenen-Vertrag ────────────────────────────────────────────────────────────
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
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
await _verifizieren(ctx) # Grounding-Panel gegen die Atome
def _gate_kandidaten(topic: str) -> None:
"""Kandidaten durchs Parse-Gate: Syntaxfehler → verworfen (mit Befund). Ein
kaputtes Diagramm darf nie in den Guide (fail-closed)."""
kandidaten = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'"
" AND mermaid!=''", (topic,))
fehler = _mermaid_fehler(kandidaten)
for d in kandidaten:
if d["id"] in fehler:
db.update("diagramme", "id", d["id"], status="verworfen",
befunde=db.j([f"Parse-Fehler: {fehler[d['id']]}"]))
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'",
(ctx.topic,)):
# hängengebliebener Kandidat (Spec/Verify offen) — sichtbar, kein stiller Verlust
befunde.append({"art": "diagramm_unverifiziert", "item": str(d["id"]),
"detail": f"Baustein {d['baustein_id']}"})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
if not befunde:
return False
await bauen(ctx)
return True
def gate(ctx: llm.Kontext) -> str | None:
"""Diagramme sind optional — KEINE Diagramme blockiert den Lauf nicht. Backstop:
ein verifiziertes Diagramm ohne Mermaid-Quelltext darf nie ausgeliefert werden."""
tot = db.one("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'"
" AND mermaid='' LIMIT 1", (ctx.topic,))
return "verifiziertes Diagramm ohne Quelltext" if tot else None

View File

@@ -237,7 +237,7 @@ def _writer(prompt: str):
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
for (mk, titel), d in zip(marker, defs):
# Beispiel-/Diagramm-Marker setzt jetzt _marker_platzieren deterministisch,
# Beispiel-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)
@@ -247,23 +247,6 @@ def _writer(prompt: str):
"lang": lang}
def _diagramm_plan(prompt: str):
# 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": ""}
def _diagramm_spec(prompt: str):
ids = [i for i in re.findall(r"^(\d+):", _text_nach(prompt, "ATOME:"), re.MULTILINE)]
knoten = [{"id": i, "label": f"Konzept {i}"} for i in ids]
kanten = [{"von": ids[k], "zu": ids[k + 1], "label": ""} for k in range(len(ids) - 1)]
return {"typ": "flow", "knoten": knoten, "kanten": kanten}
def _diagramm_verify(prompt: str):
return [{"ok": True, "mangel": ""}]
def _pruefer(prompt: str):
return {"befunde": []}
@@ -298,6 +281,4 @@ _HANDLER = {
"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,
"Diagramm-Verify": _diagramm_verify,
}

View File

@@ -55,16 +55,10 @@ _MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
# den Match und die Marker-Zeile bliebe in den Text-Checks stehen (Marker sind
# einzeilig, also kein DOTALL). Muster spiegelt frontend/src/markdown.js.
_MARKER_VOLL = re.compile(r"<!--\s*atom:.*?-->")
_MARKER_DIAGRAMM = re.compile(r"<!--\s*diagramm:\s*(\d+)\s*-->")
_MARKER_BEISPIEL = re.compile(r"<!--\s*beispiel:\s*(\d+)\s*-->")
# 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")
_BEISPIEL_BESCHR = {"code": "Code-Beispiel", "tabelle": "Tabelle/Vergleich"}
_VERBATIM_FORMEN = ("code", "tabelle")
def _ohne_marker(text: str) -> str:
@@ -72,12 +66,12 @@ 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- und Beispiel-Marker gehören ebenso raus."""
return _MARKER_BEISPIEL.sub("", _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text)))
Beispiel-Marker gehören ebenso raus."""
return _MARKER_BEISPIEL.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) —
"""Verifizierte Beispiele des Atoms in VERBATIM-Form (code/tabelle) —
(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'"
@@ -89,23 +83,20 @@ def _beispiel_verbatim(atom_id: int) -> list[tuple[int, dict]]:
def _beispiel_render(inh: dict) -> str:
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle / Mermaid-Fence)."""
"""Verbatim-Beispiel → Markdown (Code-Fence / Tabelle)."""
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))
(atom-genau via Atom-Marker). Idempotent: alte Verbatim-Marker erst raus."""
lang = _MARKER_BEISPIEL.sub("", lang)
je_atom = {}
for a in _atome_von(b["id"]):
vs = _beispiel_verbatim(a["id"])
@@ -124,10 +115,6 @@ def _marker_platzieren(b: dict, lang: str) -> str:
if pending is not None:
out += ["", f"<!-- beispiel: {pending} -->"]
lang = "\n".join(out)
diags = _diagramme_von(b["id"])
if diags:
block = "\n".join(f"<!-- diagramm: {d['id']} -->" for d in diags)
lang = lang.rstrip() + "\n\n" + block
return lang
@@ -145,24 +132,6 @@ def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
return _MARKER_BEISPIEL.sub(sub, lang)
def _diagramme_von(baustein_id: int) -> list[dict]:
return db.query("SELECT * FROM diagramme WHERE baustein_id=? AND status='verifiziert'"
" ORDER BY id", (baustein_id,))
def _diagramm_einsetzen(baustein_id: int, lang: str) -> str:
"""Assembly: `<!-- diagramm: id -->` → ```mermaid-Fence des verifizierten
Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg."""
if "<!-- diagramm:" not in lang and "<!--diagramm:" not in lang:
return lang
je_id = {d["id"]: d for d in _diagramme_von(baustein_id)}
def sub(m: re.Match) -> str:
d = je_id.get(int(m.group(1)))
return f"```mermaid\n{d['mermaid']}\n```" if d and d["mermaid"] else ""
return _MARKER_DIAGRAMM.sub(sub, lang)
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch")
@@ -185,7 +154,7 @@ 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 `<!-- beispiel: id -->`-Marker rein."""
(code/tabelle) kommen über den `<!-- beispiel: id -->`-Marker rein."""
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert' LIMIT 1", (atom_id,))
if not row:
@@ -233,23 +202,35 @@ def _anknuepf_kontext(b: dict) -> str:
# ── Stages ────────────────────────────────────────────────────────────────────
def _writer_saeubern(s: str) -> str:
"""Geleakte Writer-Delimiter/Modelltokens entfernen. Der Writer beendet seine
Ausgabe mit `=====`; steht diese Zeile unter einem Bullet, ist sie eine
Setext-H1-Unterstreichung → marked macht daraus ein riesiges <h1>. Reine
`===…`-Zeilen und `<s>`/`</s>`-Tokens fallen daher ganz raus."""
s = re.sub(r"</?s>", "", s or "")
s = re.sub(r"(?m)^\s*={3,}\s*$", "", s)
return s.strip()
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."""
(alt-JSON), sonst ganzer Text = lang. Beide Blöcke werden von geleakten
Terminatoren (`=====`/`</s>`) gesäubert."""
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
return _writer_saeubern(m.group(1)) 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()
return (_writer_saeubern(str(daten.get("kompakt", ""))),
_writer_saeubern(str(daten.get("lang", ""))))
return "", _writer_saeubern(text or "")
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
@@ -275,7 +256,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
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
lang = _marker_platzieren(b, lang) # Beispiel-Marker deterministisch setzen
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
return "pruefer"
@@ -604,15 +585,17 @@ 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- und Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly);
# Atom-Marker bleiben — die strippt erst das Frontend.
# Beispiel-Marker gegen ihre Fences/Tabellen tauschen (Assembly);
# Atom-Marker bleiben — die strippt erst das Frontend. _writer_saeubern hier
# AUCH beim Lesen: bestehende Sections mit geleaktem `=====`/`</s>` (Setext-H1
# → riesiger Bullet) werden ohne Neu-Generierung sauber ausgeliefert.
lang = ""
if sec:
lang = _beispiel_einsetzen(b["id"], _diagramm_einsetzen(b["id"], sec["text_lang"]))
lang = _beispiel_einsetzen(b["id"], _writer_saeubern(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 "",
"kompakt": _writer_saeubern(sec["text_kompakt"]) if sec else "",
"lang": lang})
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
@@ -658,12 +641,6 @@ def messen(ctx: llm.Kontext) -> list[dict]:
for i in _marker_fremd_ids(lang, atome):
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
"detail": f"Atom {i}"})
gueltige_d = {d["id"] for d in _diagramme_von(b["id"])}
for m in _MARKER_DIAGRAMM.findall(lang):
if int(m) not in gueltige_d: # Marker ohne verifiziertes Diagramm → entfernen
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
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

View File

@@ -153,10 +153,6 @@ def state(topic: str):
"bausteine": db.query(
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
" WHERE b.topic=? ORDER BY b.ord", (topic,)),
"diagramme": db.query(
"SELECT d.id, d.baustein_id, d.typ, d.quelle, d.status, b.titel, b.ord"
" FROM diagramme d JOIN bausteine b ON b.id=d.baustein_id"
" WHERE d.topic=? AND d.status!='kein' ORDER BY b.ord, d.id", (topic,)),
# topic-scoped: offene Befunde früherer Läufe bleiben nach Resume sichtbar
"befunde": db.query(
"SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"

View File

@@ -1,37 +0,0 @@
// Mermaid-Parse-Gate: prüft Diagramm-Quelltext mit DEMSELBEN Parser wie das Frontend.
// stdin: JSON [{id, code}] → stdout: JSON [{id, fehler}, …] (nur die fehlerhaften).
// Zwilling zu katex_check.mjs: was hier parst, rendert auch im Guide.
//
// Mermaid braucht zum Sanitisieren der Labels ein DOM — headless via jsdom, sonst
// wirft parse() für JEDES Diagramm mit Label „DOMPurify.addHook is not a function"
// (nicht ein Diagramm-Fehler, sondern ein Umgebungsfehler → alles fälschlich verworfen).
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
function laden(name) {
try {
return createRequire(import.meta.url)(name) // Container: backend/node_modules
} catch {
const basis = process.env.MERMAID_BASE // Dev: frontend/node_modules
return createRequire(basis.endsWith('/') ? basis : basis + '/')(name)
}
}
const { JSDOM } = laden('jsdom')
const dom = new JSDOM('<!DOCTYPE html><body></body>')
globalThis.window = dom.window
globalThis.document = dom.window.document
const mermaid = laden('mermaid').default
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
const diagramme = JSON.parse(readFileSync(0, 'utf8'))
const fehler = []
for (const d of diagramme) {
try {
await mermaid.parse(d.code) // wirft bei Syntaxfehler
} catch (e) {
fehler.push({ id: d.id, fehler: String(e.message || e).slice(0, 160) })
}
}
process.stdout.write(JSON.stringify(fehler))

View File

@@ -10,7 +10,6 @@ import agents
import artefakte
import auto_loop
import db
import diagramme
import guide
import inventar
import korpus
@@ -27,11 +26,10 @@ EBENEN = [
("inventar", inventar, "inventar_fertig"),
("artefakte", artefakte, "artefakte_fertig"),
("struktur", struktur, "struktur_fertig"),
("diagramme", diagramme, "diagramme_fertig"),
("guide", guide, "fertig"),
]
_ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_fertig",
"artefakte_fertig", "struktur_fertig", "diagramme_fertig", "fertig"]
"artefakte_fertig", "struktur_fertig", "fertig"]
_laeufe: dict[str, asyncio.Task] = {}
@@ -65,7 +63,7 @@ def lauf_stoppen(topic: str) -> None:
db.update("runs", "id", run["id"], status="stopped", beendet=db.now())
_ROLLBACK = {"guide": "diagramme_fertig", "diagramme": "struktur_fertig",
_ROLLBACK = {"guide": "struktur_fertig",
"struktur": "artefakte_fertig", "artefakte": "inventar_fertig",
"inventar": "korpus_fertig", "korpus": "neu"}
@@ -77,25 +75,23 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
if ebene not in _ROLLBACK:
raise ValueError(f"unbekannte Ebene: {ebene}")
lauf_stoppen(topic)
stufe = ["guide", "diagramme", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
db.execute("DELETE FROM sections WHERE baustein_id IN"
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
db.execute("UPDATE bausteine SET status='neu' WHERE topic=?", (topic,))
db.execute("UPDATE kapitel SET intro='' WHERE topic=?", (topic,)) # Intro ist Guide-Text
if stufe >= 1: # diagramme
db.execute("DELETE FROM diagramme WHERE topic=?", (topic,))
if stufe >= 2: # struktur
if stufe >= 1: # struktur
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
(topic,))
if stufe >= 3: # artefakte
if stufe >= 2: # artefakte
db.execute("DELETE FROM leitner WHERE artefakt_id IN (SELECT ar.id FROM artefakte ar"
" JOIN atome a ON ar.atom_id=a.id WHERE a.topic=?)", (topic,))
db.execute("DELETE FROM artefakte WHERE atom_id IN"
" (SELECT id FROM atome WHERE topic=?)", (topic,))
if stufe >= 4: # inventar
if stufe >= 3: # inventar
db.execute("DELETE FROM anker WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)",
(topic,))
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
@@ -103,7 +99,7 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
# atome_stand mit-nullen — sonst überspringt der Resume-Guard die Neu-Extraktion
db.execute("UPDATE quellen SET status='extrahiert', atome_stand='' WHERE topic=?",
(topic,))
if stufe >= 5: # korpus
if stufe >= 4: # korpus
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
db.execute("DELETE FROM quellen WHERE topic=?", (topic,))
import shutil
@@ -118,7 +114,7 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
# append-only), aber die Zählung beginnt hinter dem Reset-Marker neu.
letzte = db.one("SELECT MAX(id) AS m FROM events")["m"] or 0
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
for name in ["guide", "diagramme", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]:
for name in ["guide", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]:
resets[name] = letzte
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))

View File

@@ -9,7 +9,6 @@ import logging
import artefakte
import db
import diagramme
import guide
import inventar
import korpus
@@ -19,7 +18,7 @@ import struktur
log = logging.getLogger("creator2.qa")
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
"struktur": struktur, "diagramme": diagramme, "guide": guide}
"struktur": struktur, "guide": guide}
# Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
GEWICHTE = {
@@ -34,8 +33,6 @@ GEWICHTE = {
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"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,
}
@@ -49,7 +46,6 @@ def _basis(topic: str, ebene: str) -> int:
" AND status NOT IN ('gemerged','verworfen')",
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"diagramme": "SELECT COUNT(*) n FROM bausteine WHERE topic=?",
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
row = db.one(z, (topic,))
return max(1, int(row["n"]) if row else 1)

View File

@@ -12,11 +12,9 @@
"@fontsource-variable/inter": "^5.2.8",
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"jsdom": "^29.1.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"mermaid": "^11.4.1",
"vue": "^3.5.32"
},
"devDependencies": {

View File

@@ -7,9 +7,9 @@ import Ueben from './components/Ueben.vue'
import Kennzahlen from './components/Kennzahlen.vue'
const topics = ref([])
const topic = ref('')
const topic = ref(localStorage.getItem('topic') || '') // Auswahl über Reload halten
const state = ref(null)
const tab = ref('board')
const tab = ref(localStorage.getItem('tab') || 'board')
const neuName = ref('')
const neuArt = ref('thema')
const fehler = ref('')
@@ -24,6 +24,9 @@ function navToggle() {
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
}
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
// Ansicht über Reload halten: gewähltes Topic + Tab merken
watch(topic, (t) => localStorage.setItem('topic', t))
watch(tab, (t) => localStorage.setItem('tab', t))
let wsSchliessen = null
let timer = null
@@ -40,6 +43,8 @@ media.addEventListener('change', themeAnwenden)
async function topicsLaden() {
topics.value = await api.topics()
// gemerktes Topic gelöscht? → verwerfen, dann erst auf das erste zurückfallen
if (topic.value && !topics.value.some((t) => t.name === topic.value)) topic.value = ''
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
}

View File

@@ -41,7 +41,6 @@ const EBENEN = [
{ key: 'inventar', titel: 'Atome' },
{ key: 'artefakte', titel: 'Artefakte' },
{ key: 'struktur', titel: 'Bausteine' },
{ key: 'diagramme', titel: 'Diagramme' },
{ key: 'guide', titel: 'Guide' },
]
@@ -80,10 +79,6 @@ function karten(key) {
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.status, statusFarbe(b.status)]] }))
}
if (key === 'diagramme') {
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
}
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
}

View File

@@ -1,14 +1,8 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import mermaid from 'mermaid'
import { api } from '../api.js'
import { render, lesestat } from '../markdown.js'
// securityLevel:'strict' → kein foreignObject/HTML-Label (umgeht DOMPurify-Filter),
// keine Klick-Handler. startOnLoad:false → wir rendern selbst nach jedem Einblenden.
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
let mmId = 0
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
@@ -46,27 +40,6 @@ function html(s) {
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() {
await nextTick()
const wurzel = scrollEl.value
if (!wurzel) return
for (const code of wurzel.querySelectorAll('code.language-mermaid:not([data-mm])')) {
code.setAttribute('data-mm', '1')
const pre = code.closest('pre')
try {
const { svg } = await mermaid.render(`mm-${mmId++}`, code.textContent)
const figur = document.createElement('div')
figur.className = 'mermaid-figur'
figur.innerHTML = svg
pre?.replaceWith(figur)
} catch {
pre?.classList.add('mermaid-fehler') // kaputt: als Codeblock stehen lassen
}
}
}
watch([aktiv, ansicht, level, daten], renderMermaid, { flush: 'post' })
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1

View File

@@ -64,7 +64,7 @@ const MARKER = /<!--\s*atom:.*?-->/g
export function lesestat(text) {
let displayFormeln = 0
const t = (text || '')
.replace(/```[\s\S]*?```/g, ' ') // Code-/Diagramm-Fences zählen nicht als Text
.replace(/```[\s\S]*?```/g, ' ') // Code-Fences zählen nicht als Text
.replace(MARKER, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
.replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' })

View File

@@ -123,7 +123,7 @@ input, select {
/* Board: Ebenen-Spalten links→rechts, Listen VOLLSTÄNDIG scrollbar */
.board { display: flex; gap: 10px; padding: 12px; overflow-x: auto; flex: 1; min-height: 0; }
.spalte {
min-width: 265px; max-width: 320px; flex: 1; display: flex; flex-direction: column;
min-width: 0; max-width: 320px; flex: 1; display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
}
.spaltekopf { padding: 8px 10px; border-bottom: 1px solid var(--rand); display: grid; gap: 6px; }
@@ -223,8 +223,6 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
}
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
:is(.markdown, .kompakt) .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
.mermaid-figur { overflow-x: auto; text-align: center; margin: 16px 0; }
.mermaid-figur svg { max-width: 100%; height: auto; }
.markdown blockquote {
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);

View File

@@ -16,4 +16,3 @@ Für beispiel: BEHALTE die Form aus dem Artefakt oben und gib die passenden Feld
- form "text"/"mathe": {{"text": "…"}}
- form "code": {{"sprache": "python", "code": "…"}}
- form "tabelle": {{"tabelle": "| … | … |\n|---|---|\n| … | … |"}}
- form "diagramm": {{"spec": {{"knoten": […], "kanten": […]}}}}

View File

@@ -15,8 +15,7 @@ Erzeuge pro Atom:
"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.
- [aussage] → NUR wenn nicht trivial. Dann "mathe"/"text" (Anwendung an Mini-Instanz).
Nur EINE Form. Im Zweifel WEGLASSEN — ein schwaches/redundantes Beispiel schadet
(Forschung: die Auswahl ist kritisch). Code selbsterklärend, ohne I/O-Tricks.

View File

@@ -1,22 +0,0 @@
<!-- template:Diagramm-Plan -->
Entscheide, ob der Baustein „{titel}" ein Diagramm braucht.
Durchgang: {durchgang}.
Ein Diagramm hilft NUR, wenn es Struktur oder Ablauf zeigt, den reiner Text teuer
erklärt: Zustandsübergänge/Automaten, Abläufe/Verfahren, Baum-/Hierarchie-Strukturen.
Es schadet, wenn es bloß wiederholt, was ein Satz schon sagt (Redundanz), oder nur
schmückt. Im dritten Durchgang (Feinheiten) sparsam.
Nachbar-Bausteine (nicht dieselbe Relation doppeln):
{nachbarn}
Wähle den Typ:
- state: Automat / Zustandsübergänge.
- flow: Ablauf / Verfahren / Schritte.
- tree: Hierarchie / Baum / Zerlegung.
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences):
{{"noetig": true, "typ": "state"}}

View File

@@ -1,20 +0,0 @@
<!-- template:Diagramm-Spec -->
Beschreibe die STRUKTUR eines {typ}-Diagramms für den Baustein „{titel}".
Schreibe KEIN Mermaid, KEINEN Code — nur die Struktur als JSON. Ein Programm
kompiliert daraus das Diagramm.
Regeln (hart):
- NUR Konzepte/Zustände/Schritte aus den ATOMEN unten. Nichts erfinden, nichts
dazurecherchieren — jeder Knoten muss aus einem Atom ableitbar sein.
- „knoten": kurze Labels (14 Wörter), auf Deutsch.
- „kanten": „von"/„zu" verweisen auf „id"-Werte aus „knoten"; „label" optional
(kurze Kantenbeschriftung, z. B. das auslösende Symbol beim Automaten).
- Klein und lesbar halten: nur die tragende Struktur, keine Vollständigkeit um
jeden Preis.
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences):
{{"typ": "{typ}", "knoten": [{{"id": "q0", "label": "Startzustand"}}],
"kanten": [{{"von": "q0", "zu": "q1", "label": "a"}}]}}

View File

@@ -1,21 +0,0 @@
<!-- template:Diagramm-Verify -->
Prüfe das Diagramm GEGEN DIE ATOME. Ist es fachlich korrekt und vollständig aus
den Atomen ableitbar?
ok=false, wenn:
- ein Knoten/Zustand/Schritt vorkommt, der in keinem Atom steht (erfunden),
- die dargestellten Beziehungen/Übergänge den Atomen widersprechen,
- die tragende Struktur des Themas fehlt oder verdreht ist.
ok=true bei kleinen Vereinfachungen (Weglassen von Randfällen ist erlaubt).
Layout/Ästhetik ist KEIN Mangel — nur inhaltliche Korrektheit zählt.
Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
ATOME:
{atome}
DIAGRAMM (Mermaid):
{mermaid}
Antworte NUR mit JSON (keine Code-Fences):
[{{"ok": true, "mangel": ""}}]

View File

@@ -67,8 +67,8 @@ 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).
(Code-/Tabellen-/Diagramm-Beispiele werden NACH dir automatisch beim passenden
Atom eingefügt — du musst KEINE Beispiel-Marker setzen. Text-Beispiele unter den
(Code-/Tabellen-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:

View File

@@ -94,7 +94,7 @@ def test_tabelle_struktur_check():
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": ""}
"sprache": "python", "tabelle": ""}
assert not artefakte._referenziert_quelle(inhalt)
# in der Prosa (text) matcht es weiterhin
assert artefakte._referenziert_quelle({"text": "siehe aufgabe 3", "frage": "", "antwort": ""})
@@ -151,23 +151,6 @@ def test_formen_gate_verwirft_kaputten_code():
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")

View File

@@ -313,7 +313,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
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([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang,
text_kompakt="- p", befunde=db.j(["Behebe (laenge): kürzen"]))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
@@ -366,6 +366,20 @@ def test_split_writer_delimited_und_fallback():
assert guide._split_writer("nur text")[1] == "nur text"
def test_writer_saeubern_terminator():
import guide
# geleakter `=====`-Terminator (Setext-H1 unter Bullet) + </s> raus
roh = "- $M$ gilt\n\n$$L \\in NP$$\n- Für jedes $L$ gilt\n=====\n</s>"
sauber = guide._writer_saeubern(roh)
assert "=====" not in sauber and "</s>" not in sauber
assert sauber.endswith("- Für jedes $L$ gilt")
# _split_writer säubert den kompakt-Block (letzter Block fängt sonst das =====)
k, l = guide._split_writer("===LANG===\nText\n===KOMPAKT===\n- a\n- b\n=====\n</s>")
assert k == "- a\n- b" and l == "Text"
# Tabellen-Trenner (---) bleibt unangetastet
assert "---" in guide._writer_saeubern("| A | B |\n|---|---|\n| 1 | 2 |")
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)."""
@@ -388,3 +402,25 @@ async def test_fix_cap_friert_section_ein(monkeypatch):
bewegt = await guide.reparieren(
ctx, [{"art": "det_check", "item": str(b), "detail": "Kürzen"}])
assert bewegt is False # eingefroren → nicht geroutet → kein Fortschritt
async def test_marker_platzieren_deterministisch():
"""Der Writer setzt keine Beispiel-Marker mehr — _marker_platzieren fügt sie
hinter den Atom-Absatz ein (atom-genau)."""
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("platz")
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([]))
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
out = guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)), lang)
assert f"<!-- beispiel: {art} -->" in out
# der Marker steht NACH dem Atom-Absatz
assert out.index("<!-- beispiel:") < out.index("Weiterer Absatz")
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("<!-- beispiel:") == 1

View File

@@ -1,136 +0,0 @@
"""Ebene 3.5 Diagramme: LLM-Diagramme (Automat/Ablauf/Baum), Spec→Mermaid, Assembly."""
import db
import diagramme
import llm
from conftest import run_anlegen, topic_anlegen
def _baustein_mit_kette(topic, n=4):
"""Baustein mit n Atomen und einer braucht-Kette a0←a1←…←a(n-1) (n-1 Kanten)."""
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
atome = [db.insert("atome", topic=topic, titel=f"Atom {i}", typ="begriff",
definition="d", status="neu", baustein_id=b, ord=i, braucht=db.j([]))
for i in range(n)]
for i in range(1, n): # Atom i braucht Atom i-1
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, atome[i], atome[i - 1]))
return b, atome
def test_spec_zu_mermaid_flowchart():
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)
assert out.startswith("flowchart TD")
assert 'n1["A"]' in out and "n1 --> n2" in out
assert '"' not in out.split("\n", 1)[1].replace('["', "").replace('"]', "") # Label entschärft
async def test_fake_judge_nein_keine_diagramme():
topic = topic_anlegen("diag-leer")
b, _ = _baustein_mit_kette(topic, n=2)
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
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"
async def test_llm_diagramm_plan_spec_verify(monkeypatch):
"""LLM-Zweig: Judge sagt nötig → Spec → Mermaid → Grounding-Panel verifiziert."""
import fake_agents
topic = topic_anlegen("diag-llm")
# Baustein OHNE genug Kanten (kein DAG) → LLM-Zweig entscheidet
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="Automat", ord=0, status="neu")
for i in range(3):
db.insert("atome", topic=topic, titel=f"Zustand {i}", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
lambda p: {"noetig": True, "typ": "state"})
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,))
assert d["status"] == "verifiziert" and d["quelle"] == "llm" and d["typ"] == "state"
assert d["mermaid"].startswith("stateDiagram-v2")
async def test_llm_diagramm_grounding_verwirft(monkeypatch):
"""Grounding-Panel einstimmig ok=false → Diagramm verworfen (fail-closed)."""
import fake_agents
topic = topic_anlegen("diag-verwirf")
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="X", ord=0, status="neu")
for i in range(3):
db.insert("atome", topic=topic, titel=f"K{i}", typ="begriff", definition="d",
status="neu", baustein_id=b, ord=i, braucht=db.j([]))
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan",
lambda p: {"noetig": True, "typ": "flow"})
monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Verify",
lambda p: [{"ok": False, "mangel": "erfundener Knoten"}])
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
await diagramme.bauen(ctx)
assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "verworfen"
async def test_marker_wird_zu_fence():
"""Assembly: platzierter <!-- diagramm: id --> → ```mermaid-Fence."""
import guide
topic = topic_anlegen("diag-place")
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<!-- diagramm: {d} -->\n\nMehr Text.")
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
assert "```mermaid" in lang and "flowchart TD" in lang
assert "<!-- diagramm:" not in lang # Marker ersetzt
async def test_toter_diagramm_marker_wird_entfernt():
import guide
topic = topic_anlegen("diag-tot")
b, _ = _baustein_mit_kette(topic, n=2) # kein Diagramm gebaut
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang="Text.\n\n<!-- diagramm: 999 -->\n\nMehr.")
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
assert "<!-- diagramm:" not in lang and "```mermaid" not in lang
async def test_guide_messen_meldet_toten_marker():
"""Ein Marker ohne verifiziertes Diagramm → diagramm_marker_tot in guide.messen."""
import guide
topic = topic_anlegen("diag-qa")
b, _ = _baustein_mit_kette(topic, n=4)
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
text_lang="Text.\n\n<!-- diagramm: 777 -->\n\n" + "Wort " * 60)
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
assert "diagramm_marker_tot" in {x["art"] for x in guide.messen(ctx)}
async def test_marker_platzieren_deterministisch():
"""Der Writer setzt keine Beispiel-Marker mehr — _marker_platzieren fügt sie
hinter den Atom-Absatz ein (atom-genau)."""
import guide
topic = topic_anlegen("platz")
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([]))
art = db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert",
inhalt=db.j({"form": "tabelle", "tabelle": "| A | B |\n|---|---|\n| 1 | 2 |"}))
lang = f"#### X\n<!-- atom: {a} | X -->\nErklärung von X.\n\nWeiterer Absatz zum Thema."
out = guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)), lang)
assert f"<!-- beispiel: {art} -->" in out
# der Marker steht NACH dem Atom-Absatz
assert out.index("<!-- beispiel:") < out.index("Weiterer Absatz")
# idempotent: zweiter Lauf dupliziert nicht
assert guide._marker_platzieren(db.one("SELECT * FROM bausteine WHERE id=?", (b,)),
out).count("<!-- beispiel:") == 1

View File

@@ -146,7 +146,7 @@ async def test_ebenen_entfernen_kaskade():
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
assert set(resets) == {"struktur", "diagramme", "guide"} # diagramme hängt an struktur
assert set(resets) == {"struktur", "guide"} # guide hängt an struktur
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)