update
This commit is contained in:
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
237
backend/guide.py
237
backend/guide.py
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,))
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
28
templates/Guide-Pruefer-Urteil.md
Normal file
28
templates/Guide-Pruefer-Urteil.md
Normal 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"}}]
|
||||||
@@ -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
186
tests/test_auftraege.py
Normal 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: 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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: 40–90): 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
|
||||||
|
|||||||
Reference in New Issue
Block a user