From 32d7ad9ea17cf0494454d4d4b4fd347b4ba791b7 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 13 Jul 2026 06:07:09 +0200 Subject: [PATCH] update --- backend/config.py | 2 + backend/db.py | 41 ++++++ backend/fake_agents.py | 9 +- backend/guide.py | 237 ++++++++++++++++++++---------- backend/korpus.py | 32 +++- backend/pipeline.py | 4 + backend/qa.py | 2 +- backend/struktur.py | 3 + backend/transfer.py | 24 ++- templates/Guide-Pruefer-Urteil.md | 28 ++++ templates/Guide-Pruefer.md | 3 + tests/test_auftraege.py | 186 +++++++++++++++++++++++ tests/test_bausteine.py | 30 ++-- tests/test_korpus.py | 30 ++++ tests/test_transfer.py | 36 +++-- 15 files changed, 552 insertions(+), 115 deletions(-) create mode 100644 templates/Guide-Pruefer-Urteil.md create mode 100644 tests/test_auftraege.py diff --git a/backend/config.py b/backend/config.py index c810357..d7df394 100644 --- a/backend/config.py +++ b/backend/config.py @@ -122,6 +122,7 @@ SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom # ── Auto-Loop ───────────────────────────────────────────────────────────────── 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 # ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ───────────────── TIMEOUTS = { @@ -144,6 +145,7 @@ TIMEOUTS = { "zyklus": (150, 0), "writer": (450, 60), "pruefer": (600, 5), + "pruefer_urteil": (300, 5), "qa_judge": (600, 0), } diff --git a/backend/db.py b/backend/db.py index 73fe9fa..e9740e0 100644 --- a/backend/db.py +++ b/backend/db.py @@ -5,6 +5,7 @@ Synchronen Zugriff bewusst: DB-Arbeit ist gegen LLM-Latenz vernachlässigbar. `on_change` (von main.py gesetzt) meldet Statuswechsel ans Live-Board.""" import json +import re import sqlite3 import threading from pathlib import Path @@ -112,6 +113,14 @@ CREATE TABLE IF NOT EXISTS lernstand( topic TEXT NOT NULL, baustein_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'aktiv', xp INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(topic, baustein_id)); +CREATE TABLE IF NOT EXISTS auftraege( + id INTEGER PRIMARY KEY, baustein_id INTEGER NOT NULL, + art TEXT NOT NULL CHECK(art IN ('falsch','luecke','stil')), + 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); +CREATE INDEX IF NOT EXISTS idx_auftraege ON auftraege(baustein_id, status); """ # Tabellen, deren Änderungen das Live-Board interessieren. @@ -160,9 +169,41 @@ def _init_schema(con: sqlite3.Connection) -> None: "INSERT INTO artefakte SELECT * FROM artefakte_alt;" "DROP TABLE artefakte_alt;" "CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);") + # Auftrags-Migration: Freitext-JSON in sections.befunde → auftraege-Zeilen. + # Nur KRITISCH-Arten überleben — det-/Stil-Aufträge werden ohnehin jede Runde + # frisch berechnet, und die Prüfer-Paraphrasen waren genau die Krankheit. + if any(sp[1] == "befunde" for sp in con.execute("PRAGMA table_info(sections)")): + for row in con.execute("SELECT baustein_id, befunde FROM sections" + " WHERE befunde NOT IN ('', '[]')").fetchall(): + try: + alte = json.loads(row[1]) + except ValueError: + alte = [] + for a in alte: + p = alt_auftrag(a) + if p and not con.execute( + "SELECT 1 FROM auftraege WHERE baustein_id=? AND detail=?", + (row[0], p[1])).fetchone(): + con.execute("INSERT INTO auftraege(baustein_id, art, detail, quelle)" + " VALUES(?,?,?,?)", (row[0], *p)) + con.execute("ALTER TABLE sections DROP COLUMN befunde") con.commit() +_ALT_AUFTRAG = re.compile(r"^KRITISCH \((falsch|luecke|fachlich_falsch)\): (.+)$", re.S) + + +def alt_auftrag(text) -> tuple[str, str, str] | None: + """Alt-Format aus sections.befunde → (art, detail, quelle) oder None (verwerfen). + Auch der Transfer-Import alter Exporte nutzt dies.""" + m = _ALT_AUFTRAG.match(str(text)) + if not m: + return None + art, detail = m.group(1), m.group(2).strip() + return ("falsch" if art == "fachlich_falsch" else art, detail, + "qa" if art == "fachlich_falsch" else "pruefer") + + def _notify(tabelle: str, row: dict) -> None: if on_change is not None and tabelle in _LIVE_TABELLEN: try: diff --git a/backend/fake_agents.py b/backend/fake_agents.py index da3434e..f0edde2 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -268,6 +268,12 @@ def _pruefer(prompt: str): return {"befunde": []} +def _pruefer_urteil(prompt: str): + nummern = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "OFFENE AUFTRÄGE:"), + re.MULTILINE)] + return [{"auftrag": i, "urteil": "behoben"} 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() @@ -298,6 +304,7 @@ _HANDLER = { "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, "Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, - "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix, + "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, + "Guide-Pruefer-Urteil": _pruefer_urteil, "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 8350a97..71ed4f5 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -19,7 +19,8 @@ import jsonx import llm import belege import textkit -from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM +from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, + SECTION_WOERTER_PRO_ATOM) log = logging.getLogger("creator2.guide") @@ -457,40 +458,105 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]: return treffer -async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str: - sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) +def _offene_auftraege(b_id: int) -> list[dict]: + return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='offen'" + " 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) + + +async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict, + offene: list[dict], tag: str = "") -> None: + """Richter über bestehende Aufträge — Wortlaut bleibt eingefroren, damit + Paraphrasen-Dubletten unmöglich sind. Abschluss (behoben/kein_mangel/ + faktenbasis) nur einstimmig im 2er-Panel (destruktiv — Lektion 20/67); + sonst offen + Runde gezählt, am Runden-Cap → eskaliert (terminiert immer).""" atome = _atome_von(b["id"]) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} - auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"]) - # bereits offene KRITISCH-Aufträge übernehmen (z. B. ein aus der Ebenen-QA - # bestätigter fachlich_falsch-Claim) — der In-Card-Prüfer findet sie nicht - # immer selbst wieder, ohne dies verpuffte der Befund im Ping-Pong. - for alt in db.uj(sec["befunde"]): - if str(alt).startswith("KRITISCH") and alt not in auftraege: - auftraege.append(alt) - fakten = "\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) - res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer", + liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(offene, 1)) + stimmen = await llm.panel(ctx, 2, stage="pruefer_urteil", template="Guide-Pruefer-Urteil", + schritt="pruefer", role="judge", n=len(offene), + item=f"b{b['id']}{tag}-urteil", + werte={"ziel": ziel["text"], "fakten": _fakten_von(atome), + "kompakt": sec["text_kompakt"], "lang": sec["text_lang"], + "auftraege": liste}, + erwartet=list) + if len(stimmen) < 2: + return # Panel unvollständig → nichts ändern (fail-closed, Lektion 20) + je: dict[int, list[str]] = {} + for s in stimmen: + for e in s: + if isinstance(e, dict): + try: + je.setdefault(int(e.get("auftrag")), []).append( + str(e.get("urteil", "")).strip()) + except (TypeError, ValueError): + continue + for i, a in enumerate(offene, 1): + urteile = je.get(i, []) + einstimmig = len(urteile) >= 2 and len(set(urteile)) == 1 + if einstimmig and urteile[0] in ("behoben", "kein_mangel"): + db.update("auftraege", "id", a["id"], status=urteile[0]) + elif einstimmig and urteile[0] == "faktenbasis": + db.update("auftraege", "id", a["id"], status="eskaliert") + elif a["runden"] + 1 >= AUFTRAG_RUNDEN_MAX: + db.update("auftraege", "id", a["id"], status="eskaliert") + else: + db.update("auftraege", "id", a["id"], runden=a["runden"] + 1) + + +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 + — sonst aufersteht ein totgestimmter Fehlalarm als frische Zeile.""" + atome = _atome_von(b["id"]) + ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} + verworfen = db.query("SELECT detail FROM auftraege WHERE baustein_id=?" + " AND status IN ('kein_mangel','eskaliert')", (b["id"],)) + res = await llm.call(ctx, stage="pruefer", template="Guide-Pruefer", schritt="pruefer", role="judge", n=len(atome), - item=f"b{b['id']}{tag}", - werte={"ziel": ziel["text"], "fakten": fakten, - "kompakt": sec["text_kompakt"], "lang": sec["text_lang"]}, + item=f"b{b['id']}", + werte={"ziel": ziel["text"], "fakten": _fakten_von(atome), + "kompakt": sec["text_kompakt"], "lang": sec["text_lang"], + "geprueft": "\n".join(f"- {v['detail']}" for v in verworfen) + or "(keine)"}, erwartet=dict) + bekannte = {a["detail"] for a in db.query( + "SELECT detail FROM auftraege WHERE baustein_id=? AND status != 'behoben'", + (b["id"],))} for e in (res or {}).get("befunde", []): art = str(e.get("art", "")).strip() detail = str(e.get("detail", "")).strip() - if art in ("falsch", "luecke"): - auftraege.append(f"KRITISCH ({art}): {detail}") - elif art == "stil" and detail: - auftraege.append(detail) - auftraege = list(dict.fromkeys(auftraege)) # Duplikate (übernommene + neue) raus - db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege)) - return "fix" if auftraege else "done" + if art in ("falsch", "luecke", "stil") and detail and detail not in bekannte: + db.insert("auftraege", baustein_id=b["id"], art=art, detail=detail) + bekannte.add(detail) + + +async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str: + sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) + offene = _offene_auftraege(b["id"]) + if offene: + await _auftraege_urteilen(ctx, b, sec, offene, tag) + offene = _offene_auftraege(b["id"]) + # Fund erst bei leerer Liste — Wachstum ist damit hart gedeckelt. Der Re-Check + # nach einem Fix urteilt nur (Rest fängt die Ebenen-QA, wie bisher). + if not offene and tag != "-re": + await _auftraege_finden(ctx, b, sec) + offene = _offene_auftraege(b["id"]) + det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"]) + return "fix" if offene or det else "done" async def _stage_fix(ctx: llm.Kontext, b: dict) -> str: sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) - auftraege = db.uj(sec["befunde"]) + 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] if not auftraege: return "done" atome = _atome_von(b["id"]) @@ -501,17 +567,22 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str: "auftraege": "\n".join(f"- {a}" for a in auftraege), "fakten": fakten}, erwartet=str) - kritisch = any(a.startswith("KRITISCH") for a in auftraege) + kritisch = any(a["art"] in ("falsch", "luecke") for a in offene) if res: kompakt, lang = _split_writer(res) if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen db.update("sections", "baustein_id", b["id"], - text_kompakt=kompakt, text_lang=lang, befunde=db.j([])) + text_kompakt=kompakt, text_lang=lang) + # stil: genau ein Rewrite-Versuch, dann optimistisch zu — Rückfälle + # fangen det-Checks/Fund. falsch/luecke schließt NUR das Re-Check-Urteil. + for a in offene: + if a["art"] == "stil": + db.update("auftraege", "id", a["id"], status="behoben") if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA await _stage_pruefer(ctx, b, tag="-re") return "done" - # Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen: + # Fix fehlgeschlagen → Original behalten, Aufträge bleiben offen: # unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60 # offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen. return "done" @@ -655,11 +726,17 @@ def messen(ctx: llm.Kontext) -> list[dict]: for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True): befunde.append({"art": "det_check", "item": str(b["id"]), "detail": auftrag[:300]}) - # Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht - # geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate. - for auftrag in db.uj(sec["befunde"]): - befunde.append({"art": "fix_offen", "item": str(b["id"]), - "detail": str(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). + 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"]), + "detail": a["detail"][:300]}) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) if ziel: noetig = _stopfrei(ziel["text"]) @@ -678,6 +755,18 @@ def messen(ctx: llm.Kontext) -> list[dict]: return befunde +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).""" + 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": + db.update("auftraege", "id", alt["id"], status="eskaliert") + + async def messen_llm(ctx: llm.Kontext) -> list[dict]: """Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger — nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections @@ -686,12 +775,6 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: import hashlib ctx.ebene = EBENE befunde = [] - # topic-scoped (nicht run-scoped): ein in Run N bestätigter Fakten-Fehler bleibt - # nach Pause/Resume (Run N+1) sichtbar, sonst greift der qa_hash-Skip fälschlich. - offene_falsch = {b["item"] for b in db.query( - "SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id" - " WHERE r.topic=? AND b.ebene=? AND b.art='fachlich_falsch' AND b.status='offen'", - (ctx.topic, EBENE))} def hash_setzen(bid: int, h: str) -> None: db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid)) @@ -701,8 +784,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: if not sec or not sec["text_lang"]: return h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12] - if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch: - return # unverändert und sauber — kein Re-Check + if h == sec["qa_hash"]: + return # unverändert und sauber geprüft — die auftraege-Tabelle ist + # das persistente Gedächtnis bestätigter Claims (kein Re-Check nötig) # qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall # bleibt die Section ungeprüft (fail-closed statt fail-open). atome = _atome_von(b["id"]) @@ -734,6 +818,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: if ja >= 2: befunde.append({"art": "fachlich_falsch", "item": str(b["id"]), "detail": claim[:200]}) + # sofort persistieren (resume-sicher: nur DIESER Moment kennt den + # bestätigten Claim; qa_hash ist danach gesetzt → nie re-geprüft) + _qa_auftrag(b["id"], claim) hash_setzen(b["id"], h) # vollständig geprüft await llm.alle(eine(b) for b in _bausteine(ctx.topic)) @@ -749,14 +836,28 @@ def _text_sig(b_id: int) -> str: return hashlib.sha256(roh.encode()).hexdigest()[:16] +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).""" + 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})", + tuple(b_ids))} + + async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: - """Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check); - Stil-Befunde → direkt in den Fix (genau ein Rewrite). Rückgabe ist ehrlich: - True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt - auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch.""" + """Kritisches + offene Aufträge → zurück auf pruefer (Urteil/Re-Check mit + vollem Kontext); reine det-Stil-Befunde → direkt in den Fix (genau ein + Rewrite). Rückgabe ist ehrlich: True nur bei Textänderung ODER Auftrag- + Statusübergang — sonst erkennt auto_loop nie Stillstand.""" ctx.ebene = EBENE betroffen: dict[int, list[dict]] = {} for b in befunde: + if b["art"] == "fakten_konflikt": + continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen try: b_id = int(b["item"]) except (ValueError, TypeError): @@ -766,46 +867,28 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: return False geroutet: list[int] = [] for b_id, liste in betroffen.items(): - arten = [x["art"] for x in liste] - # ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch - kritisch_detail = any(x["art"] == "fix_offen" - and str(x["detail"]).startswith("KRITISCH") for x in liste) - kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail - if not kritisch: - # Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird - # eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts- - # Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn. + arten = {x["art"] for x in liste} + if "section_fehlt" in arten: + db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,)) + elif arten & ({"auftrag_offen"} | set(KRITISCH)): + # Urteile sind der Fortschrittsmechanismus — der Fix-Freeze gilt hier + # NIE, sonst wären Aufträge gefrorener Sections wieder unsterblich. + db.update("sections", "baustein_id", b_id, stage="pruefer") + else: + # Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird für + # det-Stil-Befunde eingefroren. Rest-Schuld (Gewicht 0.5) akzeptiert. sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,)) if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE: continue - if kritisch: - if "section_fehlt" in arten: - db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,)) - else: - # KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie - # sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix) - vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", - (b_id,))["befunde"]) - neu = [f"KRITISCH ({x['art']}): {x['detail']}" for x in liste - if x["art"] in KRITISCH] - zusammen = list(dict.fromkeys(vorhanden + neu)) - db.update("sections", "baustein_id", b_id, stage="pruefer", - befunde=db.j(zusammen)) - else: - # bestehende Fix-Aufträge (fix_offen) behalten, Stil-Details anhängen - vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", - (b_id,))["befunde"]) - neu = [f"Behebe ({x['art']}): {x['detail']}" for x in liste - if x["art"] != "fix_offen"] - auftraege = list(dict.fromkeys(vorhanden + neu)) - db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege)) + db.update("sections", "baustein_id", b_id, stage="fix") db.update("bausteine", "id", b_id, status="repair") geroutet.append(b_id) - vorher = {b_id: _text_sig(b_id) for b_id in geroutet} + text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet} + stand_vorher = _auftrag_stand(geroutet) await bauen(ctx) - bewegt = False + bewegt = _auftrag_stand(geroutet) != stand_vorher for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1 - if _text_sig(b_id) != vorher[b_id]: + if _text_sig(b_id) != text_vorher[b_id]: db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,)) bewegt = True else: diff --git a/backend/korpus.py b/backend/korpus.py index e47c3a9..475edd9 100644 --- a/backend/korpus.py +++ b/backend/korpus.py @@ -83,8 +83,25 @@ def _snapshot_schreiben(topic: str, inhalt: str) -> tuple[str, str] | None: return str(pfad), h +# LaTeX-Quellen: Umlaut-Escapes am Import auflösen — Modelle zitieren „ü", nie \"u, +# sonst scheitert der verbatim-Anker. Mathe und Makros bleiben unangetastet. +_TEX_UMLAUT = re.compile(r'\\"\{?([AOUaou])\}?') +_TEX_UMLAUTE = {"a": "ä", "o": "ö", "u": "ü", "A": "Ä", "O": "Ö", "U": "Ü"} +# \ss ist ein Kontrollwort und frisst das Folge-Leerzeichen: hei\ss t → heißt +_TEX_SZ = re.compile(r"\\ss(?![a-zA-Z])(?:\{\})?[ \t]?") + + +def _tex_normalisieren(inhalt: str) -> str: + _, sep, rumpf = inhalt.partition(r"\begin{document}") + if sep: + inhalt = rumpf + inhalt = inhalt.replace(r"\end{document}", "") + inhalt = _TEX_UMLAUT.sub(lambda m: _TEX_UMLAUTE[m.group(1)], inhalt) + return _TEX_SZ.sub("ß", inhalt) + + def _datei_lesen(pfad: Path) -> str: - """txt/md direkt; PDF via pdftotext (treu, strukturarm — Lektion 38).""" + """txt/md direkt; tex normalisiert; PDF via pdftotext (treu, strukturarm — Lektion 38).""" if pfad.suffix.lower() == ".pdf": if shutil.which("pdftotext") is None: log.warning("pdftotext fehlt — %s übersprungen", pfad.name) @@ -93,9 +110,10 @@ def _datei_lesen(pfad: Path) -> str: capture_output=True, text=True, timeout=120) return res.stdout if res.returncode == 0 else "" try: - return pfad.read_text(encoding="utf-8", errors="replace") + text = pfad.read_text(encoding="utf-8", errors="replace") except OSError: return "" + return _tex_normalisieren(text) if pfad.suffix.lower() == ".tex" else text async def _uni_quellen(ctx: llm.Kontext) -> int: @@ -103,10 +121,14 @@ async def _uni_quellen(ctx: llm.Kontext) -> int: ordner = TOPICS_DIR / ctx.topic neu = 0 for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []: - if pfad.suffix.lower() not in (".txt", ".md", ".pdf"): + suffix = pfad.suffix.lower() + if suffix not in (".tex", ".txt", ".md", ".pdf"): continue - # .pdf überspringen, wenn eine gleichnamige .txt daneben liegt (vorkonvertiert) - if pfad.suffix.lower() == ".pdf" and pfad.with_suffix(".txt").exists(): + # Vorrang bei gleichem Basename: tex > txt > pdf (treueste Fassung gewinnt) + if suffix == ".pdf" and (pfad.with_suffix(".tex").exists() + or pfad.with_suffix(".txt").exists()): + continue + if suffix == ".txt" and pfad.with_suffix(".tex").exists(): continue inhalt = _datei_lesen(pfad) if not inhalt.strip(): diff --git a/backend/pipeline.py b/backend/pipeline.py index 6ae236e..5355a6c 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -111,6 +111,8 @@ def ebenen_entfernen(topic: str, ebene: str) -> None: stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene) db.execute("DELETE FROM sections WHERE baustein_id IN" " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) + db.execute("DELETE FROM auftraege WHERE baustein_id IN" + " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) db.execute("UPDATE bausteine SET status='neu' WHERE topic=?", (topic,)) db.execute("UPDATE kapitel SET intro='' WHERE topic=?", (topic,)) # Intro ist Guide-Text if stufe >= 1: # struktur @@ -186,6 +188,8 @@ def soll_reset(topic: str) -> None: lauf_stoppen(topic) db.execute("DELETE FROM sections WHERE baustein_id IN" " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) + db.execute("DELETE FROM auftraege WHERE baustein_id IN" + " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) db.execute("DELETE FROM bausteine WHERE topic=?", (topic,)) db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) db.execute("DELETE FROM lernziele WHERE topic=?", (topic,)) diff --git a/backend/qa.py b/backend/qa.py index 59d65b3..802c74a 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -32,7 +32,7 @@ GEWICHTE = { "kapitel_zerstueckelt": 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, "fix_offen": 1.5, + "det_check": 0.5, "auftrag_offen": 1.5, "fakten_konflikt": 1.5, "beispiel_marker_tot": 3.0, } diff --git a/backend/struktur.py b/backend/struktur.py index c2c7788..fe1d48f 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -192,6 +192,9 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der Topo-Reihenfolge splitten ("Teil n").""" 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" + " (SELECT id FROM bausteine WHERE topic=?)", (topic,)) db.execute("DELETE FROM bausteine WHERE topic=?", (topic,)) atome = _atome(topic) rang = _anker_rang(topic) diff --git a/backend/transfer.py b/backend/transfer.py index cbd0f02..607d337 100644 --- a/backend/transfer.py +++ b/backend/transfer.py @@ -39,6 +39,8 @@ def export(topic: str) -> dict: " JOIN atome a ON a.id=ar.atom_id WHERE a.topic=?", (topic,)), "sections": q("SELECT s.* FROM sections s JOIN bausteine b ON b.id=s.baustein_id" " WHERE b.topic=?", (topic,)), + "auftraege": q("SELECT au.* FROM auftraege au JOIN bausteine b" + " ON b.id=au.baustein_id WHERE b.topic=?", (topic,)), "runs": q("SELECT * FROM runs WHERE topic=? ORDER BY id", (topic,)), "befunde": q("SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id" " WHERE r.topic=?", (topic,)), @@ -143,12 +145,24 @@ def importieren(d: dict) -> None: if z.get(feld): z[feld] = re.sub(r"", beispiel_remap, z[feld]) - # offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123 - # fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs - if z.get("befunde"): - z["befunde"] = db.j([re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(a)) - for a in db.uj(z["befunde"])]) + # Alt-Exporte tragen Freitext-Aufträge in der (gedroppten) befunde-Spalte — + # KRITISCH-Arten in auftraege-Zeilen wandeln, Rest verwerfen (wie Migration) + for alt in db.uj(z.pop("befunde", None) or "[]"): + p = db.alt_auftrag(re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(alt))) + if p and z["baustein_id"] is not None: + db.insert("auftraege", baustein_id=z["baustein_id"], + art=p[0], detail=p[1], quelle=p[2]) db.insert("sections", **z) + # Aufträge nennen Atom-IDs im Klartext („Atom 123") — mitremappen, sonst + # urteilt der Prüfer gegen tote IDs + for z in d.get("auftraege", []): + z = dict(z) + z.pop("id", None) + z["baustein_id"] = b_map.get(z["baustein_id"]) + if z["baustein_id"] is None: + continue + z["detail"] = re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, z["detail"]) + db.insert("auftraege", **z) r_map = _einfuegen("runs", d["runs"]) _einfuegen("befunde", d["befunde"], run_id=r_map) e_map = _einfuegen("events", d["events"], run_id=r_map) diff --git a/templates/Guide-Pruefer-Urteil.md b/templates/Guide-Pruefer-Urteil.md new file mode 100644 index 0000000..8ecf17d --- /dev/null +++ b/templates/Guide-Pruefer-Urteil.md @@ -0,0 +1,28 @@ + +Du bist Richter über OFFENE Fix-Aufträge zu einer Guide-Section. Lernziel: {ziel} + +FAKTENBASIS (einzige Wahrheit — Definitionen + wörtliche Belege): +{fakten} + +SECTION (kompakt): +{kompakt} + +SECTION (lang): +{lang} + +OFFENE AUFTRÄGE: +{auftraege} + +Urteile je Auftrag GEGEN DEN AKTUELLEN TEXT: +- "behoben": der bemängelte Punkt ist im Text nicht mehr vorhanden bzw. inzwischen korrekt. +- "offen": der Mangel besteht weiterhin. +- "kein_mangel": der Auftrag war ein Fehlalarm — der Text widerspricht der Faktenbasis + an dieser Stelle NICHT. Prüfe genau nach: freie, sinngemäße Formulierung und + mathematisch äquivalente Schreibweisen sind erlaubt und KEIN Mangel. +- "faktenbasis": der Auftrag bemängelt einen Widerspruch INNERHALB der Faktenbasis + (Atome widersprechen einander) — kein Text-Rewrite kann das lösen. + +Melde KEINE neuen Mängel. Genau ein Urteil je Auftrag, alle Aufträge. + +Antworte NUR mit JSON (keine Code-Fences): +[{{"auftrag": 1, "urteil": "behoben"}}, {{"auftrag": 2, "urteil": "offen"}}] diff --git a/templates/Guide-Pruefer.md b/templates/Guide-Pruefer.md index bf40660..a6b1460 100644 --- a/templates/Guide-Pruefer.md +++ b/templates/Guide-Pruefer.md @@ -10,6 +10,9 @@ SECTION (kompakt): SECTION (lang): {lang} +BEREITS GEPRÜFT UND VERWORFEN (Fehlalarme — NICHT erneut melden, auch nicht umformuliert): +{geprueft} + Prüfe: 1. FAKTEN: Jeder inhaltliche Claim (Zahl, Name, Behauptung) muss aus der Faktenbasis ABLEITBAR sein. Widerspruch oder klar Erfundenes → art "falsch". Freie, sinngemäße diff --git a/tests/test_auftraege.py b/tests/test_auftraege.py new file mode 100644 index 0000000..25a3d69 --- /dev/null +++ b/tests/test_auftraege.py @@ -0,0 +1,186 @@ +"""Auftrags-Lebenszyklus (Guide): eingefrorener Wortlaut, Urteil je Runde, +Fehlalarm-Tod (einstimmig), Runden-Cap → eskaliert, Fund nur bei leerer Liste.""" + +import db +import fake_agents +import guide +import llm +import pytest +from config import AUFTRAG_RUNDEN_MAX +from conftest import run_anlegen, topic_anlegen + + +def _baustein(topic, lang_extra=""): + ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv") + b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") + a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", + status="neu", baustein_id=b_id, braucht=db.j([])) + lang = f"\n" + ("Wort " * 60).strip() + lang_extra + db.insert("sections", baustein_id=b_id, stage="pruefer", text_lang=lang, + text_kompakt="- p") + return db.one("SELECT * FROM bausteine WHERE id=?", (b_id,)) + + +def _ctx(topic): + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "guide" + return ctx + + +def test_migration_befunde_json_zu_auftraegen(): + import sqlite3 + con = sqlite3.connect(":memory:") + con.execute("CREATE TABLE sections(baustein_id INTEGER PRIMARY KEY," + " stage TEXT NOT NULL DEFAULT 'writer', text_kompakt TEXT DEFAULT ''," + " text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]'," + " qa_hash TEXT DEFAULT '', fix_versuche INTEGER NOT NULL DEFAULT 0)") + con.execute("INSERT INTO sections(baustein_id, befunde) VALUES(7, ?)", (db.j([ + "KRITISCH (falsch): Zahl stimmt nicht", + "KRITISCH (luecke): Atom 5 fehlt", + "KRITISCH (fachlich_falsch): Claim X", + "KRITISCH (marker_fehlend): Marker 5 fehlt", # messen-Art → verwerfen + "KRITISCH: Marker für Atom 5 fehlt — exakt einfügen.", # det → verwerfen + "Langtext, Absatz 2 hat 130 Wörter (Regel: 40–90): teilen."]),)) + db._init_schema(con) + zeilen = con.execute("SELECT art, detail, quelle FROM auftraege").fetchall() + assert sorted(z[0] for z in zeilen) == ["falsch", "falsch", "luecke"] + assert ("falsch", "Claim X", "qa") in zeilen # fachlich_falsch → falsch, quelle qa + # Spalte weg + idempotent (zweiter Lauf legt nichts doppelt an) + assert not any(s[1] == "befunde" for s in con.execute("PRAGMA table_info(sections)")) + db._init_schema(con) + assert con.execute("SELECT COUNT(*) FROM auftraege").fetchone()[0] == 3 + + +async def test_urteil_statusuebergaenge_und_keine_neuen_funde(monkeypatch): + topic = topic_anlegen("urteil") + b = _baustein(topic) + a1 = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D1") + a2 = db.insert("auftraege", baustein_id=b["id"], art="stil", detail="D2") + a3 = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D3") + urteile = {1: "behoben", 2: "kein_mangel", 3: "faktenbasis"} + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil", + lambda p: [{"auftrag": i, "urteil": u} for i, u in urteile.items()]) + # Fund-Modus darf bei offenen Aufträgen NIE laufen + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer", + lambda p: pytest.fail("Fund-Modus trotz offener Aufträge")) + await guide._auftraege_urteilen(_ctx(topic), b, + db.one("SELECT * FROM sections WHERE baustein_id=?", + (b["id"],)), + guide._offene_auftraege(b["id"])) + stat = {r["id"]: r["status"] for r in db.query("SELECT * FROM auftraege")} + assert stat == {a1: "behoben", a2: "kein_mangel", a3: "eskaliert"} + + +async def test_kein_mangel_nur_einstimmig(monkeypatch): + topic = topic_anlegen("split") + b = _baustein(topic) + au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D") + votes = iter(["kein_mangel", "offen", "offen"]) # Stimme 1 ≠ Stimme 2 (+Ersatz) + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil", + lambda p: [{"auftrag": 1, "urteil": next(votes)}]) + 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"] == "offen" and r["runden"] == 1 # uneins → offen, Runde zählt + + +async def test_runden_cap_eskaliert_und_wird_nicht_geroutet(monkeypatch): + topic = topic_anlegen("cap") + b = _baustein(topic) + au = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="zäh", + runden=AUFTRAG_RUNDEN_MAX - 1) + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil", + lambda p: [{"auftrag": 1, "urteil": "offen"}]) + ctx = _ctx(topic) + await guide._auftraege_urteilen(ctx, b, + db.one("SELECT * FROM sections WHERE baustein_id=?", + (b["id"],)), + guide._offene_auftraege(b["id"])) + assert db.one("SELECT status FROM auftraege WHERE id=?", (au,))["status"] == "eskaliert" + # fakten_konflikt wird in reparieren nicht geroutet → kein Fortschritt = False + db.update("sections", "baustein_id", b["id"], stage="done") + bewegt = await guide.reparieren(ctx, [{"art": "fakten_konflikt", + "item": str(b["id"]), "detail": "zäh"}]) + assert bewegt is False + assert db.one("SELECT stage FROM sections WHERE baustein_id=?", + (b["id"],))["stage"] == "done" + + +async def test_fund_dedup_gegen_kein_mangel(monkeypatch): + """Ein totgestimmter Fehlalarm darf nicht als frische Zeile auferstehen.""" + topic = topic_anlegen("wiedergaenger") + b = _baustein(topic) + db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Fehlalarm X", + status="kein_mangel") + gesehen = {} + def finder(prompt): + gesehen["negativ"] = "Fehlalarm X" in prompt + return {"befunde": [{"art": "falsch", "detail": "Fehlalarm X"}, + {"art": "luecke", "detail": "Neu Y"}]} + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer", finder) + await guide._auftraege_finden(_ctx(topic), b, + db.one("SELECT * FROM sections WHERE baustein_id=?", + (b["id"],))) + assert gesehen["negativ"] # Negativ-Liste stand im Prompt + offen = guide._offene_auftraege(b["id"]) + assert [a["detail"] for a in offen] == ["Neu Y"] + + +async def test_fix_stil_optimistisch_falsch_via_urteil(monkeypatch): + topic = topic_anlegen("fixarten") + b = _baustein(topic) + stil = db.insert("auftraege", baustein_id=b["id"], art="stil", detail="glätten") + falsch = db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="Zahl prüfen") + sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", + lambda p: {"kompakt": "- p", "lang": sec["text_lang"] + " neu"}) + # Re-Check-Urteil lässt den falsch-Auftrag OFFEN → nicht optimistisch geschlossen + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Pruefer-Urteil", + lambda p: [{"auftrag": 1, "urteil": "offen"}]) + db.update("sections", "baustein_id", b["id"], stage="fix") + assert await guide._stage_fix(_ctx(topic), b) == "done" + assert db.one("SELECT status FROM auftraege WHERE id=?", (stil,))["status"] == "behoben" + assert db.one("SELECT status FROM auftraege WHERE id=?", (falsch,))["status"] == "offen" + + +def test_qa_auftrag_dedup_und_eskalation(): + topic = topic_anlegen("qapfad") + b = _baustein(topic) + guide._qa_auftrag(b["id"], "Claim A") + guide._qa_auftrag(b["id"], "Claim A") # exakter Dedup + zeilen = db.query("SELECT * FROM auftraege WHERE baustein_id=?", (b["id"],)) + assert len(zeilen) == 1 and zeilen[0]["quelle"] == "qa" + # Re-Bestätigung auf kein_mangel-Zeile: Richter uneins → eskaliert + db.update("auftraege", "id", zeilen[0]["id"], status="kein_mangel") + guide._qa_auftrag(b["id"], "Claim A") + assert db.one("SELECT status FROM auftraege WHERE id=?", + (zeilen[0]["id"],))["status"] == "eskaliert" + + +async def test_urteilsrunde_ohne_text_ist_bewegt(monkeypatch): + """Statusübergänge zählen als Fortschritt — sonst bricht auto_loop reine + Urteilsrunden als Stillstand ab, obwohl Aufträge geschlossen wurden.""" + topic = topic_anlegen("bewegt2") + b = _baustein(topic) + db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D") + db.update("sections", "baustein_id", b["id"], stage="done") + # Fake-Urteil: behoben (Default-Handler); Text bleibt unverändert + bewegt = await guide.reparieren(_ctx(topic), [ + {"art": "auftrag_offen", "item": str(b["id"]), "detail": "(falsch) D"}]) + assert bewegt is True + assert db.query("SELECT * FROM auftraege WHERE status='offen'") == [] + + +async def test_freeze_urteilt_weiter(monkeypatch): + """Fix-Cap friert nur Stil-Fixes ein — Aufträge werden trotzdem geurteilt.""" + topic = topic_anlegen("freeze") + b = _baustein(topic) + db.update("sections", "baustein_id", b["id"], + stage="done", fix_versuche=guide.FIX_MAX_VERSUCHE) + db.insert("auftraege", baustein_id=b["id"], art="falsch", detail="D") + bewegt = await guide.reparieren(_ctx(topic), [ + {"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 diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index a9ee95c..a307b4f 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -220,7 +220,7 @@ def test_det_auftraege_neue_checks(): assert "Vertröstungs-Floskel" in qa_text -async def test_fix_fehlschlag_behaelt_befunde(monkeypatch): +async def test_fix_fehlschlag_behaelt_auftraege(monkeypatch): import db import fake_agents import guide @@ -232,8 +232,8 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch): b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", status="neu", baustein_id=b_id, braucht=db.j([])) - db.insert("sections", baustein_id=b_id, stage="fix", - text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"])) + db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="") + au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y") # Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", lambda p: {"kompakt": "", "lang": "kein Marker"}) @@ -242,7 +242,8 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch): stage = await guide._stage_fix(ctx, db.one("SELECT * FROM bausteine WHERE id=?", (b_id,))) sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,)) assert stage == "done" and sec["text_lang"] == "alt" - assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar + # Auftrag bleibt offen und sichtbar — kein unsichtbar gescheiterter Fix + assert db.one("SELECT status FROM auftraege WHERE id=?", (au,))["status"] == "offen" def test_marker_titel_erzeugen_keine_auftraege(): @@ -281,8 +282,9 @@ def test_persistieren_dedup_mit_detail(): assert ("A", "offen") in stat and ("B", "repariert") in stat -async def test_fix_offen_in_messen(): - """Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr.""" +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.""" import db import guide import llm @@ -293,11 +295,17 @@ async def test_fix_offen_in_messen(): a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d", status="neu", baustein_id=b_id, braucht=db.j([])) lang = f"\n" + "Wort " * 60 - db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, - text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"])) + 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") + db.insert("auftraege", baustein_id=b_id, art="stil", detail="zäh", + status="eskaliert") # eskalierter Stil: stumm (bewusste Rest-Schuld) ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") ctx.ebene = "guide" - assert "fix_offen" in {b["art"] for b in guide.messen(ctx)} + arten = [b["art"] for b in guide.messen(ctx)] + assert arten.count("auftrag_offen") == 1 + assert arten.count("fakten_konflikt") == 1 async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch): @@ -315,7 +323,7 @@ async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch): status="neu", baustein_id=b_id, braucht=db.j([])) lang = f"\n" + ("Wort " * 60).strip() # ohne Trailing-Space db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang, - text_kompakt="- p", befunde=db.j(["Behebe (laenge): kürzen"])) + text_kompakt="- p") monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", lambda p: {"kompakt": "- p", "lang": lang}) # identisch ctx = llm.Kontext(run, topic, "minimax") @@ -408,7 +416,7 @@ async def test_fix_cap_friert_section_ein(monkeypatch): status="neu", baustein_id=b, braucht=db.j([])) lang = f"\n" + "Wort " * 60 db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p", - befunde=db.j([]), fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap + fix_versuche=guide.FIX_MAX_VERSUCHE) # schon am Cap ctx = llm.Kontext(run, topic, "minimax") ctx.ebene = "guide" bewegt = await guide.reparieren( diff --git a/tests/test_korpus.py b/tests/test_korpus.py index 395f333..1983687 100644 --- a/tests/test_korpus.py +++ b/tests/test_korpus.py @@ -161,3 +161,33 @@ async def test_thema_braucht_zwei_quellen(monkeypatch): assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat" assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"} + + +def test_tex_normalisieren(): + tex = ("\\documentclass{article}\n\\usepackage{tikz}\n\\begin{document}\n" + "Einf\\\"uhrung: Gr\\\"o\\ss e $n$, \\\"Ubung hei\\ss t \\emph{T}, " + "\\\"{a} bleibt $\\Sigma$\n\\end{document}\n") + ergebnis = korpus._tex_normalisieren(tex) + assert "documentclass" not in ergebnis and "end{document}" not in ergebnis + assert "Einführung: Größe $n$, Übung heißt \\emph{T}, ä bleibt $\\Sigma$" in ergebnis + # ohne Präambel/Escapes: unverändert + assert korpus._tex_normalisieren("Plain ü Text $x$") == "Plain ü Text $x$" + # \ss als Präfix eines anderen Kontrollworts bleibt stehen + assert korpus._tex_normalisieren("\\ssname bleibt") == "\\ssname bleibt" + + +async def test_uni_quellen_vorrang_tex(tmp_path): + topic = topic_anlegen("k-tex", art="uni") + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ordner = korpus.TOPICS_DIR / topic + ordner.mkdir(parents=True) + (ordner / "a.tex").write_text("\\begin{document}Gr\\\"o\\ss e A\\end{document}") + (ordner / "a.txt").write_text("Groesse A kaputt") + (ordner / "a.pdf").write_bytes(b"%PDF-1.4 egal") + (ordner / "b.txt").write_text("Nur als txt da") + assert await korpus._uni_quellen(ctx) == 2 # a.tex + b.txt, Rest übersprungen + quellen = db.query("SELECT titel, snapshot FROM quellen WHERE topic=? AND art='datei'", + (topic,)) + assert {q["titel"] for q in quellen} == {"a.tex", "b.txt"} + snap = next(q for q in quellen if q["titel"] == "a.tex") + assert "Größe A" in korpus.quelltext(snap) diff --git a/tests/test_transfer.py b/tests/test_transfer.py index e938322..c665af2 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -17,8 +17,8 @@ async def test_export_import_roundtrip(): await pipeline._laeufe[topic] tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte", - "lernziele", "bausteine", "kapitel", "sections", "runs", - "befunde", "events", "leitner") + "lernziele", "bausteine", "kapitel", "sections", "auftraege", + "runs", "befunde", "events", "leitner") vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen} md_vorher = guide.guide_markdown(topic) @@ -51,24 +51,30 @@ async def test_export_import_roundtrip(): assert int(m) in atom_ids, f"toter Marker {m}" -async def test_import_remappt_befunde_atom_ids(): - """Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie - auf die neu vergebenen IDs zeigen, nicht auf tote.""" - topic = topic_anlegen("befunde-remap", art="thema") +async def test_import_remappt_auftrag_atom_ids(): + """Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie auf die neu + vergebenen IDs zeigen. Alt-Exporte mit befunde-Key: KRITISCH-Arten werden + Zeilen, Rest fliegt (wie die Schema-Migration).""" + topic = topic_anlegen("auftrag-remap", art="thema") pipeline.lauf_starten(topic) await pipeline._laeufe[topic] b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,)) a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],)) - db.update("sections", "baustein_id", b["id"], - befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."])) + db.insert("auftraege", baustein_id=b["id"], art="falsch", + detail=f"Atom {a['id']} widerspricht dem Text") d = json.loads(json.dumps(transfer.export(topic))) + # Alt-Export simulieren: befunde-Freitext an einer Section + d["sections"][0]["befunde"] = db.j([ + f"KRITISCH (luecke): Atom {a['id']} fehlt inhaltlich", + "Langtext, Absatz 3 hat 120 Wörter (Regel: 40–90): teilen."]) # → verwerfen transfer.importieren(d) atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))} + zeilen = db.query("SELECT au.* FROM auftraege au JOIN bausteine b" + " ON b.id=au.baustein_id WHERE b.topic=?", (topic,)) + assert {z["art"] for z in zeilen} == {"falsch", "luecke"} # det-Zeile verworfen treffer = 0 - for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id" - " WHERE b.topic=?", (topic,)): - for auftrag in db.uj(s["befunde"]): - for m in re.findall(r"Atom (\d+)", str(auftrag)): - assert int(m) in atom_ids, f"toter Befund-Marker {m}" - treffer += 1 - assert treffer >= 1 # der eingefügte Auftrag wurde geprüft + for z in zeilen: + for m in re.findall(r"Atom (\d+)", z["detail"]): + assert int(m) in atom_ids, f"toter Auftrag-Verweis {m}" + treffer += 1 + assert treffer == 2