This commit is contained in:
team3
2026-07-10 15:43:11 +02:00
commit 0a41166cca
72 changed files with 7772 additions and 0 deletions

369
backend/agents.py Normal file
View File

@@ -0,0 +1,369 @@
"""Provider-Schicht: ein Agent-Call via Claude-CLI, OpenCode (MiniMax/Ollama) oder
direkter MiniMax-Text-API. Stacks sind unabhängig — fehlt ein Binary/Key, fällt nur
dieser Provider aus.
Bezahlte Lektionen des Vorgängers, hier eingebaut statt nachgepatcht:
- OpenCode-Starts staffeln (Session-DB-Kollision: "database is locked" bei Parallelstart)
- Prozessgruppen-Kill (CLI-Kinder halten sonst Pipes offen, communicate() hängt)
- RAM-Gate vor CLI-Spawns (~310 MB RSS je Prozess)
- Prompt via Tempdatei statt argv (ARG_MAX)
- Token-Accounting auch bei Timeout/Fehler — Verschwendung bleibt sichtbar
"""
import asyncio
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
import urllib.request
from pathlib import Path
import httpx
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_API_AGENTS,
OPENCODE_START_DELAY, PROVIDERS, RAM_MIN_FREE_PCT, resolve_role)
log = logging.getLogger("creator2.agents")
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
_api_sem = asyncio.Semaphore(MAX_CONCURRENT_API_AGENTS)
_active: dict[str, float] = {} # key → Startzeit (Anzeige)
_prozesse: dict[str, asyncio.subprocess.Process] = {}
_abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe
# ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls
# kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE:
# Cooldown für neue Starts + API-Breite halbieren; Erfolge heben sie langsam. ──
_cooldown_bis = 0.0 # time.monotonic(): vorher startet nichts Neues
_api_breite = MAX_CONCURRENT_API_AGENTS
_api_inflight = 0
_api_erfolge = 0
def drossel_melden(sekunden: float) -> None:
global _cooldown_bis, _api_breite, _api_erfolge
_cooldown_bis = max(_cooldown_bis, time.monotonic() + sekunden)
neu = max(4, _api_breite // 2)
if neu != _api_breite:
log.warning("429-Bremse: API-Breite %d%d, Cooldown %.0fs", _api_breite, neu, sekunden)
_api_breite = neu
_api_erfolge = 0
def _erfolg_melden() -> None:
global _api_erfolge, _api_breite
_api_erfolge += 1
if _api_erfolge >= 20 and _api_breite < MAX_CONCURRENT_API_AGENTS:
_api_breite += 1 # langsame Erholung: 20 Erfolge kaufen +1 Breite
_api_erfolge = 0
async def _drossel_warten(key: str) -> None:
while True:
rest = _cooldown_bis - time.monotonic()
if rest <= 0 or _ist_abgebrochen(key):
return
await asyncio.sleep(min(rest, 1.0))
async def _api_slot(key: str) -> None:
"""Cooldown + adaptive Breite (unterhalb des festen Semaphors)."""
global _api_inflight
while True:
await _drossel_warten(key)
if _api_inflight < _api_breite:
_api_inflight += 1
return
await asyncio.sleep(0.2)
class AgentErgebnis:
__slots__ = ("rc", "text", "err", "tokens", "wait_s")
def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None):
self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {}
self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie
@property
def ok(self) -> bool:
return self.rc == 0 and bool(self.text.strip())
def aktive_agenten() -> list[dict]:
now = time.time()
return sorted([{"key": k, "laufzeit": round(now - t, 1)} for k, t in _active.items()],
key=lambda a: -a["laufzeit"])
def abbrechen(prefix: str) -> None:
"""Alle laufenden + wartenden Agenten dieses Präfixes stoppen."""
_abgebrochen.add(prefix)
for key, p in list(_prozesse.items()):
if key.startswith(prefix):
_kill(p)
def abbruch_aufheben(prefix: str) -> None:
_abgebrochen.discard(prefix)
def _ist_abgebrochen(key: str) -> bool:
return any(key.startswith(p) for p in _abgebrochen)
def provider_verfuegbar(provider: str) -> bool:
cfg = PROVIDERS.get(provider)
if not cfg:
return False
if shutil.which(cfg["cli"]) is None:
return False
if cfg.get("env_key") and not os.environ.get(cfg["env_key"]):
return False
if cfg.get("check_url"):
try:
urllib.request.urlopen(cfg["check_url"], timeout=1)
except Exception:
return False
return True
async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
role: str = "quick", capabilities: str = "none") -> AgentErgebnis:
"""Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web).
Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py)."""
if os.getenv("CREATOR_FAKE_AGENTS"):
import fake_agents
return await fake_agents.antwort(key, prompt, capabilities)
if _ist_abgebrochen(key):
return AgentErgebnis(1, "", "abgebrochen")
run_provider = provider
provider, model = resolve_role(run_provider, role)
if provider != run_provider and not provider_verfuegbar(provider):
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
if not model:
return AgentErgebnis(1, "", f"kein Modell für Rolle {role} ({provider})")
use_api = (capabilities == "none" and PROVIDERS[provider]["cli"] == "opencode"
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
and bool(os.environ.get("MINIMAX_API_KEY")))
sem = _api_sem if use_api else _batch_sem
global _api_inflight
warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms)
async with sem:
if _ist_abgebrochen(key):
return AgentErgebnis(1, "", "abgebrochen")
_active[key] = time.time()
try:
if use_api:
await _api_slot(key)
wait_s = time.time() - warte_start
try:
res = await _text_api(key, prompt, timeout, model)
finally:
_api_inflight -= 1
if res.rc == 0:
_erfolg_melden()
res.wait_s = wait_s
return res
await _drossel_warten(key)
wait_s = time.time() - warte_start
if PROVIDERS[provider]["cli"] == "opencode":
res = await _opencode(key, prompt, timeout, model, capabilities)
else:
res = await _claude_cli(key, prompt, timeout, model, capabilities)
res.wait_s = wait_s
return res
finally:
_active.pop(key, None)
# ── CLI-Pfade ─────────────────────────────────────────────────────────────────
_CLAUDE_TOOLS = {"full": "Write,Bash,Read,WebSearch,WebFetch", "files": "Read,Bash,Write", "none": None}
_OPENCODE_AGENTS = {"full": "full", "files": "files", "none": "text"}
_start_lock = asyncio.Lock()
_next_start = 0.0
async def _start_slot() -> None:
"""Token-Bucket: CLI-Starts um OPENCODE_START_DELAY spreizen, ohne globalen Konvoi."""
global _next_start
loop = asyncio.get_running_loop()
async with _start_lock:
now = loop.time()
bei = max(now, _next_start)
_next_start = bei + OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, bei - now))
def _ram_frei_pct() -> float | None:
try:
text = Path("/proc/meminfo").read_text()
m = dict(re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE))
return int(m["MemAvailable"]) * 100 / int(m["MemTotal"])
except Exception:
return None
async def _ram_gate(key: str) -> bool:
"""Unter RAM_MIN_FREE_PCT frei warten neue Spawns (fail-open ohne /proc)."""
if RAM_MIN_FREE_PCT <= 0:
return True
while True:
if _ist_abgebrochen(key):
return False
pct = _ram_frei_pct()
if pct is None or pct >= RAM_MIN_FREE_PCT or not _prozesse:
return True
await asyncio.sleep(2)
def _kill(process) -> None:
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
process.kill()
except ProcessLookupError:
pass
async def _spawn(key: str, cmd: list[str], stdin_data: bytes | None, timeout: int,
env: dict | None = None) -> AgentErgebnis:
if not await _ram_gate(key):
return AgentErgebnis(1, "", "abgebrochen")
await _start_slot()
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
start_new_session=True, env=env)
track = key
n = 2
while track in _prozesse:
track = f"{key}~{n}"
n += 1
_prozesse[track] = process
start = time.monotonic()
try:
try:
stdout, stderr = await asyncio.wait_for(process.communicate(input=stdin_data), timeout=timeout)
except asyncio.TimeoutError:
_kill(process)
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
pass
raise
log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start)
return AgentErgebnis(process.returncode or 0, stdout.decode("utf-8", errors="replace"),
stderr.decode("utf-8", errors="replace"))
finally:
if _prozesse.get(track) is process:
del _prozesse[track]
async def _claude_cli(key: str, prompt: str, timeout: int, model: str, capabilities: str) -> AgentErgebnis:
cmd = ["claude", "-p", "--model", model]
if _CLAUDE_TOOLS.get(capabilities):
cmd += ["--allowedTools", _CLAUDE_TOOLS[capabilities]]
cmd += ["--dangerously-skip-permissions"]
return await _spawn(key, cmd, prompt.encode(), timeout)
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
def _opencode_tokens(key: str) -> dict | None:
"""Token-Zähler der Session (run --title=key). Best effort, nie fatal."""
try:
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
try:
row = con.execute(
"SELECT tokens_input, tokens_output, tokens_cache_read, tokens_cache_write"
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1", (key,)).fetchone()
finally:
con.close()
except Exception:
return None
if row is None:
return None
return {"input": int(row[0] or 0), "output": int(row[1] or 0),
"cache_read": int(row[2] or 0), "cache_write": int(row[3] or 0)}
async def _opencode(key: str, prompt: str, timeout: int, model: str, capabilities: str) -> AgentErgebnis:
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
f.write(prompt)
pfad = Path(f.name)
cmd = ["opencode", "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
"-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
"--dangerously-skip-permissions", "--title", key, "-f", str(pfad)]
try:
res = await _spawn(key, cmd, None, timeout)
res.text = _clean_opencode(res.text)
res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {}
return res
finally:
pfad.unlink(missing_ok=True)
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
def _clean_opencode(text: str) -> str:
text = _ANSI.sub("", text)
lines = text.splitlines()
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
lines.pop(0)
return "\n".join(lines).strip()
# ── Direkte MiniMax-Text-API (spart den ~310-MB-Prozess für tool-lose Calls) ──
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_MAX_TOKENS = 32_000
_API_MODEL_OPTS = {
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
# thinking AUS: ungebremst dachte das Modell bei großen Extraktions-Chunks
# 8k32k Output-Tokens lang (aak Lauf 15: 4 Calls liefen ins 32k-Cap, leere
# Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene).
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
"thinking": {"type": "disabled"}},
}
async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis:
body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
resp = await asyncio.wait_for(client.post(_API_URL, json=body, headers=headers), timeout=timeout)
except httpx.TimeoutException:
raise asyncio.TimeoutError
except httpx.HTTPError as e:
return AgentErgebnis(1, "", f"{type(e).__name__}: {e}")
if resp.status_code == 429:
try:
retry_after = float(resp.headers.get("retry-after", "15"))
except ValueError:
retry_after = 15.0
drossel_melden(min(retry_after, 60.0))
return AgentErgebnis(1, "", "HTTP 429: rate limited")
if resp.status_code != 200:
return AgentErgebnis(1, "", f"HTTP {resp.status_code}: {resp.text[:300]}")
data = resp.json()
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
u = data.get("usage") or {}
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
"cache_read": int(u.get("cache_read_input_tokens") or 0),
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
if not text.strip():
return AgentErgebnis(1, "", f"leere Antwort (stop={data.get('stop_reason')})", tokens)
return AgentErgebnis(0, text, "", tokens)

172
backend/artefakte.py Normal file
View File

@@ -0,0 +1,172 @@
"""Ebene 2: Artefakte. Pro Atom 2 Flashcards + 1 Worked Example, generiert in
Soll-Punkt-Gruppen (Kontext teilen spart Tokens — Lektion 52), verifiziert von einem
2er-Panel GEGEN DIE ANKER-ZITATE (inline, Lektion 48). Einstimmig ok → verifiziert;
sonst ein Fix + Re-Verify durch einen Judge; danach verifiziert oder verworfen."""
import asyncio
import logging
import re
import db
import llm
from config import ARTEFAKT_CHUNK_ATOME, VERIFY_PANEL
log = logging.getLogger("creator2.artefakte")
EBENE = "artefakte"
# Karten müssen ohne den Quelltext funktionieren (aak Lauf 8: 17 verifizierte
# Karten fragten „was steht im Beleg"). Eng gefasst, damit Fachwörter nicht
# matchen: „Belegung" (Aussagenlogik), „Quelle" (Flussnetzwerke), „belegen".
_QUELLEN_REFERENZ = re.compile(
r"\bbeleg(e|s)?\b|musterlösung|quelltext|laut (quelle|text|skript)"
r"|(aus|in) de[rm] (quelle|text|skript)|aufgabenkontext|hausaufgabe"
r"|präsenzaufgabe|klausur\w*|\baufgabe \d|\bserie \d|\bblatt \d",
re.IGNORECASE)
def _referenziert_quelle(inhalt: dict) -> bool:
return _QUELLEN_REFERENZ.search(" ".join(str(v) for v in inhalt.values())) is not None
def _zitate(atom_id: int) -> str:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,))
return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)"
def _atom_block(a: dict) -> str:
return (f"ATOM {a['id']}: {a['titel']} [{a['typ']}]\n"
f"Definition: {a['definition']}\nBelege:\n{_zitate(a['id'])}")
def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
"""Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome ohne Artefakte."""
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)
atome = [a for a in atome if not db.one(
"SELECT id FROM artefakte WHERE atom_id=? AND status!='verworfen'"
" LIMIT 1", (a["id"],))]
gruppen: dict = {}
for a in atome:
gruppen.setdefault(a["soll_id"], []).append(a)
out = []
for gruppe in gruppen.values():
out += [gruppe[i:i + ARTEFAKT_CHUNK_ATOME]
for i in range(0, len(gruppe), ARTEFAKT_CHUNK_ATOME)]
return out
async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
bloecke = "\n\n".join(_atom_block(a) for a in chunk)
res = await llm.call(ctx, stage="artefakt", template="Artefakt-Generate",
werte={"atome": bloecke}, role="guide",
n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list)
gueltig = {a["id"] for a in chunk}
for e in res or []:
atom_id = e.get("atom")
typ = str(e.get("typ", "")).strip()
if atom_id not in gueltig or typ not in ("flashcard", "beispiel"):
continue
inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")}
if _referenziert_quelle(inhalt):
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
continue
db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat")
async def _verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
"""Panel prüft alle Kandidaten des Chunks gegen die Belege. Verwerfen ist
destruktiv → nur nach Fix + Re-Verify (im Zweifel behalten, Lektion 67)."""
kandidaten = []
for a in chunk:
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'",
(a["id"],)):
if _referenziert_quelle(db.uj(k["inhalt"])): # deterministisch, vor dem Panel
db.update("artefakte", "id", k["id"], status="verworfen")
else:
kandidaten.append(k)
if not kandidaten:
return
je_atom = {a["id"]: a for a in chunk}
liste = "\n\n".join(
f"ARTEFAKT {k['id']} (Atom {k['atom_id']}, {k['typ']}): {k['inhalt']}\n"
f"Belege:\n{_zitate(k['atom_id'])}" for k in kandidaten)
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="verify", template="Artefakt-Verify",
werte={"artefakte": liste}, role="judge",
n=len(kandidaten), item=f"v{chunk[0]['id']}", erwartet=list)
for k in kandidaten:
urteile = []
maengel = []
for stimme in stimmen:
for e in stimme:
if isinstance(e, dict) and e.get("artefakt") == k["id"]:
urteile.append(bool(e.get("ok")))
if e.get("mangel"):
maengel.append(str(e["mangel"]))
break
if len(urteile) >= VERIFY_PANEL and all(urteile):
db.update("artefakte", "id", k["id"], status="verifiziert")
elif urteile:
await _fixen(ctx, k, maengel, je_atom.get(k["atom_id"], {}))
# keine gültige Stimme (Panel-Ausfall) → Kandidat bleibt, nächste Runde prüft
async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> None:
res = await llm.call(ctx, stage="artefakt_fix", template="Artefakt-Fix",
schritt="fix", role="guide", item=f"f{k['id']}",
werte={"artefakt": k["inhalt"], "typ": k["typ"],
"maengel": "\n".join(f"- {m}" for m in maengel) or "-",
"belege": _zitate(k["atom_id"])},
erwartet=dict)
if res:
inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort", "text")}
if _referenziert_quelle(inhalt):
db.update("artefakte", "id", k["id"], status="verworfen")
return
db.update("artefakte", "id", k["id"], inhalt=db.j(inhalt))
urteil = await llm.call(ctx, stage="reverify", template="Artefakt-Verify",
schritt="verify", role="judge", item=f"rv{k['id']}",
werte={"artefakte": f"ARTEFAKT {k['id']} (Atom {k['atom_id']},"
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")
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))
alle = _chunks(ctx.topic, nur_ohne=False)
await asyncio.gather(*(_verifizieren(ctx, c) for c in alle))
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen')", (ctx.topic,))
for a in atome:
karten = db.query("SELECT status FROM artefakte WHERE atom_id=? AND typ='flashcard'",
(a["id"],))
if not any(k["status"] == "verifiziert" for k in karten):
befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]),
"detail": a["titel"]})
offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'",
(a["id"],))
for k in offen:
befunde.append({"art": "artefakt_unentschieden", "item": str(k["id"]),
"detail": a["titel"]})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
if not befunde:
return False
await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes
return True

27
backend/auto_loop.py Normal file
View File

@@ -0,0 +1,27 @@
"""QA→Repair-Loop-Primitiv, DB-frei. Drei Abbruchbedingungen (Lektion 77):
Ziel erreicht / echter Stillstand (nichts bewegt UND keine Verbesserung) / hartes
Limit. Transiente Verschlechterung ist erlaubt — eine bewegte Runde darf die Note
kurzfristig senken."""
import logging
from config import LOOP_MAX_ITER
log = logging.getLogger("creator2.auto_loop")
VOLL = 10.0
async def auto_repair_loop(ebene: str, erst_note: float, reparieren,
max_iter: int = LOOP_MAX_ITER) -> tuple[float, str]:
"""reparieren() → (neue_note, bewegt). → (note, grund fertig|stillstand|limit)."""
note = erst_note
for i in range(max_iter):
if note >= VOLL:
return note, "fertig"
neue_note, bewegt = await reparieren()
log.info("%s: Iteration %d — Note %.1f%.1f, bewegt=%s", ebene, i + 1, note, neue_note, bewegt)
if not bewegt and neue_note <= note:
return neue_note, "stillstand"
note = neue_note
return note, "fertig" if note >= VOLL else "limit"

158
backend/config.py Normal file
View File

@@ -0,0 +1,158 @@
"""Zentrale Konfiguration. Tuning-Konstanten leben hier, QA-Messlatten in qa.py
(die Messlatte darf nie Teil eines Suchraums werden)."""
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = PROJECT_ROOT / "templates"
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:
"""Mini-.env-Loader. Die DATEI gewinnt über geerbtes Env — ein --reload-Master
behält sonst sein Start-Environment und pinnt stille Altwerte über .env-Edits."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if key:
os.environ[key] = value
_load_env(PROJECT_ROOT / ".env")
# ── 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
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20")) # unter x % frei warten CLI-Spawns
OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5")) # Session-DB-Kollisionen
# ── Infra-Fehler: nie fail-open ───────────────────────────────────────────────
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Call, dann Lauf-PAUSE
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
HEDGE_NACH_S = 90 # Slot ohne Ergebnis nach max(dies, timeout/2) → Zwilling; 0 = aus
# ── Budget ────────────────────────────────────────────────────────────────────
RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "0")) # in+out pro Lauf; 0 = aus
# ── Ebene 0: Korpus & Soll ────────────────────────────────────────────────────
RECHERCHE_LENSES = ("curriculum", "lehrbuch", "syllabus", "uebungen") # 1 Agent je Lens/Runde
RECHERCHE_RUNDEN_MAX = 3 # Sättigungsloop-Cap (Themen)
SOLL_MIN_BELEGE = 2 # Punkt bestätigt ab so vielen unabhängigen Quellen
SOLL_CHUNK_CHARS = 40_000 # Quelltext pro Soll-Extraktions-Call
# Themenband der Soll-Punkte: k = c·√(Kandidaten), weiches Band [0.7k, 1.3k] (Lektion 80).
# Gemessen (aak): ohne Band faltete der Konsens 254 Atome auf 7 Punkte → Kapitel ohne
# Leitstruktur, Grundlagen mischten sich in Spezialthemen.
SOLL_PUNKTE_PER_SQRT = 1.0
SOLL_PUNKTE_MIN = 5
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig)
FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
BAUSTEIN_MIN_ATOME = 4
BAUSTEIN_MAX_ATOME = 8
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
# ── Auto-Loop ─────────────────────────────────────────────────────────────────
LOOP_MAX_ITER = 10
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
TIMEOUTS = {
"recherche": (900, 0),
"soll": (300, 0),
"soll_konsens": (300, 2),
"extraktion": (450, 0),
"merge": (150, 8),
"soll_zuordnung": (300, 8),
"luecke": (450, 0),
"artefakt": (450, 20),
"verify": (200, 10),
"fix": (300, 15),
"ziele": (300, 6),
"kapitel": (200, 2),
"zyklus": (150, 0),
"writer": (450, 60),
"pruefer": (600, 5),
"qa_judge": (600, 0),
}
def timeout_fuer(step: str, n: int = 0) -> int:
base, per = TIMEOUTS[step]
return int(base + per * n)
# ── Provider-Stacks (unabhängig; jeder kann jederzeit fehlen) ─────────────────
# Rollen: quick = Massenarbeit (Extraktion), judge = kalte Urteile,
# guide = große Generierung (Writer), fast = Interaktion/Leichtes.
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
PROVIDERS = {
"claude": {
"cli": "claude",
"guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6",
"judge": "claude-sonnet-4-6",
"quick": "claude-sonnet-4-6",
"env_key": None,
},
"minimax": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
# native Route für Judges: der kalt-Endpoint stallte 20 % der Judge-Calls (Vorgänger, 2026-07-04)
"judge": "minimax/MiniMax-M3",
"quick": "minimax-kalt/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
"lokal": {
"cli": "opencode",
"guide": "ollama/qwen3.6:27b",
"fast": "ollama/qwen3.5:9b",
"judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b",
"env_key": None,
"check_url": "http://localhost:11434/api/tags",
},
}
ROLE_ROUTING = {r: os.getenv(f"ROLE_{r.upper()}", "") for r in ("quick", "judge", "guide", "fast")}
def resolve_role(run_provider: str, role: str) -> tuple[str, str]:
"""→ (provider, model). Reines Routing; Verfügbarkeits-Fallback macht agents.run_agent."""
target = ROLE_ROUTING.get(role, "") or run_provider
provider, _, model = target.partition(":")
if provider not in PROVIDERS:
provider, model = run_provider, ""
if not model:
model = PROVIDERS.get(provider, {}).get(role, "")
return provider, model

190
backend/db.py Normal file
View File

@@ -0,0 +1,190 @@
"""SQLite-Schicht. Aller Pipeline-Zustand lebt HIER (Resume-Prinzip: jeder Schritt
ist idempotent, weil er nur offene Status-Werte weiterverarbeitet).
Synchronen Zugriff bewusst: DB-Arbeit ist gegen LLM-Latenz vernachlässigbar.
`on_change` (von main.py gesetzt) meldet Statuswechsel ans Live-Board."""
import json
import sqlite3
import threading
from pathlib import Path
from config import DB_PATH
on_change = None # callable(tabelle: str, row: dict) | None — nie fatal
_lock = threading.RLock()
_con: sqlite3.Connection | None = None
def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
global _con
with _lock:
if _con is not None:
return _con
Path(path).parent.mkdir(parents=True, exist_ok=True)
_con = sqlite3.connect(path, check_same_thread=False)
_con.row_factory = sqlite3.Row
_con.execute("PRAGMA journal_mode=WAL")
_con.execute("PRAGMA foreign_keys=ON")
_init_schema(_con)
return _con
def reset_for_tests(path: str = ":memory:") -> None:
"""Frische DB (Tests): bestehende Verbindung verwerfen, neue öffnen."""
global _con
with _lock:
if _con is not None:
_con.close()
_con = None
connect(path)
_SCHEMA = """
CREATE TABLE IF NOT EXISTS topics(
name TEXT PRIMARY KEY, titel TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('thema','uni')),
status TEXT NOT NULL DEFAULT 'neu', provider TEXT NOT NULL DEFAULT '',
auto TEXT NOT NULL DEFAULT '{}', erstellt TEXT DEFAULT (datetime('now')));
CREATE TABLE IF NOT EXISTS runs(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running',
gestartet TEXT DEFAULT (datetime('now')), beendet TEXT, ebene TEXT DEFAULT '',
git_hash TEXT DEFAULT '', git_dirty INTEGER DEFAULT 0, budget_tokens INTEGER DEFAULT 0, grund TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS events(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ts TEXT DEFAULT (datetime('now')),
ebene TEXT NOT NULL, stage TEXT NOT NULL, item TEXT DEFAULT '', template TEXT DEFAULT '',
template_hash TEXT DEFAULT '', provider TEXT DEFAULT '', model TEXT DEFAULT '', role TEXT DEFAULT '',
status TEXT NOT NULL, dur_ms INTEGER DEFAULT 0, wait_ms INTEGER DEFAULT 0,
tok_in INTEGER DEFAULT 0, tok_out INTEGER DEFAULT 0, tok_cache_read INTEGER DEFAULT 0,
tok_cache_write INTEGER DEFAULT 0, meta TEXT DEFAULT '{}');
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
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');
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 '[]',
intro TEXT NOT NULL DEFAULT '');
CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]');
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
CREATE TABLE IF NOT EXISTS anker(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
start INTEGER NOT NULL, ende INTEGER NOT NULL, zitat TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_anker_atom ON anker(atom_id);
CREATE TABLE IF NOT EXISTS kanten(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, von_atom INTEGER NOT NULL, zu_atom INTEGER NOT NULL,
art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv',
UNIQUE(von_atom, zu_atom, art));
CREATE TABLE IF NOT EXISTS artefakte(
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')),
inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');
CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);
CREATE TABLE IF NOT EXISTS lernziele(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, text TEXT NOT NULL, soll_id INTEGER,
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER);
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');
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 '[]',
qa_hash TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS leitner(
artefakt_id INTEGER PRIMARY KEY, box INTEGER NOT NULL DEFAULT 1,
faellig TEXT DEFAULT (date('now')), historie TEXT DEFAULT '[]');
CREATE TABLE IF NOT EXISTS befunde(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ebene TEXT NOT NULL, art TEXT NOT NULL,
item TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'offen');
"""
# Tabellen, deren Änderungen das Live-Board interessieren.
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
def _init_schema(con: sqlite3.Connection) -> None:
con.executescript(_SCHEMA)
# Migration für Bestands-DBs: CREATE IF NOT EXISTS ergänzt keine neuen Spalten.
for zusatz in ("ALTER TABLE soll ADD COLUMN intro TEXT NOT NULL DEFAULT ''",
"ALTER TABLE lernziele ADD COLUMN titel TEXT NOT NULL DEFAULT ''",
"ALTER TABLE quellen ADD COLUMN rolle TEXT NOT NULL DEFAULT 'stoff'",
"ALTER TABLE sections ADD COLUMN qa_hash TEXT DEFAULT ''",
"ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"):
try:
con.execute(zusatz)
except sqlite3.OperationalError:
pass # Spalte existiert schon
con.commit()
def _notify(tabelle: str, row: dict) -> None:
if on_change is not None and tabelle in _LIVE_TABELLEN:
try:
on_change(tabelle, row)
except Exception:
pass
def _rowdict(row: sqlite3.Row | None) -> dict | None:
return dict(row) if row is not None else None
def query(sql: str, params: tuple = ()) -> list[dict]:
con = connect()
with _lock:
return [dict(r) for r in con.execute(sql, params).fetchall()]
def one(sql: str, params: tuple = ()) -> dict | None:
con = connect()
with _lock:
return _rowdict(con.execute(sql, params).fetchone())
def execute(sql: str, params: tuple = ()) -> int:
"""→ lastrowid. Kein Notify — für Ledger/Interna."""
con = connect()
with _lock:
cur = con.execute(sql, params)
con.commit()
return cur.lastrowid
def insert(tabelle: str, **felder) -> int:
keys = ", ".join(felder)
marks = ", ".join("?" * len(felder))
rid = execute(f"INSERT INTO {tabelle}({keys}) VALUES({marks})", tuple(felder.values()))
_notify(tabelle, {"id": rid, **felder})
return rid
def update(tabelle: str, id_feld: str, id_wert, **felder) -> None:
sets = ", ".join(f"{k}=?" for k in felder)
execute(f"UPDATE {tabelle} SET {sets} WHERE {id_feld}=?", (*felder.values(), id_wert))
_notify(tabelle, {id_feld: id_wert, **felder})
def j(obj) -> str:
return json.dumps(obj, ensure_ascii=False)
def uj(text: str | None, default=None):
if not text:
return default if default is not None else []
try:
return json.loads(text)
except ValueError:
return default if default is not None else []

67
backend/embedding.py Normal file
View File

@@ -0,0 +1,67 @@
"""Ähnlichkeit für Merge-KANDIDATEN — entscheidet nie selbst (das tut das Panel).
sentence-transformers wenn vorhanden, sonst lexikalischer Jaccard-Fallback.
Verglichen werden DEFINITIONEN, nie Titel (Lektion: Titel-Embeddings rauschen)."""
import logging
import re
log = logging.getLogger("creator2.embedding")
_modell = None
_geladen = False
def _lade_modell():
global _modell, _geladen
if _geladen:
return _modell
_geladen = True
try:
from sentence_transformers import SentenceTransformer
_modell = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
log.info("Embedding-Modell geladen")
except Exception:
log.warning("kein Embedding-Modell — lexikalischer Jaccard-Fallback"
" (findet Paraphrasen-Dubletten deutlich schlechter);"
" Fix: pip install sentence-transformers")
_modell = None
return _modell
_WORT = re.compile(r"[a-zäöüß0-9]+")
def _tokens(text: str) -> set[str]:
return set(_WORT.findall(text.lower()))
def _jaccard(a: str, b: str) -> float:
ta, tb = _tokens(a), _tokens(b)
if not ta or not tb:
return 0.0
return len(ta & tb) / len(ta | tb)
def aehnlichkeit_matrix(texte: list[str]) -> list[list[float]]:
"""Paarweise Ähnlichkeit [0..1]. Symmetrisch, Diagonale 1."""
modell = _lade_modell()
n = len(texte)
if modell is not None:
import numpy as np
emb = modell.encode(texte, normalize_embeddings=True)
return (emb @ emb.T).tolist()
return [[1.0 if i == j else _jaccard(texte[i], texte[j]) for j in range(n)] for i in range(n)]
def kandidaten_paare(texte: list[str], floor: float,
fallback_floor: float | None = None) -> list[tuple[int, int, float]]:
"""Alle Index-Paare mit Ähnlichkeit ≥ Schwelle, absteigend sortiert.
Im Jaccard-Fallback gilt fallback_floor: echte Paraphrasen messen dort nur
~0.30.5 — die Kosinus-Schwelle ließe sie alle durchrutschen (aak Lauf 8:
13 Panel-Calls bei 26 überlebenden Dubletten-Gruppen)."""
m = aehnlichkeit_matrix(texte)
if _modell is None and fallback_floor is not None:
floor = fallback_floor
paare = [(i, j, m[i][j]) for i in range(len(texte)) for j in range(i + 1, len(texte))
if m[i][j] >= floor]
return sorted(paare, key=lambda p: -p[2])

245
backend/fake_agents.py Normal file
View File

@@ -0,0 +1,245 @@
"""Deterministische Fake-Antworten auf run_agent-Ebene (CREATOR_FAKE_AGENTS=1):
kompletter Generierungspfad in Sekunden, ohne LLM. Die Fakes erkennen das Template
am Marker `<!-- template:Name -->` und LESEN den Prompt (Zitate müssen wörtlich im
gelieferten Text stehen, ids müssen existieren) — sonst testet der E2E-Pfad nichts."""
import json
import re
from agents import AgentErgebnis
# Die Fake-Welt: drei Atome, zwei Soll-Punkte, eine Voraussetzungskette.
SATZ_DFA = "Ein deterministischer endlicher Automat (DFA) besteht aus Zuständen und Übergängen."
SATZ_POTENZ = "Die Potenzmengenkonstruktion wandelt einen NFA in einen DFA um."
SATZ_PUMPING = "Das Pumping-Lemma beweist, dass eine Sprache nicht regulär ist."
FAKE_TEXT = f"{SATZ_DFA}\n\n{SATZ_POTENZ}\n\n{SATZ_PUMPING}\n"
ATOME = [
{"titel": "Deterministischer endlicher Automat", "typ": "begriff",
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E", "braucht": []},
{"titel": "Potenzmengenkonstruktion", "typ": "verfahren",
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M",
"braucht": ["Deterministischer endlicher Automat"]},
{"titel": "Pumping-Lemma", "typ": "aussage",
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S",
"braucht": ["Deterministischer endlicher Automat"]},
]
PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)),
("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))]
_TEMPLATE = re.compile(r"<!--\s*template:([\w-]+)\s*-->")
def _text_nach(prompt: str, marke: str) -> str:
pos = prompt.find(marke)
return prompt[pos + len(marke):] if pos >= 0 else ""
def _ids(text: str) -> list[int]:
return [int(m) for m in re.findall(r"^(\d+):", text, re.MULTILINE)]
def _ok(daten) -> AgentErgebnis:
return AgentErgebnis(0, json.dumps(daten, ensure_ascii=False), "",
{"input": 100, "output": 50})
async def antwort(key: str, prompt: str, capabilities: str) -> AgentErgebnis:
m = _TEMPLATE.search(prompt)
name = m.group(1) if m else ""
fn = _HANDLER.get(name)
if fn is None:
return AgentErgebnis(1, "", f"fake: kein Handler für Template {name!r}")
return _ok(fn(prompt))
def _recherche(prompt: str):
lens = re.search(r"\(Lens\): \*\*(\w+)\*\*", prompt)
lens = lens.group(1) if lens else "x"
inhalt = FAKE_TEXT + f"\nHinweis der Lens {lens}: " + "Übung macht den Meister. " * 40
return [{"titel": f"Fake-Quelle {lens}", "url": f"https://fake/{lens}", "inhalt": inhalt}]
def _soll(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
return [{"punkt": p, "zitat": s[0]} for p, s in PUNKTE if s[0] in text]
def _soll_konsens(prompt: str):
zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "KANDIDATEN:"), re.MULTILINE)
gruppen: dict[str, list[int]] = {}
for i, punkt in zeilen:
gruppen.setdefault(punkt.strip(), []).append(int(i))
return [{"punkt": p, "kandidaten": ids} for p, ids in gruppen.items()]
def _soll_beleg(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
for _, saetze in PUNKTE:
if saetze[0] in text and saetze[0] in prompt:
return {"zitat": saetze[0]}
return {"zitat": ""}
def _extraktion(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
return [a for a in ATOME if a["zitat"] in text]
def _merge(prompt: str):
paare = re.findall(r"PAAR (\d+):\nA\(id \d+\): ([^—]+)—.*?\nB\(id \d+\): ([^—]+)—",
prompt, re.DOTALL)
return [{"paar": int(n), "gleich": a.strip() == b.strip()} for n, a, b in paare]
def _zuordnung_map(prompt: str) -> dict[str, int]:
soll = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE"), re.MULTILINE)
out = {}
for i, punkt in soll:
out[punkt.strip()] = int(i)
return out
def _soll_zuordnung(prompt: str):
punkte = _zuordnung_map(prompt)
atome = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME"), re.MULTILINE)
out = []
for i, titel in atome:
t = titel.lower()
punkt = ("Nachweis von Nicht-Regularität" if "pumping" in t else "Endliche Automaten")
out.append({"atom": int(i), "soll": punkte.get(punkt, next(iter(punkte.values()), 0))})
return out
def _soll_stich(prompt: str):
punkte = _zuordnung_map(prompt)
return {"soll": next(iter(punkte.values()), "fremd")}
def _anker_fix(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
for a in ATOME:
if a["titel"] in prompt and a["zitat"] in text:
return {"zitat": a["zitat"]}
return {"zitat": ""}
def _anker_fix_batch(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
out = []
for i, titel in zeilen:
for a in ATOME:
if a["titel"] == titel.strip() and a["zitat"] in text:
out.append({"atom": int(i), "zitat": a["zitat"]})
return out
def _luecke(prompt: str):
text = _text_nach(prompt, "QUELLTEXT:")
return [{k: a[k] for k in ("titel", "typ", "definition", "zitat")}
for a in ATOME if a["zitat"] in text]
def _artefakt_generate(prompt: str):
ids = [int(i) for i in re.findall(r"ATOM (\d+):", prompt)]
out = []
for i in ids:
out += [{"atom": i, "typ": "flashcard", "frage": f"Was besagt Atom {i}?",
"antwort": "Siehe Beleg — deterministische Fake-Antwort."},
{"atom": i, "typ": "flashcard", "frage": f"Wozu dient Atom {i}?",
"antwort": "Zum Lernen — deterministische Fake-Antwort."},
{"atom": i, "typ": "beispiel", "text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."}]
return out
def _artefakt_verify(prompt: str):
ids = [int(i) for i in re.findall(r"ARTEFAKT (\d+)", prompt)]
return [{"artefakt": i, "ok": True, "mangel": ""} for i in ids]
def _artefakt_fix(prompt: str):
return {"frage": "Fix-Frage?", "antwort": "Fix-Antwort.", "text": "Fix-Beispiel."}
def _lernziele(prompt: str):
punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt)
name = punkt.group(1) if punkt else "das Thema"
ids = _ids(_text_nach(prompt, "ATOME:"))
return [{"ziel": f"Kann {name} anwenden und erklären", "titel": name, "atome": ids}]
def _kapitel_intro(prompt: str):
punkt = re.search(r"Kapitel „(.+?)“", prompt) or re.search(r'Kapitel „(.+?)"', prompt)
return {"intro": f"Dieses Kapitel behandelt {punkt.group(1) if punkt else 'das Thema'}"
" in aufeinander aufbauenden Abschnitten."}
def _kapitel_schnitt(prompt: str):
ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"),
re.MULTILINE)]
grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)]
return [{"titel": f"Themenblock {n + 1}", "bis": g}
for n, g in enumerate(dict.fromkeys(grenzen))]
def _zyklus(prompt: str):
m = re.search(r"^(\d+)→(\d+):", _text_nach(prompt, "ZYKLUS:"), re.MULTILINE)
return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {}
def _writer(prompt: str):
ziel = re.search(r"Lernziel: (.+)", prompt)
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) \| [EMS] -->)",
prompt)
defs = re.findall(r"Definition: (.+)", prompt)
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
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}")
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))
lang = "\n\n".join(absaetze)
while len(lang.split()) < lo:
lang += "\nNoch ein Übungssatz zur Vertiefung des Gelernten."
return {"kompakt": f"- Kurzfassung in {len(marker)} Schritten\n- alles Wesentliche sitzt",
"lang": lang}
def _pruefer(prompt: str):
return {"befunde": []}
def _fix(prompt: str):
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
return {"kompakt": kompakt, "lang": lang}
def _qa_falsch(prompt: str):
return []
def _qa_falsch_check(prompt: str):
nummern = [int(i) for i in re.findall(r"^(\d+):", _text_nach(prompt, "CLAIMS:"), re.MULTILINE)]
return [{"claim": i, "falsch": False} for i in nummern]
_HANDLER = {
"Korpus-Recherche": _recherche, "Korpus-Soll": _soll,
"Korpus-Soll-Konsens": _soll_konsens, "Korpus-Soll-Beleg": _soll_beleg,
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
"Atom-Merge": _merge,
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
"Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch,
"Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
"Kapitel-Schnitt": _kapitel_schnitt,
"Kanten-Zyklus": _zyklus,
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
}

408
backend/guide.py Normal file
View File

@@ -0,0 +1,408 @@
"""Ebene 4: Guide. Eine Section pro Baustein, Stages seriell je Karte:
writer → pruefer → fix → done (persistiert in sections.stage, resume-fähig).
Der Writer bekommt die komplette Faktenbasis INLINE (Definitionen, Anker-Zitate,
verifizierte Beispiele — Lektion 48) und muss Marker als unsichtbare Invariante
setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage,
Lektion 52); deterministische Checks laufen davor im Code."""
import asyncio
import logging
import re
import db
import llm
import textkit
from config import SECTION_WOERTER_PRO_ATOM
log = logging.getLogger("creator2.guide")
EBENE = "guide"
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
# 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)
KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch")
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 _zitate(atom_id: int, max_n: int = 3) -> list[str]:
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?",
(atom_id, max_n))
return [r["zitat"] for r in rows]
def _beispiel(atom_id: int) -> str:
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
" AND status='verifiziert' LIMIT 1", (atom_id,))
return db.uj(row["inhalt"], {}).get("text", "") if row else ""
def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
rows = db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status='verifiziert' LIMIT ?", (atom_id, max_n))
return [f for r in rows if (f := db.uj(r["inhalt"], {}).get("frage", "").strip())]
def _atom_paket(a: dict) -> str:
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
beispiel = _beispiel(a["id"])
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} | {a['level']} -->\n"
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
if beispiel:
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
fragen = _fragen_pool(a["id"])
if fragen:
teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen)
return teil
def _laenge_band(n_atome: int) -> tuple[int, int]:
lo, hi = SECTION_WOERTER_PRO_ATOM
return lo * n_atome, hi * n_atome
# ── Stages ────────────────────────────────────────────────────────────────────
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
atome = _atome_von(b["id"])
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
lo, hi = _laenge_band(len(atome))
res = await llm.call(ctx, stage="writer", template="Guide-Writer",
werte={"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
if res is None:
return "writer" # Stage bleibt, nächster Lauf versucht erneut
kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", ""))
fehlend = _marker_fehlend(lang, atome)
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
schritt="writer", role="guide", n=len(atome),
item=f"b{b['id']}-2",
werte={"titel": b["titel"], "ziel": ziel["text"],
"atome": "\n\n".join(_atom_paket(a) for a in atome),
"min_woerter": lo, "max_woerter": hi},
erwartet=dict)
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
return "pruefer"
def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
da = {int(m) for m in _MARKER.findall(text)}
return [a["id"] for a in atome if a["id"] not in da]
def _det_auftraege(topic: str, b: dict, lang: str) -> list[str]:
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
atome = _atome_von(b["id"])
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
for i in _marker_fehlend(lang, atome)]
lo, hi = _laenge_band(len(atome))
woerter = len(lang.split())
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}).")
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
f" oder entfernen — nie unerklärt verwenden."
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
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
f" Worten in den Fließtext einarbeiten, Zitat-Format entfernen.")
if "6=" in lang:
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
" durch $\\neq$ ersetzen.")
for nr, absatz in enumerate(lang.split("\n\n"), 1):
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
auftraege.append(f"Absatz {nr}: ungerade Anzahl $-Zeichen — jede Formel"
f" braucht öffnendes UND schließendes $ (Beispiel-Fehler:"
f" „$[.“ statt „$[$.“).")
ohne_mathe = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", lang)
nackt = sorted(set(re.findall(
r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists"
r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne_mathe)))
if nackt: # KaTeX rendert nur innerhalb von $…$ — nackte Befehle bleiben Rohtext
auftraege.append(f"LaTeX ohne $-Delimiter im Text ({', '.join(nackt[:5])}…):"
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
return auftraege
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
kap_ord = {k["id"]: k["ord"] for k in
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.sub("", 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"
" WHERE bb.topic=? AND a.status NOT IN ('gemerged','verworfen')", (topic,)):
if je_baustein.get(a["bid"], 0) <= mein_kapitel:
continue
t = textkit.norm(a["titel"])
if len(t) < 6 and len(t.split()) < 2:
continue
if re.search(rf"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
treffer.append(a["titel"])
return treffer
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
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"])
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",
schritt="pruefer", role="judge", n=len(atome),
item=f"b{b['id']}{tag}",
werte={"ziel": ziel["text"], "fakten": fakten,
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]},
erwartet=dict)
for e in (res or {}).get("befunde", []):
art = str(e.get("art", "")).strip()
detail = str(e.get("detail", "")).strip()
if art in ("falsch", "luecke"):
auftraege.append(f"KRITISCH ({art}): {detail}")
elif art == "stil" and detail:
auftraege.append(detail)
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
return "fix" if auftraege else "done"
async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
auftraege = db.uj(sec["befunde"])
if not auftraege:
return "done"
atome = _atome_von(b["id"])
fakten = "\n\n".join(_atom_paket(a) for a in atome)
res = await llm.call(ctx, stage="fix", template="Guide-Fix",
role="guide", n=len(auftraege), item=f"b{b['id']}",
werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
"auftraege": "\n".join(f"- {a}" for a in auftraege),
"fakten": fakten},
erwartet=dict)
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
if res:
lang = str(res.get("lang", ""))
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
db.update("sections", "baustein_id", b["id"],
text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([]))
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
await _stage_pruefer(ctx, b, tag="-re")
return "done"
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
_STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
async def _karte(ctx: llm.Kontext, b: dict) -> None:
db.execute("INSERT OR IGNORE INTO sections(baustein_id) VALUES(?)", (b["id"],))
for _ in range(6): # Stages seriell; Schutz gegen Stage-Schleifen
sec = db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],))
if sec["stage"] == "done":
db.update("bausteine", "id", b["id"], status="fertig")
return
naechste = await _STAGES[sec["stage"]](ctx, b)
if naechste == sec["stage"]:
return # kein Fortschritt (Writer erschöpft) → Karte offen lassen
db.update("sections", "baustein_id", b["id"], stage=naechste)
db.update("bausteine", "id", b["id"], status="fertig")
async def _kapitel_intros(ctx: llm.Kontext) -> None:
"""Advance Organizer je Kapitel: 24 Sätze Landkarte, KEINE Inhalte
(Redundancy-Effekt). Idempotent — gefüllte Intros bleiben."""
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (ctx.topic,))}
alle = _bausteine(ctx.topic)
async def einer(kap: dict) -> None:
bausteine = [b for b in alle if b["kapitel_id"] == kap["id"]]
if not bausteine:
return
liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}"
for b in bausteine)
res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro",
werte={"titel": kap["titel"], "bausteine": liste},
role="judge", n=len(bausteine), item=f"k{kap['id']}",
erwartet=dict)
intro = str((res or {}).get("intro", "")).strip()
if intro:
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))
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
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 _kapitel_intros(ctx)
def kapitel_struktur(topic: str) -> list[dict]:
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
kaps = {k["id"]: k for k in
db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))}
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
kapitel: 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:
k = kaps.get(k_id, {})
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
"intro": k.get("intro", ""), "sections": []})
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": sec["text_lang"] if sec else ""})
return kapitel
def guide_markdown(topic: str) -> str:
t = db.one("SELECT titel FROM topics WHERE name=?", (topic,))
teile = [f"# {t['titel'] if t else topic}"]
for kap in kapitel_struktur(topic):
teile.append(f"## {kap['titel']}")
if kap["intro"]:
teile.append(kap["intro"])
for s in kap["sections"]:
if s["lang"]:
teile.append(f"### {s['titel']}\n\n{s['lang']}")
return "\n\n".join(teile)
# ── QA + Repair ───────────────────────────────────────────────────────────────
def _stopfrei(text: str) -> set[str]:
stop = {"der", "die", "das", "und", "oder", "kann", "eine", "einen", "für", "von",
"mit", "den", "dem", "sich", "auf", "aus", "sind", "wird", "werden", "lernende"}
return {t for t in textkit.tokens(text) if len(t) >= 4 and t not in stop}
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
for b in _bausteine(ctx.topic):
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
befunde.append({"art": "section_fehlt", "item": str(b["id"]), "detail": b["titel"]})
continue
lang = sec["text_lang"]
atome = _atome_von(b["id"])
for i in _marker_fehlend(lang, atome):
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
"detail": f"Atom {i}"})
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
if ziel:
noetig = _stopfrei(ziel["text"])
# Writer formuliert per Design frei — Schwelle 1/3 statt 1/2, und
# kompakt + Titel zählen mit (Ziel-Wörter erscheinen paraphrasiert).
da = textkit.tokens(f"{b['titel']} {sec['text_kompakt']} {lang}")
if noetig and len(noetig & da) / len(noetig) < 0.34:
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
"detail": ziel["text"][:120]})
lo, hi = _laenge_band(len(atome))
w = len(lang.split())
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
for t in _vorwaertsverweise(ctx.topic, b, lang):
befunde.append({"art": "vorwaerts", "item": str(b["id"]), "detail": t})
return befunde
async def messen_llm(ctx: llm.Kontext) -> list[dict]:
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections
werden übersprungen (qa_hash) — die Prüfung lief sonst jede Repair-Iteration
über ALLE Sections (gemessen: 806 QA-Calls für 50 Sections)."""
import hashlib
ctx.ebene = EBENE
befunde = []
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))}
async def eine(b: dict) -> None:
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
if not sec or not sec["text_lang"]:
return
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
return # unverändert und sauber — kein Re-Check
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, b["id"]))
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 isinstance(c, dict) and c.get("claim")]
if not claims:
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']}",
werte={"fakten": fakten,
"claims": "\n".join(f"{i + 1}: {c}"
for i, c in enumerate(claims))},
erwartet=list)
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]})
await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic)))
return befunde
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)."""
ctx.ebene = EBENE
betroffen: dict[int, list[str]] = {}
for b in befunde:
try:
b_id = int(b["item"])
except (ValueError, TypeError):
continue
betroffen.setdefault(b_id, []).append(b["art"])
for b_id, arten in betroffen.items():
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))
else:
auftraege = [f"Behebe: {a}" for a in arten]
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)

456
backend/inventar.py Normal file
View File

@@ -0,0 +1,456 @@
"""Ebene 1: Inventar. Atome aus dem Korpus extrahieren — mit Verbatim-Anker als
Identitätskern. Dedup dreistufig (Lektionen 18/25/31):
1. Anker-Überlappung → Auto-Merge (deterministisch, gleiche Quellstelle = gleiches Atom)
2. Embedding-Kandidaten auf DEFINITIONEN (nie Titel) + Negations-Guard
3. 2er-Judge-Panel, Merge nur EINSTIMMIG; sonst „verwandt"-Kante (Falsch-Merge >> Dublette)
Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextraktion aus."""
import asyncio
import logging
import db
import embedding
import korpus
import llm
import textkit
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS,
MERGE_KANDIDAT_JACCARD,
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.inventar")
EBENE = "inventar"
TYPEN = ("begriff", "aussage", "verfahren")
LEVELS = ("E", "M", "S")
PAAR_CHUNK = 40 # Merge-Paare pro Judge-Call (Lektion 34/55)
def aktive_atome(topic: str) -> list[dict]:
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY id", (topic,))
# ── Extraktion ────────────────────────────────────────────────────────────────
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion.
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:
db.update("quellen", "id", quelle["id"], status="atome")
return
text = korpus.quelltext(quelle)
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
else "Atom-Extraktion")
async def reader(offset: int, chunk: str, r: int) -> None:
res = await llm.call(ctx, stage="extraktion", template=template,
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
role="quick", item=f"q{quelle['id']}-o{offset}-r{r}", erwartet=list)
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
zitat = str(a.get("zitat", "")).strip()
typ = str(a.get("typ", "")).strip().lower()
level = str(a.get("level", "M")).strip().upper()
if not titel or not definition or typ not in TYPEN:
continue
span = textkit.finde_zitat(chunk, zitat)
braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()]
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition,
level=level if level in LEVELS else "M",
status="neu" if span else "ohne_anker",
braucht=db.j(braucht))
if span:
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
elif zitat:
# Fehlgeschlagenes Zitat NICHT wegwerfen (start=-1 = unverankert):
# der Repair matcht es erst deterministisch neu, LLM nur als Fallback.
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
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)))
db.update("quellen", "id", quelle["id"], status="atome")
# ── Dedup ─────────────────────────────────────────────────────────────────────
def _wurzel(atom_id: int) -> int:
"""merged_into-Kette bis zum lebenden Atom folgen (Merge-Ketten im selben Lauf)."""
for _ in range(20):
row = db.one("SELECT merged_into FROM atome WHERE id=?", (atom_id,))
if not row or not row["merged_into"]:
return atom_id
atom_id = row["merged_into"]
return atom_id
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
"""Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
if gewinner == verlierer:
return
g = db.one("SELECT * FROM atome WHERE id=?", (gewinner,))
v = db.one("SELECT * FROM atome WHERE id=?", (verlierer,))
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
return
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"])))
db.update("atome", "id", gewinner, braucht=db.j(braucht))
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
# Merge zweier verbundener Atome — beide Fälle werden gelöscht statt verschoben.
for k in db.query("SELECT * FROM kanten WHERE von_atom=? OR zu_atom=?",
(verlierer, verlierer)):
von = gewinner if k["von_atom"] == verlierer else k["von_atom"]
zu = gewinner if k["zu_atom"] == verlierer else k["zu_atom"]
doppel = db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art=?"
" AND id!=?", (von, zu, k["art"], k["id"]))
if von == zu or doppel:
db.execute("DELETE FROM kanten WHERE id=?", (k["id"],))
else:
db.execute("UPDATE kanten SET von_atom=?, zu_atom=? WHERE id=?",
(von, zu, k["id"]))
def _anker_dedup(topic: str) -> None:
"""Gleiche Quellstelle = gleiches Atom. Deterministisch, kein Judge (Lektion: Anker
ist die Identität). Gewinner = längere Definition."""
atome = aktive_atome(topic)
anker = {a["id"]: db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a["id"],))
for a in atome}
je_def = {a["id"]: a["definition"] for a in atome}
ids = [a["id"] for a in atome if anker[a["id"]]]
for i, a_id in enumerate(ids):
for b_id in ids[i + 1:]:
if db.one("SELECT status FROM atome WHERE id=?", (a_id,))["status"] == "gemerged":
break
if db.one("SELECT status FROM atome WHERE id=?", (b_id,))["status"] == "gemerged":
continue
passt = any(ka["quelle_id"] == kb["quelle_id"]
and textkit.ueberlappung((ka["start"], ka["ende"]),
(kb["start"], kb["ende"])) >= ANKER_OVERLAP_MERGE
for ka in anker[a_id] for kb in anker[b_id])
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)
def _geprueft(a: int, b: int) -> bool:
lo, hi = min(a, b), max(a, b)
return db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art='verwandt'",
(lo, hi)) is not None
async def _judge_dedup(ctx: llm.Kontext) -> None:
"""Kandidaten (Definitions-Ähnlichkeit ≥ Floor ODER gleicher Titel-Kern,
gleiche Negationsmenge) ans 2er-Panel. Einstimmig gleich → Merge. Alles
andere → „verwandt" (persistiert, damit dasselbe Paar nie zweimal Tokens
kostet). Titel-Kern-Paare sind deterministisch — Dubletten aus
verschiedenen Quellen haben disjunkte Anker und paraphrasierte
Definitionen, das Embedding allein übersah sie (aak Lauf 8: 26 Gruppen)."""
atome = aktive_atome(topic := ctx.topic)
if len(atome) < 2:
return
je_kern: dict[str, list[int]] = {}
for idx, a in enumerate(atome):
if kern := textkit.titel_kern(a["titel"]):
je_kern.setdefault(kern, []).append(idx)
titel_paare = [(g[x], g[y], 1.0) for g in je_kern.values() if len(g) > 1
for x in range(len(g)) for y in range(x + 1, len(g))]
paare = embedding.kandidaten_paare([a["definition"] for a in atome],
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD)
offen = []
gesehen: set[tuple[int, int]] = set()
for i, j, cos in titel_paare + paare:
a, b = atome[i], atome[j]
if (a["id"], b["id"]) in gesehen:
continue
gesehen.add((a["id"], b["id"]))
if textkit.negations_menge(a["definition"]) != textkit.negations_menge(b["definition"]):
continue # Antonyme messen 0.910.95 (Lektion 31)
if _geprueft(a["id"], b["id"]):
continue
offen.append((a, b))
if not offen:
return
async def chunk_urteil(chunk: list) -> None:
liste = "\n".join(
f"PAAR {n}:\nA(id {a['id']}): {a['titel']}{a['definition']}\n"
f" Beleg A: {_erster_anker(a['id'])}\n"
f"B(id {b['id']}): {b['titel']}{b['definition']}\n"
f" Beleg B: {_erster_anker(b['id'])}"
for n, (a, b) in enumerate(chunk, 1))
stimmen = await llm.panel(ctx, MERGE_PANEL, stage="merge", template="Atom-Merge",
werte={"paare": liste}, role="judge",
n=len(chunk), item=f"c{chunk[0][0]['id']}", erwartet=list)
for n, (a, b) in enumerate(chunk, 1):
urteile = []
for stimme in stimmen:
for e in stimme:
if isinstance(e, dict) and e.get("paar") == n:
urteile.append(bool(e.get("gleich")))
break
if len(urteile) == MERGE_PANEL and all(urteile):
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
else (b["id"], a["id"]))
_merge(topic, gew, ver)
else:
lo, hi = min(a["id"], b["id"]), max(a["id"], b["id"])
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art, status)"
" 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))
def _erster_anker(atom_id: int) -> str:
row = db.one("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (atom_id,))
return (row["zitat"][:300] if row else "(kein Anker)")
def _kanten_aufloesen(topic: str) -> None:
"""braucht-Titel → Atom-IDs (Norm-Gleichheit; Unauflösbares fällt still weg)."""
atome = aktive_atome(topic)
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
for a in atome:
for titel in db.uj(a["braucht"]):
ziel = je_norm.get(textkit.norm(titel))
if ziel and ziel != a["id"]:
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
if not punkte:
return
atome = [a for a in aktive_atome(ctx.topic) if not (nur_offene and a["soll_id"])]
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
gueltig = {p["id"] for p in punkte}
async def chunk_zuordnen(chunk: list) -> None:
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in chunk)
res = await llm.call(ctx, stage="soll_zuordnung", template="Atom-Soll-Zuordnung",
werte={"soll": soll_liste, "atome": liste}, role="judge",
n=len(chunk), item=f"z{chunk[0]['id']}", erwartet=list)
je_atom = {e.get("atom"): e.get("soll") for e in res or [] if isinstance(e, dict)}
for a in chunk:
sid = je_atom.get(a["id"])
if sid in gueltig:
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))
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))
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
# ── QA + Repair ───────────────────────────────────────────────────────────────
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = aktive_atome(ctx.topic)
belegte_soll = set()
for a in atome:
if a["status"] == "ohne_anker" or not db.one(
"SELECT id FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (a["id"],)):
befunde.append({"art": "atom_ohne_anker", "item": str(a["id"]), "detail": a["titel"]})
if not a["soll_id"]:
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
else:
belegte_soll.add(a["soll_id"])
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
if p["id"] not in belegte_soll:
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
async def einer(b: dict) -> bool:
try:
item = int(b["item"])
except (TypeError, ValueError):
return False
if b["art"] == "atom_ohne_soll":
return await _soll_stichentscheid(ctx, item)
if b["art"] == "soll_ohne_atom":
return await _luecke_schliessen(ctx, item)
return False
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
# Lauf-Tokens); Rest PARALLEL (Semaphoren deckeln die echte Last).
anker_ids = [int(b["item"]) for b in befunde
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
rest = [b for b in befunde if b["art"] != "atom_ohne_anker"]
bewegt |= any(await llm.alle(einer(b) for b in rest))
if bewegt:
_anker_dedup(ctx.topic)
await _judge_dedup(ctx)
_kanten_aufloesen(ctx.topic)
await _soll_zuordnen(ctx)
return bewegt
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)
async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
"""Stufe 1 deterministisch (kostenlos): gespeicherte, unverankerte Zitate
(start=-1) mit der toleranten Suche gegen alle Quellen neu matchen.
Stufe 2 gebündelt: EIN Call je (Quelltext-Abschnitt × Atom-Chunk) statt
Atom×Quelle einzeln (war 83 % der Lauf-Tokens). Ohne Fund → verworfen —
ein Atom ohne Quellstelle verletzt das Kernprinzip."""
offen: dict[int, dict] = {}
for atom_id in atom_ids:
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
if a and a["status"] not in ("gemerged", "verworfen"):
offen[atom_id] = a
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
texte = {q["id"]: korpus.quelltext(q) for q in quellen}
for atom_id in list(offen):
for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)):
treffer = next(((qid, span) for qid, text in texte.items()
if (span := textkit.finde_zitat(text, roh["zitat"]))), None)
if treffer:
db.update("anker", "id", roh["id"], quelle_id=treffer[0],
start=treffer[1][0], ende=treffer[1][1])
db.update("atome", "id", atom_id, status="neu")
offen.pop(atom_id, None)
break
async def abschnitt_call(q: dict, offset: int, chunk_text: str, ids: list[int]) -> None:
liste = "\n".join(f"{i}: {offen[i]['titel']}{offen[i]['definition']}"
for i in ids if i in offen)
if not liste:
return
res = await llm.call(ctx, stage="anker_fix", template="Atom-Anker-Fix-Batch",
schritt="fix", role="judge", n=len(ids),
item=f"q{q['id']}-o{offset}",
werte={"atome": liste, "text": chunk_text}, erwartet=list)
for e in res or []:
atom_id = e.get("atom")
zitat = str(e.get("zitat", "")).strip()
if atom_id not in offen or not zitat:
continue
span = textkit.finde_zitat(texte[q["id"]], zitat)
if span:
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=span[0], ende=span[1], zitat=zitat)
db.update("atome", "id", atom_id, status="neu")
offen.pop(atom_id, None)
for q in quellen:
if not offen:
break
for offset, chunk_text in textkit.abschnitte(texte[q["id"]], 40_000):
ids = list(offen)
if not ids:
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:
db.update("atome", "id", atom_id, status="verworfen")
return bool(atom_ids)
async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool:
"""2er-Panel entscheidet: Punkt zuordnen oder fremd. Verwerfen (destruktiv) nur
einstimmig — im Zweifel behalten (Lektion 67), dann bekommt es den Mehrheits-Punkt."""
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
if not a or a["status"] in ("gemerged", "verworfen") or a["soll_id"]:
return False
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
stimmen = await llm.panel(ctx, 2, stage="soll_stich", template="Atom-Soll-Stich",
schritt="soll_zuordnung", role="judge", item=f"a{atom_id}",
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):
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)
return True
return False
async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
"""Gezielte Nachextraktion an der Belegstelle; nach LUECKEN_RUNDEN_MAX entscheidet
ein 2er-Panel, ob der Punkt durch bestehende Atome abgedeckt ist (Freispruch,
persistiert — Lektion 69)."""
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
"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"]
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):
db.update("soll", "id", soll_id, status="abgedeckt")
return True
return False
belege = db.uj(p["belege"])
if not belege:
return False
q = db.one("SELECT * FROM quellen WHERE id=?", (belege[0]["quelle"],))
if not q:
return False
text = korpus.quelltext(q)
pos = textkit.finde_zitat(text, belege[0]["zitat"])
fenster = text[max(0, (pos[0] if pos else 0) - 6000):(pos[1] if pos else 0) + 6000]
res = await llm.call(ctx, stage="luecke", template="Atom-Luecke",
role="quick", item=f"s{soll_id}",
werte={"punkt": p["punkt"], "quelle": q["titel"], "text": fenster},
erwartet=list)
neu = False
for a in res or []:
titel = str(a.get("titel", "")).strip()
definition = str(a.get("definition", "")).strip()
zitat = str(a.get("zitat", "")).strip()
typ = str(a.get("typ", "")).strip().lower()
span = textkit.finde_zitat(fenster, zitat)
if not titel or not definition or typ not in TYPEN or span is None:
continue
offset = text.find(fenster)
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
definition=definition, level="M", status="neu",
soll_id=soll_id, braucht=db.j([]))
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=offset + span[0], ende=offset + span[1], zitat=zitat)
neu = True
return neu

53
backend/jsonx.py Normal file
View File

@@ -0,0 +1,53 @@
"""Robustes JSON-Parsen von LLM-Antworten: Modelle liefern Fences, Prosa drumherum
oder Präfixe — wir suchen das erste vollständige JSON-Objekt/-Array."""
import json
import re
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
def parse(text: str):
"""→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry."""
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:
start = text.find(start_ch)
if start < 0:
continue
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if esc:
esc = False
continue
if c == "\\":
esc = in_str
continue
if c == '"':
in_str = not in_str
continue
if in_str:
continue
if c == start_ch:
depth += 1
elif c == end_ch:
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except ValueError:
break
try:
return json.loads(text)
except ValueError:
return None

378
backend/korpus.py Normal file
View File

@@ -0,0 +1,378 @@
"""Ebene 0: Korpus & Soll. Selbst-verifizierend, null Eingriffspunkte.
Themen: je Runde ein Recherche-Agent pro Lens (Curriculum/Lehrbuch/Syllabus/Übungen)
→ Quellen-Snapshots lokal. Uni: Dateien aus topics/<name>/ werden die Quellen.
Danach Soll-Extraktion je Quelle, Konsens deterministisch im Code (thema: Punkt
bestätigt ab Belegen aus ≥2 unabhängigen Quellen — Lektion 24; uni: 1 Beleg,
das Zitat-Gate reicht), Runden bis Sättigung."""
import asyncio
import hashlib
import logging
import re
import shutil
import subprocess
import unicodedata
from pathlib import Path
import db
import llm
import textkit
from config import (KORPUS_DIR, RECHERCHE_LENSES, RECHERCHE_RUNDEN_MAX,
SOLL_CHUNK_CHARS, SOLL_MIN_BELEGE, SOLL_PUNKTE_MIN,
SOLL_PUNKTE_PER_SQRT, TOPICS_DIR)
log = logging.getLogger("creator2.korpus")
EBENE = "korpus"
# Quellen-Rolle: aufgaben-Quellen liefern keine eigenen Konzept-Instanzen als Atome
# (Lektion aak: 76 % der Atome kamen aus Aufgabensammlungen — der Guide dozierte
# Aufgabentexte). Heuristik am Import; generische Muster, keine Domänen-Regeln.
_AUFGABEN_MUSTER = re.compile(
r"aufgab|klausur|serie|übung|uebung|präsenz|praesenz|blatt|exercise|exam|sheet|homework",
re.IGNORECASE)
def _rolle(titel: str, lens: str | None = None) -> str:
if lens == "uebungen":
return "aufgaben"
return "aufgaben" if _AUFGABEN_MUSTER.search(titel) else "stoff"
def quelltext(quelle: dict) -> str:
try:
return Path(quelle["snapshot"]).read_text(encoding="utf-8")
except OSError:
return ""
# C0/C1-Kontrollzeichen außer \n und \t: PDF-Schriften mappen Sonderglyphen (ε) auf
# Steuerbytes — der Reader echot sie als Müll („ž") und das Zitat wird unmatchbar.
# Ersetzung durch LEERZEICHEN muss VOR ftfy laufen: ftfy löscht Steuerzeichen ersatzlos
# und verklebt sonst die Nachbarwörter.
_KONTROLLZEICHEN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
def _text_reparieren(inhalt: str) -> str:
"""Byte-Schicht am Import heilen (Lektionen 82/83): Steuerzeichen→Leerzeichen,
dann ftfy (Mojibake, Ligaturen fi→fi, C1, NFC). Ohne ftfy → NFC-Fallback —
gleiches Muster wie der sentence-transformers-Fallback."""
inhalt = _KONTROLLZEICHEN.sub(" ", inhalt)
try:
import ftfy
return ftfy.fix_text(inhalt, normalization="NFC")
except ImportError:
return unicodedata.normalize("NFC", inhalt)
def _snapshot_schreiben(topic: str, inhalt: str) -> tuple[str, str] | None:
"""→ (pfad, hash) oder None bei Duplikat (gleicher Inhalt schon registriert).
Text-Reparatur am Import (Lektion 82/83): pdftotext liefert dekomponierte
Umlaute, Steuerbytes und Ligaturen — ohne Bereinigung scheitert die
Zitat-Suche an unsichtbar anderen Bytes."""
inhalt = _text_reparieren(inhalt)
h = hashlib.sha256(inhalt.encode()).hexdigest()[:16]
if db.one("SELECT id FROM quellen WHERE topic=? AND hash=?", (topic, h)):
return None
ordner = KORPUS_DIR / topic
ordner.mkdir(parents=True, exist_ok=True)
pfad = ordner / f"q-{h}.md"
pfad.write_text(inhalt, encoding="utf-8")
return str(pfad), h
def _datei_lesen(pfad: Path) -> str:
"""txt/md direkt; PDF via pdftotext (treu, strukturarm — Lektion 38)."""
if pfad.suffix.lower() == ".pdf":
if shutil.which("pdftotext") is None:
log.warning("pdftotext fehlt — %s übersprungen", pfad.name)
return ""
res = subprocess.run(["pdftotext", "-layout", str(pfad), "-"],
capture_output=True, text=True, timeout=120)
return res.stdout if res.returncode == 0 else ""
try:
return pfad.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
async def _uni_quellen(ctx: llm.Kontext) -> int:
"""Dateien aus topics/<name>/ als Quellen registrieren. → Anzahl neu."""
ordner = TOPICS_DIR / ctx.topic
neu = 0
for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []:
if pfad.suffix.lower() not in (".txt", ".md", ".pdf"):
continue
# .pdf überspringen, wenn eine gleichnamige .txt daneben liegt (vorkonvertiert)
if pfad.suffix.lower() == ".pdf" and pfad.with_suffix(".txt").exists():
continue
inhalt = _datei_lesen(pfad)
if not inhalt.strip():
continue
snap = _snapshot_schreiben(ctx.topic, inhalt)
if snap is None:
continue
db.insert("quellen", topic=ctx.topic, art="datei", titel=pfad.name,
snapshot=snap[0], hash=snap[1], status="neu", rolle=_rolle(pfad.name))
neu += 1
return neu
async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
"""Ein full-Agent je Lens. → Anzahl neuer Quellen."""
async def eine(lens: str) -> list[dict]:
res = await llm.call(ctx, stage="recherche", template="Korpus-Recherche",
werte={"topic": ctx.topic, "lens": lens},
role="quick", caps="full", item=f"r{runde}-{lens}",
erwartet=list)
return res or []
ergebnisse = await asyncio.gather(*(eine(lens) for lens in RECHERCHE_LENSES))
neu = 0
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
for q in quellen:
inhalt = str(q.get("inhalt", "")).strip()
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
continue
snap = _snapshot_schreiben(ctx.topic, inhalt)
if snap is None:
continue
titel = str(q.get("titel", ""))[:200]
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
url=str(q.get("url", ""))[:500], snapshot=snap[0], hash=snap[1],
runde=runde, status="neu", rolle=_rolle(titel, lens))
neu += 1
return neu
async def _soll_extrahieren(ctx: llm.Kontext) -> None:
"""Je neue Quelle Soll-Kandidaten ziehen (Quelltext INLINE — Lektion 48)."""
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,))
async def eine(q: dict) -> None:
text = quelltext(q)
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": ctx.topic, "quelle": q["titel"], "text": chunk},
role="judge", item=f"q{q['id']}-{i}", erwartet=list)
for kand in res or []:
punkt = str(kand.get("punkt", "")).strip()
zitat = str(kand.get("zitat", "")).strip()
if not punkt or textkit.finde_zitat(chunk, zitat) is None:
continue # Beleg muss wörtlich in der Quelle stehen
db.insert("soll", topic=ctx.topic, punkt=punkt, status="kandidat",
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))
def _min_belege(topic: str) -> int:
"""uni: Korpus ist vertrauenswürdig und das Zitat-Gate fängt Halluzinationen —
1 Beleg genügt (die ≥2-Regel warf Skript-only-Stoff weg, aak: Definition P,
FPTAS, Cook-Levin). thema: ≥2 unabhängige Quellen, weil eine einzelne
Web-Quelle selbst falsch sein kann."""
if db.one("SELECT art FROM topics WHERE name=?", (topic,))["art"] == "uni":
return 1
n = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (topic,))["n"]
return min(SOLL_MIN_BELEGE, max(1, n))
async def _konsens(ctx: llm.Kontext) -> int:
"""Kandidaten zu Punkten falten. Der Judge GRUPPIERT nur — die Zählung
(≥ min unabhängige Quellen) macht der Code. Untergrenze k = c·√(Kandidaten):
ohne sie faltete der Konsens 254 Atome auf 7 Punkte (aak) — zu grob als
Kapitel-Ebene. Nach oben offen — eine Obergrenze drückte den Judge dazu,
Kandidaten still wegzulassen (aak: LPT-Scheduling fehlte im Soll).
Vollständigkeits-Invariante: jede id wird zugeordnet oder explizit
abgelehnt; Übrige bekommen Nachrunden. → Anzahl bestätigt."""
kandidaten = db.query("SELECT * FROM soll WHERE topic=? AND"
" status IN ('kandidat','gefaltet')", (ctx.topic,))
if not kandidaten:
return 0
je_id = {k["id"]: k for k in kandidaten}
k_soll = max(SOLL_PUNKTE_MIN, round(SOLL_PUNKTE_PER_SQRT * len(kandidaten) ** 0.5))
band = str(max(3, int(0.7 * k_soll)))
min_belege = _min_belege(ctx.topic)
async def falten(kand: list[dict], hinweis: str, tag: str) -> list:
liste = "\n".join(f"{k['id']}: {k['punkt']}" for k in kand)
res = await llm.call(ctx, stage="soll_konsens", template="Korpus-Soll-Konsens",
werte={"topic": ctx.topic, "kandidaten": liste,
"band": band, "hinweis": hinweis},
role="judge", n=len(kand), item=tag, erwartet=list)
return res or []
punkte: list[dict] = [] # {"punkt": str, "ids": [int]}
je_punkt: dict[str, dict] = {}
zugeordnet: set[int] = set()
abgelehnt: set[int] = set()
def einsortieren(res: list) -> None:
for gruppe in res:
if not isinstance(gruppe, dict):
continue
ids = [i for i in gruppe.get("kandidaten", []) if isinstance(i, int)
and i in je_id and i not in zugeordnet and i not in abgelehnt]
if not ids:
continue
if gruppe.get("abgelehnt"):
abgelehnt.update(ids)
continue
zugeordnet.update(ids)
punkt = str(gruppe.get("punkt", "")).strip() or je_id[ids[0]]["punkt"]
kern = punkt.casefold()
if kern in je_punkt:
je_punkt[kern]["ids"].extend(ids)
else:
je_punkt[kern] = {"punkt": punkt, "ids": ids}
punkte.append(je_punkt[kern])
res = await falten(kandidaten, "", "k1")
gruppen = sum(1 for g in res if isinstance(g, dict) and not g.get("abgelehnt"))
if gruppen < 0.5 * k_soll and len(kandidaten) >= 2 * SOLL_PUNKTE_MIN:
nochmal = await falten(
kandidaten,
f"ACHTUNG: Der letzte Versuch hat zu grob gruppiert ({gruppen} Punkte)."
f" Trenne feiner — verschiedene Teilthemen NICHT zusammenfassen.", "k2")
if len(nochmal) > len(res):
res = nochmal
einsortieren(res)
for runde in (1, 2): # Vollständigkeit: fehlende ids gezielt nachfassen
fehlend = [k for i, k in je_id.items()
if i not in zugeordnet and i not in abgelehnt]
if not fehlend:
break
vorhanden = "\n".join(f"- {p['punkt']}" for p in punkte)
einsortieren(await falten(
fehlend,
"NACHRUNDE — diese Kandidaten sind noch keinem Punkt zugeordnet."
" Ordne JEDEN zu: an einen BESTEHENDEN Punkt (punkt wörtlich"
" wiederverwenden) oder als neue Gruppe. Ablehnen nur, wenn es kein"
" lernbares Teilthema ist.\nBESTEHENDE PUNKTE:\n" + vorhanden,
f"n{runde}"))
bestaetigt = []
for p in punkte:
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
for p, belege in bestaetigt:
db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt",
belege=db.j(belege))
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
"gefaltet" if i in ok_ids else "kandidat")
if status != k["status"]:
db.update("soll", "id", i, status=status)
return len(bestaetigt)
def _rollen_auffrischen(topic: str) -> None:
"""Rolle für Bestands-Quellen nachziehen (Reset behält die Zeilen; die Heuristik
kam ggf. erst später dazu). Nur Hochstufung stoff→aufgaben — eine Lens-basierte
aufgaben-Rolle wird nie zurückgestuft."""
for q in db.query("SELECT * FROM quellen WHERE topic=? AND rolle='stoff'", (topic,)):
if _rolle(q["titel"]) == "aufgaben":
db.update("quellen", "id", q["id"], rolle="aufgaben")
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
_rollen_auffrischen(ctx.topic)
topic = db.one("SELECT * FROM topics WHERE name=?", (ctx.topic,))
if topic["status"] not in ("neu", "korpus"):
return # Ebene fertig — Resume überspringt
db.update("topics", "name", ctx.topic, status="korpus")
if topic["art"] == "uni":
await _uni_quellen(ctx)
await _soll_extrahieren(ctx)
await _konsens(ctx)
else:
vorher = -1
for runde in range(1, RECHERCHE_RUNDEN_MAX + 1):
await _recherche_runde(ctx, runde)
await _soll_extrahieren(ctx)
jetzt = await _konsens(ctx)
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
# ── QA + Repair ───────────────────────────────────────────────────────────────
def messen(ctx: llm.Kontext) -> list[dict]:
"""Invarianten, deterministisch. → Befunde [{art, item, detail}]."""
befunde = []
n_quellen = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (ctx.topic,))["n"]
if n_quellen == 0:
befunde.append({"art": "korpus_leer", "item": "", "detail": "keine Quellen"})
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
if not punkte and n_quellen:
befunde.append({"art": "soll_leer", "item": "", "detail": "kein bestätigter Soll-Punkt"})
min_belege = _min_belege(ctx.topic)
for p in punkte:
quellen = {b["quelle"] for b in db.uj(p["belege"])}
if len(quellen) < min_belege:
befunde.append({"art": "soll_wenig_belege", "item": str(p["id"]),
"detail": f"{p['punkt']}: {len(quellen)} < {min_belege}"})
if punkte: # Konsens lief: übrige Kandidaten sind unerledigt, kein stiller Verlust
for k in db.query("SELECT * FROM soll WHERE topic=? AND status='kandidat'",
(ctx.topic,)):
befunde.append({"art": "soll_kandidat_offen", "item": str(k["id"]),
"detail": k["punkt"]})
return befunde
async def _beleg_nachsuchen(ctx: llm.Kontext, soll_id: int) -> bool:
"""Gezielter Beleg-Judge in noch nicht zitierten Quellen; ein bestätigter
Punkt ohne Fund wird zum Kandidaten zurückgestuft (Konsens-Regel bleibt
hart). → True nur, wenn sich etwas geändert hat."""
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
if not p:
return False
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
andere = [q for q in db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
if q["id"] not in belegte]
for q in andere:
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
schritt="soll", role="judge", item=f"s{p['id']}-q{q['id']}",
werte={"punkt": p["punkt"],
"text": quelltext(q)[:SOLL_CHUNK_CHARS]},
erwartet=dict)
zitat = str((res or {}).get("zitat", "")).strip()
if zitat and textkit.finde_zitat(quelltext(q), zitat) is not None:
belege = db.uj(p["belege"]) + [{"quelle": q["id"], "zitat": zitat}]
db.update("soll", "id", p["id"], belege=db.j(belege))
return True
if p["status"] == "bestaetigt":
db.update("soll", "id", p["id"], status="kandidat")
return True
return False
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
offene = [int(b["item"]) for b in befunde if b["art"] == "soll_kandidat_offen"]
bewegt = any(await llm.alle(_beleg_nachsuchen(ctx, s) for s in punkte + offene))
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
await _konsens(ctx)
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",
(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":
await _recherche_runde(ctx, runde=99)
await _soll_extrahieren(ctx)
await _konsens(ctx)
bewegt = True
return bewegt

61
backend/ledger.py Normal file
View File

@@ -0,0 +1,61 @@
"""Event-Ledger: jeder LLM-Call wird eine append-only-Zeile in `events`.
Kennzahlen sind Queries darauf; das Budget zählt live mit."""
import subprocess
import db
from config import PROJECT_ROOT
class BudgetErschoepft(Exception):
pass
def git_stand() -> tuple[str, bool]:
"""(hash, dirty) des Repos — fürs Lauf-Protokoll; Fehler → ('', False)."""
try:
h = subprocess.run(["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT,
capture_output=True, text=True, timeout=5).stdout.strip()
d = subprocess.run(["git", "status", "--porcelain"], cwd=PROJECT_ROOT,
capture_output=True, text=True, timeout=5).stdout.strip()
return h, bool(d)
except Exception:
return "", False
def log_call(run_id: int, *, ebene: str, stage: str, item: str = "", template: str = "",
template_hash: str = "", provider: str = "", model: str = "", role: str = "",
status: str, dur_ms: int = 0, wait_ms: int = 0, tokens: dict | None = None,
meta: dict | None = None) -> None:
t = tokens or {}
db.execute(
"INSERT INTO events(run_id, ebene, stage, item, template, template_hash, provider,"
" model, role, status, dur_ms, wait_ms, tok_in, tok_out, tok_cache_read, tok_cache_write, meta)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(run_id, ebene, stage, item, template, template_hash, provider, model, role, status,
dur_ms, wait_ms, int(t.get("input", 0)), int(t.get("output", 0)),
int(t.get("cache_read", 0)), int(t.get("cache_write", 0)), db.j(meta or {})))
def verbraucht(run_id: int) -> int:
row = db.one("SELECT COALESCE(SUM(tok_in+tok_out),0) AS s FROM events WHERE run_id=?", (run_id,))
return int(row["s"]) if row else 0
def budget_pruefen(run_id: int) -> None:
"""Nach jedem Call. Hartes Limit: wirft statt weiterzubrennen."""
run = db.one("SELECT budget_tokens FROM runs WHERE id=?", (run_id,))
limit = int(run["budget_tokens"]) if run else 0
if limit and verbraucht(run_id) >= limit:
raise BudgetErschoepft(f"Token-Budget erreicht ({limit})")
def kennzahlen(run_id: int) -> list[dict]:
return db.query(
"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"
" FROM events WHERE run_id=? GROUP BY ebene, stage, template ORDER BY MIN(id)",
(run_id,))

161
backend/llm.py Normal file
View File

@@ -0,0 +1,161 @@
"""Call-Schicht über agents.py: Template-Rendering, JSON-Parsen, Infra-Behandlung,
Hedge, Panels, Ledger, Budget.
Fehlerklassen bewusst getrennt (Lektion 1/2/68):
- Inhaltsfehler (ungültiges JSON, leere Antwort) → begrenzte Restarts, dann None.
- Infra-Fehler (429/Timeout/Netz) → eigener Zähler mit Backoff, erschöpft → LaufPause
(fail-closed: der Lauf pausiert fortsetzbar, statt mit Teilergebnis weiterzulaufen)."""
import asyncio
import logging
import time
import agents
import jsonx
import ledger
import prompts
from config import HEDGE_NACH_S, INFRA_BACKOFF_BASE, INFRA_MAX_RETRIES, timeout_fuer
log = logging.getLogger("creator2.llm")
MAX_RESTARTS = 2 # inhaltliche Neuversuche pro Call
class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
class Kontext:
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
def __init__(self, run_id: int, topic: str, provider: str):
self.run_id = run_id
self.topic = topic
self.provider = provider
self.ebene = ""
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", "connection",
"network", "overloaded", "unavailable", "database is locked")
def _ist_infra(err: str) -> bool:
e = err.lower()
return any(m in e for m in _INFRA_MARKER)
async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
role: str, caps: str) -> 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."""
schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0
async def einer(k: str):
return await agents.run_agent(k, prompt, timeout, provider=ctx.provider,
role=role, capabilities=caps)
haupt = asyncio.ensure_future(einer(key))
if not schwelle:
return await haupt
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
if fertig:
return haupt.result()
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
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
finally:
for t in (haupt, zwilling):
if not t.done():
t.cancel()
async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
role: str = "judge", caps: str = "none", schritt: str | None = None,
n: int = 0, item: str = "", erwartet=dict):
"""Ein LLM-Call → geparstes JSON (erwartet: dict|list) oder roher Text (erwartet=str).
None nur nach erschöpften inhaltlichen Restarts. Ledger + Budget immer."""
prompt, thash = prompts.render(template, **werte)
timeout = timeout_fuer(schritt or stage, n)
infra_rest = INFRA_MAX_RETRIES
inhalt_rest = MAX_RESTARTS
versuch = 0
while True:
versuch += 1
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
start = time.monotonic()
status, tokens, err, wait_ms = "error", None, "", 0
try:
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
tokens, err = res.tokens, res.err
wait_ms = int(res.wait_s * 1000)
if res.ok:
if erwartet is str:
status = "ok"
return res.text
daten = jsonx.parse(res.text)
if daten is not None and isinstance(daten, erwartet):
status = "ok"
return daten
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
elif _ist_infra(res.err):
status = "infra"
else:
status = "error"
err = res.err
except asyncio.TimeoutError:
status = "infra"
err = "timeout"
finally:
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
template=template, template_hash=thash, role=role,
provider=ctx.provider, status=status,
dur_ms=int((time.monotonic() - start) * 1000),
wait_ms=wait_ms,
tokens=tokens, meta={"err": err[:300]} if err else None)
ledger.budget_pruefen(ctx.run_id)
if status == "infra":
infra_rest -= 1
if infra_rest < 0:
raise LaufPause(f"{stage}: Infrastruktur erschöpft ({err})")
pause = INFRA_BACKOFF_BASE * 2 ** (INFRA_MAX_RETRIES - infra_rest - 1)
agents.drossel_melden(pause) # global: auch Timeouts/CLI-429 bremsen alle
log.warning("%s: Infra-Fehler (%s), Pause %.0fs", key, err[:80], pause)
await asyncio.sleep(pause)
continue
inhalt_rest -= 1
if inhalt_rest < 0:
log.warning("%s: inhaltlich erschöpft (%s)", key, err[:120])
return None
log.info("%s: Neuversuch (%s)", key, err[:80])
async def alle(coros) -> list:
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter."""
tasks = [asyncio.ensure_future(c) for c in coros]
try:
return await asyncio.gather(*tasks)
except BaseException:
for t in tasks:
if not t.done():
t.cancel()
raise
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)))
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"})
if ersatz is not None:
gueltig.append(ersatz)
return gueltig

259
backend/main.py Normal file
View File

@@ -0,0 +1,259 @@
"""FastAPI-App: REST + WebSocket-Live-Board + statisches Frontend.
Der State-Snapshot liefert IMMER alle Karten (kein 20er-Cap) — das Frontend
scrollt je Spalte; Deltas kommen über /ws."""
import asyncio
import logging
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import agents
import db
import guide
import ledger
import pipeline
from config import FRONTEND_DIST
from ws import hub
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
log = logging.getLogger("creator2.main")
app = FastAPI(title="creator2")
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"],
allow_methods=["*"], allow_headers=["*"])
@app.on_event("startup")
async def startup() -> None:
db.connect()
hub.bind_loop(asyncio.get_running_loop())
db.on_change = hub.push
class TopicNeu(BaseModel):
name: str
titel: str = ""
art: str = "thema" # thema | uni
provider: str = ""
class StartAuftrag(BaseModel):
budget: int | None = None
class UebenAntwort(BaseModel):
artefakt_id: int
richtig: bool
class AutoFlag(BaseModel):
ebene: str
an: bool
def _ebenen_info(t: dict, run: dict | None) -> list[dict]:
"""Je Ebene: fertig/laeuft/auto + Tokens (Läufe seit letztem Entfernen)
+ Dauer (letzter Lauf)."""
flags = db.uj(t["auto"], {})
resets = db.uj(t["resets"], {})
out = []
for name, _, marke in pipeline.EBENEN:
rows = db.query(
"SELECT SUM(e.tok_in+e.tok_out) AS tok,"
" (julianday(MAX(e.ts))-julianday(MIN(e.ts)))*86400 AS dauer"
" FROM events e JOIN runs r ON e.run_id=r.id"
" WHERE r.topic=? AND e.ebene=? AND e.id>? GROUP BY e.run_id ORDER BY e.run_id",
(t["name"], name, resets.get(name, 0)))
out.append({
"name": name, "fertig": pipeline._fertig_ab(t["status"], marke),
"laeuft": bool(run and run["status"] == "running" and run["ebene"] == name),
"auto": bool(flags.get(name, True)),
"tokens": sum(int(r["tok"] or 0) for r in rows),
"dauer_s": int(rows[-1]["dauer"] or 0) if rows else 0,
})
return out
@app.get("/api/topics")
def topics_liste():
out = []
for t in db.query("SELECT * FROM topics ORDER BY erstellt DESC"):
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (t["name"],))
out.append({**t, "run": run})
return out
@app.post("/api/topics")
def topic_anlegen(auftrag: TopicNeu):
if not auftrag.name.strip():
raise HTTPException(400, "Name fehlt")
if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)):
raise HTTPException(409, "Topic existiert")
if auftrag.art not in ("thema", "uni"):
raise HTTPException(400, "art muss thema|uni sein")
from config import DEFAULT_PROVIDER
db.insert("topics", name=auftrag.name, titel=auftrag.titel or auftrag.name,
art=auftrag.art, provider=auftrag.provider or DEFAULT_PROVIDER)
return {"ok": True}
@app.post("/api/topics/{topic}/start")
async def lauf_start(topic: str, auftrag: StartAuftrag):
# async: lauf_starten hängt den Lauf per ensure_future an DIESEN Event-Loop
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
try:
run_id = pipeline.lauf_starten(topic, auftrag.budget)
except RuntimeError as e:
raise HTTPException(409, str(e))
return {"run_id": run_id}
@app.post("/api/topics/{topic}/stop")
async def lauf_stop(topic: str):
pipeline.lauf_stoppen(topic)
return {"ok": True}
@app.get("/api/topics/{topic}/state")
def state(topic: str):
t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
if not t:
raise HTTPException(404, "unbekanntes Topic")
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,))
atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,))
fuer_atom = {}
for a in db.query(
"SELECT atom_id, SUM(status='verifiziert') AS ok, COUNT(*) AS n FROM artefakte"
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?) GROUP BY atom_id", (topic,)):
fuer_atom[a["atom_id"]] = {"verifiziert": a["ok"], "gesamt": a["n"]}
return {
"topic": t, "run": run,
"ebenen": _ebenen_info(t, run),
"verbraucht": ledger.verbraucht(run["id"]) if run else 0,
"quellen": db.query("SELECT id, art, titel, url, runde, status, rolle FROM quellen"
" WHERE topic=? ORDER BY id", (topic,)),
# Kandidaten nur zeigen, solange der Konsens noch nichts bestätigt hat
"soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" ORDER BY id", (topic,))
or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)),
"atome": [{**a, "artefakte": fuer_atom.get(a["id"], {"verifiziert": 0, "gesamt": 0})}
for a in atome],
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
"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,)),
"befunde": db.query(
"SELECT * FROM befunde WHERE run_id=? AND status='offen' ORDER BY id",
(run["id"],)) if run else [],
"agenten": agents.aktive_agenten(),
}
@app.get("/api/topics/{topic}/guide")
def guide_holen(topic: str):
# kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown
# verdoppelte nur die Payload (aak: +0,43 MB ungenutzt)
return {"kapitel": guide.kapitel_struktur(topic)}
@app.post("/api/topics/{topic}/soll-reset")
async def soll_reset(topic: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
pipeline.soll_reset(topic)
return {"ok": True}
@app.post("/api/topics/{topic}/voll-reset")
async def voll_reset(topic: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
pipeline.voll_reset(topic)
return {"ok": True}
@app.patch("/api/topics/{topic}/auto")
async def auto_setzen(topic: str, flag: AutoFlag):
t = db.one("SELECT auto FROM topics WHERE name=?", (topic,))
if not t:
raise HTTPException(404, "unbekanntes Topic")
flags = db.uj(t["auto"], {})
flags[flag.ebene] = flag.an
db.update("topics", "name", topic, auto=db.j(flags))
return {"ok": True}
@app.post("/api/topics/{topic}/ebene/{ebene}/entfernen")
async def ebene_entfernen(topic: str, ebene: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
try:
pipeline.ebenen_entfernen(topic, ebene)
except ValueError as e:
raise HTTPException(400, str(e))
return {"ok": True}
@app.delete("/api/topics/{topic}")
async def topic_loeschen(topic: str):
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
raise HTTPException(404, "unbekanntes Topic")
pipeline.topic_loeschen(topic)
return {"ok": True}
@app.get("/api/topics/{topic}/ueben")
def ueben_faellig(topic: str):
rows = db.query(
"SELECT ar.id, ar.inhalt, a.titel, a.level, COALESCE(l.box,1) AS box,"
" COALESCE(l.faellig, date('now')) AS faellig"
" FROM artefakte ar JOIN atome a ON ar.atom_id=a.id"
" LEFT JOIN leitner l ON l.artefakt_id=ar.id"
" WHERE a.topic=? AND ar.typ='flashcard' AND ar.status='verifiziert'"
" AND COALESCE(l.faellig, date('now')) <= date('now') ORDER BY box, ar.id", (topic,))
return [{**r, "inhalt": db.uj(r["inhalt"], {})} for r in rows]
_INTERVALLE = {1: 0, 2: 1, 3: 3, 4: 7, 5: 14} # Leitner: Tage bis zur Wiedervorlage
@app.post("/api/ueben/antwort")
def ueben_antwort(a: UebenAntwort):
row = db.one("SELECT * FROM leitner WHERE artefakt_id=?", (a.artefakt_id,))
box = min(5, (row["box"] if row else 1) + 1) if a.richtig else 1
tage = _INTERVALLE[box]
if row:
db.execute("UPDATE leitner SET box=?, faellig=date('now', ?) WHERE artefakt_id=?",
(box, f"+{tage} day", a.artefakt_id))
else:
db.execute("INSERT INTO leitner(artefakt_id, box, faellig) VALUES(?,?,date('now', ?))",
(a.artefakt_id, box, f"+{tage} day"))
return {"box": box}
@app.get("/api/runs/{run_id}/kennzahlen")
def kennzahlen(run_id: int):
return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)}
@app.websocket("/ws")
async def websocket(ws: WebSocket):
await hub.connect(ws)
try:
while True:
await ws.receive_text() # Client sendet nichts Relevantes; hält die Verbindung
except WebSocketDisconnect:
hub.disconnect(ws)
if FRONTEND_DIST.is_dir():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
@app.get("/")
def index():
return FileResponse(FRONTEND_DIST / "index.html")

198
backend/pipeline.py Normal file
View File

@@ -0,0 +1,198 @@
"""Orchestrierung: fünf Ebenen strikt seriell, je Ebene bauen → QA → Repair-Loop
bis 100 %/Stillstand/Limit. Resume ist der Normalfall: der Fortschritt liegt
komplett in der DB (topics.status + Item-Status), ein neuer Start überspringt
Fertiges. Infra-Erschöpfung pausiert den Lauf (fail-closed), Budget stoppt hart."""
import asyncio
import logging
import agents
import artefakte
import auto_loop
import db
import guide
import inventar
import korpus
import ledger
import llm
import qa
import struktur
from config import RUN_BUDGET_TOKENS
log = logging.getLogger("creator2.pipeline")
EBENEN = [
("korpus", korpus, "korpus_fertig"),
("inventar", inventar, "inventar_fertig"),
("artefakte", artefakte, "artefakte_fertig"),
("struktur", struktur, "struktur_fertig"),
("guide", guide, "fertig"),
]
_ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_fertig",
"artefakte_fertig", "struktur_fertig", "fertig"]
_laeufe: dict[str, asyncio.Task] = {}
def _fertig_ab(status: str, marke: str) -> bool:
try:
return _ORDNUNG.index(status) >= _ORDNUNG.index(marke)
except ValueError:
return False
def lauf_starten(topic: str, budget: int | None = None) -> int:
if topic in _laeufe and not _laeufe[topic].done():
raise RuntimeError("Lauf läuft bereits")
agents.abbruch_aufheben(f"{topic}-")
git_hash, dirty = ledger.git_stand()
run_id = db.insert("runs", topic=topic, status="running", git_hash=git_hash,
git_dirty=int(dirty),
budget_tokens=budget if budget is not None else RUN_BUDGET_TOKENS)
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id))
return run_id
def lauf_stoppen(topic: str) -> None:
agents.abbrechen(f"{topic}-")
task = _laeufe.get(topic)
if task and not task.done():
task.cancel()
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")
_ROLLBACK = {"guide": "struktur_fertig", "struktur": "artefakte_fertig",
"artefakte": "inventar_fertig", "inventar": "korpus_fertig", "korpus": "neu"}
def ebenen_entfernen(topic: str, ebene: str) -> None:
"""Ergebnisse einer Ebene UND aller nachgelagerten verwerfen (rechts hängt an
links), Topic-Status entsprechend zurückrollen. Quellen-Snapshots bleiben,
außer bei korpus."""
if ebene not in _ROLLBACK:
raise ValueError(f"unbekannte Ebene: {ebene}")
lauf_stoppen(topic)
stufe = ["guide", "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
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
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
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
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)
# 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]:
resets[name] = letzte
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))
def topic_loeschen(topic: str) -> None:
lauf_stoppen(topic)
ebenen_entfernen(topic, "korpus")
for run in db.query("SELECT id FROM runs WHERE topic=?", (topic,)):
db.execute("DELETE FROM events WHERE run_id=?", (run["id"],))
db.execute("DELETE FROM befunde WHERE run_id=?", (run["id"],))
db.execute("DELETE FROM runs WHERE topic=?", (topic,))
db.execute("DELETE FROM topics WHERE name=?", (topic,))
def voll_reset(topic: str) -> None:
"""Alles Generierte verwerfen (auch Atome + Artefakte); Quellen-Snapshots und
Lauf-Historie bleiben. Für Neuextraktion nach Extraktions-Regeländerungen."""
lauf_stoppen(topic)
soll_reset(topic)
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,))
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.update("topics", "name", topic, status="neu")
def soll_reset(topic: str) -> None:
"""Soll/Struktur/Guide verwerfen, Atome + Artefakte behalten. Der nächste Lauf
baut das Soll neu; der Extraktions-Guard in inventar verhindert Doppel-Atome."""
lauf_stoppen(topic)
db.execute("DELETE FROM sections WHERE baustein_id IN"
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
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("DELETE FROM soll WHERE topic=?", (topic,))
db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0"
" WHERE topic=?", (topic,))
db.execute("UPDATE quellen SET status='neu' WHERE topic=?", (topic,))
db.update("topics", "name", topic, status="korpus")
async def _lauf(topic: str, run_id: int) -> None:
t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
ctx = llm.Kontext(run_id, topic, t["provider"])
try:
for name, modul, marke in EBENEN:
status = db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"]
if _fertig_ab(status, marke):
continue
db.update("runs", "id", run_id, ebene=name)
await modul.bauen(ctx)
erst_note, _ = await qa.messen(ctx, name)
async def reparieren(n=name, m=modul):
offene = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=?"
" AND status='offen'", (run_id, n))
bewegt = await m.reparieren(ctx, offene)
neue_note, _ = await qa.messen(ctx, n)
return neue_note, bewegt
note, grund = await auto_loop.auto_repair_loop(name, erst_note, reparieren)
log.info("%s/%s: Note %.1f (%s)", topic, name, note, grund)
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",
grund=f"Auto-Stopp nach {name}")
return
db.update("runs", "id", run_id, status="done", beendet="jetzt")
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")
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")
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")
log.exception("%s: Lauf fehlgeschlagen", topic)

29
backend/prompts.py Normal file
View File

@@ -0,0 +1,29 @@
"""Template-Lader: templates/<Name>.md, Platzhalter {name}, Datei-Hash je Call
(Ledger — uncommittete Prompt-Änderungen bleiben nachvollziehbar)."""
import hashlib
from functools import lru_cache
from config import TEMPLATES_DIR
@lru_cache(maxsize=None)
def _lade(name: str) -> tuple[str, str]:
text = (TEMPLATES_DIR / f"{name}.md").read_text(encoding="utf-8")
return text, hashlib.sha256(text.encode()).hexdigest()[:12]
def render(name: str, **werte) -> tuple[str, str]:
"""→ (prompt, template_hash). format_map, damit geschweifte Klammern in
JSON-Beispielen doppelt geschrieben werden ({{ }})."""
text, h = _lade(name)
return text.format_map(_Sicher(werte)), h
class _Sicher(dict):
def __missing__(self, key):
raise KeyError(f"Template-Platzhalter fehlt: {key}")
def cache_leeren() -> None:
_lade.cache_clear()

87
backend/qa.py Normal file
View File

@@ -0,0 +1,87 @@
"""QA-Rahmen. Zwei Sorten Messung, strikt getrennt (Kernprinzip):
- Invarianten: deterministisch, je Ebene in deren messen() — können echt 100 % erreichen.
- Soll-QA: Abgleich gegen das eingefrorene Soll (steckt in den Invarianten von E1:
soll_ohne_atom / atom_ohne_soll — Judges vergleichen, raten nie).
Die Note ist eine deterministische Formel aus gemessenen Quoten (Lektion 70),
nie eine LLM-Schätzung. 10,0 gibt es nur bei null offenen Befunden."""
import logging
import artefakte
import db
import guide
import inventar
import korpus
import llm
import struktur
log = logging.getLogger("creator2.qa")
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
"struktur": struktur, "guide": guide}
# Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
GEWICHTE = {
"korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5,
"soll_kandidat_offen": 3.0,
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
"section_fehlt": 3.0, "marker_fehlend": 3.0, "ziel_ohne_anker": 3.0,
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
}
def _basis(topic: str, ebene: str) -> int:
"""Bezugsgröße der Quoten je Ebene (geprüfte Items)."""
z = {"korpus": "SELECT COUNT(*) n FROM soll WHERE topic=? AND status='bestaetigt'",
"inventar": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"artefakte": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
" AND status NOT IN ('gemerged','verworfen')",
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
row = db.one(z, (topic,))
return max(1, int(row["n"]) if row else 1)
def note(befunde: list[dict], basis: int) -> float:
if not befunde:
return 10.0
je_art: dict[str, int] = {}
for b in befunde:
je_art[b["art"]] = je_art.get(b["art"], 0) + 1
abzug = sum(GEWICHTE.get(art, 1.5) * min(1.0, n / basis) for art, n in je_art.items())
return round(min(9.9, max(0.0, 10.0 - abzug)), 1)
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}
alt_keys = set()
for alt in alte:
key = (alt["art"], alt["item"])
if key in neu_keys:
alt_keys.add(key)
else:
db.update("befunde", "id", alt["id"], status="repariert")
for b in befunde:
if (b["art"], str(b["item"])) 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")
async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]:
modul = MODULE[ebene]
befunde = modul.messen(ctx)
if ebene == "guide" and mit_llm:
befunde = befunde + await guide.messen_llm(ctx)
_persistieren(ctx, ebene, befunde)
n = note(befunde, _basis(ctx.topic, ebene))
log.info("QA %s/%s: Note %.1f, %d Befunde", ctx.topic, ebene, n, len(befunde))
return n, befunde

424
backend/struktur.py Normal file
View File

@@ -0,0 +1,424 @@
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR:
Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge =
topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der
schwächsten Kante (deterministischer Fallback)."""
import asyncio
import logging
from collections import defaultdict
import db
import llm
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, KAPITEL_BAUSTEINE,
ZIELE_CHUNK_ATOME)
log = logging.getLogger("creator2.struktur")
EBENE = "struktur"
def _atome(topic: str) -> list[dict]:
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen') ORDER BY id", (topic,))
def _braucht_kanten(topic: str) -> list[dict]:
return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
# ── Lernziele ─────────────────────────────────────────────────────────────────
async def _ziele_bilden(ctx: llm.Kontext) -> None:
atome = [a for a in _atome(ctx.topic) if not a["ziel_id"]]
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[a["soll_id"]].append(a)
async def eine_gruppe(soll_id, gruppe: list[dict]) -> None:
punkt = db.one("SELECT punkt FROM soll WHERE id=?", (soll_id,)) or {"punkt": ctx.topic}
rest = gruppe
for runde in ("1", "2"): # Schlussrunde MUSS alles entscheiden (Lektion 23)
if not rest:
return
chunk = rest[:ZIELE_CHUNK_ATOME]
liste = "\n".join(f"{a['id']}: {a['titel']}{a['definition']}" for a in chunk)
res = await llm.call(ctx, stage="ziele", template="Lernziele",
werte={"punkt": punkt["punkt"], "atome": liste,
"pflicht": "Jedes Atom MUSS genau einem Ziel"
" zugeordnet sein." if runde == "2" else ""},
role="judge", n=len(chunk),
item=f"s{soll_id}-r{runde}", erwartet=list)
gueltig = {a["id"] for a in chunk}
zugeordnet: set[int] = set()
for z in res or []:
ids = [i for i in z.get("atome", []) if isinstance(i, int)
and i in gueltig and i not in zugeordnet]
text = str(z.get("ziel", "")).strip()
if not ids or not text:
continue
ziel_id = db.insert("lernziele", topic=ctx.topic, text=text,
titel=str(z.get("titel", "")).strip()[:80],
soll_id=soll_id, status="aktiv")
for i in ids:
db.update("atome", "id", i, ziel_id=ziel_id)
zugeordnet.update(ids)
rest = [a for a in gruppe if a["id"] not in zugeordnet
and not db.one("SELECT ziel_id FROM atome WHERE id=?",
(a["id"],))["ziel_id"]]
if rest: # nach der Schlussrunde: deterministisches Sammelziel statt Lücke
ziel_id = db.insert("lernziele", topic=ctx.topic,
text=f"Grundlagen: {punkt['punkt']}",
soll_id=soll_id, status="aktiv")
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()))
# ── Zyklen brechen ────────────────────────────────────────────────────────────
def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None:
"""Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben)."""
nach = defaultdict(list)
for v, z in kanten:
nach[v].append(z)
farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz
eltern: dict[int, int] = {}
for start in knoten:
if farbe[start]:
continue
stapel = [(start, iter(nach[start]))]
farbe[start] = 1
while stapel:
v, it = stapel[-1]
fortschritt = False
for z in it:
if z not in farbe:
continue
if farbe[z] == 0:
farbe[z] = 1
eltern[z] = v
stapel.append((z, iter(nach[z])))
fortschritt = True
break
if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren
pfad = [(v, z)]
k = v
while k != z:
pfad.append((eltern[k], k))
k = eltern[k]
return pfad
if not fortschritt:
farbe[v] = 2
stapel.pop()
return None
async def _zyklen_brechen(ctx: llm.Kontext) -> None:
while True:
atome = _atome(ctx.topic)
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
zyklus = _finde_zyklus(ids, kanten)
if not zyklus:
return
je_id = {a["id"]: a for a in atome}
liste = "\n".join(f"{v}{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}"
for v, z in zyklus)
res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus",
werte={"kanten": liste}, role="judge",
item=f"z{zyklus[0][0]}", erwartet=dict)
wahl = None
if res:
paar = (res.get("von"), res.get("zu"))
if paar in zyklus:
wahl = paar
if wahl is None: # deterministischer Fallback: kleinste Kante
wahl = min(zyklus)
db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?"
" AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1]))
log.info("Zyklus gebrochen: %s%s", wahl[0], wahl[1])
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
def _topo(knoten: list[int], kanten: list[tuple[int, int]],
rang: dict[int, tuple]) -> list[int]:
"""Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil).
Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen
sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder
(gemessen aak: 22 Atome ohne Baustein)."""
kn = set(knoten)
vorher = defaultdict(set)
nachher = defaultdict(set)
for v, z in kanten:
if v in kn and z in kn:
vorher[v].add(z)
nachher[z].add(v)
offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k])
out = []
erledigt: set[int] = set()
while offen:
k = offen.pop(0)
out.append(k)
erledigt.add(k)
neu = []
for n in nachher[k]:
if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu:
neu.append(n)
offen = sorted(offen + neu, key=lambda kk: rang[kk])
out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k])
return out
def _anker_rang(topic: str) -> dict[int, tuple]:
"""Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers."""
out = {}
for a in _atome(topic):
row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0"
" ORDER BY quelle_id, start LIMIT 1", (a["id"],))
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
return out
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
Ziel-Gruppen ins Band bringen — kleine Ziele desselben Soll-Punkts mergen
(das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel), große entlang der
Topo-Reihenfolge splitten ("Teil n")."""
topic = ctx.topic
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
atome = _atome(topic)
rang = _anker_rang(topic)
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)]
gruppen: dict = defaultdict(list)
for a in atome:
gruppen[a["ziel_id"]].append(a)
# kleine Ziele mergen (kleinste zuerst). Partnerwahl thematisch statt per
# Soll-Punkt — das Soll ist seit der Entkopplung reine Checkliste (152 feine
# Punkte hätten sonst keine Merge-Partner): meiste braucht-Kanten zwischen
# den Gruppen, sonst Nachbar in der Quellreihenfolge.
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
ids1 = {a["id"] for a in g1}
ids2 = {a["id"] for a in g2}
return sum(1 for v, z in kanten
if (v in ids1 and z in ids2) or (v in ids2 and z in ids1))
def gruppen_rang(g: list[dict]) -> tuple:
return min(rang[a["id"]] for a in g)
geaendert = True
while geaendert:
geaendert = False
kleine = sorted([z for z, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
key=lambda z: len(gruppen[z]))
for z in kleine:
passende = [p for p in gruppen if p != z
and len(gruppen[p]) + len(gruppen[z]) <= BAUSTEIN_MAX_ATOME]
if not passende:
continue
eigener = gruppen_rang(gruppen[z])
def naehe(pp: int) -> tuple:
r = gruppen_rang(gruppen[pp])
return (-kanten_score(gruppen[z], gruppen[pp]),
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
len(gruppen[pp]))
p = min(passende, key=naehe)
for a in gruppen[z]:
db.update("atome", "id", a["id"], ziel_id=p)
gruppen[p] += gruppen.pop(z)
db.update("lernziele", "id", z, status="gemerged")
geaendert = True
break
# Bausteine anlegen (große Ziele splitten), Atome zuordnen
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
for ziel_id, gruppe in gruppen.items():
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
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)
for teil in range(n_teile):
teil_ids = ordnung[teil * groesse:(teil + 1) * groesse]
if not teil_ids:
continue
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel,
status="neu")
for pos, atom_id in enumerate(teil_ids):
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
_bausteine_ordnen(topic, kanten, rang)
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
"""Baustein-DAG aus aggregierten Atom-Kanten; Zyklen deterministisch an der
schwächsten Aggregat-Kante gebrochen (wenigste Atom-Kanten)."""
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
je_atom = {a["id"]: a["baustein_id"] for a in _atome(topic)}
gewicht: dict = defaultdict(int)
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
bv, bz = je_atom.get(v), je_atom.get(z)
if bv and bz and bv != bz:
gewicht[(bv, bz)] += 1
b_kanten = set(gewicht)
b_ids = [b["id"] for b in bausteine]
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
atome = _atome(topic)
b_rang = {}
for b in bausteine:
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
if a["baustein_id"] == b["id"]), default=(9, 9))
while True:
zyklus = _finde_zyklus(b_ids, list(b_kanten))
if not zyklus:
break
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
b_kanten.discard(schwach)
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
# Ordnung vollständig rückwärts auflösen).
bv, bz = schwach
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
" AND status='aktiv'"
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
(topic, bv, bz))
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
db.update("bausteine", "id", b_id, ord=pos)
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge. Das Soll
ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der Judge
liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er unvollständig
(aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). Der Code
erzwingt Reihenfolge + Lückenlosigkeit; ein ungültiges Ergebnis bekommt
EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback). Idempotent."""
topic = ctx.topic
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
if not bausteine:
return
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
liste = "\n".join(f"{b['id']}: {b['titel']}{ziele.get(b['ziel_id'], {}).get('text', '')}"
for b in bausteine)
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
folge = [b["id"] for b in bausteine]
pos_von = {bid: i for i, bid in enumerate(folge)}
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
schritt="kapitel", role="judge", n=len(bausteine),
item=tag, erwartet=list,
werte={"bausteine": liste, "richtwert": str(richtwert),
"hinweis": hinweis})
segmente: list[tuple[str, list[int]]] = []
start = 0
for gruppe in res or []:
if not isinstance(gruppe, dict):
return None
titel = str(gruppe.get("titel", "")).strip()
ende = pos_von.get(gruppe.get("bis"))
if not titel or ende is None or ende < start:
return None
segmente.append((titel, folge[start:ende + 1]))
start = ende + 1
return segmente if segmente and start == len(folge) else None
segmente = await schneiden("", "kapitel")
if segmente is None:
segmente = await schneiden(
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
" \"titel\" und \"bis\" (eine id aus der Liste, streng aufsteigend);"
" das letzte \"bis\" MUSS die letzte id der Liste sein.", "kapitel-2")
art = "judge"
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
art = "fallback"
je_id = {b["id"]: b for b in bausteine}
n = max(3, round(len(folge) ** 0.5))
groesse = -(-len(folge) // n)
segmente = [(je_id[teil[0]]["titel"], teil)
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
for pos, (titel, ids) in enumerate(segmente):
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, art=art)
for b_id in ids:
db.update("bausteine", "id", b_id, kapitel_id=k_id)
async def bauen(ctx: llm.Kontext) -> None:
ctx.ebene = EBENE
await _ziele_bilden(ctx)
await _zyklen_brechen(ctx)
_bausteine_schneiden(ctx)
await _kapitel_bilden(ctx)
def messen(ctx: llm.Kontext) -> list[dict]:
befunde = []
atome = _atome(ctx.topic)
for a in atome:
if not a["ziel_id"] or not a["baustein_id"]:
befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]})
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,))
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).
for b in bausteine:
n = len(je_baustein[b["id"]])
partner = any(bb["id"] != b["id"]
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
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"})
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
kap_ids = {k["id"] for k in kaps}
for b in bausteine:
if b["kapitel_id"] not in kap_ids:
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
"detail": b["titel"]})
belegt = {b["kapitel_id"] for b in bausteine}
for k in kaps:
if k["id"] not in belegt:
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
"detail": k["titel"]})
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
"detail": k["titel"]})
ord_von = {b["id"]: b["ord"] for b in bausteine}
for k in _braucht_kanten(ctx.topic):
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None)
if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0):
befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]),
"detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"})
ids = [a["id"] for a in atome]
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
if k["von_atom"] in ids and k["zu_atom"] in ids]
if _finde_zyklus(ids, kanten):
befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"})
return befunde
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
ctx.ebene = EBENE
if not befunde:
return False
await _ziele_bilden(ctx) # fängt Partition-Lücken
await _zyklen_brechen(ctx) # fängt Zyklen
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band + Vorwärts-Kanten
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
return True

155
backend/textkit.py Normal file
View File

@@ -0,0 +1,155 @@
"""Deterministische Text-Helfer: Normalisierung, Verbatim-Zitatsuche mit Offset-
Mapping, Abschnitts-Split. Lektionen 27 (Casefold+NFKC), 31 (Negations-Guard),
43 (Dash-Toleranz), 47 (Abschnitte)."""
import re
import unicodedata
from config import ABSCHNITT_CHARS, FUZZY_FEHLERQUOTE, FUZZY_MIN_ZEICHEN
_WS = re.compile(r"\s+")
_WORT = re.compile(r"[a-zäöüß0-9]+")
def norm(text: str) -> str:
"""NFKC + casefold + Whitespace-Faltung — für jeden Ähnlichkeits-/Gleichheitsvergleich."""
return _WS.sub(" ", unicodedata.normalize("NFKC", text).casefold()).strip()
def tokens(text: str) -> set[str]:
return set(_WORT.findall(norm(text)))
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne", "un"}
def negations_menge(text: str) -> frozenset[str]:
"""Antonyme messen 0.910.95 Cosinus — gleiche Negationsmenge ist harte
Merge-Vorbedingung."""
return frozenset(t for t in tokens(text) if t in _NEGATION or t.startswith("nicht"))
def _normiert_mit_map(text: str) -> tuple[str, list[int]]:
"""Whitespace-gefaltete Kopie + Map: Position in Kopie → Position im Original."""
out: list[str] = []
mapping: list[int] = []
in_ws = False
for i, c in enumerate(text):
if c.isspace():
if not in_ws and out:
out.append(" ")
mapping.append(i)
in_ws = True
else:
out.append(c)
mapping.append(i)
in_ws = False
return "".join(out), mapping
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
Original-Positionen. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren
Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt
gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute
(„a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen
komponiert NFKC nie. Also BEIDE Seiten auf den Basisbuchstaben falten (ä→a);
ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
out: list[str] = []
mapping: list[int] = []
for i, c in enumerate(text):
for cn in unicodedata.normalize("NFKD", c).casefold():
if cn.isalnum() and not unicodedata.combining(cn):
out.append(cn)
mapping.append(i)
return "".join(out), mapping
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
"""Zitat im Quelltext finden. Vier Stufen (Hypothes.is-Muster, Lektion 83):
1. exakt (nur Whitespace-tolerant), 2. + casefold (PDF-Small-Caps),
3. alnum-gefaltet (Interpunktion/Dashes/Quotes egal, Wortlaut identisch),
4. fuzzy auf der Faltung (fuzzysearch, harte Fehlerquote + Eindeutigkeits-Guard) —
fängt Reader-„Verschönerungen": weggelassene Listing-Zeilennummern, rekonstruierte
Glyphen, geglättete Formeln. → (start, ende) im Original oder None.
Paraphrasen bleiben draußen (Distanzschranke, keine Wort-Umstellungen)."""
if not zitat.strip():
return 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 start, ende
hay_l, map_l = _locker_mit_map(text)
ndl_l, _ = _locker_mit_map(zitat)
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
return None
pos = hay_l.find(ndl_l)
if pos < 0:
return _fuzzy_span(hay_l, map_l, ndl_l)
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | None:
"""Stufe 4: Levenshtein-Substring auf der alnum-Faltung. Nur ab FUZZY_MIN_ZEICHEN,
Distanz ≤ FUZZY_FEHLERQUOTE·Länge. Eindeutigkeits-Guard (Falsch-Anker >> fehlender
Anker): gibt es einen NICHT überlappenden Zweittreffer fast gleicher Distanz → None
(Realfall: SubSetSum- und Partition-Definition teilen den Satzanfang)."""
if len(ndl_l) < FUZZY_MIN_ZEICHEN:
return None
try:
from fuzzysearch import find_near_matches
except ImportError:
return None
max_dist = max(2, int(len(ndl_l) * FUZZY_FEHLERQUOTE))
treffer = find_near_matches(ndl_l, hay_l, max_l_dist=max_dist)
if not treffer:
return None
best = min(treffer, key=lambda m: (m.dist, m.start))
schranke = best.dist + max(3, int(len(ndl_l) * 0.05))
for m in treffer:
if (m.end <= best.start or m.start >= best.end) and m.dist <= schranke:
return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen
return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1
def titel_kern(titel: str) -> str:
"""Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über
Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als
deterministischer Dubletten-Kandidaten-Generator neben dem Embedding."""
kern, _ = _locker_mit_map(titel)
return kern
def abschnitte(text: str, max_chars: int = ABSCHNITT_CHARS) -> list[tuple[int, str]]:
"""Text an Absatzgrenzen in ≤max_chars-Abschnitte splitten (lost in the middle).
→ [(offset, abschnitt)]."""
if len(text) <= max_chars:
return [(0, text)]
out: list[tuple[int, str]] = []
start = 0
while start < len(text):
ende = min(start + max_chars, len(text))
if ende < len(text):
brk = text.rfind("\n\n", start, ende)
if brk <= start:
brk = text.rfind("\n", start, ende)
if brk > start:
ende = brk
out.append((start, text[start:ende]))
start = ende
return out
def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float:
"""Span-Überlappung relativ zum kürzeren Span [0..1]."""
schnitt = max(0, min(a[1], b[1]) - max(a[0], b[0]))
kuerzer = min(a[1] - a[0], b[1] - b[0])
return schnitt / kuerzer if kuerzer > 0 else 0.0

41
backend/ws.py Normal file
View File

@@ -0,0 +1,41 @@
"""Live-Board-Hub: Statuswechsel aus der DB-Schicht → alle WebSocket-Clients.
Kein Polling; das Frontend hält den Snapshot und wendet Deltas an."""
import asyncio
import json
import logging
log = logging.getLogger("creator2.ws")
class Hub:
def __init__(self):
self._clients: set = set()
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
async def connect(self, websocket) -> None:
await websocket.accept()
self._clients.add(websocket)
def disconnect(self, websocket) -> None:
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:
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)))
async def _send_all(self, msg: str) -> None:
for ws in list(self._clients):
try:
await ws.send_text(msg)
except Exception:
self._clients.discard(ws)
hub = Hub()