This commit is contained in:
team3
2026-07-14 00:15:03 +02:00
parent af0e3c0629
commit 3f6114681c
22 changed files with 757 additions and 307 deletions

View File

@@ -116,7 +116,8 @@ DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang})
BAUSTEIN_MIN_ATOME = 4
BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
THEMA_SOLL_PUNKTE = 8 # Richtwert Soll-Punkte je Thema (Themen-Schnitt-Judge);
# zu klein → Singleton-Kapitel, zu groß → Mammut-Kapitel
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
@@ -125,6 +126,8 @@ SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
LOOP_MAX_ITER = 10
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung
AUFTRAG_RUNDEN_MAX = 4 # Auftrag nach so vielen Urteilsrunden ohne Abschluss → eskaliert
KLAERUNG_PANEL = 3 # Stichentscheid über eskalierte falsch/luecke: Mehrheit 2/3
# (Lektion 19); läuft je Auftrag genau einmal (auftraege.geklaert)
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
TIMEOUTS = {
@@ -142,8 +145,10 @@ TIMEOUTS = {
"fix": (300, 15),
"ziele": (300, 6),
"level": (200, 4),
"themen": (300, 2),
"kapitel": (200, 2),
"ordnung": (300, 2),
"klaerung": (300, 5),
"writer": (450, 60),
"pruefer": (600, 5),
"pruefer_urteil": (300, 5),

View File

@@ -67,7 +67,11 @@ CREATE TABLE IF NOT EXISTS quellen(
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]');
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]',
thema_id INTEGER);
CREATE TABLE IF NOT EXISTS themen(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, art TEXT NOT NULL DEFAULT 'judge');
CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
@@ -93,7 +97,8 @@ CREATE TABLE IF NOT EXISTS lernziele(
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge');
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge',
thema_id INTEGER);
CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
@@ -119,13 +124,14 @@ CREATE TABLE IF NOT EXISTS auftraege(
detail TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'pruefer',
status TEXT NOT NULL DEFAULT 'offen'
CHECK(status IN ('offen','behoben','kein_mangel','eskaliert')),
runden INTEGER NOT NULL DEFAULT 0);
runden INTEGER NOT NULL DEFAULT 0,
geklaert INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_auftraege ON auftraege(baustein_id, status);
"""
# Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
"lernziele", "bausteine", "kapitel", "sections", "befunde", "themen"}
def _init_schema(con: sqlite3.Connection) -> None:
@@ -145,6 +151,11 @@ def _init_schema(con: sqlite3.Connection) -> None:
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'",
# Themen-Schicht: Soll-Punkte tragen die Struktur (Refactoring Stufe 1)
"ALTER TABLE soll ADD COLUMN thema_id INTEGER",
"ALTER TABLE bausteine ADD COLUMN thema_id INTEGER",
# Klärungs-Stichentscheid: eskalierte Aufträge einmalig klären
"ALTER TABLE auftraege ADD COLUMN geklaert INTEGER NOT NULL DEFAULT 0",
# braucht-Maschinerie entfernt: Ordnung kommt vom Judge,
# nicht mehr vom (zu dünnen) Abhängigkeits-Graph
"ALTER TABLE atome DROP COLUMN braucht"):

View File

@@ -208,12 +208,15 @@ def _titel_fix(prompt: str):
return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen]
def _kapitel_schnitt(prompt: str):
ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"),
re.MULTILINE)]
grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)]
return [{"titel": f"Themenblock {n + 1}", "bis": g}
for n, g in enumerate(dict.fromkeys(grenzen))]
def _themen_schnitt(prompt: str):
# konsekutive 2er-Gruppen; Rest hängt an der letzten Gruppe (nie Singleton,
# damit das Größen-Gate im Code deterministisch passiert)
nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "SOLL-PUNKTE:"),
re.MULTILINE)]
gruppen = [nummern[i:i + 2] for i in range(0, len(nummern), 2)]
if len(gruppen) > 1 and len(gruppen[-1]) == 1:
gruppen[-2] += gruppen.pop()
return [{"titel": f"Fake-Thema {n + 1}", "punkte": g} for n, g in enumerate(gruppen)]
def _baustein_ordnung(prompt: str):
@@ -251,6 +254,14 @@ def _pruefer_urteil(prompt: str):
return [{"auftrag": i, "urteil": "behoben"} for i in nummern]
def _klaerung(prompt: str):
# Default „unbegruendet": schließt Konflikte, Fake-E2E terminiert bei Note 10
nummern = [int(i) for i in re.findall(r"^(\d+): ",
_text_nach(prompt, "ESKALIERTE AUFTRÄGE:"),
re.MULTILINE)]
return [{"auftrag": i, "urteil": "unbegruendet"} for i in nummern]
def _fix(prompt: str):
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
@@ -279,9 +290,10 @@ _HANDLER = {
"Artefakt-Fix": _artefakt_fix,
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Themen-Schnitt": _themen_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Baustein-Ordnung": _baustein_ordnung,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer,
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix,
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Konflikt-Klaerung": _klaerung,
"Guide-Fix": _fix,
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
}

View File

@@ -19,8 +19,8 @@ import jsonx
import llm
import belege
import textkit
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG,
SECTION_WOERTER_PRO_ATOM)
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, KLAERUNG_PANEL,
LEVEL_RANG, SECTION_WOERTER_PRO_ATOM)
log = logging.getLogger("creator2.guide")
@@ -186,13 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels. Der Writer knüpft an, statt neu zu erklären
(Dopplungs-Schutz zwischen den Durchgängen)."""
desselben Ziels — aber NIE Atome dieses Bausteins selbst (seit Level =
dominantes Atom-Level enthält ein M-Baustein auch eigene E-Atome; die
stünden sonst doppelt im Writer-Prompt: als bekannt UND als zu schreiben)."""
rang = LEVEL_RANG.get(b["level"], 1)
kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)):
if LEVEL_RANG.get(a["level"], 1) < rang:
if a["baustein_id"] != b["id"] and LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
@@ -454,6 +455,14 @@ def _offene_auftraege(b_id: int) -> list[dict]:
" ORDER BY id", (b_id,))
def _klaerung_kandidaten(b_id: int) -> list[dict]:
"""Eskalierte falsch/luecke, die noch nie geklärt wurden. eskaliert+geklaert=1
ist der terminale Endzustand (unauflösbar) — Klärung läuft je Auftrag genau
einmal, auch nach Re-Eskalation (strukturell, kein Sondercode)."""
return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='eskaliert'"
" AND art IN ('falsch','luecke') AND geklaert=0 ORDER BY id", (b_id,))
def _fakten_von(atome: list[dict]) -> str:
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
@@ -499,6 +508,54 @@ async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict,
db.update("auftraege", "id", a["id"], runden=a["runden"] + 1)
async def _klaeren(ctx: llm.Kontext, b: dict) -> None:
"""Stichentscheid (Lektion 19) über eskalierte falsch/luecke: EIN 3er-Panel
je Baustein, Mehrheit entscheidet. text_falsch → Auftrag wieder offen (der
Fix bekommt ein Streich-Mandat); unbegruendet → kein_mangel; quelle_unklar
oder Voll-Patt → terminal unauflösbar. Unter 3 gültigen Stimmen KEINE
Entscheidung (fail-closed, Lektion 20) — nächste Runde erneut."""
kand = _klaerung_kandidaten(b["id"])
if not kand:
return
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
return
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(kand, 1))
stimmen = await llm.panel(ctx, KLAERUNG_PANEL, stage="klaerung",
template="Guide-Konflikt-Klaerung", schritt="klaerung",
role="judge", n=len(kand), item=f"b{b['id']}-klaerung",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": liste},
erwartet=list)
je: dict[int, list[str]] = {}
for s in stimmen:
for e in s:
if isinstance(e, dict):
try:
urteil = str(e.get("urteil", "")).strip()
if urteil in ("text_falsch", "unbegruendet", "quelle_unklar"):
je.setdefault(int(e.get("auftrag")), []).append(urteil)
except (TypeError, ValueError):
continue
for i, a in enumerate(kand, 1):
urteile = je.get(i, [])
if len(urteile) < KLAERUNG_PANEL:
continue # Rumpf-Panel → keine Entscheidung, bleibt geklaert=0
mehrheit = max(set(urteile), key=urteile.count)
if urteile.count(mehrheit) * 2 <= len(urteile):
mehrheit = "quelle_unklar" # Voll-Patt (1/1/1) → terminal
# EIN atomares Update je Auftrag → crash-/resume-sicher
if mehrheit == "text_falsch":
db.update("auftraege", "id", a["id"], status="offen", runden=0, geklaert=1)
elif mehrheit == "unbegruendet":
db.update("auftraege", "id", a["id"], status="kein_mangel", geklaert=1)
else:
db.update("auftraege", "id", a["id"], geklaert=1) # bleibt eskaliert
async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene
@@ -545,9 +602,19 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
auftraege = det + [f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"]
for a in offene]
def _auftrag_zeile(a: dict) -> str:
zeile = (f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"])
# Klärungs-Vermerk generiert, NICHT im detail persistiert — der eingefrorene
# Wortlaut ist die Dedup-Invariante der auftraege-Tabelle
if a["geklaert"] and a["art"] in ("falsch", "luecke"):
zeile += (" [KLÄRUNG: 3er-Panel bestätigt — der Text widerspricht hier der"
" Faktenbasis. Anhand der Faktenbasis korrigieren; ist die Passage"
" dort nicht belegbar, STREICHEN oder aufs Belegbare abschwächen.]")
return zeile
auftraege = det + [_auftrag_zeile(a) for a in offene]
if not auftraege:
return "done"
atome = _atome_von(b["id"])
@@ -718,15 +785,22 @@ def messen(ctx: llm.Kontext) -> list[dict]:
befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
# Gate. Eskalierte dreiteilig: ungeklärt → klaerung_offen (reparierbar,
# Stichentscheid), geklärt → fakten_konflikt (terminal, kleine ehrliche
# Rest-Schuld), Stil → stil_rest (terminal, war vorher komplett stumm).
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND"
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)):
befunde.append({"art": "fakten_konflikt", "item": str(b["id"]),
for a in db.query("SELECT art, detail, geklaert FROM auftraege WHERE"
" baustein_id=? AND status='eskaliert'", (b["id"],)):
if a["art"] == "stil":
art = "stil_rest"
elif a["geklaert"]:
art = "fakten_konflikt"
else:
art = "klaerung_offen"
befunde.append({"art": art, "item": str(b["id"]),
"detail": a["detail"][:300]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
@@ -748,13 +822,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong)."""
gegen alles Nicht-Behobene; trifft er eine offene ODER kein_mangel-Zeile,
widersprechen sich zwei Panels → eskaliert = ab in den Klärungs-Stichentscheid.
geklaert wird NIE zurückgesetzt: war der Auftrag schon geklärt, ist das
terminal (deckelt QA↔Klärung-Ping-Pong hart auf einen Durchlauf)."""
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim))
if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
elif alt["status"] == "kein_mangel":
elif alt["status"] in ("offen", "kein_mangel"):
db.update("auftraege", "id", alt["id"], status="eskaliert")
@@ -790,8 +866,10 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
erwartet=list)
if verdacht is None:
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
claims = [str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")]
# dedupen — ein doppelt gemeldeter Claim eskalierte sonst im selben
# Durchlauf seine eigene frisch eingefügte Zeile
claims = list(dict.fromkeys(str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")))
if not claims:
hash_setzen(b["id"], h) # sauber geprüft
return
@@ -828,14 +906,16 @@ def _text_sig(b_id: int) -> str:
def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt,
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)-
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab)."""
"""Zustands-Snapshot der Aufträge dieser Bausteine. Status- und geklaert-
Übergänge sind Fortschritt (auch ohne Textänderung); ein reines runden-
Inkrement zählt NICHT — identisches Panel-Patt auf identischem Text ist
per Definition Leer-Churn und hielt auto_loop sonst bis LOOP_MAX_ITER am
Leben. Echte Urteilsrunden schließen/eskalieren etwas → Statuswechsel."""
if not b_ids:
return set()
marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["runden"]) for r in db.query(
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})",
return {(r["id"], r["status"], r["geklaert"]) for r in db.query(
f"SELECT id, status, geklaert FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))}
@@ -846,16 +926,29 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
klaerbar: set[int] = set()
for b in befunde:
if b["art"] == "fakten_konflikt":
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
if b["art"] in ("fakten_konflikt", "stil_rest"):
continue # terminal: geklärt-unauflösbar bzw. Stil-Rest — keine Route
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
if b["art"] == "klaerung_offen":
klaerbar.add(b_id) # Stichentscheid statt Stage-Routing
continue
betroffen.setdefault(b_id, []).append(b)
if not betroffen:
if not betroffen and not klaerbar:
return False
alle_ids = list(betroffen.keys() | klaerbar)
text_vorher = {b_id: _text_sig(b_id) for b_id in alle_ids}
stand_vorher = _auftrag_stand(alle_ids)
# Klärung VOR dem Routing: text_falsch öffnet Aufträge wieder → die routen
# unten in den Fix; unbegruendet/quelle_unklar brauchen keine Stage.
if klaerbar:
je_baustein = {bb["id"]: bb for bb in _bausteine(ctx.topic)}
await llm.alle(_klaeren(ctx, je_baustein[b_id])
for b_id in sorted(klaerbar) if b_id in je_baustein)
geroutet: list[int] = []
for b_id, liste in betroffen.items():
arten = {x["art"] for x in liste}
@@ -874,10 +967,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
stand_vorher = _auftrag_stand(geroutet)
for b_id in sorted(klaerbar):
# wiedereröffnete Aufträge → direkt in den Fix (der Re-Check urteilt danach;
# umgeht den Freeze bewusst — das Streich-Mandat erzeugt eine Textänderung)
if b_id not in geroutet and _offene_auftraege(b_id):
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
await bauen(ctx)
bewegt = _auftrag_stand(geroutet) != stand_vorher
bewegt = _auftrag_stand(alle_ids) != stand_vorher
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))

View File

@@ -182,6 +182,7 @@ def state(topic: str):
{"verifiziert": 0, "gesamt": ziel_gesamt})}
for a in atome],
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
"themen": db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,)),
"bausteine": db.query(
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
" WHERE b.topic=? ORDER BY b.ord", (topic,)),

View File

@@ -126,6 +126,9 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
# Themen-Schicht fällt mit — das Soll selbst bleibt eingefroren
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
db.execute("UPDATE soll SET thema_id=NULL WHERE topic=?", (topic,))
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
(topic,))
if stufe >= 2: # artefakte
@@ -200,6 +203,7 @@ def soll_reset(topic: str) -> None:
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0"
" WHERE topic=?", (topic,))

View File

@@ -29,12 +29,14 @@ GEWICHTE = {
"flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5,
"paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5,
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
"level_mix": 3.0, "kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"thema_partition": 3.0, "baustein_thema_mix": 3.0, "themen_fallback": 1.5,
"kapitel_level": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5,
"titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"det_check": 0.5, "auftrag_offen": 1.5, "fakten_konflikt": 1.5,
"det_check": 0.5, "auftrag_offen": 1.5, "klaerung_offen": 1.5,
"fakten_konflikt": 0.5, "stil_rest": 0.5,
"beispiel_marker_tot": 3.0,
}

View File

@@ -1,16 +1,19 @@
"""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."""
"""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 defaultdict
from collections import Counter, defaultdict
import db
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
LEVEL_RANG, THEMA_SOLL_PUNKTE, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.struktur")
@@ -22,6 +25,68 @@ def _atome(topic: str) -> list[dict]:
" ('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:
@@ -100,12 +165,28 @@ def _baustein_rang(bausteine: list[dict], atome: list[dict],
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 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")."""
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"
@@ -113,51 +194,50 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
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"], a["level"])].append(a)
gruppen[a["ziel_id"]].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)
# 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:
# 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]]
# 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) -> tuple:
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[0])
db.update("atome", "id", a["id"], ziel_id=p)
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")
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, level), gruppe in gruppen.items():
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)
@@ -174,7 +254,9 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
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)
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)
@@ -182,50 +264,61 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
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)."""
"""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:
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))
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,
"durchgang": DURCHGANG[level]})
perm = [x for x in res or [] if isinstance(x, int)]
return perm if sorted(perm) == soll else None
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("", 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)
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:
@@ -265,94 +358,37 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
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
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
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
themen = {t["id"]: t for t in db.query("SELECT * FROM themen 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)
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 die Bausteine
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt Dominanz
await _bausteine_schneiden(ctx)
await _kapitel_bilden(ctx)
_kapitel_bilden(ctx.topic)
def messen(ctx: llm.Kontext) -> list[dict]:
@@ -361,25 +397,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
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
# 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).
# 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["level"] == b["level"] for bb in bausteine)
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"})
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']})"})
# 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}
@@ -392,30 +444,18 @@ def messen(ctx: llm.Kontext) -> list[dict]:
"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"})
# 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
@@ -423,8 +463,5 @@ 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
await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt
return True

View File

@@ -26,6 +26,7 @@ def export(topic: str) -> dict:
"topics": q("SELECT * FROM topics WHERE name=?", (topic,)),
"quellen": q("SELECT * FROM quellen WHERE topic=?", (topic,)),
"soll": q("SELECT * FROM soll WHERE topic=?", (topic,)),
"themen": q("SELECT * FROM themen WHERE topic=?", (topic,)),
"lernziele": q("SELECT * FROM lernziele WHERE topic=?", (topic,)),
"kapitel": q("SELECT * FROM kapitel WHERE topic=?", (topic,)),
"bausteine": q("SELECT * FROM bausteine WHERE topic=?", (topic,)),
@@ -101,11 +102,13 @@ def importieren(d: dict) -> None:
z["geprueft"] = db.j([q_map[q] for q in db.uj(z.get("geprueft") or "[]")
if q in q_map])
soll_zeilen.append(z)
s_map = _einfuegen("soll", soll_zeilen)
t_map = _einfuegen("themen", d.get("themen", [])) # Alt-Exporte: kein Key → leer
s_map = _einfuegen("soll", soll_zeilen, thema_id=t_map)
z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
k_map = _einfuegen("kapitel", d["kapitel"])
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map)
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map,
thema_id=t_map)
# merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus
# Alt-Exporten verwerfen (Spalte existiert nicht mehr)
a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"},