This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

460
backend/guide.py Normal file
View File

@@ -0,0 +1,460 @@
"""Stufe 4: Writer (DELIMITED, „bekannt"-Kontext, Verweis statt Wiederholung)
→ deterministische Prüfung → 2er-Prüfer-Panel (qa_hash, fail-closed) → Fix
(Cap 3, Klärung 3er einmalig). Gate re-prüft die harten Checks selbst."""
import hashlib
import re
from . import belege, config, db, engine, llm, panels, textkit
_MARKER_RE = re.compile(r"<!--\s*atom:\s*(\d+)\s*-->")
def _baustein(bid: int) -> dict:
return db.one("SELECT * FROM bausteine WHERE id=?", bid)
def _atome(bid: int) -> list[dict]:
return db.query("SELECT * FROM atome WHERE baustein_id=? AND status='aktiv' "
"ORDER BY ord", bid)
def _band(n_atome: int) -> tuple[int, int]:
lo, hi = config.SECTION_WOERTER_PRO_ATOM
return lo * n_atome, hi * n_atome
def _slug(titel: str) -> str:
s = textkit.norm(titel).replace(" ", "-")
return re.sub(r"[^a-z0-9äöüß-]", "", s)
def _bekannt_kontext(topic: str, baustein: dict) -> str:
"""Bereits behandelte Atome (frühere Kapitel/Bausteine) als Linkziele."""
fruehere = db.query(
"SELECT a.titel, b2.titel bt FROM atome a JOIN bausteine b2 ON "
"b2.id=a.baustein_id JOIN kapitel k2 ON k2.id=b2.kapitel_id "
"JOIN bausteine b ON b.id=? JOIN kapitel k ON k.id=b.kapitel_id "
"WHERE a.topic=? AND a.status='aktiv' AND "
"(k2.ord < k.ord OR (k2.ord=k.ord AND b2.ord < b.ord))",
baustein["id"], topic)
if not fruehere:
return "-"
return "\n".join(f"- {f['titel']} (im Abschnitt '{f['bt']}')"
for f in fruehere[:60])
@engine.worker("guide.writer")
async def writer(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
b = _baustein(bid)
atome = _atome(bid)
if not atome:
return engine.Ergebnis(daten={"skip": True})
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
if sec and sec["text"] and sec["stage"] != "writer" and \
not payload.get("neu"): # Resume-Skip — außer bei Neuschreiben
return engine.Ergebnis(daten={"skip": True}, neue_tasks=[
{"knoten": "det_pruefung", "item": task["item"].replace("baustein", "det")}])
# Prosa-Form statt Feldnamen — „TITEL:/DEFINITION:" leakte 41× in Guides
atome_text = "\n".join(
f"<!-- atom: {a['id']} -->\n**{a['titel']}** — {a['definition']}"
for a in atome)
lo, hi = _band(len(atome))
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="writer", item=task["item"],
role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "writer"],
werte={"baustein": b["titel"], "atome": atome_text,
"belege": belege.fenster_fuer_atome([a["id"] for a in atome])[:40000],
"bekannt": _bekannt_kontext(task["topic"], b),
"band": f"{lo}-{hi}"})
if antwort is None:
raise RuntimeError("writer ohne Ergebnis")
text = textkit.block_text(antwort, "TEXT") or antwort
text = re.sub(r"^=+\s*$", "", text, flags=re.MULTILINE).strip()
# Führende Baustein-Titel-Überschrift entfernen — die setzt die Montage
m = re.match(r"^#{1,3}\s+(.+)\n+", text)
if m and textkit.jaccard(m.group(1), b["titel"], gestemmt=False) >= 0.6:
text = text[m.end():]
versuche = 0 if payload.get("neu") else \
(sec or {}).get("fix_versuche") or 0 # Neuschreiben resettet den Fix-Cap
db.execute("INSERT OR REPLACE INTO sections(baustein_id, stage, text, "
"qa_hash, fix_versuche) VALUES(?,?,?,?,?)",
bid, "geprueft", text, "", versuche)
return engine.Ergebnis(daten={"woerter": len(text.split())}, neue_tasks=[
{"knoten": "det_pruefung", "item": task["item"].replace("baustein", "det"),
"payload": {"baustein_id": bid}}])
def _det_befunde(topic: str, bid: int, text: str) -> list[dict]:
atome = _atome(bid)
eigene = {a["id"] for a in atome}
marker = [int(m) for m in _MARKER_RE.findall(text)]
befunde = []
for a in atome:
if marker.count(a["id"]) != 1:
befunde.append({"art": "marker_fehlend" if a["id"] not in marker
else "marker_doppelt",
"item": f"baustein:{bid}",
"detail": f"Atom {a['id']} ({a['titel']})"})
for m in set(marker) - eigene:
befunde.append({"art": "marker_fremd", "item": f"baustein:{bid}",
"detail": f"Atom {m} gehört nicht zum Baustein"})
lo, hi = _band(len(atome))
woerter = len(re.sub(r"<!--.*?-->", "", text).split())
if not lo * 0.75 <= woerter <= hi * 1.25:
befunde.append({"art": "laenge", "item": f"baustein:{bid}",
"detail": f"{woerter} Wörter, Band {lo}-{hi}"})
leak = re.search(r"^(TITEL|TYP|DEFINITION|ZITAT|BELEG):", text, re.MULTILINE)
if leak:
befunde.append({"art": "format_leak", "item": f"baustein:{bid}",
"detail": f"Rohformat im Text: {leak.group(0)}"})
# Lesefluss-Checks: linear lesbar, keine Sprünge, keine Echo-Zeilen
link = re.search(r"\]\(#[^)]*\)", text)
if link:
befunde.append({"art": "link_im_text", "item": f"baustein:{bid}",
"detail": "Anker-Link im Fließtext (Rückbezug als Prosa)"})
echo = re.search(r"^\*\*[^*\n]+\*\*\s+—", text, re.MULTILINE)
if echo:
befunde.append({"art": "atom_echo", "item": f"baustein:{bid}",
"detail": f"Fettzeilen-Echo: {echo.group(0)[:60]}"})
else:
# Semantisches Echo: erster Satz nach dem Marker ≈ Atom-Definition
# (fängt Fettzeilen OHNE Gedankenstrich, Gutachten-Rückfall Z.294/297)
for a in atome:
m = re.search(rf"<!--\s*atom:\s*{a['id']}\s*-->\s*\n+(.+)", text)
if not m:
continue
erster = textkit.satz_split(m.group(1))
if erster and textkit.jaccard(erster[0], a["definition"]) >= \
config.ECHO_JACCARD:
befunde.append({"art": "atom_echo", "item": f"baustein:{bid}",
"detail": f"Definition-Echo bei Atom {a['id']}: "
f"{erster[0][:60]}"})
break
h = re.search(r"^#{1,3}\s", text, re.MULTILINE)
if h:
befunde.append({"art": "h_ebene", "item": f"baustein:{bid}",
"detail": "H1-H3 im Section-Text (nur #### erlaubt)"})
# Vorwärtsverweise: Titel von Atomen SPÄTERER Kapitel fallen nicht früher
b = _baustein(bid)
spaetere = db.query(
"SELECT a.titel FROM atome a JOIN bausteine b2 ON b2.id=a.baustein_id "
"JOIN kapitel k2 ON k2.id=b2.kapitel_id JOIN bausteine b ON b.id=? "
"JOIN kapitel k ON k.id=b.kapitel_id WHERE a.topic=? AND "
"a.status='aktiv' AND k2.ord > k.ord", bid, topic)
# Verlinkte Erwähnungen sind KEIN Vorwärtsverweis — „Verweis statt
# Wiederholung" ist ja genau das gewollte Muster
ohne_links = re.sub(r"\[[^\]]*\]\([^)]*\)", " ", text)
text_norm = textkit.norm(ohne_links)
for s in spaetere:
titel_norm = textkit.norm(s["titel"])
if len(titel_norm) >= 12 and titel_norm in text_norm:
befunde.append({"art": "vorwaerts", "item": f"baustein:{bid}",
"detail": f"'{s['titel']}' wird erst später gelehrt"})
return befunde
@engine.worker("guide.det_pruefung")
async def det_pruefung(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
befunde = _det_befunde(task["topic"], bid, sec["text"]) if sec else []
# Nur Befunde schließen, die es LIVE nicht mehr gibt — fortbestehende
# Zeilen behalten ihre Historie (geklaert-Flag, Eskalations-Stand)
frisch = {(b["art"], b["item"], b["detail"][:400]) for b in befunde}
for row in db.query(
"SELECT id, art, item, detail FROM befunde WHERE item=? AND "
"status='offen' AND art IN ('marker_fehlend','marker_fremd',"
"'marker_doppelt','vorwaerts','laenge','format_leak',"
"'link_im_text','atom_echo','h_ebene')",
f"baustein:{bid}"):
if (row["art"], row["item"], row["detail"][:400]) not in frisch:
db.update("befunde", "id=?", (row["id"],), status="veraltet")
for b in befunde:
befund_merken(task["run_id"], "det_pruefung", b["art"], b["item"],
b["detail"], task["runde"])
return engine.Ergebnis(daten={"befunde": len(befunde)}, neue_tasks=[
{"knoten": "llm_pruefung", "item": task["item"].replace("det", "pruef"),
"payload": {"baustein_id": bid}}])
@engine.worker("guide.llm_pruefung", buendel=True)
async def llm_pruefung(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
neue = [{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}]
teil, aktive = {}, {} # bid → (mitglied, section, hash)
for m in mitglieder:
bid = db.uj(m["payload"], {})["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
if sec is None or not sec["text"]:
teil[m["id"]] = "fertig"
continue
hash_ = hashlib.sha256(sec["text"].encode()).hexdigest()[:16]
if sec["qa_hash"] == hash_: # unverändert seit letzter Prüfung
teil[m["id"]] = "fertig"
continue
aktive[bid] = (m, sec, hash_)
if not aktive:
return engine.Ergebnis(daten={"skip": True}, neue_tasks=neue,
teil_status=teil)
sections_text = "\n\n".join(
f"=== SECTION {bid} ===\n{sec['text']}\n\n"
f"--- BELEGE für SECTION {bid} ---\n"
+ belege.fenster_fuer_atome([a["id"] for a in _atome(bid)])[:30000]
for bid, (_, sec, _) in aktive.items())
async def ruf(i):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="llm_pruefung",
item=f"{task['item']}:p{i}", role="judge", n=len(aktive),
skill_namen=["richter", "deutsch-praezise", "guide-pruefung"],
werte={"sections": sections_text})
if text is None:
return None
return {"befunde": [b for b in textkit.bloecke(text, "BEFUND")
if b.get("art") in ("falsch", "luecke", "stil")
and b.get("section", "").strip().isdigit()],
"ok": {int(b["section"]) for b in textkit.bloecke(text, "OK")
if b.get("section", "").strip().isdigit()}}
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
gueltig = [s for s in stimmen if s is not None]
if len(gueltig) < config.PRUEF_PANEL:
# fail-closed: qa_hash NICHT setzen — nächste Runde prüft erneut
for _, (m, _, _) in aktive.items():
teil[m["id"]] = "fertig"
return engine.Ergebnis(daten={"panel_ausfall": True}, neue_tasks=neue,
teil_status=teil)
gesamt = 0
for bid, (m, sec, hash_) in aktive.items():
je_stimme = [[b for b in s["befunde"] if int(b["section"]) == bid]
for s in gueltig]
if not all(bid in s["ok"] or je_stimme[k]
for k, s in enumerate(gueltig)):
teil[m["id"]] = "neu" # Section fehlt in einer Stimme → neuer Versuch
continue
bestaetigt = []
for b in je_stimme[0]: # nur DOPPELT bestätigte Befunde zählen (Lektion 15)
for anderes in je_stimme[1:]:
if any(b["art"] == b2.get("art") and textkit.jaccard(
b.get("stelle", ""), b2.get("stelle", ""),
gestemmt=False) >= 0.3 for b2 in anderes):
bestaetigt.append(b)
break
for b in bestaetigt:
befund_merken(task["run_id"], "llm_pruefung", b["art"],
f"baustein:{bid}",
f"{b.get('stelle', '')[:150]}{b.get('detail', '')[:200]}",
task["runde"])
db.update("sections", "baustein_id=?", (bid,), qa_hash=hash_)
offen = db.query("SELECT id FROM befunde WHERE stufe='guide' AND item=? "
"AND status='offen'", f"baustein:{bid}")
if offen and (sec["fix_versuche"] or 0) < config.FIX_MAX_VERSUCHE:
neue.append({"knoten": "fix", "item": f"r{task['runde']}:fix:{bid}",
"payload": {"baustein_id": bid}})
teil[m["id"]] = "fertig"
gesamt += len(bestaetigt)
return engine.Ergebnis(daten={"befunde": gesamt}, neue_tasks=neue,
teil_status=teil)
@engine.worker("guide.fix")
async def fix(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
bid = payload["baustein_id"]
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
offen = db.query("SELECT * FROM befunde WHERE item=? AND status='offen'",
f"baustein:{bid}")
if sec is None or not offen:
return engine.Ergebnis(daten={"skip": True})
if task["art"] == "klaerung": # 3er-Stichentscheid, je Befund genau einmal
return await _klaerung(task, bid, sec, offen)
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix", item=task["item"],
role="writer",
skill_namen=["autor", "deutsch-praezise", "guide-lesbarkeit", "guide-fix"],
werte={"section": sec["text"],
"befunde": "\n".join(f"- [{b['art']}] {b['detail']}" for b in offen),
"belege": belege.fenster_fuer_atome(
[a["id"] for a in _atome(bid)])[:30000]})
if antwort is None:
raise RuntimeError("fix ohne Ergebnis")
text = textkit.block_text(antwort, "TEXT")
alte_marker = sorted(_MARKER_RE.findall(sec["text"]))
if not text or sorted(_MARKER_RE.findall(text)) != alte_marker:
# Fix, der die Marker-Invariante bricht, wird verworfen
db.update("sections", "baustein_id=?", (bid,),
fix_versuche=(sec["fix_versuche"] or 0) + 1)
return engine.Ergebnis(daten={"verworfen": True})
db.update("sections", "baustein_id=?", (bid,), text=text, qa_hash="",
fix_versuche=(sec["fix_versuche"] or 0) + 1, stage="fix")
for b in offen:
db.update("befunde", "id=?", (b["id"],), status="behoben")
return engine.Ergebnis(daten={"gefixt": len(offen)})
async def _klaerung(task, bid, sec, offen) -> engine.Ergebnis:
beleg_text = belege.fenster_fuer_atome([a["id"] for a in _atome(bid)])[:20000]
for b in offen:
if b["geklaert"]:
continue
async def ruf(i, b=b):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="fix",
item=f"{task['item']}:k{b['id']}:p{i}", role="judge",
skill_namen=["richter", "deutsch-praezise", "klaerung"],
werte={"befund": f"[{b['art']}] {b['detail']}", "belege": beleg_text})
urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil") if urteile else None
stimmen = await panels.panel(ruf, config.KLAERUNG_PANEL)
ist_mangel = panels.mehrheit(
stimmen, lambda s: True if s == "mangel"
else (False if s == "kein_mangel" else None))
db.update("befunde", "id=?", (b["id"],), geklaert=1,
status="offen" if ist_mangel else "freispruch")
return engine.Ergebnis(daten={"geklaert": len(offen)})
@engine.worker("guide.kapitel_intro")
async def kapitel_intro(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
kap = db.one("SELECT * FROM kapitel WHERE id=?", payload["kapitel_id"])
if kap is None:
return engine.Ergebnis(daten={"skip": True})
if kap["intro"]:
return engine.Ergebnis(daten={"skip": True},
neue_tasks=[{"knoten": "redundanz",
"item": f"r{task['runde']}:redundanz"}])
titel = db.query("SELECT titel FROM bausteine WHERE kapitel_id=? ORDER BY ord",
kap["id"])
vorherige = db.query("SELECT titel FROM kapitel WHERE topic=? AND ord<? "
"ORDER BY ord", task["topic"], kap["ord"])
antwort = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="kapitel_intro",
item=task["item"], role="writer",
skill_namen=["autor", "deutsch-praezise", "kapitel-intro"],
werte={"kapitel": kap["titel"],
"sections": "\n".join(f"- {t['titel']}" for t in titel),
"vorherige": "\n".join(f"- {v['titel']}" for v in vorherige)
or "- (keine)"})
intro = textkit.block_text(antwort or "", "INTRO")
if not intro: # Ausfall/Format kaputt: neuer Versuch statt leerem Intro
raise RuntimeError("kapitel_intro ohne INTRO-Block")
db.update("kapitel", "id=?", (kap["id"],), intro=intro[:800])
return engine.Ergebnis(daten={"intro": True}, neue_tasks=[
{"knoten": "redundanz", "item": f"r{task['runde']}:redundanz"}])
def befund_merken(run_id: int, knoten: str, art: str, item: str, detail: str,
runde: int) -> bool:
"""Befund-Dedupe (creator2-Schlüssel art+item+detail): existiert derselbe
Befund offen oder final (eskaliert/freispruch), KEINE neue Zeile — sonst
startet die Eskalations-Kette (geklaert-Flag!) jede Runde von vorn."""
kern = detail[:400]
if db.one("SELECT id FROM befunde WHERE art=? AND item=? AND "
"substr(detail,1,400)=? AND status IN ('offen','eskaliert',"
"'freispruch')", art, item, kern):
return False
db.insert("befunde", run_id=run_id, stufe="guide", knoten=knoten, art=art,
item=item, detail=detail[:500], runde=runde)
return True
def _rewrites(topic: str, bid) -> int:
return db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND knoten='writer' "
"AND art='reparatur' AND item LIKE ?", topic,
f"%:baustein:{bid}")["c"]
@engine.worker("guide.gate")
async def gate(task: dict) -> engine.Ergebnis:
topic, runde = task["topic"], task["runde"]
befunde, neue = [], []
bausteine = db.query("SELECT b.id FROM bausteine b WHERE b.topic=?", topic)
# Final entschiedene det-Befunde (Klärung/Eskalation) blocken nicht erneut
final = {(r["art"], r["item"], r["detail"][:400]) for r in db.query(
"SELECT art, item, detail FROM befunde WHERE status IN "
"('freispruch','eskaliert') AND run_id IN (SELECT id FROM runs WHERE "
"topic=?)", topic)}
alle_marker: list[int] = []
for b in bausteine:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", b["id"])
if sec is None or not sec["text"]:
befunde.append({"art": "section_fehlt", "item": f"baustein:{b['id']}",
"detail": ""})
continue
alle_marker += [int(m) for m in _MARKER_RE.findall(sec["text"])]
for f in _det_befunde(topic, b["id"], sec["text"]):
if f["art"] == "laenge": # Länge blockiert nicht hart (nur Befund)
continue
if (f["art"], f["item"], f["detail"][:400]) in final:
continue
befunde.append(f)
for m in {m for m in alle_marker if alle_marker.count(m) > 1}:
befunde.append({"art": "marker_global_doppelt", "item": f"atom:{m}",
"detail": "Atom in mehreren Sections gelehrt"})
# Kaputte Marker mit erschöpftem Fix → Section NEU schreiben (max 1×!
# sonst resettet jedes Neuschreiben den Fix-Cap → Endlos-Karussell)
for f in [f for f in befunde if f["art"].startswith("marker")]:
bid = f["item"].split(":")[-1]
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
if sec and (sec["fix_versuche"] or 0) >= config.FIX_MAX_VERSUCHE \
and _rewrites(topic, bid) < 1:
neue.append({"knoten": "writer", "item": f"r{runde + 1}:baustein:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid), "neu": True}})
# Inhaltliche Befunde (LLM/Redundanz): Fix, danach 3er-Klärung
offene = db.query(
"SELECT * FROM befunde b WHERE b.run_id IN (SELECT id FROM runs WHERE "
"topic=?) AND b.stufe='guide' AND b.status='offen' AND b.art IN "
"('falsch','luecke','redundanz','vorwaerts','format_leak')", topic)
for b in offene:
befunde.append({"art": b["art"], "item": b["item"],
"detail": b["detail"][:120]})
bid = b["item"].split(":")[-1]
if not bid.isdigit():
continue
sec = db.one("SELECT fix_versuche FROM sections WHERE baustein_id=?", bid)
versuche = (sec or {}).get("fix_versuche") or 0
if versuche < config.FIX_MAX_VERSUCHE:
neue.append({"knoten": "fix", "item": f"r{runde + 1}:fix:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid)}})
elif not b["geklaert"]:
neue.append({"knoten": "fix", "item": f"r{runde + 1}:klaerung:{bid}",
"art": "klaerung", "runde": runde + 1,
"payload": {"baustein_id": int(bid)}})
elif _rewrites(topic, bid) < 1:
neue.append({"knoten": "writer", "item": f"r{runde + 1}:baustein:{bid}",
"art": "reparatur", "runde": runde + 1,
"payload": {"baustein_id": int(bid), "neu": True}})
else:
# Exit nach Fix-Cap + 3er-Klärung + 1 Neuschreiben: eskalieren
# statt ewig blocken — 3 Instanzen haben geprüft, Rest ist
# dokumentierte Grenze (sichtbar in den Kennzahlen)
db.update("befunde", "id=?", (b["id"],), status="eskaliert")
befunde.pop() # zählt nicht mehr als gate-blockierend
if befunde:
# Re-Prüfung der gefixten Sections in der nächsten Runde
for bid in {b["item"].split(":")[-1] for b in befunde
if b["item"].startswith("baustein:")}:
if bid.isdigit():
neue.append({"knoten": "det_pruefung",
"item": f"r{runde + 1}:det:{bid}", "art": "reparatur",
"runde": runde + 1, "payload": {"baustein_id": int(bid)}})
neue.append({"knoten": "redundanz", "item": f"r{runde + 1}:redundanz",
"art": "reparatur", "runde": runde + 1})
return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue)
db.update("topics", "name=?", (topic,), status="guide_fertig")
db.execute("UPDATE sections SET stage='fertig' WHERE baustein_id IN "
"(SELECT id FROM bausteine WHERE topic=?)", topic)
return engine.Ergebnis(gate_status="gruen", neue_tasks=[
{"knoten": "montage", "item": "montage"}])