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

@@ -19,7 +19,8 @@ import jsonx
import llm
import belege
import textkit
from config import DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG, SECTION_WOERTER_PRO_ATOM
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG,
SECTION_WOERTER_PRO_ATOM)
log = logging.getLogger("creator2.guide")
@@ -457,40 +458,105 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
return treffer
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
def _offene_auftraege(b_id: int) -> list[dict]:
return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='offen'"
" ORDER BY id", (b_id,))
def _fakten_von(atome: list[dict]) -> str:
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict,
offene: list[dict], tag: str = "") -> None:
"""Richter über bestehende Aufträge — Wortlaut bleibt eingefroren, damit
Paraphrasen-Dubletten unmöglich sind. Abschluss (behoben/kein_mangel/
faktenbasis) nur einstimmig im 2er-Panel (destruktiv — Lektion 20/67);
sonst offen + Runde gezählt, am Runden-Cap → eskaliert (terminiert immer)."""
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
# bereits offene KRITISCH-Aufträge übernehmen (z. B. ein aus der Ebenen-QA
# bestätigter fachlich_falsch-Claim) — der In-Card-Prüfer findet sie nicht
# immer selbst wieder, ohne dies verpuffte der Befund im Ping-Pong.
for alt in db.uj(sec["befunde"]):
if str(alt).startswith("KRITISCH") and alt not in auftraege:
auftraege.append(alt)
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(offene, 1))
stimmen = await llm.panel(ctx, 2, stage="pruefer_urteil", template="Guide-Pruefer-Urteil",
schritt="pruefer", role="judge", n=len(offene),
item=f"b{b['id']}{tag}-urteil",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": liste},
erwartet=list)
if len(stimmen) < 2:
return # Panel unvollständig → nichts ändern (fail-closed, Lektion 20)
je: dict[int, list[str]] = {}
for s in stimmen:
for e in s:
if isinstance(e, dict):
try:
je.setdefault(int(e.get("auftrag")), []).append(
str(e.get("urteil", "")).strip())
except (TypeError, ValueError):
continue
for i, a in enumerate(offene, 1):
urteile = je.get(i, [])
einstimmig = len(urteile) >= 2 and len(set(urteile)) == 1
if einstimmig and urteile[0] in ("behoben", "kein_mangel"):
db.update("auftraege", "id", a["id"], status=urteile[0])
elif einstimmig and urteile[0] == "faktenbasis":
db.update("auftraege", "id", a["id"], status="eskaliert")
elif a["runden"] + 1 >= AUFTRAG_RUNDEN_MAX:
db.update("auftraege", "id", a["id"], status="eskaliert")
else:
db.update("auftraege", "id", a["id"], runden=a["runden"] + 1)
async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene
— sonst aufersteht ein totgestimmter Fehlalarm als frische Zeile."""
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
verworfen = db.query("SELECT detail FROM auftraege WHERE baustein_id=?"
" AND status IN ('kein_mangel','eskaliert')", (b["id"],))
res = await llm.call(ctx, stage="pruefer", template="Guide-Pruefer",
schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}",
werte={"ziel": ziel["text"], "fakten": fakten,
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]},
item=f"b{b['id']}",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"geprueft": "\n".join(f"- {v['detail']}" for v in verworfen)
or "(keine)"},
erwartet=dict)
bekannte = {a["detail"] for a in db.query(
"SELECT detail FROM auftraege WHERE baustein_id=? AND status != 'behoben'",
(b["id"],))}
for e in (res or {}).get("befunde", []):
art = str(e.get("art", "")).strip()
detail = str(e.get("detail", "")).strip()
if art in ("falsch", "luecke"):
auftraege.append(f"KRITISCH ({art}): {detail}")
elif art == "stil" and detail:
auftraege.append(detail)
auftraege = list(dict.fromkeys(auftraege)) # Duplikate (übernommene + neue) raus
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
return "fix" if auftraege else "done"
if art in ("falsch", "luecke", "stil") and detail and detail not in bekannte:
db.insert("auftraege", baustein_id=b["id"], art=art, detail=detail)
bekannte.add(detail)
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"])
if offene:
await _auftraege_urteilen(ctx, b, sec, offene, tag)
offene = _offene_auftraege(b["id"])
# Fund erst bei leerer Liste — Wachstum ist damit hart gedeckelt. Der Re-Check
# nach einem Fix urteilt nur (Rest fängt die Ebenen-QA, wie bisher).
if not offene and tag != "-re":
await _auftraege_finden(ctx, b, sec)
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
return "fix" if offene or det else "done"
async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
auftraege = db.uj(sec["befunde"])
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
auftraege = det + [f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"]
for a in offene]
if not auftraege:
return "done"
atome = _atome_von(b["id"])
@@ -501,17 +567,22 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
"auftraege": "\n".join(f"- {a}" for a in auftraege),
"fakten": fakten},
erwartet=str)
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
kritisch = any(a["art"] in ("falsch", "luecke") for a in offene)
if res:
kompakt, lang = _split_writer(res)
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
lang = _marker_platzieren(b, lang) # Marker nach dem Rewrite neu setzen
db.update("sections", "baustein_id", b["id"],
text_kompakt=kompakt, text_lang=lang, befunde=db.j([]))
text_kompakt=kompakt, text_lang=lang)
# stil: genau ein Rewrite-Versuch, dann optimistisch zu — Rückfälle
# fangen det-Checks/Fund. falsch/luecke schließt NUR das Re-Check-Urteil.
for a in offene:
if a["art"] == "stil":
db.update("auftraege", "id", a["id"], status="behoben")
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re")
return "done"
# Fix fehlgeschlagen → Original behalten, aber die Aufträge NICHT löschen:
# Fix fehlgeschlagen → Original behalten, Aufträge bleiben offen:
# unsichtbar gescheiterte Fixes hießen „done“ (aak: 23 Sections mit 60
# offenen Aufträgen, darunter KRITISCH) — die Ebenen-QA muss sie sehen.
return "done"
@@ -655,11 +726,17 @@ def messen(ctx: llm.Kontext) -> list[dict]:
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]})
# Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht
# geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate.
for auftrag in db.uj(sec["befunde"]):
befunde.append({"art": "fix_offen", "item": str(b["id"]),
"detail": str(auftrag)[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND"
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)):
befunde.append({"art": "fakten_konflikt", "item": str(b["id"]),
"detail": a["detail"][:300]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
@@ -678,6 +755,18 @@ def messen(ctx: llm.Kontext) -> list[dict]:
return befunde
def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong)."""
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim))
if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
elif alt["status"] == "kein_mangel":
db.update("auftraege", "id", alt["id"], status="eskaliert")
async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections
@@ -686,12 +775,6 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
import hashlib
ctx.ebene = EBENE
befunde = []
# topic-scoped (nicht run-scoped): ein in Run N bestätigter Fakten-Fehler bleibt
# nach Pause/Resume (Run N+1) sichtbar, sonst greift der qa_hash-Skip fälschlich.
offene_falsch = {b["item"] for b in db.query(
"SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id"
" WHERE r.topic=? AND b.ebene=? AND b.art='fachlich_falsch' AND b.status='offen'",
(ctx.topic, EBENE))}
def hash_setzen(bid: int, h: str) -> None:
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid))
@@ -701,8 +784,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
if not sec or not sec["text_lang"]:
return
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
return # unverändert und sauber — kein Re-Check
if h == sec["qa_hash"]:
return # unverändert und sauber geprüft — die auftraege-Tabelle ist
# das persistente Gedächtnis bestätigter Claims (kein Re-Check nötig)
# qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall
# bleibt die Section ungeprüft (fail-closed statt fail-open).
atome = _atome_von(b["id"])
@@ -734,6 +818,9 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
if ja >= 2:
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
"detail": claim[:200]})
# sofort persistieren (resume-sicher: nur DIESER Moment kennt den
# bestätigten Claim; qa_hash ist danach gesetzt → nie re-geprüft)
_qa_auftrag(b["id"], claim)
hash_setzen(b["id"], h) # vollständig geprüft
await llm.alle(eine(b) for b in _bausteine(ctx.topic))
@@ -749,14 +836,28 @@ def _text_sig(b_id: int) -> str:
return hashlib.sha256(roh.encode()).hexdigest()[:16]
def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt,
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)-
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab)."""
if not b_ids:
return set()
marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["runden"]) for r in db.query(
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))}
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
Stil-Befunde → direkt in den Fix (genau ein Rewrite). Rückgabe ist ehrlich:
True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt
auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch."""
"""Kritisches + offene Aufträge → zurück auf pruefer (Urteil/Re-Check mit
vollem Kontext); reine det-Stil-Befunde → direkt in den Fix (genau ein
Rewrite). Rückgabe ist ehrlich: True nur bei Textänderung ODER Auftrag-
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
for b in befunde:
if b["art"] == "fakten_konflikt":
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
try:
b_id = int(b["item"])
except (ValueError, TypeError):
@@ -766,46 +867,28 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
return False
geroutet: list[int] = []
for b_id, liste in betroffen.items():
arten = [x["art"] for x in liste]
# ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch
kritisch_detail = any(x["art"] == "fix_offen"
and str(x["detail"]).startswith("KRITISCH") for x in liste)
kritisch = any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail
if not kritisch:
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird
# eingefroren — kein weiteres Routing der Stil-/Längen-/vorwaerts-
# Befunde. Rest-Schuld (Gewicht 0.5) akzeptiert; bremst den 617-Churn.
arten = {x["art"] for x in liste}
if "section_fehlt" in arten:
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
elif arten & ({"auftrag_offen"} | set(KRITISCH)):
# Urteile sind der Fortschrittsmechanismus — der Fix-Freeze gilt hier
# NIE, sonst wären Aufträge gefrorener Sections wieder unsterblich.
db.update("sections", "baustein_id", b_id, stage="pruefer")
else:
# Fix-Cap: eine Section, deren Fix K-mal nichts geändert hat, wird für
# det-Stil-Befunde eingefroren. Rest-Schuld (Gewicht 0.5) akzeptiert.
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", (b_id,))
if sec and sec["fix_versuche"] >= FIX_MAX_VERSUCHE:
continue
if kritisch:
if "section_fehlt" in arten:
db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,))
else:
# KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie
# sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix)
vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?",
(b_id,))["befunde"])
neu = [f"KRITISCH ({x['art']}): {x['detail']}" for x in liste
if x["art"] in KRITISCH]
zusammen = list(dict.fromkeys(vorhanden + neu))
db.update("sections", "baustein_id", b_id, stage="pruefer",
befunde=db.j(zusammen))
else:
# bestehende Fix-Aufträge (fix_offen) behalten, Stil-Details anhängen
vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?",
(b_id,))["befunde"])
neu = [f"Behebe ({x['art']}): {x['detail']}" for x in liste
if x["art"] != "fix_offen"]
auftraege = list(dict.fromkeys(vorhanden + neu))
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
stand_vorher = _auftrag_stand(geroutet)
await bauen(ctx)
bewegt = False
bewegt = _auftrag_stand(geroutet) != stand_vorher
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != vorher[b_id]:
if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))
bewegt = True
else: