This commit is contained in:
team3
2026-07-12 16:13:50 +02:00
parent a284e03225
commit 31a42bbf1d
48 changed files with 3625 additions and 253 deletions

View File

@@ -159,7 +159,10 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
_active[key] = time.time()
try:
if use_api:
await _api_slot(key)
await _api_slot(key) # belegt _api_inflight — bei Abbruch freigeben
if _ist_abgebrochen(key):
_api_inflight -= 1
return AgentErgebnis(1, "", "abgebrochen")
wait_s = time.time() - warte_start
try:
res = await _text_api(key, prompt, timeout, model, role)
@@ -171,6 +174,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
res.model = model
return res
await _drossel_warten(key)
if _ist_abgebrochen(key): # Nachzügler nach dem Cooldown nicht mehr feuern
return AgentErgebnis(1, "", "abgebrochen")
wait_s = time.time() - warte_start
if PROVIDERS[provider]["cli"] == "opencode":
res = await _opencode(key, prompt, timeout, model, capabilities)
@@ -255,11 +260,13 @@ async def _spawn(key: str, cmd: list[str], stdin_data: bytes | None, timeout: in
try:
try:
stdout, stderr = await asyncio.wait_for(process.communicate(input=stdin_data), timeout=timeout)
except asyncio.TimeoutError:
except (asyncio.TimeoutError, asyncio.CancelledError):
# Timeout ODER Cancel (Hedge-Verlierer, LaufPause) → Prozess killen,
# sonst rechnet ein verwaister CLI-Prozess unsichtbar weiter
_kill(process)
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
except (asyncio.TimeoutError, asyncio.CancelledError):
pass
raise
log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start)
@@ -308,7 +315,14 @@ async def _opencode(key: str, prompt: str, timeout: int, model: str, capabilitie
"-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
"--dangerously-skip-permissions", "--title", key, "-f", str(pfad)]
try:
res = await _spawn(key, cmd, None, timeout)
try:
res = await _spawn(key, cmd, None, timeout)
except asyncio.TimeoutError:
# Timeout heißt nicht 0 Tokens — die Session hat oft schon zigtausend
# erzeugt. Als infra (err="timeout") mit echten Tokens zurückgeben,
# damit Budget/Ledger stimmen. CancelledError NICHT fangen (Hedge).
tokens = await asyncio.to_thread(_opencode_tokens, key)
return AgentErgebnis(1, "", "timeout", tokens or {})
res.text = _clean_opencode(res.text)
res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {}
return res

View File

@@ -44,11 +44,12 @@ def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
if nur_ohne:
# verworfene zählen nicht — sonst kann der Repair nach Verwurf nie
# nachgenerieren (Lauf 8: 5 Atome blieben dauerhaft ohne Flashcard)
# Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht).
# Prüfte man „irgendein lebendes Artefakt", blockierte ein überlebendes
# Beispiel die Flashcard-Nachgenerierung dauerhaft (Lauf 8: 5 Atome ohne).
atome = [a for a in atome if not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND status!='verworfen'"
" LIMIT 1", (a["id"],))]
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status!='verworfen' LIMIT 1", (a["id"],))]
gruppen: dict = {}
for a in atome:
gruppen.setdefault(a["soll_id"], []).append(a)
@@ -74,6 +75,12 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
if _referenziert_quelle(inhalt):
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
continue
# Nachgenerierung (nur_ohne) läuft auch, wenn nur die Flashcard fehlt —
# dann kein zweites Beispiel anlegen (Verify-Tokens/Leitner-Dubletten).
if typ == "beispiel" and db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status!='verworfen' LIMIT 1", (atom_id,)):
continue
db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat")
@@ -133,17 +140,23 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N
f" {k['typ']}): {db.j(res) if res else k['inhalt']}\n"
f"Belege:\n{_zitate(k['atom_id'])}"},
erwartet=list)
ok = any(isinstance(e, dict) and e.get("artefakt") == k["id"] and e.get("ok")
for e in urteil or [])
db.update("artefakte", "id", k["id"], status="verifiziert" if ok else "verworfen")
# Verwerfen nur bei EXPLIZITEM Negativurteil. Call-Ausfall (urteil None) oder
# ausgelassene id → kein Entscheid, Kandidat bleibt (messen meldet unentschieden,
# nächste Runde prüft per Panel neu) — sonst fail-open destruktiv.
eintrag = next((e for e in (urteil or [])
if isinstance(e, dict) and e.get("artefakt") == k["id"]), None)
if eintrag is None:
return
db.update("artefakte", "id", k["id"],
status="verifiziert" if eintrag.get("ok") else "verworfen")
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
neu = _chunks(ctx.topic, nur_ohne=True)
await asyncio.gather(*(_generieren(ctx, c) for c in neu))
await llm.alle(_generieren(ctx, c) for c in neu)
alle = _chunks(ctx.topic, nur_ohne=False)
await asyncio.gather(*(_verifizieren(ctx, c) for c in alle))
await llm.alle(_verifizieren(ctx, c) for c in alle)
def messen(ctx: llm.Kontext) -> list[dict]:

View File

@@ -10,7 +10,6 @@ STORAGE_DIR = PROJECT_ROOT / "storage"
TOPICS_DIR = PROJECT_ROOT / "topics" # Uni-Quelldateien: topics/<name>/*.txt|md|pdf
KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus/<topic>/q<id>.md
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
DB_PATH = STORAGE_DIR / os.getenv("CREATOR_DB", "creator2.db") # Override für Smoke-Läufe
def _load_env(path: Path) -> None:
@@ -32,6 +31,23 @@ def _load_env(path: Path) -> None:
_load_env(PROJECT_ROOT / ".env")
# NACH _load_env lesen — sonst ist ein in .env gesetztes CREATOR_DB wirkungslos
# (der Loader-Kommentar verspricht „die Datei gewinnt").
DB_PATH = STORAGE_DIR / os.getenv("CREATOR_DB", "creator2.db") # Override für Smoke-Läufe
# ── Topic-Namen ───────────────────────────────────────────────────────────────
import re as _re
# Erstes Zeichen alphanumerisch (kein führendes „-" = rsync/CLI-Option), danach
# nur Wortzeichen/Umlaute/- /_. Kein „.", kein „/" → sicher als Pfadkomponente
# (rmtree, Snapshot-Ordner) UND als Shell-Argument (Transfer-Makefile).
TOPIC_NAME_RE = _re.compile(r"^[A-Za-z0-9äöüÄÖÜß][\wäöüÄÖÜß-]{0,63}$")
def topic_name_ok(name: str) -> bool:
return bool(name) and TOPIC_NAME_RE.match(name) is not None
# ── Nebenläufigkeit ───────────────────────────────────────────────────────────
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # CLI-Prozesse (~310 MB RSS)
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28")) # direkte API, ~0 RAM
@@ -86,6 +102,10 @@ BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
# ── Ebene 3.5: Diagramme ──────────────────────────────────────────────────────
DIAGRAMM_MIN_KANTEN = 3 # ab so vielen internen braucht-Kanten → Abhängigkeitsdiagramm
DIAGRAMM_TYPEN = ("dependency", "state", "flow", "tree")
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
@@ -111,6 +131,9 @@ TIMEOUTS = {
"writer": (450, 60),
"pruefer": (600, 5),
"qa_judge": (600, 0),
"diagramm_plan": (200, 0),
"diagramm_spec": (300, 0),
"diagramm_verify": (200, 5),
}

View File

@@ -62,7 +62,7 @@ CREATE TABLE IF NOT EXISTS quellen(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('web','datei')),
titel TEXT NOT NULL, url TEXT DEFAULT '', snapshot TEXT NOT NULL, hash TEXT DEFAULT '',
runde INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu',
rolle TEXT NOT NULL DEFAULT 'stoff');
rolle TEXT NOT NULL DEFAULT 'stoff', atome_stand TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
@@ -96,6 +96,12 @@ CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
CREATE TABLE IF NOT EXISTS diagramme(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, baustein_id INTEGER NOT NULL,
typ TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'dag',
spec TEXT NOT NULL DEFAULT '{}', mermaid TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'geplant', befunde TEXT NOT NULL DEFAULT '[]');
CREATE INDEX IF NOT EXISTS idx_diagramme_baustein ON diagramme(baustein_id);
CREATE TABLE IF NOT EXISTS sections(
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
@@ -110,7 +116,7 @@ CREATE TABLE IF NOT EXISTS befunde(
# Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
"lernziele", "bausteine", "kapitel", "sections", "befunde", "diagramme"}
def _init_schema(con: sqlite3.Connection) -> None:
@@ -126,7 +132,8 @@ def _init_schema(con: sqlite3.Connection) -> None:
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'",
"ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
"ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'"):
"ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'",
"ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''"):
try:
con.execute(zusatz)
except sqlite3.OperationalError:
@@ -181,6 +188,14 @@ def update(tabelle: str, id_feld: str, id_wert, **felder) -> None:
_notify(tabelle, {id_feld: id_wert, **felder})
def now() -> str:
"""UTC-Zeitstempel im SQLite-datetime('now')-Format (wie runs.gestartet).
Für Timestamp-Spalten — der Literal-String „jetzt" landete sonst als Müll
in runs.beendet und machte jede Dauer-Query unbrauchbar."""
import time
return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
def j(obj) -> str:
return json.dumps(obj, ensure_ascii=False)

304
backend/diagramme.py Normal file
View File

@@ -0,0 +1,304 @@
"""Ebene 3.5: Diagramme. Pro Baustein ein Mermaid-Diagramm, wenn es Struktur oder
Prozess sichtbar macht (Multimedia-Prinzip: instruktive Bilder heben Transfer,
Deko schadet). Zuverlässigkeits-Prinzip: der LLM liefert NIE rohes Mermaid, nur
Struktur-JSON; Python kompiliert deterministisch; ein Parse-Gate (mermaid_check.mjs)
ist das Sicherheitsnetz. Abhängigkeitsdiagramme kommen direkt aus dem braucht-DAG,
ganz ohne LLM — korrekt per Konstruktion, null Halluzination.
Phasen von bauen(): planen (welche + Typ) → Spec (nur LLM-Zweig) → kompilieren →
Gate → verifizieren (Grounding). Platziert werden Diagramme erst im Guide-Writer
(E4) über `<!-- diagramm: id -->`; das Assembly tauscht den Marker gegen den Fence."""
import json
import logging
import os
import re
import subprocess
from pathlib import Path
import db
import llm
from config import DIAGRAMM_MIN_KANTEN, DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL
log = logging.getLogger("creator2.diagramme")
EBENE = "diagramme"
_MERMAID_SKRIPT = Path(__file__).resolve().parent / "mermaid_check.mjs"
_MERMAID_BASE = Path(__file__).resolve().parent.parent / "frontend"
_mermaid_gewarnt = False
def _mermaid_fehler(diagramme: list[dict]) -> dict[int, str]:
"""Parse-Gate: Diagramme durch DENSELBEN Parser wie das Frontend jagen (Zwilling
zu guide._katex_fehler). → {id: fehlermeldung} nur der fehlerhaften. Fake-Modus
überspringt (deterministisch); fail-open MIT Warnung, wenn node/mermaid fehlt."""
global _mermaid_gewarnt
if not diagramme or os.getenv("CREATOR_FAKE_AGENTS"):
return {}
try:
res = subprocess.run(
["node", str(_MERMAID_SKRIPT)],
input=json.dumps([{"id": d["id"], "code": d["mermaid"]} for d in diagramme]),
capture_output=True, text=True, timeout=60,
env={**os.environ, "MERMAID_BASE": str(_MERMAID_BASE)})
if res.returncode:
raise RuntimeError(res.stderr.strip()[:200])
return {e["id"]: e["fehler"] for e in json.loads(res.stdout)}
except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip)
if not _mermaid_gewarnt:
_mermaid_gewarnt = True
log.warning("Mermaid-Gate nicht verfügbar (%s) — Diagramm-Validierung fällt aus", e)
return {}
def _bausteine(topic: str) -> list[dict]:
return db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
def _atome_von(baustein_id: int) -> list[dict]:
return db.query("SELECT * FROM atome WHERE baustein_id=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
def _interne_kanten(topic: str, ids: set[int]) -> list[dict]:
"""aktive braucht-Kanten, deren BEIDE Enden im Baustein liegen."""
if len(ids) < 2:
return []
return [k for k in db.query(
"SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'", (topic,))
if k["von_atom"] in ids and k["zu_atom"] in ids]
# ── Spec (JSON-Zwischenrepräsentation) ────────────────────────────────────────
def _dag_spec(atome: list[dict], kanten: list[dict]) -> dict:
"""Abhängigkeitsdiagramm direkt aus dem braucht-Graph. `von braucht zu` ⇒ Pfeil
zu→von (Voraussetzung zeigt auf das, was sie ermöglicht). Kein LLM."""
knoten = [{"id": str(a["id"]), "label": a["titel"]} for a in atome]
kanten_spec = [{"von": str(k["zu_atom"]), "zu": str(k["von_atom"]), "label": ""}
for k in kanten]
return {"typ": "dependency", "knoten": knoten, "kanten": kanten_spec}
# ── Kompilieren (deterministisch, kein LLM) ───────────────────────────────────
def _sid(raw) -> str:
"""Mermaid-Knoten-ID: nur alnum/_ erlaubt, führender Buchstabe."""
return "n" + re.sub(r"[^A-Za-z0-9]", "_", str(raw))
def _lbl(text) -> str:
"""Label für Mermaid entschärfen: keine Anführungszeichen/Klammern/Zeilenumbrüche,
die die Syntax brechen. Gekürzt (Lesbarkeit)."""
t = str(text).replace('"', "'").replace("\n", " ")
t = t.replace("[", "(").replace("]", ")").replace("{", "(").replace("}", ")")
return t.strip()[:80] or "?"
def _spec_zu_mermaid(spec: dict) -> str:
"""Struktur-JSON → Mermaid-Quelltext. state → stateDiagram-v2, sonst flowchart."""
knoten = spec.get("knoten") or []
kanten = spec.get("kanten") or []
if not knoten:
return ""
if spec.get("typ") == "state":
zeilen = ["stateDiagram-v2"]
for k in kanten:
lab = _lbl(k.get("label", ""))
pfeil = f": {lab}" if lab and lab != "?" else ""
zeilen.append(f" {_sid(k['von'])} --> {_sid(k['zu'])}{pfeil}")
return "\n".join(zeilen)
zeilen = ["flowchart TD"]
for n in knoten:
zeilen.append(f' {_sid(n["id"])}["{_lbl(n["label"])}"]')
for k in kanten:
lab = _lbl(k.get("label", ""))
pfeil = f"-- {lab} -->" if lab and lab != "?" else "-->"
zeilen.append(f' {_sid(k["von"])} {pfeil} {_sid(k["zu"])}')
return "\n".join(zeilen)
def _kompilieren(topic: str) -> None:
for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'"
" AND spec!='{}'", (topic,)):
mermaid = _spec_zu_mermaid(db.uj(d["spec"], {}))
if mermaid:
db.update("diagramme", "id", d["id"], mermaid=mermaid, status="kandidat")
# ── Planung (welche + Typ) ────────────────────────────────────────────────────
def _planen(topic: str) -> None:
"""Deterministischer Zweig: Baustein mit ≥DIAGRAMM_MIN_KANTEN internen
braucht-Kanten → Abhängigkeitsdiagramm aus dem DAG. Idempotent: Bausteine mit
bestehender Diagramm-Zeile werden übersprungen. Kein LLM, null Halluzination."""
for b in _bausteine(topic):
if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)):
continue
atome = _atome_von(b["id"])
ids = {a["id"] for a in atome}
kanten = _interne_kanten(topic, ids)
if len(kanten) >= DIAGRAMM_MIN_KANTEN:
db.insert("diagramme", topic=topic, baustein_id=b["id"], typ="dependency",
quelle="dag", spec=db.j(_dag_spec(atome, kanten)), status="geplant")
def _atom_liste(atome: list[dict]) -> str:
return "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in atome)
async def _plan_judge(ctx: llm.Kontext) -> None:
"""LLM-Zweig, Schritt „welche + Typ": je Baustein OHNE Diagramm-Zeile entscheidet
ein Judge, ob ein Automaten-/Ablauf-/Baum-Diagramm nötig ist (Multimedia-Regel
im Prompt). „noetig" → status='geplant'/quelle='llm'; sonst eine 'kein'-Zeile,
damit der Judge nicht jede Repair-Iteration erneut fragt (idempotent)."""
alle = _bausteine(ctx.topic)
je_kap: dict = {}
for b in alle:
je_kap.setdefault(b["kapitel_id"], []).append(b["titel"])
async def einer(b: dict) -> None:
if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)):
return
atome = _atome_von(b["id"])
if not atome:
return
nachbarn = "\n".join(f"- {t}" for t in je_kap.get(b["kapitel_id"], [])
if t != b["titel"]) or "(keine)"
res = await llm.call(ctx, stage="diagramm_plan", template="Diagramm-Plan",
role="judge", n=len(atome), item=f"dp{b['id']}",
werte={"titel": b["titel"], "atome": _atom_liste(atome),
"nachbarn": nachbarn,
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"])},
erwartet=dict)
if res is None:
return # Ausfall → nächste Runde erneut (kein 'kein'-Eintrag)
typ = str(res.get("typ", "")).strip()
if res.get("noetig") and typ in DIAGRAMM_TYPEN and typ != "dependency":
db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ=typ,
quelle="llm", status="geplant")
else:
db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ="",
quelle="llm", status="kein") # kein Diagramm — nicht erneut fragen
await llm.alle(einer(b) for b in alle)
def _spec_normalisieren(res: dict, typ: str) -> dict:
"""LLM-Spec säubern: nur Knoten mit id+label, nur Kanten zwischen echten Knoten."""
knoten = [{"id": str(n.get("id", "")).strip(), "label": str(n.get("label", "")).strip()}
for n in (res.get("knoten") or []) if isinstance(n, dict) and n.get("id")]
gueltig = {n["id"] for n in knoten}
kanten = [{"von": str(k.get("von", "")).strip(), "zu": str(k.get("zu", "")).strip(),
"label": str(k.get("label", "")).strip()}
for k in (res.get("kanten") or [])
if isinstance(k, dict) and str(k.get("von")) in gueltig
and str(k.get("zu")) in gueltig]
return {"typ": typ, "knoten": knoten, "kanten": kanten}
async def _spec_bauen(ctx: llm.Kontext) -> None:
"""LLM liefert NUR Struktur-JSON (kein Mermaid) — die Hauptfehlerquelle
(roh geschriebenes Mermaid) fällt so weg. Kompiliert wird deterministisch."""
offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'"
" AND quelle='llm' AND spec='{}'", (ctx.topic,))
async def einer(d: dict) -> None:
b = db.one("SELECT * FROM bausteine WHERE id=?", (d["baustein_id"],))
atome = _atome_von(d["baustein_id"])
res = await llm.call(ctx, stage="diagramm_spec", template="Diagramm-Spec",
role="guide", n=len(atome), item=f"ds{d['id']}",
werte={"titel": b["titel"] if b else "", "typ": d["typ"],
"atome": _atom_liste(atome)}, erwartet=dict)
if res is None:
return # Ausfall → bleibt 'geplant', nächste Runde erneut
spec = _spec_normalisieren(res, d["typ"])
if spec["knoten"]:
db.update("diagramme", "id", d["id"], spec=db.j(spec))
await llm.alle(einer(d) for d in offen)
async def _verifizieren(ctx: llm.Kontext) -> None:
"""Grounding-Panel für LLM-Diagramme: jeder Knoten muss aus einem Atom ableitbar
sein, keine erfundene Struktur. Verwerfen (destruktiv) nur einstimmig
(llm.einstimmig) — Panel-Ausfall lässt den Kandidaten stehen."""
offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'"
" AND quelle='llm'", (ctx.topic,))
def urteil(stimme):
if stimme and isinstance(stimme[0], dict) and "ok" in stimme[0]:
return bool(stimme[0]["ok"])
return None
async def einer(d: dict) -> None:
atome = _atome_von(d["baustein_id"])
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="diagramm_verify",
template="Diagramm-Verify", schritt="diagramm_verify",
role="judge", n=len(atome), item=f"dv{d['id']}",
werte={"atome": _atom_liste(atome), "mermaid": d["mermaid"]},
erwartet=list)
if llm.einstimmig(stimmen, VERIFY_PANEL, urteil):
db.update("diagramme", "id", d["id"], status="verifiziert")
elif llm.einstimmig(stimmen, VERIFY_PANEL,
lambda s: None if urteil(s) is None else not urteil(s)):
db.update("diagramme", "id", d["id"], status="verworfen",
befunde=db.j(["Grounding-Panel: nicht aus den Atomen ableitbar"]))
# sonst: Kandidat bleibt (Panel-Ausfall / uneins) — nächste Runde erneut
await llm.alle(einer(d) for d in offen)
# ── Ebenen-Vertrag ────────────────────────────────────────────────────────────
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
_planen(ctx.topic) # welche/Typ: deterministisch (DAG)
await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume)
await _spec_bauen(ctx) # LLM → Struktur-JSON (kein rohes Mermaid)
_kompilieren(ctx.topic) # Spec → Mermaid (deterministisch)
_gate_kandidaten(ctx.topic) # Parse-Gate: ungültige Mermaid-Quelle → verworfen
# dag-Diagramme sind per Konstruktion gegroundet → nach bestandenem Gate verifiziert
for d in db.query("SELECT id FROM diagramme WHERE topic=? AND status='kandidat'"
" AND quelle='dag'", (ctx.topic,)):
db.update("diagramme", "id", d["id"], status="verifiziert")
await _verifizieren(ctx) # LLM-Diagramme: Grounding-Panel gegen die Atome
def _gate_kandidaten(topic: str) -> None:
"""Kandidaten durchs Parse-Gate: Syntaxfehler → verworfen (mit Befund). Ein
kaputtes Diagramm darf nie in den Guide (fail-closed)."""
kandidaten = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'"
" AND mermaid!=''", (topic,))
fehler = _mermaid_fehler(kandidaten)
for d in kandidaten:
if d["id"] in fehler:
db.update("diagramme", "id", d["id"], status="verworfen",
befunde=db.j([f"Parse-Fehler: {fehler[d['id']]}"]))
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'",
(ctx.topic,)):
# hängengebliebener Kandidat (Spec/Verify offen) — sichtbar, kein stiller Verlust
befunde.append({"art": "diagramm_unverifiziert", "item": str(d["id"]),
"detail": f"Baustein {d['baustein_id']}"})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
if not befunde:
return False
await bauen(ctx)
return True
def gate(ctx: llm.Kontext) -> str | None:
"""Diagramme sind optional — KEINE Diagramme blockiert den Lauf nicht. Backstop:
ein verifiziertes Diagramm ohne Mermaid-Quelltext darf nie ausgeliefert werden."""
tot = db.one("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'"
" AND mermaid='' LIMIT 1", (ctx.topic,))
return "verifiziertes Diagramm ohne Quelltext" if tot else None

View File

@@ -205,8 +205,12 @@ def _writer(prompt: str):
lo = int(re.search(r"hat (\d+)", prompt).group(1)) if re.search(r"hat (\d+)", prompt) else 40
absaetze = [f"Ziel dieser Einheit: {ziel.group(1) if ziel else 'Lernen'}. Formal: $n \\ge 0$."]
fuellung = "Daran anknüpfend gehen wir das Schritt für Schritt am Beispiel durch. " * 5
for (mk, titel), d in zip(marker, defs):
absaetze.append(f"#### {titel} verstehen\n{mk}\n{d} {fuellung}")
diag = re.findall(r"(<!-- diagramm: \d+ -->)", prompt) # angebotene Diagramme
for n, ((mk, titel), d) in enumerate(zip(marker, defs)):
block = f"#### {titel} verstehen\n{mk}\n{d} {fuellung}"
if n == 0 and diag: # erstes Diagramm beim ersten Absatz platzieren
block += f"\n\n{diag[0]}"
absaetze.append(block)
absaetze.append("**Kernpunkte:**\n" + "\n".join(f"- {t} sitzt" for _, t in marker))
if fragen:
absaetze.append("**Prüfe dich:**\n" + "\n".join(f"- {f}" for f in fragen))
@@ -217,6 +221,23 @@ def _writer(prompt: str):
"lang": lang}
def _diagramm_plan(prompt: str):
# deterministisch: die Fake-Welt braucht kein LLM-Diagramm (DAG-Pfad wird
# getestet). Unit-Tests für den LLM-Zweig monkeypatchen diesen Handler.
return {"noetig": False, "typ": ""}
def _diagramm_spec(prompt: str):
ids = [i for i in re.findall(r"^(\d+):", _text_nach(prompt, "ATOME:"), re.MULTILINE)]
knoten = [{"id": i, "label": f"Konzept {i}"} for i in ids]
kanten = [{"von": ids[k], "zu": ids[k + 1], "label": ""} for k in range(len(ids) - 1)]
return {"typ": "flow", "knoten": knoten, "kanten": kanten}
def _diagramm_verify(prompt: str):
return [{"ok": True, "mangel": ""}]
def _pruefer(prompt: str):
return {"befunde": []}
@@ -251,4 +272,6 @@ _HANDLER = {
"Kanten-Zyklus": _zyklus,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
"Diagramm-Plan": _diagramm_plan, "Diagramm-Spec": _diagramm_spec,
"Diagramm-Verify": _diagramm_verify,
}

View File

@@ -50,7 +50,51 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
_MARKER_VOLL = re.compile(r"<!--\s*atom:[^>]*-->") # ganze Marker-Zeile (für Text-Checks)
# ganze Marker-Zeile — non-greedy statt [^>]*, sonst bricht ein „>" im Atom-Titel
# den Match und die Marker-Zeile bliebe in den Text-Checks stehen (Marker sind
# einzeilig, also kein DOTALL). Muster spiegelt frontend/src/markdown.js.
_MARKER_VOLL = re.compile(r"<!--\s*atom:.*?-->")
_MARKER_DIAGRAMM = re.compile(r"<!--\s*diagramm:\s*(\d+)\s*-->")
# Kurzbeschreibung je Diagramm-Typ (Writer-Angebot)
_DIAGRAMM_BESCHR = {"dependency": "Abhängigkeitsgraph der Begriffe dieses Bausteins",
"state": "Zustandsdiagramm/Automat", "flow": "Ablaufdiagramm",
"tree": "Baumstruktur"}
def _ohne_marker(text: str) -> str:
"""Marker-Zeilen entfernen: sie sind Backend-Schnittstelle, kein Fließtext.
ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem
gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26"
unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop).
Diagramm-Marker gehören ebenso raus."""
return _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text))
def _diagramme_von(baustein_id: int) -> list[dict]:
return db.query("SELECT * FROM diagramme WHERE baustein_id=? AND status='verifiziert'"
" ORDER BY id", (baustein_id,))
def _diagramm_angebot(baustein_id: int) -> str:
"""Verfügbare Diagramme für den Writer — er platziert den Marker beim Absatz."""
zeilen = [f"<!-- diagramm: {d['id']} --> — {_DIAGRAMM_BESCHR.get(d['typ'], d['typ'])}"
for d in _diagramme_von(baustein_id)]
return "\n".join(zeilen) or "(keine)"
def _diagramm_einsetzen(baustein_id: int, lang: str) -> str:
"""Assembly: `<!-- diagramm: id -->` → ```mermaid-Fence des verifizierten
Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg."""
if "<!-- diagramm:" not in lang and "<!--diagramm:" not in lang:
return lang
je_id = {d["id"]: d for d in _diagramme_von(baustein_id)}
def sub(m: re.Match) -> str:
d = je_id.get(int(m.group(1)))
return f"```mermaid\n{d['mermaid']}\n```" if d and d["mermaid"] else ""
return _MARKER_DIAGRAMM.sub(sub, lang)
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
@@ -134,7 +178,8 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi,
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"}
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)",
"diagramme": _diagramm_angebot(b["id"])}
res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte,
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
if res is None:
@@ -166,8 +211,9 @@ _FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))"
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
_EN_STOP = frozenset("the and of is that this are with for as by be from which were"
" has have not can will or an it its when each such".split())
# ohne deutsche Homographen (an/will/not/or/is) — die flaggten deutschen Text
_EN_STOP = frozenset("the and of that this are with for as by be from which were"
" has have can its when each such".split())
def _stil_auftraege(text: str, wo: str) -> list[str]:
@@ -177,7 +223,7 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
text = _MARKER_VOLL.sub("", text)
text = _ohne_marker(text)
auftraege = []
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
@@ -189,7 +235,11 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
f" eigenständig formulieren.")
for absatz in text.split("\n\n"):
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
treffer = {t for t in toks if t in _EN_STOP} # verschiedene Stopwörter
# ≥3 VERSCHIEDENE Treffer zusätzlich zur Quote — ein deutscher Satz mit
# zufällig zwei englischen Wörtern reicht nicht mehr für einen Fehlalarm
if len(toks) >= 12 and len(treffer) >= 3 \
and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
kurz = " ".join(absatz.split())[:60]
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
@@ -199,7 +249,10 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
def _mathe_auftraege(text: str, wo: str) -> list[str]:
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
Lang-Fassungen betroffen)."""
Lang-Fassungen betroffen). Marker vorher strippen — Atom-Titel wie „a_n" oder
„A*-Suche" trafen sonst die Formelreste-Checks unfixierbar (der Fix darf den
Marker nicht ändern → Endlos-Loop)."""
text = _ohne_marker(text)
auftraege = []
for nr, absatz in enumerate(text.split("\n\n"), 1):
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
@@ -257,15 +310,16 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
for i in _marker_fremd_ids(lang, atome)]
lo, hi = _laenge_band(len(atome))
woerter = len(lang.split())
woerter = len(_ohne_marker(lang).split()) # Marker-Tokens zählen nicht mit
if woerter > hi:
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
elif woerter < lo:
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
# Auftrag an den Detektor angleichen: der prüft nur Titel-PRÄSENZ, also kann
# „erklären + behalten" den Befund nie schließen — nur Entfernen/Umschreiben.
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz"
f" erklären oder die Verwendung entfernen — nie unerklärt lassen,"
f" NICHT vertrösten."
f" den späteren Fachbegriff entfernen oder die Stelle ohne ihn"
f" umschreiben (mit bereits eingeführten Begriffen)."
for t in _vorwaertsverweise(topic, b, lang)]
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
@@ -316,7 +370,7 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
mein_kapitel = je_baustein.get(b["id"], 0)
text = textkit.norm(_MARKER_VOLL.sub("", lang))
text = textkit.norm(_ohne_marker(lang))
treffer = []
for a in db.query(
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
@@ -336,6 +390,12 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
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(f"> {z}" for z in _zitate(a["id"])) for a in atome)
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
@@ -351,6 +411,7 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
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"
@@ -423,7 +484,7 @@ async def _kapitel_intros(ctx: llm.Kontext) -> None:
db.update("kapitel", "id", kap["id"], intro=intro)
offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,))
await asyncio.gather(*(einer(k) for k in offen))
await llm.alle(einer(k) for k in offen)
async def bauen(ctx: llm.Kontext) -> None:
@@ -431,7 +492,7 @@ async def bauen(ctx: llm.Kontext) -> None:
offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig"
or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or
{"stage": "writer"})["stage"] != "done"]
await asyncio.gather(*(_karte(ctx, b) for b in offen))
await llm.alle(_karte(ctx, b) for b in offen)
await _kapitel_intros(ctx)
@@ -440,13 +501,14 @@ def _pruefe_dich(baustein_id: int, lang: str) -> list[dict]:
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
out = []
for a in _atome_von(baustein_id):
lang_norm = textkit.norm(lang) # Case/Whitespace-tolerant — ein Stil-Fix an der
for a in _atome_von(baustein_id): # Frage ließ das rohe Substring-Matching scheitern
for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND"
" typ='flashcard' AND status='verifiziert'", (a["id"],)):
fc = db.uj(r["inhalt"], {})
frage = str(fc.get("frage", "")).strip()
antwort = str(fc.get("antwort", "")).strip()
if frage and antwort and frage in lang:
if frage and antwort and textkit.norm(frage) in lang_norm:
out.append({"frage": frage, "antwort": antwort})
return out
@@ -461,18 +523,31 @@ def kapitel_struktur(topic: str) -> list[dict]:
for b in _bausteine(topic):
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
# neue Gruppe bei Kapitelwechsel — bei einer kapitellosen (NULL-)Gruppe
# zusätzlich bei Level-Wechsel, sonst mischt ein „Weitere Themen"-Block
# E-, M- und S-Bausteine und erbt fälschlich das Level des ersten.
neu = (not kapitel or kapitel[-1]["_kid"] != k_id
or (k_id is None and kapitel[-1]["level"] != b["level"]))
if neu:
k = kaps.get(k_id, {})
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
kapitel.append({"kapitel_id": k_id, "_kid": k_id,
"titel": k.get("titel", "Weitere Themen"),
"intro": k.get("intro", ""),
"level": k.get("level", b["level"]), "sections": []})
lang = sec["text_lang"] if sec else ""
# Diagramm-Marker gegen den ```mermaid-Fence tauschen (Assembly-Schritt);
# Atom-Marker bleiben — die strippt erst das Frontend.
lang = _diagramm_einsetzen(b["id"], sec["text_lang"]) if sec else ""
kapitel[-1]["sections"].append({
"baustein": b["id"], "titel": b["titel"],
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
"kompakt": sec["text_kompakt"] if sec else "",
"lang": lang,
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
for i, kap in enumerate(kapitel):
if kap.pop("_kid") is None:
kap["kapitel_id"] = f"frei-{i}"
return kapitel
@@ -512,9 +587,20 @@ def messen(ctx: llm.Kontext) -> list[dict]:
for i in _marker_fremd_ids(lang, atome):
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
"detail": f"Atom {i}"})
gueltige_d = {d["id"] for d in _diagramme_von(b["id"])}
for m in _MARKER_DIAGRAMM.findall(lang):
if int(m) not in gueltige_d: # Marker ohne verifiziertes Diagramm → entfernen
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
f" entfernen (kein solches verifiziertes Diagramm)"})
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]})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
@@ -541,9 +627,15 @@ 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 item FROM befunde WHERE run_id=? AND ebene=? AND art='fachlich_falsch'"
" AND status='offen'", (ctx.run_id, EBENE))}
"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))
async def eine(b: dict) -> None:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
@@ -552,16 +644,20 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
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
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, b["id"]))
# 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"])
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome)
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
werte={"fakten": fakten, "text": sec["text_lang"]},
erwartet=list)
claims = [str(c.get("claim", "")).strip() for c in verdacht or []
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")]
if not claims:
hash_setzen(b["id"], h) # sauber geprüft
return
stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check",
schritt="qa_judge", role="judge", item=f"b{b['id']}",
@@ -569,20 +665,34 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"claims": "\n".join(f"{i + 1}: {c}"
for i, c in enumerate(claims))},
erwartet=list)
if not stimmen:
return # Panel-Ausfall → kein Hash, nächste Runde prüft erneut
for i, claim in enumerate(claims, 1):
ja = sum(1 for s in stimmen for e in s
if isinstance(e, dict) and e.get("claim") == i and e.get("falsch"))
if ja >= 2:
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
"detail": claim[:200]})
hash_setzen(b["id"], h) # vollständig geprüft
await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic)))
await llm.alle(eine(b) for b in _bausteine(ctx.topic))
return befunde
def _text_sig(b_id: int) -> str:
import hashlib
s = db.one("SELECT text_kompakt, text_lang FROM sections WHERE baustein_id=?", (b_id,))
if not s:
return ""
roh = (s["text_kompakt"] or "") + "\x00" + (s["text_lang"] or "")
return hashlib.sha256(roh.encode()).hexdigest()[:16]
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)."""
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."""
ctx.ebene = EBENE
betroffen: dict[int, list[dict]] = {}
for b in befunde:
@@ -591,17 +701,35 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b)
if not betroffen:
return False
for b_id, liste in betroffen.items():
arten = [x["art"] for x in liste]
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
stage = "writer" if "section_fehlt" in arten else "pruefer"
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
# 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)
if any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail:
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:
# Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix
# raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen)
auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste]
# 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")
if betroffen:
await bauen(ctx)
return bool(betroffen)
vorher = {b_id: _text_sig(b_id) for b_id in betroffen}
await bauen(ctx)
return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen)

View File

@@ -34,15 +34,15 @@ def aktive_atome(topic: str) -> list[dict]:
# ── Extraktion ────────────────────────────────────────────────────────────────
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
schon = db.one(
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
" LIMIT 1", (ctx.topic, quelle["id"]))
if schon and not force:
db.update("quellen", "id", quelle["id"], status="atome")
# Idempotenz-Guard über `atome_stand` (nicht „hat irgendein Anker-Atom"): dieser
# Merker wird erst NACH einem vollständigen Extraktions-Pass gesetzt. Bricht der
# Lauf mitten in der Extraktion ab (einige Reader haben schon eingefügt, Merker
# noch leer), liest der Resume die Quelle komplett neu — sonst gingen die Atome
# der ungelesenen Abschnitte still verloren. Doppelte fängt der Anker-Overlap-Merge.
# force=True (Repair quelle_unvollstaendig) liest ohnehin neu.
stand = db.one("SELECT atome_stand FROM quellen WHERE id=?", (quelle["id"],))
if stand and stand["atome_stand"] and not force:
db.update("quellen", "id", quelle["id"], status=stand["atome_stand"])
return
text = korpus.quelltext(quelle)
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
@@ -61,8 +61,8 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
if tiefe < 2 and len(chunk) > 2000:
mitte = len(chunk) // 2
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
await llm.alle((reader(offset, chunk[:mitte], r, tiefe + 1),
reader(offset + mitte, chunk[mitte:], r, tiefe + 1)))
else:
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
return
@@ -91,12 +91,14 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False
start=-1, ende=-1, zitat=zitat)
stuecke = textkit.abschnitte(text)
await asyncio.gather(*(reader(off, chunk, r)
for off, chunk in stuecke
for r in range(READER_JE_ABSCHNITT)))
await llm.alle(reader(off, chunk, r)
for off, chunk in stuecke
for r in range(READER_JE_ABSCHNITT))
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl))
db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome")
endstand = "teilweise" if fehl else "atome"
# Merker erst JETZT setzen (nach vollständigem Pass) — er ist das Resume-Gate
db.update("quellen", "id", quelle["id"], status=endstand, atome_stand=endstand)
# ── Dedup ─────────────────────────────────────────────────────────────────────
@@ -161,6 +163,9 @@ def _anker_dedup(topic: str) -> None:
if passt:
gew, ver = (a_id, b_id) if len(je_def[a_id]) >= len(je_def[b_id]) else (b_id, a_id)
_merge(topic, gew, ver)
# Verlierer-Anker in-memory erben, sonst übersteht eine transitive
# Dublette (C überlappt nur mit B, B ging in A) diesen Pass.
anker[gew].extend(anker[ver])
def _geprueft(a: int, b: int) -> bool:
@@ -233,7 +238,7 @@ async def _judge_dedup(ctx: llm.Kontext) -> None:
" VALUES(?,?,?,'verwandt','nein')", (topic, lo, hi))
chunks = [offen[i:i + PAAR_CHUNK] for i in range(0, len(offen), PAAR_CHUNK)]
await asyncio.gather(*(chunk_urteil(c) for c in chunks))
await llm.alle(chunk_urteil(c) for c in chunks)
def _erster_anker(atom_id: int) -> str:
@@ -283,13 +288,13 @@ async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
db.update("atome", "id", a["id"], soll_id=sid)
chunks = [atome[i:i + ZIELE_CHUNK_ATOME] for i in range(0, len(atome), ZIELE_CHUNK_ATOME)]
await asyncio.gather(*(chunk_zuordnen(c) for c in chunks))
await llm.alle(chunk_zuordnen(c) for c in chunks)
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='extrahiert'", (ctx.topic,))
await asyncio.gather(*(_extrahiere_quelle(ctx, q) for q in offene))
await llm.alle(_extrahiere_quelle(ctx, q) for q in offene)
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
@@ -351,8 +356,19 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
if b["art"] == "quelle_unvollstaendig":
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
if q:
vorher = db.one(
"SELECT COUNT(*) AS n FROM anker k JOIN atome a ON a.id=k.atom_id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN"
" ('gemerged','verworfen')", (ctx.topic, q["id"]))["n"]
await _extrahiere_quelle(ctx, q, force=True)
return True
nachher = db.one(
"SELECT COUNT(*) AS n FROM anker k JOIN atome a ON a.id=k.atom_id"
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN"
" ('gemerged','verworfen')", (ctx.topic, q["id"]))["n"]
# ehrliches bewegt: blieb die Quelle 'teilweise' ohne neue Anker,
# erkennt auto_loop Stillstand statt 10 Token-Runden zu drehen
nq = db.one("SELECT status FROM quellen WHERE id=?", (q["id"],))
return nq["status"] == "atome" or nachher > vorher
return False
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
@@ -411,6 +427,8 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
offen[atom_id] = a
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
texte = {q["id"]: korpus.quelltext(q) for q in quellen}
start_ids = set(offen)
ausgefallen: set[int] = set() # Atome, deren zuständiger Call ausfiel (None)
for atom_id in list(offen):
for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)):
@@ -432,6 +450,9 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
schritt="fix", role="judge", n=len(ids),
item=f"q{q['id']}-o{offset}",
werte={"atome": liste, "text": chunk_text}, erwartet=list)
if res is None: # Call-Ausfall ≠ „keine Quellstelle" → nicht verwerfen
ausgefallen.update(i for i in ids if i in offen)
return
for e in res or []:
atom_id = e.get("atom")
zitat = str(e.get("zitat", "")).strip()
@@ -453,9 +474,13 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
break
await llm.alle(abschnitt_call(q, offset, chunk_text, ids[i:i + ANKER_FIX_CHUNK])
for i in range(0, len(ids), ANKER_FIX_CHUNK))
for atom_id in offen:
# Verwerfen nur, wo ALLE zuständigen Calls liefen (kein Ausfall) und keiner
# eine Quellstelle fand. Ausgefallene bleiben ohne_anker → nächste Runde erneut.
verworfen = [i for i in offen if i not in ausgefallen]
for atom_id in verworfen:
db.update("atome", "id", atom_id, status="verworfen")
return bool(atom_ids)
verankert = start_ids - set(offen)
return bool(verankert or verworfen) # ehrliches „bewegt" (kein Ausfall-Dauerschleifen)
async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool:
@@ -471,14 +496,16 @@ async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool:
werte={"titel": a["titel"], "definition": a["definition"],
"beleg": _erster_anker(atom_id), "soll": soll_liste},
erwartet=dict)
urteile = [s.get("soll", "fremd") for s in stimmen]
if urteile and all(u == "fremd" for u in urteile):
# Verwerfen ist destruktiv → nur bei vollzähligem „fremd"-Panel. Eine Stimme
# ohne soll-Feld ist KEIN fremd-Votum (zählte bisher fälschlich mit).
if llm.einstimmig(stimmen, 2, lambda s: (s.get("soll") == "fremd")
if "soll" in s else None):
db.update("atome", "id", atom_id, status="verworfen")
return True
gueltig = {p["id"] for p in punkte}
for u in urteile:
if u in gueltig:
db.update("atome", "id", atom_id, soll_id=u)
for s in stimmen: # Zuordnung ist nicht destruktiv → Mehrheit/erste gültige reicht
if s.get("soll") in gueltig:
db.update("atome", "id", atom_id, soll_id=s["soll"])
return True
return False
@@ -490,16 +517,24 @@ async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
if not p or p["status"] != "bestaetigt":
return False
runden = db.one( # bisherige Nachextraktions-Versuche: aus dem Ledger, topic-weit
# bisherige ECHTE Nachextraktions-Runden: nur erfolgreiche Calls (status='ok';
# Parse-/Infra-Retries schreiben eigene Zeilen) und nur seit dem letzten
# Inventar-Reset (Alt-Läufe zählten sonst mit → zu früher Freispruch).
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (ctx.topic,))["resets"], {})
seit = resets.get("inventar", 0)
runden = db.one(
"SELECT COUNT(*) AS n FROM events e JOIN runs r ON e.run_id=r.id"
" WHERE r.topic=? AND e.stage='luecke' AND e.item=?", (ctx.topic, f"s{soll_id}"))["n"]
" WHERE r.topic=? AND e.stage='luecke' AND e.item=? AND e.status='ok' AND e.id>?",
(ctx.topic, f"s{soll_id}", seit))["n"]
if runden >= LUECKEN_RUNDEN_MAX:
atome = aktive_atome(ctx.topic)
liste = "\n".join(f"- {a['titel']}: {a['definition']}" for a in atome[:200])
stimmen = await llm.panel(ctx, 2, stage="luecke_stich", template="Soll-Abgedeckt",
schritt="soll_zuordnung", role="judge", item=f"s{soll_id}",
werte={"punkt": p["punkt"], "atome": liste}, erwartet=dict)
if stimmen and all(bool(s.get("abgedeckt")) for s in stimmen):
# Freispruch ist destruktiv fürs Soll → nur bei vollzähligem Panel
if llm.einstimmig(stimmen, 2, lambda s: bool(s.get("abgedeckt"))
if "abgedeckt" in s else None):
db.update("soll", "id", soll_id, status="abgedeckt")
return True
return False

View File

@@ -8,14 +8,25 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
def parse(text: str):
"""→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry."""
"""→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry.
Jeden Code-Fence UND den Gesamttext durchprobieren; erster Treffer gewinnt.
(Nur den ersten Fence zu nehmen verlor JSON, das nach einem Prosa-Beispiel-
Fence stand.)"""
if not text:
return None
for kandidat in [m.group(1) for m in _FENCE.finditer(text)] + [text]:
daten = _ein_kandidat(kandidat.strip())
if daten is not None:
return daten
return None
def _ein_kandidat(text: str):
"""Erstes vollständiges JSON-Objekt/-Array in `text` (Klammer-Balance) oder
None. Das FRÜHESTE Klammerzeichen entscheidet — sonst gewinnt ein {…} im
Array-Inneren."""
if not text:
return None
m = _FENCE.search(text)
if m:
text = m.group(1)
text = text.strip()
# Das FRÜHESTE Klammerzeichen entscheidet: sonst gewinnt ein {…} im Array-Inneren.
erste = sorted((("{", "}"), ("[", "]")),
key=lambda p: text.find(p[0]) if p[0] in text else len(text))
for start_ch, end_ch in erste:

View File

@@ -129,7 +129,7 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
erwartet=list)
return res or []
ergebnisse = await asyncio.gather(*(eine(lens) for lens in RECHERCHE_LENSES))
ergebnisse = await llm.alle(eine(lens) for lens in RECHERCHE_LENSES)
neu = 0
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
for q in quellen:
@@ -159,6 +159,15 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None:
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,))
async def eine(q: dict) -> None:
# Resume-Idempotenz: Kandidaten eines abgebrochenen Passes dieser Quelle
# zuerst entfernen (Status='neu' ⇒ Konsens lief noch nicht ⇒ diese
# Kandidaten stammen nur aus dem unterbrochenen Lauf) — sonst blähen
# Doppel-Kandidaten das √n-Konsensband auf.
for r in db.query("SELECT id, belege FROM soll WHERE topic=? AND status='kandidat'",
(ctx.topic,)):
belege = db.uj(r["belege"])
if belege and belege[0].get("quelle") == q["id"]:
db.execute("DELETE FROM soll WHERE id=?", (r["id"],))
text = quelltext(q)
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
@@ -173,7 +182,7 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None:
belege=db.j([{"quelle": q["id"], "zitat": zitat}]))
db.update("quellen", "id", q["id"], status="extrahiert")
await asyncio.gather(*(eine(q) for q in offene))
await llm.alle(eine(q) for q in offene)
def _min_belege(topic: str) -> int:
@@ -267,11 +276,37 @@ async def _konsens(ctx: llm.Kontext) -> int:
belege = [b for i in p["ids"] for b in db.uj(je_id[i]["belege"])]
if len({b["quelle"] for b in belege}) >= min_belege:
bestaetigt.append((p, belege))
for r in db.query("SELECT id FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) # idempotent: neu aufbauen
# Bestehende bestätigte Zeilen NICHT löschen und neu bauen (das warf die von
# _belege_nachsuchen ergänzten Belege + den geprueft-Cache weg und churnte die
# soll.id). Stattdessen abgleichen: Match über punkt-Kern, sonst über einen
# gemeinsamen (quelle,zitat)-Beleg. Treffer → Belege-Union, geprueft bleibt.
alt = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
alt_kern: dict[str, dict] = {}
alt_beleg: dict[tuple, dict] = {}
for r in alt:
alt_kern.setdefault(r["punkt"].casefold(), r)
for b in db.uj(r["belege"]):
alt_beleg.setdefault((b["quelle"], b["zitat"]), r)
behalten: set[int] = set()
for p, belege in bestaetigt:
db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt",
belege=db.j(belege))
treffer = alt_kern.get(p["punkt"].casefold())
if treffer is None:
for b in belege:
if (treffer := alt_beleg.get((b["quelle"], b["zitat"]))):
break
if treffer is not None:
vorhanden = db.uj(treffer["belege"])
gesehen = {(b["quelle"], b["zitat"]) for b in vorhanden}
vereint = vorhanden + [b for b in belege
if (b["quelle"], b["zitat"]) not in gesehen]
db.update("soll", "id", treffer["id"], punkt=p["punkt"], belege=db.j(vereint))
behalten.add(treffer["id"])
else:
behalten.add(db.insert("soll", topic=ctx.topic, punkt=p["punkt"],
status="bestaetigt", belege=db.j(belege)))
for r in alt: # verwaiste bestätigte Zeilen (kein Konsens mehr) entfernen
if r["id"] not in behalten:
db.execute("DELETE FROM soll WHERE id=?", (r["id"],))
ok_ids = {i for p, _ in bestaetigt for i in p["ids"]}
for i, k in je_id.items(): # Buchführung: kein Kandidat verschwindet still
status = ("abgelehnt" if i in abgelehnt else
@@ -310,7 +345,12 @@ async def bauen(ctx: llm.Kontext) -> None:
if jetzt <= vorher >= 0: # Sättigung: Runde ohne neuen bestätigten Punkt
break
vorher = jetzt
db.update("topics", "name", ctx.topic, status="korpus_fertig") # Auto-Freeze
# Auto-Freeze nur bei ≥1 bestätigtem Punkt — sonst bleibt Status 'korpus', der
# QA-Loop läuft an (soll_leer) und das Gate stoppt den Lauf fail-closed, statt
# E1 mit leerem Soll zu starten (dort würde jedes Atom kaskadenartig verworfen).
if db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=? AND status='bestaetigt'",
(ctx.topic,))["n"]:
db.update("topics", "name", ctx.topic, status="korpus_fertig")
# ── QA + Repair ───────────────────────────────────────────────────────────────
@@ -361,22 +401,31 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
if not faellig:
return
text = quelltext(q)
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig)
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
schritt="soll", role="judge", item=f"q{q['id']}",
n=len(faellig),
werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]},
erwartet=list)
if res is None:
return # Ausfall: nicht als „geprüft" verbuchen
offen_ids = {p["id"] for p in faellig}
gefunden: dict[int, str] = {}
for e in res:
if not isinstance(e, dict):
ausfall = False # ein Fenster-Ausfall → Quelle nicht vollständig geprüft
# ALLE Fenster durchsuchen (nicht nur die ersten 40k) — sonst nagelt der
# geprueft-Cache Belege hinter dem ersten Fenster dauerhaft fest.
for fi, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
rest = [p for p in faellig if p["id"] in offen_ids]
if not rest:
break
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in rest)
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
schritt="soll", role="judge", item=f"q{q['id']}-{fi}",
n=len(rest),
werte={"punkte": liste, "text": chunk}, erwartet=list)
if res is None:
ausfall = True
continue
zitat = str(e.get("zitat", "")).strip()
if zitat and e.get("soll") in {p["id"] for p in faellig} \
and textkit.finde_zitat(text, zitat) is not None:
gefunden[e["soll"]] = zitat
for e in res:
if not isinstance(e, dict):
continue
zitat = str(e.get("zitat", "")).strip()
if zitat and e.get("soll") in offen_ids \
and textkit.finde_zitat(chunk, zitat) is not None:
gefunden[e["soll"]] = zitat
offen_ids.discard(e["soll"])
for p in faellig:
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
if p["id"] in gefunden:
@@ -384,7 +433,7 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
"zitat": gefunden[p["id"]]}]
db.update("soll", "id", p["id"], belege=db.j(belege))
bewegt = True
else:
elif not ausfall: # nur eine lückenlos durchsuchte Quelle gilt als geprüft
geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]})
db.update("soll", "id", p["id"], geprueft=db.j(geprueft))
@@ -414,10 +463,33 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
(ctx.topic,))
bewegt = bewegt or len(noch) < len(offene)
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
topic = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))
if topic["art"] == "thema":
art = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))["art"]
if art == "thema":
await _recherche_runde(ctx, runde=99)
await _soll_extrahieren(ctx)
await _konsens(ctx)
bewegt = True
else: # uni: keine Recherche möglich — vorhandene Quellen neu falten,
# ohne Kandidaten sogar frisch extrahieren; ehrliches bewegt
vorher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?"
" AND status='bestaetigt'", (ctx.topic,))["n"]
if not db.query("SELECT id FROM soll WHERE topic=? AND"
" status IN ('kandidat','gefaltet')", (ctx.topic,)):
db.execute("UPDATE quellen SET status='neu' WHERE topic=?", (ctx.topic,))
await _soll_extrahieren(ctx)
await _konsens(ctx)
nachher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?"
" AND status='bestaetigt'", (ctx.topic,))["n"]
bewegt = bewegt or nachher > vorher
return bewegt
def gate(ctx: llm.Kontext) -> str | None:
"""Fail-closed-Gate vor dem Ebenen-Marker: ohne Quellen oder ohne bestätigten
Soll-Punkt darf E1 nicht starten (leeres Soll verwirft in E1 jedes Atom)."""
if not db.one("SELECT id FROM quellen WHERE topic=? LIMIT 1", (ctx.topic,)):
return "keine Quellen"
if not db.one("SELECT id FROM soll WHERE topic=? AND status='bestaetigt' LIMIT 1",
(ctx.topic,)):
return "kein bestätigter Soll-Punkt"
return None

View File

@@ -55,7 +55,8 @@ def kennzahlen(run_id: int) -> list[dict]:
"SELECT ebene, stage, template, COUNT(*) AS calls,"
" SUM(tok_in) AS tok_in, SUM(tok_out) AS tok_out,"
" SUM(tok_cache_read) AS cache_read, SUM(dur_ms) AS dur_ms, SUM(wait_ms) AS wait_ms,"
" SUM(status='ok') AS ok, SUM(status='timeout') AS timeouts,"
" SUM(status IN ('error','infra')) AS fehler"
" SUM(status='ok') AS ok,"
" SUM(status='infra' AND meta LIKE '%timeout%') AS timeouts,"
" SUM(status IN ('error','infra','parse')) AS fehler"
" FROM events WHERE run_id=? GROUP BY ebene, stage, template ORDER BY MIN(id)",
(run_id,))

View File

@@ -45,9 +45,11 @@ def _ist_infra(err: str) -> bool:
async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
role: str, caps: str) -> agents.AgentErgebnis:
role: str, caps: str, log_hedge=None) -> agents.AgentErgebnis:
"""Ein Agent-Call mit Stall-Hedge: läuft der Slot max(HEDGE_NACH_S, timeout/2)
ohne Ergebnis, startet genau EIN Zwilling; das erste valide Ergebnis gewinnt."""
ohne Ergebnis, startet genau EIN Zwilling; das erste valide Ergebnis gewinnt.
Der zweite (Verlierer-)Call wird über `log_hedge(res|None)` als eigene
Ledger-Zeile verbucht — sonst fehlen seine Tokens in Budget und Kennzahlen."""
schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0
async def einer(k: str):
@@ -59,18 +61,42 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
return await haupt
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
if fertig:
return haupt.result()
return haupt.result() # kein Zwilling gestartet → kein Hedge-Log
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
paar = {haupt, zwilling}
gewinner = None
sonstige: list[agents.AgentErgebnis] = [] # fertig, aber nicht Gewinner
letzter_fehler = None
try:
for aufgabe in asyncio.as_completed([haupt, zwilling]):
res = await aufgabe
if res.ok:
return res
return res # beide fertig, keins ok → letztes Ergebnis zur Diagnose
pending = set(paar)
while pending and gewinner is None:
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
for t in done:
try:
res = t.result()
except asyncio.TimeoutError as e:
letzter_fehler = e # ein Timeout bricht den Hedge NICHT ab —
continue # der Zwilling darf noch liefern
if res.ok and gewinner is None:
gewinner = res
else:
sonstige.append(res)
finally:
for t in (haupt, zwilling):
for t in paar:
if not t.done():
t.cancel()
if log_hedge is not None:
if gewinner is not None:
# der Verlierer: fertig (Tokens bekannt) oder gecancelt (0 Tokens)
log_hedge(sonstige[0] if sonstige else None)
elif len(sonstige) >= 2:
# kein Gewinner: call() loggt das erste als Hauptzeile, das zweite hier
log_hedge(sonstige[1])
if gewinner is not None:
return gewinner
if sonstige:
return sonstige[0] # keins ok → erstes Ergebnis zur Diagnose
raise letzter_fehler if letzter_fehler else asyncio.TimeoutError()
async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
@@ -83,13 +109,23 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
infra_rest = INFRA_MAX_RETRIES
inhalt_rest = MAX_RESTARTS
versuch = 0
def _log_hedge(res) -> None:
# Verlierer-/Zweitcall des Hedge als eigene Ledger-Zeile (Budget zählt mit)
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=f"{item}-hedge",
template=template, template_hash=thash, role=role,
provider=ctx.provider, model=(res.model if res else ""),
status="hedge" if res else "hedge_cancel",
tokens=(res.tokens if res else None))
while True:
versuch += 1
ledger.budget_pruefen(ctx.run_id) # Vorab: nicht erst NACH dem Call stoppen
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
start = time.monotonic()
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
try:
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
res = await _roher_call(key, prompt, timeout, ctx, role, caps, _log_hedge)
tokens, err = res.tokens, res.err
wait_ms = int(res.wait_s * 1000)
model = res.model
@@ -147,13 +183,23 @@ async def alle(coros) -> list:
raise
def einstimmig(stimmen: list, n: int, urteil) -> bool:
"""Destruktiver Entscheid (verwerfen/mergen/freisprechen) nur bei VOLLZÄHLIGEM
Panel: mindestens n gültige Stimmen, alle True. `urteil(s)` liefert True/False
oder None (Ausfall/Parse/fehlendes Feld = keine Stimme → kein Entscheid).
Falsch-Verwerfen >> ein überlebendes Item (Lektion 67). Muster von
`chunk_urteil` (inventar) verallgemeinert."""
gueltig = [u for u in (urteil(s) for s in stimmen) if u is not None]
return len(gueltig) >= n and all(gueltig)
async def panel(ctx: Kontext, groesse: int, **kw) -> list:
"""`groesse` unabhängige Calls, alle Ergebnisse (None-gefiltert). Fällt genau eine
Stimme eines 2er-Panels aus, ersetzt EIN Ersatz-Richter (Lektion 20), dann
entscheidet der Aufrufer über Konsens."""
item = kw.get("item", "")
stimmen = await asyncio.gather(*(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
for i in range(groesse)))
stimmen = await alle(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
for i in range(groesse))
gueltig = [s for s in stimmen if s is not None]
if len(gueltig) == groesse - 1 and groesse >= 2:
ersatz = await call(ctx, **{**kw, "item": f"{item}-pE"})

View File

@@ -18,7 +18,7 @@ import db
import guide
import ledger
import pipeline
from config import FRONTEND_DIST, PROJECT_ROOT
from config import FRONTEND_DIST, PROJECT_ROOT, topic_name_ok
from ws import hub
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
@@ -93,6 +93,10 @@ def topics_liste():
def topic_anlegen(auftrag: TopicNeu):
if not auftrag.name.strip():
raise HTTPException(400, "Name fehlt")
# streng validieren: der Name wird Pfadkomponente (rmtree, Snapshots) und
# Shell-Argument (Transfer) — „..", „/" oder „;curl…" wären sonst gefährlich
if not topic_name_ok(auftrag.name):
raise HTTPException(400, "Name: nur Buchstaben/Ziffern/-/_ (Anfang alphanumerisch)")
if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)):
raise HTTPException(409, "Topic existiert")
if auftrag.art not in ("thema", "uni"):
@@ -149,9 +153,14 @@ def state(topic: str):
"bausteine": db.query(
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
" WHERE b.topic=? ORDER BY b.ord", (topic,)),
"diagramme": db.query(
"SELECT d.id, d.baustein_id, d.typ, d.quelle, d.status, b.titel, b.ord"
" FROM diagramme d JOIN bausteine b ON b.id=d.baustein_id"
" WHERE d.topic=? AND d.status!='kein' ORDER BY b.ord, d.id", (topic,)),
# topic-scoped: offene Befunde früherer Läufe bleiben nach Resume sichtbar
"befunde": db.query(
"SELECT * FROM befunde WHERE run_id=? AND status='offen' ORDER BY id",
(run["id"],)) if run else [],
"SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"
" WHERE r.topic=? AND b.status='offen' ORDER BY b.id", (topic,)),
"agenten": agents.aktive_agenten(),
}
@@ -177,6 +186,10 @@ async def transfer_ausfuehren(topic: str, richtung: str):
raise HTTPException(403, "Transfer nur lokal verfügbar")
if richtung not in ("push", "pull"):
raise HTTPException(404, "unbekannte Richtung")
# Name gegen die Whitelist prüfen UND Existenz sichern — der Name fließt in
# ein make-Shell-Rezept; ohne diese Schranke wäre er ein Injektionsvektor.
if not topic_name_ok(topic) or not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
res = await asyncio.to_thread(
subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"],
cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600)

37
backend/mermaid_check.mjs Normal file
View File

@@ -0,0 +1,37 @@
// Mermaid-Parse-Gate: prüft Diagramm-Quelltext mit DEMSELBEN Parser wie das Frontend.
// stdin: JSON [{id, code}] → stdout: JSON [{id, fehler}, …] (nur die fehlerhaften).
// Zwilling zu katex_check.mjs: was hier parst, rendert auch im Guide.
//
// Mermaid braucht zum Sanitisieren der Labels ein DOM — headless via jsdom, sonst
// wirft parse() für JEDES Diagramm mit Label „DOMPurify.addHook is not a function"
// (nicht ein Diagramm-Fehler, sondern ein Umgebungsfehler → alles fälschlich verworfen).
import { createRequire } from 'node:module'
import { readFileSync } from 'node:fs'
function laden(name) {
try {
return createRequire(import.meta.url)(name) // Container: backend/node_modules
} catch {
const basis = process.env.MERMAID_BASE // Dev: frontend/node_modules
return createRequire(basis.endsWith('/') ? basis : basis + '/')(name)
}
}
const { JSDOM } = laden('jsdom')
const dom = new JSDOM('<!DOCTYPE html><body></body>')
globalThis.window = dom.window
globalThis.document = dom.window.document
const mermaid = laden('mermaid').default
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
const diagramme = JSON.parse(readFileSync(0, 'utf8'))
const fehler = []
for (const d of diagramme) {
try {
await mermaid.parse(d.code) // wirft bei Syntaxfehler
} catch (e) {
fehler.push({ id: d.id, fehler: String(e.message || e).slice(0, 160) })
}
}
process.stdout.write(JSON.stringify(fehler))

View File

@@ -10,6 +10,7 @@ import agents
import artefakte
import auto_loop
import db
import diagramme
import guide
import inventar
import korpus
@@ -26,10 +27,11 @@ EBENEN = [
("inventar", inventar, "inventar_fertig"),
("artefakte", artefakte, "artefakte_fertig"),
("struktur", struktur, "struktur_fertig"),
("diagramme", diagramme, "diagramme_fertig"),
("guide", guide, "fertig"),
]
_ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_fertig",
"artefakte_fertig", "struktur_fertig", "fertig"]
"artefakte_fertig", "struktur_fertig", "diagramme_fertig", "fertig"]
_laeufe: dict[str, asyncio.Task] = {}
@@ -60,11 +62,12 @@ def lauf_stoppen(topic: str) -> None:
run = db.one("SELECT id FROM runs WHERE topic=? AND status='running'"
" ORDER BY id DESC", (topic,))
if run:
db.update("runs", "id", run["id"], status="stopped", beendet="jetzt")
db.update("runs", "id", run["id"], status="stopped", beendet=db.now())
_ROLLBACK = {"guide": "struktur_fertig", "struktur": "artefakte_fertig",
"artefakte": "inventar_fertig", "inventar": "korpus_fertig", "korpus": "neu"}
_ROLLBACK = {"guide": "diagramme_fertig", "diagramme": "struktur_fertig",
"struktur": "artefakte_fertig", "artefakte": "inventar_fertig",
"inventar": "korpus_fertig", "korpus": "neu"}
def ebenen_entfernen(topic: str, ebene: str) -> None:
@@ -74,39 +77,48 @@ def ebenen_entfernen(topic: str, ebene: str) -> None:
if ebene not in _ROLLBACK:
raise ValueError(f"unbekannte Ebene: {ebene}")
lauf_stoppen(topic)
stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
stufe = ["guide", "diagramme", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
db.execute("DELETE FROM sections WHERE baustein_id IN"
" (SELECT id FROM bausteine 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
if stufe >= 1: # struktur
if stufe >= 1: # diagramme
db.execute("DELETE FROM diagramme WHERE topic=?", (topic,))
if stufe >= 2: # struktur
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
(topic,))
if stufe >= 2: # artefakte
if stufe >= 3: # artefakte
db.execute("DELETE FROM leitner WHERE artefakt_id IN (SELECT ar.id FROM artefakte ar"
" JOIN atome a ON ar.atom_id=a.id WHERE a.topic=?)", (topic,))
db.execute("DELETE FROM artefakte WHERE atom_id IN"
" (SELECT id FROM atome WHERE topic=?)", (topic,))
if stufe >= 3: # inventar
if stufe >= 4: # inventar
db.execute("DELETE FROM anker WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)",
(topic,))
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
db.execute("UPDATE quellen SET status='extrahiert' WHERE topic=?", (topic,))
if stufe >= 4: # korpus
# atome_stand mit-nullen — sonst überspringt der Resume-Guard die Neu-Extraktion
db.execute("UPDATE quellen SET status='extrahiert', atome_stand='' WHERE topic=?",
(topic,))
if stufe >= 5: # korpus
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
db.execute("DELETE FROM quellen WHERE topic=?", (topic,))
import shutil
from config import KORPUS_DIR
shutil.rmtree(KORPUS_DIR / topic, ignore_errors=True)
# Gürtel-und-Hosenträger vor dem destruktiven rmtree: der Zielpfad MUSS
# unter KORPUS_DIR liegen (Alt-DBs könnten Namen ohne heutige Validierung
# tragen). Sonst nichts löschen.
ziel = (KORPUS_DIR / topic).resolve()
if ziel != KORPUS_DIR.resolve() and ziel.is_relative_to(KORPUS_DIR.resolve()):
shutil.rmtree(ziel, ignore_errors=True)
# Token-/Dauer-Anzeige der entfernten Ebenen nullen: Events bleiben (Ledger ist
# append-only), aber die Zählung beginnt hinter dem Reset-Marker neu.
letzte = db.one("SELECT MAX(id) AS m FROM events")["m"] or 0
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
for name in ["guide", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]:
for name in ["guide", "diagramme", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]:
resets[name] = letzte
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))
@@ -134,6 +146,8 @@ def voll_reset(topic: str) -> None:
(topic,))
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
# Atome weg → Merker nullen, sonst überspringt der Guard die Neu-Extraktion
db.execute("UPDATE quellen SET atome_stand='' WHERE topic=?", (topic,))
db.update("topics", "name", topic, status="neu")
@@ -174,25 +188,31 @@ async def _lauf(topic: str, run_id: int) -> None:
note, grund = await auto_loop.auto_repair_loop(name, erst_note, reparieren)
log.info("%s/%s: Note %.1f (%s)", topic, name, note, grund)
# Fail-closed-Gate vor dem Ebenen-Marker (nur wo das Modul eins hat):
# ein RuntimeError landet im Exception-Handler → Run 'failed', der
# Ebenen-Marker wird NICHT gesetzt, die nächste Ebene startet nie.
gate = getattr(modul, "gate", None)
if gate and (grund_gate := gate(ctx)):
raise RuntimeError(f"{name}-Gate: {grund_gate}")
db.update("topics", "name", topic, status=marke)
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
if not flags.get(name, True) and marke != "fertig":
db.update("runs", "id", run_id, status="done", beendet="jetzt",
db.update("runs", "id", run_id, status="done", beendet=db.now(),
grund=f"Auto-Stopp nach {name}")
return
db.update("runs", "id", run_id, status="done", beendet="jetzt")
db.update("runs", "id", run_id, status="done", beendet=db.now())
except llm.LaufPause as e:
agents.abbrechen(f"{topic}-") # Nachzügler-Tasks dürfen keine Tokens mehr ziehen
db.update("runs", "id", run_id, status="paused", grund=str(e)[:300], beendet="jetzt")
db.update("runs", "id", run_id, status="paused", grund=str(e)[:300], beendet=db.now())
log.warning("%s: Lauf pausiert — %s", topic, e)
except ledger.BudgetErschoepft as e:
agents.abbrechen(f"{topic}-")
db.update("runs", "id", run_id, status="budget", grund=str(e)[:300], beendet="jetzt")
db.update("runs", "id", run_id, status="budget", grund=str(e)[:300], beendet=db.now())
log.warning("%s: Budget erreicht", topic)
except asyncio.CancelledError:
raise
except Exception as e:
agents.abbrechen(f"{topic}-")
db.update("runs", "id", run_id, status="failed", grund=f"{type(e).__name__}: {e}"[:300],
beendet="jetzt")
beendet=db.now())
log.exception("%s: Lauf fehlgeschlagen", topic)

View File

@@ -9,6 +9,7 @@ import logging
import artefakte
import db
import diagramme
import guide
import inventar
import korpus
@@ -18,7 +19,7 @@ import struktur
log = logging.getLogger("creator2.qa")
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
"struktur": struktur, "guide": guide}
"struktur": struktur, "diagramme": diagramme, "guide": guide}
# Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
GEWICHTE = {
@@ -32,7 +33,9 @@ GEWICHTE = {
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 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,
"det_check": 0.5,
"det_check": 0.5, "fix_offen": 1.5,
"diagramm_parse_fehler": 3.0, "diagramm_ungegroundet": 3.0,
"diagramm_unverifiziert": 0.5, "diagramm_marker_tot": 3.0,
}
@@ -45,6 +48,7 @@ def _basis(topic: str, ebene: str) -> int:
" AND status NOT IN ('gemerged','verworfen')",
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"diagramme": "SELECT COUNT(*) n FROM bausteine WHERE topic=?",
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
row = db.one(z, (topic,))
return max(1, int(row["n"]) if row else 1)
@@ -62,21 +66,30 @@ def note(befunde: list[dict], basis: int) -> float:
def _persistieren(ctx: llm.Kontext, ebene: str, befunde: list[dict]) -> None:
"""Offene Alt-Befunde, die nicht mehr auftreten → repariert; Neues → offen.
Bestehende offene Zeilen bleiben (kein Duplikat)."""
alte = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=? AND status='offen'",
(ctx.run_id, ebene))
neu_keys = {(b["art"], str(b["item"])) for b in befunde}
Schlüssel schließt detail EIN — mehrere Befunde gleicher (art, item) mit
verschiedenen Details (z. B. mehrere det_check je Section) sind legitim und
brauchen je eine Zeile; ohne detail kollabierten sie und alte Details blieben
für immer offen. topic-scoped (JOIN runs), damit auch offene Zeilen früherer
Läufe geschlossen werden, wenn der Befund nicht mehr auftritt."""
alte = db.query(
"SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"
" WHERE r.topic=? AND b.ebene=? AND b.status='offen'", (ctx.topic, ebene))
def schluessel(art, item, detail) -> tuple:
return (art, str(item), (detail or "")[:500])
neu_keys = {schluessel(b["art"], b["item"], b.get("detail", "")) for b in befunde}
alt_keys = set()
for alt in alte:
key = (alt["art"], alt["item"])
if key in neu_keys:
alt_keys.add(key)
k = schluessel(alt["art"], alt["item"], alt["detail"])
if k in neu_keys:
alt_keys.add(k)
else:
db.update("befunde", "id", alt["id"], status="repariert")
for b in befunde:
if (b["art"], str(b["item"])) not in alt_keys:
if schluessel(b["art"], b["item"], b.get("detail", "")) not in alt_keys:
db.insert("befunde", run_id=ctx.run_id, ebene=ebene, art=b["art"],
item=str(b["item"]), detail=b["detail"][:500], status="offen")
item=str(b["item"]), detail=(b.get("detail") or "")[:500], status="offen")
async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]:

View File

@@ -74,7 +74,7 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None:
for a in rest:
db.update("atome", "id", a["id"], ziel_id=ziel_id)
await asyncio.gather(*(eine_gruppe(s, g) for s, g in gruppen.items()))
await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items())
# ── Zyklen brechen ────────────────────────────────────────────────────────────
@@ -220,8 +220,11 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
key=lambda k: len(gruppen[k]))
for k in kleine:
passende = [p for p in gruppen if p != k and p[1] == k[1]
and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME]
# nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der
# Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine
# kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen,
# den messen dann als unreparierbaren band-Befund meldete.
passende = [p for p in gruppen if p != k and p[1] == k[1]]
if not passende:
continue
eigener = gruppen_rang(gruppen[k])
@@ -249,9 +252,15 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
z = ziele.get(ziel_id, {})
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
groesse = -(-len(gruppe) // n_teile)
# gleichverteilt statt ceil-Scheiben: der letzte Teil war sonst der Rest
# (43→8,8,8,8,8,3 verletzt das Band). divmod → 43→8,7,7,7,7,7; mit
# MIN≤MAX/2 liegt jeder Teil bei n_teile≥2 beweisbar im Band.
basis, rest_teile = divmod(len(gruppe), n_teile)
start = 0
for teil in range(n_teile):
teil_ids = ordnung[teil * groesse:(teil + 1) * groesse]
gr = basis + (1 if teil < rest_teile else 0)
teil_ids = ordnung[start:start + gr]
start += gr
if not teil_ids:
continue
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
@@ -473,14 +482,13 @@ def messen(ctx: llm.Kontext) -> list[dict]:
je_baustein: dict = defaultdict(list)
for a in atome:
je_baustein[a["baustein_id"]].append(a)
# Band-Unterschreitung nur, wo ein Merge MÖGLICH gewesen wäre (kombiniert
# ≤ Band) — dieselbe Bedingung wie im Schnitt; eine unerfüllbare Messlatte
# pendelt nur die Note (Lektion 75).
# Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches
# Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und
# re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine
# einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75).
for b in bausteine:
n = len(je_baustein[b["id"]])
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"]
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
for bb in bausteine)
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] for bb in bausteine)
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
befunde.append({"art": "band", "item": str(b["id"]),
"detail": f"{b['titel']}: {n} Atome"})

View File

@@ -65,6 +65,18 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]:
return "".join(out), mapping
def _casefold_mit_map(text: str) -> tuple[str, list[int]]:
"""Gefaltete Kopie + Map auf die Original-Positionen. casefold() kann ein
Zeichen zu mehreren expandieren (ß→ss) — die Map hält den Rückweg eindeutig."""
out: list[str] = []
mapping: list[int] = []
for i, c in enumerate(text):
for cn in c.casefold():
out.append(cn)
mapping.append(i)
return "".join(out), mapping
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
@@ -81,11 +93,17 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
haystack, mapping = _normiert_mit_map(text)
needle = _WS.sub(" ", zitat).strip()
pos = haystack.find(needle)
if pos < 0:
pos = haystack.casefold().find(needle.casefold())
if pos >= 0:
start = mapping[pos]
ende = mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1
return mapping[pos], mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1
# Stufe 2: casefold — eigene Map, weil casefold die Länge ändert (ß→ss, fi→fi).
# Direkt in `mapping` zu indizieren verschob den Span um jede Expansion vor dem
# Treffer und lief am Textende in einen IndexError.
hay_cf, sub = _casefold_mit_map(haystack)
ndl_cf = needle.casefold()
pos = hay_cf.find(ndl_cf)
if pos >= 0:
start = mapping[sub[pos]]
ende = mapping[sub[min(pos + len(ndl_cf) - 1, len(sub) - 1)]] + 1
return start, ende
hay_l, map_l = _locker_mit_map(text)
ndl_l, _ = _locker_mit_map(zitat)

View File

@@ -11,6 +11,7 @@ import re
import sys
from pathlib import Path
import config
import db
import pipeline
from config import PROJECT_ROOT
@@ -69,6 +70,11 @@ def _einfuegen(tabelle: str, zeilen: list[dict], **ersetzungen) -> dict:
def importieren(d: dict) -> None:
topic = d["topic"]
# Vor dem destruktiven topic_loeschen (rmtree) den Namen validieren — ein
# manipulierter Export mit topic="../evil" würde sonst außerhalb von storage
# löschen/schreiben.
if not config.topic_name_ok(topic):
raise ValueError(f"ungültiger Topic-Name: {topic!r}")
if db.one("SELECT name FROM topics WHERE name=?", (topic,)):
pipeline.topic_loeschen(topic) # Zielseite wird überschrieben
@@ -119,12 +125,21 @@ def importieren(d: dict) -> None:
alt = int(m.group(1))
return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0)
def auftrag_remap(m: re.Match) -> str:
alt = int(m.group(2))
return f"{m.group(1)}{a_map[alt]}" if alt in a_map else m.group(0)
for z in d["sections"]: # PK ist baustein_id
z = dict(z)
z["baustein_id"] = b_map.get(z["baustein_id"])
for feld in ("text_lang", "text_kompakt"):
if z.get(feld):
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
# offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs
if z.get("befunde"):
z["befunde"] = db.j([re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(a))
for a in db.uj(z["befunde"])])
db.insert("sections", **z)
r_map = _einfuegen("runs", d["runs"])
_einfuegen("befunde", d["befunde"], run_id=r_map)

View File

@@ -12,9 +12,21 @@ class Hub:
def __init__(self):
self._clients: set = set()
self._loop: asyncio.AbstractEventLoop | None = None
self._queue: asyncio.Queue | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
self._queue = asyncio.Queue()
# EIN Sender-Task serialisiert die Broadcasts — ein Task-pro-push konnte
# sich bei Backpressure überholen (out-of-order-Deltas, konkurrierende
# send_text auf demselben Socket).
loop.create_task(self._sende_schleife())
async def _sende_schleife(self) -> None:
assert self._queue is not None
while True:
msg = await self._queue.get()
await self._send_all(msg)
async def connect(self, websocket) -> None:
await websocket.accept()
@@ -24,11 +36,12 @@ class Hub:
self._clients.discard(websocket)
def push(self, tabelle: str, row: dict) -> None:
"""Thread-sicher (DB-Schicht ruft synchron): auf den Loop dispatchen."""
if not self._clients or self._loop is None:
"""Thread-sicher (DB-Schicht ruft synchron): in die Queue legen, der
Sender-Task verschickt der Reihe nach."""
if not self._clients or self._loop is None or self._queue is None:
return
msg = json.dumps({"tabelle": tabelle, "row": row}, ensure_ascii=False, default=str)
self._loop.call_soon_threadsafe(lambda: asyncio.ensure_future(self._send_all(msg)))
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)
async def _send_all(self, msg: str) -> None:
for ws in list(self._clients):