This commit is contained in:
team3
2026-07-14 00:15:03 +02:00
parent af0e3c0629
commit 3f6114681c
22 changed files with 757 additions and 307 deletions

View File

@@ -19,8 +19,8 @@ import jsonx
import llm
import belege
import textkit
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, LEVEL_RANG,
SECTION_WOERTER_PRO_ATOM)
from config import (AUFTRAG_RUNDEN_MAX, DURCHGANG, FIX_MAX_VERSUCHE, KLAERUNG_PANEL,
LEVEL_RANG, SECTION_WOERTER_PRO_ATOM)
log = logging.getLogger("creator2.guide")
@@ -186,13 +186,14 @@ def _laenge_band(n_atome: int) -> tuple[int, int]:
def _anknuepf_kontext(b: dict) -> str:
"""Was der Leser aus früheren Durchgängen schon kennt: tieferstufige Atome
desselben Ziels. Der Writer knüpft an, statt neu zu erklären
(Dopplungs-Schutz zwischen den Durchgängen)."""
desselben Ziels — aber NIE Atome dieses Bausteins selbst (seit Level =
dominantes Atom-Level enthält ein M-Baustein auch eigene E-Atome; die
stünden sonst doppelt im Writer-Prompt: als bekannt UND als zu schreiben)."""
rang = LEVEL_RANG.get(b["level"], 1)
kontext: dict[int, dict] = {}
for a in db.query("SELECT * FROM atome WHERE ziel_id=? AND status NOT IN"
" ('gemerged','verworfen')", (b["ziel_id"],)):
if LEVEL_RANG.get(a["level"], 1) < rang:
if a["baustein_id"] != b["id"] and LEVEL_RANG.get(a["level"], 1) < rang:
kontext[a["id"]] = a
return "\n".join(f"- {a['titel']}: {a['definition']}" for a in kontext.values())
@@ -454,6 +455,14 @@ def _offene_auftraege(b_id: int) -> list[dict]:
" ORDER BY id", (b_id,))
def _klaerung_kandidaten(b_id: int) -> list[dict]:
"""Eskalierte falsch/luecke, die noch nie geklärt wurden. eskaliert+geklaert=1
ist der terminale Endzustand (unauflösbar) — Klärung läuft je Auftrag genau
einmal, auch nach Re-Eskalation (strukturell, kein Sondercode)."""
return db.query("SELECT * FROM auftraege WHERE baustein_id=? AND status='eskaliert'"
" AND art IN ('falsch','luecke') AND geklaert=0 ORDER BY id", (b_id,))
def _fakten_von(atome: list[dict]) -> str:
return "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
+ "\n".join(_quote(z) for z in _zitate(a["id"])) for a in atome)
@@ -499,6 +508,54 @@ async def _auftraege_urteilen(ctx: llm.Kontext, b: dict, sec: dict,
db.update("auftraege", "id", a["id"], runden=a["runden"] + 1)
async def _klaeren(ctx: llm.Kontext, b: dict) -> None:
"""Stichentscheid (Lektion 19) über eskalierte falsch/luecke: EIN 3er-Panel
je Baustein, Mehrheit entscheidet. text_falsch → Auftrag wieder offen (der
Fix bekommt ein Streich-Mandat); unbegruendet → kein_mangel; quelle_unklar
oder Voll-Patt → terminal unauflösbar. Unter 3 gültigen Stimmen KEINE
Entscheidung (fail-closed, Lektion 20) — nächste Runde erneut."""
kand = _klaerung_kandidaten(b["id"])
if not kand:
return
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
return
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
liste = "\n".join(f"{i}: [{a['art']}] {a['detail']}" for i, a in enumerate(kand, 1))
stimmen = await llm.panel(ctx, KLAERUNG_PANEL, stage="klaerung",
template="Guide-Konflikt-Klaerung", schritt="klaerung",
role="judge", n=len(kand), item=f"b{b['id']}-klaerung",
werte={"ziel": ziel["text"], "fakten": _fakten_von(atome),
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": liste},
erwartet=list)
je: dict[int, list[str]] = {}
for s in stimmen:
for e in s:
if isinstance(e, dict):
try:
urteil = str(e.get("urteil", "")).strip()
if urteil in ("text_falsch", "unbegruendet", "quelle_unklar"):
je.setdefault(int(e.get("auftrag")), []).append(urteil)
except (TypeError, ValueError):
continue
for i, a in enumerate(kand, 1):
urteile = je.get(i, [])
if len(urteile) < KLAERUNG_PANEL:
continue # Rumpf-Panel → keine Entscheidung, bleibt geklaert=0
mehrheit = max(set(urteile), key=urteile.count)
if urteile.count(mehrheit) * 2 <= len(urteile):
mehrheit = "quelle_unklar" # Voll-Patt (1/1/1) → terminal
# EIN atomares Update je Auftrag → crash-/resume-sicher
if mehrheit == "text_falsch":
db.update("auftraege", "id", a["id"], status="offen", runden=0, geklaert=1)
elif mehrheit == "unbegruendet":
db.update("auftraege", "id", a["id"], status="kein_mangel", geklaert=1)
else:
db.update("auftraege", "id", a["id"], geklaert=1) # bleibt eskaliert
async def _auftraege_finden(ctx: llm.Kontext, b: dict, sec: dict) -> None:
"""Fund-Modus: läuft nur bei leerer Auftragsliste. Bereits verworfene Details
gehen als Negativ-Liste mit, Inserts dedupen exakt gegen alles Nicht-Behobene
@@ -545,9 +602,19 @@ async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
offene = _offene_auftraege(b["id"])
det = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
auftraege = det + [f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"]
for a in offene]
def _auftrag_zeile(a: dict) -> str:
zeile = (f"KRITISCH ({a['art']}): {a['detail']}"
if a["art"] in ("falsch", "luecke") else a["detail"])
# Klärungs-Vermerk generiert, NICHT im detail persistiert — der eingefrorene
# Wortlaut ist die Dedup-Invariante der auftraege-Tabelle
if a["geklaert"] and a["art"] in ("falsch", "luecke"):
zeile += (" [KLÄRUNG: 3er-Panel bestätigt — der Text widerspricht hier der"
" Faktenbasis. Anhand der Faktenbasis korrigieren; ist die Passage"
" dort nicht belegbar, STREICHEN oder aufs Belegbare abschwächen.]")
return zeile
auftraege = det + [_auftrag_zeile(a) for a in offene]
if not auftraege:
return "done"
atome = _atome_von(b["id"])
@@ -718,15 +785,22 @@ def messen(ctx: llm.Kontext) -> list[dict]:
befunde.append({"art": "det_check", "item": str(b["id"]),
"detail": auftrag[:300]})
# Offene Aufträge sichtbar machen — sonst passiert eine bekannte Lücke das
# Gate. Eskalierte falsch/luecke = Faktenbasis-Konflikt: ehrliche Rest-
# Schuld, wird nie geroutet; eskalierter Stil bleibt stumm (nur DB).
# Gate. Eskalierte dreiteilig: ungeklärt → klaerung_offen (reparierbar,
# Stichentscheid), geklärt → fakten_konflikt (terminal, kleine ehrliche
# Rest-Schuld), Stil → stil_rest (terminal, war vorher komplett stumm).
for a in db.query("SELECT art, detail FROM auftraege WHERE baustein_id=?"
" AND status='offen'", (b["id"],)):
befunde.append({"art": "auftrag_offen", "item": str(b["id"]),
"detail": f"({a['art']}) {a['detail']}"[:300]})
for a in db.query("SELECT detail FROM auftraege WHERE baustein_id=? AND"
" status='eskaliert' AND art IN ('falsch','luecke')", (b["id"],)):
befunde.append({"art": "fakten_konflikt", "item": str(b["id"]),
for a in db.query("SELECT art, detail, geklaert FROM auftraege WHERE"
" baustein_id=? AND status='eskaliert'", (b["id"],)):
if a["art"] == "stil":
art = "stil_rest"
elif a["geklaert"]:
art = "fakten_konflikt"
else:
art = "klaerung_offen"
befunde.append({"art": art, "item": str(b["id"]),
"detail": a["detail"][:300]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
@@ -748,13 +822,15 @@ def messen(ctx: llm.Kontext) -> list[dict]:
def _qa_auftrag(b_id: int, claim: str) -> None:
"""Panel-bestätigter Fakten-Claim → Auftragszeile (quelle qa). Exakter Dedup
gegen alles Nicht-Behobene; trifft er eine kein_mangel-Zeile, widersprechen
sich die Richter → eskaliert (Faktenbasis klären, kein weiteres Ping-Pong)."""
gegen alles Nicht-Behobene; trifft er eine offene ODER kein_mangel-Zeile,
widersprechen sich zwei Panels → eskaliert = ab in den Klärungs-Stichentscheid.
geklaert wird NIE zurückgesetzt: war der Auftrag schon geklärt, ist das
terminal (deckelt QA↔Klärung-Ping-Pong hart auf einen Durchlauf)."""
alt = db.one("SELECT id, status FROM auftraege WHERE baustein_id=? AND detail=?"
" AND status != 'behoben'", (b_id, claim))
if alt is None:
db.insert("auftraege", baustein_id=b_id, art="falsch", detail=claim, quelle="qa")
elif alt["status"] == "kein_mangel":
elif alt["status"] in ("offen", "kein_mangel"):
db.update("auftraege", "id", alt["id"], status="eskaliert")
@@ -790,8 +866,10 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
erwartet=list)
if verdacht is None:
return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash
claims = [str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")]
# dedupen — ein doppelt gemeldeter Claim eskalierte sonst im selben
# Durchlauf seine eigene frisch eingefügte Zeile
claims = list(dict.fromkeys(str(c.get("claim", "")).strip() for c in verdacht
if isinstance(c, dict) and c.get("claim")))
if not claims:
hash_setzen(b["id"], h) # sauber geprüft
return
@@ -828,14 +906,16 @@ def _text_sig(b_id: int) -> str:
def _auftrag_stand(b_ids: list[int]) -> set:
"""Zustands-Snapshot der Aufträge dieser Bausteine — Urteile sind Fortschritt,
auch ohne Textänderung (die Note bewegt sich erst unter der min(1, n/basis)-
Sättigung; ohne dies bräche auto_loop reine Urteilsrunden als Stillstand ab)."""
"""Zustands-Snapshot der Aufträge dieser Bausteine. Status- und geklaert-
Übergänge sind Fortschritt (auch ohne Textänderung); ein reines runden-
Inkrement zählt NICHT — identisches Panel-Patt auf identischem Text ist
per Definition Leer-Churn und hielt auto_loop sonst bis LOOP_MAX_ITER am
Leben. Echte Urteilsrunden schließen/eskalieren etwas → Statuswechsel."""
if not b_ids:
return set()
marks = ",".join("?" * len(b_ids))
return {(r["id"], r["status"], r["runden"]) for r in db.query(
f"SELECT id, status, runden FROM auftraege WHERE baustein_id IN ({marks})",
return {(r["id"], r["status"], r["geklaert"]) for r in db.query(
f"SELECT id, status, geklaert FROM auftraege WHERE baustein_id IN ({marks})",
tuple(b_ids))}
@@ -846,16 +926,29 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
Statusübergang — sonst erkennt auto_loop nie Stillstand."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
klaerbar: set[int] = set()
for b in befunde:
if b["art"] == "fakten_konflikt":
continue # eskaliert = Faktenbasis-Problem, kein Rewrite kann das lösen
if b["art"] in ("fakten_konflikt", "stil_rest"):
continue # terminal: geklärt-unauflösbar bzw. Stil-Rest — keine Route
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
if b["art"] == "klaerung_offen":
klaerbar.add(b_id) # Stichentscheid statt Stage-Routing
continue
betroffen.setdefault(b_id, []).append(b)
if not betroffen:
if not betroffen and not klaerbar:
return False
alle_ids = list(betroffen.keys() | klaerbar)
text_vorher = {b_id: _text_sig(b_id) for b_id in alle_ids}
stand_vorher = _auftrag_stand(alle_ids)
# Klärung VOR dem Routing: text_falsch öffnet Aufträge wieder → die routen
# unten in den Fix; unbegruendet/quelle_unklar brauchen keine Stage.
if klaerbar:
je_baustein = {bb["id"]: bb for bb in _bausteine(ctx.topic)}
await llm.alle(_klaeren(ctx, je_baustein[b_id])
for b_id in sorted(klaerbar) if b_id in je_baustein)
geroutet: list[int] = []
for b_id, liste in betroffen.items():
arten = {x["art"] for x in liste}
@@ -874,10 +967,15 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
text_vorher = {b_id: _text_sig(b_id) for b_id in geroutet}
stand_vorher = _auftrag_stand(geroutet)
for b_id in sorted(klaerbar):
# wiedereröffnete Aufträge → direkt in den Fix (der Re-Check urteilt danach;
# umgeht den Freeze bewusst — das Streich-Mandat erzeugt eine Textänderung)
if b_id not in geroutet and _offene_auftraege(b_id):
db.update("sections", "baustein_id", b_id, stage="fix")
db.update("bausteine", "id", b_id, status="repair")
geroutet.append(b_id)
await bauen(ctx)
bewegt = _auftrag_stand(geroutet) != stand_vorher
bewegt = _auftrag_stand(alle_ids) != stand_vorher
for b_id in geroutet: # Fix-Cap-Zähler pflegen: Änderung → reset, sonst +1
if _text_sig(b_id) != text_vorher[b_id]:
db.execute("UPDATE sections SET fix_versuche=0 WHERE baustein_id=?", (b_id,))