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

431 lines
22 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), 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