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_MIN_ATOME = 4
BAUSTEIN_MAX_ATOME = 8 BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call 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 ──────────────────────────────────────────────────────────── # ── Ebene 4: Guide ────────────────────────────────────────────────────────────
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom 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 LOOP_MAX_ITER = 10
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung 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 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 je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
TIMEOUTS = { TIMEOUTS = {
@@ -142,8 +145,10 @@ TIMEOUTS = {
"fix": (300, 15), "fix": (300, 15),
"ziele": (300, 6), "ziele": (300, 6),
"level": (200, 4), "level": (200, 4),
"themen": (300, 2),
"kapitel": (200, 2), "kapitel": (200, 2),
"ordnung": (300, 2), "ordnung": (300, 2),
"klaerung": (300, 5),
"writer": (450, 60), "writer": (450, 60),
"pruefer": (600, 5), "pruefer": (600, 5),
"pruefer_urteil": (300, 5), "pruefer_urteil": (300, 5),

View File

@@ -67,7 +67,11 @@ CREATE TABLE IF NOT EXISTS quellen(
CREATE TABLE IF NOT EXISTS soll( CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL, id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]', 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( CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '', 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', 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( CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL, 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, 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( CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0, 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', detail TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'pruefer',
status TEXT NOT NULL DEFAULT 'offen' status TEXT NOT NULL DEFAULT 'offen'
CHECK(status IN ('offen','behoben','kein_mangel','eskaliert')), 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); CREATE INDEX IF NOT EXISTS idx_auftraege ON auftraege(baustein_id, status);
""" """
# Tabellen, deren Änderungen das Live-Board interessieren. # Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte", _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: 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 quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0", "ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'", "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, # braucht-Maschinerie entfernt: Ordnung kommt vom Judge,
# nicht mehr vom (zu dünnen) Abhängigkeits-Graph # nicht mehr vom (zu dünnen) Abhängigkeits-Graph
"ALTER TABLE atome DROP COLUMN braucht"): "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] return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen]
def _kapitel_schnitt(prompt: str): def _themen_schnitt(prompt: str):
ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"), # 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)] re.MULTILINE)]
grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)] gruppen = [nummern[i:i + 2] for i in range(0, len(nummern), 2)]
return [{"titel": f"Themenblock {n + 1}", "bis": g} if len(gruppen) > 1 and len(gruppen[-1]) == 1:
for n, g in enumerate(dict.fromkeys(grenzen))] gruppen[-2] += gruppen.pop()
return [{"titel": f"Fake-Thema {n + 1}", "punkte": g} for n, g in enumerate(gruppen)]
def _baustein_ordnung(prompt: str): def _baustein_ordnung(prompt: str):
@@ -251,6 +254,14 @@ def _pruefer_urteil(prompt: str):
return [{"auftrag": i, "urteil": "behoben"} for i in nummern] 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): def _fix(prompt: str):
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip() kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip() lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
@@ -279,9 +290,10 @@ _HANDLER = {
"Artefakt-Fix": _artefakt_fix, "Artefakt-Fix": _artefakt_fix,
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify, "Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "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, "Baustein-Ordnung": _baustein_ordnung,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "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, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
} }

View File

@@ -19,8 +19,8 @@ import jsonx
import llm import llm
import belege import belege
import textkit import textkit
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, KLAERUNG_PANEL,
SECTION_WOERTER_PRO_ATOM) LEVEL_RANG, SECTION_WOERTER_PRO_ATOM)
log = logging.getLogger("creator2.guide") log = logging.getLogger("creator2.guide")
@@ -186,13 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
def _anknuepf_kontext(b: dict) -> str: def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome """Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels. Der Writer knüpft an, statt neu zu erklären desselben Ziels — aber NIE Atome dieses Bausteins selbst (seit Level =
(Dopplungs-Schutz zwischen den Durchgängen).""" 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) rang = LEVEL_RANG.get(b["level"], 1)
kontext: dict[int, dict] = {} kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN" for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)): " ('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 kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values()) 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,)) " 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: def _fakten_von(atome: list[dict]) -> str:
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n" 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) + "\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) 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: async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details """Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene 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"],)) sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"]) offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"]) 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"] def _auftrag_zeile(a: dict) -> str:
for a in offene] 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: if not auftraege:
return "done" return "done"
atome = _atome_von(b["id"]) 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"]), befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]}) "detail": auftrag[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das # Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest- # Gate. Eskalierte dreiteilig: ungeklärt → klaerung_offen (reparierbar,
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB). # 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=?" for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)): " AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]), befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]}) "detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND" for a in db.query("SELECT art, detail, geklaert FROM auftraege WHERE"
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)): " baustein_id=? AND status='eskaliert'", (b["id"],)):
befunde.append({"art": "fakten_konflikt", "item": str(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]}) "detail": a["detail"][:300]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel: if ziel:
@@ -748,13 +822,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
def _qa_auftrag(b_id: int, claim: str) -> None: def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup """Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen gegen alles Nicht-Behobene; trifft er eine offene ODER kein_mangel-Zeile,
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong).""" 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=?" alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim)) " AND status != 'behoben'", (b_id, claim))
if alt is None: if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa") 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") db.update("auftraege", "id", alt["id"], status="eskaliert")
@@ -790,8 +866,10 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
erwartet=list) erwartet=list)
if verdacht is None: if verdacht is None:
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
claims = [str(c.get("claim", "")).strip() for c in verdacht # dedupen — ein doppelt gemeldeter Claim eskalierte sonst im selben
if isinstance(c, dict) and c.get("claim")] # 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: if not claims:
hash_setzen(b["id"], h) # sauber geprüft hash_setzen(b["id"], h) # sauber geprüft
return return
@@ -828,14 +906,16 @@ def _text_sig(b_id: int) -> str:
def _auftrag_stand(b_ids: list[int]) -> set: def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt, """Zustands-Snapshot der Aufträge dieser Bausteine. Status- und geklaert-
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)- Übergänge sind Fortschritt (auch ohne Textänderung); ein reines runden-
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab).""" 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: if not b_ids:
return set() return set()
marks = ",".join("?" * len(b_ids)) marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["runden"]) for r in db.query( return {(r["id"], r["status"], r["geklaert"]) for r in db.query(
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})", f"SELECT id, status, geklaert FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))} 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.""" Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {} betroffen: dict[int, list[dict]] = {}
klaerbar: set[int] = set()
for b in befunde: for b in befunde:
if b["art"] == "fakten_konflikt": if b["art"] in ("fakten_konflikt", "stil_rest"):
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen continue # terminal: geklärt-unauflösbar bzw. Stil-Rest — keine Route
try: try:
b_id = int(b["item"]) b_id = int(b["item"])
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
if b["art"] == "klaerung_offen":
klaerbar.add(b_id) # Stichentscheid statt Stage-Routing
continue
betroffen.setdefault(b_id, []).append(b) betroffen.setdefault(b_id, []).append(b)
if not betroffen: if not betroffen and not klaerbar:
return False 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] = [] geroutet: list[int] = []
for b_id, liste in betroffen.items(): for b_id, liste in betroffen.items():
arten = {x["art"] for x in liste} 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("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair") db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id) geroutet.append(b_id)
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet} for b_id in sorted(klaerbar):
stand_vorher = _auftrag_stand(geroutet) # 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) 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 for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != text_vorher[b_id]: if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (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})} {"verifiziert": 0, "gesamt": ziel_gesamt})}
for a in atome], for a in atome],
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)), "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( "bausteine": db.query(
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id" "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,)), " 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 bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele 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=?", db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
(topic,)) (topic,))
if stufe >= 2: # artefakte 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 bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele 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("DELETE FROM soll WHERE topic=?", (topic,))
db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0" db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0"
" WHERE topic=?", (topic,)) " WHERE topic=?", (topic,))

View File

@@ -29,12 +29,14 @@ GEWICHTE = {
"flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5, "flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5,
"paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5, "paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5,
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5, "partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
"level_mix": 3.0, "kapitel_level": 3.0, "thema_partition": 3.0, "baustein_thema_mix": 3.0, "themen_fallback": 1.5,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5, "kapitel_level": 3.0,
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 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, "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, "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, "beispiel_marker_tot": 3.0,
} }

View File

@@ -1,16 +1,19 @@
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt """Ebene 3: Struktur. Das Soll trägt die Gliederung: ein Judge partitioniert
(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der die Soll-Punkte in THEMEN (= Kapitel), Lernziele entstehen je Soll-Punkt,
Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick) Bausteine = Ziel-Gruppen im Größenband (Merge nur INNERHALB eines Themas —
mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame der frühere Quell-Nähe-Merge über Soll-Grenzen mischte 69/73 Bausteine
von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus.""" 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 import logging
from collections import defaultdict from collections import Counter, defaultdict
import db import db
import llm import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG, 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") log = logging.getLogger("creator2.struktur")
@@ -22,6 +25,68 @@ def _atome(topic: str) -> list[dict]:
" ('gemerged','verworfen') ORDER BY id", (topic,)) " ('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 ───────────────────────────────────────────────────────────────── # ── Lernziele ─────────────────────────────────────────────────────────────────
async def _ziele_bilden(ctx: llm.Kontext) -> None: async def _ziele_bilden(ctx: llm.Kontext) -> None:
@@ -100,12 +165,28 @@ def _baustein_rang(bausteine: list[dict], atome: list[dict],
return b_rang 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: async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden. """Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine Gruppen = LERNZIELE (ein Ziel = ein Baustein — kein Level-Split mehr).
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt Kleine Gruppen mergen NUR innerhalb desselben Themas (Quell-Nähe wählt
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der den Partner im Thema), große entlang der Quellreihenfolge splitten
Quellreihenfolge splitten ("Teil n").""" ("Teil n"). Baustein-Level = dominantes Atom-Level."""
topic = ctx.topic topic = ctx.topic
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen # Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
db.execute("DELETE FROM auftraege WHERE baustein_id IN" 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,)) db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic) atome = _atome(topic)
rang = _anker_rang(topic) rang = _anker_rang(topic)
thema = _thema_je_ziel(topic)
gruppen: dict = defaultdict(list) gruppen: dict = defaultdict(list)
for a in atome: 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: def gruppen_rang(g: list[dict]) -> tuple:
return min(rang[a["id"]] for a in g) 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 geaendert = True
while geaendert: while geaendert:
geaendert = False geaendert = False
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME], kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
key=lambda k: len(gruppen[k])) key=lambda k: len(gruppen[k]))
for k in kleine: for k in kleine:
# nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der # keine MAX-Schranke: der Split zerlegt große Gruppen bandkonform;
# Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine # sonst bliebe eine Kleingruppe ohne Partner als unreparierbarer
# kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen, # band-Befund liegen (Lektion 75)
# den messen dann als unreparierbaren band-Befund meldete. passende = [p for p in gruppen if p != k and thema.get(p) == thema.get(k)]
passende = [p for p in gruppen if p != k and p[1] == k[1]]
if not passende: if not passende:
continue continue
eigener = gruppen_rang(gruppen[k]) eigener = gruppen_rang(gruppen[k])
def naehe(pp: tuple) -> tuple: def naehe(pp) -> tuple:
r = gruppen_rang(gruppen[pp]) r = gruppen_rang(gruppen[pp])
return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])), return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
len(gruppen[pp])) len(gruppen[pp]))
p = min(passende, key=naehe) p = min(passende, key=naehe)
for a in gruppen[k]: 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) gruppen[p] += gruppen.pop(k)
# Ziel erst still legen, wenn KEIN Level mehr darauf zeigt db.update("lernziele", "id", k, status="gemerged")
if not any(kk[0] == k[0] for kk in gruppen):
db.update("lernziele", "id", k[0], status="gemerged")
geaendert = True geaendert = True
break break
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen # Bausteine anlegen (große Gruppen splitten), Atome zuordnen
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} 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]) 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, {}) z = ziele.get(ziel_id, {})
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME) n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
@@ -174,7 +254,9 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
continue continue
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})" 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, 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): for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos) db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
@@ -182,42 +264,53 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
async def _bausteine_ordnen(ctx: llm.Kontext) -> None: async def _bausteine_ordnen(ctx: llm.Kontext) -> None:
"""Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global """Didaktische Feinordnung je (Level, Thema)-Segment per Judge; ord läuft
fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität global fortlaufend über (Level-Rang, Thema-ord, Feinordnung) — eine Achse,
bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior Vorwärts-Logik und Kapitel-Kontiguität bleiben konstruktiv korrekt.
vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine Eingabe je Segment nach Quellpositions-Prior vorsortiert; der Code erzwingt
exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund eine exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
(ordnung_fallback, Muster wie kapitel_fallback).""" (ordnung_fallback). 1er-Segmente brauchen keinen Call."""
topic = ctx.topic topic = ctx.topic
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic) atome = _atome(topic)
b_rang = _baustein_rang(bausteine, atome, _anker_rang(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,))} 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 offset = 0
for level in LEVEL_RANG: for level in LEVEL_RANG:
folge = sorted((b for b in bausteine if b["level"] == level), 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"]]) key=lambda b: b_rang[b["id"]])
if not folge: if not folge:
continue 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']}" liste = "\n".join(f"{i + 1}: {b['titel']}"
f"{ziele.get(b['ziel_id'], {}).get('text', '')}" f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
for i, b in enumerate(folge)) for i, b in enumerate(folge))
soll = list(range(1, len(folge) + 1)) 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: async def ordnen(hinweis: str, tag: str) -> list[int] | None:
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung", res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
role="judge", n=len(folge), item=tag, erwartet=list, role="judge", n=len(folge), item=tag, erwartet=list,
werte={"bausteine": liste, "hinweis": hinweis, werte={"bausteine": liste, "hinweis": hinweis,
"thema": thema_titel or ctx.topic,
"durchgang": DURCHGANG[level]}) "durchgang": DURCHGANG[level]})
perm = [x for x in res or [] if isinstance(x, int)] perm = [x for x in res or [] if isinstance(x, int)]
return perm if sorted(perm) == soll else None return perm if sorted(perm) == soll else None
perm = await ordnen("", f"ordnung-{level}") perm = await ordnen("", tag_basis)
if perm is None: if perm is None:
perm = await ordnen( perm = await ordnen(
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU" "ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
f" den Nummern 1{len(folge)}, jede genau einmal.", f" den Nummern 1{len(folge)}, jede genau einmal.",
f"ordnung-{level}-2") f"{tag_basis}-2")
art = "judge" art = "judge"
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
art = "fallback" art = "fallback"
@@ -265,94 +358,37 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None:
await llm.alle(einer(z, g) for z, g in gruppen.items()) await llm.alle(einer(z, g) for z, g in gruppen.items())
async def _kapitel_bilden(ctx: llm.Kontext) -> None: def _kapitel_bilden(topic: str) -> None:
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE """Kapitel = (Level, Thema)-Segment der geordneten Baustein-Folge
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das deterministisch, KEIN Judge. Kontiguität ist durch die globale Sortierung
Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der (Level-Rang, Thema-ord, Feinordnung) konstruktiv garantiert; Titel = Thema-
Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er Titel. Der frühere Grenz-Judge samt Retry/√n-Fallback/Größen-Gate entfällt."""
unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen).
Der Code erzwingt Reihenfolge, Lückenlosigkeit UND Größe (≥2 Bausteine außer
im letzten, ≤2×Richtwert-Band — Lernen: 68/73 Singleton-Kapitel); ungültig
bekommt EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback)."""
topic = ctx.topic
alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
if not alle: if not alle:
return 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,)) db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
pos_global = 0 pos = 0
for level in LEVEL_RANG: letzter_key = None
bausteine = [b for b in alle if b["level"] == level] k_id = None
if not bausteine: for b in alle:
continue key = (b["level"], b["thema_id"])
# Positionsnummern statt DB-IDs: nach dem Level-Split sind die IDs if key != letzter_key:
# nicht mehr monoton entlang der Lesefolge — der Judge verlor die titel = themen.get(b["thema_id"], {}).get("titel") or b["titel"]
# Reihenfolge und lieferte 0 gültige Schnitte (aak Run 46: 15/15 Fallback) k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos,
liste = "\n".join(f"{i + 1}: {b['titel']}" art="det", level=b["level"])
f"{ziele.get(b['ziel_id'], {}).get('text', '')}" pos += 1
for i, b in enumerate(bausteine)) letzter_key = key
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE)) db.update("bausteine", "id", b["id"], kapitel_id=k_id)
max_groesse = 2 * KAPITEL_BAUSTEINE
folge = [b["id"] for b in bausteine]
pos_von = {i + 1: i for i in range(len(folge))}
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
schritt="kapitel", role="judge", n=len(bausteine),
item=tag, erwartet=list,
werte={"bausteine": liste, "richtwert": str(richtwert),
"durchgang": DURCHGANG[level], "hinweis": hinweis})
segmente: list[tuple[str, list[int]]] = []
start = 0
for gruppe in res or []:
if not isinstance(gruppe, dict):
return None
titel = str(gruppe.get("titel", "")).strip()
ende = pos_von.get(gruppe.get("bis"))
if not titel or ende is None or ende < start:
return None
segmente.append((titel, folge[start:ende + 1]))
start = ende + 1
if not segmente or start != len(folge):
return None
for i, (_, ids) in enumerate(segmente): # Größen-Gate
if len(ids) > max_groesse:
return None
if len(ids) < 2 and len(folge) > 1 and i < len(segmente) - 1:
return None
return segmente
segmente = await schneiden("", f"kapitel-{level}")
if segmente is None:
segmente = await schneiden(
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
" \"titel\" und \"bis\" (eine NUMMER aus der Liste, streng"
" aufsteigend); das letzte \"bis\" MUSS die letzte Nummer der Liste"
f" sein. Jedes Kapitel bündelt 2{max_groesse} Bausteine (Richtwert"
f" {richtwert} Kapitel); nur das letzte darf kleiner sein.",
f"kapitel-{level}-2")
art = "judge"
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
art = "fallback"
je_id = {b["id"]: b for b in bausteine}
n = max(1, round(len(folge) ** 0.5))
groesse = -(-len(folge) // n)
segmente = [(je_id[teil[0]]["titel"], teil)
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
for titel, ids in segmente:
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos_global,
art=art, level=level)
pos_global += 1
for b_id in ids:
db.update("bausteine", "id", b_id, kapitel_id=k_id)
async def bauen(ctx: llm.Kontext) -> None: async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE ctx.ebene = EBENE
await _themen_bilden(ctx) # Soll-Punkte → Themen (Kapitel-Gerüst)
await _ziele_bilden(ctx) 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 _bausteine_schneiden(ctx)
await _kapitel_bilden(ctx) _kapitel_bilden(ctx.topic)
def messen(ctx: llm.Kontext) -> list[dict]: def messen(ctx: llm.Kontext) -> list[dict]:
@@ -361,25 +397,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
for a in atome: for a in atome:
if not a["ziel_id"] or not a["baustein_id"]: if not a["ziel_id"] or not a["baustein_id"]:
befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]}) 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,)) bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,))
je_baustein: dict = defaultdict(list) je_baustein: dict = defaultdict(list)
for a in atome: for a in atome:
je_baustein[a["baustein_id"]].append(a) 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 # Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches
# Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und # THEMA, anderer Baustein) — spiegelgleich zur Merge-Schranke, damit die
# re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine # Messlatte erfüllbar bleibt (Lektion 75).
# einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75).
for b in bausteine: for b in bausteine:
n = len(je_baustein[b["id"]]) 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): if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
befunde.append({"art": "band", "item": str(b["id"]), befunde.append({"art": "band", "item": str(b["id"]),
"detail": f"{b['titel']}: {n} Atome"}) "detail": f"{b['titel']}: {n} Atome"})
for a in je_baustein[b["id"]]: # ein Baustein = genau ein Level # Themenreinheit: die Soll-Punkte aller Atome gehören zum Baustein-Thema.
if a["level"] != b["level"]: # Atome ohne soll_id überspringen — deren Befund gehört zur Inventar-Ebene.
befunde.append({"art": "level_mix", "item": str(a["id"]), for a in je_baustein[b["id"]]:
"detail": f"Atom {a['level']} in Baustein {b['level']}" if a["soll_id"] and soll_thema.get(a["soll_id"]) != b["thema_id"]:
f" ({b['titel']})"}) 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,)) kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
kap_ids = {k["id"] for k in kaps} kap_ids = {k["id"] for k in kaps}
kap_level = {k["id"]: k["level"] 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" "detail": f"{b['titel']} ({b['level']}) in Kapitel"
f" {kap_level.get(b['kapitel_id'])}"}) f" {kap_level.get(b['kapitel_id'])}"})
belegt = {b["kapitel_id"] for b in bausteine} 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: for k in kaps:
if k["id"] not in belegt: if k["id"] not in belegt:
befunde.append({"art": "kapitel_leer", "item": str(k["id"]), befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
"detail": k["titel"]}) "detail": k["titel"]})
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust # stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (je Segment)
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]), segmente = sorted({(b["level"], b["thema_id"]) for b in bausteine
"detail": k["titel"]}) if b["ordnung"] == "fallback"},
# Singleton-Kapitel zerstückeln die Navigation (Lernen: 68/73) — erlaubt key=lambda s: (LEVEL_RANG.get(s[0], 9), s[1] or 0))
# nur, wenn der ganze Durchgang bloß einen Baustein hat. for level, thema_id in segmente:
if je_kapitel[k["id"]] == 1 and je_level[k["level"]] > 1: befunde.append({"art": "ordnung_fallback", "item": f"{level}/t{thema_id or 0}",
letzte = max((kk["ord"] for kk in kaps if kk["level"] == k["level"])) "detail": f"Segment {level}/{thema_id}: Judge lieferte keine"
if k["ord"] != letzte: f" gültige Permutation — Quellreihenfolge übernommen"})
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
"detail": k["titel"]})
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback)
for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}):
befunde.append({"art": "ordnung_fallback", "item": lv,
"detail": f"Durchgang {lv}: Judge lieferte keine gültige"
f" Permutation — Quellreihenfolge übernommen"})
return befunde return befunde
@@ -423,8 +463,5 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE ctx.ebene = EBENE
if not befunde: if not befunde:
return False return False
await _ziele_bilden(ctx) # fängt Partition-Lücken await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt
await _level_kalibrieren(ctx)
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
return True return True

View File

@@ -26,6 +26,7 @@ def export(topic: str) -> dict:
"topics": q("SELECT * FROM topics WHERE name=?", (topic,)), "topics": q("SELECT * FROM topics WHERE name=?", (topic,)),
"quellen": q("SELECT * FROM quellen WHERE topic=?", (topic,)), "quellen": q("SELECT * FROM quellen WHERE topic=?", (topic,)),
"soll": q("SELECT * FROM soll 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,)), "lernziele": q("SELECT * FROM lernziele WHERE topic=?", (topic,)),
"kapitel": q("SELECT * FROM kapitel WHERE topic=?", (topic,)), "kapitel": q("SELECT * FROM kapitel WHERE topic=?", (topic,)),
"bausteine": q("SELECT * FROM bausteine 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 "[]") z["geprueft"] = db.j([q_map[q] for q in db.uj(z.get("geprueft") or "[]")
if q in q_map]) if q in q_map])
soll_zeilen.append(z) 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) z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
k_map = _einfuegen("kapitel", d["kapitel"]) 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 # merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus
# Alt-Exporten verwerfen (Spalte existiert nicht mehr) # Alt-Exporten verwerfen (Spalte existiert nicht mehr)
a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"}, 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 (einzige erlaubte Faktenbasis — nichts erfinden):
{belege} {belege}
Regel: Mathe IMMER in $…$ (KaTeX), nie als roher Klartext.
Antworte NUR mit JSON (keine Code-Fences). Für flashcard: Antworte NUR mit JSON (keine Code-Fences). Für flashcard:
{{"frage": "…", "antwort": "…"}} {{"frage": "…", "antwort": "…"}}
Für beispiel: BEHALTE die Form aus dem Artefakt oben und gib die passenden Felder zurück: 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. - Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen.
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen, - Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
keine Rezitation exakter Formulierungen verlangen). 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 - SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
„Beleg", „Quelle", „Musterlösung", „Skript", „Aufgabe 3" sind verboten. Die Frage „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, 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. funktionieren und Wissen abfragen, nicht Textinhalte.
- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte - ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte
Fachtermini sind okay; ganze englische Sätze nicht). 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. - Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz. - Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
- Antworte für JEDES Artefakt (id wörtlich übernehmen). - Antworte für JEDES Artefakt (id wörtlich übernehmen).

View File

@@ -1,5 +1,6 @@
<!-- template:Baustein-Ordnung --> <!-- template:Baustein-Ordnung -->
Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest. Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
Thema: {thema}
Durchgang: {durchgang} Durchgang: {durchgang}
BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials): 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 - Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der
Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…". Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…".
- Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis. - 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). Antworte in GENAU ZWEI Blöcken mit diesen Trennern (KEIN JSON, keine Code-Fences).
LaTeX-Backslashes bleiben roh (kein Escaping): 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"}]) {"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}])
assert bewegt is True # geurteilt (behoben) trotz Freeze assert bewegt is True # geurteilt (behoben) trotz Freeze
assert db.one("SELECT COUNT(*) n FROM auftraege WHERE status='behoben'")["n"] == 1 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(): async def test_auftrag_offen_in_messen():
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr; """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 db
import guide import guide
import llm 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("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="luecke", detail="Atom-Inhalt fehlt")
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch", 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", 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 = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide" ctx.ebene = "guide"
arten = [b["art"] for b in guide.messen(ctx)] arten = [b["art"] for b in guide.messen(ctx)]
assert arten.count("auftrag_offen") == 1 assert arten.count("auftrag_offen") == 1
assert arten.count("klaerung_offen") == 1
assert arten.count("fakten_konflikt") == 1 assert arten.count("fakten_konflikt") == 1
assert arten.count("stil_rest") == 1
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch): 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] folge = [b["kapitel_id"] for b in bausteine]
segmente = [k for k, _ in itertools.groupby(folge)] segmente = [k for k, _ in itertools.groupby(folge)]
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome # Level je Baustein = dominantes Atom-Level; Themenreinheit: die Soll-Punkte
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord # aller Atome gehören zum Baustein-Thema; Kapitel-Level monoton E→M→S
rang = {"E": 0, "M": 1, "S": 2} rang = {"E": 0, "M": 1, "S": 2}
kap_level = {k["id"]: k["level"] for k in kaps} 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: for b in bausteine:
assert kap_level[b["kapitel_id"]] == b["level"] assert kap_level[b["kapitel_id"]] == b["level"]
for a in atome: eigene = [a for a in atome if a["baustein_id"] == b["id"]]
if a["baustein_id"] == b["id"]: zaehler = Counter(a["level"] for a in eigene)
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"]) 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] level_folge = [rang[b["level"]] for b in bausteine]
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet" 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,)): 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") topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic) run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id) _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,)) 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): 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 bausteine WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM kapitel 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 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" 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) # 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"], {}) 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) _pruefe_endzustand(topic, run1)
pipeline.topic_loeschen(topic) pipeline.topic_loeschen(topic)
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine", for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
"kapitel", "runs"): "kapitel", "themen", "runs"):
feld = "name" if tabelle == "topics" else "topic" feld = "name" if tabelle == "topics" else "topic"
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle 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) 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): def _baustein_groessen(topic):
from collections import Counter from collections import Counter
c = Counter(a["baustein_id"] for a in struktur._atome(topic)) 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")] assert not [x for x in befunde if x["art"] in ("band", "partition")]
async def test_merge_ohne_soll_schranke_und_kapitel(): async def test_merge_im_thema_ueber_soll_grenzen():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per # kleines Ziel merged über Soll-Punkt-Grenzen, solange BEIDE Punkte im
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente # selben Thema liegen; Kapitel = (Level, Thema) deterministisch
topic = topic_anlegen("kapitel") topic = topic_anlegen("kapitel")
run = run_anlegen(topic) run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([])) 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([])) 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") 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") 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 _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) await struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic) atome = struktur._atome(topic)
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll 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,)) bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert bausteine and all(x["kapitel_id"] for x in bausteine) 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) == [] 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 assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
async def test_kapitel_retry_und_fallback(monkeypatch): def test_kapitel_deterministisch(monkeypatch):
topic = topic_anlegen("kapfall") """Kapitel = (Level, Thema)-Segment ohne LLM: Titel = Thema-Titel, art='det',
run = run_anlegen(topic) 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") 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, ordn = 0
status="neu") for i in range(4)] for level in ("E", "M"):
ctx = llm.Kontext(run, topic, "minimax") for thema in (t1, t2):
ctx.ebene = "struktur" db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{ordn}",
ord=ordn, status="neu", level=level, thema_id=thema)
aufrufe = {"n": 0} ordn += 1
def judge(prompt): def explodiert(prompt): # beweist: kein LLM-Call nötig
aufrufe["n"] += 1 raise AssertionError("Kapitel-Bildung darf keinen Judge rufen")
if aufrufe["n"] == 1: monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze struktur._kapitel_bilden(topic)
return [{"titel": "Gutes Kapitel", "bis": b[-1]}] kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge) assert [k["titel"] for k in kaps] == ["Thema Eins", "Thema Zwei"] * 2
await struktur._kapitel_bilden(ctx) assert [k["level"] for k in kaps] == ["E", "E", "M", "M"]
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) assert all(k["art"] == "det" for k in kaps)
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge" zu_kapitel = {b["ord"]: b["kapitel_id"] for b in
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))}
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", assert len(set(zu_kapitel.values())) == 4 # je (Level, Thema) ein Kapitel
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)}
async def test_level_kalibrierung(monkeypatch): 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" assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
async def test_level_split_ordnung_kapitel(): async def test_dominantes_level():
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord, # Ein Ziel mit 4×E + 4×M → EIN Baustein (kein Level-Split mehr);
# Kapitel je Durchgang, kein level_mix/kapitel_level # Level = Mehrheit, Gleichstand → niedrigeres (E). 3×E + 5×M → M.
topic = topic_anlegen("split") topic = topic_anlegen("dominant")
run = run_anlegen(topic) run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) 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") ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(4): for i in range(4):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d", 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" ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx) await struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert [b["level"] for b in bausteine] == ["E", "M"] assert [b["level"] for b in bausteine] == ["E"] # Tie 4/4 → niedrigeres
await struktur._kapitel_bilden(ctx) struktur._kapitel_bilden(topic)
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert [k["level"] for k in kaps] == ["E", "M"]
arten = {b["art"] for b in struktur.messen(ctx)} 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(): async def test_kein_merge_ueber_thema():
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen # kleine Ziele in VERSCHIEDENEN Themen mergen NICHT — zwei Bausteine,
topic = topic_anlegen("levelmerge") # und kein band-Befund (kein Partner im eigenen Thema)
topic = topic_anlegen("themamerge")
run = run_anlegen(topic) run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv") s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv") _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): for i in range(2):
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d", _atom(topic, f"A{i}", z1, s1)
level="E", status="neu", soll_id=soll, ziel_id=z1) _atom(topic, f"B{i}", z2, s2)
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
level="M", status="neu", soll_id=soll, ziel_id=z2)
ctx = llm.Kontext(run, topic, "minimax") ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._bausteine_schneiden(ctx) await struktur._bausteine_schneiden(ctx)
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)): bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
levels = {a["level"] for a in struktur._atome(topic) assert len(bausteine) == 2 and {b["ziel_id"] for b in bausteine} == {z1, z2}
if a["baustein_id"] == b["id"]} struktur._kapitel_bilden(topic)
assert levels == {b["level"]} 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) pipeline.lauf_starten(topic)
await pipeline._laeufe[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", "lernziele", "bausteine", "kapitel", "sections", "auftraege",
"runs", "befunde", "events", "leitner") "runs", "befunde", "events", "leitner")
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen} 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,)): " ('gemerged','verworfen')", (topic,)):
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],)) 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"],)) 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'", for s in db.query("SELECT belege FROM soll WHERE topic=? AND status='bestaetigt'",
(topic,)): (topic,)):
for b in db.uj(s["belege"]): for b in db.uj(s["belege"]):