This commit is contained in:
team3
2026-07-12 20:02:13 +02:00
parent 31a42bbf1d
commit cde1721d10
23 changed files with 731 additions and 219 deletions

View File

@@ -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, `<!-- diagramm: id -->`)
→ 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 `<!-- 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)`

View File

@@ -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)

View File

@@ -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),

View File

@@ -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:

View File

@@ -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:

View File

@@ -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\): (<!-- atom: \d+ \| (.+?) -->)",
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"(<!-- diagramm: \d+ -->)", 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,

View File

@@ -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"<!--\s*atom:\s*(\d+)\s*\|")
# 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")
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"<!-- beispiel: {pending} -->", ""]
pending = None
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
def _beispiel_einsetzen(baustein_id: int, lang: str) -> str:
"""Assembly: `<!-- beispiel: id -->` → gerendertes Verbatim-Beispiel; tote Marker weg."""
if "<!-- beispiel:" not in lang and "<!--beispiel:" not in lang:
return lang
je_id = {aid: inh for a in _atome_von(baustein_id)
for aid, inh in _beispiel_verbatim(a["id"])}
def sub(m: re.Match) -> str:
inh = je_id.get(int(m.group(1)))
return _beispiel_render(inh) if inh else ""
return _MARKER_BEISPIEL.sub(sub, lang)
def _diagramme_von(baustein_id: int) -> list[dict]:
@@ -76,13 +150,6 @@ def _diagramme_von(baustein_id: int) -> list[dict]:
" ORDER BY id", (baustein_id,))
def _diagramm_angebot(baustein_id: int) -> str:
"""Verfügbare Diagramme für den Writer — er platziert den Marker beim Absatz."""
zeilen = [f"<!-- diagramm: {d['id']} --> — {_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: `<!-- diagramm: id -->` → ```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 `<!-- beispiel: id -->`-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"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
treffer.append(a["titel"])
@@ -428,13 +514,14 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> 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 <!-- 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
befunde.append({"art": "beispiel_marker_tot", "item": str(b["id"]),
"detail": f"Stray Beispiel-Marker <!-- beispiel: {m} -->"
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

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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) {
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
<h3 style="margin-top: 0">{{ s.titel }}</h3>
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
<details v-if="ansicht === 'erklaerend' && s.fragen?.length" class="antworten">
<summary>Antworten zu Prüfe dich"</summary>
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
<div class="frage">{{ f.frage }}</div>
<div v-html="htmlAntwort(s, fi)"></div>
</div>
</details>
</div>
</template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>

View File

@@ -64,7 +64,7 @@ const MARKER = /<!--\s*atom:.*?-->/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 ' ' })

View File

@@ -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;

View File

@@ -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": […]}}}}

View File

@@ -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": "…"}}]

View File

@@ -0,0 +1,23 @@
<!-- template:Braucht-Aufloesung -->
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}}]

View File

@@ -16,13 +16,18 @@ SECTION (lang):
Regeln:
- Alle Marker-Zeilen (<!-- atom: … -->) 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===
<der korrigierte Fließtext>
===KOMPAKT===
<die korrigierten Stichpunkte>
=====

View File

@@ -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 35 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 `<!-- diagramm: id -->`-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===
<der ausführliche Fließtext mit Markern und Formeln>
===KOMPAKT===
<die 23 Stichpunkte>
=====

View File

@@ -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<!-- beispiel: {art} -->\n\nMehr."
out = guide._beispiel_einsetzen(b, lang)
assert md in out and "<!-- beispiel:" not in out
# toter Marker verschwindet
assert "<!-- beispiel:" not in guide._beispiel_einsetzen(b, "x\n<!-- beispiel: 999 -->\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"<!-- atom: {a} | X -->\n<!-- beispiel: 777 -->\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)}

View File

@@ -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"<!-- atom: {a} | A -->\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

View File

@@ -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 <!-- diagramm: id --> → ```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<!-- diagramm: {d['id']} -->\n\nMehr Text.")
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
@@ -128,3 +114,23 @@ async def test_guide_messen_meldet_toten_marker():
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

@@ -39,7 +39,11 @@ def _pruefe_endzustand(topic, run_id):
assert "| E -->" 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 "<!-- beispiel:" not in md
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert kaps, "keine Kapitel gebildet"
for k in kaps:

View File

@@ -1,6 +1,7 @@
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
import db
import inventar
import llm
import struktur
from conftest import run_anlegen, topic_anlegen
@@ -224,6 +225,30 @@ def test_level_konflikt_absenkung():
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
async def test_braucht_fallback_kompositum():
# braucht-Titel "Approximationsalgorithmus" (Kompositum) trifft weder norm noch
# titel_kern das Atom "Approximativer Algorithmus" (Phrase). Der Fallback-Judge
# legt die Kante; _level_konflikte_loesen senkt die Voraussetzung von M auf E.
topic = topic_anlegen("brfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
a = db.insert("atome", topic=topic, titel="Absolute Güte", typ="begriff",
definition="d", level="E", status="neu", ziel_id=ziel,
braucht=db.j(["Approximationsalgorithmus"]))
b = db.insert("atome", topic=topic, titel="Approximativer Algorithmus", typ="begriff",
definition="d", level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
inventar._kanten_aufloesen(topic) # deterministisch: verfehlt das Kompositum
assert db.query("SELECT * FROM kanten WHERE topic=? AND von_atom=?", (topic, a)) == []
await inventar._braucht_fallback(ctx)
kante = db.one("SELECT * FROM kanten WHERE topic=? AND von_atom=? AND zu_atom=?",
(topic, a, b))
assert kante and kante["art"] == "braucht"
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (b,))["level"] == "E"
async def test_level_split_ordnung_kapitel():
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
# Kapitel je Durchgang, kein level_mix/kapitel_level