update
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
304
backend/diagramme.py
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
196
backend/guide.py
196
backend/guide.py
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
37
backend/mermaid_check.mjs
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user