Files
creator2/backend/struktur.py
2026-07-12 16:13:50 +02:00

564 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR:
Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge =
topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der
schwächsten Kante (deterministischer Fallback)."""
import asyncio
import logging
from collections import defaultdict
import db
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.struktur")
EBENE = "struktur"
def _atome(topic: str) -> list[dict]:
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY id", (topic,))
def _braucht_kanten(topic: str) -> list[dict]:
return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
# ── Lernziele ─────────────────────────────────────────────────────────────────
async def _ziele_bilden(ctx: llm.Kontext) -> None:
atome = [a for a in _atome(ctx.topic) if not a["ziel_id"]]
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[a["soll_id"]].append(a)
async def eine_gruppe(soll_id, gruppe: list[dict]) -> None:
punkt = db.one("SELECT punkt FROM soll WHERE id=?", (soll_id,)) or {"punkt": ctx.topic}
rest = gruppe
for runde in ("1", "2"): # Schlussrunde MUSS alles entscheiden (Lektion 23)
if not rest:
return
chunk = rest[:ZIELE_CHUNK_ATOME]
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in chunk)
res = await llm.call(ctx, stage="ziele", template="Lernziele",
werte={"punkt": punkt["punkt"], "atome": liste,
"pflicht": "Jedes Atom MUSS genau einem Ziel"
" zugeordnet sein." if runde == "2" else ""},
role="judge", n=len(chunk),
item=f"s{soll_id}-r{runde}", erwartet=list)
gueltig = {a["id"] for a in chunk}
zugeordnet: set[int] = set()
for z in res or []:
ids = [i for i in z.get("atome", []) if isinstance(i, int)
and i in gueltig and i not in zugeordnet]
text = str(z.get("ziel", "")).strip()
if not ids or not text:
continue
ziel_id = db.insert("lernziele", topic=ctx.topic, text=text,
titel=str(z.get("titel", "")).strip()[:80],
soll_id=soll_id, status="aktiv")
for i in ids:
db.update("atome", "id", i, ziel_id=ziel_id)
zugeordnet.update(ids)
rest = [a for a in gruppe if a["id"] not in zugeordnet
and not db.one("SELECT ziel_id FROM atome WHERE id=?",
(a["id"],))["ziel_id"]]
if rest: # nach der Schlussrunde: deterministisches Sammelziel statt Lücke
ziel_id = db.insert("lernziele", topic=ctx.topic,
text=f"Grundlagen: {punkt['punkt']}",
soll_id=soll_id, status="aktiv")
for a in rest:
db.update("atome", "id", a["id"], ziel_id=ziel_id)
await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items())
# ── Zyklen brechen ────────────────────────────────────────────────────────────
def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None:
"""Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben)."""
nach = defaultdict(list)
for v, z in kanten:
nach[v].append(z)
farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz
eltern: dict[int, int] = {}
for start in knoten:
if farbe[start]:
continue
stapel = [(start, iter(nach[start]))]
farbe[start] = 1
while stapel:
v, it = stapel[-1]
fortschritt = False
for z in it:
if z not in farbe:
continue
if farbe[z] == 0:
farbe[z] = 1
eltern[z] = v
stapel.append((z, iter(nach[z])))
fortschritt = True
break
if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren
pfad = [(v, z)]
k = v
while k != z:
pfad.append((eltern[k], k))
k = eltern[k]
return pfad
if not fortschritt:
farbe[v] = 2
stapel.pop()
return None
async def _zyklen_brechen(ctx: llm.Kontext) -> None:
while True:
atome = _atome(ctx.topic)
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
zyklus = _finde_zyklus(ids, kanten)
if not zyklus:
return
je_id = {a["id"]: a for a in atome}
liste = "\n".join(f"{v}{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}"
for v, z in zyklus)
res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus",
werte={"kanten": liste}, role="judge",
item=f"z{zyklus[0][0]}", erwartet=dict)
wahl = None
if res:
paar = (res.get("von"), res.get("zu"))
if paar in zyklus:
wahl = paar
if wahl is None: # deterministischer Fallback: kleinste Kante
wahl = min(zyklus)
db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?"
" AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1]))
log.info("Zyklus gebrochen: %s%s", wahl[0], wahl[1])
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
def _topo(knoten: list[int], kanten: list[tuple[int, int]],
rang: dict[int, tuple]) -> list[int]:
"""Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil).
Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen
sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder
(gemessen aak: 22 Atome ohne Baustein)."""
kn = set(knoten)
vorher = defaultdict(set)
nachher = defaultdict(set)
for v, z in kanten:
if v in kn and z in kn:
vorher[v].add(z)
nachher[z].add(v)
offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k])
out = []
erledigt: set[int] = set()
while offen:
k = offen.pop(0)
out.append(k)
erledigt.add(k)
neu = []
for n in nachher[k]:
if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu:
neu.append(n)
offen = sorted(offen + neu, key=lambda kk: rang[kk])
out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k])
return out
def _anker_rang(topic: str) -> dict[int, tuple]:
"""Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers."""
out = {}
for a in _atome(topic):
row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0"
" ORDER BY quelle_id, start LIMIT 1", (a["id"],))
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
return out
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
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,))
atome = _atome(topic)
rang = _anker_rang(topic)
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)]
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[(a["ziel_id"], a["level"])].append(a)
# 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}
return sum(1 for v, z in kanten
if (v in ids1 and z in ids2) or (v in ids2 and z in ids1))
def gruppen_rang(g: list[dict]) -> tuple:
return min(rang[a["id"]] for a in g)
geaendert = True
while geaendert:
geaendert = False
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:
# nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der
# Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine
# kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen,
# den messen dann als unreparierbaren band-Befund meldete.
passende = [p for p in gruppen if p != k and p[1] == k[1]]
if not passende:
continue
eigener = gruppen_rang(gruppen[k])
def naehe(pp: tuple) -> tuple:
r = gruppen_rang(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[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 Gruppen splitten), Atome zuordnen
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
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
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
# gleichverteilt statt ceil-Scheiben: der letzte Teil war sonst der Rest
# (43→8,8,8,8,8,3 verletzt das Band). divmod → 43→8,7,7,7,7,7; mit
# MIN≤MAX/2 liegt jeder Teil bei n_teile≥2 beweisbar im Band.
basis, rest_teile = divmod(len(gruppe), n_teile)
start = 0
for teil in range(n_teile):
gr = basis + (1 if teil < rest_teile else 0)
teil_ids = ordnung[start:start + gr]
start += gr
if not teil_ids:
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", level=level)
for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
_bausteine_ordnen(topic, kanten, rang)
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
"""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,))
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 and b_level[bv] == b_level[bz]:
gewicht[(bv, bz)] += 1
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
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:
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 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(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": 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 []:
if not isinstance(e, dict):
continue
atom_id = e.get("atom")
level = str(e.get("level", "")).strip().upper()
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(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, 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
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,))}
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 = {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 _kapitel_bilden(ctx)
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = _atome(ctx.topic)
for a in atome:
if not a["ziel_id"] or not a["baustein_id"]:
befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]})
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,))
je_baustein: dict = defaultdict(list)
for a in atome:
je_baustein[a["baustein_id"]].append(a)
# Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches
# Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und
# re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine
# einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75).
for b in bausteine:
n = len(je_baustein[b["id"]])
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] 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"]),
"detail": k["titel"]})
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)
bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None)
if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0):
befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]),
"detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"})
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
if _finde_zyklus(ids, kanten):
befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"})
return befunde
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
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