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

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Pflicht: welcher Provider-Stack standardmäßig läuft (claude | minimax | lokal)
DEFAULT_PROVIDER=minimax
# MiniMax (opencode + direkte Text-API)
MINIMAX_API_KEY=
# Rollen-Routing über Stacks hinweg (optional): z.B. jeden Judge-Call zu Claude
# ROLE_JUDGE=claude
# ROLE_GUIDE=claude
# Nebenläufigkeit (optional)
# MAX_CONCURRENT_AGENTS=16
# MAX_CONCURRENT_API_AGENTS=28
# Token-Budget pro Lauf (Summe in+out; 0 = kein Limit)
# RUN_BUDGET_TOKENS=5000000
# Fake-Modus: alle LLM-Calls deterministisch (Tests/Frontend-Smoke)
# CREATOR_FAKE_AGENTS=1

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.env
storage/
topics/
frontend/node_modules/
frontend/dist/
.pytest_cache/

90
DESIGN.md Normal file
View File

@@ -0,0 +1,90 @@
# Design creator2
Beschlossen am 2026-07-09 (Diskussion Nutzer + Claude). Ersetzt `../creator`.
## Warum Neubau
Der Vorgänger gruppierte nach Entitäts-Ähnlichkeit (Clustering → Naming → Umbrella → Dedup
→ Degradier-Pass). Der Guide las sich als Aneinanderreihung von Bausteinen. Viele Fixes
waren Symptomreparatur an dieser Kerneinheit. creator2 wechselt das Kriterium: gruppiert
wird nach „lernt sich gut zusammen" (Backward Design), nicht nach „ist dieselbe Entität".
## Pipeline
```
E0 Korpus Themen: N Recherche-Sweeps (Curricula/Lehrbuch/Syllabus/Übungen) → Snapshots
Uni: Dateien aus topics/<name>/ (txt/md/pdf) → Snapshots
Soll-Extraktion je Quelle → Konsens (Punkt braucht ≥2 unabhängige Quellen)
Themen: Runden bis Sättigung (Runde ohne neuen Soll-Punkt) → Auto-Freeze
E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit Verbatim-Anker
Dedup: Anker-Overlap (auto) → Embedding-Kandidaten → 2er-Panel (einstimmig)
Soll-Zuordnung je Atom; Soll-Punkt ohne Atom → gezielte Nachextraktion
E2 Artefakte je Soll-Punkt-Gruppe: Flashcards+Beispiel pro Atom → Verify-Panel gegen Anker → Fix
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen
→ Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
→ topologische Ordnung (Bausteine + Atome darin)
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker) → det. Checks → Prüfer → Fix
Assembly in Baustein-Ordnung
```
Jede Ebene endet mit `qa.messen(ebene)`; `auto_loop` fährt QA→Repair bis Note 10,0,
Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene.
## Datenmodell (SQLite, backend/db.py)
- `topics(name, titel, art thema|uni, status, provider)`
- `runs(id, topic, status running|paused|done|failed|budget, gestartet, beendet, git_hash, git_dirty, budget_tokens, grund)`
- `events(run_id, ts, 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)` — Ledger, append-only
- `quellen(id, topic, art web|datei, titel, url, snapshot, hash, runde, status)`
- `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])`
- `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)`
- `anker(atom_id, quelle_id, start, ende, zitat)`
- `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)`
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
- `lernziele(id, topic, text, soll_id, status)`
- `bausteine(id, topic, ziel_id, titel, ord, status)`
- `sections(baustein_id, stage, text_kompakt, text_lang, befunde JSON)`
- `leitner(artefakt_id, box, faellig, historie)`
- `befunde(run_id, ebene, art, item, detail, status offen|repariert|freigesprochen)`
Alles status-basiert → Resume = Lauf neu starten, fertige Items werden übersprungen.
## QA (backend/qa.py)
**Invarianten (deterministisch, gated):**
- E0: jeder bestätigte Soll-Punkt hat ≥2 Belege aus verschiedenen Quellen; Korpus nicht leer
- E1: jedes Atom hat Anker (Zitat verbatim in Quelle gefunden), Definition, Typ, genau einen
Soll-Punkt; jeder Soll-Punkt ≥1 Atom
- E2: jedes Atom ≥1 verifizierte Flashcard; Beispiel-Status entschieden
- E3: Partition (jedes Atom genau ein Baustein via Ziel), Bandgröße, DAG azyklisch,
keine Kante von spätem zu frühem Baustein
- E4: jeder Atom-Marker vorhanden, Ziel-Anker im Text, Längenband, keine Vorwärtsverweise
(Titel eines späteren Atoms fällt nicht früher)
**LLM-Stichproben (nur wo nötig, immer 2 unabhängige Bestätiger):** fachlich-falsch im Guide.
Note = 10 · (1 gewichtete Befundquote); 10,0 nur bei null offenen Befunden.
## Nebenläufigkeit
Einfache Wellen (`asyncio.gather` + Semaphoren in agents.py), keine Streaming-Engine.
Panels = n parallele Calls + Konsensregel. Hedge: Slot ohne Ergebnis nach
max(90 s, timeout/2) bekommt einen Zwilling, erstes valides Ergebnis gewinnt.
Live-Board trotzdem: jeder Statuswechsel → WebSocket-Broadcast.
## Provider
Wie Vorgänger: `claude`-CLI, `opencode` (MiniMax), MiniMax-Text-API direkt (tool-lose Calls).
Rollen quick|judge|guide|fast je Provider-Stack, Routing via env (`ROLE_JUDGE=claude`).
`CREATOR_FAKE_AGENTS=1` → fake_agents.py.
## Frontend (Vue 3 + Vite)
Tabs: **Board** (Spalten = Ebenen, ALLE Karten, scrollbar, live via WS) · **Guide**
(marked+KaTeX+highlight, Level-Filter) · **Üben** (Leitner-Boxen) · **Kennzahlen**
(Tokens/Zeit je Ebene/Stage/Template aus dem Ledger).
## Bewusst gestrichen
Delta-Läufe (Material vollständig), Abnahme-Benchmark, Projekt-/Link-Quellen,
Chat/Prüfungs-Features, ELO/Stufen-Ansichten (Level-Feld existiert, Ansichten später).

115
LEKTIONEN.md Normal file
View File

@@ -0,0 +1,115 @@
# Bezahlte Lektionen aus creator (Vorgänger)
Extrahiert 2026-07-09 aus dem alten Code. Format: **Regel** — Grund (Beleg alt: Datei:Zeile).
Diese Regeln sind Anforderungen an creator2, kein übernommener Code.
## Infra-Fehler
1. **Infra-Fehler (429/Timeout/Netz) getrennt von Inhaltsfehlern zählen** — sonst frisst ein 429 die inhaltlichen Restarts und der Lauf läuft fail-open weiter (pipeline.py:80).
2. **Erschöpfte Infra-Retries ⇒ Lauf-PAUSE (fortsetzbar), nie Teilergebnis** — Quorum verhungerte sonst still (pipeline.py:304).
3. **Retries + Backoff zentral: 3×, 8/16/32 s** (config.py:180).
4. **Stall-Hedge: genau EIN Zwilling nach Schwelle, erster valider gewinnt** — Stalls verbrannten 160230 s Timeout (pipeline.py:225).
5. **Hedge-Schwelle = max(90 s, timeout/2)** — pauschale 90 s hedgten gesunde lange Calls (pipeline.py:196).
6. **Judge-Timeouts eng (p50 672 s), Retry heilt in Sekunden** (config.py:201).
7. **Prozessgruppen-Kill (killpg + start_new_session)** — CLI-Kinder hielten sonst Pipes offen (agents.py:287).
8. **CLI-Starts staffeln (Token-Bucket)** — OpenCode-Session-DB: "database is locked" (agents.py:154).
9. **RAM-Gate vor CLI-Spawns (~310 MB RSS je Prozess)** (agents.py:195).
10. **Concurrency-Slot VOR dem Spawn erwerben** — Queue-Wartezeit zählt nicht gegen den Timeout (agents.py:76).
11. **Agent-Slots nach Pipeline-Stufe priorisieren** (früh vor spät) — nur nötig bei Streaming; Wellen-Design umgeht es.
12. **.env-Datei gewinnt über geerbtes Env** — --reload-Master pinnte stale Werte stundenlang (config.py:13).
13. **Provider-Routen pro Rolle empirisch wählen** — kalt-Endpoint stallte 20 % der Judges (config.py:244).
14. **Timeouts = Basis + pro-Item·n**, nie fix (config.py:195).
## Judge-Rauschen
15. **Hochgewichtete LLM-Urteile: Verdacht + 2 unabhängige Bestätiger** — Einzel-Judge pendelte 0↔5 Befunde (guide_qa.py:138).
16. **Instabile Klassifikations-Judges: Zweitpass auf die Verdachtsmenge** (qa.py:516).
17. **Mehrheit bei Recall-kritischen Entscheidungen** — Einstimmigkeit verwarf reale Einzelfunde (board_inventory.py:580).
18. **Einstimmigkeit bei irreversiblen Merges** (board_inventory.py:1195).
19. **Detektor-vs-Judge-Dissens: dritter Stichentscheid (2/3), Freispruch persistieren** (repair.py:82).
20. **Panel-Ausfall: Ersatz-Richter vor fail-open** (block_calls.py:358).
21. **Panels: 2 = Konsens-Minimum, 3 nur wo Rauschen teuer** (config.py:162).
22. **Quorum-Rennen mit Grace-Fenster** — Sofort-Kill warf fast fertige Stimmen weg (pipeline.py:174).
23. **Klärungs-Loops hart deckeln, Schlussrunde muss alles entscheiden** (config.py:128).
24. **Billige Konsensregeln (≥2 Nennungen) deterministisch im Code, nicht per LLM** (board_inventory.py:546).
## Fehlmerge-Schutz
25. **Relationen (A≤B) über beide Operanden + Richtung individuieren, Konflikte vetoen** — "SAT≤Clique" vs "3-SAT≤Clique" hat hohen Cosinus (blocks.py:1150).
26. **Deterministische Guards nur so streng wie beweisbar; Bedeutung dem belegten Judge** (blocks.py:1157).
27. **Casefold + NFKC vor jedem Ähnlichkeitsvergleich** — "VERTEX COVER" fiel unter die Schwelle (board_inventory.py:1133).
28. **Acronym↔Expansion explizit als Kandidaten einspeisen** — Cosinus ~0.53, nie Kandidat (blocks.py:1093).
29. **Auto-Merge nur über ordnungsunabhängigen Canonical-Key; leerer Key ⇒ nie** (blocks.py:1106).
30. **Embedding-Backstop vetot strukturlose Fehl-Demotes** (config.py:99).
31. **Negations-Guard: Antonyme messen 0.910.95 Cosinus** — gleiche Negationsmenge als harte Merge-Vorbedingung (blocks.py:681).
32. **Frei generierte Titel gegen Korpus verankern** — Drift zu Lehrbuch-Kanon (board_inventory.py:121).
33. **Quorum schützt nicht vor Ko-Halluzination** — Titel ohne Korpus-Anker separat belegen (board_inventory.py:576).
34. **Kandidatenblöcke in Größe cappen** — sonst Riesenkomponente, instabile Judge-Listen (config.py:56).
35. **Containment-Zuordnung nur bei genau einem signifikanten Ganzwort-Treffer** (blocks.py:1250).
36. **Token/Stem-Match statt Substring** — "bergang" ⊂ "Übergang" (qa.py:243).
37. **Benannte Aussagen (Reduktionen, Sätze) strukturell vor Demotion schützen** (blocks.py:1322).
## PDF/Text
38. **Nie blind einem PDF-Extraktor vertrauen** — pymupdf4llm droppt Formeln still; pdftotext ist treu aber strukturarm. v1 nutzt pdftotext (Treue > Struktur); zweiter Extraktor + Verlust-Guard bei Bedarf (blocks.py:349).
39. **PDF-Spacing-Artefakte entzerren ("H ITTING S ET"), mit Frequenzbeleg** (blocks.py:373).
40. **Logik-Versions-Marker für abgeleitete Artefakte** — mtime-Cache überlebte Algorithmus-Änderungen (blocks.py:385).
41. **Optionale Abhängigkeiten (OCR) prüfen, still degradieren** (blocks.py:308).
42. **Titel-Splits klammerbewusst** (textkit.py:54).
43. **Unicode-Dash-Varianten tolerant normalisieren, ASCII-Hyphen schützen** (textkit.py:115).
44. **Marker-Vergleiche gegen Escaping härten (Backslashes strippen)** (guide_qa.py:36).
45. **Metadaten-Header vor Coverage-Detektion strippen** — Phantom-Lücken (qa.py:104).
46. **Regex-Fänge auf Umbruch-Fortsetzung prüfen** (qa.py:210).
47. **Große Quelltexte in ~12k-Abschnitte splitten** — lost in the middle (blocks.py:437).
## Token-Fresser
48. **Evidenz INLINE in Judge-Prompts, nie Judges selbst suchen lassen** — 82 % der Lauf-Tokens waren Cache-Reads aus Judge-Tool-Loops (config.py:143). WICHTIGSTE EINZELLEKTION.
49. **Strukturierten Output als Antworttext, robust parsen; Tool-Writes nur Fallback** — 40 von 64 Turns in einem Write/Validate-Loop (blocks.py:599).
50. **Tool-lose Calls über die direkte API statt CLI-Prozess** (agents.py:242).
51. **MCP-Server nur für full-Agenten** — ~3×300 MB pro Prozess (agents.py:529).
52. **Zusammengehörige Schritte in EINEM Call bündeln** (guide_board.py:7).
53. **Token-Verbrauch auch bei Fehlern/Timeouts erfassen** (agents.py:487).
54. **Rückkopplungslisten (Lücken/Fixes) über Schnittmenge filtern und hart cappen** — 107 "Lücken" auf 216 Subs (blocks.py:786).
55. **Chunk-/Split-Größen zentral und tunebar** (config.py:151).
## Prompt-Design
56. **Faktenbasis vorgeben, VERBATIM-Zitate verlangen, jeden Claim gaten** (Guide-Writer-Board.md:10).
57. **Maschinen-Marker als exakte Struktur-Invariante, unsichtbar, nie Überschrift** (Guide-Writer-Board.md:14).
58. **Längenbudget als HARTE Obergrenze formulieren, nicht als Ziel** (Guide-Writer-Board.md:27).
59. **Ausgabekanal + Format ausschließlich vorschreiben: "NUR JSON, keine Fences, keine Datei"** (Guide-Outline.md:21).
60. **Fixierte Eingabemengen einfrieren: "Copy VERBATIM, do NOT add/remove/split/merge/rename"** (Subblock-Anreichern.md:12).
61. **Aggregat-Titel: nur Eigenschaften, die auf ALLE Mitglieder zutreffen** (Blocks-Gruppierung.md:14).
62. **Namen nur aus Begriffen der Quelle, nie Lehrbuch-Oberbegriff** (Blocks-Naming.md:8).
63. **Katalognummern ("Satz 7.13") sind keine Titel** (Blocks-Sanierung.md:11).
64. **Quellgebundene Schritte: NUR die gelieferten Auszüge, kein Web, keine PDFs** (Blocks-Source-Inline.md:1).
65. **Bewertungs-Prompts: unbeantwortbare Fragen erkennen, Korrektheit in anderen Worten zählt** (Block-Rating-Critique.md:22).
66. **Reservierte Parser-Zeichen dem Modell verbieten** (Blocks-Gruppierung.md:16).
## Übergreifende Guards
67. **Destruktive Entscheidungen: im Zweifel behalten (fail-open)** (repair.py:8).
68. **Infra-Erschöpfung: fail-closed (Pause)** — die Wahl pro Fehlerklasse bewusst treffen (pipeline.py:330).
69. **Freisprüche persistieren** — sonst pendelt die Note ewig unter 10 (qa.py:437).
70. **Note deterministisch aus gemessenen Quoten, nie vom LLM schätzen lassen** (qa.py:294).
71. **Qualitäts-Gate vor teuren Downstream-Phasen** (config.py:132).
72. **QA nutzt EIGENE Detektoren/Schwellen, nie die der Pipeline** — geteilte blinde Flecken (qa.py:1).
73. **Fix-Schwellen an Sektionsgröße koppeln; Kritisches immer fixen** (config.py:170).
74. **Schema-Parser defensiv gegen Judge-Overreport** — 65-Einträge-Vollinventar (guide_board.py:66).
75. **Detektor-Messlatte und Fix-Auftrag teilen dieselbe Formel** — sonst unfixbare Befunde (guide_qa.py:75).
76. **Zwei Retry-Ebenen: Race-Restart vs. Item-Backoff/Dead-Letter, sichtbar + requeue-bar** (kanban.py:135).
77. **Auto-Loop: Ziel erreicht / echter Stillstand / hartes Limit — transiente Verschlechterung zulassen** (auto_loop.py:31).
78. **Guard-Strenge an Fehlerkosten ausrichten** — reversible Fehl-Merges brauchen keine harten Vetos (board_inventory.py:1427).
79. **Nach parallelen Judges ein Reconcile-Pass gegen Duplikat-Parents** (config.py:90).
80. **Zielgrößen sublinear (√n) statt statischer Bänder** (config.py:94).
81. **Fakten-Checks: exakt zitierte Quellstellen einblenden, kein Keyword-Pack** (blocks.py:549).
82. **Unicode am Import normalisieren (NFC + Kontrollzeichen→Leerzeichen), Matching diakritik-fest (NFKD, Kombinationszeichen droppen)** — pdftotext liefert dekomponierte Umlaute und Steuerbytes für Sonderglyphen (ε→\x0f), LLM-Zitate präkomponierte Zeichen bzw. Müll-Echos; per-Zeichen-NFKC komponiert nie → 59/96 aak-Atome „ohne Anker" trotz wörtlicher Zitate (creator2: korpus.py:_snapshot_schreiben, textkit.py:_locker_mit_map).
83. **Text-Reparatur und Zitat-Anchoring nicht selbst erfinden: ftfy am Import, fuzzysearch als letzte Matching-Stufe (Hypothes.is-Muster exakt→locker→fuzzy)** — Reader „verschönern" Zitate (Listing-Zeilennummern weg, verlorene Glyphen rekonstruiert); Fuzzy NUR mit Mindestlänge, harter Fehlerquote und Eindeutigkeits-Guard gegen Doppelgänger-Passagen (SubSetSum vs. Partition teilen den Satzanfang); Steuerzeichen VOR ftfy zu Leerzeichen (ftfy löscht sie und verklebt Wörter) (creator2: korpus.py:_text_reparieren, textkit.py:_fuzzy_span).
## Meta
- Jede Guard/Schwelle trägt ihren gemessenen Beleg im Kommentar.
- Tunebare Parameter und QA-Messlatte strikt trennen (Messlatte nie im Suchraum).
- Deterministisch vor LLM: Konsens, Kandidaten, Guards im Code; der Judge ist Präzisions-Gate für Zweifelsfälle.
- Provider-Ausfall darf nie den Lauf reißen — Stacks unabhängig.

27
Makefile Normal file
View File

@@ -0,0 +1,27 @@
.PHONY: install dev stop test test-e2e build
install:
pip install --break-system-packages fastapi "uvicorn[standard]" httpx pytest pytest-asyncio ftfy fuzzysearch
cd frontend && npm install
@echo "Optional: pip install sentence-transformers (Embedding-Kandidaten; ohne läuft lexikalischer Fallback)."
@echo "Optional: poppler-utils für PDF-Quellen (pdftotext)."
dev:
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && uvicorn main:app --reload --port 8000 &
@cd frontend && npx vite --port 5173
stop:
-@pkill -f "uvicorn main:app" 2>/dev/null
-@pkill -f "vite --port 5173" 2>/dev/null
@echo "gestoppt."
test:
cd backend && python -m pytest ../tests -x -q
test-e2e:
cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q
build:
cd frontend && npm run build

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()

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>creator2</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1211
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "creator2-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"vue": "^3.5.32"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"vite": "^8.0.8"
}
}

185
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,185 @@
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { api, wsVerbinden } from './api.js'
import Board from './components/Board.vue'
import Guide from './components/Guide.vue'
import Ueben from './components/Ueben.vue'
import Kennzahlen from './components/Kennzahlen.vue'
const topics = ref([])
const topic = ref('')
const state = ref(null)
const tab = ref('board')
const neuName = ref('')
const neuArt = ref('thema')
const fehler = ref('')
const theme = ref(localStorage.getItem('theme') || 'auto')
const navAuf = ref(localStorage.getItem('navAuf') !== '0')
function navToggle() {
navAuf.value = !navAuf.value
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
}
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
let wsSchliessen = null
let timer = null
const media = window.matchMedia('(prefers-color-scheme: light)')
function themeAnwenden() {
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
document.documentElement.dataset.theme =
theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden)
async function topicsLaden() {
topics.value = await api.topics()
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
}
async function stateLaden() {
if (!topic.value) { state.value = null; return }
try {
state.value = await api.state(topic.value)
} catch (e) { fehler.value = String(e) }
}
function onWs() {
clearTimeout(timer)
timer = setTimeout(stateLaden, 400)
}
async function anlegen() {
if (!neuName.value.trim()) return
try {
await api.topicAnlegen({ name: neuName.value.trim(), art: neuArt.value })
const name = neuName.value.trim()
neuName.value = ''
await topicsLaden()
topic.value = name
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
const menue = ref(null) // Kontextmenü: { x, y, name }
function menueOeffnen(e, name) {
menue.value = { x: e.clientX, y: e.clientY, name }
}
async function menueLoeschen() {
const name = menue.value?.name
menue.value = null
if (!name) return
try {
await api.topicLoeschen(name)
if (topic.value === name) topic.value = ''
await topicsLaden()
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
async function starten() {
try { fehler.value = ''; await api.start(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) }
}
async function stoppen() {
await api.stop(topic.value)
await stateLaden()
}
async function waehlen(name) {
topic.value = name
await stateLaden()
}
let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kommen
onMounted(async () => {
themeAnwenden()
await topicsLaden()
await stateLaden()
wsSchliessen = wsVerbinden(onWs)
poll = setInterval(() => {
if (state.value?.run?.status === 'running') stateLaden()
}, 5000)
})
onUnmounted(() => {
wsSchliessen && wsSchliessen()
clearInterval(poll)
media.removeEventListener('change', themeAnwenden)
})
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
function runBadge(run) {
if (!run) return null
const farbe = { running: 'blau', done: 'gruen', paused: 'gelb', budget: 'gelb',
failed: 'rot', stopped: 'rot' }[run.status] || ''
return { farbe, text: run.status + (run.ebene && run.status === 'running' ? ` · ${run.ebene}` : '') }
}
</script>
<template>
<aside class="sidebar">
<div class="topics">
<div v-for="t in topics" :key="t.name" class="topic-eintrag"
:class="{ aktiv: t.name === topic }" @click="waehlen(t.name)"
@contextmenu.prevent="menueOeffnen($event, t.name)">
<span>{{ t.titel }}</span>
<span v-if="t.run?.status === 'running'" class="badge blau">läuft</span>
<span v-else-if="t.status === 'fertig'" class="badge gruen"></span>
</div>
</div>
<div class="neu">
<input v-model="neuName" placeholder="neues Thema…" @keyup.enter="anlegen" />
<div style="display: flex; gap: 6px">
<select v-model="neuArt" style="flex: 1">
<option value="thema">thema</option><option value="uni">uni</option>
</select>
<button class="sekundaer" @click="anlegen">Anlegen</button>
</div>
</div>
</aside>
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
@click="menue = null" @contextmenu.prevent="menue = null">
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
<button class="rot" @click.stop="menueLoeschen">Thema {{ menue.name }}" löschen</button>
</div>
</div>
<div class="hauptbereich">
<button class="nav-toggle" :title="navAuf ? 'Navigation einklappen' : 'Navigation anzeigen'"
@click="navToggle"></button>
<div class="topbar">
<div class="tabs">
<button v-for="t in ['board', 'guide', 'üben', 'kennzahlen']" :key="t"
:class="{ aktiv: tab === t }" @click="tab = t">{{ t }}</button>
</div>
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
<span v-if="fehler" class="badge rot">{{ fehler }}</span>
<span style="flex: 1"></span>
<select v-model="theme" title="Farbschema">
<option value="hell">hell</option>
<option value="dunkel">dunkel</option>
<option value="papier">papier</option>
<option value="auto">auto</option>
</select>
</div>
<div class="inhalt">
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
<Kennzahlen v-else :run="state?.run" />
</div>
</div>
</template>

44
frontend/src/api.js Normal file
View File

@@ -0,0 +1,44 @@
const BASE = import.meta.env.DEV ? 'http://localhost:8000' : ''
async function req(pfad, opts = {}) {
const res = await fetch(BASE + pfad, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
return res.json()
}
export const api = {
topics: () => req('/api/topics'),
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
start: (topic, budget) =>
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
setAuto: (topic, ebene, an) =>
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
ebeneEntfernen: (topic, ebene) =>
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
state: (topic) => req(`/api/topics/${topic}/state`),
guide: (topic) => req(`/api/topics/${topic}/guide`),
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
antwort: (artefakt_id, richtig) =>
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
}
export function wsVerbinden(onEvent) {
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `ws://${location.host}`) + '/ws'
let ws
const verbinden = () => {
ws = new WebSocket(url)
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
ws.onclose = () => setTimeout(verbinden, 2000)
}
verbinden()
return () => ws && ws.close()
}

View File

@@ -0,0 +1,177 @@
<script setup>
// Ebenen-Kanban: 5 Spalten = Pipeline links→rechts, jede Spalte selbst steuerbar.
// Befunde + laufende Agenten als Leiste ÜBER dem Board.
import { computed, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ state: Object, topic: String })
const emit = defineEmits(['reload'])
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
const jetzt = ref(Date.now())
const empfangen = ref(Date.now())
const ebenenStart = ref({}) // Ebene → lokaler Start-Anker (epoch ms), monoton
watch(() => props.state, () => {
empfangen.value = Date.now()
const neu = {}
for (const e of props.state?.ebenen || []) {
if (!e.laeuft) continue
// Server-Dauer ist eine Untergrenze (Event-zu-Event); frühester Anker gewinnt,
// damit die Anzeige beim nächsten State-Load nicht auf 0 zurückspringt
const ausServer = Date.now() - (e.dauer_s || 0) * 1000
const bisher = ebenenStart.value[e.name]
neu[e.name] = bisher ? Math.min(bisher, ausServer) : ausServer
}
ebenenStart.value = neu
})
const ticker = setInterval(() => { jetzt.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(ticker))
const extraSek = computed(() => Math.max(0, (jetzt.value - empfangen.value) / 1000))
function ebenenDauer(key) {
const i = info(key)
if (i.laeuft && ebenenStart.value[key])
return Math.max(i.dauer_s || 0, Math.round((jetzt.value - ebenenStart.value[key]) / 1000))
return i.dauer_s || 0
}
const EBENEN = [
{ key: 'korpus', titel: 'Quellen' },
{ key: 'inventar', titel: 'Atome' },
{ key: 'artefakte', titel: 'Artefakte' },
{ key: 'struktur', titel: 'Bausteine' },
{ key: 'guide', titel: 'Guide' },
]
const statusFarbe = (s) => ({
bestaetigt: 'gruen', verifiziert: 'gruen', fertig: 'gruen', done: 'gruen', aktiv: 'gruen',
extrahiert: 'blau', atome: 'blau', kandidat: 'gelb', abgedeckt: 'gelb',
repair: 'gelb', ohne_anker: 'rot', verworfen: 'rot',
}[s] || '')
const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
const aktiveAtome = computed(() =>
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
function karten(key) {
const s = props.state
if (!s) return []
if (key === 'korpus') {
return [
...s.quellen.map((q) => ({ id: `q${q.id}`, text: q.titel,
badges: [[q.status, statusFarbe(q.status)], [q.rolle, q.rolle === 'aufgaben' ? 'gelb' : 'blau']] })),
...s.soll.map((p) => ({ id: `s${p.id}`, text: p.punkt,
badges: [[p.status, statusFarbe(p.status)]] })),
]
}
if (key === 'inventar') {
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
badges: [[a.status, statusFarbe(a.status)], [`${a.typ} · ${a.level}`, '']] }))
}
if (key === 'artefakte') {
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
badges: [[`${a.artefakte.verifiziert}/${a.artefakte.gesamt} verifiziert`,
a.artefakte.verifiziert > 0 ? 'gruen' : '']] }))
}
if (key === 'struktur') {
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.status, statusFarbe(b.status)]] }))
}
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
}
function generierenErlaubt(idx) {
if (!props.state || props.state.run?.status === 'running') return false
const e = info(EBENEN[idx].key)
if (e.fertig) return false
return EBENEN.slice(0, idx).every((v) => info(v.key).fertig)
}
const menue = ref(null) // Spalten-Kontextmenü: { x, y, key, titel, idx }
function menueOeffnen(ev, e, idx) {
menue.value = { x: ev.clientX, y: ev.clientY, key: e.key, titel: e.titel, idx }
}
async function menueGenerieren() {
const m = menue.value
menue.value = null
if (!m || !generierenErlaubt(m.idx)) return
await api.start(props.topic)
emit('reload')
}
async function menueEntfernen() {
const m = menue.value
menue.value = null
if (!m) return
await api.ebeneEntfernen(props.topic, m.key)
emit('reload')
}
async function autoSetzen(key, ev) {
await api.setAuto(props.topic, key, ev.target.checked)
emit('reload')
}
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`)
const mmss = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : n)
</script>
<template>
<template v-if="state">
<div v-if="state.agenten.length" class="leiste">
<strong style="font-size: 12px; color: var(--dim); white-space: nowrap">
Agenten ({{ state.agenten.length }})
</strong>
<span v-for="a in state.agenten" :key="a.key" class="badge blau"
:title="a.key">{{ mmss(a.laufzeit + extraSek) }}</span>
</div>
<div class="board">
<div v-for="(e, idx) in EBENEN" :key="e.key" class="spalte"
@contextmenu.prevent="menueOeffnen($event, e, idx)">
<div class="spaltekopf">
<div class="zeile">
<span class="titel">{{ e.titel }}
<span class="badge">{{ karten(e.key).length }}</span></span>
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
</span>
</div>
<div class="zeile">
<span class="badge">{{ k(info(e.key).tokens || 0) }} Tok</span>
<span class="badge">{{ dauer(ebenenDauer(e.key)) }}</span>
<label><input type="checkbox" :checked="info(e.key).auto"
@change="autoSetzen(e.key, $event)" /> Auto</label>
</div>
</div>
<div class="liste">
<div v-for="kt in karten(e.key)" :key="kt.id" class="karte">
{{ kt.text }}
<div class="meta">
<span v-for="([txt, farbe], i) in kt.badges" :key="i"
class="badge" :class="farbe">{{ txt }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
@click="menue = null" @contextmenu.prevent="menue = null">
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
Generieren
</button>
<button class="rot" @click.stop="menueEntfernen">
{{ menue.titel }}" + folgende Ebenen entfernen
</button>
</div>
</div>
</template>
<div v-else class="seite">Kein Thema gewählt links anlegen.</div>
</template>

View File

@@ -0,0 +1,159 @@
<script setup>
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { api } from '../api.js'
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
const props = defineProps({ topic: String, state: Object })
const daten = ref(null)
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
const LEVEL_NAME = { E: 'Umfang: nur Einstieg', M: 'Umfang: bis Mittel', S: 'Umfang: alles' }
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
onUnmounted(() => document.body.classList.remove('vollbild'))
const aktiv = ref(0)
const kapEls = []
async function laden() {
if (!props.topic) return
daten.value = await api.guide(props.topic)
}
watch(() => props.topic, laden, { immediate: true })
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
// Markdown+KaTeX sind teuer (66 Sections, ~3.400 Formeln): einmal rendern,
// dann aus dem Cache — sonst rechnet jeder Scroll-Tick alles neu (~600 ms).
const htmlCache = new Map()
watch(daten, () => htmlCache.clear())
function html(s) {
const key = `${ansicht.value}|${level.value}|${s.baustein}`
if (!htmlCache.has(key)) {
htmlCache.set(key, ansicht.value === 'kompakt'
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
}
return htmlCache.get(key)
}
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
function hoehe(i) {
const st = stats.value.je[i]
const sections = daten.value?.kapitel?.[i]?.sections?.length || 1
return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180)
}
function setAnsicht(a) {
ansicht.value = a
laden()
}
function levelToggle() {
const folge = ['E', 'M', 'S']
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
laden()
}
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
const stats = computed(() => {
const je = (daten.value?.kapitel || []).map((kap) => {
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
let formeln = 0
for (const s of kap.sections) {
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
: gefiltert(s.lang, level.value))
woerter += st.woerter
formeln += st.displayFormeln
}
return { woerter, minuten: woerter / 150 + (formeln * 5) / 60 }
})
return {
je,
woerter: je.reduce((a, k) => a + k.woerter, 0),
minuten: je.reduce((a, k) => a + k.minuten, 0),
}
})
function zeit(min) {
const m = Math.max(1, Math.round(min))
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
}
function setKapEl(i, el) {
if (el) kapEls[i] = el
}
async function springe(i) {
aktiv.value = i // erst rendern (Platzhalter → Inhalt), dann springen
await nextTick()
kapEls[i]?.scrollIntoView({ block: 'start' })
}
function onScroll(e) {
const top = e.target.scrollTop + 130
let a = 0
kapEls.forEach((el, i) => { if (el && el.offsetTop <= top) a = i })
aktiv.value = a
}
</script>
<template>
<div class="guide-layout" :class="{ fokus }">
<nav v-if="daten?.kapitel?.length" class="guide-nav">
<div class="schalter-zeile">
<div class="schalter">
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
@click="setAnsicht('erklaerend')">
<svg width="14" height="12" viewBox="0 0 14 12">
<path d="M0 1h14M0 3.5h14M0 6h14M0 8.5h14M0 11h9"
stroke="currentColor" stroke-width="1.4" fill="none" />
</svg>
</button>
<button :class="{ aktiv: ansicht === 'kompakt' }" title="kompakt (wenig Text)"
@click="setAnsicht('kompakt')">
<svg width="14" height="12" viewBox="0 0 14 12">
<path d="M0 2.5h14M0 6h8" stroke="currentColor" stroke-width="1.4" fill="none" />
</svg>
</button>
</div>
<button class="schalter-solo" :title="LEVEL_NAME[level]" @click="levelToggle">
{{ LEVEL_ICON[level] }}
</button>
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
@click="fokus = true"></button>
</div>
<div class="fortschritt">
<div>Kapitel {{ aktiv + 1 }} / {{ daten.kapitel.length }}</div>
<div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div>
</div>
<a v-for="(kap, i) in daten.kapitel" :key="i" :class="{ aktiv: i === aktiv }"
@click="springe(i)">
<span>{{ i + 1 }}. {{ kap.titel }}</span>
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
</a>
</nav>
<div class="seite guide-text" @scroll="onScroll">
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
@click="fokus = false"></button>
<template v-if="daten">
<div v-for="(kap, i) in daten.kapitel" :key="i" class="kapitel"
:ref="(el) => setKapEl(i, el)">
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
<template v-if="sichtbar(i)">
<p v-if="kap.intro && ansicht === 'erklaerend'"
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
<h3 style="margin-top: 0">{{ s.titel }}</h3>
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
</div>
</template>
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
</div>
<div v-if="!daten.kapitel.length" class="guide-section">Noch kein Guide generiert.</div>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../api.js'
const props = defineProps({ run: Object })
const daten = ref(null)
async function laden() {
if (!props.run) return
daten.value = await api.kennzahlen(props.run.id)
}
watch(() => props.run?.id, laden, { immediate: true })
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n)
const s = (ms) => `${(ms / 1000).toFixed(0)}s`
</script>
<template>
<div class="seite">
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 12px">
<h2 style="margin: 0; flex: 1">Kennzahlen {{ run ? `(Lauf ${run.id})` : '' }}</h2>
<span v-if="daten" class="badge">{{ k(daten.verbraucht) }} Tokens gesamt</span>
<button class="sekundaer" @click="laden">Neu laden</button>
</div>
<table v-if="daten" class="kennzahlen">
<thead>
<tr>
<th>Ebene / Stage / Template</th><th>Calls</th><th>ok</th><th>Timeout</th>
<th>Fehler</th><th>Tok in</th><th>Tok out</th><th>Cache</th>
<th>Dauer</th><th>Wartezeit</th>
</tr>
</thead>
<tbody>
<tr v-for="z in daten.zeilen" :key="z.ebene + z.stage + z.template">
<td>{{ z.ebene }} / {{ z.stage }} <span style="color: var(--dim)">{{ z.template }}</span></td>
<td>{{ z.calls }}</td>
<td>{{ z.ok }}</td>
<td :style="z.timeouts ? 'color: var(--gelb)' : ''">{{ z.timeouts }}</td>
<td :style="z.fehler ? 'color: var(--rot)' : ''">{{ z.fehler }}</td>
<td>{{ k(z.tok_in || 0) }}</td>
<td>{{ k(z.tok_out || 0) }}</td>
<td>{{ k(z.cache_read || 0) }}</td>
<td>{{ s(z.dur_ms || 0) }}</td>
<td>{{ s(z.wait_ms || 0) }}</td>
</tr>
</tbody>
</table>
<p v-else style="color: var(--dim)">Noch kein Lauf.</p>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import { ref, watch } from 'vue'
import { api } from '../api.js'
import { render } from '../markdown.js'
const props = defineProps({ topic: String })
const stapel = ref([])
const zeigeAntwort = ref(false)
const fertigGelernt = ref(0)
async function laden() {
if (!props.topic) return
stapel.value = await api.ueben(props.topic)
zeigeAntwort.value = false
fertigGelernt.value = 0
}
watch(() => props.topic, laden, { immediate: true })
async function antworten(richtig) {
const karte = stapel.value[0]
await api.antwort(karte.id, richtig)
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
if (richtig) fertigGelernt.value++
zeigeAntwort.value = false
}
</script>
<template>
<div class="seite">
<div v-if="stapel.length" class="flash">
<div class="meta" style="color: var(--dim); margin-bottom: 8px">
{{ stapel[0].titel }} · Box {{ stapel[0].box }} · noch {{ stapel.length }} fällig
</div>
<div class="frage" v-html="render(stapel[0].inhalt.frage)"></div>
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">Antwort zeigen</button>
<template v-else>
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
<div style="display: flex; gap: 10px; justify-content: center">
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
</div>
</template>
</div>
<div v-else class="flash">
<p>Keine fälligen Karten. {{ fertigGelernt ? `${fertigGelernt} gelernt — stark.` : '' }}</p>
<button class="sekundaer" @click="laden">Neu laden</button>
</div>
</div>
</template>

6
frontend/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@fontsource-variable/inter'
import './style.css'
createApp(App).mount('#app')

102
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,102 @@
import { Marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import katex from 'katex'
import DOMPurify from 'dompurify'
import 'highlight.js/styles/github-dark.css'
import 'katex/dist/katex.min.css'
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const l = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language: l }).value
},
})
)
function kx(tex, displayMode) {
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt
return katex.renderToString(tex, { displayMode }).replace(/\n/g, ' ')
}
function mathe(text) {
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
return text
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
try { return kx(tex, true) } catch { return _ }
})
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
try { return kx(tex, true) } catch { return _ }
})
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
try { return kx(tex, false) } catch { return _ }
})
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
try { return kx(tex, false) } catch { return _ }
})
}
export function render(text) {
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
}
const RANG = { E: 0, M: 1, S: 2 }
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
export function lesestat(text) {
let displayFormeln = 0
const t = (text || '')
.replace(MARKER, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
.replace(/\$[^$\n]+?\$/g, ' Formel ')
const woerter = t.split(/\s+/).filter(Boolean).length
return { woerter, displayFormeln }
}
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
export function segmente(lang) {
const out = []
let letzt = 0
let aktuell = { level: null, titel: '', text: '' }
for (const m of lang.matchAll(MARKER)) {
aktuell.text = lang.slice(letzt, m.index)
out.push(aktuell)
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
letzt = m.index + m[0].length
}
aktuell.text = lang.slice(letzt)
out.push(aktuell)
return out
}
export function gefiltert(lang, maxLevel) {
if (!maxLevel || maxLevel === 'S') return lang
return segmente(lang)
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
.map((s) => s.text)
.join('\n')
}
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
export function renderGestuft(lang, maxLevel) {
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
const seg = segmente(lang)
const letzt = seg[seg.length - 1]
const i = letzt.text.indexOf('**Kernpunkte:**')
if (i >= 0) {
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
letzt.text = letzt.text.slice(0, i)
}
return seg.map((s) => {
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
const html = render(s.text)
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
return gelernt ? `<div class="gelernt">${html}</div>` : html
}).join('')
}

243
frontend/src/style.css Normal file
View File

@@ -0,0 +1,243 @@
:root {
--bg: #10141a;
--panel: #171d26;
--karte: #1f2733;
--rand: #2c3646;
--text: #dce3ec;
--dim: #8b98a9;
--akzent: #4da3ff;
--gruen: #3fc380;
--gelb: #e8c34a;
--rot: #e06060;
--badge-gruen-bg: #1d3a2c;
--badge-gelb-bg: #3a331d;
--badge-rot-bg: #3a1d1d;
--badge-blau-bg: #1d2b3a;
--code-bg: #0d1117;
}
:root { --lese-text: #c9d2df; } /* Guide-Fließtext: weicher als --text */
:root[data-theme="light"] { --lese-text: #333747; }
:root[data-theme="sepia"] {
--bg: #f0e7d3;
--panel: #f9f2df;
--karte: #efe4c9;
--rand: #d9cba8;
--text: #443b2c;
--dim: #83765c;
--akzent: #7a5c2e;
--gruen: #4f7a42;
--gelb: #8f6b14;
--rot: #a6442e;
--badge-gruen-bg: #e2ecd5;
--badge-gelb-bg: #f0e6c2;
--badge-rot-bg: #f0d8cd;
--badge-blau-bg: #e8e0c8;
--code-bg: #ede3ca;
--lese-text: #443b2c;
}
:root[data-theme="light"] {
--bg: #f3f5f8;
--panel: #ffffff;
--karte: #eef1f5;
--rand: #d4dae2;
--text: #1c2430;
--dim: #5c6a7c;
--akzent: #1a73d6;
--gruen: #1d8a55;
--gelb: #9a7b12;
--rot: #c23b3b;
--badge-gruen-bg: #dcf2e6;
--badge-gelb-bg: #f6eecb;
--badge-rot-bg: #f6d9d9;
--badge-blau-bg: #dcebfa;
--code-bg: #f0f2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 system-ui, sans-serif;
}
#app { height: 100vh; display: flex; }
button {
background: var(--akzent); color: #fff; border: 0; border-radius: 6px;
padding: 6px 14px; cursor: pointer; font: inherit;
}
button.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
button.klein { padding: 2px 8px; font-size: 12px; }
button:disabled { opacity: 0.4; cursor: default; }
input, select {
background: var(--karte); color: var(--text); border: 1px solid var(--rand);
border-radius: 6px; padding: 6px 10px; font: inherit;
}
/* Sidebar-Navigation */
.sidebar {
width: 220px; min-width: 220px; background: var(--panel);
border-right: 1px solid var(--rand); display: flex; flex-direction: column;
}
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
.sidebar .topic-eintrag {
display: flex; align-items: center; gap: 6px; padding: 7px 10px;
border-radius: 6px; cursor: pointer; color: var(--dim);
}
.sidebar .topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
.kontextmenue {
position: fixed; background: var(--panel); border: 1px solid var(--rand);
border-radius: 8px; padding: 4px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
.kontextmenue button {
display: block; width: 100%; text-align: left; background: none;
color: var(--text); border: 0; padding: 7px 12px; border-radius: 6px;
}
.kontextmenue button:hover:not(:disabled) { background: var(--karte); }
.kontextmenue button.rot { color: var(--rot); }
.hauptbereich { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand);
}
.tabs { display: flex; gap: 4px; }
.tabs button { background: transparent; color: var(--dim); }
.tabs button.aktiv { background: var(--karte); color: var(--text); }
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.badge {
display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; background: var(--karte); color: var(--dim);
}
.badge.gruen { background: var(--badge-gruen-bg); color: var(--gruen); }
.badge.gelb { background: var(--badge-gelb-bg); color: var(--gelb); }
.badge.rot { background: var(--badge-rot-bg); color: var(--rot); }
.badge.blau { background: var(--badge-blau-bg); color: var(--akzent); }
/* Befunde/Agenten-Leiste über dem Board */
.leiste {
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
padding: 8px 12px; border-bottom: 1px solid var(--rand); background: var(--panel);
min-height: 40px; max-height: 130px; overflow-y: auto;
}
/* Board: Ebenen-Spalten links→rechts, Listen VOLLSTÄNDIG scrollbar */
.board { display: flex; gap: 10px; padding: 12px; overflow-x: auto; flex: 1; min-height: 0; }
.spalte {
min-width: 265px; max-width: 320px; flex: 1; display: flex; flex-direction: column;
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
}
.spaltekopf { padding: 8px 10px; border-bottom: 1px solid var(--rand); display: grid; gap: 6px; }
.spaltekopf .zeile { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.spaltekopf .titel { font-weight: 600; font-size: 13px; flex: 1; }
.spaltekopf label { font-size: 12px; color: var(--dim); display: flex; gap: 4px; align-items: center; }
.spalte .liste { overflow-y: auto; flex: 1; padding: 8px; }
.karte {
background: var(--karte); border: 1px solid var(--rand); border-radius: 6px;
padding: 6px 9px; margin-bottom: 6px; font-size: 13px;
}
.karte .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
.seite { flex: 1; overflow-y: auto; padding: 20px; }
.seite.schmal { max-width: 900px; margin: 0 auto; }
/* Guide: Kapitel-Navigation + Lesetypografie (~70 Zeichen/Zeile, 17px) */
.guide-layout { flex: 1; display: flex; overflow: hidden; min-height: 0; }
.guide-nav {
width: 260px; min-width: 220px; overflow-y: auto; padding: 14px 10px;
border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px;
}
.guide-nav .schalter-zeile {
display: flex; gap: 6px; align-items: center; padding: 2px 4px 10px;
}
.schalter { display: flex; border: 1px solid var(--rand); border-radius: 6px; overflow: hidden; }
.schalter button {
background: none; color: var(--dim); border: 0; border-radius: 0;
padding: 4px 9px; font-size: 12px; line-height: 1;
display: flex; align-items: center;
}
.schalter button.aktiv { background: var(--karte); color: var(--text); }
.schalter-solo {
background: none; color: var(--dim); border: 1px solid var(--rand);
border-radius: 6px; padding: 3px 9px; font-size: 12px;
}
.guide-nav .fortschritt {
color: var(--dim); font-size: 12px; padding: 0 8px 10px;
border-bottom: 1px solid var(--rand); margin-bottom: 8px;
}
.guide-nav a {
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
padding: 5px 8px; border-radius: 6px;
color: var(--dim); cursor: pointer; line-height: 1.35;
}
.guide-nav a .zeit { font-size: 11px; white-space: nowrap; opacity: 0.75; }
.guide-nav a:hover { color: var(--text); }
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
.seite.guide-text {
position: relative; max-width: 680px; margin: 0 auto;
scrollbar-width: none; /* Scrollbalken unsichtbar, Scrollen bleibt */
font-family: 'Inter Variable', system-ui, sans-serif;
font-size: 19px; line-height: 1.55; color: var(--lese-text);
hyphens: auto; -webkit-hyphens: auto;
}
/* Fokus-Modus: Navigation weg, Karten-Rahmen aufgelöst — reiner Artikel-Fluss */
.guide-layout.fokus .guide-nav { display: none; }
.guide-layout.fokus .guide-section {
border: 0; background: none; padding: 0 0 6px; margin-bottom: 4px;
}
body.vollbild .sidebar, body.vollbild .topbar { display: none; }
body.vollbild .nav-toggle { display: none; }
/* Eingeklappt: Themen-Sidebar + Topbar weg — die Kapitelübersicht bleibt */
body.nav-zu .sidebar, body.nav-zu .topbar { display: none; }
body.nav-zu .guide-nav { padding-top: 44px; }
.nav-toggle {
position: fixed; top: 6px; left: 8px; z-index: 20;
background: none; color: var(--dim); border: 0; padding: 4px 8px; font-size: 16px;
}
.nav-toggle:hover { color: var(--text); }
.sidebar .topics { padding-top: 40px; } /* Platz für das fixe ☰ links oben */
.fokus-aus {
position: fixed; top: 10px; right: 16px; z-index: 5;
background: none; color: var(--dim); border: 0;
padding: 4px 8px; font-size: 16px; opacity: 0.6;
}
.fokus-aus:hover { opacity: 1; }
.seite.guide-text::-webkit-scrollbar { display: none; }
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
.guide-text .kapitel { scroll-margin-top: 8px; }
.guide-text .kapitel-titel { margin: 30px 0 6px; }
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
.guide-text .kompakt {
background: none; padding: 0; color: var(--lese-text);
font-size: 17px; line-height: 1.55;
}
.guide-text .kompakt li { margin: 5px 0; }
.guide-section {
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
padding: 16px 22px; margin-bottom: 16px;
}
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
.markdown .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
.markdown blockquote {
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
}
.markdown blockquote p { margin: 4px 0; }
.markdown :is(code):not(.hljs) { background: var(--karte); padding: 1px 5px; border-radius: 4px; }
.markdown pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; }
.markdown table { border-collapse: collapse; }
.markdown td, .markdown th { border: 1px solid var(--rand); padding: 4px 10px; }
.flash {
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
padding: 28px; max-width: 620px; margin: 40px auto; text-align: center;
}
.flash .frage { font-size: 18px; margin-bottom: 18px; }
.flash .antwort { background: var(--karte); border-radius: 8px; padding: 14px; margin: 14px 0; }
table.kennzahlen { border-collapse: collapse; width: 100%; font-size: 13px; }
table.kennzahlen th, table.kennzahlen td {
border-bottom: 1px solid var(--rand); padding: 5px 10px; text-align: right;
}
table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: left; }
table.kennzahlen th { color: var(--dim); }

6
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})

3
pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

16
templates/Artefakt-Fix.md Normal file
View File

@@ -0,0 +1,16 @@
<!-- template:Artefakt-Fix -->
Dieses Artefakt (Typ {typ}) hat Mängel. Behebe sie in EINEM Durchgang.
ARTEFAKT:
{artefakt}
MÄNGEL:
{maengel}
BELEGE (einzige erlaubte Faktenbasis — nichts erfinden):
{belege}
Antworte NUR mit JSON (keine Code-Fences). Für flashcard:
{{"frage": "…", "antwort": "…"}}
Für beispiel:
{{"text": "…"}}

View File

@@ -0,0 +1,26 @@
<!-- template:Artefakt-Generate -->
Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate).
Erzeuge pro Atom:
- 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich
NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren.
- 1 Worked Example (typ "beispiel", Feld "text"): ein EINFACHES durchgerechnetes
Beispiel — die kleinste Instanz, die das Atom klärt. Keine mehrstufigen oder
verschachtelten Konstruktionen. Alle Werte/Aussagen müssen aus den Belegen ableitbar
sein. Wenn die Belege KEIN sinnvolles einfaches Beispiel hergeben, weglassen.
Regeln:
- Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen (außer Beispiel s. o.).
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
keine Rezitation exakter Formulierungen verlangen).
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
„Beleg", „Quelle", „Musterlösung", „Skript", „Aufgabe 3" sind verboten. Die Frage
fragt Wissen ab („Was ist X?", „Warum gilt Y?"), nie was in einem Text steht,
erwähnt oder gestellt wird.
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
[{{"atom": 12, "typ": "flashcard", "frage": "…", "antwort": "…"}},
{{"atom": 12, "typ": "beispiel", "text": "…"}}]

View File

@@ -0,0 +1,18 @@
<!-- template:Artefakt-Verify -->
Unten Artefakte (Flashcards/Beispiele) mit den Beleg-Zitaten ihres Atoms.
Prüfe jedes Artefakt GEGEN DIE BELEGE:
- ok=false, wenn eine Aussage den Belegen widerspricht oder nicht aus ihnen ableitbar ist.
- ok=false, wenn die Frage aus der Antwort heraus nicht fair beantwortbar ist.
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
funktionieren und Wissen abfragen, nicht Textinhalte.
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
- Antworte für JEDES Artefakt (id wörtlich übernehmen).
ARTEFAKTE:
{artefakte}
Antworte NUR mit JSON (keine Code-Fences):
[{{"artefakt": 5, "ok": true, "mangel": ""}}]

View File

@@ -0,0 +1,17 @@
<!-- template:Atom-Anker-Fix-Batch -->
Unten Atome ohne Quellen-Anker (Format „id: titel — definition") und ein Quelltext-Abschnitt.
Suche für JEDES Atom die Passage im Abschnitt, die es belegt, und kopiere sie
WÖRTLICH (exakt, Zeichen für Zeichen, 540 Wörter). Regeln:
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
diesem Abschnitt: weglassen.
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
ATOME:
{atome}
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
[{{"atom": 12, "zitat": "…"}}]

View File

@@ -0,0 +1,12 @@
<!-- template:Atom-Anker-Fix -->
Atom: „{titel}" — {definition}
Suche im QUELLTEXT unten die Passage, die dieses Atom belegt, und kopiere sie
WÖRTLICH (exakt, Zeichen für Zeichen, 540 Wörter). Wenn der Text das Atom
NICHT enthält: leeres Zitat. Nichts paraphrasieren.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
{{"zitat": "…"}}

View File

@@ -0,0 +1,25 @@
<!-- template:Atom-Extraktion-Aufgaben -->
Thema: „{topic}". Quelle: „{quelle}" — eine AUFGABENSAMMLUNG (Übungen/Klausuren).
Extrahiere die GEÜBTEN KONZEPTE, nicht die Aufgaben. Für jede Aufgabe im Quelltext:
Welches Konzept/Verfahren/welche Aussage wird hier geprüft? DAS wird das Atom.
Regeln:
- „titel"/„definition": das Konzept ALLGEMEIN, destilliert — OHNE Instanzdetails der
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
kein Feld außer dem Zitat.
- „typ": begriff | aussage | verfahren. „level": E | M | S.
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (540 Wörter) — sie ist der Beleg,
dass dieses Konzept geübt wird.
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]

View File

@@ -0,0 +1,27 @@
<!-- template:Atom-Extraktion -->
Thema: „{topic}". Quelle: „{quelle}".
Extrahiere aus dem QUELLTEXT unten ALLE Atome. Ein Atom ist die kleinste eigenständig
lernbare Einheit, mit Typ:
- **begriff** — eine Definition (z. B. „DFA")
- **aussage** — Satz, Lemma, Eigenschaft (z. B. „Pumping-Lemma")
- **verfahren** — Schritt-für-Schritt-Methode (z. B. „Potenzmengenkonstruktion")
Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind
KEINE Atome. Regeln:
- Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden.
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
- „definition": 12 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
540 Wörter). Ohne wörtliches Zitat kein Atom.
- „level": E (Einstieg) | M (Mittel) | S (Schwer).
- „braucht": Titel anderer Atome DIESES Auszugs, die man vorher verstanden haben muss
(nur echte Voraussetzungen, keine bloße Verwandtschaft). Leer, wenn keine.
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]

14
templates/Atom-Luecke.md Normal file
View File

@@ -0,0 +1,14 @@
<!-- template:Atom-Luecke -->
Soll-Punkt ohne Atome: „{punkt}". Quelle: „{quelle}".
Der QUELLTEXT unten enthält die Belegstelle dieses Punkts. Extrahiere die Atome
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
- „zitat": Kernpassage WÖRTLICH kopiert (540 Wörter). Ohne wörtliches Zitat kein Atom.
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…"}}]

18
templates/Atom-Merge.md Normal file
View File

@@ -0,0 +1,18 @@
<!-- template:Atom-Merge -->
Unten Paare von Atomen (je Titel, Definition, Beleg-Zitat aus der Quelle).
Entscheide pro Paar: Meinen A und B EXAKT dasselbe Konzept (gleich=true) oder nicht
(gleich=false)? Regeln:
- Vergleiche die BELEGE und Definitionen, nicht die Titelwörter.
- gleich=true NUR bei identischem Gegenstand. Verwandt, Teilaspekt, Spezialfall,
gleiche Aussage über verschiedene Objekte (z. B. Pumping-Lemma regulär vs. kontextfrei),
umgekehrte Richtung einer Beziehung → gleich=false.
- Ein falscher Merge zerstört Information; eine stehengebliebene Dublette ist harmlos.
IM ZWEIFEL gleich=false.
- Antworte für JEDES Paar.
PAARE:
{paare}
Antworte NUR mit JSON (keine Code-Fences):
[{{"paar": 1, "gleich": false}}]

View File

@@ -0,0 +1,13 @@
<!-- template:Atom-Soll-Stich -->
Atom: „{titel}" — {definition}
Beleg aus der Quelle: {beleg}
SOLL-PUNKTE (Format „id: punkt"):
{soll}
Stichentscheid: Gehört dieses Atom zum Thema? Wenn ja, nenne die id des passendsten
Soll-Punkts. Wenn es wirklich themenfremd ist: "fremd". Du MUSST entscheiden.
Der Beleg zeigt, dass es im Material steht — Zweifel sprechen für Zuordnung, nicht für fremd.
Antworte NUR mit JSON (keine Code-Fences):
{{"soll": 3}} oder {{"soll": "fremd"}}

View File

@@ -0,0 +1,15 @@
<!-- template:Atom-Soll-Zuordnung -->
SOLL-PUNKTE (Format „id: punkt"):
{soll}
ATOME (Format „id: titel — definition"):
{atome}
Ordne JEDES Atom genau EINEM Soll-Punkt zu (dem thematisch passendsten).
Regeln:
- Nur die ids oben verwenden, keine erfinden.
- Passt wirklich KEIN Punkt (Atom ist themenfremd), setze "soll": 0.
- Antworte für jedes Atom.
Antworte NUR mit JSON (keine Code-Fences):
[{{"atom": 12, "soll": 3}}]

28
templates/Guide-Fix.md Normal file
View File

@@ -0,0 +1,28 @@
<!-- template:Guide-Fix -->
Überarbeite diese Guide-Section in EINEM Durchgang unter ALLEN Aufträgen unten.
AUFTRÄGE:
{auftraege}
FAKTENBASIS (einzige Wahrheit; zitierte Fakten wörtlich):
{fakten}
SECTION (kompakt):
{kompakt}
SECTION (lang):
{lang}
Regeln:
- Alle Marker-Zeilen (<!-- atom: … -->) EXAKT erhalten bzw. fehlende exakt einfügen —
je Atom genau eine, unsichtbar, keine Überschrift.
- Struktur erhalten: `####`-Zwischenüberschriften, „**Kernpunkte:**"- und
„**Prüfe dich:**"-Block am Ende; andere Überschriften-Ebenen bleiben verboten.
- Mathe in KaTeX (`$…$` / `$$…$$`) setzen bzw. belassen.
- Nur die beauftragten Stellen ändern; den Rest wörtlich erhalten.
- Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der
Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…".
- Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis.
Antworte NUR mit JSON (keine Code-Fences):
{{"kompakt": "…", "lang": "…"}}

View File

@@ -0,0 +1,32 @@
<!-- template:Guide-Pruefer -->
Prüfe diese Guide-Section in EINEM Durchgang. Lernziel: {ziel}
FAKTENBASIS (einzige Wahrheit — Definitionen + wörtliche Belege):
{fakten}
SECTION (kompakt):
{kompakt}
SECTION (lang):
{lang}
Prüfe:
1. FAKTEN: Jeder inhaltliche Claim (Zahl, Name, Behauptung) muss aus der Faktenbasis
ABLEITBAR sein. Widerspruch oder klar Erfundenes → art "falsch". Freie, sinngemäße
Formulierung ist ausdrücklich ERWÜNSCHT und kein Befund — der Text soll lehren,
nicht abschreiben. Belegte/korrekte Claims NICHT auflisten.
2. COVERAGE: Fehlt ein Atom-Inhalt, sodass das Lernziel nicht erreichbar ist → art "luecke".
3. LESBARKEIT: Grobe Mängel (Gedankensprünge, unerklärte Fachbegriffe, fremdsprachige
Passagen, Aufgaben-Metadaten wie Aufgabennummern/Punktzahlen/„Zeigen Sie…") →
art "stil", als konkreter, behebbarer Auftrag. Geschmack ist kein Befund.
4. SELBSTSTÄNDIGKEIT: Der Text verweist auf seine Quellen („laut/gemäß Beleg",
„im Skript", „aus der Hausaufgabe/Präsenzaufgabe bekannt", „die Quelle
betrachtet…") → art "stil". Der Leser kennt keine Quellen; solche Verweise
sind durch die Aussage selbst zu ersetzen. Normale Wendungen wie „ein Beleg
dafür, dass" (= Nachweis) sind KEIN Befund.
„detail" = ein Satz, der den Befund für einen Fix-Agenten eindeutig macht.
Keine Befunde → leere Liste.
Antworte NUR mit JSON (keine Code-Fences):
{{"befunde": [{{"art": "falsch", "detail": "…"}}]}}

53
templates/Guide-Writer.md Normal file
View File

@@ -0,0 +1,53 @@
<!-- template:Guide-Writer -->
Schreibe EINE Guide-Section für den Baustein „{titel}".
Lernziel: {ziel}
Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool.
Regeln (hart):
1. LEHREN, NICHT ABSCHREIBEN: Formuliere frei und didaktisch in eigenen Worten —
Lehrbuchton, auf Deutsch (fremdsprachige Belege sinngemäß übersetzen). Die
Faktenbasis unten ist die einzige Quelle der Wahrheit: Jede Zahl, jeder Name,
jede Behauptung muss aus ihr ABLEITBAR sein. Nichts erfinden, nichts
dazurecherchieren — ein Fakten-Gate prüft jeden Claim.
Aufgaben-Metadaten sind im Lehrtext VERBOTEN: keine Aufgabennummern, Punktzahlen,
„Zeigen Sie…"-Formulierungen, keine Story-Verkleidungen aus Übungsaufgaben.
KEINE Blockquotes („>"): Belege nie wörtlich einblocken — Inhalt gehört in
eigenen Worten in den Fließtext.
Der Text darf seine Quellen NIE erwähnen: kein „laut Beleg", „gemäß dem Beleg",
„im Skript", „aus der Hausaufgabe bekannt" — der Leser kennt keine Quellen,
der Text steht für sich.
2. MARKER: Übernimm für jedes Atom seine Marker-Zeile EXAKT wie angegeben, direkt vor
dem Absatz, der das Atom behandelt. Der Marker ist eine unsichtbare Schnittstelle,
KEINE sichtbare Überschrift. Jeder Marker genau einmal. Kein Atom auslassen.
3. STRUKTUR von „lang" (in dieser Reihenfolge):
- Erster Satz direkt zur Sache — kein Vorgeplänkel, keine Motivation-Floskeln.
- Absätze GLEICHMÄSSIG kurz: 4090 Wörter. Nie über ~110 Wörter — dann teilen.
- Je Konzept eine `####`-Zwischenüberschrift (Inhaltsaussage, nicht nur Stichwort) —
nur bei echtem Konzeptwechsel, nicht nach jedem zweiten Absatz.
Andere Überschriften-Ebenen (#, ##, ###) sind VERBOTEN.
- Atom-Reihenfolge einhalten (sie ist didaktisch sortiert). EINFACHE Beispiele an
der passenden Stelle einweben, wo sie das Atom klären — kleinste sinnvolle
Instanz, Werte müssen aus der Faktenbasis ableitbar sein. Komplexe oder
mehrstufige Beispiele weglassen oder auf ihren Kern vereinfachen.
- Kohäsion: Jeder Absatz knüpft explizit an den vorigen an (Konnektor oder
Übergangssatz). Fachbegriffe konsistent wiederverwenden — keine Synonym-Variation.
- Abschluss: „**Kernpunkte:**" mit 35 Stichpunkten (nur Verdichtung, KEINE neuen
Formulierungen) und „**Prüfe dich:**" mit genau 2 Fragen aus dem FRAGEN-POOL
(wörtlich übernehmen, ohne Antworten).
4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$`
(z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle
ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
IMMER umschließende `$…$`) — ohne Delimiter rendert nichts.
5. LÄNGE: „lang" hat {min_woerter}{max_woerter} Wörter. Die Obergrenze ist ein HARTES
Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel
ein, kein Fülltext, keine Wiederholungen.
6. „kompakt": 23 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
Paraphrase, keine Marker.
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht.
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
{{"kompakt": "…", "lang": "…"}}

View File

@@ -0,0 +1,12 @@
<!-- template:Kanten-Zyklus -->
Der Voraussetzungs-Graph enthält einen Zyklus (unten die Kanten, Format
„von→zu: X braucht Y").
Wähle die SCHWÄCHSTE Kante — die, deren Voraussetzungs-Behauptung am wenigsten
zwingend ist (eher Verwandtschaft als echte Voraussetzung). Genau eine.
ZYKLUS:
{kanten}
Antworte NUR mit JSON (keine Code-Fences), mit den ids der gewählten Kante:
{{"von": 12, "zu": 7}}

View File

@@ -0,0 +1,17 @@
<!-- template:Kapitel-Intro -->
Schreibe einen Advance Organizer für das Guide-Kapitel „{titel}".
Unten die Abschnitte des Kapitels (Titel: Lernziel).
Regeln:
- 24 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen,
worauf baut es auf.
- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren
(das wäre Redundanz zum Kapiteltext).
- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…").
ABSCHNITTE:
{bausteine}
Antworte NUR mit JSON (keine Code-Fences):
{{"intro": "…"}}

View File

@@ -0,0 +1,19 @@
<!-- template:Kapitel-Schnitt -->
Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge
(Format „id: titel — lernziel").
Setze Kapitelgrenzen. Regeln:
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen.
Das letzte "bis" muss die letzte id der Liste sein.
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
Baustein-Titel-Duplikat, keine Nummerierung).
{hinweis}
BAUSTEINE:
{bausteine}
Antworte NUR mit JSON (keine Code-Fences):
[{{"titel": "…", "bis": 12}}, {{"titel": "…", "bis": 27}}]

View File

@@ -0,0 +1,16 @@
<!-- template:Korpus-Recherche -->
Du bist Recherche-Agent für das Lernthema „{topic}". Deine Perspektive (Lens): **{lens}**.
- curriculum: Was lehren Universitäts-Curricula/Modulhandbücher zu diesem Thema?
- lehrbuch: Kapitelgliederungen + Kerninhalte der Standard-Lehrbücher.
- syllabus: Kurs-Syllabi/Vorlesungspläne realer Kurse.
- uebungen: Übungssammlungen/Aufgabentypen, die zu diesem Thema geübt werden.
Auftrag: Finde über die Websuche 23 hochwertige Quellen aus deiner Lens und extrahiere deren
INHALT (nicht nur Links): Gliederung, zentrale Begriffe, Aussagen, Verfahren — als
zusammenhängender Fließtext/Markdown, so wörtlich wie möglich an der Quelle.
Mindestens 500 Wörter Inhalt pro Quelle, sonst Quelle weglassen. Nichts erfinden —
nur was wirklich in der Quelle steht.
Antworte NUR mit JSON (keine Code-Fences, keine Prosa davor/danach, KEINE Datei schreiben):
[{{"titel": "…", "url": "…", "inhalt": "…"}}]

View File

@@ -0,0 +1,12 @@
<!-- template:Korpus-Soll-Beleg -->
Soll-Punkt: „{punkt}"
Suche im QUELLTEXT unten eine Passage, die diesen Punkt belegt. Wenn es eine gibt:
kopiere sie WÖRTLICH (exakt, 530 Wörter). Wenn der Text den Punkt NICHT behandelt:
leeres Zitat. Nichts paraphrasieren, nichts erfinden.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences):
{{"zitat": "…"}}

View File

@@ -0,0 +1,25 @@
<!-- template:Korpus-Soll-Konsens -->
Thema: „{topic}". Unten Soll-Punkt-KANDIDATEN aus verschiedenen Quellen (Format „id: punkt").
Gruppiere Kandidaten, die DASSELBE Teilthema meinen (Paraphrasen, Synonyme,
Acronym vs. Ausschreibung). Regeln:
- Du GRUPPIERST nur — ob ein Punkt bestätigt wird, entscheidet der Code (Quellenzählung).
- VOLLSTÄNDIGKEIT: JEDE Kandidaten-id muss genau einmal vorkommen — in einer
Themen-Gruppe oder in einer Ablehnungs-Gruppe. Nichts stillschweigend weglassen.
- Ablehnen ("abgelehnt": true + "grund") darfst du nur, was kein eigenständig
lernbares Teilthema ist: Meta-Überschriften („Einführung"), reine Aufgabeninstanzen.
- MINDESTENS {band} Punkte, nach oben offen — im Zweifel feiner trennen. Die Punkte
sind eine Prüf-CHECKLISTE („was gehört alles zum Thema"), keine Kapitel — jeder
Punkt muss einzeln belegbar und prüfbar sein.
- Verschiedene Teilthemen sind verschiedene Punkte: NICHT über-mergen. Ein Oberbegriff,
der mehrere lernbare Teilthemen verschluckt (z. B. „NP-Vollständigkeit" für zehn
einzelne Reduktionen), ist ein Fehler. Im Zweifel getrennt lassen.
- Ids wörtlich übernehmen, keine erfinden.
- „punkt" = die klarste Formulierung der Gruppe (aus den Kandidaten wählen, nicht neu erfinden).
{hinweis}
KANDIDATEN:
{kandidaten}
Antworte NUR mit JSON (keine Code-Fences):
[{{"punkt": "…", "kandidaten": [1, 2]}}, {{"abgelehnt": true, "grund": "…", "kandidaten": [3]}}]

17
templates/Korpus-Soll.md Normal file
View File

@@ -0,0 +1,17 @@
<!-- template:Korpus-Soll -->
Thema: „{topic}". Quelle: „{quelle}".
Extrahiere aus dem QUELLTEXT unten die Soll-Punkte: die prüfungsrelevanten Teilthemen,
die dieses Material zum Thema abdeckt. Ein Soll-Punkt ist ein kurzes Teilthema
(310 Wörter), kein ganzer Satz. Regeln:
- Arbeite AUSSCHLIESSLICH mit dem Quelltext unten. Kein externes Wissen, nichts erfinden.
- Zu jedem Punkt ein Beleg-Zitat: eine Passage WÖRTLICH aus dem Quelltext kopiert
(exakt, Zeichen für Zeichen, 530 Wörter). Ohne wörtlichen Beleg keinen Punkt melden.
- Katalognummern („Satz 7.13", „Kapitel 3") sind keine Punkte — der Inhalt zählt.
- Metadaten, Organisatorisches, Literaturlisten sind keine Soll-Punkte.
QUELLTEXT:
{text}
Antworte NUR mit JSON (keine Code-Fences, keine Prosa):
[{{"punkt": "…", "zitat": "…"}}]

20
templates/Lernziele.md Normal file
View File

@@ -0,0 +1,20 @@
<!-- template:Lernziele -->
Soll-Punkt: „{punkt}". Unten die Atome dieses Punkts (Format „id: titel — definition").
Backward Design: Formuliere Lernziele („Kann …") und ordne JEDES Atom genau einem
Ziel zu. Ein Ziel = eine didaktische Einheit, die man in einer Sitzung lernt.
Regeln:
- Ziele beschreiben Können, nicht Stoff („Kann reguläre Sprachen mit dem Pumping-Lemma
widerlegen", nicht „Pumping-Lemma").
- Atome, die man ZUSAMMEN lernt (Voraussetzungskette, gleicher Aufgabentyp), gehören
in EIN Ziel. Richtwert 48 Atome pro Ziel — kein hartes Limit, Kohärenz geht vor.
- Atom-ids wörtlich übernehmen, keine erfinden, jede id höchstens einmal.
- „titel": ein Kapitelname zum Ziel — 36 Wörter, Substantiv-Stil (z. B.
„Reduktion von SAT auf CLIQUE"), KEIN „Kann…"-Satz, keine Katalognummern.
{pflicht}
ATOME:
{atome}
Antworte NUR mit JSON (keine Code-Fences):
[{{"ziel": "Kann …", "titel": "…", "atome": [12, 13]}}]

View File

@@ -0,0 +1,13 @@
<!-- template:QA-Guide-Falsch-Check -->
Bestätigungs-Pass: Unten Verdachts-Claims (Format „nr: claim") und die Faktenbasis.
Entscheide pro Claim: Widerspricht er der Faktenbasis WIRKLICH (falsch=true)?
Sei streng gegen den Verdacht — nur ein klarer Widerspruch zählt. Antworte für jeden Claim.
FAKTENBASIS:
{fakten}
CLAIMS:
{claims}
Antworte NUR mit JSON (keine Code-Fences):
[{{"claim": 1, "falsch": false}}]

View File

@@ -0,0 +1,14 @@
<!-- template:QA-Guide-Falsch -->
Unabhängiges Audit: Finde in der Guide-Section unten Claims, die der Faktenbasis
WIDERSPRECHEN (fachlich falsch). Nur echte Widersprüche — fehlende Belegung allein
ist kein Befund, Formulierungsvarianten sind keiner. Zitiere den Claim wörtlich.
Keine Funde → leere Liste.
FAKTENBASIS:
{fakten}
SECTION:
{text}
Antworte NUR mit JSON (keine Code-Fences):
[{{"claim": "…"}}]

View File

@@ -0,0 +1,14 @@
<!-- template:Soll-Abgedeckt -->
Soll-Punkt: „{punkt}"
Vorhandene Atome des Themas:
{atome}
Gezielte Nachextraktion hat für diesen Punkt keine eigenen Atome gefunden.
Entscheide: Ist der Punkt durch die vorhandenen Atome INHALTLICH bereits abgedeckt
(z. B. weil er ein Ober-/Unterthema eines anderen Punkts ist)?
- abgedeckt=true nur, wenn die Atome den Punkt wirklich tragen.
- abgedeckt=false, wenn hier tatsächlich Stoff fehlt.
Antworte NUR mit JSON (keine Code-Fences):
{{"abgedeckt": false}}

33
tests/conftest.py Normal file
View File

@@ -0,0 +1,33 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
import db # noqa: E402
import embedding # noqa: E402
import korpus # noqa: E402
@pytest.fixture(autouse=True)
def fake_welt(tmp_path, monkeypatch):
"""Jeder Test: frische In-Memory-DB, Fake-Agenten, Korpus/Topics in tmp.
Embedding fest auf Jaccard-Fallback — kein Modell-Download, deterministisch."""
monkeypatch.setenv("CREATOR_FAKE_AGENTS", "1")
monkeypatch.setattr(embedding, "_geladen", True)
monkeypatch.setattr(embedding, "_modell", None)
monkeypatch.setattr(korpus, "KORPUS_DIR", tmp_path / "korpus")
monkeypatch.setattr(korpus, "TOPICS_DIR", tmp_path / "topics")
db.on_change = None
db.reset_for_tests(":memory:")
yield tmp_path
def topic_anlegen(name="fake-thema", art="thema"):
db.insert("topics", name=name, titel=name, art=art, provider="minimax")
return name
def run_anlegen(topic, budget=0):
return db.insert("runs", topic=topic, status="running", budget_tokens=budget)

44
tests/test_artefakte.py Normal file
View File

@@ -0,0 +1,44 @@
"""Ebene 2: Quellen-Referenz-Guard — Karten müssen ohne den Quelltext
funktionieren (Lauf 8: 17 verifizierte Karten fragten „was steht im Beleg")."""
import artefakte
import db
import llm
from conftest import run_anlegen, topic_anlegen
def test_referenz_muster_trifft_nur_referenzen():
schlecht = [
{"frage": "Welchen Namen trägt die Technik, die im Beleg erwähnt wird?",
"antwort": "k-Enumeration.", "text": ""},
{"frage": "Was ist MAX-3-SAT?",
"antwort": "Aus dem Beleg geht nur hervor, dass es in Hausaufgabe 13.1 vorkommt.",
"text": ""},
{"frage": "Welche Aufgabe wird in Aufgabe 1 gestellt?", "antwort": "x", "text": ""},
{"frage": "Wie groß ist |V'|?", "antwort": "Laut Musterlösung genau |V|.", "text": ""},
]
gut = [ # Fachwörter dürfen NICHT matchen (generisch bleiben)
{"frage": "Was ist eine aussagenlogische Belegung?",
"antwort": "Eine Zuordnung von Wahrheitswerten zu Variablen.", "text": ""},
{"frage": "Was verbindet ein Fluss mit Quelle und Senke?",
"antwort": "Er belegt jede Kante mit einem Wert unter der Kapazität.", "text": ""},
]
assert all(artefakte._referenziert_quelle(k) for k in schlecht)
assert not any(artefakte._referenziert_quelle(k) for k in gut)
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
topic = topic_anlegen("guard")
run = run_anlegen(topic)
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
status="neu", braucht=db.j([]))
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "artefakte"
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
await artefakte._verifizieren(ctx, [atom]) # Guard greift VOR dem Panel
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
# verworfene zählen nicht als versorgt: Repair generiert für das Atom nach
chunks = artefakte._chunks(topic, nur_ohne=True)
assert [x["id"] for c in chunks for x in c] == [a]

187
tests/test_bausteine.py Normal file
View File

@@ -0,0 +1,187 @@
"""Deterministische Bausteine: textkit, jsonx, auto_loop, QA-Note."""
import auto_loop
import jsonx
import qa
import textkit
def test_finde_zitat_whitespace_tolerant():
text = "Der Automat\nbesteht aus Zuständen."
span = textkit.finde_zitat(text, "Der Automat besteht aus Zuständen.")
assert span is not None
start, ende = span
assert text[start:ende].split() == ["Der", "Automat", "besteht", "aus", "Zuständen."]
def test_finde_zitat_case_fallback_und_fehlschlag():
assert textkit.finde_zitat("DER AUTOMAT LÄUFT", "der Automat läuft") is not None
assert textkit.finde_zitat("völlig anderer Text", "der Automat läuft") is None
def test_finde_zitat_locker_interpunktion():
# PDF-Extrakte variieren Interpunktion/Dashes/Quotes — Wortlaut gleich → Treffer
text = "Es gilt: „Ein Automat mit Zuständen akzeptiert reguläre Sprachen."
zitat = 'Ein Automat, mit Zuständen, akzeptiert reguläre Sprachen'
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert "Automat" in text[span[0]:span[1]]
# Paraphrase (andere Wörter) bleibt draußen
assert textkit.finde_zitat(text, "Ein DFA mit Zuständen akzeptiert alle Sprachen") is None
# zu kurze Zitate matchen locker nicht (Eindeutigkeit)
assert textkit.finde_zitat("abc def", "ab, c") is None
def test_finde_zitat_dekomponierte_umlaute():
"""Lektion 82: pdftotext liefert dekomponierte Umlaute (a+U+0308), das LLM-Zitat
präkomponierte (ä) — die Locker-Stufe faltet beide auf den Basisbuchstaben.
Vorher scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
import unicodedata
text = unicodedata.normalize("NFD", "Satz 6.25. 3-SAT ist NP-vollständig. Beweis folgt später.")
span = textkit.finde_zitat(text, "Satz 6.25. 3-SAT ist NP-vollständig.")
assert span is not None
assert text[span[0]:span[1]].startswith("Satz 6.25")
# umgekehrt: präkomponierter Text, dekomponiertes Zitat
assert textkit.finde_zitat(
"Die Erfüllbarkeit boolescher Ausdrücke ist zentral.",
unicodedata.normalize("NFD", "Erfüllbarkeit boolescher Ausdrücke ist zentral")) is not None
def test_snapshot_schreiben_normalisiert_nfc():
"""Korpus-Snapshots werden am Import NFC-normalisiert — die exakte Zitat-Stufe
darf nicht an unsichtbar anderen Bytes scheitern."""
import unicodedata
import korpus
roh = unicodedata.normalize("NFD", "NP-vollständig und erfüllbar")
assert not unicodedata.is_normalized("NFC", roh)
pfad, _h = korpus._snapshot_schreiben("nfc-test", roh)
inhalt = open(pfad, encoding="utf-8").read()
assert unicodedata.is_normalized("NFC", inhalt)
assert inhalt == unicodedata.normalize("NFC", roh)
def test_snapshot_schreiben_ersetzt_kontrollzeichen():
"""PDF-Schriften mappen Sonderglyphen (ε) auf Steuerbytes — der Reader echot sie
als Müll und das Zitat wird unmatchbar. Import ersetzt sie durch Leerzeichen;
\\n und \\t bleiben (Layout)."""
import korpus
pfad, _h = korpus._snapshot_schreiben("ctrl-test", "Algorithmus (A\x0f )\nZeile\tzwei\x07!")
inhalt = open(pfad, encoding="utf-8").read()
assert inhalt == "Algorithmus (A )\nZeile\tzwei !"
def test_finde_zitat_fuzzy_listing_zeilennummern():
"""Stufe 4 (Lektion 83): pdftotext streut Listing-Zeilennummern in den Text,
das Reader-Zitat lässt sie weg — fuzzy mit harter Distanzschranke matcht trotzdem."""
text = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m)\n 1 for i=1 to m do\n"
" 2 Ei = 0; Bi = ∅;\n 3 od\n 4 for j=1 to n do\n"
" 5 wähle i mit Ei minimal;\n 6 od\n")
zitat = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m) for i=1 to m do Ei = 0; "
"Bi = ∅; od for j=1 to n do wähle i mit Ei minimal; od")
span = textkit.finde_zitat(text, zitat)
assert span is not None
assert text[span[0]:span[1]].startswith("Algorithmus ListScheduling")
def test_finde_zitat_fuzzy_rekonstruierte_glyphe():
"""PDF verlor die ε-Glyphe („(A )"), der Reader zitiert „(Aε)" — 1 Zeichen
Differenz auf langem Zitat → Treffer."""
text = ("Man nennt eine solche Familie von Algorithmen (A ) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
zitat = ("Man nennt eine solche Familie von Algorithmen (Aε) ein vollständiges "
"polynomielles Approximationsschema (FPTAS).")
assert textkit.finde_zitat(text, zitat) is not None
def test_finde_zitat_fuzzy_mehrdeutig_bleibt_draussen():
"""Eindeutigkeits-Guard: zwei fast identische Definitionen (SubSetSum/Partition-
Muster) → kein Anker statt Falsch-Anker."""
a = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl K. Entscheide: Gibt es eine Teilmenge S?"
b = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl X. Entscheide: Gibt es eine Teilmenge T?"
zitat = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl Q. Entscheide: Gibt es eine Teilmenge U?"
assert textkit.finde_zitat(a + "\n\nDazwischen steht anderer Text.\n\n" + b, zitat) is None
def test_finde_zitat_fuzzy_grenzen():
"""Paraphrase über der Distanzschranke bleibt draußen; Kurz-Zitate nie fuzzy."""
text = "Der List-Scheduling-Algorithmus verteilt Jobs der Reihe nach auf die am wenigsten belastete Maschine."
assert textkit.finde_zitat(text, "List Scheduling weist jeden Job der aktuell "
"günstigsten Maschine in Reihenfolge zu und stoppt") is None
assert textkit.finde_zitat("kurzer Text über ähm Dinge", "kurzer Test über ähm Dinge") is None
def test_snapshot_schreiben_ftfy_mojibake():
"""ftfy am Import: Mojibake und Ligaturen werden repariert (Lektion 83)."""
import importlib.util
import pytest
if importlib.util.find_spec("ftfy") is None:
pytest.skip("ftfy nicht installiert — NFC-Fallback deckt test_snapshot_schreiben_normalisiert_nfc")
import korpus
pfad, _h = korpus._snapshot_schreiben("ftfy-test", "effiziente NP-vollständige Suffixe")
inhalt = open(pfad, encoding="utf-8").read()
assert "vollständige" in inhalt # Mojibake ä → ä
assert "Suffixe" in inhalt # Ligatur ffi → ffi
def test_abschnitte_und_ueberlappung():
text = ("Absatz eins.\n\n" * 50) + ("Absatz zwei.\n\n" * 50)
teile = textkit.abschnitte(text, max_chars=300)
assert all(len(t) <= 300 for _, t in teile)
assert "".join(t for _, t in teile) == text
assert textkit.ueberlappung((0, 10), (5, 15)) == 0.5
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0
def test_negations_guard():
assert textkit.negations_menge("L ist nicht regulär") != textkit.negations_menge("L ist regulär")
def test_jsonx_fences_und_prosa():
assert jsonx.parse('```json\n{"a": 1}\n```') == {"a": 1}
assert jsonx.parse('Hier das Ergebnis: [{"b": "x — y"}] Danke!') == [{"b": "x — y"}]
assert jsonx.parse("kein json") is None
assert jsonx.parse('{"s": "mit \\"quote\\" und }"}') == {"s": 'mit "quote" und }'}
async def test_auto_loop_bedingungen():
note, grund = await auto_loop.auto_repair_loop("t", 10.0, None)
assert grund == "fertig"
async def stillstand():
return 8.0, False
note, grund = await auto_loop.auto_repair_loop("t", 8.0, stillstand)
assert grund == "stillstand"
zaehler = {"n": 0}
async def livelock():
zaehler["n"] += 1
return 8.0, True # bewegt, aber nie besser → hartes Limit greift
note, grund = await auto_loop.auto_repair_loop("t", 7.0, livelock, max_iter=4)
assert grund == "limit" and zaehler["n"] == 4
def test_qa_note_formel():
assert qa.note([], 10) == 10.0
n = qa.note([{"art": "marker_fehlend", "item": "1", "detail": ""}], 10)
assert n < 10.0
viele = [{"art": "atom_ohne_anker", "item": str(i), "detail": ""} for i in range(50)]
assert 0.0 <= qa.note(viele, 10) <= 9.9
def test_det_auftraege_blockquote_und_artefakt():
import db
import guide
from conftest import topic_anlegen
topic = topic_anlegen("detcheck")
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
status="neu", baustein_id=b_id, braucht=db.j([]))
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang)
text = " ".join(auftraege)
assert "Blockquote" in text and "6=" in text
assert "ungerade Anzahl $-Zeichen" in text

View File

@@ -0,0 +1,54 @@
"""Budget-Stopp (hart) und Infra-Fail-closed (Lauf-Pause statt Teilergebnis)."""
import asyncio
import agents
import ledger
import llm
import pytest
from conftest import run_anlegen, topic_anlegen
async def test_budget_stoppt_hart(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic, budget=120) # Fake-Call kostet 150 Tokens
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
with pytest.raises(ledger.BudgetErschoepft):
await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert ledger.verbraucht(run) >= 120 # der Verbrauch bleibt sichtbar
async def test_infra_pause_statt_fail_open(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
async def immer_429(key, prompt, timeout, **kw):
return agents.AgentErgebnis(1, "", "HTTP 429: rate limited")
monkeypatch.setattr(agents, "run_agent", immer_429)
monkeypatch.setattr(llm, "INFRA_BACKOFF_BASE", 0.01)
with pytest.raises(llm.LaufPause):
await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
stati = [e["status"] for e in
__import__("db").query("SELECT status FROM events WHERE run_id=?", (run,))]
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
topic = topic_anlegen()
run = run_anlegen(topic)
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "test"
async def unsinn(key, prompt, timeout, **kw):
return agents.AgentErgebnis(0, "kein json", "")
monkeypatch.setattr(agents, "run_agent", unsinn)
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause

167
tests/test_e2e.py Normal file
View File

@@ -0,0 +1,167 @@
"""Fake-E2E: kompletter Generierungspfad in Sekunden. Thema (Web-Recherche) und
Uni (Dateien), plus Resume-Idempotenz."""
import asyncio
import itertools
import db
import guide
import korpus
import pipeline
from conftest import topic_anlegen
from fake_agents import FAKE_TEXT
async def _lauf_komplett(topic):
run_id = pipeline.lauf_starten(topic)
await pipeline._laeufe[topic]
return run_id
def _pruefe_endzustand(topic, run_id):
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
assert run["status"] == "done"
offen = db.query("SELECT * FROM befunde WHERE run_id=? AND status='offen'", (run_id,))
assert offen == [], f"offene Befunde: {offen}"
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
" ('gemerged','verworfen')", (topic,))
assert len(atome) == 3 # die Fake-Welt hat exakt drei Atome
assert all(a["soll_id"] and a["ziel_id"] and a["baustein_id"] for a in atome)
for a in atome:
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
karten = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='flashcard'"
" AND status='verifiziert'", (a["id"],))
assert karten
md = guide.guide_markdown(topic)
for a in atome:
assert f"<!-- atom: {a['id']} |" in md
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
assert "\n## " in "\n" + md and "### " in md
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
assert kaps, "keine Kapitel gebildet"
for k in kaps:
assert k["intro"], f"Kapitel-Intro fehlt: {k['titel']}"
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert all(b["kapitel_id"] for b in bausteine)
folge = [b["kapitel_id"] for b in bausteine]
segmente = [k for k, _ in itertools.groupby(folge)]
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
assert events and all(e["template_hash"] for e in events if e["template"])
async def test_e2e_thema():
topic = topic_anlegen("fake-thema", art="thema")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
assert len(bausteine) == 1
async def test_e2e_uni(tmp_path):
ordner = korpus.TOPICS_DIR / "fake-uni"
ordner.mkdir(parents=True)
(ordner / "skript.txt").write_text(FAKE_TEXT, encoding="utf-8")
(ordner / "uebung.txt").write_text(FAKE_TEXT + "\nSerie 1.\n", encoding="utf-8")
topic = topic_anlegen("fake-uni", art="uni")
run_id = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run_id)
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (topic,))
assert len(quellen) == 2 and all(q["art"] == "datei" for q in quellen)
rollen = {q["titel"]: q["rolle"] for q in quellen}
assert rollen["skript.txt"] == "stoff" and rollen["uebung.txt"] == "aufgaben"
async def test_voll_reset_extrahiert_neu():
topic = topic_anlegen("fake-vollreset", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
pipeline.voll_reset(topic)
assert db.query("SELECT * FROM atome WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM artefakte WHERE atom_id IN"
" (SELECT id FROM atome WHERE topic=?)", (topic,)) == []
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2) # kompletter Neuaufbau inkl. neuer Atome
async def test_soll_reset_kein_doppel():
topic = topic_anlegen("fake-reset", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
pipeline.soll_reset(topic)
assert db.query("SELECT * FROM soll WHERE topic=?", (topic,)) == []
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # Extraktions-Guard: keine Doppel-Atome
async def test_auto_stopp_und_fortsetzen():
topic = topic_anlegen("fake-auto", art="thema")
db.update("topics", "name", topic, auto=db.j({"inventar": False}))
run1 = await _lauf_komplett(topic)
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "inventar_fertig"
r = db.one("SELECT * FROM runs WHERE id=?", (run1,))
assert r["status"] == "done" and "Auto-Stopp" in r["grund"]
db.update("topics", "name", topic, auto=db.j({})) # Auto wieder an → durchlaufen
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
async def test_ebenen_entfernen_kaskade():
topic = topic_anlegen("fake-entfernen", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
pipeline.ebenen_entfernen(topic, "struktur")
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
assert set(resets) == {"struktur", "guide"}
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
run2 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run2)
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # Atome blieben erhalten
async def test_topic_loeschen():
topic = topic_anlegen("fake-weg", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
pipeline.topic_loeschen(topic)
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
"kapitel", "runs"):
feld = "name" if tabelle == "topics" else "topic"
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle
async def test_resume_idempotent():
topic = topic_anlegen("fake-resume", art="thema")
run1 = await _lauf_komplett(topic)
_pruefe_endzustand(topic, run1)
atome_vorher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
calls_vorher = len(db.query("SELECT id FROM events", ()))
run2 = pipeline.lauf_starten(topic) # zweiter Lauf auf fertigem Topic
await pipeline._laeufe[topic]
assert db.one("SELECT status FROM runs WHERE id=?", (run2,))["status"] == "done"
atome_nachher = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
assert atome_nachher == atome_vorher # nichts dupliziert
assert len(db.query("SELECT id FROM events", ())) == calls_vorher # keine neuen LLM-Calls

94
tests/test_inventar.py Normal file
View File

@@ -0,0 +1,94 @@
"""Anker-Repair: unverankerte Zitate (start=-1) werden erst deterministisch
neu gematcht — ohne LLM-Call."""
import db
import inventar
import llm
from conftest import run_anlegen, topic_anlegen
def test_merge_kollision_und_kette():
topic = topic_anlegen("mergekante")
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
status="neu", braucht=db.j([]))
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
status="neu", braucht=db.j([]))
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
status="neu", braucht=db.j([]))
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, c))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, c))
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, a))
inventar._merge(topic, a, b)
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
assert len(kanten) == 1 and kanten[0]["von_atom"] == a and kanten[0]["zu_atom"] == c
# Merge-Kette: (B, C) — B ist schon gemerged, Wurzel A übernimmt
inventar._merge(topic, b, c)
assert db.one("SELECT merged_into FROM atome WHERE id=?", (c,))["merged_into"] == a
async def test_anker_rematch_ohne_llm(tmp_path):
topic = topic_anlegen("rematch")
run = run_anlegen(topic)
snap = tmp_path / "q.md"
snap.write_text("Vorher. Der Satz steht hier drin. Nachher.", encoding="utf-8")
q = db.insert("quellen", topic=topic, art="datei", titel="q",
snapshot=str(snap), hash="h", status="atome")
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
status="ohne_anker", braucht=db.j([]))
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
zitat="Der Satz steht hier drin.")
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
assert await inventar._anker_fixen_batch(ctx, [a]) is True
row = db.one("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a,))
assert row is not None
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "neu"
assert db.query("SELECT id FROM events WHERE run_id=?", (run,)) == [] # kein LLM-Call
assert inventar.messen(ctx) == [] or all(
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
def test_titel_kern_faltet_schreibvarianten():
import textkit
assert textkit.titel_kern("ΔTSP2 TourLänge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
assert textkit.titel_kern("FeedbackVertexSet ∈ NP") == textkit.titel_kern("Feedback Vertex Set ∈ NP")
assert textkit.titel_kern("!!!") == ""
def test_fallback_nutzt_jaccard_schwelle(monkeypatch):
import embedding
a = "Polynomialzeitreduktionen sind transitiv wenn L1 auf L2 und L2 auf L3 reduzierbar sind"
b = ("Polynomialzeitreduktionen sind transitiv wenn eine Sprache L1 auf L2"
" und L2 auf L3 in Polynomialzeit reduzierbar ist")
# Kosinus-Schwelle allein: Paraphrase rutscht durch (aak Lauf 8)
assert embedding.kandidaten_paare([a, b], 0.75) == []
paare = embedding.kandidaten_paare([a, b], 0.75, 0.3)
assert [(i, j) for i, j, _ in paare] == [(0, 1)]
async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
topic = topic_anlegen("titeldup")
run = run_anlegen(topic)
# identischer Titel, Definitionen lexikalisch fern (Jaccard < 0.3):
# nur der Titel-Kern-Generator bringt das Paar ans Panel
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
" der Größe höchstens k enthält.",
status="neu", braucht=db.j([]))
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
" abdeckt und maximal k Elemente hat.",
status="neu", braucht=db.j([]))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "inventar"
await inventar._judge_dedup(ctx)
stati = {r["id"]: r["status"] for r in db.query(
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
assert sorted(stati.values()) == ["gemerged", "neu"]

105
tests/test_korpus.py Normal file
View File

@@ -0,0 +1,105 @@
"""Ebene 0, Konsens: Vollständigkeits-Invariante (kein Kandidat verschwindet
still), explizite Ablehnung, uni-Beleg-Regel (1 Beleg reicht)."""
import re
import db
import fake_agents
import korpus
import llm
from conftest import run_anlegen, topic_anlegen
def _seed(art="uni", n_quellen=1):
topic = topic_anlegen("k-test", art=art)
quellen = [db.insert("quellen", topic=topic, art="datei", titel=f"q{i}.txt",
snapshot=f"q{i}.txt", hash=f"h{i}",
status="extrahiert", rolle="stoff")
for i in range(n_quellen)]
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
ctx.ebene = "korpus"
return topic, quellen, ctx
def _kandidat(topic, punkt, quelle):
return db.insert("soll", topic=topic, punkt=punkt, status="kandidat",
belege=db.j([{"quelle": quelle, "zitat": f"Zitat {punkt}"}]))
def _ids(prompt):
return [int(i) for i in re.findall(r"^(\d+): ", prompt.split("KANDIDATEN:")[1],
re.MULTILINE)]
async def test_nachrunde_ordnet_vergessene_zu(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
b = _kandidat(topic, "Beta", q)
c = _kandidat(topic, "Gamma", q)
def judge(prompt):
if "NACHRUNDE" in prompt: # nur die fehlende id wird angeboten
assert _ids(prompt) == [c] and "Alpha" in prompt
return [{"punkt": "Alpha", "kandidaten": [c]}] # wörtlich → Merge
return [{"punkt": "Alpha", "kandidaten": [a]},
{"punkt": "Beta", "kandidaten": [b]}] # c stillschweigend vergessen
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 2 # uni: 1 Quelle reicht als Beleg
alpha = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
" AND punkt='Alpha'", (topic,))
assert len(db.uj(alpha["belege"])) == 2 # Gamma-Beleg in Alpha aufgegangen
for i in (a, b, c):
assert db.one("SELECT status FROM soll WHERE id=?", (i,))["status"] == "gefaltet"
assert korpus.messen(ctx) == []
async def test_ablehnung_ist_explizit(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
m = _kandidat(topic, "Einführung ins Thema", q)
def judge(prompt):
return [{"punkt": "Alpha", "kandidaten": [a]},
{"abgelehnt": True, "grund": "Meta-Überschrift", "kandidaten": [m]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 1
assert db.one("SELECT status FROM soll WHERE id=?", (m,))["status"] == "abgelehnt"
assert korpus.messen(ctx) == [] # abgelehnt ist erledigt, kein Befund
async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
topic, (q,), ctx = _seed()
a = _kandidat(topic, "Alpha", q)
c = _kandidat(topic, "Gamma", q)
def judge(prompt):
if "NACHRUNDE" in prompt:
return [] # Judge verweigert die Zuordnung dauerhaft
return [{"punkt": "Alpha", "kandidaten": [a]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
await korpus._konsens(ctx)
assert db.one("SELECT status FROM soll WHERE id=?", (c,))["status"] == "kandidat"
befunde = korpus.messen(ctx)
assert [b["art"] for b in befunde] == ["soll_kandidat_offen"]
assert befunde[0]["item"] == str(c)
async def test_thema_braucht_zwei_quellen(monkeypatch):
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
x1 = _kandidat(topic, "X", q1)
x2 = _kandidat(topic, "X (Synonym)", q2)
y = _kandidat(topic, "Y", q1)
def judge(prompt):
if "NACHRUNDE" in prompt:
return []
return [{"punkt": "X", "kandidaten": [x1, x2]},
{"punkt": "Y", "kandidaten": [y]}]
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt
assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"}

127
tests/test_struktur.py Normal file
View File

@@ -0,0 +1,127 @@
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
import db
import llm
import struktur
from conftest import run_anlegen, topic_anlegen
def _atom(topic, titel, ziel_id=None, soll_id=1):
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
definition=f"Definition {titel}", status="neu",
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
def test_topo_ordnung():
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
def test_topo_fremde_kanten_bleiben_draussen():
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
assert out == [1, 2]
def test_zyklus_finden():
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
async def test_zyklen_brechen_und_schneiden():
topic = topic_anlegen()
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
a = _atom(topic, "A", ziel, soll)
b = _atom(topic, "B", ziel, soll)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b))
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, b, a))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
await struktur._zyklen_brechen(ctx)
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
(topic,))
assert len(aktiv) == 1 # genau eine Kante gebrochen
struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert all(x["baustein_id"] for x in atome)
async def test_band_split():
topic = topic_anlegen("band")
run = run_anlegen(topic)
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
_atom(topic, f"A{i}", ziel, soll)
ctx = llm.Kontext(run, topic, "minimax")
struktur._bausteine_schneiden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert len(bausteine) >= 3
for bs in bausteine:
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
assert 4 <= n <= 8
befunde = struktur.messen(ctx)
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
async def test_merge_ohne_soll_schranke_und_kapitel():
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
# Kapitel entstehen danach als kontiguierliche Segmente
topic = topic_anlegen("kapitel")
run = run_anlegen(topic)
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
b0 = _atom(topic, "B0", z2, s2)
for i in (1, 2, 3):
_atom(topic, f"B{i}", z2, s2)
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
(topic, a, b0))
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
struktur._bausteine_schneiden(ctx)
atome = struktur._atome(topic)
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
await struktur._kapitel_bilden(ctx)
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
assert bausteine and all(x["kapitel_id"] for x in bausteine)
assert struktur.messen(ctx) == []
async def test_kapitel_retry_und_fallback(monkeypatch):
import fake_agents
topic = topic_anlegen("kapfall")
run = run_anlegen(topic)
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
b = [db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{i}", ord=i,
status="neu") for i in range(4)]
ctx = llm.Kontext(run, topic, "minimax")
ctx.ebene = "struktur"
aufrufe = {"n": 0}
def judge(prompt):
aufrufe["n"] += 1
if aufrufe["n"] == 1:
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
await struktur._kapitel_bilden(ctx)
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
lambda p: [{"titel": "x", "bis": 999999}])
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
assert kaps and all(k["art"] == "fallback" for k in kaps)
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}