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

View File

@@ -10,6 +10,8 @@ MÄNGEL:
BELEGE (einzige erlaubte Faktenbasis — nichts erfinden):
{belege}
Regel: Mathe IMMER in $…$ (KaTeX), nie als roher Klartext.
Antworte NUR mit JSON (keine Code-Fences). Für flashcard:
{{"frage": "…", "antwort": "…"}}
Für beispiel: BEHALTE die Form aus dem Artefakt oben und gib die passenden Felder zurück:

View File

@@ -23,6 +23,8 @@ Regeln:
- Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen.
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
keine Rezitation exakter Formulierungen verlangen).
- Mathe IMMER in $…$ (KaTeX), nie als roher Klartext — gilt für jeden
mathematischen Ausdruck in Frage, Antwort und Beispieltext.
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
„Beleg", „Quelle", „Musterlösung", „Skript", „Aufgabe 3" sind verboten. Die Frage
fragt Wissen ab („Was ist X?", „Warum gilt Y?"), nie was in einem Text steht,

View File

@@ -13,6 +13,9 @@ Prüfe jedes Artefakt GEGEN DIE BELEGE:
funktionieren und Wissen abfragen, nicht Textinhalte.
- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte
Fachtermini sind okay; ganze englische Sätze nicht).
- ok=false, wenn mathematische Ausdrücke NICHT in $…$ (KaTeX) stehen —
roher Klartext wird beim Lernen falsch dargestellt. „mangel": die
betroffenen Ausdrücke nennen.
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
- Antworte für JEDES Artefakt (id wörtlich übernehmen).

View File

@@ -1,5 +1,6 @@
<!-- template:Baustein-Ordnung -->
Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
Thema: {thema}
Durchgang: {durchgang}
BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials):

View File

@@ -23,6 +23,9 @@ Regeln:
- Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der
Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…".
- Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis.
- Trägt ein Auftrag den Vermerk [KLÄRUNG …]: die strittige Passage anhand der
Faktenbasis korrigieren; ist sie dort nicht belegbar, die Aussage STREICHEN
oder auf das Belegbare abschwächen — nicht umformulieren und behalten.
Antworte in GENAU ZWEI Blöcken mit diesen Trennern (KEIN JSON, keine Code-Fences).
LaTeX-Backslashes bleiben roh (kein Escaping):

View File

@@ -0,0 +1,29 @@
<!-- template:Guide-Konflikt-Klaerung -->
Du bist Stichentscheider: Zwei 2er-Panels konnten sich über die folgenden
Aufträge nicht einigen — sie sind eskaliert. Entscheide NUR anhand der
Faktenbasis (kein Weltwissen). Lernziel: {ziel}
FAKTENBASIS (einzige Wahrheit — Definitionen + wörtliche Belege):
{fakten}
SECTION (kompakt):
{kompakt}
SECTION (lang):
{lang}
ESKALIERTE AUFTRÄGE:
{auftraege}
Urteile je Auftrag:
- "text_falsch": Der Text widerspricht an der bemängelten Stelle der Faktenbasis
ODER die bemängelte Aussage ist aus ihr nicht ableitbar. Der Auftrag ist berechtigt.
- "unbegruendet": Der Text ist mit der Faktenbasis vereinbar — der Auftrag ist ein
Fehlalarm. Sinngemäße Formulierung und mathematisch äquivalente Schreibweisen
sind KEIN Mangel.
- "quelle_unklar": Die Faktenbasis selbst ist widersprüchlich oder zu dünn, um
den Streit zu entscheiden.
Melde KEINE neuen Mängel. Genau ein Urteil je Auftrag, alle Aufträge.
Antworte NUR mit JSON (keine Code-Fences):
[{{"auftrag": 1, "urteil": "text_falsch"}}, {{"auftrag": 2, "urteil": "unbegruendet"}}]

View File

@@ -1,22 +0,0 @@
<!-- template:Kapitel-Schnitt -->
Unten die BAUSTEINE eines Lern-Guide-Durchgangs in ihrer endgültigen
Lese-Reihenfolge, durchnummeriert (Format „nummer: titel — lernziel").
Durchgang: {durchgang}. Formuliere die Kapiteltitel für diesen Durchgang.
Setze Kapitelgrenzen. Regeln:
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = NUMMER des LETZTEN
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen,
"bis" muss streng aufsteigen. Das letzte "bis" ist die letzte Nummer der Liste.
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
- Jedes Kapitel bündelt MINDESTENS 2 Bausteine (nur das letzte darf kleiner
sein) und höchstens das Doppelte des Durchschnitts — keine Einzel-Kapitel.
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
Baustein-Titel-Duplikat, keine Nummerierung).
{hinweis}
BAUSTEINE:
{bausteine}
Antworte NUR mit JSON (keine Code-Fences):
[{{"titel": "…", "bis": 12}}, {{"titel": "…", "bis": 27}}]

View File

@@ -0,0 +1,22 @@
<!-- template:Themen-Schnitt -->
Unten die bestätigten SOLL-PUNKTE eines Lernthemas, durchnummeriert
(Format „nummer: punkt"). Die Liste ist NICHT thematisch sortiert.
Gruppiere die Punkte in kohärente THEMEN — sie werden die Kapitel des Guides.
Regeln:
- Jede Nummer gehört in GENAU EIN Thema: keine auslassen, keine doppeln,
keine Nummern erfinden. Vollständige Partition.
- Ein Thema = ein zusammenhängendes Teilgebiet, das man am Stück lernt.
- Die Reihenfolge deiner Antwortzeilen ist die LESE-Reihenfolge:
Grundlagen-Themen vor Themen, die darauf aufbauen.
- Richtwert: etwa {richtwert} Themen. Jedes Thema bündelt mindestens 2 Punkte
(höchstens EIN kleineres Rest-Thema) und höchstens {max_punkte}.
- „titel" = kurzer Themenname, 36 Wörter, Substantiv-Stil, keine Nummerierung —
er wird Kapitel-Titel im Guide.
{hinweis}
SOLL-PUNKTE:
{punkte}
Antworte NUR mit JSON (keine Code-Fences):
[{{"titel": "…", "punkte": [1, 4, 5]}}, {{"titel": "…", "punkte": [2, 3]}}]

View File

@@ -184,3 +184,131 @@ async def test_freeze_urteilt_weiter(monkeypatch):
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}])
assert bewegt is True # geurteilt (behoben) trotz Freeze
assert db.one("SELECT COUNT(*) n FROM auftraege WHERE status='behoben'")["n"] == 1
# ── Klärungs-Stichentscheid (eskalierte falsch/luecke) ────────────────────────
async def test_klaerung_text_falsch_wiedereroeffnet(monkeypatch):
"""Mehrheit text_falsch → Auftrag wieder offen (runden=0, geklaert=1); der
Fix bekommt das Streich-Mandat als [KLÄRUNG]-Vermerk."""
topic = topic_anlegen("klaer1")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Zahl falsch",
status="eskaliert", runden=AUFTRAG_RUNDEN_MAX)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": "text_falsch"}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["runden"], r["geklaert"]) == ("offen", 0, 1)
# der Fix-Prompt trägt das Streich-Mandat
gesehen = {}
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
def fix(prompt):
gesehen["klaerung"] = "[KLÄRUNG" in prompt and "STREICHEN" in prompt
return {"kompakt": "- p", "lang": sec["text_lang"] + " korrigiert"}
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", fix)
db.update("sections", "baustein_id", b["id"], stage="fix")
await guide._stage_fix(_ctx(topic), b)
assert gesehen["klaerung"]
async def test_klaerung_unbegruendet_schliesst():
"""Default-Fake stimmt unbegruendet → kein_mangel, geklaert=1, bewegt True."""
topic = topic_anlegen("klaer2")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="luecke", detail="Fehlalarm",
status="eskaliert")
db.update("sections", "baustein_id", b["id"], stage="done")
bewegt = await guide.reparieren(_ctx(topic), [
{"art": "klaerung_offen", "item": str(b["id"]), "detail": "Fehlalarm"}])
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("kein_mangel", 1)
assert bewegt is True
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
(b["id"],))["stage"] == "done" # keine Stage-Route nötig
async def test_klaerung_patt_terminal(monkeypatch):
"""Voll-Patt (1/1/1) → geklaert=1, bleibt eskaliert; messen zeigt
fakten_konflikt; zweiter reparieren-Lauf ruft die Klärung nie wieder."""
topic = topic_anlegen("klaer3")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="strittig",
status="eskaliert")
votes = iter(["text_falsch", "unbegruendet", "quelle_unklar"])
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": next(votes)}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
ctx = _ctx(topic)
arten = [x["art"] for x in guide.messen(ctx)]
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: pytest.fail("geklärter Auftrag erneut geklärt"))
assert await guide.reparieren(ctx, [{"art": "fakten_konflikt",
"item": str(b["id"]), "detail": "strittig"}]) is False
async def test_klaerung_ohne_gueltige_stimmen_fail_closed(monkeypatch):
"""Liefern die Richter keine gültigen Urteile, bleibt geklaert=0 —
nächste Runde klärt erneut (fail-closed)."""
topic = topic_anlegen("klaer4")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
status="eskaliert")
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
lambda p: [{"auftrag": 1, "urteil": "quatsch"}])
await guide._klaeren(_ctx(topic), b)
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 0)
async def test_runden_inkrement_ist_nicht_bewegt(monkeypatch):
"""Identisches Panel-Patt auf identischem Text = Leer-Churn → reparieren
meldet ehrlich False (runden zählt nicht mehr als Bewegung)."""
topic = topic_anlegen("leerchurn")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Patt")
db.update("sections", "baustein_id", b["id"], stage="done")
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]})
bewegt = await guide.reparieren(_ctx(topic), [
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) Patt"}])
assert bewegt is False # nur runden+1, kein Status-/Text-Fortschritt
async def test_stil_rest_keine_route():
topic = topic_anlegen("stilrest")
b = _baustein(topic)
db.insert("auftraege", baustein_id=b["id"], art="stil", detail="zäh",
status="eskaliert")
db.update("sections", "baustein_id", b["id"], stage="done")
assert await guide.reparieren(_ctx(topic), [
{"art": "stil_rest", "item": str(b["id"]), "detail": "zäh"}]) is False
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
(b["id"],))["stage"] == "done"
async def test_reeskalation_nach_klaerung_terminal(monkeypatch):
"""Wiedereröffneter (geklärter) Auftrag, der erneut am Cap eskaliert, ist
terminal unauflösbar — keine zweite Klärung."""
topic = topic_anlegen("reesk")
b = _baustein(topic)
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
geklaert=1, runden=AUFTRAG_RUNDEN_MAX - 1)
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
lambda p: [{"auftrag": 1, "urteil": "offen"}])
await guide._auftraege_urteilen(_ctx(topic), b,
db.one("SELECT * FROM sections WHERE baustein_id=?",
(b["id"],)),
guide._offene_auftraege(b["id"]))
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
ctx = _ctx(topic)
arten = [x["art"] for x in guide.messen(ctx)]
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
assert guide._klaerung_kandidaten(b["id"]) == []

View File

@@ -284,7 +284,8 @@ def test_persistieren_dedup_mit_detail():
async def test_auftrag_offen_in_messen():
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
eskalierte falsch/luecke-Aufträge werden als fakten_konflikt sichtbar."""
eskalierte dreiteilig: ungeklärt → klaerung_offen, geklärt → fakten_konflikt,
Stil → stil_rest (war vorher stumm)."""
import db
import guide
import llm
@@ -298,14 +299,18 @@ async def test_auftrag_offen_in_messen():
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch",
status="eskaliert")
status="eskaliert") # geklaert=0 → wartet auf Stichentscheid
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Unauflösbar",
status="eskaliert", geklaert=1)
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld)
status="eskaliert")
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide"
arten = [b["art"] for b in guide.messen(ctx)]
assert arten.count("auftrag_offen") == 1
assert arten.count("klaerung_offen") == 1
assert arten.count("fakten_konflikt") == 1
assert arten.count("stil_rest") == 1
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):

View File

@@ -53,15 +53,21 @@ def _pruefe_endzustand(topic, run_id):
folge = [b["kapitel_id"] for b in bausteine]
segmente = [k for k, _ in itertools.groupby(folge)]
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord
# Level je Baustein = dominantes Atom-Level; Themenreinheit: die Soll-Punkte
# aller Atome gehören zum Baustein-Thema; Kapitel-Level monoton E→M→S
rang = {"E": 0, "M": 1, "S": 2}
kap_level = {k["id"]: k["level"] for k in kaps}
soll_thema = {p["id"]: p["thema_id"] for p in
db.query("SELECT id, thema_id FROM soll WHERE topic=?", (topic,))}
from collections import Counter
for b in bausteine:
assert kap_level[b["kapitel_id"]] == b["level"]
for a in atome:
if a["baustein_id"] == b["id"]:
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"])
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
zaehler = Counter(a["level"] for a in eigene)
dominant = min(zaehler, key=lambda lv: (-zaehler[lv], rang.get(lv, 9)))
assert b["level"] == dominant, (b["titel"], b["level"], dict(zaehler))
for a in eigene:
assert soll_thema.get(a["soll_id"]) == b["thema_id"], (a["titel"], b["titel"])
level_folge = [rang[b["level"]] for b in bausteine]
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet"
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
@@ -74,9 +80,11 @@ async def test_e2e_thema():
topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
# Fake-Welt: 3 Atome mit Level E/M/S → je Level ein eigener Baustein-Durchgang
# Fake-Welt: 3 Atome E/M/S im selben Thema → EIN Baustein, Level = E
# (Gleichstand 1/1/1 → niedrigstes; kein Level-Split mehr)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M", "S"]
assert [b["level"] for b in bausteine] == ["E"]
assert all(b["thema_id"] for b in bausteine)
async def test_e2e_uni(tmp_path):
@@ -143,6 +151,9 @@ async def test_ebenen_entfernen_kaskade():
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM themen WHERE topic=?", (topic,)) == []
assert all(p["thema_id"] is None for p in
db.query("SELECT thema_id FROM soll WHERE topic=?", (topic,)))
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
@@ -161,7 +172,7 @@ async def test_topic_loeschen():
_pruefe_endzustand(topic, run1)
pipeline.topic_loeschen(topic)
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
"kapitel", "runs"):
"kapitel", "themen", "runs"):
feld = "name" if tabelle == "topics" else "topic"
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle

View File

@@ -14,6 +14,14 @@ def _atom(topic, titel, ziel_id=None, soll_id=1):
soll_id=soll_id, ziel_id=ziel_id)
def _thema(topic, soll_ids, titel="T", ord=0):
"""Themen-Seed: eine themen-Zeile, Soll-Punkte darauf zeigen lassen."""
t = db.insert("themen", topic=topic, titel=titel, ord=ord, art="judge")
for s in soll_ids:
db.update("soll", "id", s, thema_id=t)
return t
def _baustein_groessen(topic):
from collections import Counter
c = Counter(a["baustein_id"] for a in struktur._atome(topic))
@@ -88,13 +96,14 @@ async def test_band_split():
assert not [x for x in befunde if x["art"] in ("band", "partition")]
async def test_merge_ohne_soll_schranke_und_kapitel():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente
async def test_merge_im_thema_ueber_soll_grenzen():
# kleines Ziel merged über Soll-Punkt-Grenzen, solange BEIDE Punkte im
# selben Thema liegen; Kapitel = (Level, Thema) deterministisch
topic = topic_anlegen("kapitel")
run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
t = _thema(topic, [s1, s2], "Gemeinsames Thema")
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
_atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
@@ -105,9 +114,12 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
await struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
await struktur._kapitel_bilden(ctx)
struktur._kapitel_bilden(topic)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert bausteine and all(x["kapitel_id"] for x in bausteine)
assert all(x["thema_id"] == t for x in bausteine)
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert [k["titel"] for k in kaps] == ["Gemeinsames Thema"]
assert struktur.messen(ctx) == []
@@ -183,32 +195,30 @@ def test_baustein_rang_median_gegen_merge_gift():
assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
async def test_kapitel_retry_und_fallback(monkeypatch):
topic = topic_anlegen("kapfall")
run = run_anlegen(topic)
def test_kapitel_deterministisch(monkeypatch):
"""Kapitel = (Level, Thema)-Segment ohne LLM: Titel = Thema-Titel, art='det',
Reihenfolge folgt der globalen Baustein-ord."""
topic = topic_anlegen("kapdet")
t1 = db.insert("themen", topic=topic, titel="Thema Eins", ord=0, art="judge")
t2 = db.insert("themen", topic=topic, titel="Thema Zwei", ord=1, art="judge")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = [db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{i}", ord=i,
status="neu") for i in range(4)]
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1:
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
lambda p: [{"titel": "x", "bis": 999999}])
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert kaps and all(k["art"] == "fallback" for k in kaps)
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}
ordn = 0
for level in ("E", "M"):
for thema in (t1, t2):
db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{ordn}",
ord=ordn, status="neu", level=level, thema_id=thema)
ordn += 1
def explodiert(prompt): # beweist: kein LLM-Call nötig
raise AssertionError("Kapitel-Bildung darf keinen Judge rufen")
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
struktur._kapitel_bilden(topic)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["titel"] for k in kaps] == ["Thema Eins", "Thema Zwei"] * 2
assert [k["level"] for k in kaps] == ["E", "E", "M", "M"]
assert all(k["art"] == "det" for k in kaps)
zu_kapitel = {b["ord"]: b["kapitel_id"] for b in
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))}
assert len(set(zu_kapitel.values())) == 4 # je (Level, Thema) ein Kapitel
async def test_level_kalibrierung(monkeypatch):
@@ -231,12 +241,13 @@ async def test_level_kalibrierung(monkeypatch):
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
async def test_level_split_ordnung_kapitel():
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
# Kapitel je Durchgang, kein level_mix/kapitel_level
topic = topic_anlegen("split")
async def test_dominantes_level():
# Ein Ziel mit 4×E + 4×M → EIN Baustein (kein Level-Split mehr);
# Level = Mehrheit, Gleichstand → niedrigeres (E). 3×E + 5×M → M.
topic = topic_anlegen("dominant")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
_thema(topic, [soll])
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(4):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
@@ -247,29 +258,106 @@ async def test_level_split_ordnung_kapitel():
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M"]
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["level"] for k in kaps] == ["E", "M"]
assert [b["level"] for b in bausteine] == ["E"] # Tie 4/4 → niedrigeres
struktur._kapitel_bilden(topic)
arten = {b["art"] for b in struktur.messen(ctx)}
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
assert not arten & {"kapitel_level", "band", "partition", "baustein_thema_mix"}
# Mehrheit gewinnt: reine Funktion
assert struktur._dominantes_level([{"level": "E"}] * 3 + [{"level": "M"}] * 5) == "M"
assert struktur._dominantes_level([{"level": "S"}]) == "S"
async def test_kein_merge_ueber_level():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
topic = topic_anlegen("levelmerge")
async def test_kein_merge_ueber_thema():
# kleine Ziele in VERSCHIEDENEN Themen mergen NICHT — zwei Bausteine,
# und kein band-Befund (kein Partner im eigenen Thema)
topic = topic_anlegen("themamerge")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
_thema(topic, [s1], "T1", 0)
_thema(topic, [s2], "T2", 1)
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
for i in range(2):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
level="E", status="neu", soll_id=soll, ziel_id=z1)
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=z2)
_atom(topic, f"A{i}", z1, s1)
_atom(topic, f"B{i}", z2, s2)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx)
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
levels = {a["level"] for a in struktur._atome(topic)
if a["baustein_id"] == b["id"]}
assert levels == {b["level"]}
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert len(bausteine) == 2 and {b["ziel_id"] for b in bausteine} == {z1, z2}
struktur._kapitel_bilden(topic)
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
async def test_themen_bilden_gate_retry_fallback(monkeypatch):
topic = topic_anlegen("themengate")
run = run_anlegen(topic)
punkte = [db.insert("soll", topic=topic, punkt=f"P{i}", status="bestaetigt",
belege=db.j([])) for i in range(4)]
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1: # unvollständig → Gate schlägt zu, Retry
return [{"titel": "T", "punkte": [1, 2]}]
return [{"titel": "Grundlagen", "punkte": [1, 3]},
{"titel": "Vertiefung", "punkte": [2, 4]}]
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", judge)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._themen_bilden(ctx)
assert aufrufe["n"] == 2 # Retry hat gegriffen
themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,))
assert [t["titel"] for t in themen] == ["Grundlagen", "Vertiefung"]
zu = {p: db.one("SELECT thema_id FROM soll WHERE id=?", (p,))["thema_id"]
for p in punkte}
assert zu[punkte[0]] == zu[punkte[2]] == themen[0]["id"]
assert zu[punkte[1]] == zu[punkte[3]] == themen[1]["id"]
assert "thema_partition" not in {b["art"] for b in struktur.messen(ctx)}
# beide Versuche Müll → √n-Fallback + Befund
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
db.execute("UPDATE soll SET thema_id=NULL WHERE topic=?", (topic,))
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", lambda p: [{"x": 1}])
await struktur._themen_bilden(ctx)
themen = db.query("SELECT * FROM themen WHERE topic=?", (topic,))
assert themen and all(t["art"] == "fallback" for t in themen)
assert "themen_fallback" in {b["art"] for b in struktur.messen(ctx)}
offen = db.query("SELECT * FROM soll WHERE topic=? AND thema_id IS NULL", (topic,))
assert offen == [] # Fallback partitioniert vollständig
async def test_themen_skip_wenn_vollstaendig(monkeypatch):
"""Gültige Partition wird NIE neu gewürfelt (Churn-Schutz je Repair-Runde)."""
topic = topic_anlegen("themenskip")
run = run_anlegen(topic)
s = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
_thema(topic, [s])
def explodiert(prompt):
raise AssertionError("vollständige Partition darf keinen Call auslösen")
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._themen_bilden(ctx) # kein Call → kein Raise
async def test_thema_befunde():
topic = topic_anlegen("themabefund")
run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
t = _thema(topic, [s1], "T1")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
arten = {b["art"] for b in struktur.messen(ctx)}
assert "thema_partition" in arten # s2 hat kein Thema
# Baustein trägt T1, enthält aber ein Atom mit Soll-Punkt ohne dieses Thema
ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=s1, status="aktiv")
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0,
status="neu", level="E", thema_id=t)
db.insert("atome", topic=topic, titel="fremd", typ="begriff", definition="d",
status="neu", soll_id=s2, ziel_id=ziel, baustein_id=b)
arten = {x["art"] for x in struktur.messen(ctx)}
assert "baustein_thema_mix" in arten

View File

@@ -16,7 +16,7 @@ async def test_export_import_roundtrip():
pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte",
tabellen = ("quellen", "soll", "themen", "atome", "anker", "kanten", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "auftraege",
"runs", "befunde", "events", "leitner")
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen}
@@ -35,6 +35,11 @@ async def test_export_import_roundtrip():
" ('gemerged','verworfen')", (topic,)):
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
assert a["soll_id"] and db.one("SELECT id FROM soll WHERE id=?", (a["soll_id"],))
# Themen-Verweise remappt: bestätigte Punkte und Bausteine zeigen auf echte Themen
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,)):
assert p["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (p["thema_id"],))
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
assert b["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (b["thema_id"],))
for s in db.query("SELECT belege FROM soll WHERE topic=? AND status='bestaetigt'",
(topic,)):
for b in db.uj(s["belege"]):