"""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