431 lines
22 KiB
Python
431 lines
22 KiB
Python
"""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."""
|
||
|
||
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,))
|
||
|
||
|
||
# ── 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())
|
||
|
||
|
||
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
|
||
|
||
def _anker_rang(topic: str) -> dict[int, tuple]:
|
||
"""Natürliche Quellreihenfolge: (quelle_id, start) des ersten Ankers,
|
||
stoff-Quellen bevorzugt — Aufgabensammlungen ordnen nicht didaktisch."""
|
||
out = {}
|
||
for a in _atome(topic):
|
||
row = db.one("SELECT k.quelle_id, k.start FROM anker k"
|
||
" JOIN quellen q ON q.id=k.quelle_id"
|
||
" WHERE k.atom_id=? AND k.start>=0"
|
||
" ORDER BY (q.rolle != 'stoff'), k.quelle_id, k.start LIMIT 1",
|
||
(a["id"],))
|
||
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
|
||
return out
|
||
|
||
|
||
def _baustein_rang(bausteine: list[dict], atome: list[dict],
|
||
rang: dict[int, tuple]) -> dict[int, tuple]:
|
||
"""Quellpositions-Prior je Baustein: MEDIAN der Atom-Ränge — robust gegen
|
||
einzelne Merge-Importe mit fremdem Rang (aak: ein „Komplexitätstheorie"-Atom
|
||
von Zeichen 464 zog den ETH-Baustein per min() an Position 0)."""
|
||
b_rang = {}
|
||
for b in bausteine:
|
||
raenge = sorted(rang.get(a["id"], (9, 9)) for a in atome
|
||
if a["baustein_id"] == b["id"])
|
||
b_rang[b["id"]] = raenge[len(raenge) // 2] if raenge else (9, 9)
|
||
return b_rang
|
||
|
||
|
||
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")."""
|
||
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"
|
||
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
|
||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||
atome = _atome(topic)
|
||
rang = _anker_rang(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 ü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)
|
||
|
||
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 ((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 = sorted((a["id"] for a in gruppe), key=lambda i: rang[i])
|
||
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)
|
||
|
||
await _bausteine_ordnen(ctx)
|
||
|
||
|
||
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)."""
|
||
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,))}
|
||
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))
|
||
|
||
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
|
||
|
||
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)
|
||
|
||
|
||
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())
|
||
|
||
|
||
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 _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||
await _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']})"})
|
||
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"]})
|
||
# 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"})
|
||
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 _level_kalibrieren(ctx)
|
||
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
|
||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
||
return True
|