This commit is contained in:
team3
2026-07-13 06:07:09 +02:00
parent 8926f87185
commit 32d7ad9ea1
15 changed files with 552 additions and 115 deletions

View File

@@ -122,6 +122,7 @@ SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
# ── Auto-Loop ───────────────────────────────────────────────────────────────── # ── Auto-Loop ─────────────────────────────────────────────────────────────────
LOOP_MAX_ITER = 10 LOOP_MAX_ITER = 10
FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung FIX_MAX_VERSUCHE = 3 # Section einfrieren nach so vielen Fixes OHNE Textänderung
AUFTRAG_RUNDEN_MAX = 4 # Auftrag nach so vielen Urteilsrunden ohne Abschluss → eskaliert
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ───────────────── # ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
TIMEOUTS = { TIMEOUTS = {
@@ -144,6 +145,7 @@ TIMEOUTS = {
"zyklus": (150, 0), "zyklus": (150, 0),
"writer": (450, 60), "writer": (450, 60),
"pruefer": (600, 5), "pruefer": (600, 5),
"pruefer_urteil": (300, 5),
"qa_judge": (600, 0), "qa_judge": (600, 0),
} }

View File

@@ -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.""" `on_change` (von main.py gesetzt) meldet Statuswechsel ans Live-Board."""
import json import json
import re
import sqlite3 import sqlite3
import threading import threading
from pathlib import Path from pathlib import Path
@@ -112,6 +113,14 @@ CREATE TABLE IF NOT EXISTS lernstand(
topic TEXT NOT NULL, baustein_id INTEGER NOT NULL, topic TEXT NOT NULL, baustein_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'aktiv', xp INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'aktiv', xp INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(topic, baustein_id)); 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. # 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;" "INSERT INTO artefakte SELECT * FROM artefakte_alt;"
"DROP TABLE artefakte_alt;" "DROP TABLE artefakte_alt;"
"CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);") "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() 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: def _notify(tabelle: str, row: dict) -> None:
if on_change is not None and tabelle in _LIVE_TABELLEN: if on_change is not None and tabelle in _LIVE_TABELLEN:
try: try:

View File

@@ -268,6 +268,12 @@ def _pruefer(prompt: str):
return {"befunde": []} 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): def _fix(prompt: str):
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip() kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip() lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
@@ -298,6 +304,7 @@ _HANDLER = {
"Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung, "Kapitel-Schnitt": _kapitel_schnitt, "Level-Kalibrierung": _level_kalibrierung,
"Kanten-Zyklus": _zyklus, "Braucht-Aufloesung": _braucht_aufloesung, "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, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
} }

View File

@@ -19,7 +19,8 @@ import jsonx
import llm import llm
import belege import belege
import textkit 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") log = logging.getLogger("creator2.guide")
@@ -457,40 +458,105 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
return treffer return treffer
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str: def _offene_auftraege(b_id: int) -> list[dict]:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) 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"]) atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]} 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"]) liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(offene, 1))
# bereits offene KRITISCH-Aufträge übernehmen (z. B. ein aus der Ebenen-QA stimmen = await llm.panel(ctx, 2, stage="pruefer_urteil", template="Guide-Pruefer-Urteil",
# bestätigter fachlich_falsch-Claim) — der In-Card-Prüfer findet sie nicht schritt="pruefer", role="judge", n=len(offene),
# immer selbst wieder, ohne dies verpuffte der Befund im Ping-Pong. item=f"b{b['id']}{tag}-urteil",
for alt in db.uj(sec["befunde"]): werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
if str(alt).startswith("KRITISCH") and alt not in auftraege: "kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
auftraege.append(alt) "auftraege": liste},
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n" erwartet=list)
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome) if len(stimmen) < 2:
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer", 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), schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}", item=f"b{b['id']}",
werte={"ziel": ziel["text"], "fakten": fakten, werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]}, "kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"geprueft": "\n".join(f"- {v['detail']}" for v in verworfen)
or "(keine)"},
erwartet=dict) 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", []): for e in (res or {}).get("befunde", []):
art = str(e.get("art", "")).strip() art = str(e.get("art", "")).strip()
detail = str(e.get("detail", "")).strip() detail = str(e.get("detail", "")).strip()
if art in ("falsch", "luecke"): if art in ("falsch", "luecke", "stil") and detail and detail not in bekannte:
auftraege.append(f"KRITISCH ({art}): {detail}") db.insert("auftraege", baustein_id=b["id"], art=art, detail=detail)
elif art == "stil" and detail: bekannte.add(detail)
auftraege.append(detail)
auftraege = list(dict.fromkeys(auftraege)) # Duplikate (übernommene + neue) raus
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege)) async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
return "fix" if auftraege else "done" 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: async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
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: if not auftraege:
return "done" return "done"
atome = _atome_von(b["id"]) 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), "auftraege": "\n".join(f"- {a}" for a in auftraege),
"fakten": fakten}, "fakten": fakten},
erwartet=str) erwartet=str)
kritisch = any(a.startswith("KRITISCH") for a in auftraege) kritisch = any(a["art"] in ("falsch", "luecke") for a in offene)
if res: if res:
kompakt, lang = _split_writer(res) kompakt, lang = _split_writer(res)
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen
db.update("sections", "baustein_id", b["id"], 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 if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re") await _stage_pruefer(ctx, b, tag="-re")
return "done" 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 # unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60
# offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen. # offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen.
return "done" 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): for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
befunde.append({"art": "det_check", "item": str(b["id"]), befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]}) "detail": auftrag[:300]})
# Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht # Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate. # Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
for auftrag in db.uj(sec["befunde"]): # Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
befunde.append({"art": "fix_offen", "item": str(b["id"]), for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
"detail": str(auftrag)[:300]}) " 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"],)) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel: if ziel:
noetig = _stopfrei(ziel["text"]) noetig = _stopfrei(ziel["text"])
@@ -678,6 +755,18 @@ def messen(ctx: llm.Kontext) -> list[dict]:
return befunde 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]: async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger — """Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections 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 import hashlib
ctx.ebene = EBENE ctx.ebene = EBENE
befunde = [] 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: def hash_setzen(bid: int, h: str) -> None:
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid)) 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"]: if not sec or not sec["text_lang"]:
return return
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12] h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch: if h == sec["qa_hash"]:
return # unverändert und sauber — kein Re-Check 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 # qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open). # bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"]) atome = _atome_von(b["id"])
@@ -734,6 +818,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
if ja >= 2: if ja >= 2:
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]), befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
"detail": claim[:200]}) "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 hash_setzen(b["id"], h) # vollständig geprüft
await llm.alle(eine(b) for b in _bausteine(ctx.topic)) 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] 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: async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check); """Kritisches + offene Aufträge → zurück auf pruefer (Urteil/Re-Check mit
Stil-Befunde → direkt in den Fix (genau ein Rewrite). Rückgabe ist ehrlich: vollem Kontext); reine det-Stil-Befunde → direkt in den Fix (genau ein
True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt Rewrite). Rückgabe ist ehrlich: True nur bei Textänderung ODER Auftrag-
auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch.""" Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {} betroffen: dict[int, list[dict]] = {}
for b in befunde: for b in befunde:
if b["art"] == "fakten_konflikt":
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
try: try:
b_id = int(b["item"]) b_id = int(b["item"])
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -766,46 +867,28 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return False return False
geroutet: list[int] = [] geroutet: list[int] = []
for b_id, liste in betroffen.items(): for b_id, liste in betroffen.items():
arten = [x["art"] for x in liste] arten = {x["art"] for x in liste}
# ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch if "section_fehlt" in arten:
kritisch_detail = any(x["art"] == "fix_offen" db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
and str(x["detail"]).startswith("KRITISCH") for x in liste) elif arten & ({"auftrag_offen"} | set(KRITISCH)):
kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail # Urteile sind der Fortschrittsmechanismus — der Fix-Freeze gilt hier
if not kritisch: # NIE, sonst wären Aufträge gefrorener Sections wieder unsterblich.
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird db.update("sections", "baustein_id", b_id, stage="pruefer")
# eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts- else:
# Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn. # 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,)) sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE: if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
continue continue
if kritisch: db.update("sections", "baustein_id", b_id, stage="fix")
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("bausteine", "id", b_id, status="repair") db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id) 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) 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 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,)) db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
bewegt = True bewegt = True
else: else:

View File

@@ -83,8 +83,25 @@ def _snapshot_schreiben(topic: str, inhalt: str) -> tuple[str, str] | None:
return str(pfad), h 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: 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 pfad.suffix.lower() == ".pdf":
if shutil.which("pdftotext") is None: if shutil.which("pdftotext") is None:
log.warning("pdftotext fehlt — %s übersprungen", pfad.name) 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) capture_output=True, text=True, timeout=120)
return res.stdout if res.returncode == 0 else "" return res.stdout if res.returncode == 0 else ""
try: try:
return pfad.read_text(encoding="utf-8", errors="replace") text = pfad.read_text(encoding="utf-8", errors="replace")
except OSError: except OSError:
return "" return ""
return _tex_normalisieren(text) if pfad.suffix.lower() == ".tex" else text
async def _uni_quellen(ctx: llm.Kontext) -> int: 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 ordner = TOPICS_DIR / ctx.topic
neu = 0 neu = 0
for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []: 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 continue
# .pdf überspringen, wenn eine gleichnamige .txt daneben liegt (vorkonvertiert) # Vorrang bei gleichem Basename: tex > txt > pdf (treueste Fassung gewinnt)
if pfad.suffix.lower() == ".pdf" and pfad.with_suffix(".txt").exists(): 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 continue
inhalt = _datei_lesen(pfad) inhalt = _datei_lesen(pfad)
if not inhalt.strip(): if not inhalt.strip():

View File

@@ -111,6 +111,8 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene) stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
db.execute("DELETE FROM sections WHERE baustein_id IN" db.execute("DELETE FROM sections WHERE baustein_id IN"
" (SELECT id FROM bausteine WHERE topic=?)", (topic,)) " (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 bausteine SET status='neu' WHERE topic=?", (topic,))
db.execute("UPDATE kapitel SET intro='' WHERE topic=?", (topic,)) # Intro ist Guide-Text db.execute("UPDATE kapitel SET intro='' WHERE topic=?", (topic,)) # Intro ist Guide-Text
if stufe >= 1: # struktur if stufe >= 1: # struktur
@@ -186,6 +188,8 @@ def soll_reset(topic: str) -> None:
lauf_stoppen(topic) lauf_stoppen(topic)
db.execute("DELETE FROM sections WHERE baustein_id IN" db.execute("DELETE FROM sections WHERE baustein_id IN"
" (SELECT id FROM bausteine WHERE topic=?)", (topic,)) " (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 bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,)) db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,)) db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))

View File

@@ -32,7 +32,7 @@ GEWICHTE = {
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0, "kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0, "section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0,
"ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5, "ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
"det_check": 0.5, "fix_offen": 1.5, "det_check": 0.5, "auftrag_offen": 1.5, "fakten_konflikt": 1.5,
"beispiel_marker_tot": 3.0, "beispiel_marker_tot": 3.0,
} }

View File

@@ -192,6 +192,9 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der sein Ziel, Baustein bleibt EIN Ziel + EIN Level), große entlang der
Topo-Reihenfolge splitten ("Teil n").""" Topo-Reihenfolge splitten ("Teil n")."""
topic = ctx.topic 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,)) db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic) atome = _atome(topic)
rang = _anker_rang(topic) rang = _anker_rang(topic)

View File

@@ -39,6 +39,8 @@ def export(topic: str) -> dict:
" JOIN atome a ON a.id=ar.atom_id WHERE a.topic=?", (topic,)), " 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" "sections": q("SELECT s.* FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
" WHERE b.topic=?", (topic,)), " 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,)), "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" "befunde": q("SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"
" WHERE r.topic=?", (topic,)), " WHERE r.topic=?", (topic,)),
@@ -143,12 +145,24 @@ def importieren(d: dict) -> None:
if z.get(feld): if z.get(feld):
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld]) z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
z[feld] = re.sub(r"<!--\s*beispiel:\s*(\d+)\s*-->", beispiel_remap, z[feld]) z[feld] = re.sub(r"<!--\s*beispiel:\s*(\d+)\s*-->", beispiel_remap, z[feld])
# offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123 # Alt-Exporte tragen Freitext-Aufträge in der (gedroppten) befunde-Spalte —
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs # KRITISCH-Arten in auftraege-Zeilen wandeln, Rest verwerfen (wie Migration)
if z.get("befunde"): for alt in db.uj(z.pop("befunde", None) or "[]"):
z["befunde"] = db.j([re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(a)) p = db.alt_auftrag(re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(alt)))
for a in db.uj(z["befunde"])]) 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) 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"]) r_map = _einfuegen("runs", d["runs"])
_einfuegen("befunde", d["befunde"], run_id=r_map) _einfuegen("befunde", d["befunde"], run_id=r_map)
e_map = _einfuegen("events", d["events"], run_id=r_map) e_map = _einfuegen("events", d["events"], run_id=r_map)

View File

@@ -0,0 +1,28 @@
<!-- template:Guide-Pruefer-Urteil -->
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"}}]

View File

@@ -10,6 +10,9 @@ SECTION (kompakt):
SECTION (lang): SECTION (lang):
{lang} {lang}
BEREITS GEPRÜFT UND VERWORFEN (Fehlalarme — NICHT erneut melden, auch nicht umformuliert):
{geprueft}
Prüfe: Prüfe:
1. FAKTEN: Jeder inhaltliche Claim (Zahl, Name, Behauptung) muss aus der Faktenbasis 1. FAKTEN: Jeder inhaltliche Claim (Zahl, Name, Behauptung) muss aus der Faktenbasis
ABLEITBAR sein. Widerspruch oder klar Erfundenes → art "falsch". Freie, sinngemäße ABLEITBAR sein. Widerspruch oder klar Erfundenes → art "falsch". Freie, sinngemäße

186
tests/test_auftraege.py Normal file
View File

@@ -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"<!-- atom: {a} | A -->\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: 4090): 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

View File

@@ -220,7 +220,7 @@ def test_det_auftraege_neue_checks():
assert "Vertröstungs-Floskel" in qa_text 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 db
import fake_agents import fake_agents
import guide 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") 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", db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([])) status="neu", baustein_id=b_id, braucht=db.j([]))
db.insert("sections", baustein_id=b_id, stage="fix", db.insert("sections", baustein_id=b_id, stage="fix", text_lang="alt", text_kompakt="")
text_lang="alt", text_kompakt="", befunde=db.j(["Behebe (x): y"])) au = db.insert("auftraege", baustein_id=b_id, art="falsch", detail="y")
# Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad # Fix liefert Text OHNE Marker → Marker-Invariante bricht → Fehlschlag-Pfad
monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "", "lang": "kein Marker"}) 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,))) 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,)) sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b_id,))
assert stage == "done" and sec["text_lang"] == "alt" 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(): 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 assert ("A", "offen") in stat and ("B", "repariert") in stat
async def test_fix_offen_in_messen(): async def test_auftrag_offen_in_messen():
"""Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr.""" """Gescheiterter Fix (stage done, offene Aufträge) passiert das Gate nicht mehr;
eskalierte falsch/luecke-Aufträge werden als fakten_konflikt sichtbar."""
import db import db
import guide import guide
import llm 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", a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([])) status="neu", baustein_id=b_id, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60 lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, text_kompakt="- p")
text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"])) db.insert("auftraege", baustein_id=b_id, art="luecke", detail="Atom-Inhalt fehlt")
db.insert("auftraege", baustein_id=b_id, art="falsch", detail="Widerspruch",
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 = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "guide" 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): 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([])) status="neu", baustein_id=b_id, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space lang = f"<!-- atom: {a} | A -->\n" + ("Wort " * 60).strip() # ohne Trailing-Space
db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang, 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", monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix",
lambda p: {"kompakt": "- p", "lang": lang}) # identisch lambda p: {"kompakt": "- p", "lang": lang}) # identisch
ctx = llm.Kontext(run, topic, "minimax") 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([])) status="neu", baustein_id=b, braucht=db.j([]))
lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60 lang = f"<!-- atom: {a} | A -->\n" + "Wort " * 60
db.insert("sections", baustein_id=b, stage="fix", text_lang=lang, text_kompakt="- p", 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 = llm.Kontext(run, topic, "minimax")
ctx.ebene = "guide" ctx.ebene = "guide"
bewegt = await guide.reparieren( bewegt = await guide.reparieren(

View File

@@ -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 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 db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"} 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)

View File

@@ -17,8 +17,8 @@ async def test_export_import_roundtrip():
await pipeline._laeufe[topic] await pipeline._laeufe[topic]
tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte", tabellen = ("quellen", "soll", "atome", "anker", "kanten", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "runs", "lernziele", "bausteine", "kapitel", "sections", "auftraege",
"befunde", "events", "leitner") "runs", "befunde", "events", "leitner")
vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen} vorher = {t: len(db.query(f"SELECT * FROM {t}", ())) for t in tabellen}
md_vorher = guide.guide_markdown(topic) 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}" assert int(m) in atom_ids, f"toter Marker {m}"
async def test_import_remappt_befunde_atom_ids(): async def test_import_remappt_auftrag_atom_ids():
"""Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie """Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie auf die neu
auf die neu vergebenen IDs zeigen, nicht auf tote.""" vergebenen IDs zeigen. Alt-Exporte mit befunde-Key: KRITISCH-Arten werden
topic = topic_anlegen("befunde-remap", art="thema") Zeilen, Rest fliegt (wie die Schema-Migration)."""
topic = topic_anlegen("auftrag-remap", art="thema")
pipeline.lauf_starten(topic) pipeline.lauf_starten(topic)
await pipeline._laeufe[topic] await pipeline._laeufe[topic]
b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,)) b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,))
a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],)) a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],))
db.update("sections", "baustein_id", b["id"], db.insert("auftraege", baustein_id=b["id"], art="falsch",
befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."])) detail=f"Atom {a['id']} widerspricht dem Text")
d = json.loads(json.dumps(transfer.export(topic))) 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: 4090): teilen."]) # → verwerfen
transfer.importieren(d) transfer.importieren(d)
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))} 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 treffer = 0
for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id" for z in zeilen:
" WHERE b.topic=?", (topic,)): for m in re.findall(r"Atom (\d+)", z["detail"]):
for auftrag in db.uj(s["befunde"]): assert int(m) in atom_ids, f"toter Auftrag-Verweis {m}"
for m in re.findall(r"Atom (\d+)", str(auftrag)):
assert int(m) in atom_ids, f"toter Befund-Marker {m}"
treffer += 1 treffer += 1
assert treffer >= 1 # der eingefügte Auftrag wurde geprüft assert treffer == 2