This commit is contained in:
team3
2026-07-12 04:20:07 +02:00
parent 14bcf1b8e9
commit a284e03225
29 changed files with 866 additions and 329 deletions

View File

@@ -18,10 +18,10 @@ stop:
@echo "gestoppt."
test:
cd backend && python -m pytest ../tests -x -q
cd backend && python3 -m pytest ../tests -x -q
test-e2e:
cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q
cd backend && CREATOR_FAKE_AGENTS=1 python3 -m pytest ../tests/test_e2e.py -x -q
build:
cd frontend && npm run build

View File

@@ -82,11 +82,12 @@ async def _api_slot(key: str) -> None:
class AgentErgebnis:
__slots__ = ("rc", "text", "err", "tokens", "wait_s")
__slots__ = ("rc", "text", "err", "tokens", "wait_s", "model")
def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None):
self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {}
self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie
self.model = "" # aufgelöstes Modell — run_agent füllt es (Ledger)
@property
def ok(self) -> bool:
@@ -161,12 +162,13 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
await _api_slot(key)
wait_s = time.time() - warte_start
try:
res = await _text_api(key, prompt, timeout, model)
res = await _text_api(key, prompt, timeout, model, role)
finally:
_api_inflight -= 1
if res.rc == 0:
_erfolg_melden()
res.wait_s = wait_s
res.model = model
return res
await _drossel_warten(key)
wait_s = time.time() - warte_start
@@ -175,6 +177,7 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
else:
res = await _claude_cli(key, prompt, timeout, model, capabilities)
res.wait_s = wait_s
res.model = model
return res
finally:
_active.pop(key, None)
@@ -335,12 +338,20 @@ _API_MODEL_OPTS = {
# Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene).
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
"thinking": {"type": "disabled"}},
# native Route (judge/guide): ungedrosselt liefen Merge-Judges ins Output-Cap
# (5871 % Parse-Fehler). role=guide (Writer) behält Thinking — _text_api
# nimmt die thinking-Option dort wieder raus.
"minimax/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
}
async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis:
async def _text_api(key: str, prompt: str, timeout: int, model: str,
role: str = "judge") -> AgentErgebnis:
opts = dict(_API_MODEL_OPTS.get(model, {}))
if role == "guide":
opts.pop("thinking", None) # Writer braucht Denkraum — nur Judges kalt
body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})}
"messages": [{"role": "user", "content": prompt}], **opts}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:

View File

@@ -74,6 +74,13 @@ ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestück
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
LEVEL_RANG = {"E": 0, "M": 1, "S": 2} # Durchgänge: Grundgerüst / Standard / Feinheiten
DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang})
"E": "Grundgerüst, erster Durchgang — der Leser kennt noch nichts,"
" alles voraussetzungsfrei erklären",
"M": "Standardstoff, zweiter Durchgang — der Leser kennt das Grundgerüst",
"S": "Feinheiten, dritter Durchgang — der Leser kennt Grundgerüst und Standardstoff",
}
BAUSTEIN_MIN_ATOME = 4
BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call

View File

@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS quellen(
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
intro TEXT NOT NULL DEFAULT '');
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]');
CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
@@ -90,11 +90,12 @@ CREATE TABLE IF NOT EXISTS lernziele(
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER);
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
level TEXT NOT NULL DEFAULT 'M');
CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
art TEXT NOT NULL DEFAULT 'judge');
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
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 '[]',
@@ -122,7 +123,10 @@ def _init_schema(con: sqlite3.Connection) -> None:
"ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"):
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'",
"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 '[]'"):
try:
con.execute(zusatz)
except sqlite3.OperationalError:

View File

@@ -75,10 +75,13 @@ def _soll_konsens(prompt: str):
def _soll_beleg(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
for _, saetze in PUNKTE:
if saetze[0] in text and saetze[0] in prompt:
return {"zitat": saetze[0]}
return {"zitat": ""}
zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE:"), re.MULTILINE)
out = []
for i, punkt in zeilen:
for p, saetze in PUNKTE:
if p == punkt.strip() and saetze[0] in text:
out.append({"soll": int(i), "zitat": saetze[0]})
return out
def _extraktion(prompt: str):
@@ -116,14 +119,6 @@ def _soll_stich(prompt: str):
return {"soll": next(iter(punkte.values()), "fremd")}
def _anker_fix(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
for a in ATOME:
if a["titel"] in prompt and a["zitat"] in text:
return {"zitat": a["zitat"]}
return {"zitat": ""}
def _anker_fix_batch(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
@@ -176,9 +171,16 @@ def _kapitel_intro(prompt: str):
def _level_kalibrierung(prompt: str):
ids = _ids(_text_nach(prompt, "ATOME:"))
# deterministisch: erstes Atom wird Grundgerüst, Rest Standardstoff
return [{"atom": i, "level": "E" if n == 0 else "M"} for n, i in enumerate(ids)]
# deklariertes Level der Fake-Welt zurückgeben (Titel-Match) — der E2E-Pfad
# erzeugt so echte E/M/S-Durchgänge; Unbekanntes wird Standardstoff
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
je_titel = {a["titel"]: a["level"] for a in ATOME}
return [{"atom": int(i), "level": je_titel.get(t.strip(), "M")} for i, t in zeilen]
def _titel_fix(prompt: str):
zeilen = re.findall(r"^(\d+): ", _text_nach(prompt, "ATOME:"), re.MULTILINE)
return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen]
def _kapitel_schnitt(prompt: str):
@@ -196,7 +198,7 @@ def _zyklus(prompt: str):
def _writer(prompt: str):
ziel = re.search(r"Lernziel: (.+)", prompt)
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) \| [EMS] -->)",
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]
@@ -240,8 +242,9 @@ _HANDLER = {
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
"Atom-Merge": _merge,
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
"Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch,
"Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
"Atom-Anker-Fix-Batch": _anker_fix_batch,
"Atom-Luecke": _luecke, "Atom-Titel-Fix": _titel_fix,
"Soll-Abgedeckt": lambda p: {"abgedeckt": True},
"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,

View File

@@ -17,7 +17,7 @@ from pathlib import Path
import db
import llm
import textkit
from config import SECTION_WOERTER_PRO_ATOM
from config import DURCHGANG, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
log = logging.getLogger("creator2.guide")
@@ -50,10 +50,11 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
_MARKER_VOLL = re.compile(r"<!--\s*atom:[^>]*-->") # ganze Marker-Zeile (für Text-Checks)
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch")
KRITISCH = ("marker_fehlend", "marker_fremd", "ziel_ohne_anker", "fachlich_falsch")
def _bausteine(topic: str) -> list[dict]:
@@ -86,7 +87,7 @@ def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
def _atom_paket(a: dict) -> str:
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} | {a['level']} -->\n"
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
if beispiel:
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
@@ -101,16 +102,40 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
return lo * n_atome, hi * n_atome
def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels plus direkte braucht-Voraussetzungen. Der Writer knüpft an,
statt neu zu erklären (Dopplungs-Schutz zwischen den Durchgängen)."""
rang = LEVEL_RANG.get(b["level"], 1)
atome = _atome_von(b["id"])
ids = {a["id"] for a in atome}
kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)):
if LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
if ids:
marks = ",".join("?" * len(ids))
for a in db.query(f"SELECT a.* FROM kanten k JOIN atome a ON a.id=k.zu_atom"
f" WHERE k.art='braucht' AND k.status='aktiv'"
f" AND k.von_atom IN ({marks})", tuple(ids)):
if a["id"] not in ids and LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
# ── Stages ────────────────────────────────────────────────────────────────────
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"]}
lo, hi = _laenge_band(len(atome))
res = await llm.call(ctx, stage="writer", template="Guide-Writer",
werte={"titel": b["titel"], "ziel": ziel["text"],
werte = {"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
"min_woerter": lo, "max_woerter": hi,
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
"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)
if res is None:
return "writer" # Stage bleibt, nächster Lauf versucht erneut
@@ -119,11 +144,7 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
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={"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
erwartet=dict)
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", ""))
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
@@ -135,6 +156,46 @@ def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
return [a["id"] for a in atome if a["id"] not in da]
def _marker_fremd_ids(text: str, atome: list[dict]) -> list[int]:
"""Marker fremder Atome (z. B. aus dem Anknüpf-Kontext übernommen)."""
eigene = {a["id"] for a in atome}
return sorted({int(m) for m in _MARKER.findall(text)} - eigene)
_FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))"
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
_EN_STOP = frozenset("the and of is that this are with for as by be from which were"
" has have not can will or an it its when each such".split())
def _stil_auftraege(text: str, wo: str) -> list[str]:
"""Deterministische Lesbarkeits-Checks je Fassung: Vertröstungs-Floskeln
(der alte Vorwärts-Fix klebte „dazu später mehr“ ein statt zu erklären),
Quellen-Referenzen (Satz 6.25 — der Leser hat kein nummeriertes Skript)
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
text = _MARKER_VOLL.sub("", text)
auftraege = []
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
f" stattdessen kurz mit Bekanntem einordnen oder die Stelle"
f" entfernen.")
for m in sorted({m.group(0) for m in _LEAK.finditer(text)}):
auftraege.append(f"{wo}: Quellen-Referenz „{m}“ — der Leser kennt kein"
f" nummeriertes Skript und keine Aufgabenstellung; Inhalt"
f" eigenständig formulieren.")
for absatz in text.split("\n\n"):
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
kurz = " ".join(absatz.split())[:60]
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
return auftraege
def _mathe_auftraege(text: str, wo: str) -> list[str]:
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
@@ -182,20 +243,29 @@ def _mathe_auftraege(text: str, wo: str) -> list[str]:
return auftraege
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[str]:
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
qa: bool = False) -> list[str]:
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise,
Lesbarkeit. qa=True (Ebenen-QA): Marker/Länge/Vorwärts haben dort eigene
Befund-Arten und ein weiteres Band (Lektion 75) — nur die Stil-Checks."""
atome = _atome_von(b["id"])
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
auftraege = []
if not qa:
auftraege += [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
for i in _marker_fehlend(lang, atome)]
auftraege += [f"KRITISCH: Marker {i} gehört nicht zu diesem Baustein"
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
for i in _marker_fremd_ids(lang, atome)]
lo, hi = _laenge_band(len(atome))
woerter = len(lang.split())
if woerter > hi:
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
elif woerter < lo:
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
f" oder entfernen — nie unerklärt verwenden."
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz"
f" erklären oder die Verwendung entfernen — nie unerklärt lassen,"
f" NICHT vertrösten."
for t in _vorwaertsverweise(topic, b, lang)]
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
@@ -204,21 +274,49 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "") -> list[st
if "6=" in lang:
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
" durch $\\neq$ ersetzen.")
for nr, absatz in enumerate(lang.split("\n\n"), 1):
# Marker-Zeilen zählen nicht — sie stehen im selben Block wie der Absatz
zeilen = [z for z in absatz.splitlines()
if z.strip() and not z.strip().startswith("<!--")]
if not zeilen or any(z.lstrip().startswith(("-", "*", "#", "$$", ">"))
for z in zeilen):
continue # Listen/Überschriften/Display-Formeln sind kein Fließtext
w = sum(len(z.split()) for z in zeilen)
if w > 110: # Writer-Regel 4090, hart <110 — Textwände töten den Lesefluss
auftraege.append(f"Langtext, Absatz {nr} hat {w} Wörter (Regel: 4090):"
f" in kürzere Absätze teilen.")
inhalt = [z.strip() for z in lang.splitlines()
if z.strip() and not z.strip().startswith("<!--")]
for i, z in enumerate(inhalt):
if z.startswith("####") and (not z.lstrip("# ").strip() or i + 1 == len(inhalt)
or inhalt[i + 1].startswith("#")):
auftraege.append(f"Überschrift „{z[:50]}“ ohne folgenden Text: entfernen"
f" oder den zugehörigen Absatz ergänzen.")
auftraege += _mathe_auftraege(lang, "Langtext")
auftraege += _stil_auftraege(lang, "Langtext")
if kompakt:
if _MARKER.search(kompakt):
auftraege.append("Atom-Marker in der Kompakt-Fassung: Marker gehören nur"
" in den Langtext — aus der Kompakt-Fassung entfernen.")
if any(z.lstrip().startswith(">") for z in kompakt.splitlines()):
auftraege.append("Blockquote („>“) in der Kompakt-Fassung: Inhalt in"
" eigene Stichpunkte umformulieren.")
auftraege += _mathe_auftraege(kompakt, "Kompakt-Fassung")
auftraege += _stil_auftraege(kompakt, "Kompakt-Fassung")
return auftraege
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Die Kapitel-ord
läuft global über die Durchgänge (E<M<S) — frühere Durchgänge gelten damit
automatisch als bekannt, spätere als Vorgriff. Nur signifikante Titel
(≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
kap_ord = {k["id"]: k["ord"] for k in
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
mein_kapitel = je_baustein.get(b["id"], 0)
text = textkit.norm(_MARKER.sub("", lang))
text = textkit.norm(_MARKER_VOLL.sub("", lang))
treffer = []
for a in db.query(
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
@@ -279,8 +377,10 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re")
return "done"
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
# Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen:
# unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60
# offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen.
return "done"
_STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
@@ -313,7 +413,9 @@ async def _kapitel_intros(ctx: llm.Kontext) -> None:
liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}"
for b in bausteine)
res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro",
werte={"titel": kap["titel"], "bausteine": liste},
werte={"titel": kap["titel"], "bausteine": liste,
"durchgang": DURCHGANG.get(kap["level"],
DURCHGANG["M"])},
role="judge", n=len(bausteine), item=f"k{kap['id']}",
erwartet=dict)
intro = str((res or {}).get("intro", "")).strip()
@@ -333,6 +435,22 @@ 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 = []
for a in _atome_von(baustein_id):
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 frage in lang:
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."""
@@ -346,12 +464,15 @@ def kapitel_struktur(topic: str) -> list[dict]:
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
k = kaps.get(k_id, {})
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
"intro": k.get("intro", ""), "sections": []})
"intro": k.get("intro", ""),
"level": k.get("level", b["level"]), "sections": []})
lang = sec["text_lang"] if sec else ""
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": sec["text_lang"] if sec else ""})
"lang": lang,
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
return kapitel
@@ -388,6 +509,12 @@ def messen(ctx: llm.Kontext) -> list[dict]:
for i in _marker_fehlend(lang, atome):
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
"detail": f"Atom {i}"})
for i in _marker_fremd_ids(lang, atome):
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
"detail": f"Atom {i}"})
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]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
@@ -457,19 +584,22 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
ctx.ebene = EBENE
betroffen: dict[int, list[str]] = {}
betroffen: dict[int, list[dict]] = {}
for b in befunde:
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b["art"])
for b_id, arten in betroffen.items():
betroffen.setdefault(b_id, []).append(b)
for b_id, liste in betroffen.items():
arten = [x["art"] for x in liste]
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
stage = "writer" if "section_fehlt" in arten else "pruefer"
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
else:
auftraege = [f"Behebe: {a}" for a in arten]
# Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix
# raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen)
auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste]
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
db.update("bausteine", "id", b_id, status="repair")
if betroffen:

View File

@@ -7,6 +7,7 @@ Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextrakt
import asyncio
import logging
import re
import db
import embedding
@@ -22,7 +23,7 @@ log = logging.getLogger("creator2.inventar")
EBENE = "inventar"
TYPEN = ("begriff", "aussage", "verfahren")
LEVELS = ("E", "M", "S")
PAAR_CHUNK = 40 # Merge-Paare pro Judge-Call (Lektion 34/55)
PAAR_CHUNK = 10 # Merge-Paare pro Judge-Call (40 riss das Output-Cap: 5871 % Parse-Fehler)
def aktive_atome(topic: str) -> list[dict]:
@@ -32,25 +33,39 @@ def aktive_atome(topic: str) -> list[dict]:
# ── Extraktion ────────────────────────────────────────────────────────────────
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion.
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
schon = db.one(
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
" LIMIT 1", (ctx.topic, quelle["id"]))
if schon:
if schon and not force:
db.update("quellen", "id", quelle["id"], status="atome")
return
text = korpus.quelltext(quelle)
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
else "Atom-Extraktion")
fehl: list[int] = []
async def reader(offset: int, chunk: str, r: int) -> None:
async def reader(offset: int, chunk: str, r: int, tiefe: int = 0) -> None:
res = await llm.call(ctx, stage="extraktion", template=template,
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
role="quick", item=f"q{quelle['id']}-o{offset}-r{r}", erwartet=list)
role="quick",
item=f"q{quelle['id']}-o{offset}-r{r}" + "-h" * tiefe,
erwartet=list)
if res is None:
# Leerantwort/Parse-Fehler ist meist das Output-Cap (stop=max_tokens,
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
if tiefe < 2 and len(chunk) > 2000:
mitte = len(chunk) // 2
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
else:
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
return
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
@@ -79,7 +94,9 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
await asyncio.gather(*(reader(off, chunk, r)
for off, chunk in stuecke
for r in range(READER_JE_ABSCHNITT)))
db.update("quellen", "id", quelle["id"], status="atome")
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl))
db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome")
# ── Dedup ─────────────────────────────────────────────────────────────────────
@@ -202,7 +219,11 @@ async def _judge_dedup(ctx: llm.Kontext) -> None:
if isinstance(e, dict) and e.get("paar") == n:
urteile.append(bool(e.get("gleich")))
break
if len(urteile) == MERGE_PANEL and all(urteile):
if len(urteile) < MERGE_PANEL:
continue # Panel unvollständig (Parse-/Infra-Ausfall): Paar bleibt
# offen für die nächste Runde — ein dauerhaftes „nein" ließ
# Dubletten systematisch überleben (aak: 42/73 Calls unparsbar)
if all(urteile):
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
else (b["id"], a["id"]))
_merge(topic, gew, ver)
@@ -221,12 +242,20 @@ def _erster_anker(atom_id: int) -> str:
def _kanten_aufloesen(topic: str) -> None:
"""braucht-Titel → Atom-IDs (Norm-Gleichheit; Unauflösbares fällt still weg)."""
"""braucht-Titel → Atom-IDs. Norm-Gleichheit zuerst, Titel-Kern als zweite
Stufe (Norm allein löste nur 7279 % auf — der Rest fiel still weg und
Ordnung/Level-Logik arbeiteten auf einem lückigen Graph)."""
atome = aktive_atome(topic)
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"])
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 ziel and ziel != a["id"]:
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
@@ -269,6 +298,20 @@ async def bauen(ctx: llm.Kontext) -> None:
# ── QA + Repair ───────────────────────────────────────────────────────────────
_TITEL_KATALOG = re.compile(r"^(Satz|Lemma|Korollar|Bemerkung|Definition|Aufgabe"
r"|Übung|Transformation|Theorem)\s+[IVX]*\d", re.IGNORECASE)
def _titel_kaputt(titel: str) -> bool:
"""Katalognummern-Titel (Satz 6.26 — der Leser hat kein nummeriertes Skript)
und abgerissene Fragmente (aak: „Teste für alle V 0 ⊆ V mit |")."""
if _TITEL_KATALOG.match(titel):
return True
if titel.rstrip().endswith(("|", ",", ";", ":", "-", "", "(", "{", "", "=")):
return True
return titel.count("(") != titel.count(")")
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = aktive_atome(ctx.topic)
@@ -281,9 +324,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
else:
belegte_soll.add(a["soll_id"])
if _titel_kaputt(a["titel"]):
befunde.append({"art": "titel_katalog", "item": str(a["id"]), "detail": a["titel"]})
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
if p["id"] not in belegte_soll:
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='teilweise'",
(ctx.topic,)):
befunde.append({"art": "quelle_unvollstaendig", "item": str(q["id"]),
"detail": q["titel"]})
return befunde
@@ -299,6 +348,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return await _soll_stichentscheid(ctx, item)
if b["art"] == "soll_ohne_atom":
return await _luecke_schliessen(ctx, item)
if b["art"] == "quelle_unvollstaendig":
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
if q:
await _extrahiere_quelle(ctx, q, force=True)
return True
return False
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
@@ -306,7 +360,11 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
anker_ids = [int(b["item"]) for b in befunde
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
rest = [b for b in befunde if b["art"] != "atom_ohne_anker"]
titel_ids = [int(b["item"]) for b in befunde
if b["art"] == "titel_katalog" and str(b["item"]).isdigit()]
if titel_ids:
bewegt |= await _titel_fixen(ctx, titel_ids)
rest = [b for b in befunde if b["art"] not in ("atom_ohne_anker", "titel_katalog")]
bewegt |= any(await llm.alle(einer(b) for b in rest))
if bewegt:
_anker_dedup(ctx.topic)
@@ -316,6 +374,27 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return bewegt
async def _titel_fixen(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
"""Kaputte Titel (Katalognummern, Fragmente) aus der Definition neu benennen."""
atome = [a for i in atom_ids if (a := db.one("SELECT * FROM atome WHERE id=?", (i,)))
and a["status"] not in ("gemerged", "verworfen")]
if not atome:
return False
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in atome)
res = await llm.call(ctx, stage="titel_fix", template="Atom-Titel-Fix",
schritt="fix", werte={"atome": liste}, role="judge",
n=len(atome), item=f"t{atome[0]['id']}", erwartet=list)
gueltig = {a["id"] for a in atome}
bewegt = False
for e in res or []:
if isinstance(e, dict) and e.get("atom") in gueltig:
titel = str(e.get("titel", "")).strip()
if titel and not _titel_kaputt(titel):
db.update("atome", "id", e["atom"], titel=titel)
bewegt = True
return bewegt
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)

View File

@@ -136,12 +136,19 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
inhalt = str(q.get("inhalt", "")).strip()
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
continue
url = str(q.get("url", ""))[:500]
# URL-Dedup: dieselbe Seite über Runden erneut geholt liefert leicht
# abweichende Extrakte (anderer Content-Hash) und zählte dann als
# „unabhängiger" Beleg im ≥2-Quellen-Konsens (Lernen: eine URL 4×).
if url and db.one("SELECT id FROM quellen WHERE topic=? AND url=?",
(ctx.topic, url)):
continue
snap = _snapshot_schreiben(ctx.topic, inhalt)
if snap is None:
continue
titel = str(q.get("titel", ""))[:200]
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
url=str(q.get("url", ""))[:500], snapshot=snap[0], hash=snap[1],
url=url, snapshot=snap[0], hash=snap[1],
runde=runde, status="neu", rolle=_rolle(titel, lens))
neu += 1
return neu
@@ -331,38 +338,76 @@ def messen(ctx: llm.Kontext) -> list[dict]:
return befunde
async def _beleg_nachsuchen(ctx: llm.Kontext, soll_id: int) -> bool:
"""Gezielter Beleg-Judge in noch nicht zitierten Quellen; ein bestätigter
Punkt ohne Fund wird zum Kandidaten zurückgestuft (Konsens-Regel bleibt
hart). → True nur, wenn sich etwas geändert hat."""
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
if not p:
async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
"""Gezielte Beleg-Judges, gebündelt: EIN Call je Quelle mit ALLEN dort noch
offenen Punkten — Punkt×Quelle einzeln waren 3072 Calls / 27 % der
Themen-Tokens (Lernen). Negativ-Cache soll.geprueft: erfolglos geprüfte
(Punkt, Quelle)-Paare werden über Repair-Iterationen nie wiederholt.
Bestätigte Punkte ohne Fund in irgendeiner Quelle → zurück zu Kandidat."""
punkte = [p for s in dict.fromkeys(soll_ids)
if (p := db.one("SELECT * FROM soll WHERE id=?", (s,)))]
if not punkte:
return False
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
bewegt = False
async def eine_quelle(q: dict) -> None:
nonlocal bewegt
faellig = []
for p in punkte:
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
andere = [q for q in db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
if q["id"] not in belegte]
for q in andere:
if q["id"] not in belegte and q["id"] not in db.uj(p["geprueft"]):
faellig.append(p)
if not faellig:
return
text = quelltext(q)
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig)
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
schritt="soll", role="judge", item=f"s{p['id']}-q{q['id']}",
werte={"punkt": p["punkt"],
"text": quelltext(q)[:SOLL_CHUNK_CHARS]},
erwartet=dict)
zitat = str((res or {}).get("zitat", "")).strip()
if zitat and textkit.finde_zitat(quelltext(q), zitat) is not None:
belege = db.uj(p["belege"]) + [{"quelle": q["id"], "zitat": zitat}]
schritt="soll", role="judge", item=f"q{q['id']}",
n=len(faellig),
werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]},
erwartet=list)
if res is None:
return # Ausfall: nicht als „geprüft" verbuchen
gefunden: dict[int, str] = {}
for e in res:
if not isinstance(e, dict):
continue
zitat = str(e.get("zitat", "")).strip()
if zitat and e.get("soll") in {p["id"] for p in faellig} \
and textkit.finde_zitat(text, zitat) is not None:
gefunden[e["soll"]] = zitat
for p in faellig:
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
if p["id"] in gefunden:
belege = db.uj(frisch["belege"]) + [{"quelle": q["id"],
"zitat": gefunden[p["id"]]}]
db.update("soll", "id", p["id"], belege=db.j(belege))
return True
if p["status"] == "bestaetigt":
bewegt = True
else:
geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]})
db.update("soll", "id", p["id"], geprueft=db.j(geprueft))
await llm.alle(eine_quelle(q) for q in quellen)
for p in punkte:
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
if frisch["status"] != "bestaetigt" \
or len(db.uj(frisch["belege"])) > len(db.uj(p["belege"])):
continue
belegte = {bl["quelle"] for bl in db.uj(frisch["belege"])}
rest = [q for q in quellen if q["id"] not in belegte
and q["id"] not in db.uj(frisch["geprueft"])]
if not rest: # wirklich überall erfolglos gesucht — Konsens-Regel bleibt hart
db.update("soll", "id", p["id"], status="kandidat")
return True
return False
bewegt = True
return bewegt
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
offene = [int(b["item"]) for b in befunde if b["art"] == "soll_kandidat_offen"]
bewegt = any(await llm.alle(_beleg_nachsuchen(ctx, s) for s in punkte + offene))
bewegt = await _belege_nachsuchen(ctx, punkte + offene)
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
await _konsens(ctx)
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",

View File

@@ -87,11 +87,12 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
versuch += 1
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
start = time.monotonic()
status, tokens, err, wait_ms = "error", None, "", 0
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
try:
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
tokens, err = res.tokens, res.err
wait_ms = int(res.wait_s * 1000)
model = res.model
if res.ok:
if erwartet is str:
status = "ok"
@@ -112,7 +113,7 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
finally:
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
template=template, template_hash=thash, role=role,
provider=ctx.provider, status=status,
provider=ctx.provider, model=model, status=status,
dur_ms=int((time.monotonic() - start) * 1000),
wait_ms=wait_ms,
tokens=tokens, meta={"err": err[:300]} if err else None)

View File

@@ -27,9 +27,12 @@ GEWICHTE = {
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
"level_konflikt": 3.0, "level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "ziel_ohne_anker": 3.0,
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"det_check": 0.5,
}

View File

@@ -10,8 +10,8 @@ from collections import defaultdict
import db
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, KAPITEL_BAUSTEINE,
ZIELE_CHUNK_ATOME)
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.struktur")
@@ -186,8 +186,9 @@ def _anker_rang(topic: str) -> dict[int, tuple]:
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
Ziel-Gruppen ins Band bringen — kleine Ziele desselben Soll-Punkts mergen
(das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel), große entlang der
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
Topo-Reihenfolge splitten ("Teil n")."""
topic = ctx.topic
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
@@ -197,12 +198,13 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[a["ziel_id"]].append(a)
gruppen[(a["ziel_id"], a["level"])].append(a)
# kleine Ziele mergen (kleinste zuerst). Partnerwahl thematisch statt per
# Soll-Punkt — das Soll ist seit der Entkopplung reine Checkliste (152 feine
# Punkte hätten sonst keine Merge-Partner): meiste braucht-Kanten zwischen
# den Gruppen, sonst Nachbar in der Quellreihenfolge.
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
# Partnerwahl thematisch statt per Soll-Punkt — das Soll ist seit der
# Entkopplung reine Checkliste (152 feine Punkte hätten sonst keine
# Merge-Partner): meiste braucht-Kanten zwischen den Gruppen, sonst
# Nachbar in der Quellreihenfolge.
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
ids1 = {a["id"] for a in g1}
ids2 = {a["id"] for a in g2}
@@ -215,32 +217,34 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
geaendert = True
while geaendert:
geaendert = False
kleine = sorted([z for z, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
key=lambda z: len(gruppen[z]))
for z in kleine:
passende = [p for p in gruppen if p != z
and len(gruppen[p]) + len(gruppen[z]) <= BAUSTEIN_MAX_ATOME]
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
key=lambda k: len(gruppen[k]))
for k in kleine:
passende = [p for p in gruppen if p != k and p[1] == k[1]
and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME]
if not passende:
continue
eigener = gruppen_rang(gruppen[z])
eigener = gruppen_rang(gruppen[k])
def naehe(pp: int) -> tuple:
def naehe(pp: tuple) -> tuple:
r = gruppen_rang(gruppen[pp])
return (-kanten_score(gruppen[z], gruppen[pp]),
return (-kanten_score(gruppen[k], gruppen[pp]),
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
len(gruppen[pp]))
p = min(passende, key=naehe)
for a in gruppen[z]:
db.update("atome", "id", a["id"], ziel_id=p)
gruppen[p] += gruppen.pop(z)
db.update("lernziele", "id", z, status="gemerged")
for a in gruppen[k]:
db.update("atome", "id", a["id"], ziel_id=p[0])
gruppen[p] += gruppen.pop(k)
# Ziel erst still legen, wenn KEIN Level mehr darauf zeigt
if not any(kk[0] == k[0] for kk in gruppen):
db.update("lernziele", "id", k[0], status="gemerged")
geaendert = True
break
# Bausteine anlegen (große Ziele splitten), Atome zuordnen
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
for ziel_id, gruppe in gruppen.items():
for (ziel_id, level), gruppe in gruppen.items():
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
z = ziele.get(ziel_id, {})
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
@@ -252,7 +256,7 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
continue
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel,
status="neu")
status="neu", level=level)
for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
@@ -260,24 +264,33 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
"""Baustein-DAG aus aggregierten Atom-Kanten; Zyklen deterministisch an der
schwächsten Aggregat-Kante gebrochen (wenigste Atom-Kanten)."""
"""Baustein-DAG aus aggregierten Atom-Kanten, JE LEVEL getrennt geordnet;
ord läuft global fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-
Kontiguität bleiben level-blind korrekt). Cross-Level-Kanten zeigen nach der
Konfliktlösung immer auf tiefere Level und sind durch die Blockordnung
automatisch erfüllt. Zyklen deterministisch an der schwächsten Aggregat-Kante
gebrochen (wenigste Atom-Kanten)."""
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
je_atom = {a["id"]: a["baustein_id"] for a in _atome(topic)}
atome = _atome(topic)
je_atom = {a["id"]: a["baustein_id"] for a in atome}
b_level = {b["id"]: b["level"] for b in bausteine}
gewicht: dict = defaultdict(int)
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
bv, bz = je_atom.get(v), je_atom.get(z)
if bv and bz and bv != bz:
if bv and bz and bv != bz and b_level[bv] == b_level[bz]:
gewicht[(bv, bz)] += 1
b_kanten = set(gewicht)
b_ids = [b["id"] for b in bausteine]
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
atome = _atome(topic)
b_rang = {}
for b in bausteine:
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
if a["baustein_id"] == b["id"]), default=(9, 9))
offset = 0
for level in LEVEL_RANG:
b_ids = [b["id"] for b in bausteine if b["level"] == level]
if not b_ids:
continue
b_kanten = {k for k in gewicht if b_level[k[0]] == level}
while True:
zyklus = _finde_zyklus(b_ids, list(b_kanten))
if not zyklus:
@@ -294,28 +307,34 @@ def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) ->
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
(topic, bv, bz))
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
db.update("bausteine", "id", b_id, ord=pos)
db.update("bausteine", "id", b_id, ord=offset + pos)
offset += len(b_ids)
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
"""Stufen als Komplexitätsschichten (4C/ID): Die Extraktion sieht nur ihren
Chunk und stuft absolut (aak: 13 % E → Einstiegs-Durchgang war ein
Lücken-Gerippe). Hier stuft ein Judge je Baustein RELATIV nach — er sieht
alle Atome nebeneinander. E-Atome müssen allein einen kohärenten
Kurzdurchgang tragen; rein vertiefende Bausteine bleiben ohne E."""
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (ctx.topic,))
Lücken-Gerippe). Hier stuft ein Judge je LERNZIEL relativ nach — VOR dem
Schnitt, denn je Level entstehen getrennte Bausteine. Die E-Atome eines
Ziels müssen allein einen kohärenten Kurzdurchgang tragen; rein
vertiefende Ziele bleiben ohne E."""
atome = _atome(ctx.topic)
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?",
(ctx.topic,))}
gruppen: dict = defaultdict(list)
for a in atome:
if a["ziel_id"]:
gruppen[a["ziel_id"]].append(a)
async def einer(b: dict) -> None:
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
if not eigene:
return
async def einer(ziel_id: int, eigene: list[dict]) -> None:
z = ziele.get(ziel_id, {})
titel = z.get("titel") or z.get("text") or ctx.topic
# bewusst OHNE bisheriges Level: der Extraktions-Prior ist verzerrt
# (Chunk-blind, M-Anchoring) und würde die Neubewertung ankern
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in eigene)
res = await llm.call(ctx, stage="level", template="Level-Kalibrierung",
werte={"titel": b["titel"], "atome": liste},
role="judge", n=len(eigene), item=f"lv{b['id']}",
werte={"titel": titel, "atome": liste},
role="judge", n=len(eigene), item=f"lvz{ziel_id}",
erwartet=list)
gueltig = {a["id"]: a["level"] for a in eigene}
for e in res or []:
@@ -326,33 +345,69 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
if atom_id in gueltig and level in ("E", "M", "S") and level != gueltig[atom_id]:
db.update("atome", "id", atom_id, level=level)
await llm.alle(einer(b) for b in bausteine)
await llm.alle(einer(z, g) for z, g in gruppen.items())
def _level_konflikte_loesen(topic: str) -> None:
"""Deterministisch: Eine Voraussetzung darf nie über dem Level ihres Nutzers
liegen — sonst fehlt dem Durchgang sein Fundament. Auflösung durch ABSENKEN
der Voraussetzung (sie gehört ins Grundgerüst des Abhängigen); Anheben würde
Kerninhalte aus dem E-Durchgang verdrängen. Fixpunkt: Level fallen monoton,
terminiert nach max. 2 Stufen je Atom."""
level = {a["id"]: a["level"] for a in _atome(topic)}
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)
if k["von_atom"] in level and k["zu_atom"] in level]
gesenkt = 0
geaendert = True
while geaendert:
geaendert = False
for v, z in kanten: # v braucht z ⇒ level(z) ≤ level(v)
if LEVEL_RANG[level[z]] > LEVEL_RANG[level[v]]:
level[z] = level[v]
db.update("atome", "id", z, level=level[v])
gesenkt += 1
geaendert = True
if gesenkt:
log.info("Level-Konflikte: %d Voraussetzungen abgesenkt", gesenkt)
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge. Das Soll
ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der Judge
liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er unvollständig
(aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). Der Code
erzwingt Reihenfolge + Lückenlosigkeit; ein ungültiges Ergebnis bekommt
EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback). Idempotent."""
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der
Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er
unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen).
Der Code erzwingt Reihenfolge, Lückenlosigkeit UND Größe (≥2 Bausteine außer
im letzten, ≤2×Richtwert-Band — Lernen: 68/73 Singleton-Kapitel); ungültig
bekommt EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback)."""
topic = ctx.topic
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
if not bausteine:
alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
if not alle:
return
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
liste = "\n".join(f"{b['id']}: {b['titel']}{ziele.get(b['ziel_id'], {}).get('text', '')}"
for b in bausteine)
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
pos_global = 0
for level in LEVEL_RANG:
bausteine = [b for b in alle if b["level"] == level]
if not bausteine:
continue
# Positionsnummern statt DB-IDs: nach dem Level-Split sind die IDs
# nicht mehr monoton entlang der Lesefolge — der Judge verlor die
# Reihenfolge und lieferte 0 gültige Schnitte (aak Run 46: 15/15 Fallback)
liste = "\n".join(f"{i + 1}: {b['titel']}"
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
for i, b in enumerate(bausteine))
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
max_groesse = 2 * KAPITEL_BAUSTEINE
folge = [b["id"] for b in bausteine]
pos_von = {bid: i for i, bid in enumerate(folge)}
pos_von = {i + 1: i for i in range(len(folge))}
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
schritt="kapitel", role="judge", n=len(bausteine),
item=tag, erwartet=list,
werte={"bausteine": liste, "richtwert": str(richtwert),
"hinweis": hinweis})
"durchgang": DURCHGANG[level], "hinweis": hinweis})
segmente: list[tuple[str, list[int]]] = []
start = 0
for gruppe in res or []:
@@ -364,25 +419,36 @@ async def _kapitel_bilden(ctx: llm.Kontext) -> None:
return None
segmente.append((titel, folge[start:ende + 1]))
start = ende + 1
return segmente if segmente and start == len(folge) else None
if not segmente or start != len(folge):
return None
for i, (_, ids) in enumerate(segmente): # Größen-Gate
if len(ids) > max_groesse:
return None
if len(ids) < 2 and len(folge) > 1 and i < len(segmente) - 1:
return None
return segmente
segmente = await schneiden("", "kapitel")
segmente = await schneiden("", f"kapitel-{level}")
if segmente is None:
segmente = await schneiden(
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
" \"titel\" und \"bis\" (eine id aus der Liste, streng aufsteigend);"
" das letzte \"bis\" MUSS die letzte id der Liste sein.", "kapitel-2")
" \"titel\" und \"bis\" (eine NUMMER aus der Liste, streng"
" aufsteigend); das letzte \"bis\" MUSS die letzte Nummer der Liste"
f" sein. Jedes Kapitel bündelt 2{max_groesse} Bausteine (Richtwert"
f" {richtwert} Kapitel); nur das letzte darf kleiner sein.",
f"kapitel-{level}-2")
art = "judge"
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
art = "fallback"
je_id = {b["id"]: b for b in bausteine}
n = max(3, round(len(folge) ** 0.5))
n = max(1, round(len(folge) ** 0.5))
groesse = -(-len(folge) // n)
segmente = [(je_id[teil[0]]["titel"], teil)
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
for pos, (titel, ids) in enumerate(segmente):
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, art=art)
for titel, ids in segmente:
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos_global,
art=art, level=level)
pos_global += 1
for b_id in ids:
db.update("bausteine", "id", b_id, kapitel_id=k_id)
@@ -391,8 +457,9 @@ async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
await _ziele_bilden(ctx)
await _zyklen_brechen(ctx)
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
_level_konflikte_loesen(ctx.topic)
_bausteine_schneiden(ctx)
await _level_kalibrieren(ctx)
await _kapitel_bilden(ctx)
@@ -411,19 +478,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
# pendelt nur die Note (Lektion 75).
for b in bausteine:
n = len(je_baustein[b["id"]])
partner = any(bb["id"] != b["id"]
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"]
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
for bb in bausteine)
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
befunde.append({"art": "band", "item": str(b["id"]),
"detail": f"{b['titel']}: {n} Atome"})
for a in je_baustein[b["id"]]: # ein Baustein = genau ein Level
if a["level"] != b["level"]:
befunde.append({"art": "level_mix", "item": str(a["id"]),
"detail": f"Atom {a['level']} in Baustein {b['level']}"
f" ({b['titel']})"})
level_von = {a["id"]: a["level"] for a in atome}
for k in _braucht_kanten(ctx.topic): # Voraussetzung nie über dem Nutzer-Level
lv, lz = level_von.get(k["von_atom"]), level_von.get(k["zu_atom"])
if lv and lz and LEVEL_RANG[lz] > LEVEL_RANG[lv]:
befunde.append({"art": "level_konflikt", "item": str(k["id"]),
"detail": f"Atom {k['von_atom']} ({lv}) braucht"
f" {k['zu_atom']} ({lz})"})
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
kap_ids = {k["id"] for k in kaps}
kap_level = {k["id"]: k["level"] for k in kaps}
for b in bausteine:
if b["kapitel_id"] not in kap_ids:
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
"detail": b["titel"]})
elif kap_level.get(b["kapitel_id"]) != b["level"]:
befunde.append({"art": "kapitel_level", "item": str(b["id"]),
"detail": f"{b['titel']} ({b['level']}) in Kapitel"
f" {kap_level.get(b['kapitel_id'])}"})
belegt = {b["kapitel_id"] for b in bausteine}
je_kapitel: dict = defaultdict(int)
je_level: dict = defaultdict(int)
for b in bausteine:
je_kapitel[b["kapitel_id"]] += 1
je_level[b["level"]] += 1
for k in kaps:
if k["id"] not in belegt:
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
@@ -431,6 +520,13 @@ def messen(ctx: llm.Kontext) -> list[dict]:
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
"detail": k["titel"]})
# Singleton-Kapitel zerstückeln die Navigation (Lernen: 68/73) — erlaubt
# nur, wenn der ganze Durchgang bloß einen Baustein hat.
if je_kapitel[k["id"]] == 1 and je_level[k["level"]] > 1:
letzte = max((kk["ord"] for kk in kaps if kk["level"] == k["level"]))
if k["ord"] != letzte:
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
"detail": k["titel"]})
ord_von = {b["id"]: b["ord"] for b in bausteine}
for k in _braucht_kanten(ctx.topic):
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
@@ -452,7 +548,8 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return False
await _ziele_bilden(ctx) # fängt Partition-Lücken
await _zyklen_brechen(ctx) # fängt Zyklen
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band + Vorwärts-Kanten
await _level_kalibrieren(ctx)
_level_konflikte_loesen(ctx.topic) # fängt level_konflikt
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, Vorwärts-Kanten, level_mix
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
return True

View File

@@ -7,6 +7,7 @@ alte→neue Maps nachgezogen. Die Dateien (storage/korpus/, topics/) trägt das
Makefile per rsync NACH dem Import (topic_loeschen räumt den Korpus-Ordner)."""
import json
import re
import sys
from pathlib import Path
@@ -89,6 +90,8 @@ def importieren(d: dict) -> None:
if b.get("quelle") in q_map:
b["quelle"] = q_map[b["quelle"]]
z["belege"] = db.j(belege)
z["geprueft"] = db.j([q_map[q] for q in db.uj(z.get("geprueft") or "[]")
if q in q_map])
soll_zeilen.append(z)
s_map = _einfuegen("soll", soll_zeilen)
@@ -109,9 +112,19 @@ def importieren(d: dict) -> None:
z = dict(z)
z["artefakt_id"] = ar_map.get(z["artefakt_id"])
db.insert("leitner", **z)
# Atom-Marker im Text remappen — ohne Rewrite zeigten nach dem Import ALLE
# Marker auf nicht existierende Atom-IDs (aak: 421/421 tot; Beispiel-/
# Fragen-Verknüpfung lief ins Leere)
def marker_remap(m: re.Match) -> str:
alt = int(m.group(1))
return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0)
for z in d["sections"]: # PK ist baustein_id
z = dict(z)
z["baustein_id"] = b_map.get(z["baustein_id"])
for feld in ("text_lang", "text_kompakt"):
if z.get(feld):
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
db.insert("sections", **z)
r_map = _einfuegen("runs", d["runs"])
_einfuegen("befunde", d["befunde"], run_id=r_map)

View File

@@ -1,14 +1,15 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
import { render, lesestat } from '../markdown.js'
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
const LEVEL_NAME = { E: 'Umfang: Grundgerüst', M: 'Umfang: + Standardstoff', S: 'Umfang: alles' }
const LEVEL_NAME = { E: 'Durchgang 1: Grundgerüst', M: 'Durchgang 2: Standardstoff',
S: 'Durchgang 3: Feinheiten' }
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
@@ -28,10 +29,9 @@ const htmlCache = new Map()
watch(daten, () => htmlCache.clear())
function html(s) {
const key = `${ansicht.value}|${level.value}|${s.baustein}`
const key = `${ansicht.value}|${s.baustein}`
if (!htmlCache.has(key)) {
htmlCache.set(key, ansicht.value === 'kompakt'
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
htmlCache.set(key, render(ansicht.value === 'kompakt' ? s.kompakt : s.lang))
}
return htmlCache.get(key)
}
@@ -47,23 +47,29 @@ function hoehe(i) {
function setAnsicht(a) {
ansicht.value = a
laden()
}
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
// S-Bausteine überspringt der Toggle die Stufe)
const levels = computed(() => {
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
return folge.length ? folge : ['E']
})
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
function levelToggle() {
const folge = ['E', 'M', 'S']
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
laden()
const folge = levels.value
level.value = folge[(folge.indexOf(level.value) + 1) % folge.length]
}
// Nur Kapitel/Bausteine mit Inhalt im gewählten Umfang anzeigen — der
// Level-Filter ließe sonst leere Titel-Hüllen stehen (36/66 Bausteine ohne E)
// Der gewählte Durchgang: nur seine Kapitel, nur Sections mit Inhalt
const anzeige = computed(() => {
const out = []
for (const kap of daten.value?.kapitel || []) {
const sections = kap.sections.filter((s) => ansicht.value === 'kompakt'
? (s.kompakt || s.lang || '').trim()
: lesestat(gefiltert(s.lang, level.value)).woerter >= 10)
if (kap.level !== level.value) continue
const sections = kap.sections.filter((s) =>
((ansicht.value === 'kompakt' ? s.kompakt : s.lang) || '').trim())
if (sections.length) out.push({ ...kap, sections })
}
return out
@@ -77,8 +83,7 @@ const stats = computed(() => {
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
let formeln = 0
for (const s of kap.sections) {
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
: gefiltert(s.lang, level.value))
const st = lesestat(ansicht.value === 'kompakt' ? s.kompakt : s.lang)
woerter += st.woerter
formeln += st.displayFormeln
}
@@ -156,12 +161,23 @@ function onScroll(e) {
<div v-for="(kap, i) in anzeige" :key="kap.kapitel_id" class="kapitel"
:ref="(el) => setKapEl(i, el)">
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
<div class="kapitel-meta">
{{ (stats.je[i]?.woerter || 0).toLocaleString('de-DE') }} Wörter ·
{{ zeit(stats.je[i]?.minuten || 0) }}
</div>
<template v-if="sichtbar(i)">
<p v-if="kap.intro && ansicht === 'erklaerend'"
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
<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="render(f.antwort)"></div>
</div>
</details>
</div>
</template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>

View File

@@ -48,8 +48,10 @@ export function render(text) {
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
}
const RANG = { E: 0, M: 1, S: 2 }
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
const MARKER = /<!--\s*atom:[^>]*-->/g
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
export function lesestat(text) {
@@ -61,47 +63,3 @@ export function lesestat(text) {
const woerter = t.split(/\s+/).filter(Boolean).length
return { woerter, displayFormeln }
}
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
export function segmente(lang) {
const out = []
let letzt = 0
let aktuell = { level: null, titel: '', text: '' }
for (const m of lang.matchAll(MARKER)) {
aktuell.text = lang.slice(letzt, m.index)
out.push(aktuell)
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
letzt = m.index + m[0].length
}
aktuell.text = lang.slice(letzt)
out.push(aktuell)
return out
}
export function gefiltert(lang, maxLevel) {
if (!maxLevel || maxLevel === 'S') return lang
return segmente(lang)
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
.map((s) => s.text)
.join('\n')
}
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
export function renderGestuft(lang, maxLevel) {
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
const seg = segmente(lang)
const letzt = seg[seg.length - 1]
const i = letzt.text.indexOf('**Kernpunkte:**')
if (i >= 0) {
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
letzt.text = letzt.text.slice(0, i)
}
return seg.map((s) => {
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
const html = render(s.text)
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
return gelernt ? `<div class="gelernt">${html}</div>` : html
}).join('')
}

View File

@@ -203,9 +203,13 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
}
.fokus-aus:hover { opacity: 1; }
.seite.guide-text::-webkit-scrollbar { display: none; }
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
.guide-text .kapitel { scroll-margin-top: 8px; }
.guide-text .kapitel-titel { margin: 30px 0 6px; }
.guide-text .kapitel-titel { margin: 30px 0 2px; }
.guide-text .kapitel-meta { color: var(--dim); font-size: 13px; margin: 0 0 10px; }
.guide-section .antworten { margin-top: 10px; border-top: 1px solid var(--rand); padding-top: 8px; }
.guide-section .antworten summary { cursor: pointer; color: var(--dim); font-size: 14px; }
.guide-section .antworten .antwort { margin: 10px 0; }
.guide-section .antworten .frage { font-weight: 600; font-size: 14px; margin-bottom: 2px; }
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
.guide-text .kompakt {

View File

@@ -7,6 +7,8 @@ Prüfe jedes Artefakt GEGEN DIE BELEGE:
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
funktionieren und Wissen abfragen, nicht Textinhalte.
- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte
Fachtermini sind okay; ganze englische Sätze nicht).
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
- Antworte für JEDES Artefakt (id wörtlich übernehmen).

View File

@@ -1,12 +0,0 @@
<!-- template:Atom-Anker-Fix -->
Atom: „{titel}" — {definition}
Suche im QUELLTEXT unten die Passage, die dieses Atom belegt, und kopiere sie
WÖRTLICH (exakt, Zeichen für Zeichen, 540 Wörter). Wenn der Text das Atom
NICHT enthält: leeres Zitat. Nichts paraphrasieren.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
{{"zitat": "…"}}

View File

@@ -17,6 +17,8 @@ Regeln:
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (540 Wörter) — sie ist der Beleg,
dass dieses Konzept geübt wird.
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
- SPRACHE: „titel" und „definition" IMMER auf Deutsch (fremdsprachige Quellen
sinngemäß übersetzen; etablierte Fachtermini bleiben). Nur das „zitat" bleibt original.
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.

View File

@@ -10,6 +10,9 @@ lernbare Einheit, mit Typ:
Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind
KEINE Atome. Regeln:
- Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden.
- SPRACHE: „titel" und „definition" IMMER auf Deutsch — auch bei fremdsprachigen
Quellen sinngemäß übersetzen. Etablierte Fachtermini ohne deutsche Entsprechung
bleiben original. Nur das „zitat" bleibt in der Quellsprache.
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
- „definition": 12 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).

View File

@@ -0,0 +1,14 @@
<!-- template:Atom-Titel-Fix -->
Unten Lern-Atome mit kaputten Titeln (Format „id: titel — definition"):
Katalognummern („Satz 6.26") oder abgerissene Fragmente.
Gib jedem Atom einen sauberen Titel:
- Konzeptname aus der Definition, 26 Wörter, auf Deutsch.
- KEINE Nummern, keine Quellen-Referenzen, keine Satzzeichen am Ende.
- Der Titel muss das Konzept eindeutig benennen (dient als Verweis-Anker).
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences):
[{{"atom": 12, "titel": "…"}}]

View File

@@ -1,6 +1,12 @@
<!-- template:Guide-Writer -->
Schreibe EINE Guide-Section für den Baustein „{titel}".
Lernziel: {ziel}
DURCHGANG: {durchgang}. Der Guide hat drei getrennte Lese-Durchgänge — diese
Section muss für ihren Durchgang ALLEIN kohärent lesbar sein.
BEREITS BEHANDELT (frühere Durchgänge — NICHT neu erklären, nur bei Bedarf mit
einem Halbsatz anknüpfen; dafür KEINE Marker setzen):
{kontext}
Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool.
@@ -54,7 +60,9 @@ Regeln (hart):
ein, kein Fülltext, keine Wiederholungen.
6. „kompakt": 23 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
Paraphrase, keine Marker.
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht.
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).
ATOME:
{atome}

View File

@@ -1,14 +1,17 @@
<!-- template:Kapitel-Intro -->
Schreibe einen Advance Organizer für das Guide-Kapitel „{titel}".
Schreibe die Einleitung für das Guide-Kapitel „{titel}".
Durchgang: {durchgang}.
Unten die Abschnitte des Kapitels (Titel: Lernziel).
Regeln:
- 24 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen,
worauf baut es auf.
- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren
(das wäre Redundanz zum Kapiteltext).
- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…").
- 12 Sätze, KONKRET: ein greifbarer Aufhänger — die zentrale Frage, das
Problem oder der Anwendungsfall, den das Kapitel löst. Wenn möglich mit
einem konkreten Beispiel oder einer vertrauten Analogie.
- VERBOTEN: Landkarten-Meta („Dieses Kapitel führt ein…", „legt das Fundament",
„Ausgehend von … werden … umrissen") — solche Vorschauen liest niemand.
- Nichts erklären, nichts definieren (das macht der Kapiteltext).
- Kein Vorgeplänkel, keine Motivations-Floskeln.
ABSCHNITTE:
{bausteine}

View File

@@ -1,13 +1,16 @@
<!-- template:Kapitel-Schnitt -->
Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge
(Format „id: titel — lernziel").
Unten die BAUSTEINE eines Lern-Guide-Durchgangs in ihrer endgültigen
Lese-Reihenfolge, durchnummeriert (Format „nummer: titel — lernziel").
Durchgang: {durchgang}. Formuliere die Kapiteltitel für diesen Durchgang.
Setze Kapitelgrenzen. Regeln:
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen.
Das letzte "bis" muss die letzte id der Liste sein.
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = NUMMER des LETZTEN
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen,
"bis" muss streng aufsteigen. Das letzte "bis" ist die letzte Nummer der Liste.
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
- Jedes Kapitel bündelt MINDESTENS 2 Bausteine (nur das letzte darf kleiner
sein) und höchstens das Doppelte des Durchschnitts — keine Einzel-Kapitel.
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
Baustein-Titel-Duplikat, keine Nummerierung).
{hinweis}

View File

@@ -1,12 +1,15 @@
<!-- template:Korpus-Soll-Beleg -->
Soll-Punkt: „{punkt}"
Unten Soll-Punkte (Format „id: punkt") und EIN Quelltext.
Suche im QUELLTEXT unten eine Passage, die diesen Punkt belegt. Wenn es eine gibt:
kopiere sie WÖRTLICH (exakt, 530 Wörter). Wenn der Text den Punkt NICHT behandelt:
leeres Zitat. Nichts paraphrasieren, nichts erfinden.
Suche für JEDEN Punkt eine Passage im QUELLTEXT, die ihn belegt. Wenn es eine
gibt: kopiere sie WÖRTLICH (exakt, 530 Wörter). Behandelt der Text einen Punkt
NICHT: den Punkt weglassen. Nichts paraphrasieren, nichts erfinden.
SOLL-PUNKTE:
{punkte}
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
{{"zitat": "…"}}
Antworte NUR mit JSON (keine Code-Fences) — nur Punkte MIT Fund:
[{{"soll": 12, "zitat": "…"}}]

View File

@@ -1,17 +1,19 @@
<!-- template:Level-Kalibrierung -->
Unten die Atome EINES Guide-Bausteins „{titel}" (Format „id: titel — definition").
Unten die Atome EINES Lernziels „{titel}" (Format „id: titel — definition").
Kalibriere die Lernstufen RELATIV zueinander. Die Stufen sind Komplexitätsschichten
(nicht „leicht/schwer"):
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um den
Baustein zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
einen kohärenten Kurzdurchgang ergeben.
(nicht „leicht/schwer") und werden zu GETRENNTEN Bausteinen und Lese-Durchgängen:
- E = Grundgerüst: das Minimum, das man beim ERSTEN Durchgang braucht, um das
Lernziel zu verstehen — Kernbegriffe, die Kernaussage. Die E-Atome allein müssen
einen kohärenten, für sich lesbaren Kurzdurchgang tragen.
- M = Standardstoff: Sätze, Verfahren, Standardbeweise — der zweite Durchgang.
- S = Feinheiten: Spezialfälle, Beweisdetails, Optimierungen, Randnotizen.
Regeln:
- Behandelt der Baustein grundlegenden Stoff, gehört sein tragender Kern zu E.
- Rein vertiefende Bausteine (reine Beweis-/Spezialbausteine) dürfen ohne E bleiben.
- Behandelt das Ziel grundlegenden Stoff, gehört sein tragender Kern zu E.
- Voraussetzungen eines Atoms gehören auf DESSEN Stufe oder tiefer — was ein
E-Atom braucht, kann nicht M/S sein.
- Rein vertiefende Ziele (reine Beweis-/Spezialthemen) dürfen ohne E bleiben.
- Jede id genau einmal, keine erfinden.
ATOME:

View File

@@ -178,7 +178,7 @@ def test_det_auftraege_blockquote_und_artefakt():
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
lang = (f"<!-- atom: {a} | A -->\n" + "Fließtext. " * 45
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
kompakt = "- Definition mit x_i und Laufzeit 2^(n/2) als Klartext."
@@ -189,6 +189,62 @@ def test_det_auftraege_blockquote_und_artefakt():
assert "Kompakt-Fassung" in text and "x_i" in text # Klartext-Formeln gefangen
def test_det_auftraege_neue_checks():
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("detcheck2")
ziel = db.insert("lernziele", topic=topic, text="Kann X", 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([]))
wand = "Wort " * 130
lang = (f"<!-- atom: {a} | A -->\n<!-- atom: 99999 | Fremd -->\n"
"Der Beweis folgt aus Satz 6.25, dazu später mehr.\n\n"
+ wand + "\n\n#### Leere Überschrift\n\n#### Noch eine\nText dahinter.\n\n"
"The proof is left as an exercise for the reader and this paragraph"
" should be detected by the language check here.")
kompakt = f"<!-- atom: {a} | A -->\n- Punkt"
text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, kompakt))
assert "gehört nicht zu diesem Baustein" in text # marker_fremd
assert "Vertröstungs-Floskel" in text # „später mehr"
assert "Quellen-Referenz" in text # Satz 6.25
assert "Wörter (Regel: 4090)" in text # Absatz-Wand
assert "ohne folgenden Text" in text # leere Überschrift
assert "englische Passage" in text
assert "Marker gehören nur" in text # Marker in Kompakt-Fassung
# qa=True: Marker/Länge/Vorwärts haben eigene Befund-Arten, Stil-Checks bleiben
qa_text = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0},
lang, kompakt, qa=True))
assert "gehört nicht zu diesem Baustein" not in qa_text
assert "Vertröstungs-Floskel" in qa_text
async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
import db
import fake_agents
import guide
import llm
from conftest import run_anlegen, topic_anlegen
topic = topic_anlegen("fixfail")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
db.insert("sections", baustein_id=b_id, stage="fix",
text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"]))
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "", "lang": "kein Marker"})
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide"
stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,)))
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,))
assert stage == "done" and sec["text_lang"] == "alt"
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
def test_katex_gate_und_markdown_hygiene(monkeypatch):
import guide
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen

View File

@@ -36,6 +36,7 @@ def _pruefe_endzustand(topic, run_id):
md = guide.guide_markdown(topic)
for a in atome:
assert f"<!-- atom: {a['id']} |" in md
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
@@ -48,6 +49,17 @@ def _pruefe_endzustand(topic, run_id):
folge = [b["kapitel_id"] for b in bausteine]
segmente = [k for k, _ in itertools.groupby(folge)]
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord
rang = {"E": 0, "M": 1, "S": 2}
kap_level = {k["id"]: k["level"] for k in kaps}
for b in bausteine:
assert kap_level[b["kapitel_id"]] == b["level"]
for a in atome:
if a["baustein_id"] == b["id"]:
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"])
level_folge = [rang[b["level"]] for b in bausteine]
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet"
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
@@ -58,9 +70,9 @@ async def test_e2e_thema():
topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
# Fake-Welt: 3 Atome mit Level E/M/S → je Level ein eigener Baustein-Durchgang
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert len(bausteine) == 1
assert [b["level"] for b in bausteine] == ["E", "M", "S"]
async def test_e2e_uni(tmp_path):

View File

@@ -132,11 +132,10 @@ async def test_level_kalibrierung(monkeypatch):
topic = topic_anlegen("level")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a1 = db.insert("atome", topic=topic, titel="Kern", typ="begriff", definition="d",
level="M", status="neu", baustein_id=b_id, braucht=db.j([]))
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
a2 = db.insert("atome", topic=topic, titel="Detail", typ="aussage", definition="d",
level="M", status="neu", baustein_id=b_id, braucht=db.j([]))
level="M", status="neu", ziel_id=ziel, braucht=db.j([]))
def judge(prompt):
return [{"atom": a1, "level": "E"}, {"atom": a2, "level": "S"},
@@ -147,3 +146,67 @@ async def test_level_kalibrierung(monkeypatch):
await struktur._level_kalibrieren(ctx)
assert db.one("SELECT level FROM atome WHERE id=?", (a1,))["level"] == "E"
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
def test_level_konflikt_absenkung():
# v(E) braucht z(S), z braucht w(M) → Kaskade senkt z UND w auf E
topic = topic_anlegen("konflikt")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
def atom(titel, level):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition="d", level=level, status="neu", ziel_id=ziel,
braucht=db.j([]))
v, z, w = atom("V", "E"), atom("Z", "S"), atom("W", "M")
for von, zu in ((v, z), (z, w)):
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, von, zu))
struktur._level_konflikte_loesen(topic)
assert db.one("SELECT level FROM atome WHERE id=?", (z,))["level"] == "E"
assert db.one("SELECT level FROM atome WHERE id=?", (w,))["level"] == "E"
ctx = llm.Kontext(run, topic, "minimax")
assert not [b for b in struktur.messen(ctx) if b["art"] == "level_konflikt"]
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
topic = topic_anlegen("split")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(4):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=ziel, braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M"]
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["level"] for k in kaps] == ["E", "M"]
arten = {b["art"] for b in struktur.messen(ctx)}
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
def test_kein_merge_ueber_level():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
topic = topic_anlegen("levelmerge")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
for i in range(2):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=z1, braucht=db.j([]))
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=z2, braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
levels = {a["level"] for a in struktur._atome(topic)
if a["baustein_id"] == b["id"]}
assert levels == {b["level"]}

View File

@@ -42,3 +42,10 @@ async def test_export_import_roundtrip():
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
assert db.one("SELECT id FROM kapitel WHERE id=?", (b["kapitel_id"],))
assert db.one("SELECT baustein_id FROM sections WHERE baustein_id=?", (b["id"],))
# Marker-Remap: jede Marker-id im importierten Text existiert (aak: 421/421 tot)
atom_ids = {a["id"] for a in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
for s in db.query("SELECT s.* FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
" WHERE b.topic=?", (topic,)):
for feld in ("text_lang", "text_kompakt"):
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
assert int(m) in atom_ids, f"toter Marker {m}"