Files
creator2/backend/struktur.py
2026-07-14 00:15:03 +02:00

468 lines
23 KiB
Python
Raw Permalink 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. 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