This commit is contained in:
team3
2026-07-14 00:15:03 +02:00
parent af0e3c0629
commit 3f6114681c
22 changed files with 757 additions and 307 deletions

View File

@@ -1,16 +1,19 @@
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der
Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick)
mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame
von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus."""
"""Ebene 3: Struktur. Das Soll trägt die Gliederung: ein Judge partitioniert
die Soll-Punkte in THEMEN (= Kapitel), Lernziele entstehen je Soll-Punkt,
Bausteine = Ziel-Gruppen im Größenband (Merge nur INNERHALB eines Themas —
der frühere Quell-Nähe-Merge über Soll-Grenzen mischte 69/73 Bausteine
thematisch). Ein Ziel = EIN Baustein; sein Level ist das dominante Atom-Level
(der frühere Level-Split zerriss Ziele über bis zu 3 Durchgänge). Reihenfolge =
Judge je (Level, Thema)-Segment mit Quellpositions-Prior; Kapitel = (Level,
Thema) deterministisch."""
import logging
from collections import defaultdict
from collections import Counter, defaultdict
import db
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
LEVEL_RANG, THEMA_SOLL_PUNKTE, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.struktur")
@@ -22,6 +25,68 @@ def _atome(topic: str) -> list[dict]:
" ('gemerged','verworfen') ORDER BY id", (topic,))
# ── Themen (Soll-Punkte → Kapitel-Gerüst) ────────────────────────────────────
async def _themen_bilden(ctx: llm.Kontext) -> None:
"""Partition der bestätigten Soll-Punkte in Themen (Titel + Lesefolge).
Skip-Gate: haben alle Punkte ein gültiges Thema, passiert nichts — sonst
würde jede Repair-Runde die Partition neu würfeln und Bausteine, Kapitel
und Sections durchrotieren (Korrektheits-, nicht Spar-Bedingung)."""
topic = ctx.topic
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" ORDER BY id", (topic,))
if not punkte:
return
gueltig = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?", (topic,))}
if all(p["thema_id"] in gueltig for p in punkte):
return
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
liste = "\n".join(f"{i + 1}: {p['punkt']}" for i, p in enumerate(punkte))
richtwert = max(2, round(len(punkte) / THEMA_SOLL_PUNKTE))
max_punkte = 2 * THEMA_SOLL_PUNKTE
soll_nummern = list(range(1, len(punkte) + 1))
async def schneiden(hinweis: str, tag: str) -> list[dict] | None:
res = await llm.call(ctx, stage="themen", template="Themen-Schnitt",
role="judge", n=len(punkte), item=tag, erwartet=list,
werte={"punkte": liste, "richtwert": str(richtwert),
"max_punkte": str(max_punkte), "hinweis": hinweis})
gruppen = []
for g in res or []:
if not isinstance(g, dict) or not str(g.get("titel", "")).strip():
return None
nummern = [n for n in g.get("punkte", []) if isinstance(n, int)]
gruppen.append({"titel": str(g["titel"]).strip(), "nummern": nummern})
alle = sorted(n for g in gruppen for n in g["nummern"])
if alle != soll_nummern: # exakte Partition: nichts fehlt, nichts doppelt
return None
if any(len(g["nummern"]) > max_punkte for g in gruppen):
return None
if len(punkte) >= 4 and sum(1 for g in gruppen if len(g["nummern"]) < 2) > 1:
return None # höchstens EIN Rest-Thema unter 2 Punkten
return gruppen
gruppen = await schneiden("", "themen")
if gruppen is None:
gruppen = await schneiden(
"ACHTUNG: Der letzte Versuch war ungültig. Jede Nummer der Liste"
f" GENAU EINMAL verwenden (1{len(punkte)}), jedes Thema braucht"
" einen titel; keine Nummern erfinden.", "themen-2")
art = "judge"
if gruppen is None: # deterministischer Fallback: √n konsekutive Segmente
art = "fallback"
n = max(1, round(len(punkte) ** 0.5))
groesse = -(-len(punkte) // n)
gruppen = [{"titel": punkte[i * groesse]["punkt"][:120],
"nummern": list(range(i * groesse + 1,
min((i + 1) * groesse, len(punkte)) + 1))}
for i in range(n) if i * groesse < len(punkte)]
for pos, g in enumerate(gruppen):
t_id = db.insert("themen", topic=topic, titel=g["titel"][:120], ord=pos, art=art)
for nummer in g["nummern"]:
db.update("soll", "id", punkte[nummer - 1]["id"], thema_id=t_id)
# ── Lernziele ─────────────────────────────────────────────────────────────────
async def _ziele_bilden(ctx: llm.Kontext) -> None:
@@ -100,12 +165,28 @@ def _baustein_rang(bausteine: list[dict], atome: list[dict],
return b_rang
def _thema_je_ziel(topic: str) -> dict[int, int | None]:
"""ziel_id → thema_id über lernziele.soll_id → soll.thema_id."""
out = {}
for z in db.query("SELECT z.id, s.thema_id FROM lernziele z"
" LEFT JOIN soll s ON s.id=z.soll_id WHERE z.topic=?", (topic,)):
out[z["id"]] = z["thema_id"]
return out
def _dominantes_level(atome: list[dict]) -> str:
"""Mehrheits-Level; Gleichstand → das niedrigere (E vor M vor S) — im
Zweifel früh erklären statt spät."""
zaehler = Counter(a["level"] for a in atome)
return min(zaehler, key=lambda lv: (-zaehler[lv], LEVEL_RANG.get(lv, 9)))
async 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
Quellreihenfolge splitten ("Teil n")."""
Gruppen = LERNZIELE (ein Ziel = ein Baustein — kein Level-Split mehr).
Kleine Gruppen mergen NUR innerhalb desselben Themas (Quell-Nähe wählt
den Partner im Thema), große entlang der Quellreihenfolge splitten
("Teil n"). Baustein-Level = dominantes Atom-Level."""
topic = ctx.topic
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
db.execute("DELETE FROM auftraege WHERE baustein_id IN"
@@ -113,51 +194,50 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic)
rang = _anker_rang(topic)
thema = _thema_je_ziel(topic)
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[(a["ziel_id"], a["level"])].append(a)
gruppen[a["ziel_id"]].append(a)
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
# Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine
# Checkliste (152 feine Punkte hätten sonst keine Merge-Partner).
def gruppen_rang(g: list[dict]) -> tuple:
return min(rang[a["id"]] for a in g)
# kleine Gruppen mergen (kleinste zuerst), NUR im selben Thema — die
# Themen-Schranke ersetzt die frühere Level-Schranke; Quell-Nähe bleibt
# als Partnerwahl INNERHALB des Themas (dort ist sie harmlos).
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]]
# keine MAX-Schranke: der Split zerlegt große Gruppen bandkonform;
# sonst bliebe eine Kleingruppe ohne Partner als unreparierbarer
# band-Befund liegen (Lektion 75)
passende = [p for p in gruppen if p != k and thema.get(p) == thema.get(k)]
if not passende:
continue
eigener = gruppen_rang(gruppen[k])
def naehe(pp: tuple) -> tuple:
def naehe(pp) -> tuple:
r = gruppen_rang(gruppen[pp])
return ((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])
db.update("atome", "id", a["id"], ziel_id=p)
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")
db.update("lernziele", "id", k, 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():
for ziel_id, gruppe in gruppen.items():
ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i])
je_id = {a["id"]: a for a in gruppe}
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)
@@ -174,7 +254,9 @@ async 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", level=level)
status="neu",
level=_dominantes_level([je_id[i] for i in teil_ids]),
thema_id=thema.get(ziel_id))
for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
@@ -182,50 +264,61 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
async def _bausteine_ordnen(ctx: llm.Kontext) -> None:
"""Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global
fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität
bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior
vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine
exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
(ordnung_fallback, Muster wie kapitel_fallback)."""
"""Didaktische Feinordnung je (Level, Thema)-Segment per Judge; ord läuft
global fortlaufend über (Level-Rang, Thema-ord, Feinordnung) — eine Achse,
Vorwärts-Logik und Kapitel-Kontiguität bleiben konstruktiv korrekt.
Eingabe je Segment nach Quellpositions-Prior vorsortiert; der Code erzwingt
eine exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
(ordnung_fallback). 1er-Segmente brauchen keinen Call."""
topic = ctx.topic
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic)
b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic))
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,))
# Rest-Segment je Level für thema_id NULL (defensiv: Testdaten, Altbestand)
segmente = [(t["id"], t["titel"]) for t in themen] + [(None, "")]
offset = 0
for level in LEVEL_RANG:
folge = sorted((b for b in bausteine if b["level"] == level),
key=lambda b: b_rang[b["id"]])
if not folge:
continue
liste = "\n".join(f"{i + 1}: {b['titel']}"
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
for i, b in enumerate(folge))
soll = list(range(1, len(folge) + 1))
for thema_id, thema_titel in segmente:
folge = sorted((b for b in bausteine
if b["level"] == level and b["thema_id"] == thema_id),
key=lambda b: b_rang[b["id"]])
if not folge:
continue
if len(folge) == 1: # nichts zu ordnen — kein Call, kein Schein-Fallback
db.update("bausteine", "id", folge[0]["id"], ord=offset, ordnung="judge")
offset += 1
continue
liste = "\n".join(f"{i + 1}: {b['titel']}"
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
for i, b in enumerate(folge))
soll = list(range(1, len(folge) + 1))
tag_basis = f"ordnung-{level}-t{thema_id or 0}"
async def ordnen(hinweis: str, tag: str) -> list[int] | None:
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
role="judge", n=len(folge), item=tag, erwartet=list,
werte={"bausteine": liste, "hinweis": hinweis,
"durchgang": DURCHGANG[level]})
perm = [x for x in res or [] if isinstance(x, int)]
return perm if sorted(perm) == soll else None
async def ordnen(hinweis: str, tag: str) -> list[int] | None:
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
role="judge", n=len(folge), item=tag, erwartet=list,
werte={"bausteine": liste, "hinweis": hinweis,
"thema": thema_titel or ctx.topic,
"durchgang": DURCHGANG[level]})
perm = [x for x in res or [] if isinstance(x, int)]
return perm if sorted(perm) == soll else None
perm = await ordnen("", f"ordnung-{level}")
if perm is None:
perm = await ordnen(
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
f" den Nummern 1{len(folge)}, jede genau einmal.",
f"ordnung-{level}-2")
art = "judge"
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
art = "fallback"
perm = soll
for pos, nr in enumerate(perm):
db.update("bausteine", "id", folge[nr - 1]["id"],
ord=offset + pos, ordnung=art)
offset += len(folge)
perm = await ordnen("", tag_basis)
if perm is None:
perm = await ordnen(
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
f" den Nummern 1{len(folge)}, jede genau einmal.",
f"{tag_basis}-2")
art = "judge"
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
art = "fallback"
perm = soll
for pos, nr in enumerate(perm):
db.update("bausteine", "id", folge[nr - 1]["id"],
ord=offset + pos, ordnung=art)
offset += len(folge)
async def _level_kalibrieren(ctx: llm.Kontext) -> None:
@@ -265,94 +358,37 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
await llm.alle(einer(z, g) for z, g in gruppen.items())
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
def _kapitel_bilden(topic: str) -> None:
"""Kapitel = (Level, Thema)-Segment der geordneten Baustein-Folge
deterministisch, KEIN Judge. Kontiguität ist durch die globale Sortierung
(Level-Rang, Thema-ord, Feinordnung) konstruktiv garantiert; Titel = Thema-
Titel. Der frühere Grenz-Judge samt Retry/√n-Fallback/Größen-Gate entfällt."""
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,))}
themen = {t["id"]: t for t in db.query("SELECT * FROM themen 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)
pos = 0
letzter_key = None
k_id = None
for b in alle:
key = (b["level"], b["thema_id"])
if key != letzter_key:
titel = themen.get(b["thema_id"], {}).get("titel") or b["titel"]
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos,
art="det", level=b["level"])
pos += 1
letzter_key = key
db.update("bausteine", "id", b["id"], kapitel_id=k_id)
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
await _themen_bilden(ctx) # Soll-Punkte → Themen (Kapitel-Gerüst)
await _ziele_bilden(ctx)
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt Dominanz
await _bausteine_schneiden(ctx)
await _kapitel_bilden(ctx)
_kapitel_bilden(ctx.topic)
def messen(ctx: llm.Kontext) -> list[dict]:
@@ -361,25 +397,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
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"]})
# Themen-Partition: jeder bestätigte Soll-Punkt braucht ein gültiges Thema
themen_ids = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?",
(ctx.topic,))}
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'",
(ctx.topic,)):
if p["thema_id"] not in themen_ids:
befunde.append({"art": "thema_partition", "item": str(p["id"]),
"detail": p["punkt"]})
for t in db.query("SELECT * FROM themen WHERE topic=? AND art='fallback'",
(ctx.topic,)): # stiller Fallback wäre unsichtbarer Verlust
befunde.append({"art": "themen_fallback", "item": str(t["id"]),
"detail": t["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)
soll_thema = {p["id"]: p["thema_id"] for p in
db.query("SELECT id, thema_id FROM soll WHERE topic=?", (ctx.topic,))}
# 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).
# THEMA, anderer Baustein) — spiegelgleich zur Merge-Schranke, damit die
# Messlatte erfüllbar bleibt (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)
partner = any(bb["id"] != b["id"] and bb["thema_id"] == b["thema_id"]
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']})"})
# Themenreinheit: die Soll-Punkte aller Atome gehören zum Baustein-Thema.
# Atome ohne soll_id überspringen — deren Befund gehört zur Inventar-Ebene.
for a in je_baustein[b["id"]]:
if a["soll_id"] and soll_thema.get(a["soll_id"]) != b["thema_id"]:
befunde.append({"art": "baustein_thema_mix", "item": str(a["id"]),
"detail": f"Atom {a['titel'][:60]} fremdes Thema"
f" in {b['titel']}"})
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}
@@ -392,30 +444,18 @@ def messen(ctx: llm.Kontext) -> list[dict]:
"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"]})
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback)
for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}):
befunde.append({"art": "ordnung_fallback", "item": lv,
"detail": f"Durchgang {lv}: Judge lieferte keine gültige"
f" Permutation — Quellreihenfolge übernommen"})
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (je Segment)
segmente = sorted({(b["level"], b["thema_id"]) for b in bausteine
if b["ordnung"] == "fallback"},
key=lambda s: (LEVEL_RANG.get(s[0], 9), s[1] or 0))
for level, thema_id in segmente:
befunde.append({"art": "ordnung_fallback", "item": f"{level}/t{thema_id or 0}",
"detail": f"Segment {level}/{thema_id}: Judge lieferte keine"
f" gültige Permutation — Quellreihenfolge übernommen"})
return befunde
@@ -423,8 +463,5 @@ 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 _level_kalibrieren(ctx)
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt
return True