From 3f6114681c581a6659e02a9f3f2ccdba36287666 Mon Sep 17 00:00:00 2001 From: team3 Date: Tue, 14 Jul 2026 00:15:03 +0200 Subject: [PATCH] update --- backend/config.py | 7 +- backend/db.py | 19 +- backend/fake_agents.py | 28 +- backend/guide.py | 156 +++++++++-- backend/main.py | 1 + backend/pipeline.py | 4 + backend/qa.py | 10 +- backend/struktur.py | 383 +++++++++++++++------------ backend/transfer.py | 7 +- templates/Artefakt-Fix.md | 2 + templates/Artefakt-Generate.md | 2 + templates/Artefakt-Verify.md | 3 + templates/Baustein-Ordnung.md | 1 + templates/Guide-Fix.md | 3 + templates/Guide-Konflikt-Klaerung.md | 29 ++ templates/Kapitel-Schnitt.md | 22 -- templates/Themen-Schnitt.md | 22 ++ tests/test_auftraege.py | 128 +++++++++ tests/test_bausteine.py | 11 +- tests/test_e2e.py | 27 +- tests/test_struktur.py | 192 ++++++++++---- tests/test_transfer.py | 7 +- 22 files changed, 757 insertions(+), 307 deletions(-) create mode 100644 templates/Guide-Konflikt-Klaerung.md delete mode 100644 templates/Kapitel-Schnitt.md create mode 100644 templates/Themen-Schnitt.md diff --git a/backend/config.py b/backend/config.py index 0a00e4b..303edfe 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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), diff --git a/backend/db.py b/backend/db.py index ff3b397..4405770 100644 --- a/backend/db.py +++ b/backend/db.py @@ -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"): diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 8eb4e8a..cd920c4 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -208,12 +208,15 @@ def _titel_fix(prompt: str): return [{"atom": int(i), "titel": f"Konzept {i}"} for i in zeilen] -def _kapitel_schnitt(prompt: str): - ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"), - re.MULTILINE)] - grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)] - return [{"titel": f"Themenblock {n + 1}", "bis": g} - for n, g in enumerate(dict.fromkeys(grenzen))] +def _themen_schnitt(prompt: str): + # konsekutive 2er-Gruppen; Rest hängt an der letzten Gruppe (nie Singleton, + # damit das Größen-Gate im Code deterministisch passiert) + nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "SOLL-PUNKTE:"), + re.MULTILINE)] + gruppen = [nummern[i:i + 2] for i in range(0, len(nummern), 2)] + if len(gruppen) > 1 and len(gruppen[-1]) == 1: + gruppen[-2] += gruppen.pop() + return [{"titel": f"Fake-Thema {n + 1}", "punkte": g} for n, g in enumerate(gruppen)] def _baustein_ordnung(prompt: str): @@ -251,6 +254,14 @@ def _pruefer_urteil(prompt: str): return [{"auftrag": i, "urteil": "behoben"} for i in nummern] +def _klaerung(prompt: str): + # Default „unbegruendet": schließt Konflikte, Fake-E2E terminiert bei Note 10 + nummern = [int(i) for i in re.findall(r"^(\d+): ", + _text_nach(prompt, "ESKALIERTE AUFTRÄGE:"), + re.MULTILINE)] + return [{"auftrag": i, "urteil": "unbegruendet"} for i in nummern] + + def _fix(prompt: str): kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip() lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip() @@ -279,9 +290,10 @@ _HANDLER = { "Artefakt-Fix": _artefakt_fix, "Aussagen-Generate": _aussagen_generate, "Aussagen-Verify": _aussagen_verify, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, - "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, + "Themen-Schnitt": _themen_schnitt, "Level-Kalibrierung": _level_kalibrierung, "Baustein-Ordnung": _baustein_ordnung, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, - "Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Fix": _fix, + "Guide-Pruefer-Urteil": _pruefer_urteil, "Guide-Konflikt-Klaerung": _klaerung, + "Guide-Fix": _fix, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check, } diff --git a/backend/guide.py b/backend/guide.py index bf2c589..de212be 100644 --- a/backend/guide.py +++ b/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,)) diff --git a/backend/main.py b/backend/main.py index 62e037b..3510383 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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,)), diff --git a/backend/pipeline.py b/backend/pipeline.py index 0ed0a1e..553151c 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -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,)) diff --git a/backend/qa.py b/backend/qa.py index b4ac69b..fb23682 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -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, } diff --git a/backend/struktur.py b/backend/struktur.py index 10aa2bb..5bb0094 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -1,16 +1,19 @@ -"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt -(Zuordnung im selben Call — Lektion 52), Baustein-Schnitt = Partition der -Ziel-Gruppen ins Größenband. Reihenfolge = Judge je Level (Dozenten-Blick) -mit Quellpositions-Prior — der braucht-Graph war zu dünn (aak: 77 wirksame -von 385 Kanten) und ordnete faktisch per Zufall; die Maschinerie ist raus.""" +"""Ebene 3: Struktur. Das Soll trägt die Gliederung: ein Judge partitioniert +die Soll-Punkte in THEMEN (= Kapitel), Lernziele entstehen je Soll-Punkt, +Bausteine = Ziel-Gruppen im Größenband (Merge nur INNERHALB eines Themas — +der frühere Quell-Nähe-Merge über Soll-Grenzen mischte 69/73 Bausteine +thematisch). Ein Ziel = EIN Baustein; sein Level ist das dominante Atom-Level +(der frühere Level-Split zerriss Ziele über bis zu 3 Durchgänge). Reihenfolge = +Judge je (Level, Thema)-Segment mit Quellpositions-Prior; Kapitel = (Level, +Thema) deterministisch.""" import logging -from collections import defaultdict +from collections import Counter, defaultdict import db import llm from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, DURCHGANG, - KAPITEL_BAUSTEINE, LEVEL_RANG, ZIELE_CHUNK_ATOME) + LEVEL_RANG, THEMA_SOLL_PUNKTE, ZIELE_CHUNK_ATOME) log = logging.getLogger("creator2.struktur") @@ -22,6 +25,68 @@ def _atome(topic: str) -> list[dict]: " ('gemerged','verworfen') ORDER BY id", (topic,)) +# ── Themen (Soll-Punkte → Kapitel-Gerüst) ──────────────────────────────────── + +async def _themen_bilden(ctx: llm.Kontext) -> None: + """Partition der bestätigten Soll-Punkte in Themen (Titel + Lesefolge). + Skip-Gate: haben alle Punkte ein gültiges Thema, passiert nichts — sonst + würde jede Repair-Runde die Partition neu würfeln und Bausteine, Kapitel + und Sections durchrotieren (Korrektheits-, nicht Spar-Bedingung).""" + topic = ctx.topic + punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'" + " ORDER BY id", (topic,)) + if not punkte: + return + gueltig = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?", (topic,))} + if all(p["thema_id"] in gueltig for p in punkte): + return + db.execute("DELETE FROM themen WHERE topic=?", (topic,)) + liste = "\n".join(f"{i + 1}: {p['punkt']}" for i, p in enumerate(punkte)) + richtwert = max(2, round(len(punkte) / THEMA_SOLL_PUNKTE)) + max_punkte = 2 * THEMA_SOLL_PUNKTE + soll_nummern = list(range(1, len(punkte) + 1)) + + async def schneiden(hinweis: str, tag: str) -> list[dict] | None: + res = await llm.call(ctx, stage="themen", template="Themen-Schnitt", + role="judge", n=len(punkte), item=tag, erwartet=list, + werte={"punkte": liste, "richtwert": str(richtwert), + "max_punkte": str(max_punkte), "hinweis": hinweis}) + gruppen = [] + for g in res or []: + if not isinstance(g, dict) or not str(g.get("titel", "")).strip(): + return None + nummern = [n for n in g.get("punkte", []) if isinstance(n, int)] + gruppen.append({"titel": str(g["titel"]).strip(), "nummern": nummern}) + alle = sorted(n for g in gruppen for n in g["nummern"]) + if alle != soll_nummern: # exakte Partition: nichts fehlt, nichts doppelt + return None + if any(len(g["nummern"]) > max_punkte for g in gruppen): + return None + if len(punkte) >= 4 and sum(1 for g in gruppen if len(g["nummern"]) < 2) > 1: + return None # höchstens EIN Rest-Thema unter 2 Punkten + return gruppen + + gruppen = await schneiden("", "themen") + if gruppen is None: + gruppen = await schneiden( + "ACHTUNG: Der letzte Versuch war ungültig. Jede Nummer der Liste" + f" GENAU EINMAL verwenden (1–{len(punkte)}), jedes Thema braucht" + " einen titel; keine Nummern erfinden.", "themen-2") + art = "judge" + if gruppen is None: # deterministischer Fallback: √n konsekutive Segmente + art = "fallback" + n = max(1, round(len(punkte) ** 0.5)) + groesse = -(-len(punkte) // n) + gruppen = [{"titel": punkte[i * groesse]["punkt"][:120], + "nummern": list(range(i * groesse + 1, + min((i + 1) * groesse, len(punkte)) + 1))} + for i in range(n) if i * groesse < len(punkte)] + for pos, g in enumerate(gruppen): + t_id = db.insert("themen", topic=topic, titel=g["titel"][:120], ord=pos, art=art) + for nummer in g["nummern"]: + db.update("soll", "id", punkte[nummer - 1]["id"], thema_id=t_id) + + # ── Lernziele ───────────────────────────────────────────────────────────────── async def _ziele_bilden(ctx: llm.Kontext) -> None: @@ -100,12 +165,28 @@ def _baustein_rang(bausteine: list[dict], atome: list[dict], return b_rang +def _thema_je_ziel(topic: str) -> dict[int, int | None]: + """ziel_id → thema_id über lernziele.soll_id → soll.thema_id.""" + out = {} + for z in db.query("SELECT z.id, s.thema_id FROM lernziele z" + " LEFT JOIN soll s ON s.id=z.soll_id WHERE z.topic=?", (topic,)): + out[z["id"]] = z["thema_id"] + return out + + +def _dominantes_level(atome: list[dict]) -> str: + """Mehrheits-Level; Gleichstand → das niedrigere (E vor M vor S) — im + Zweifel früh erklären statt spät.""" + zaehler = Counter(a["level"] for a in atome) + return min(zaehler, key=lambda lv: (-zaehler[lv], LEVEL_RANG.get(lv, 9))) + + async def _bausteine_schneiden(ctx: llm.Kontext) -> None: """Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden. - Gruppen sind (Ziel, Level) — je Level entstehen GETRENNTE Bausteine - (E/M/S-Durchgänge). Kleine Gruppen level-intern mergen (das Atom wechselt - sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der - Quellreihenfolge splitten ("Teil n").""" + Gruppen = LERNZIELE (ein Ziel = ein Baustein — kein Level-Split mehr). + Kleine Gruppen mergen NUR innerhalb desselben Themas (Quell-Nähe wählt + den Partner im Thema), große entlang der Quellreihenfolge splitten + ("Teil n"). Baustein-Level = dominantes Atom-Level.""" topic = ctx.topic # Aufträge hängen an Baustein-IDs — beim Neuschnitt mitlöschen, sonst Waisen db.execute("DELETE FROM auftraege WHERE baustein_id IN" @@ -113,51 +194,50 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None: db.execute("DELETE FROM bausteine WHERE topic=?", (topic,)) atome = _atome(topic) rang = _anker_rang(topic) + thema = _thema_je_ziel(topic) gruppen: dict = defaultdict(list) for a in atome: - gruppen[(a["ziel_id"], a["level"])].append(a) + gruppen[a["ziel_id"]].append(a) - # kleine Gruppen mergen (kleinste zuerst), nur innerhalb des Levels. - # Partnerwahl über Quell-Nähe — das Soll ist seit der Entkopplung reine - # Checkliste (152 feine Punkte hätten sonst keine Merge-Partner). def gruppen_rang(g: list[dict]) -> tuple: return min(rang[a["id"]] for a in g) + # kleine Gruppen mergen (kleinste zuerst), NUR im selben Thema — die + # Themen-Schranke ersetzt die frühere Level-Schranke; Quell-Nähe bleibt + # als Partnerwahl INNERHALB des Themas (dort ist sie harmlos). geaendert = True while geaendert: geaendert = False kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME], key=lambda k: len(gruppen[k])) for k in kleine: - # nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der - # Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine - # kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen, - # den messen dann als unreparierbaren band-Befund meldete. - passende = [p for p in gruppen if p != k and p[1] == k[1]] + # keine MAX-Schranke: der Split zerlegt große Gruppen bandkonform; + # sonst bliebe eine Kleingruppe ohne Partner als unreparierbarer + # band-Befund liegen (Lektion 75) + passende = [p for p in gruppen if p != k and thema.get(p) == thema.get(k)] if not passende: continue eigener = gruppen_rang(gruppen[k]) - def naehe(pp: tuple) -> tuple: + def naehe(pp) -> tuple: r = gruppen_rang(gruppen[pp]) return ((abs(r[0] - eigener[0]), abs(r[1] - eigener[1])), len(gruppen[pp])) p = min(passende, key=naehe) for a in gruppen[k]: - db.update("atome", "id", a["id"], ziel_id=p[0]) + db.update("atome", "id", a["id"], ziel_id=p) gruppen[p] += gruppen.pop(k) - # Ziel erst still legen, wenn KEIN Level mehr darauf zeigt - if not any(kk[0] == k[0] for kk in gruppen): - db.update("lernziele", "id", k[0], status="gemerged") + db.update("lernziele", "id", k, status="gemerged") geaendert = True break # Bausteine anlegen (große Gruppen splitten), Atome zuordnen ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} - for (ziel_id, level), gruppe in gruppen.items(): + for ziel_id, gruppe in gruppen.items(): ordnung = sorted((a["id"] for a in gruppe), key=lambda i: rang[i]) + je_id = {a["id"]: a for a in gruppe} z = ziele.get(ziel_id, {}) titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME) @@ -174,7 +254,9 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None: continue b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})" b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel, - status="neu", level=level) + status="neu", + level=_dominantes_level([je_id[i] for i in teil_ids]), + thema_id=thema.get(ziel_id)) for pos, atom_id in enumerate(teil_ids): db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos) @@ -182,50 +264,61 @@ async def _bausteine_schneiden(ctx: llm.Kontext) -> None: async def _bausteine_ordnen(ctx: llm.Kontext) -> None: - """Didaktische Ordnung je Level per Judge (Dozenten-Blick); ord läuft global - fortlaufend E→M→S (eine Achse — Vorwärts-Logik und Kapitel-Kontiguität - bleiben level-blind korrekt). Eingabe ist nach Quellpositions-Prior - vorsortiert (das Skript IST vom Dozenten geordnet); der Code erzwingt eine - exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund - (ordnung_fallback, Muster wie kapitel_fallback).""" + """Didaktische Feinordnung je (Level, Thema)-Segment per Judge; ord läuft + global fortlaufend über (Level-Rang, Thema-ord, Feinordnung) — eine Achse, + Vorwärts-Logik und Kapitel-Kontiguität bleiben konstruktiv korrekt. + Eingabe je Segment nach Quellpositions-Prior vorsortiert; der Code erzwingt + eine exakte Permutation — ungültig → 1 Retry → Prior-Ordnung mit Befund + (ordnung_fallback). 1er-Segmente brauchen keinen Call.""" topic = ctx.topic bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) atome = _atome(topic) b_rang = _baustein_rang(bausteine, atome, _anker_rang(topic)) ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} + themen = db.query("SELECT * FROM themen WHERE topic=? ORDER BY ord", (topic,)) + # Rest-Segment je Level für thema_id NULL (defensiv: Testdaten, Altbestand) + segmente = [(t["id"], t["titel"]) for t in themen] + [(None, "")] offset = 0 for level in LEVEL_RANG: - folge = sorted((b for b in bausteine if b["level"] == level), - key=lambda b: b_rang[b["id"]]) - if not folge: - continue - liste = "\n".join(f"{i + 1}: {b['titel']} — " - f"{ziele.get(b['ziel_id'], {}).get('text', '')}" - for i, b in enumerate(folge)) - soll = list(range(1, len(folge) + 1)) + for thema_id, thema_titel in segmente: + folge = sorted((b for b in bausteine + if b["level"] == level and b["thema_id"] == thema_id), + key=lambda b: b_rang[b["id"]]) + if not folge: + continue + if len(folge) == 1: # nichts zu ordnen — kein Call, kein Schein-Fallback + db.update("bausteine", "id", folge[0]["id"], ord=offset, ordnung="judge") + offset += 1 + continue + liste = "\n".join(f"{i + 1}: {b['titel']} — " + f"{ziele.get(b['ziel_id'], {}).get('text', '')}" + for i, b in enumerate(folge)) + soll = list(range(1, len(folge) + 1)) + tag_basis = f"ordnung-{level}-t{thema_id or 0}" - async def ordnen(hinweis: str, tag: str) -> list[int] | None: - res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung", - role="judge", n=len(folge), item=tag, erwartet=list, - werte={"bausteine": liste, "hinweis": hinweis, - "durchgang": DURCHGANG[level]}) - perm = [x for x in res or [] if isinstance(x, int)] - return perm if sorted(perm) == soll else None + async def ordnen(hinweis: str, tag: str) -> list[int] | None: + res = await llm.call(ctx, stage="ordnung", template="Baustein-Ordnung", + role="judge", n=len(folge), item=tag, erwartet=list, + werte={"bausteine": liste, "hinweis": hinweis, + "thema": thema_titel or ctx.topic, + "durchgang": DURCHGANG[level]}) + perm = [x for x in res or [] if isinstance(x, int)] + return perm if sorted(perm) == soll else None - perm = await ordnen("", f"ordnung-{level}") - if perm is None: - perm = await ordnen( - "ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU" - f" den Nummern 1–{len(folge)}, jede genau einmal.", - f"ordnung-{level}-2") - art = "judge" - if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund - art = "fallback" - perm = soll - for pos, nr in enumerate(perm): - db.update("bausteine", "id", folge[nr - 1]["id"], - ord=offset + pos, ordnung=art) - offset += len(folge) + perm = await ordnen("", tag_basis) + if perm is None: + perm = await ordnen( + "ACHTUNG: Der letzte Versuch war ungültig. Antworte mit GENAU" + f" den Nummern 1–{len(folge)}, jede genau einmal.", + f"{tag_basis}-2") + art = "judge" + if perm is None: # Prior-Ordnung ist der ehrliche Fallback — mit Befund + art = "fallback" + perm = soll + for pos, nr in enumerate(perm): + db.update("bausteine", "id", folge[nr - 1]["id"], + ord=offset + pos, ordnung=art) + offset += len(folge) async def _level_kalibrieren(ctx: llm.Kontext) -> None: @@ -265,94 +358,37 @@ async def _level_kalibrieren(ctx: llm.Kontext) -> None: await llm.alle(einer(z, g) for z, g in gruppen.items()) -async def _kapitel_bilden(ctx: llm.Kontext) -> None: - """Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge, JE - DURCHGANG (Level) geschnitten; kapitel.ord läuft global E→M→S weiter. Das - Soll ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der - Judge liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er - unvollständig (aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). - Der Code erzwingt Reihenfolge, Lückenlosigkeit UND Größe (≥2 Bausteine außer - im letzten, ≤2×Richtwert-Band — Lernen: 68/73 Singleton-Kapitel); ungültig - bekommt EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback).""" - topic = ctx.topic +def _kapitel_bilden(topic: str) -> None: + """Kapitel = (Level, Thema)-Segment der geordneten Baustein-Folge — + deterministisch, KEIN Judge. Kontiguität ist durch die globale Sortierung + (Level-Rang, Thema-ord, Feinordnung) konstruktiv garantiert; Titel = Thema- + Titel. Der frühere Grenz-Judge samt Retry/√n-Fallback/Größen-Gate entfällt.""" alle = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) if not alle: return - ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))} + themen = {t["id"]: t for t in db.query("SELECT * FROM themen WHERE topic=?", (topic,))} db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) - pos_global = 0 - for level in LEVEL_RANG: - bausteine = [b for b in alle if b["level"] == level] - if not bausteine: - continue - # Positionsnummern statt DB-IDs: nach dem Level-Split sind die IDs - # nicht mehr monoton entlang der Lesefolge — der Judge verlor die - # Reihenfolge und lieferte 0 gültige Schnitte (aak Run 46: 15/15 Fallback) - liste = "\n".join(f"{i + 1}: {b['titel']} — " - f"{ziele.get(b['ziel_id'], {}).get('text', '')}" - for i, b in enumerate(bausteine)) - richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE)) - max_groesse = 2 * KAPITEL_BAUSTEINE - folge = [b["id"] for b in bausteine] - pos_von = {i + 1: i for i in range(len(folge))} - - async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None: - res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt", - schritt="kapitel", role="judge", n=len(bausteine), - item=tag, erwartet=list, - werte={"bausteine": liste, "richtwert": str(richtwert), - "durchgang": DURCHGANG[level], "hinweis": hinweis}) - segmente: list[tuple[str, list[int]]] = [] - start = 0 - for gruppe in res or []: - if not isinstance(gruppe, dict): - return None - titel = str(gruppe.get("titel", "")).strip() - ende = pos_von.get(gruppe.get("bis")) - if not titel or ende is None or ende < start: - return None - segmente.append((titel, folge[start:ende + 1])) - start = ende + 1 - if not segmente or start != len(folge): - return None - for i, (_, ids) in enumerate(segmente): # Größen-Gate - if len(ids) > max_groesse: - return None - if len(ids) < 2 and len(folge) > 1 and i < len(segmente) - 1: - return None - return segmente - - segmente = await schneiden("", f"kapitel-{level}") - if segmente is None: - segmente = await schneiden( - "ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht" - " \"titel\" und \"bis\" (eine NUMMER aus der Liste, streng" - " aufsteigend); das letzte \"bis\" MUSS die letzte Nummer der Liste" - f" sein. Jedes Kapitel bündelt 2–{max_groesse} Bausteine (Richtwert" - f" {richtwert} Kapitel); nur das letzte darf kleiner sein.", - f"kapitel-{level}-2") - art = "judge" - if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente - art = "fallback" - je_id = {b["id"]: b for b in bausteine} - n = max(1, round(len(folge) ** 0.5)) - groesse = -(-len(folge) // n) - segmente = [(je_id[teil[0]]["titel"], teil) - for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])] - for titel, ids in segmente: - k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos_global, - art=art, level=level) - pos_global += 1 - for b_id in ids: - db.update("bausteine", "id", b_id, kapitel_id=k_id) + pos = 0 + letzter_key = None + k_id = None + for b in alle: + key = (b["level"], b["thema_id"]) + if key != letzter_key: + titel = themen.get(b["thema_id"], {}).get("titel") or b["titel"] + k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, + art="det", level=b["level"]) + pos += 1 + letzter_key = key + db.update("bausteine", "id", b["id"], kapitel_id=k_id) async def bauen(ctx: llm.Kontext) -> None: ctx.ebene = EBENE + await _themen_bilden(ctx) # Soll-Punkte → Themen (Kapitel-Gerüst) await _ziele_bilden(ctx) - await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt die Bausteine + await _level_kalibrieren(ctx) # VOR dem Schnitt: Level bestimmt Dominanz await _bausteine_schneiden(ctx) - await _kapitel_bilden(ctx) + _kapitel_bilden(ctx.topic) def messen(ctx: llm.Kontext) -> list[dict]: @@ -361,25 +397,41 @@ def messen(ctx: llm.Kontext) -> list[dict]: for a in atome: if not a["ziel_id"] or not a["baustein_id"]: befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]}) + # Themen-Partition: jeder bestätigte Soll-Punkt braucht ein gültiges Thema + themen_ids = {t["id"] for t in db.query("SELECT id FROM themen WHERE topic=?", + (ctx.topic,))} + for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", + (ctx.topic,)): + if p["thema_id"] not in themen_ids: + befunde.append({"art": "thema_partition", "item": str(p["id"]), + "detail": p["punkt"]}) + for t in db.query("SELECT * FROM themen WHERE topic=? AND art='fallback'", + (ctx.topic,)): # stiller Fallback wäre unsichtbarer Verlust + befunde.append({"art": "themen_fallback", "item": str(t["id"]), + "detail": t["titel"]}) bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,)) je_baustein: dict = defaultdict(list) for a in atome: je_baustein[a["baustein_id"]].append(a) + soll_thema = {p["id"]: p["thema_id"] for p in + db.query("SELECT id, thema_id FROM soll WHERE topic=?", (ctx.topic,))} # Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches - # Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und - # re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine - # einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75). + # THEMA, anderer Baustein) — spiegelgleich zur Merge-Schranke, damit die + # Messlatte erfüllbar bleibt (Lektion 75). for b in bausteine: n = len(je_baustein[b["id"]]) - partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] for bb in bausteine) + partner = any(bb["id"] != b["id"] and bb["thema_id"] == b["thema_id"] + for bb in bausteine) if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner): befunde.append({"art": "band", "item": str(b["id"]), "detail": f"{b['titel']}: {n} Atome"}) - for a in je_baustein[b["id"]]: # ein Baustein = genau ein Level - if a["level"] != b["level"]: - befunde.append({"art": "level_mix", "item": str(a["id"]), - "detail": f"Atom {a['level']} in Baustein {b['level']}" - f" ({b['titel']})"}) + # Themenreinheit: die Soll-Punkte aller Atome gehören zum Baustein-Thema. + # Atome ohne soll_id überspringen — deren Befund gehört zur Inventar-Ebene. + for a in je_baustein[b["id"]]: + if a["soll_id"] and soll_thema.get(a["soll_id"]) != b["thema_id"]: + befunde.append({"art": "baustein_thema_mix", "item": str(a["id"]), + "detail": f"Atom {a['titel'][:60]} fremdes Thema" + f" in {b['titel']}"}) kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,)) kap_ids = {k["id"] for k in kaps} kap_level = {k["id"]: k["level"] for k in kaps} @@ -392,30 +444,18 @@ def messen(ctx: llm.Kontext) -> list[dict]: "detail": f"{b['titel']} ({b['level']}) in Kapitel" f" {kap_level.get(b['kapitel_id'])}"}) belegt = {b["kapitel_id"] for b in bausteine} - je_kapitel: dict = defaultdict(int) - je_level: dict = defaultdict(int) - for b in bausteine: - je_kapitel[b["kapitel_id"]] += 1 - je_level[b["level"]] += 1 for k in kaps: if k["id"] not in belegt: befunde.append({"art": "kapitel_leer", "item": str(k["id"]), "detail": k["titel"]}) - if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust - befunde.append({"art": "kapitel_fallback", "item": str(k["id"]), - "detail": k["titel"]}) - # Singleton-Kapitel zerstückeln die Navigation (Lernen: 68/73) — erlaubt - # nur, wenn der ganze Durchgang bloß einen Baustein hat. - if je_kapitel[k["id"]] == 1 and je_level[k["level"]] > 1: - letzte = max((kk["ord"] for kk in kaps if kk["level"] == k["level"])) - if k["ord"] != letzte: - befunde.append({"art": "kapitel_zerstueckelt", "item": str(k["id"]), - "detail": k["titel"]}) - # stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (wie kapitel_fallback) - for lv in sorted({b["level"] for b in bausteine if b["ordnung"] == "fallback"}): - befunde.append({"art": "ordnung_fallback", "item": lv, - "detail": f"Durchgang {lv}: Judge lieferte keine gültige" - f" Permutation — Quellreihenfolge übernommen"}) + # stiller Ordnungs-Fallback wäre unsichtbarer Qualitätsverlust (je Segment) + segmente = sorted({(b["level"], b["thema_id"]) for b in bausteine + if b["ordnung"] == "fallback"}, + key=lambda s: (LEVEL_RANG.get(s[0], 9), s[1] or 0)) + for level, thema_id in segmente: + befunde.append({"art": "ordnung_fallback", "item": f"{level}/t{thema_id or 0}", + "detail": f"Segment {level}/{thema_id}: Judge lieferte keine" + f" gültige Permutation — Quellreihenfolge übernommen"}) return befunde @@ -423,8 +463,5 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: ctx.ebene = EBENE if not befunde: return False - await _ziele_bilden(ctx) # fängt Partition-Lücken - await _level_kalibrieren(ctx) - await _bausteine_schneiden(ctx) # Neu-Schnitt fängt Band, level_mix, ordnung_fallback - await _kapitel_bilden(ctx) # fängt Kapitel-Befunde + await bauen(ctx) # Kette ist idempotent: Themen skip-gated, Rest Neu-Schnitt return True diff --git a/backend/transfer.py b/backend/transfer.py index a5f674c..2ad6a23 100644 --- a/backend/transfer.py +++ b/backend/transfer.py @@ -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"}, diff --git a/templates/Artefakt-Fix.md b/templates/Artefakt-Fix.md index 15e9212..f33b0c0 100644 --- a/templates/Artefakt-Fix.md +++ b/templates/Artefakt-Fix.md @@ -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: diff --git a/templates/Artefakt-Generate.md b/templates/Artefakt-Generate.md index 351ad88..9be6d8f 100644 --- a/templates/Artefakt-Generate.md +++ b/templates/Artefakt-Generate.md @@ -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, diff --git a/templates/Artefakt-Verify.md b/templates/Artefakt-Verify.md index cc2ac4d..a2549fa 100644 --- a/templates/Artefakt-Verify.md +++ b/templates/Artefakt-Verify.md @@ -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). diff --git a/templates/Baustein-Ordnung.md b/templates/Baustein-Ordnung.md index 7b33051..26bd56f 100644 --- a/templates/Baustein-Ordnung.md +++ b/templates/Baustein-Ordnung.md @@ -1,5 +1,6 @@ Du bist Dozent und legst die Lehr-Reihenfolge dieser Lernbausteine fest. +Thema: {thema} Durchgang: {durchgang} BAUSTEINE (aktuell in der Reihenfolge des Quellmaterials): diff --git a/templates/Guide-Fix.md b/templates/Guide-Fix.md index 8441b05..3813f23 100644 --- a/templates/Guide-Fix.md +++ b/templates/Guide-Fix.md @@ -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): diff --git a/templates/Guide-Konflikt-Klaerung.md b/templates/Guide-Konflikt-Klaerung.md new file mode 100644 index 0000000..cfdd771 --- /dev/null +++ b/templates/Guide-Konflikt-Klaerung.md @@ -0,0 +1,29 @@ + +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"}}] diff --git a/templates/Kapitel-Schnitt.md b/templates/Kapitel-Schnitt.md deleted file mode 100644 index 5fd1614..0000000 --- a/templates/Kapitel-Schnitt.md +++ /dev/null @@ -1,22 +0,0 @@ - -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}}] diff --git a/templates/Themen-Schnitt.md b/templates/Themen-Schnitt.md new file mode 100644 index 0000000..9f12dee --- /dev/null +++ b/templates/Themen-Schnitt.md @@ -0,0 +1,22 @@ + +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]}}] diff --git a/tests/test_auftraege.py b/tests/test_auftraege.py index 492b065..93ff9e7 100644 --- a/tests/test_auftraege.py +++ b/tests/test_auftraege.py @@ -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"]) == [] diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index 04fead0..0df404d 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -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): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 658bbf4..b993e02 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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