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

@@ -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"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
werte = {"titel": b["titel"], "ziel": ziel["text"],
"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)"}
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,21 +243,30 @@ 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."
for i in _marker_fehlend(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."
for t in _vorwaertsverweise(topic, b, lang)]
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):"
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
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
@@ -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
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:
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"])}
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}]
db.update("soll", "id", p["id"], belege=db.j(belege))
return True
if p["status"] == "bestaetigt":
db.update("soll", "id", p["id"], status="kandidat")
return True
return False
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))
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")
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,62 +264,77 @@ 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))
while True:
zyklus = _finde_zyklus(b_ids, list(b_kanten))
if not zyklus:
break
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
b_kanten.discard(schwach)
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
# Ordnung vollständig rückwärts auflösen).
bv, bz = schwach
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
" AND status='aktiv'"
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
" 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)
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:
break
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
b_kanten.discard(schwach)
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
# Ordnung vollständig rückwärts auflösen).
bv, bz = schwach
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
" AND status='aktiv'"
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
" 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=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,73 +345,121 @@ 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)
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
folge = [b["id"] for b in bausteine]
pos_von = {bid: i for i, bid in enumerate(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})
segmente: list[tuple[str, list[int]]] = []
start = 0
for gruppe in res or []:
if not isinstance(gruppe, dict):
return None
titel = str(gruppe.get("titel", "")).strip()
ende = pos_von.get(gruppe.get("bis"))
if not titel or ende is None or ende < start:
return None
segmente.append((titel, folge[start:ende + 1]))
start = ende + 1
return segmente if segmente and start == len(folge) else None
segmente = await schneiden("", "kapitel")
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")
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))
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 b_id in ids:
db.update("bausteine", "id", b_id, kapitel_id=k_id)
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 = {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),
"durchgang": DURCHGANG[level], "hinweis": hinweis})
segmente: list[tuple[str, list[int]]] = []
start = 0
for gruppe in res or []:
if not isinstance(gruppe, dict):
return None
titel = str(gruppe.get("titel", "")).strip()
ende = pos_von.get(gruppe.get("bis"))
if not titel or ende is None or ende < start:
return None
segmente.append((titel, folge[start:ende + 1]))
start = ende + 1
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("", f"kapitel-{level}")
if segmente is None:
segmente = await schneiden(
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
" \"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(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])]
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)
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)
@@ -450,9 +546,10 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
if not befunde:
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 _ziele_bilden(ctx) # fängt Partition-Lücken
await _zyklen_brechen(ctx) # fängt Zyklen
await _level_kalibrieren(ctx)
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
_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)