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

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