update
This commit is contained in:
@@ -116,7 +116,8 @@ DURCHGANG = { # Leser-Kontext je Durchgang (Templates: {durchgang})
|
||||
BAUSTEIN_MIN_ATOME = 4
|
||||
BAUSTEIN_MAX_ATOME = 8
|
||||
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
|
||||
THEMA_SOLL_PUNKTE = 8 # Richtwert Soll-Punkte je Thema (Themen-Schnitt-Judge);
|
||||
# zu klein → Singleton-Kapitel, zu groß → Mammut-Kapitel
|
||||
|
||||
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
|
||||
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
||||
@@ -125,6 +126,8 @@ SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
||||
LOOP_MAX_ITER = 10
|
||||
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung
|
||||
AUFTRAG_RUNDEN_MAX = 4 # Auftrag nach so vielen Urteilsrunden ohne Abschluss → eskaliert
|
||||
KLAERUNG_PANEL = 3 # Stichentscheid über eskalierte falsch/luecke: Mehrheit 2/3
|
||||
# (Lektion 19); läuft je Auftrag genau einmal (auftraege.geklaert)
|
||||
|
||||
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
|
||||
TIMEOUTS = {
|
||||
@@ -142,8 +145,10 @@ TIMEOUTS = {
|
||||
"fix": (300, 15),
|
||||
"ziele": (300, 6),
|
||||
"level": (200, 4),
|
||||
"themen": (300, 2),
|
||||
"kapitel": (200, 2),
|
||||
"ordnung": (300, 2),
|
||||
"klaerung": (300, 5),
|
||||
"writer": (450, 60),
|
||||
"pruefer": (600, 5),
|
||||
"pruefer_urteil": (300, 5),
|
||||
|
||||
@@ -67,7 +67,11 @@ CREATE TABLE IF NOT EXISTS quellen(
|
||||
CREATE TABLE IF NOT EXISTS soll(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
|
||||
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]');
|
||||
intro TEXT NOT NULL DEFAULT '', geprueft TEXT NOT NULL DEFAULT '[]',
|
||||
thema_id INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS themen(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||
ord INTEGER DEFAULT 0, art TEXT NOT NULL DEFAULT 'judge');
|
||||
CREATE TABLE IF NOT EXISTS atome(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
||||
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
|
||||
@@ -93,7 +97,8 @@ CREATE TABLE IF NOT EXISTS lernziele(
|
||||
CREATE TABLE IF NOT EXISTS bausteine(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
||||
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER,
|
||||
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge');
|
||||
level TEXT NOT NULL DEFAULT 'M', ordnung TEXT NOT NULL DEFAULT 'judge',
|
||||
thema_id INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS kapitel(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
||||
@@ -119,13 +124,14 @@ CREATE TABLE IF NOT EXISTS auftraege(
|
||||
detail TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'pruefer',
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK(status IN ('offen','behoben','kein_mangel','eskaliert')),
|
||||
runden INTEGER NOT NULL DEFAULT 0);
|
||||
runden INTEGER NOT NULL DEFAULT 0,
|
||||
geklaert INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE INDEX IF NOT EXISTS idx_auftraege ON auftraege(baustein_id, status);
|
||||
"""
|
||||
|
||||
# Tabellen, deren Änderungen das Live-Board interessieren.
|
||||
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
|
||||
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
|
||||
"lernziele", "bausteine", "kapitel", "sections", "befunde", "themen"}
|
||||
|
||||
|
||||
def _init_schema(con: sqlite3.Connection) -> None:
|
||||
@@ -145,6 +151,11 @@ def _init_schema(con: sqlite3.Connection) -> None:
|
||||
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE sections ADD COLUMN fix_versuche INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE bausteine ADD COLUMN ordnung TEXT NOT NULL DEFAULT 'judge'",
|
||||
# Themen-Schicht: Soll-Punkte tragen die Struktur (Refactoring Stufe 1)
|
||||
"ALTER TABLE soll ADD COLUMN thema_id INTEGER",
|
||||
"ALTER TABLE bausteine ADD COLUMN thema_id INTEGER",
|
||||
# Klärungs-Stichentscheid: eskalierte Aufträge einmalig klären
|
||||
"ALTER TABLE auftraege ADD COLUMN geklaert INTEGER NOT NULL DEFAULT 0",
|
||||
# braucht-Maschinerie entfernt: Ordnung kommt vom Judge,
|
||||
# nicht mehr vom (zu dünnen) Abhängigkeits-Graph
|
||||
"ALTER TABLE atome DROP COLUMN braucht"):
|
||||
|
||||
@@ -208,12 +208,15 @@ def _titel_fix(prompt: str):
|
||||
return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen]
|
||||
|
||||
|
||||
def _kapitel_schnitt(prompt: str):
|
||||
ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"),
|
||||
def _themen_schnitt(prompt: str):
|
||||
# konsekutive 2er-Gruppen; Rest hängt an der letzten Gruppe (nie Singleton,
|
||||
# damit das Größen-Gate im Code deterministisch passiert)
|
||||
nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "SOLL-PUNKTE:"),
|
||||
re.MULTILINE)]
|
||||
grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)]
|
||||
return [{"titel": f"Themenblock {n + 1}", "bis": g}
|
||||
for n, g in enumerate(dict.fromkeys(grenzen))]
|
||||
gruppen = [nummern[i:i + 2] for i in range(0, len(nummern), 2)]
|
||||
if len(gruppen) > 1 and len(gruppen[-1]) == 1:
|
||||
gruppen[-2] += gruppen.pop()
|
||||
return [{"titel": f"Fake-Thema {n + 1}", "punkte": g} for n, g in enumerate(gruppen)]
|
||||
|
||||
|
||||
def _baustein_ordnung(prompt: str):
|
||||
@@ -251,6 +254,14 @@ def _pruefer_urteil(prompt: str):
|
||||
return [{"auftrag": i, "urteil": "behoben"} for i in nummern]
|
||||
|
||||
|
||||
def _klaerung(prompt: str):
|
||||
# Default „unbegruendet": schließt Konflikte, Fake-E2E terminiert bei Note 10
|
||||
nummern = [int(i) for i in re.findall(r"^(\d+): ",
|
||||
_text_nach(prompt, "ESKALIERTE AUFTRÄGE:"),
|
||||
re.MULTILINE)]
|
||||
return [{"auftrag": i, "urteil": "unbegruendet"} for i in nummern]
|
||||
|
||||
|
||||
def _fix(prompt: str):
|
||||
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
|
||||
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
|
||||
@@ -279,9 +290,10 @@ _HANDLER = {
|
||||
"Artefakt-Fix": _artefakt_fix,
|
||||
"Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify,
|
||||
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||
"Themen-Schnitt": _themen_schnitt, "Level-Kalibrierung": _level_kalibrierung,
|
||||
"Baustein-Ordnung": _baustein_ordnung,
|
||||
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer,
|
||||
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix,
|
||||
"Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Konflikt-Klaerung": _klaerung,
|
||||
"Guide-Fix": _fix,
|
||||
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
||||
}
|
||||
|
||||
156
backend/guide.py
156
backend/guide.py
@@ -19,8 +19,8 @@ import jsonx
|
||||
import llm
|
||||
import belege
|
||||
import textkit
|
||||
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG,
|
||||
SECTION_WOERTER_PRO_ATOM)
|
||||
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, KLAERUNG_PANEL,
|
||||
LEVEL_RANG, SECTION_WOERTER_PRO_ATOM)
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
@@ -186,13 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
|
||||
|
||||
def _anknuepf_kontext(b: dict) -> str:
|
||||
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
|
||||
desselben Ziels. Der Writer knüpft an, statt neu zu erklären
|
||||
(Dopplungs-Schutz zwischen den Durchgängen)."""
|
||||
desselben Ziels — aber NIE Atome dieses Bausteins selbst (seit Level =
|
||||
dominantes Atom-Level enthält ein M-Baustein auch eigene E-Atome; die
|
||||
stünden sonst doppelt im Writer-Prompt: als bekannt UND als zu schreiben)."""
|
||||
rang = LEVEL_RANG.get(b["level"], 1)
|
||||
kontext: dict[int, dict] = {}
|
||||
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
|
||||
" ('gemerged','verworfen')", (b["ziel_id"],)):
|
||||
if LEVEL_RANG.get(a["level"], 1) < rang:
|
||||
if a["baustein_id"] != b["id"] and LEVEL_RANG.get(a["level"], 1) < rang:
|
||||
kontext[a["id"]] = a
|
||||
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
|
||||
|
||||
@@ -454,6 +455,14 @@ def _offene_auftraege(b_id: int) -> list[dict]:
|
||||
" ORDER BY id", (b_id,))
|
||||
|
||||
|
||||
def _klaerung_kandidaten(b_id: int) -> list[dict]:
|
||||
"""Eskalierte falsch/luecke, die noch nie geklärt wurden. eskaliert+geklaert=1
|
||||
ist der terminale Endzustand (unauflösbar) — Klärung läuft je Auftrag genau
|
||||
einmal, auch nach Re-Eskalation (strukturell, kein Sondercode)."""
|
||||
return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='eskaliert'"
|
||||
" AND art IN ('falsch','luecke') AND geklaert=0 ORDER BY id", (b_id,))
|
||||
|
||||
|
||||
def _fakten_von(atome: list[dict]) -> str:
|
||||
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
||||
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
|
||||
@@ -499,6 +508,54 @@ async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict,
|
||||
db.update("auftraege", "id", a["id"], runden=a["runden"] + 1)
|
||||
|
||||
|
||||
async def _klaeren(ctx: llm.Kontext, b: dict) -> None:
|
||||
"""Stichentscheid (Lektion 19) über eskalierte falsch/luecke: EIN 3er-Panel
|
||||
je Baustein, Mehrheit entscheidet. text_falsch → Auftrag wieder offen (der
|
||||
Fix bekommt ein Streich-Mandat); unbegruendet → kein_mangel; quelle_unklar
|
||||
oder Voll-Patt → terminal unauflösbar. Unter 3 gültigen Stimmen KEINE
|
||||
Entscheidung (fail-closed, Lektion 20) — nächste Runde erneut."""
|
||||
kand = _klaerung_kandidaten(b["id"])
|
||||
if not kand:
|
||||
return
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
if not sec or not sec["text_lang"]:
|
||||
return
|
||||
atome = _atome_von(b["id"])
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||
liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(kand, 1))
|
||||
stimmen = await llm.panel(ctx, KLAERUNG_PANEL, stage="klaerung",
|
||||
template="Guide-Konflikt-Klaerung", schritt="klaerung",
|
||||
role="judge", n=len(kand), item=f"b{b['id']}-klaerung",
|
||||
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
|
||||
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
|
||||
"auftraege": liste},
|
||||
erwartet=list)
|
||||
je: dict[int, list[str]] = {}
|
||||
for s in stimmen:
|
||||
for e in s:
|
||||
if isinstance(e, dict):
|
||||
try:
|
||||
urteil = str(e.get("urteil", "")).strip()
|
||||
if urteil in ("text_falsch", "unbegruendet", "quelle_unklar"):
|
||||
je.setdefault(int(e.get("auftrag")), []).append(urteil)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for i, a in enumerate(kand, 1):
|
||||
urteile = je.get(i, [])
|
||||
if len(urteile) < KLAERUNG_PANEL:
|
||||
continue # Rumpf-Panel → keine Entscheidung, bleibt geklaert=0
|
||||
mehrheit = max(set(urteile), key=urteile.count)
|
||||
if urteile.count(mehrheit) * 2 <= len(urteile):
|
||||
mehrheit = "quelle_unklar" # Voll-Patt (1/1/1) → terminal
|
||||
# EIN atomares Update je Auftrag → crash-/resume-sicher
|
||||
if mehrheit == "text_falsch":
|
||||
db.update("auftraege", "id", a["id"], status="offen", runden=0, geklaert=1)
|
||||
elif mehrheit == "unbegruendet":
|
||||
db.update("auftraege", "id", a["id"], status="kein_mangel", geklaert=1)
|
||||
else:
|
||||
db.update("auftraege", "id", a["id"], geklaert=1) # bleibt eskaliert
|
||||
|
||||
|
||||
async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
|
||||
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
|
||||
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene
|
||||
@@ -545,9 +602,19 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
offene = _offene_auftraege(b["id"])
|
||||
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
|
||||
auftraege = det + [f"KRITISCH ({a['art']}): {a['detail']}"
|
||||
if a["art"] in ("falsch", "luecke") else a["detail"]
|
||||
for a in offene]
|
||||
|
||||
def _auftrag_zeile(a: dict) -> str:
|
||||
zeile = (f"KRITISCH ({a['art']}): {a['detail']}"
|
||||
if a["art"] in ("falsch", "luecke") else a["detail"])
|
||||
# Klärungs-Vermerk generiert, NICHT im detail persistiert — der eingefrorene
|
||||
# Wortlaut ist die Dedup-Invariante der auftraege-Tabelle
|
||||
if a["geklaert"] and a["art"] in ("falsch", "luecke"):
|
||||
zeile += (" [KLÄRUNG: 3er-Panel bestätigt — der Text widerspricht hier der"
|
||||
" Faktenbasis. Anhand der Faktenbasis korrigieren; ist die Passage"
|
||||
" dort nicht belegbar, STREICHEN oder aufs Belegbare abschwächen.]")
|
||||
return zeile
|
||||
|
||||
auftraege = det + [_auftrag_zeile(a) for a in offene]
|
||||
if not auftraege:
|
||||
return "done"
|
||||
atome = _atome_von(b["id"])
|
||||
@@ -718,15 +785,22 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde.append({"art": "det_check", "item": str(b["id"]),
|
||||
"detail": auftrag[:300]})
|
||||
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
|
||||
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
|
||||
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
|
||||
# Gate. Eskalierte dreiteilig: ungeklärt → klaerung_offen (reparierbar,
|
||||
# Stichentscheid), geklärt → fakten_konflikt (terminal, kleine ehrliche
|
||||
# Rest-Schuld), Stil → stil_rest (terminal, war vorher komplett stumm).
|
||||
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
|
||||
" AND status='offen'", (b["id"],)):
|
||||
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
|
||||
"detail": f"({a['art']}) {a['detail']}"[:300]})
|
||||
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND"
|
||||
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)):
|
||||
befunde.append({"art": "fakten_konflikt", "item": str(b["id"]),
|
||||
for a in db.query("SELECT art, detail, geklaert FROM auftraege WHERE"
|
||||
" baustein_id=? AND status='eskaliert'", (b["id"],)):
|
||||
if a["art"] == "stil":
|
||||
art = "stil_rest"
|
||||
elif a["geklaert"]:
|
||||
art = "fakten_konflikt"
|
||||
else:
|
||||
art = "klaerung_offen"
|
||||
befunde.append({"art": art, "item": str(b["id"]),
|
||||
"detail": a["detail"][:300]})
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
|
||||
if ziel:
|
||||
@@ -748,13 +822,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
|
||||
def _qa_auftrag(b_id: int, claim: str) -> None:
|
||||
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
|
||||
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen
|
||||
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong)."""
|
||||
gegen alles Nicht-Behobene; trifft er eine offene ODER kein_mangel-Zeile,
|
||||
widersprechen sich zwei Panels → eskaliert = ab in den Klärungs-Stichentscheid.
|
||||
geklaert wird NIE zurückgesetzt: war der Auftrag schon geklärt, ist das
|
||||
terminal (deckelt QA↔Klärung-Ping-Pong hart auf einen Durchlauf)."""
|
||||
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
|
||||
" AND status != 'behoben'", (b_id, claim))
|
||||
if alt is None:
|
||||
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
|
||||
elif alt["status"] == "kein_mangel":
|
||||
elif alt["status"] in ("offen", "kein_mangel"):
|
||||
db.update("auftraege", "id", alt["id"], status="eskaliert")
|
||||
|
||||
|
||||
@@ -790,8 +866,10 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
|
||||
erwartet=list)
|
||||
if verdacht is None:
|
||||
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
|
||||
claims = [str(c.get("claim", "")).strip() for c in verdacht
|
||||
if isinstance(c, dict) and c.get("claim")]
|
||||
# dedupen — ein doppelt gemeldeter Claim eskalierte sonst im selben
|
||||
# Durchlauf seine eigene frisch eingefügte Zeile
|
||||
claims = list(dict.fromkeys(str(c.get("claim", "")).strip() for c in verdacht
|
||||
if isinstance(c, dict) and c.get("claim")))
|
||||
if not claims:
|
||||
hash_setzen(b["id"], h) # sauber geprüft
|
||||
return
|
||||
@@ -828,14 +906,16 @@ def _text_sig(b_id: int) -> str:
|
||||
|
||||
|
||||
def _auftrag_stand(b_ids: list[int]) -> set:
|
||||
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt,
|
||||
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)-
|
||||
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab)."""
|
||||
"""Zustands-Snapshot der Aufträge dieser Bausteine. Status- und geklaert-
|
||||
Übergänge sind Fortschritt (auch ohne Textänderung); ein reines runden-
|
||||
Inkrement zählt NICHT — identisches Panel-Patt auf identischem Text ist
|
||||
per Definition Leer-Churn und hielt auto_loop sonst bis LOOP_MAX_ITER am
|
||||
Leben. Echte Urteilsrunden schließen/eskalieren etwas → Statuswechsel."""
|
||||
if not b_ids:
|
||||
return set()
|
||||
marks = ",".join("?" * len(b_ids))
|
||||
return {(r["id"], r["status"], r["runden"]) for r in db.query(
|
||||
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})",
|
||||
return {(r["id"], r["status"], r["geklaert"]) for r in db.query(
|
||||
f"SELECT id, status, geklaert FROM auftraege WHERE baustein_id IN ({marks})",
|
||||
tuple(b_ids))}
|
||||
|
||||
|
||||
@@ -846,16 +926,29 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
|
||||
ctx.ebene = EBENE
|
||||
betroffen: dict[int, list[dict]] = {}
|
||||
klaerbar: set[int] = set()
|
||||
for b in befunde:
|
||||
if b["art"] == "fakten_konflikt":
|
||||
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
|
||||
if b["art"] in ("fakten_konflikt", "stil_rest"):
|
||||
continue # terminal: geklärt-unauflösbar bzw. Stil-Rest — keine Route
|
||||
try:
|
||||
b_id = int(b["item"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if b["art"] == "klaerung_offen":
|
||||
klaerbar.add(b_id) # Stichentscheid statt Stage-Routing
|
||||
continue
|
||||
betroffen.setdefault(b_id, []).append(b)
|
||||
if not betroffen:
|
||||
if not betroffen and not klaerbar:
|
||||
return False
|
||||
alle_ids = list(betroffen.keys() | klaerbar)
|
||||
text_vorher = {b_id: _text_sig(b_id) for b_id in alle_ids}
|
||||
stand_vorher = _auftrag_stand(alle_ids)
|
||||
# Klärung VOR dem Routing: text_falsch öffnet Aufträge wieder → die routen
|
||||
# unten in den Fix; unbegruendet/quelle_unklar brauchen keine Stage.
|
||||
if klaerbar:
|
||||
je_baustein = {bb["id"]: bb for bb in _bausteine(ctx.topic)}
|
||||
await llm.alle(_klaeren(ctx, je_baustein[b_id])
|
||||
for b_id in sorted(klaerbar) if b_id in je_baustein)
|
||||
geroutet: list[int] = []
|
||||
for b_id, liste in betroffen.items():
|
||||
arten = {x["art"] for x in liste}
|
||||
@@ -874,10 +967,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
db.update("sections", "baustein_id", b_id, stage="fix")
|
||||
db.update("bausteine", "id", b_id, status="repair")
|
||||
geroutet.append(b_id)
|
||||
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
|
||||
stand_vorher = _auftrag_stand(geroutet)
|
||||
for b_id in sorted(klaerbar):
|
||||
# wiedereröffnete Aufträge → direkt in den Fix (der Re-Check urteilt danach;
|
||||
# umgeht den Freeze bewusst — das Streich-Mandat erzeugt eine Textänderung)
|
||||
if b_id not in geroutet and _offene_auftraege(b_id):
|
||||
db.update("sections", "baustein_id", b_id, stage="fix")
|
||||
db.update("bausteine", "id", b_id, status="repair")
|
||||
geroutet.append(b_id)
|
||||
await bauen(ctx)
|
||||
bewegt = _auftrag_stand(geroutet) != stand_vorher
|
||||
bewegt = _auftrag_stand(alle_ids) != stand_vorher
|
||||
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
|
||||
if _text_sig(b_id) != text_vorher[b_id]:
|
||||
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
|
||||
|
||||
@@ -182,6 +182,7 @@ def state(topic: str):
|
||||
{"verifiziert": 0, "gesamt": ziel_gesamt})}
|
||||
for a in atome],
|
||||
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
|
||||
"themen": db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,)),
|
||||
"bausteine": db.query(
|
||||
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
|
||||
" WHERE b.topic=? ORDER BY b.ord", (topic,)),
|
||||
|
||||
@@ -126,6 +126,9 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
|
||||
# Themen-Schicht fällt mit — das Soll selbst bleibt eingefroren
|
||||
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE soll SET thema_id=NULL WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
|
||||
(topic,))
|
||||
if stufe >= 2: # artefakte
|
||||
@@ -200,6 +203,7 @@ def soll_reset(topic: str) -> None:
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0"
|
||||
" WHERE topic=?", (topic,))
|
||||
|
||||
@@ -29,12 +29,14 @@ GEWICHTE = {
|
||||
"flashcard_unvollstaendig": 1.5, "atom_ohne_beispiel": 1.5,
|
||||
"paar_unvollstaendig": 1.5, "artefakt_aufgegeben": 0.5,
|
||||
"partition": 3.0, "band": 0.5, "ordnung_fallback": 1.5,
|
||||
"level_mix": 3.0, "kapitel_level": 3.0,
|
||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
||||
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||
"thema_partition": 3.0, "baustein_thema_mix": 3.0, "themen_fallback": 1.5,
|
||||
"kapitel_level": 3.0,
|
||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5,
|
||||
"titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
|
||||
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
|
||||
"det_check": 0.5, "auftrag_offen": 1.5, "fakten_konflikt": 1.5,
|
||||
"det_check": 0.5, "auftrag_offen": 1.5, "klaerung_offen": 1.5,
|
||||
"fakten_konflikt": 0.5, "stil_rest": 0.5,
|
||||
"beispiel_marker_tot": 3.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
|
||||
(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der
|
||||
Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick)
|
||||
mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame
|
||||
von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus."""
|
||||
"""Ebene 3: Struktur. Das Soll trägt die Gliederung: ein Judge partitioniert
|
||||
die Soll-Punkte in THEMEN (= Kapitel), Lernziele entstehen je Soll-Punkt,
|
||||
Bausteine = Ziel-Gruppen im Größenband (Merge nur INNERHALB eines Themas —
|
||||
der frühere Quell-Nähe-Merge über Soll-Grenzen mischte 69/73 Bausteine
|
||||
thematisch). Ein Ziel = EIN Baustein; sein Level ist das dominante Atom-Level
|
||||
(der frühere Level-Split zerriss Ziele über bis zu 3 Durchgänge). Reihenfolge =
|
||||
Judge je (Level, Thema)-Segment mit Quellpositions-Prior; Kapitel = (Level,
|
||||
Thema) deterministisch."""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
import db
|
||||
import llm
|
||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG,
|
||||
KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME)
|
||||
LEVEL_RANG, THEMA_SOLL_PUNKTE, ZIELE_CHUNK_ATOME)
|
||||
|
||||
log = logging.getLogger("creator2.struktur")
|
||||
|
||||
@@ -22,6 +25,68 @@ def _atome(topic: str) -> list[dict]:
|
||||
" ('gemerged','verworfen') ORDER BY id", (topic,))
|
||||
|
||||
|
||||
# ── Themen (Soll-Punkte → Kapitel-Gerüst) ────────────────────────────────────
|
||||
|
||||
async def _themen_bilden(ctx: llm.Kontext) -> None:
|
||||
"""Partition der bestätigten Soll-Punkte in Themen (Titel + Lesefolge).
|
||||
Skip-Gate: haben alle Punkte ein gültiges Thema, passiert nichts — sonst
|
||||
würde jede Repair-Runde die Partition neu würfeln und Bausteine, Kapitel
|
||||
und Sections durchrotieren (Korrektheits-, nicht Spar-Bedingung)."""
|
||||
topic = ctx.topic
|
||||
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
|
||||
" ORDER BY id", (topic,))
|
||||
if not punkte:
|
||||
return
|
||||
gueltig = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?", (topic,))}
|
||||
if all(p["thema_id"] in gueltig for p in punkte):
|
||||
return
|
||||
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
|
||||
liste = "\n".join(f"{i + 1}: {p['punkt']}" for i, p in enumerate(punkte))
|
||||
richtwert = max(2, round(len(punkte) / THEMA_SOLL_PUNKTE))
|
||||
max_punkte = 2 * THEMA_SOLL_PUNKTE
|
||||
soll_nummern = list(range(1, len(punkte) + 1))
|
||||
|
||||
async def schneiden(hinweis: str, tag: str) -> list[dict] | None:
|
||||
res = await llm.call(ctx, stage="themen", template="Themen-Schnitt",
|
||||
role="judge", n=len(punkte), item=tag, erwartet=list,
|
||||
werte={"punkte": liste, "richtwert": str(richtwert),
|
||||
"max_punkte": str(max_punkte), "hinweis": hinweis})
|
||||
gruppen = []
|
||||
for g in res or []:
|
||||
if not isinstance(g, dict) or not str(g.get("titel", "")).strip():
|
||||
return None
|
||||
nummern = [n for n in g.get("punkte", []) if isinstance(n, int)]
|
||||
gruppen.append({"titel": str(g["titel"]).strip(), "nummern": nummern})
|
||||
alle = sorted(n for g in gruppen for n in g["nummern"])
|
||||
if alle != soll_nummern: # exakte Partition: nichts fehlt, nichts doppelt
|
||||
return None
|
||||
if any(len(g["nummern"]) > max_punkte for g in gruppen):
|
||||
return None
|
||||
if len(punkte) >= 4 and sum(1 for g in gruppen if len(g["nummern"]) < 2) > 1:
|
||||
return None # höchstens EIN Rest-Thema unter 2 Punkten
|
||||
return gruppen
|
||||
|
||||
gruppen = await schneiden("", "themen")
|
||||
if gruppen is None:
|
||||
gruppen = await schneiden(
|
||||
"ACHTUNG: Der letzte Versuch war ungültig. Jede Nummer der Liste"
|
||||
f" GENAU EINMAL verwenden (1–{len(punkte)}), jedes Thema braucht"
|
||||
" einen titel; keine Nummern erfinden.", "themen-2")
|
||||
art = "judge"
|
||||
if gruppen is None: # deterministischer Fallback: √n konsekutive Segmente
|
||||
art = "fallback"
|
||||
n = max(1, round(len(punkte) ** 0.5))
|
||||
groesse = -(-len(punkte) // n)
|
||||
gruppen = [{"titel": punkte[i * groesse]["punkt"][:120],
|
||||
"nummern": list(range(i * groesse + 1,
|
||||
min((i + 1) * groesse, len(punkte)) + 1))}
|
||||
for i in range(n) if i * groesse < len(punkte)]
|
||||
for pos, g in enumerate(gruppen):
|
||||
t_id = db.insert("themen", topic=topic, titel=g["titel"][:120], ord=pos, art=art)
|
||||
for nummer in g["nummern"]:
|
||||
db.update("soll", "id", punkte[nummer - 1]["id"], thema_id=t_id)
|
||||
|
||||
|
||||
# ── Lernziele ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
||||
@@ -100,12 +165,28 @@ def _baustein_rang(bausteine: list[dict], atome: list[dict],
|
||||
return b_rang
|
||||
|
||||
|
||||
def _thema_je_ziel(topic: str) -> dict[int, int | None]:
|
||||
"""ziel_id → thema_id über lernziele.soll_id → soll.thema_id."""
|
||||
out = {}
|
||||
for z in db.query("SELECT z.id, s.thema_id FROM lernziele z"
|
||||
" LEFT JOIN soll s ON s.id=z.soll_id WHERE z.topic=?", (topic,)):
|
||||
out[z["id"]] = z["thema_id"]
|
||||
return out
|
||||
|
||||
|
||||
def _dominantes_level(atome: list[dict]) -> str:
|
||||
"""Mehrheits-Level; Gleichstand → das niedrigere (E vor M vor S) — im
|
||||
Zweifel früh erklären statt spät."""
|
||||
zaehler = Counter(a["level"] for a in atome)
|
||||
return min(zaehler, key=lambda lv: (-zaehler[lv], LEVEL_RANG.get(lv, 9)))
|
||||
|
||||
|
||||
async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
||||
Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine
|
||||
(E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt
|
||||
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
|
||||
Quellreihenfolge splitten ("Teil n")."""
|
||||
Gruppen = LERNZIELE (ein Ziel = ein Baustein — kein Level-Split mehr).
|
||||
Kleine Gruppen mergen NUR innerhalb desselben Themas (Quell-Nähe wählt
|
||||
den Partner im Thema), große entlang der Quellreihenfolge splitten
|
||||
("Teil n"). Baustein-Level = dominantes Atom-Level."""
|
||||
topic = ctx.topic
|
||||
# Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen
|
||||
db.execute("DELETE FROM auftraege WHERE baustein_id IN"
|
||||
@@ -113,51 +194,50 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
atome = _atome(topic)
|
||||
rang = _anker_rang(topic)
|
||||
thema = _thema_je_ziel(topic)
|
||||
|
||||
gruppen: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
gruppen[(a["ziel_id"], a["level"])].append(a)
|
||||
gruppen[a["ziel_id"]].append(a)
|
||||
|
||||
# kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels.
|
||||
# Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine
|
||||
# Checkliste (152 feine Punkte hätten sonst keine Merge-Partner).
|
||||
def gruppen_rang(g: list[dict]) -> tuple:
|
||||
return min(rang[a["id"]] for a in g)
|
||||
|
||||
# kleine Gruppen mergen (kleinste zuerst), NUR im selben Thema — die
|
||||
# Themen-Schranke ersetzt die frühere Level-Schranke; Quell-Nähe bleibt
|
||||
# als Partnerwahl INNERHALB des Themas (dort ist sie harmlos).
|
||||
geaendert = True
|
||||
while geaendert:
|
||||
geaendert = False
|
||||
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||
key=lambda k: len(gruppen[k]))
|
||||
for k in kleine:
|
||||
# nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der
|
||||
# Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine
|
||||
# kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen,
|
||||
# den messen dann als unreparierbaren band-Befund meldete.
|
||||
passende = [p for p in gruppen if p != k and p[1] == k[1]]
|
||||
# keine MAX-Schranke: der Split zerlegt große Gruppen bandkonform;
|
||||
# sonst bliebe eine Kleingruppe ohne Partner als unreparierbarer
|
||||
# band-Befund liegen (Lektion 75)
|
||||
passende = [p for p in gruppen if p != k and thema.get(p) == thema.get(k)]
|
||||
if not passende:
|
||||
continue
|
||||
eigener = gruppen_rang(gruppen[k])
|
||||
|
||||
def naehe(pp: tuple) -> tuple:
|
||||
def naehe(pp) -> tuple:
|
||||
r = gruppen_rang(gruppen[pp])
|
||||
return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
||||
len(gruppen[pp]))
|
||||
|
||||
p = min(passende, key=naehe)
|
||||
for a in gruppen[k]:
|
||||
db.update("atome", "id", a["id"], ziel_id=p[0])
|
||||
db.update("atome", "id", a["id"], ziel_id=p)
|
||||
gruppen[p] += gruppen.pop(k)
|
||||
# Ziel erst still legen, wenn KEIN Level mehr darauf zeigt
|
||||
if not any(kk[0] == k[0] for kk in gruppen):
|
||||
db.update("lernziele", "id", k[0], status="gemerged")
|
||||
db.update("lernziele", "id", k, status="gemerged")
|
||||
geaendert = True
|
||||
break
|
||||
|
||||
# Bausteine anlegen (große Gruppen splitten), Atome zuordnen
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
for (ziel_id, level), gruppe in gruppen.items():
|
||||
for ziel_id, gruppe in gruppen.items():
|
||||
ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i])
|
||||
je_id = {a["id"]: a for a in gruppe}
|
||||
z = ziele.get(ziel_id, {})
|
||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
||||
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
|
||||
@@ -174,7 +254,9 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
continue
|
||||
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel,
|
||||
status="neu", level=level)
|
||||
status="neu",
|
||||
level=_dominantes_level([je_id[i] for i in teil_ids]),
|
||||
thema_id=thema.get(ziel_id))
|
||||
for pos, atom_id in enumerate(teil_ids):
|
||||
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
||||
|
||||
@@ -182,42 +264,53 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
|
||||
|
||||
async def _bausteine_ordnen(ctx: llm.Kontext) -> None:
|
||||
"""Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global
|
||||
fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität
|
||||
bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior
|
||||
vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine
|
||||
exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
|
||||
(ordnung_fallback, Muster wie kapitel_fallback)."""
|
||||
"""Didaktische Feinordnung je (Level, Thema)-Segment per Judge; ord läuft
|
||||
global fortlaufend über (Level-Rang, Thema-ord, Feinordnung) — eine Achse,
|
||||
Vorwärts-Logik und Kapitel-Kontiguität bleiben konstruktiv korrekt.
|
||||
Eingabe je Segment nach Quellpositions-Prior vorsortiert; der Code erzwingt
|
||||
eine exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund
|
||||
(ordnung_fallback). 1er-Segmente brauchen keinen Call."""
|
||||
topic = ctx.topic
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
atome = _atome(topic)
|
||||
b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic))
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,))
|
||||
# Rest-Segment je Level für thema_id NULL (defensiv: Testdaten, Altbestand)
|
||||
segmente = [(t["id"], t["titel"]) for t in themen] + [(None, "")]
|
||||
offset = 0
|
||||
for level in LEVEL_RANG:
|
||||
folge = sorted((b for b in bausteine if b["level"] == level),
|
||||
for thema_id, thema_titel in segmente:
|
||||
folge = sorted((b for b in bausteine
|
||||
if b["level"] == level and b["thema_id"] == thema_id),
|
||||
key=lambda b: b_rang[b["id"]])
|
||||
if not folge:
|
||||
continue
|
||||
if len(folge) == 1: # nichts zu ordnen — kein Call, kein Schein-Fallback
|
||||
db.update("bausteine", "id", folge[0]["id"], ord=offset, ordnung="judge")
|
||||
offset += 1
|
||||
continue
|
||||
liste = "\n".join(f"{i + 1}: {b['titel']} — "
|
||||
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for i, b in enumerate(folge))
|
||||
soll = list(range(1, len(folge) + 1))
|
||||
tag_basis = f"ordnung-{level}-t{thema_id or 0}"
|
||||
|
||||
async def ordnen(hinweis: str, tag: str) -> list[int] | None:
|
||||
res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung",
|
||||
role="judge", n=len(folge), item=tag, erwartet=list,
|
||||
werte={"bausteine": liste, "hinweis": hinweis,
|
||||
"thema": thema_titel or ctx.topic,
|
||||
"durchgang": DURCHGANG[level]})
|
||||
perm = [x for x in res or [] if isinstance(x, int)]
|
||||
return perm if sorted(perm) == soll else None
|
||||
|
||||
perm = await ordnen("", f"ordnung-{level}")
|
||||
perm = await ordnen("", tag_basis)
|
||||
if perm is None:
|
||||
perm = await ordnen(
|
||||
"ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU"
|
||||
f" den Nummern 1–{len(folge)}, jede genau einmal.",
|
||||
f"ordnung-{level}-2")
|
||||
f"{tag_basis}-2")
|
||||
art = "judge"
|
||||
if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund
|
||||
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())
|
||||
|
||||
|
||||
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE
|
||||
DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das
|
||||
Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der
|
||||
Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er
|
||||
unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen).
|
||||
Der Code erzwingt Reihenfolge, Lückenlosigkeit UND Größe (≥2 Bausteine außer
|
||||
im letzten, ≤2×Richtwert-Band — Lernen: 68/73 Singleton-Kapitel); ungültig
|
||||
bekommt EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback)."""
|
||||
topic = ctx.topic
|
||||
def _kapitel_bilden(topic: str) -> None:
|
||||
"""Kapitel = (Level, Thema)-Segment der geordneten Baustein-Folge —
|
||||
deterministisch, KEIN Judge. Kontiguität ist durch die globale Sortierung
|
||||
(Level-Rang, Thema-ord, Feinordnung) konstruktiv garantiert; Titel = Thema-
|
||||
Titel. Der frühere Grenz-Judge samt Retry/√n-Fallback/Größen-Gate entfällt."""
|
||||
alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
if not alle:
|
||||
return
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
themen = {t["id"]: t for t in db.query("SELECT * FROM themen WHERE topic=?", (topic,))}
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
pos_global = 0
|
||||
for level in LEVEL_RANG:
|
||||
bausteine = [b for b in alle if b["level"] == level]
|
||||
if not bausteine:
|
||||
continue
|
||||
# Positionsnummern statt DB-IDs: nach dem Level-Split sind die IDs
|
||||
# nicht mehr monoton entlang der Lesefolge — der Judge verlor die
|
||||
# Reihenfolge und lieferte 0 gültige Schnitte (aak Run 46: 15/15 Fallback)
|
||||
liste = "\n".join(f"{i + 1}: {b['titel']} — "
|
||||
f"{ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for i, b in enumerate(bausteine))
|
||||
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
|
||||
max_groesse = 2 * KAPITEL_BAUSTEINE
|
||||
folge = [b["id"] for b in bausteine]
|
||||
pos_von = {i + 1: i for i in range(len(folge))}
|
||||
|
||||
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
|
||||
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
|
||||
schritt="kapitel", role="judge", n=len(bausteine),
|
||||
item=tag, erwartet=list,
|
||||
werte={"bausteine": liste, "richtwert": str(richtwert),
|
||||
"durchgang": DURCHGANG[level], "hinweis": hinweis})
|
||||
segmente: list[tuple[str, list[int]]] = []
|
||||
start = 0
|
||||
for gruppe in res or []:
|
||||
if not isinstance(gruppe, dict):
|
||||
return None
|
||||
titel = str(gruppe.get("titel", "")).strip()
|
||||
ende = pos_von.get(gruppe.get("bis"))
|
||||
if not titel or ende is None or ende < start:
|
||||
return None
|
||||
segmente.append((titel, folge[start:ende + 1]))
|
||||
start = ende + 1
|
||||
if not segmente or start != len(folge):
|
||||
return None
|
||||
for i, (_, ids) in enumerate(segmente): # Größen-Gate
|
||||
if len(ids) > max_groesse:
|
||||
return None
|
||||
if len(ids) < 2 and len(folge) > 1 and i < len(segmente) - 1:
|
||||
return None
|
||||
return segmente
|
||||
|
||||
segmente = await schneiden("", f"kapitel-{level}")
|
||||
if segmente is None:
|
||||
segmente = await schneiden(
|
||||
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
|
||||
" \"titel\" und \"bis\" (eine NUMMER aus der Liste, streng"
|
||||
" aufsteigend); das letzte \"bis\" MUSS die letzte Nummer der Liste"
|
||||
f" sein. Jedes Kapitel bündelt 2–{max_groesse} Bausteine (Richtwert"
|
||||
f" {richtwert} Kapitel); nur das letzte darf kleiner sein.",
|
||||
f"kapitel-{level}-2")
|
||||
art = "judge"
|
||||
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
|
||||
art = "fallback"
|
||||
je_id = {b["id"]: b for b in bausteine}
|
||||
n = max(1, round(len(folge) ** 0.5))
|
||||
groesse = -(-len(folge) // n)
|
||||
segmente = [(je_id[teil[0]]["titel"], teil)
|
||||
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
|
||||
for titel, ids in segmente:
|
||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos_global,
|
||||
art=art, level=level)
|
||||
pos_global += 1
|
||||
for b_id in ids:
|
||||
db.update("bausteine", "id", b_id, kapitel_id=k_id)
|
||||
pos = 0
|
||||
letzter_key = None
|
||||
k_id = None
|
||||
for b in alle:
|
||||
key = (b["level"], b["thema_id"])
|
||||
if key != letzter_key:
|
||||
titel = themen.get(b["thema_id"], {}).get("titel") or b["titel"]
|
||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos,
|
||||
art="det", level=b["level"])
|
||||
pos += 1
|
||||
letzter_key = key
|
||||
db.update("bausteine", "id", b["id"], kapitel_id=k_id)
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
await _themen_bilden(ctx) # Soll-Punkte → Themen (Kapitel-Gerüst)
|
||||
await _ziele_bilden(ctx)
|
||||
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine
|
||||
await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt Dominanz
|
||||
await _bausteine_schneiden(ctx)
|
||||
await _kapitel_bilden(ctx)
|
||||
_kapitel_bilden(ctx.topic)
|
||||
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
@@ -361,25 +397,41 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
for a in atome:
|
||||
if not a["ziel_id"] or not a["baustein_id"]:
|
||||
befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]})
|
||||
# Themen-Partition: jeder bestätigte Soll-Punkt braucht ein gültiges Thema
|
||||
themen_ids = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?",
|
||||
(ctx.topic,))}
|
||||
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'",
|
||||
(ctx.topic,)):
|
||||
if p["thema_id"] not in themen_ids:
|
||||
befunde.append({"art": "thema_partition", "item": str(p["id"]),
|
||||
"detail": p["punkt"]})
|
||||
for t in db.query("SELECT * FROM themen WHERE topic=? AND art='fallback'",
|
||||
(ctx.topic,)): # stiller Fallback wäre unsichtbarer Verlust
|
||||
befunde.append({"art": "themen_fallback", "item": str(t["id"]),
|
||||
"detail": t["titel"]})
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,))
|
||||
je_baustein: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
je_baustein[a["baustein_id"]].append(a)
|
||||
soll_thema = {p["id"]: p["thema_id"] for p in
|
||||
db.query("SELECT id, thema_id FROM soll WHERE topic=?", (ctx.topic,))}
|
||||
# Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches
|
||||
# Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und
|
||||
# re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine
|
||||
# einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75).
|
||||
# THEMA, anderer Baustein) — spiegelgleich zur Merge-Schranke, damit die
|
||||
# Messlatte erfüllbar bleibt (Lektion 75).
|
||||
for b in bausteine:
|
||||
n = len(je_baustein[b["id"]])
|
||||
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] for bb in bausteine)
|
||||
partner = any(bb["id"] != b["id"] and bb["thema_id"] == b["thema_id"]
|
||||
for bb in bausteine)
|
||||
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
||||
befunde.append({"art": "band", "item": str(b["id"]),
|
||||
"detail": f"{b['titel']}: {n} Atome"})
|
||||
for a in je_baustein[b["id"]]: # ein Baustein = genau ein Level
|
||||
if a["level"] != b["level"]:
|
||||
befunde.append({"art": "level_mix", "item": str(a["id"]),
|
||||
"detail": f"Atom {a['level']} in Baustein {b['level']}"
|
||||
f" ({b['titel']})"})
|
||||
# Themenreinheit: die Soll-Punkte aller Atome gehören zum Baustein-Thema.
|
||||
# Atome ohne soll_id überspringen — deren Befund gehört zur Inventar-Ebene.
|
||||
for a in je_baustein[b["id"]]:
|
||||
if a["soll_id"] and soll_thema.get(a["soll_id"]) != b["thema_id"]:
|
||||
befunde.append({"art": "baustein_thema_mix", "item": str(a["id"]),
|
||||
"detail": f"Atom {a['titel'][:60]} fremdes Thema"
|
||||
f" in {b['titel']}"})
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
||||
kap_ids = {k["id"] for k in kaps}
|
||||
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||
@@ -392,30 +444,18 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
"detail": f"{b['titel']} ({b['level']}) in Kapitel"
|
||||
f" {kap_level.get(b['kapitel_id'])}"})
|
||||
belegt = {b["kapitel_id"] for b in bausteine}
|
||||
je_kapitel: dict = defaultdict(int)
|
||||
je_level: dict = defaultdict(int)
|
||||
for b in bausteine:
|
||||
je_kapitel[b["kapitel_id"]] += 1
|
||||
je_level[b["level"]] += 1
|
||||
for k in kaps:
|
||||
if k["id"] not in belegt:
|
||||
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
|
||||
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
# Singleton-Kapitel zerstückeln die Navigation (Lernen: 68/73) — erlaubt
|
||||
# nur, wenn der ganze Durchgang bloß einen Baustein hat.
|
||||
if je_kapitel[k["id"]] == 1 and je_level[k["level"]] > 1:
|
||||
letzte = max((kk["ord"] for kk in kaps if kk["level"] == k["level"]))
|
||||
if k["ord"] != letzte:
|
||||
befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback)
|
||||
for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}):
|
||||
befunde.append({"art": "ordnung_fallback", "item": lv,
|
||||
"detail": f"Durchgang {lv}: Judge lieferte keine gültige"
|
||||
f" Permutation — Quellreihenfolge übernommen"})
|
||||
# stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (je Segment)
|
||||
segmente = sorted({(b["level"], b["thema_id"]) for b in bausteine
|
||||
if b["ordnung"] == "fallback"},
|
||||
key=lambda s: (LEVEL_RANG.get(s[0], 9), s[1] or 0))
|
||||
for level, thema_id in segmente:
|
||||
befunde.append({"art": "ordnung_fallback", "item": f"{level}/t{thema_id or 0}",
|
||||
"detail": f"Segment {level}/{thema_id}: Judge lieferte keine"
|
||||
f" gültige Permutation — Quellreihenfolge übernommen"})
|
||||
return befunde
|
||||
|
||||
|
||||
@@ -423,8 +463,5 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
if not befunde:
|
||||
return False
|
||||
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
||||
await _level_kalibrieren(ctx)
|
||||
await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback
|
||||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
||||
await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt
|
||||
return True
|
||||
|
||||
@@ -26,6 +26,7 @@ def export(topic: str) -> dict:
|
||||
"topics": q("SELECT * FROM topics WHERE name=?", (topic,)),
|
||||
"quellen": q("SELECT * FROM quellen WHERE topic=?", (topic,)),
|
||||
"soll": q("SELECT * FROM soll WHERE topic=?", (topic,)),
|
||||
"themen": q("SELECT * FROM themen WHERE topic=?", (topic,)),
|
||||
"lernziele": q("SELECT * FROM lernziele WHERE topic=?", (topic,)),
|
||||
"kapitel": q("SELECT * FROM kapitel WHERE topic=?", (topic,)),
|
||||
"bausteine": q("SELECT * FROM bausteine WHERE topic=?", (topic,)),
|
||||
@@ -101,11 +102,13 @@ def importieren(d: dict) -> None:
|
||||
z["geprueft"] = db.j([q_map[q] for q in db.uj(z.get("geprueft") or "[]")
|
||||
if q in q_map])
|
||||
soll_zeilen.append(z)
|
||||
s_map = _einfuegen("soll", soll_zeilen)
|
||||
t_map = _einfuegen("themen", d.get("themen", [])) # Alt-Exporte: kein Key → leer
|
||||
s_map = _einfuegen("soll", soll_zeilen, thema_id=t_map)
|
||||
|
||||
z_map = _einfuegen("lernziele", d["lernziele"], soll_id=s_map)
|
||||
k_map = _einfuegen("kapitel", d["kapitel"])
|
||||
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map)
|
||||
b_map = _einfuegen("bausteine", d["bausteine"], ziel_id=z_map, kapitel_id=k_map,
|
||||
thema_id=t_map)
|
||||
# merged_into zeigt auf atome selbst → zweiphasig; braucht-Key aus
|
||||
# Alt-Exporten verwerfen (Spalte existiert nicht mehr)
|
||||
a_map = _einfuegen("atome", [{**{k: v for k, v in z.items() if k != "braucht"},
|
||||
|
||||
@@ -10,6 +10,8 @@ MÄNGEL:
|
||||
BELEGE (einzige erlaubte Faktenbasis — nichts erfinden):
|
||||
{belege}
|
||||
|
||||
Regel: Mathe IMMER in $…$ (KaTeX), nie als roher Klartext.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences). Für flashcard:
|
||||
{{"frage": "…", "antwort": "…"}}
|
||||
Für beispiel: BEHALTE die Form aus dem Artefakt oben und gib die passenden Felder zurück:
|
||||
|
||||
@@ -23,6 +23,8 @@ Regeln:
|
||||
- Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen.
|
||||
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
|
||||
keine Rezitation exakter Formulierungen verlangen).
|
||||
- Mathe IMMER in $…$ (KaTeX), nie als roher Klartext — gilt für jeden
|
||||
mathematischen Ausdruck in Frage, Antwort und Beispieltext.
|
||||
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
|
||||
„Beleg", „Quelle", „Musterlösung", „Skript", „Aufgabe 3" sind verboten. Die Frage
|
||||
fragt Wissen ab („Was ist X?", „Warum gilt Y?"), nie was in einem Text steht,
|
||||
|
||||
@@ -13,6 +13,9 @@ Prüfe jedes Artefakt GEGEN DIE BELEGE:
|
||||
funktionieren und Wissen abfragen, nicht Textinhalte.
|
||||
- ok=false, wenn Frage oder Antwort nicht auf Deutsch ist (etablierte
|
||||
Fachtermini sind okay; ganze englische Sätze nicht).
|
||||
- ok=false, wenn mathematische Ausdrücke NICHT in $…$ (KaTeX) stehen —
|
||||
roher Klartext wird beim Lernen falsch dargestellt. „mangel": die
|
||||
betroffenen Ausdrücke nennen.
|
||||
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
|
||||
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
|
||||
- Antworte für JEDES Artefakt (id wörtlich übernehmen).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<!-- template:Baustein-Ordnung -->
|
||||
Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest.
|
||||
Thema: {thema}
|
||||
Durchgang: {durchgang}
|
||||
|
||||
BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials):
|
||||
|
||||
@@ -23,6 +23,9 @@ Regeln:
|
||||
- Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der
|
||||
Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…".
|
||||
- Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis.
|
||||
- Trägt ein Auftrag den Vermerk [KLÄRUNG …]: die strittige Passage anhand der
|
||||
Faktenbasis korrigieren; ist sie dort nicht belegbar, die Aussage STREICHEN
|
||||
oder auf das Belegbare abschwächen — nicht umformulieren und behalten.
|
||||
|
||||
Antworte in GENAU ZWEI Blöcken mit diesen Trennern (KEIN JSON, keine Code-Fences).
|
||||
LaTeX-Backslashes bleiben roh (kein Escaping):
|
||||
|
||||
29
templates/Guide-Konflikt-Klaerung.md
Normal file
29
templates/Guide-Konflikt-Klaerung.md
Normal 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"}}]
|
||||
@@ -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}}]
|
||||
22
templates/Themen-Schnitt.md
Normal file
22
templates/Themen-Schnitt.md
Normal 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, 3–6 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]}}]
|
||||
@@ -184,3 +184,131 @@ async def test_freeze_urteilt_weiter(monkeypatch):
|
||||
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}])
|
||||
assert bewegt is True # geurteilt (behoben) trotz Freeze
|
||||
assert db.one("SELECT COUNT(*) n FROM auftraege WHERE status='behoben'")["n"] == 1
|
||||
|
||||
|
||||
# ── Klärungs-Stichentscheid (eskalierte falsch/luecke) ────────────────────────
|
||||
|
||||
async def test_klaerung_text_falsch_wiedereroeffnet(monkeypatch):
|
||||
"""Mehrheit text_falsch → Auftrag wieder offen (runden=0, geklaert=1); der
|
||||
Fix bekommt das Streich-Mandat als [KLÄRUNG]-Vermerk."""
|
||||
topic = topic_anlegen("klaer1")
|
||||
b = _baustein(topic)
|
||||
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Zahl falsch",
|
||||
status="eskaliert", runden=AUFTRAG_RUNDEN_MAX)
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
|
||||
lambda p: [{"auftrag": 1, "urteil": "text_falsch"}])
|
||||
await guide._klaeren(_ctx(topic), b)
|
||||
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
|
||||
assert (r["status"], r["runden"], r["geklaert"]) == ("offen", 0, 1)
|
||||
# der Fix-Prompt trägt das Streich-Mandat
|
||||
gesehen = {}
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
def fix(prompt):
|
||||
gesehen["klaerung"] = "[KLÄRUNG" in prompt and "STREICHEN" in prompt
|
||||
return {"kompakt": "- p", "lang": sec["text_lang"] + " korrigiert"}
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", fix)
|
||||
db.update("sections", "baustein_id", b["id"], stage="fix")
|
||||
await guide._stage_fix(_ctx(topic), b)
|
||||
assert gesehen["klaerung"]
|
||||
|
||||
|
||||
async def test_klaerung_unbegruendet_schliesst():
|
||||
"""Default-Fake stimmt unbegruendet → kein_mangel, geklaert=1, bewegt True."""
|
||||
topic = topic_anlegen("klaer2")
|
||||
b = _baustein(topic)
|
||||
au = db.insert("auftraege", baustein_id=b["id"], art="luecke", detail="Fehlalarm",
|
||||
status="eskaliert")
|
||||
db.update("sections", "baustein_id", b["id"], stage="done")
|
||||
bewegt = await guide.reparieren(_ctx(topic), [
|
||||
{"art": "klaerung_offen", "item": str(b["id"]), "detail": "Fehlalarm"}])
|
||||
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
|
||||
assert (r["status"], r["geklaert"]) == ("kein_mangel", 1)
|
||||
assert bewegt is True
|
||||
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
|
||||
(b["id"],))["stage"] == "done" # keine Stage-Route nötig
|
||||
|
||||
|
||||
async def test_klaerung_patt_terminal(monkeypatch):
|
||||
"""Voll-Patt (1/1/1) → geklaert=1, bleibt eskaliert; messen zeigt
|
||||
fakten_konflikt; zweiter reparieren-Lauf ruft die Klärung nie wieder."""
|
||||
topic = topic_anlegen("klaer3")
|
||||
b = _baustein(topic)
|
||||
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="strittig",
|
||||
status="eskaliert")
|
||||
votes = iter(["text_falsch", "unbegruendet", "quelle_unklar"])
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
|
||||
lambda p: [{"auftrag": 1, "urteil": next(votes)}])
|
||||
await guide._klaeren(_ctx(topic), b)
|
||||
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
|
||||
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
|
||||
ctx = _ctx(topic)
|
||||
arten = [x["art"] for x in guide.messen(ctx)]
|
||||
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
|
||||
lambda p: pytest.fail("geklärter Auftrag erneut geklärt"))
|
||||
assert await guide.reparieren(ctx, [{"art": "fakten_konflikt",
|
||||
"item": str(b["id"]), "detail": "strittig"}]) is False
|
||||
|
||||
|
||||
async def test_klaerung_ohne_gueltige_stimmen_fail_closed(monkeypatch):
|
||||
"""Liefern die Richter keine gültigen Urteile, bleibt geklaert=0 —
|
||||
nächste Runde klärt erneut (fail-closed)."""
|
||||
topic = topic_anlegen("klaer4")
|
||||
b = _baustein(topic)
|
||||
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
|
||||
status="eskaliert")
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Konflikt-Klaerung",
|
||||
lambda p: [{"auftrag": 1, "urteil": "quatsch"}])
|
||||
await guide._klaeren(_ctx(topic), b)
|
||||
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
|
||||
assert (r["status"], r["geklaert"]) == ("eskaliert", 0)
|
||||
|
||||
|
||||
async def test_runden_inkrement_ist_nicht_bewegt(monkeypatch):
|
||||
"""Identisches Panel-Patt auf identischem Text = Leer-Churn → reparieren
|
||||
meldet ehrlich False (runden zählt nicht mehr als Bewegung)."""
|
||||
topic = topic_anlegen("leerchurn")
|
||||
b = _baustein(topic)
|
||||
db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Patt")
|
||||
db.update("sections", "baustein_id", b["id"], stage="done")
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
|
||||
lambda p: [{"auftrag": 1, "urteil": "offen"}])
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
|
||||
lambda p: {"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]})
|
||||
bewegt = await guide.reparieren(_ctx(topic), [
|
||||
{"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) Patt"}])
|
||||
assert bewegt is False # nur runden+1, kein Status-/Text-Fortschritt
|
||||
|
||||
|
||||
async def test_stil_rest_keine_route():
|
||||
topic = topic_anlegen("stilrest")
|
||||
b = _baustein(topic)
|
||||
db.insert("auftraege", baustein_id=b["id"], art="stil", detail="zäh",
|
||||
status="eskaliert")
|
||||
db.update("sections", "baustein_id", b["id"], stage="done")
|
||||
assert await guide.reparieren(_ctx(topic), [
|
||||
{"art": "stil_rest", "item": str(b["id"]), "detail": "zäh"}]) is False
|
||||
assert db.one("SELECT stage FROM sections WHERE baustein_id=?",
|
||||
(b["id"],))["stage"] == "done"
|
||||
|
||||
|
||||
async def test_reeskalation_nach_klaerung_terminal(monkeypatch):
|
||||
"""Wiedereröffneter (geklärter) Auftrag, der erneut am Cap eskaliert, ist
|
||||
terminal unauflösbar — keine zweite Klärung."""
|
||||
topic = topic_anlegen("reesk")
|
||||
b = _baustein(topic)
|
||||
au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D",
|
||||
geklaert=1, runden=AUFTRAG_RUNDEN_MAX - 1)
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil",
|
||||
lambda p: [{"auftrag": 1, "urteil": "offen"}])
|
||||
await guide._auftraege_urteilen(_ctx(topic), b,
|
||||
db.one("SELECT * FROM sections WHERE baustein_id=?",
|
||||
(b["id"],)),
|
||||
guide._offene_auftraege(b["id"]))
|
||||
r = db.one("SELECT * FROM auftraege WHERE id=?", (au,))
|
||||
assert (r["status"], r["geklaert"]) == ("eskaliert", 1)
|
||||
ctx = _ctx(topic)
|
||||
arten = [x["art"] for x in guide.messen(ctx)]
|
||||
assert "fakten_konflikt" in arten and "klaerung_offen" not in arten
|
||||
assert guide._klaerung_kandidaten(b["id"]) == []
|
||||
|
||||
@@ -284,7 +284,8 @@ def test_persistieren_dedup_mit_detail():
|
||||
|
||||
async def test_auftrag_offen_in_messen():
|
||||
"""Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
|
||||
eskalierte falsch/luecke-Aufträge werden als fakten_konflikt sichtbar."""
|
||||
eskalierte dreiteilig: ungeklärt → klaerung_offen, geklärt → fakten_konflikt,
|
||||
Stil → stil_rest (war vorher stumm)."""
|
||||
import db
|
||||
import guide
|
||||
import llm
|
||||
@@ -298,14 +299,18 @@ async def test_auftrag_offen_in_messen():
|
||||
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
|
||||
db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
|
||||
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch",
|
||||
status="eskaliert")
|
||||
status="eskaliert") # geklaert=0 → wartet auf Stichentscheid
|
||||
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Unauflösbar",
|
||||
status="eskaliert", geklaert=1)
|
||||
db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh",
|
||||
status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld)
|
||||
status="eskaliert")
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "guide"
|
||||
arten = [b["art"] for b in guide.messen(ctx)]
|
||||
assert arten.count("auftrag_offen") == 1
|
||||
assert arten.count("klaerung_offen") == 1
|
||||
assert arten.count("fakten_konflikt") == 1
|
||||
assert arten.count("stil_rest") == 1
|
||||
|
||||
|
||||
async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch):
|
||||
|
||||
@@ -53,15 +53,21 @@ def _pruefe_endzustand(topic, run_id):
|
||||
folge = [b["kapitel_id"] for b in bausteine]
|
||||
segmente = [k for k, _ in itertools.groupby(folge)]
|
||||
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
|
||||
# Stufen-Bausteine: jeder Baustein trägt genau ein Level, alle seine Atome
|
||||
# dieses Level; Kapitel-Level monoton E→M→S entlang der globalen ord
|
||||
# Level je Baustein = dominantes Atom-Level; Themenreinheit: die Soll-Punkte
|
||||
# aller Atome gehören zum Baustein-Thema; Kapitel-Level monoton E→M→S
|
||||
rang = {"E": 0, "M": 1, "S": 2}
|
||||
kap_level = {k["id"]: k["level"] for k in kaps}
|
||||
soll_thema = {p["id"]: p["thema_id"] for p in
|
||||
db.query("SELECT id, thema_id FROM soll WHERE topic=?", (topic,))}
|
||||
from collections import Counter
|
||||
for b in bausteine:
|
||||
assert kap_level[b["kapitel_id"]] == b["level"]
|
||||
for a in atome:
|
||||
if a["baustein_id"] == b["id"]:
|
||||
assert a["level"] == b["level"], (a["titel"], a["level"], b["level"])
|
||||
eigene = [a for a in atome if a["baustein_id"] == b["id"]]
|
||||
zaehler = Counter(a["level"] for a in eigene)
|
||||
dominant = min(zaehler, key=lambda lv: (-zaehler[lv], rang.get(lv, 9)))
|
||||
assert b["level"] == dominant, (b["titel"], b["level"], dict(zaehler))
|
||||
for a in eigene:
|
||||
assert soll_thema.get(a["soll_id"]) == b["thema_id"], (a["titel"], b["titel"])
|
||||
level_folge = [rang[b["level"]] for b in bausteine]
|
||||
assert level_folge == sorted(level_folge), "Durchgänge nicht E<M<S geordnet"
|
||||
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
|
||||
@@ -74,9 +80,11 @@ async def test_e2e_thema():
|
||||
topic = topic_anlegen("fake-thema", art="thema")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
# Fake-Welt: 3 Atome mit Level E/M/S → je Level ein eigener Baustein-Durchgang
|
||||
# Fake-Welt: 3 Atome E/M/S im selben Thema → EIN Baustein, Level = E
|
||||
# (Gleichstand 1/1/1 → niedrigstes; kein Level-Split mehr)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [b["level"] for b in bausteine] == ["E", "M", "S"]
|
||||
assert [b["level"] for b in bausteine] == ["E"]
|
||||
assert all(b["thema_id"] for b in bausteine)
|
||||
|
||||
|
||||
async def test_e2e_uni(tmp_path):
|
||||
@@ -143,6 +151,9 @@ async def test_ebenen_entfernen_kaskade():
|
||||
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM themen WHERE topic=?", (topic,)) == []
|
||||
assert all(p["thema_id"] is None for p in
|
||||
db.query("SELECT thema_id FROM soll WHERE topic=?", (topic,)))
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
|
||||
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
|
||||
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
|
||||
@@ -161,7 +172,7 @@ async def test_topic_loeschen():
|
||||
_pruefe_endzustand(topic, run1)
|
||||
pipeline.topic_loeschen(topic)
|
||||
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
|
||||
"kapitel", "runs"):
|
||||
"kapitel", "themen", "runs"):
|
||||
feld = "name" if tabelle == "topics" else "topic"
|
||||
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle
|
||||
|
||||
|
||||
@@ -14,6 +14,14 @@ def _atom(topic, titel, ziel_id=None, soll_id=1):
|
||||
soll_id=soll_id, ziel_id=ziel_id)
|
||||
|
||||
|
||||
def _thema(topic, soll_ids, titel="T", ord=0):
|
||||
"""Themen-Seed: eine themen-Zeile, Soll-Punkte darauf zeigen lassen."""
|
||||
t = db.insert("themen", topic=topic, titel=titel, ord=ord, art="judge")
|
||||
for s in soll_ids:
|
||||
db.update("soll", "id", s, thema_id=t)
|
||||
return t
|
||||
|
||||
|
||||
def _baustein_groessen(topic):
|
||||
from collections import Counter
|
||||
c = Counter(a["baustein_id"] for a in struktur._atome(topic))
|
||||
@@ -88,13 +96,14 @@ async def test_band_split():
|
||||
assert not [x for x in befunde if x["art"] in ("band", "partition")]
|
||||
|
||||
|
||||
async def test_merge_ohne_soll_schranke_und_kapitel():
|
||||
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per
|
||||
# Quell-Nähe); Kapitel entstehen danach als kontiguierliche Segmente
|
||||
async def test_merge_im_thema_ueber_soll_grenzen():
|
||||
# kleines Ziel merged über Soll-Punkt-Grenzen, solange BEIDE Punkte im
|
||||
# selben Thema liegen; Kapitel = (Level, Thema) deterministisch
|
||||
topic = topic_anlegen("kapitel")
|
||||
run = run_anlegen(topic)
|
||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||
t = _thema(topic, [s1, s2], "Gemeinsames Thema")
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
||||
_atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
|
||||
@@ -105,9 +114,12 @@ async def test_merge_ohne_soll_schranke_und_kapitel():
|
||||
await struktur._bausteine_schneiden(ctx)
|
||||
atome = struktur._atome(topic)
|
||||
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
struktur._kapitel_bilden(topic)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert bausteine and all(x["kapitel_id"] for x in bausteine)
|
||||
assert all(x["thema_id"] == t for x in bausteine)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert [k["titel"] for k in kaps] == ["Gemeinsames Thema"]
|
||||
assert struktur.messen(ctx) == []
|
||||
|
||||
|
||||
@@ -183,32 +195,30 @@ def test_baustein_rang_median_gegen_merge_gift():
|
||||
assert b_rang[2] < b_rang[1] # trotz 464-Import bleibt Baustein 1 hinten
|
||||
|
||||
|
||||
async def test_kapitel_retry_und_fallback(monkeypatch):
|
||||
topic = topic_anlegen("kapfall")
|
||||
run = run_anlegen(topic)
|
||||
def test_kapitel_deterministisch(monkeypatch):
|
||||
"""Kapitel = (Level, Thema)-Segment ohne LLM: Titel = Thema-Titel, art='det',
|
||||
Reihenfolge folgt der globalen Baustein-ord."""
|
||||
topic = topic_anlegen("kapdet")
|
||||
t1 = db.insert("themen", topic=topic, titel="Thema Eins", ord=0, art="judge")
|
||||
t2 = db.insert("themen", topic=topic, titel="Thema Zwei", ord=1, art="judge")
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b = [db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{i}", ord=i,
|
||||
status="neu") for i in range(4)]
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
|
||||
aufrufe = {"n": 0}
|
||||
def judge(prompt):
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1:
|
||||
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
|
||||
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
|
||||
lambda p: [{"titel": "x", "bis": 999999}])
|
||||
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert kaps and all(k["art"] == "fallback" for k in kaps)
|
||||
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}
|
||||
ordn = 0
|
||||
for level in ("E", "M"):
|
||||
for thema in (t1, t2):
|
||||
db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{ordn}",
|
||||
ord=ordn, status="neu", level=level, thema_id=thema)
|
||||
ordn += 1
|
||||
def explodiert(prompt): # beweist: kein LLM-Call nötig
|
||||
raise AssertionError("Kapitel-Bildung darf keinen Judge rufen")
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
|
||||
struktur._kapitel_bilden(topic)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [k["titel"] for k in kaps] == ["Thema Eins", "Thema Zwei"] * 2
|
||||
assert [k["level"] for k in kaps] == ["E", "E", "M", "M"]
|
||||
assert all(k["art"] == "det" for k in kaps)
|
||||
zu_kapitel = {b["ord"]: b["kapitel_id"] for b in
|
||||
db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))}
|
||||
assert len(set(zu_kapitel.values())) == 4 # je (Level, Thema) ein Kapitel
|
||||
|
||||
|
||||
async def test_level_kalibrierung(monkeypatch):
|
||||
@@ -231,12 +241,13 @@ async def test_level_kalibrierung(monkeypatch):
|
||||
assert db.one("SELECT level FROM atome WHERE id=?", (a2,))["level"] == "S"
|
||||
|
||||
|
||||
async def test_level_split_ordnung_kapitel():
|
||||
# Ein Ziel mit 4×E + 4×M → zwei Bausteine mit level, E-ord < M-ord,
|
||||
# Kapitel je Durchgang, kein level_mix/kapitel_level
|
||||
topic = topic_anlegen("split")
|
||||
async def test_dominantes_level():
|
||||
# Ein Ziel mit 4×E + 4×M → EIN Baustein (kein Level-Split mehr);
|
||||
# Level = Mehrheit, Gleichstand → niedrigeres (E). 3×E + 5×M → M.
|
||||
topic = topic_anlegen("dominant")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
_thema(topic, [soll])
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
for i in range(4):
|
||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||
@@ -247,29 +258,106 @@ async def test_level_split_ordnung_kapitel():
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._bausteine_schneiden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [b["level"] for b in bausteine] == ["E", "M"]
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [k["level"] for k in kaps] == ["E", "M"]
|
||||
assert [b["level"] for b in bausteine] == ["E"] # Tie 4/4 → niedrigeres
|
||||
struktur._kapitel_bilden(topic)
|
||||
arten = {b["art"] for b in struktur.messen(ctx)}
|
||||
assert not arten & {"level_mix", "kapitel_level", "band", "partition"}
|
||||
assert not arten & {"kapitel_level", "band", "partition", "baustein_thema_mix"}
|
||||
# Mehrheit gewinnt: reine Funktion
|
||||
assert struktur._dominantes_level([{"level": "E"}] * 3 + [{"level": "M"}] * 5) == "M"
|
||||
assert struktur._dominantes_level([{"level": "S"}]) == "S"
|
||||
|
||||
|
||||
async def test_kein_merge_ueber_level():
|
||||
# kleine E-Gruppe darf NICHT mit kleiner M-Gruppe mergen
|
||||
topic = topic_anlegen("levelmerge")
|
||||
async def test_kein_merge_ueber_thema():
|
||||
# kleine Ziele in VERSCHIEDENEN Themen mergen NICHT — zwei Bausteine,
|
||||
# und kein band-Befund (kein Partner im eigenen Thema)
|
||||
topic = topic_anlegen("themamerge")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=soll, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=soll, status="aktiv")
|
||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||
_thema(topic, [s1], "T1", 0)
|
||||
_thema(topic, [s2], "T2", 1)
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
||||
for i in range(2):
|
||||
db.insert("atome", topic=topic, titel=f"E{i}", typ="begriff", definition="d",
|
||||
level="E", status="neu", soll_id=soll, ziel_id=z1)
|
||||
db.insert("atome", topic=topic, titel=f"M{i}", typ="begriff", definition="d",
|
||||
level="M", status="neu", soll_id=soll, ziel_id=z2)
|
||||
_atom(topic, f"A{i}", z1, s1)
|
||||
_atom(topic, f"B{i}", z2, s2)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._bausteine_schneiden(ctx)
|
||||
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
||||
levels = {a["level"] for a in struktur._atome(topic)
|
||||
if a["baustein_id"] == b["id"]}
|
||||
assert levels == {b["level"]}
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert len(bausteine) == 2 and {b["ziel_id"] for b in bausteine} == {z1, z2}
|
||||
struktur._kapitel_bilden(topic)
|
||||
assert "band" not in {b["art"] for b in struktur.messen(ctx)}
|
||||
|
||||
|
||||
async def test_themen_bilden_gate_retry_fallback(monkeypatch):
|
||||
topic = topic_anlegen("themengate")
|
||||
run = run_anlegen(topic)
|
||||
punkte = [db.insert("soll", topic=topic, punkt=f"P{i}", status="bestaetigt",
|
||||
belege=db.j([])) for i in range(4)]
|
||||
aufrufe = {"n": 0}
|
||||
|
||||
def judge(prompt):
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1: # unvollständig → Gate schlägt zu, Retry
|
||||
return [{"titel": "T", "punkte": [1, 2]}]
|
||||
return [{"titel": "Grundlagen", "punkte": [1, 3]},
|
||||
{"titel": "Vertiefung", "punkte": [2, 4]}]
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", judge)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._themen_bilden(ctx)
|
||||
assert aufrufe["n"] == 2 # Retry hat gegriffen
|
||||
themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert [t["titel"] for t in themen] == ["Grundlagen", "Vertiefung"]
|
||||
zu = {p: db.one("SELECT thema_id FROM soll WHERE id=?", (p,))["thema_id"]
|
||||
for p in punkte}
|
||||
assert zu[punkte[0]] == zu[punkte[2]] == themen[0]["id"]
|
||||
assert zu[punkte[1]] == zu[punkte[3]] == themen[1]["id"]
|
||||
assert "thema_partition" not in {b["art"] for b in struktur.messen(ctx)}
|
||||
|
||||
# beide Versuche Müll → √n-Fallback + Befund
|
||||
db.execute("DELETE FROM themen WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE soll SET thema_id=NULL WHERE topic=?", (topic,))
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", lambda p: [{"x": 1}])
|
||||
await struktur._themen_bilden(ctx)
|
||||
themen = db.query("SELECT * FROM themen WHERE topic=?", (topic,))
|
||||
assert themen and all(t["art"] == "fallback" for t in themen)
|
||||
assert "themen_fallback" in {b["art"] for b in struktur.messen(ctx)}
|
||||
offen = db.query("SELECT * FROM soll WHERE topic=? AND thema_id IS NULL", (topic,))
|
||||
assert offen == [] # Fallback partitioniert vollständig
|
||||
|
||||
|
||||
async def test_themen_skip_wenn_vollstaendig(monkeypatch):
|
||||
"""Gültige Partition wird NIE neu gewürfelt (Churn-Schutz je Repair-Runde)."""
|
||||
topic = topic_anlegen("themenskip")
|
||||
run = run_anlegen(topic)
|
||||
s = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
_thema(topic, [s])
|
||||
|
||||
def explodiert(prompt):
|
||||
raise AssertionError("vollständige Partition darf keinen Call auslösen")
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Themen-Schnitt", explodiert)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._themen_bilden(ctx) # kein Call → kein Raise
|
||||
|
||||
|
||||
async def test_thema_befunde():
|
||||
topic = topic_anlegen("themabefund")
|
||||
run = run_anlegen(topic)
|
||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||
t = _thema(topic, [s1], "T1")
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
arten = {b["art"] for b in struktur.messen(ctx)}
|
||||
assert "thema_partition" in arten # s2 hat kein Thema
|
||||
# Baustein trägt T1, enthält aber ein Atom mit Soll-Punkt ohne dieses Thema
|
||||
ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=s1, status="aktiv")
|
||||
b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0,
|
||||
status="neu", level="E", thema_id=t)
|
||||
db.insert("atome", topic=topic, titel="fremd", typ="begriff", definition="d",
|
||||
status="neu", soll_id=s2, ziel_id=ziel, baustein_id=b)
|
||||
arten = {x["art"] for x in struktur.messen(ctx)}
|
||||
assert "baustein_thema_mix" in arten
|
||||
|
||||
@@ -16,7 +16,7 @@ async def test_export_import_roundtrip():
|
||||
pipeline.lauf_starten(topic)
|
||||
await pipeline._laeufe[topic]
|
||||
|
||||
tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte",
|
||||
tabellen = ("quellen", "soll", "themen", "atome", "anker", "kanten", "artefakte",
|
||||
"lernziele", "bausteine", "kapitel", "sections", "auftraege",
|
||||
"runs", "befunde", "events", "leitner")
|
||||
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen}
|
||||
@@ -35,6 +35,11 @@ async def test_export_import_roundtrip():
|
||||
" ('gemerged','verworfen')", (topic,)):
|
||||
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
|
||||
assert a["soll_id"] and db.one("SELECT id FROM soll WHERE id=?", (a["soll_id"],))
|
||||
# Themen-Verweise remappt: bestätigte Punkte und Bausteine zeigen auf echte Themen
|
||||
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,)):
|
||||
assert p["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (p["thema_id"],))
|
||||
for b in db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)):
|
||||
assert b["thema_id"] and db.one("SELECT id FROM themen WHERE id=?", (b["thema_id"],))
|
||||
for s in db.query("SELECT belege FROM soll WHERE topic=? AND status='bestaetigt'",
|
||||
(topic,)):
|
||||
for b in db.uj(s["belege"]):
|
||||
|
||||
Reference in New Issue
Block a user