"""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 Counter, defaultdict import db import llm from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG, LEVEL_RANG, THEMA_SOLL_PUNKTE, 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,)) # ── 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: 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 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 = 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" " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) 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"]].append(a) 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: # 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: 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) gruppen[p] += gruppen.pop(k) 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, 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) # 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=_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) await _bausteine_ordnen(ctx) async def _bausteine_ordnen(ctx: llm.Kontext) -> None: """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: 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, "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("", 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: """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 _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 themen = {t["id"]: t for t in db.query("SELECT * FROM themen WHERE topic=?", (topic,))} db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) 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 Dominanz await _bausteine_schneiden(ctx) _kapitel_bilden(ctx.topic) 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"]}) # 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 # 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["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"}) # 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} 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} for k in kaps: if k["id"] not in belegt: befunde.append({"art": "kapitel_leer", "item": str(k["id"]), "detail": k["titel"]}) # 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 async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: ctx.ebene = EBENE if not befunde: return False await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt return True