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

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Pflicht
MINIMAX_API_KEY=
KONTAKT_MAIL= # für ehrliche User-Agents (Wikipedia/Fetch)
# Optionale Such-Backends (mit Key vor ddgs in der Kette)
TAVILY_API_KEY=
BRAVE_API_KEY=
SEARXNG_URL=
# Overrides (leer = Default aus backend/config.py)
RUN_BUDGET_TOKENS= # Default 10000000, 0 = aus
MAX_PARALLEL_LLM= # Default 12
HEDGE_NACH_S= # Default 90, 0 = aus
SOLL_PUNKTE_MIN= # Default 5 (kleine Themen: runtersetzen)
RECHERCHE_RUNDEN_MAX= # Default 4
LLM_LOG= # 1 = Prompt+Antwort je Call in DB-Tabelle call_texte
CREATOR_FAKE= # 1 = alle LLM/Suche/Laden deterministisch gefakt
CREATOR_DB= # DB-Pfad-Override (Smoke-Läufe)

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
node_modules/
storage/
.env
frontend/dist/
.pytest_cache/

210
DESIGN.md Normal file
View File

@@ -0,0 +1,210 @@
# Plan: creator3 — Themen-Guide-Generator (Neubau unter /home/arbeit/projects/creator3)
STATUS: FINAL. Basis: 10 Recherche-Agenten (Befunde R1-R11 im Anhang) + 1 Design-Agent.
## Kontext
creator3 ersetzt creator2 für den Themen-Modus: Guides zu Themen (Symfony, Kommunikation, Psychologie) aus Web-Recherche. Kernziel: Abdeckung nahe 100 % (Lücken minimieren), dann Länge minimieren (Dopplungen minimieren). Nur MiniMax-API als LLM. Neu ggü. creator2: Pipeline als konfigurierbarer Graph (pipeline.yaml), Agenten bekommen Skill-Dateien (Rolle/Aufgabe/Stil), Graphensicht statt Kanban, Gates ÜBERALL hart, Budget default AN. uni-Modus, Artefakte (Flashcards/Üben) und Level-Durchgänge entfallen. creator2 nur Lesereferenz. NIEMALS git commit.
## Stack
Python 3.12 + FastAPI + SQLite (WAL, EINE Writer-Connection) + Vue 3/Vite. LLM: MiniMax-M3 via `https://api.minimax.io/anthropic/v1/messages` (thinking IMMER explizit: disabled für Extraktion/Judge, adaptive für Writer). Suche: Wikipedia de+en immer + Kette Tavily→Brave→ddgs (Circuit Breaker). Extraktion: trafilatura ≥2.1 markdown + ftfy NFC (einmalig vor Snapshot). Frontend: markdown-it + Shiki (statt marked/hljs), Graph als Eigenbau (HTML-Knoten + SVG-Kanten + @panzoom/panzoom).
## Pipeline-Graph (24 Knoten, 5 Stufen; Stufe = Spalte in Graphensicht)
Stufe 1 korpus: recherche_plan (llm) → suche (system) → laden (system) → soll_extraktion (llm, 40k-Chunks) → soll_konsens (llm, Barriere; Judge gruppiert, CODE zählt ≥2 Quellen) → gate_korpus.
Stufe 2 inventar: atom_extraktion (llm, 6k-Abschnitte × 2 Reader) → anker_fix (llm; erst det. 5-Stufen-Re-Match) → dedup (llm, Barriere; Auto-Merge Anker-Overlap ≥0.5 + Kandidaten-Kanäle + 2er-Panel einstimmig) → soll_zuordnung (llm) → luecke (llm; art nachextraktion|freispruch) → gate_inventar (Coverage: jeder bestätigte Soll-Punkt ≥1 Atom oder Freispruch — deterministisch).
Stufe 3 struktur: lernziele → bausteine (Band 4-8) → ordnung (Barriere; themen_schnitt + permutation, exakt validiert, Prior-Fallback) → gate_struktur (Coverage: jeder Soll-Punkt ≥1 Baustein).
Stufe 4 guide: writer (DELIMITED ===TEXT===, „bekannt"-Kontext, Verweis statt Wiederholung) + kapitel_intro → det_pruefung (system) → llm_pruefung (2er-Panel, qa_hash fail-closed) → fix (Cap 3) → redundanz (Barriere; Satzpaar-Jaccard ≥0.6 über Kapitelgrenzen → 2er-Panel) → gate_guide (3er-Klärung für Eskalationen).
Stufe 5: montage (system; Marker-Einmaligkeit, Anker-Links, topic=fertig).
Rücklauf-Kanten (nur von Gates, Task-Rezepte mit Runde+1 im Item-Schlüssel): gate_korpus→recherche_plan (neue Lenses, Cap 4); gate_inventar→suche (Web-Nachrecherche für Lücken, Queries deterministisch aus Punkt+Zitat gegroundet, Cap 1). Reparatur-Kanten: gate_korpus→soll_konsens; gate_inventar→anker_fix|luecke; gate_struktur→bausteine; gate_guide→fix|det_pruefung|redundanz.
Gate-Schutz dreifach: max_versuche je Task, GATE_RUNDEN_MAX je Gate, Stillstand-Fingerprint (SHA-256 sortierte Befundmenge, 2× identisch → runs.status='paused', fail-closed).
## Backend-Dateiplan (~4700 Zeilen, deutsche Namen)
- config.py (160): Konstanten + .env-Loader (Datei gewinnt), Topic-Namens-Regex.
- db.py (260): WAL, eine Writer-Connection, busy_timeout 5000, on_change-Hook, Schema.
- engine.py (420): Scheduler auf asyncio.Event-Wecker + 2-s-Poll; Claim UPDATE…WHERE status='offen' (rowcount==1); Zombie-Reset beim Start; Barriere-Logik; Gate-Trigger bei leerer Stufe; Ergebnis+fertig+Folgetasks in EINER Transaktion; Infra-Retries getrennt von versuch; Budget-/Pause-Prüfung.
- graph.py (140): pipeline.yaml laden/validieren (graphlib-DAG, Worker-Registry, Skills existieren, 1 Gate/Stufe) + Layout-Export.
- pipeline.yaml (120): Knoten (id, typ, worker, skills[], stufe, max_parallel, max_versuche, barriere) + Kanten (von, nach, art normal|reparatur|ruecklauf).
- llm.py (280): call()-Zustandsmaschine ok|parse|cap|infra|error|pause (cap = KEIN Retry); Backoff 8/16/32 + Jitter → LaufPause; globale Drossel (429 + Statuscodes 1002/1041/2045, Breite halbieren min 4, 20 Erfolge = +1); Hedge (max(90, timeout/2), Verlierer verbuchen); Budget VOR+NACH; Fake-Hook vor allem außer Pause-Check.
- skills.py (120): Frontmatter, Komposition 1 Rolle + 1 Aufgabe + 0..n Stil (Format nur+zuletzt in Aufgabe), {{platzhalter}} per str.replace mit Vorab-Validierung, zweistufiges Hashing → skill_hash ins Ledger.
- ledger.py (100): events im finally JEDES Versuchs (inkl. hedge/hedge_cancel), budget_pruefen, Kennzahlen (Coverage, Dopplungsquote, Kosten je Knoten).
- panels.py (80): einstimmig (None = keine Stimme), panel mit Ersatz-Richter, klaerung (3er-Mehrheit).
- textkit.py (380): norm, finde_zitat (5 Stufen mit Negations-/Eindeutigkeits-Guards), titel_kern, abschnitte, ueberlappung, negations_menge; NEU satz_split, jaccard_gestemmt (PyStemmer), akronym_kanal.
- suche.py (220): Wikipedia REST+Action-API (UA mit KONTAKT_MAIL, max 3 parallel) + Tavily/Brave/ddgs-Kette, Timeout 10 s/1 Retry, Circuit Breaker, URL-Dedupe; degraded-Befund statt Crash.
- laden.py (200): httpx (Bytes! → trafilatura markdown → ftfy NFC → Snapshot via Temp+os.replace), Roh-HTML archivieren, robots.txt-Cache, 1 req/s/Domain, JS-/PDF-Detektor → Befund.
- korpus.py (280), inventar.py (420), dedup.py (220), struktur.py (280), guide.py (380), redundanz.py (110), montage.py (90), belege.py (60): Stufen-Worker wie im Graph.
- fakes.py (320): Handler-Registry über `<!-- aufgabe:name -->`-Marker, harter Fehler ohne Handler; Fakes LESEN Prompt (Zitat-Gate mitgetestet); Fake-Suche/-Laden mit präparierten Quellen (Dublette inkl. Akronym, Lücke die erst Nachrecherche deckt, Kapitel-übergreifende Dopplung).
- ws.py (60): Hub eine Queue + ein Sender-Task; dirty-Signal {typ, topic, bereich}.
- main.py (260): Lifespan (kein on_event), Routen, ensure_future + _laeufe-Dict.
## DB-Schema
topics(name PK, titel, status neu→…→fertig, auto, erstellt); runs(id, topic, status running|done|paused|stopped|budget|failed, stufe, budget_tokens, grund, git_hash); tasks(id, run_id, topic, knoten, item, status offen|laufend|fertig|fehler, art, versuch, infra_versuch, runde, payload, ergebnis, fehler, erzeugt_von, gestartet, beendet, UNIQUE(topic,knoten,item)); gate_laeufe(run_id, knoten, runde, status gruen|rot|pause, fingerprint, befunde, UNIQUE(run_id,knoten,runde)); events(run_id, ts, stufe, knoten, item, skills, skill_hash, model, role, status, dur_ms, wait_ms, tok_in/out/cache_read/cache_write, meta); quellen(id, topic, titel, url, url_norm UNIQUE(topic,url_norm), backend, snapshot, roh, hash, runde, zweck korpus|luecke:<id>, status, grund, atome_stand); soll(id, topic, punkt, status, belege JSON, kapitel_id, geprueft, freispruch); atome(id, topic, titel, typ, definition, status, soll_id, ziel_id, baustein_id, ord, merged_into); anker(atom_id, quelle_id, start, ende, zitat; start=-1 unverankert); kanten(von_atom, zu_atom, art='verwandt', UNIQUE); lernziele; bausteine(ziel_id, titel, ord, kapitel_id); kapitel(titel, intro, ord); sections(baustein_id PK, stage, text, qa_hash, fix_versuche) — EIN Text, kein kompakt/lang-Dual; befunde(run_id, stufe, knoten, art, item, detail, status offen|behoben|freispruch|veraltet, quelle, runde, geklaert).
## Skills (Frontmatter: name, art, beschreibung, [platzhalter])
- rolle/: extraktor, richter, autor, planer (je mit konkreten Verhaltensregeln, kein Rollen-Theater).
- stil/: deutsch-praezise, guide-lesbarkeit (R10-Regeln inkl. „Ein-Satz-Refresher + Anker-Link statt Wiederholung").
- aufgabe/ (20): recherche-plan, soll-extraktion, soll-konsens, soll-beleg, atom-extraktion, anker-fix, dedup-urteil, soll-zuordnung, luecke-nachextraktion, luecke-freispruch, lernziele, bausteine, themen-schnitt, ordnung, writer, kapitel-intro, guide-pruefung, guide-fix, redundanz-urteil, klaerung. Alle DELIMITED-Ausgabe (===BLOCK===), zusammengesetzt ≤ ~1000 Tokens, ≤ 8-10 Regeln; Rolle+Stil als stabiler Cache-Präfix.
## API + Frontend
Routen: GET/POST/DELETE /api/topics; /start {budget?}, /pause, /weiter, /stop; GET /graph (Struktur + Zähler-Snapshot + Gate-Status — KEIN Monolith); GET /knoten/{id}/tasks (lazy); GET /guide (Kapitel-Struktur); GET /kennzahlen (Coverage %, Dopplungsquote, Kosten je Knoten, Gate-Runden); GET /befunde; WS /ws (dirty-Signale, 250 ms koalesziert).
Frontend: App.vue (Tabs Graph/Guide/Kennzahlen, Drawer mobil), Graph.vue (Eigenbau nach R7), KnotenPanel.vue (Task-Liste + Befunde), Guide.vue (nach R10: 68ch, content-visibility, TOC + Scroll-Spy, print-CSS), Kennzahlen.vue, store.js, markdown.js (markdown-it html:false + anchor + DOMPurify + Shiki dual-theme, Sprachen php/twig/yaml/bash/sql/json/xml, KEIN KaTeX), api.js, style.css (Custom-Properties, dark default, Breakpoint 768px).
Deps: vue ^3.5, vite ^8, markdown-it ^14.3, markdown-it-anchor, shiki ^4.3 + @shikijs/markdown-it, dompurify ^3.4, @panzoom/panzoom, @fontsource-variable/inter.
## Konfiguration
.env: MINIMAX_API_KEY (aus creator2/.env kopieren), TAVILY_API_KEY?, BRAVE_API_KEY?, SEARXNG_URL?, KONTAKT_MAIL, RUN_BUDGET_TOKENS, MAX_PARALLEL_LLM, CREATOR_FAKE, CREATOR_DB.
config.py-Kernwerte: ROLLEN alle MiniMax-M3 (extraktion/judge thinking=disabled temp 0.2/0.1; writer adaptive 0.7; max_tokens 32000); MAX_PARALLEL_LLM 12; INFRA 3×, Backoff 8/16/32+Jitter; DROSSEL_MIN_BREITE 4; HEDGE_NACH_S 90; RUN_BUDGET_TOKENS 10_000_000 (AN, Worst Case ~$24); RECHERCHE_LENSES generisch (ueberblick/tutorial/referenz/praxis); RECHERCHE_RUNDEN_MAX 4; SOLL_MIN_BELEGE 2; ABSCHNITT_CHARS 6000; READER 2; ANKER_OVERLAP 0.5; FUZZY 40/0.08; MERGE_PANEL 2; DEDUP_JACCARD 0.3; DEDUP_TITEL_FUZZY 85; PAARE/CALL 10; LUECKEN_RUNDEN 2; WEB_NACHRECHERCHE 1; BAUSTEIN 4-8; SECTION_WOERTER (40,220); PRUEF_PANEL 2; KLAERUNG 3; FIX_CAP 3; REDUNDANZ_JACCARD 0.6; STILLSTAND_N 2; GATE_RUNDEN_MAX je Gate.
Python-Deps: fastapi, uvicorn, httpx, pyyaml, trafilatura, ftfy, ddgs, fuzzysearch, rapidfuzz, PyStemmer, pytest, pytest-asyncio.
## Baureihenfolge (Meilensteine mit „fertig wenn")
- M1 Gerüst: config, db, skills, graph, engine + Mini-Testgraph. Fertig: test_engine/test_graph/test_skills grün + Mini-Fake-E2E inkl. Resume=0-Events und Stillstand→Pause.
- M2 LLM-Schicht: llm, ledger, panels, Fake-Hook. Fertig: test_llm grün (cap-kein-Retry, Drossel, Hedge-Verbuchung, Budget-Vorab-Stopp).
- M3 Suche+Laden: suche, laden, textkit. Fertig: Tests grün + EIN manueller Echt-Fetch (Wikipedia + Doku-Seite) mit intakten Code-Fences.
- M4 Korpus: bis gate_korpus im Fake grün (Zitat-Fehler blockiert Gate, Rücklauf mit neuen Lenses).
- M5 Inventar: bis gate_inventar grün (Dublette inkl. Akronym gemerged, Lücke via Web-Rücklauf geschlossen, Coverage 100 %).
- M6 Struktur+Guide+Montage: test_e2e komplett grün (Dopplung gefunden+gefixt, Marker einmalig, Resume 0 Events, Pause+Neustart ohne Duplikate).
- M7 API+Frontend: Fake-Lauf im Browser live verfolgbar; Guide mobil+Desktop lesbar.
- M8 Echtlauf-Härtung: kleines Thema, Budget aktiv, ohne Eingriff fertig; Mini-Deutsch-Eval (5 Sections); Konstanten nachjustieren.
## Tests (tests/)
test_graph, test_engine (Claim atomar, Zombie-Reset, Barriere, atomare Folgetasks, Runde-im-Item), test_llm, test_skills, test_suche (Fallback, Circuit Breaker, degraded), test_laden (Normalisierung einmalig, robots, Roh-Archiv), test_dedup (Akronym-Kanal!, Subset-100-Falle, verwandt-Kante = 0 neue Calls), test_redundanz (gepflanzte Dopplung → Befund; selbes Kapitel → kein Befund), test_gates (Gate blockiert hart, Stillstand→Pause, Coverage-Formel), test_e2e.
## Risiken
1. MiniMax-Deutsch unbelegt → M8-Eval, Stil-Skills, Prüfer-Panel.
2. trafilatura-Code-Fences → Roh-HTML-Archiv, M3-Checkpunkt.
3. Harte Gates → mehr Pausen: gewollt; Ventile = Freispruch-Panels, 3er-Klärung, Stillstand-Fingerprint.
4. Web-Nachrecherche bläht Kosten → Cap 1 Runde, gegroundete Queries, Budget-Vorab-Check.
5. Eigenbau-Engine-Races → eine Writer-Connection, atomare Transaktionen, M1 testet zuerst; Notbremse: mehr Barrieren.
---
# ANHANG: Recherche-Befunde (R1-R11)
## Auftrag (Nutzer)
- Neubau creator3 unter /home/arbeit/projects/creator3, creator2 NUR Lesereferenz (kein Code übernehmen).
- Nur Themen-Modus (Websuche als Quelle); uni-Variante entfällt. Keine Artefakte (Flashcards/Fragen/Üben).
- LLM: NUR MiniMax per API (Anthropic-kompatibler Endpoint), Claude komplett raus.
- Kernziel: Guideumfang maximieren (Abdeckung nahe 100 %, Lücken minimieren), dann Guidelänge minimieren (Dopplungen minimieren). Guide gut lesbar, responsiv. Deutsch.
- Nutzer-Vision: Pipeline-System als Graph; Knoten haben genau EINE Aufgabe (erstellen, filtern, zusammenführen, prüfen …); Agenten bekommen Skill-Dateien (Rolle/Aufgabe/Stil) beim Start; Frontend = Graphensicht (Knoten mit Task-Zählern, Kanten) statt Kanban.
- Annahmen bestätigt: Suche „etwas Zuverlässiges" = Multi-Backend mit Fallback; Guides Deutsch; Token-Budget diesmal default AN; kein Eingreifen (Pause nur bei echten Fehlern); MiniMax-Key aus creator2/.env übernehmen; Stack Python+SQLite+Vue.
- Lektionen aus creator2-Analyse: Gates diesmal ÜBERALL hart (creator2 gated nur Korpus), Budget default an.
## Recherche-Befunde (kondensiert)
### R1: creator2 Frontend/API (fertig)
- Lauf-Start: `asyncio.ensure_future` + `_laeufe`-Dict gegen Doppelstart (pipeline.py:43-52); kein Task-Framework. Übernehmen.
- WS-Muster übernehmen: Hub mit EINER Queue + EIN Sender-Task (Ordering!), `call_soon_threadsafe` aus DB-Schicht (ws.py:20-44). Frontend: Delta = nur Ping → debounced (400 ms) Full-State-Reload + Reconnect mit Cleanup-Flag + `{typ:'verbunden'}`-Resync + 5-s-Poll nur bei laufendem Run (App.vue:59-62, api.js:54-74).
- Schwächen vermeiden: State-Monolith (alles bei jedem Reload) → creator3: kompakter Zähler-Snapshot, Task-Liste je Knoten lazy per Klick; WS-Payload tote Fracht → leeres dirty-Signal mit Topic-Filter; Rechtsklick-Menü mobil unbrauchbar → Klick-Panel; `on_event("startup")` deprecated → Lifespan.
- markdown.js-Muster übernehmen: marked+marked-highlight+hljs, DOMPurify über End-HTML, Render-Cache (600 ms/Scroll-Falle), Kapitel-Virtualisierung (nur aktiv ±1 im DOM). KaTeX-Ausheben nur falls Formeln (creator3: vermutlich weglassen, Themen sind nicht mathelastig — Entscheidung im Plan).
- Lesetypografie übernehmen: max-width 680px, 19px/1.55, Inter Variable, hyphens auto, Tabellen overflow-x, CSS-Custom-Properties-Themes (dark default), EIN Breakpoint 768px mit Drawer.
- Deps: vue ^3.5, vite ^8, marked ^18, marked-highlight, highlight.js ^11, dompurify ^3, @fontsource-variable/inter. Keine Graph-Lib im Bestand.
- API-Formen als Vorlage: POST /api/topics {name}, /start {budget}, /pause, /state-Snapshot, /guide (Kapitel-Struktur statt Roh-MD — 0,43 MB gespart), /kennzahlen. encodeURIComponent überall + Server-Namensvalidierung.
### R2: Websuche-Backends (fertig)
- Empfehlung: **Wikipedia de+en immer parallel** (Grundstock, offiziell, quasi nie down) + **Fallback-Kette für Websuche**: Keyed-API (falls Key) → ddgs → weiter-ohne-Crash. KEIN Voll-Fan-out (verbrennt Quota ohne Mehrwert).
- Wikipedia: Suche `https://de.wikipedia.org/w/rest.php/v1/search/page?q=…&limit=10`; Extrakte via Action-API `prop=extracts&explaintext=1` (stabilste Variante, RESTBase-summary unsicher). Pflicht: aussagekräftiger User-Agent mit Kontakt; mit UA 200 req/min, max 3 parallel, Retry-After respektieren.
- ddgs ≥9.14 (Paket umbenannt von duckduckgo-search!): heute Metasearch-Aggregator (`backend="auto"`, Bing/Brave/DDG/Google/Mojeek…), `DDGS().text(q, region="de-de", max_results=10)`. Schwächen: Exceptions untypisiert (#478), auto-Modus kann Ergebnisse verlieren (#427). Grauzone (DDG-ToS) → Best-Effort-Schicht, 1-2 s Pause zwischen Queries.
- Brave API: Free-Tier abgeschafft (Feb 2026) → $5 Gratis-Guthaben/Monat ≈ 1000 Queries, Kreditkarte Pflicht, Überschreitung wird berechnet. Optional via BRAVE_API_KEY.
- Tavily: 1000 Credits/Monat OHNE Karte — bevorzugte Optional-Key-Alternative (TAVILY_API_KEY).
- Muster: Timeout ~10 s/Query, 1 Retry, dann nächstes Backend; Circuit Breaker (Backend n× tot → Rest des Laufs überspringen); URL-Dedupe normalisiert (Tracking-Parameter, Trailing Slash); Lauf scheitert nur, wenn ALLE Backends ausfallen, sonst degraded mit Befund.
- SearxNG: nur als optionales Self-Host-Backend (SEARXNG_URL), öffentliche Instanzen unzuverlässig.
### R3: creator2 LLM-Disziplin (fertig)
- `call()`-Zustandsmaschine übernehmen: Infra (3 Retries, Backoff 8/16/32 s → LaufPause fail-closed) vs. Inhalt (2 Restarts → None) getrennt; Status ok/parse/cap/infra/error/pause. `cap` (stop=max_tokens) = KEIN Retry (deterministisch, Aufrufer halbiert Chunk). 1-Element-Dict zählt als Liste (Modelle lassen Klammern weg).
- Globale 429-Drossel: ein 429 drosselt ALLE (Cooldown global, Breite halbieren min 4, 20 Erfolge = +1); retry-after respektiert (min 60 s). Start-Breite 28.
- Ledger: events-Schema übernehmen (run_id, ebene, stage, item, template, template_hash, model, role, status, dur_ms, wait_ms, tok_in/out/cache, meta); Log im finally JEDES Versuchs; Hedge-Verlierer als eigene Zeile (hedge/hedge_cancel). Budget: Prüfung VOR und nach jedem Call; creator3: Default AN.
- Panel-Primitive: `einstimmig` (volles Panel, None = keine Stimme = kein Entscheid), `panel` mit Ersatz-Richter bei genau einem Ausfall; Größen: Merge 2 einstimmig, Verify 2, Klärung 3 (Mehrheit).
- MiniMax `_text_api`: Endpoint api.minimax.io/anthropic/v1/messages, x-api-key + anthropic-version; max_tokens 32k; thinking DISABLED für Judges/Extraktion (sonst 8-32k Denk-Tokens, 44-min-Stalls), thinking AN nur für Writer; temperature 0.2-0.3; leere Antwort → Fehler MIT Tokens (stop_reason durchreichen für cap-Erkennung); httpx Timeout + wait_for; Rollen: judge/guide=MiniMax-M3, quick/fast=M2.7-highspeed (kalt-Route stallte 20 % der Judges → native Route für Judges).
- Hedge BEHALTEN (API-Stalls belegt): Zwilling nach max(90 s, timeout/2), erster valider gewinnt, Verlierer verbuchen, HEDGE_NACH_S=0 schaltbar.
- Entfällt ohne CLI: RAM-Gate, Token-Bucket, killpg/_spawn, _opencode/_claude_cli, _batch_sem, caps files/full, Provider-Stacks claude/lokal, „database is locked"-Marker.
- Bleibt: AgentErgebnis, Pause/Abbruch-Flags, _active, Fake-Hook VOR allem außer Pause-Check, Semaphor + getrennte wait_ms-Messung.
- LEKTIONEN-Triage: übernehmen 1-6, 10, 12-37, 40, 42-49, 52-83 + 4 Meta-Regeln; entfällt 7-9, 11, 38-39, 41, 51; Lektion 50 = Gründungsentscheidung.
- Konsequenz API-only: Recherche (caps=full) MUSS als eigener Nicht-LLM-Baustein gebaut werden (Suche+Download im Code, LLM destilliert nur).
### R4: creator2 Pipeline/QA (fertig)
- Orchestrierung übernehmen: EBENEN-Kette seriell, Status-Marker nie zurückdrehen, `_fertig_ab`-Skip, Resume = 2. Lauf auf fertigem Topic → 0 neue Calls (bewiesen in test_e2e). Runs-Status running/done/stopped/paused/budget/failed.
- QA: Note-Formel (10.0 nur bei 0 Befunden; Gewichte kritisch 3.0/mittel 1.5/stil 0.5, Basis = geprüfte Items); Befund-Persistenz mit Schlüssel (art,item,detail[:500]); auto_repair_loop (fertig/stillstand/limit, max 10 Iter).
- KRITISCH für creator3: Gate-Aufrufmuster existiert generisch (pipeline ruft `modul.gate(ctx)`, RuntimeError blockiert Marker), aber NUR korpus definiert eins. creator3: gate() je Ebene VERPFLICHTEND; bei stillstand/limit mit offenen kritischen Befunden → PAUSE statt Weiterlauf.
- Korpus-Themen-Modus komplett übernehmen: Runden bis Sättigung (Runde ohne neuen bestätigten Punkt), Zitat-Gate (finde_zitat gegen Quelle), Konsens: Judge gruppiert nur, CODE zählt ≥2 unabhängige Quellen; URL- und Content-Hash-Dedup; Beleg-Nachsuche gebündelt (1 Call je Quelle statt Punkt×Quelle); geprueft-Negativ-Cache; Snapshots: ftfy NACH Steuerzeichen-Ersatz, sha256[:16]-Dateiname.
- Inventar (ausgereiftester Teil) komplett: 6k-Abschnitte (12k riss 32k-Cap), 2 Reader, Cap → Chunk halbieren (Tiefe 2); Anker 5-Stufen-Matching (exakt→casefold→alnum→fuzzy 8 %→Wort-Alignment) mit Negations-/Eindeutigkeits-Guards; unverankert = start=-1 → erst det. Re-Match, dann LLM-Batch-Fix, verwerfen nur ohne Call-Ausfall; Dedup-Kette: Anker-Overlap ≥0.5 auto → Kandidaten (Embedding cos 0.65 / Jaccard 0.3 Titel-Kern) mit Negations-Gleichheit → 2er-Panel einstimmig; verwandt-Kanten = nie zweimal Tokens; Lücke: ±6k-Fenster-Nachextraktion, nach 2 Runden Freispruch nur per einstimmigem Panel.
- Struktur: Themen-Schnitt mit exakter Partitions-Validierung + √n-Fallback; Lernziele 2 Runden + Sammelziel; Band 4-8 mit Merge nur im Thema/Split entlang Quellreihenfolge; Ordnung: Median-Anker-Rang als Prior, Judge-Permutation exakt validiert, Fallback = Prior mit Befund.
- Guide: Stages writer→pruefer→fix→done in sections.stage; DELIMITED (===LANG===/===KOMPAKT===, JSON scheiterte an LaTeX-Backslashes 34 %); Fix-Cap 3 mit Freeze nur für Stil; Auftrags-System (2er-Panel, Klärung 3er einmalig, Eskalation); LLM-QA fail-closed mit qa_hash; det-Checks (Marker fehlend/fremd, Länge ×1.25-Band, Vorwärtsverweise, Stil).
- WICHTIG Dopplungs-Ziel: creator2 garantiert nur Marker-Einmaligkeit, KEINEN inhaltlichen Redundanz-Check. Für creator3-Kernziel „Länge minimieren" NEU bauen: Rückwärts-Redundanz-Check (analog Vorwärtsverweis-Detektor) + „Verweis statt Wiederholung"-Regel im Writer-Skill.
- Fake-System komplett übernehmen: Template-Marker `<!-- template:Name -->`, Handler-Registry, harter Fehler ohne Handler, Fakes LESEN Prompt (Zitat-Gate mitgetestet), In-Memory-DB, Resume-Test 0 neue Events.
- Schema übernehmen minus: artefakte, leitner, lernstand, topics.art, quellen.art='datei', kanten art='braucht'. textkit.py-Funktionsumfang 1:1 nachbauen (norm, finde_zitat 5 Stufen, titel_kern, abschnitte, ueberlappung, negations_menge). prompts.py-Muster (Datei + sha256-Hash) und belege.py (Fenster 300/+1200) übernehmen.
### R5: Skill-Datei-Format (fertig)
- Format: `skills/{rolle,aufgabe,stil}/name.md`, YAML-Frontmatter minimal: name, art (rolle|aufgabe|stil), beschreibung, optional platzhalter (Validierung vor Call). KEIN version-Feld (Hash ist die Version), kein model/tools.
- Komposition statisch erzwungen: genau 1 Rolle + 1 Aufgabe + 0..n Stil; NUR die Aufgabe definiert das Ausgabeformat (eliminiert Konfliktklasse per Konstruktion — Modelle arbitrieren Konflikte nur ~64 % korrekt); Platzhalter `{{name}}` nur im Aufgabe-Body, simples str.replace statt format_map (creator2 muss deshalb JSON-Klammern doppeln).
- Reihenfolge: Rolle → Stil-Regeln → Aufgabe (Input mittig, Ausgabeformat als LETZTER Block — creator2-bewährt).
- „Curse of Instructions": Erfolg fällt ≈ p^n mit Regelzahl → Budget: zusammengesetzter Prompt ohne Input ≤ ~1000 Tokens, ≤ ~8-10 verifizierbare Regeln. Wichtig für MiniMax-Klasse.
- Rollen-Theater bringt nichts (belegt) — Rolle nur, wenn sie konkrete Verhaltensregeln trägt („nichts erfinden, nur Belegbares").
- Hashing zweistufig: hash_i = sha256(Skill-Rohbytes)[:12] je Datei; skill_hash = sha256(name:hash_i-Liste in Reihenfolge)[:12] → ins Ledger (kompatibel zum template_hash-Muster); Einzelliste zusätzlich ins Log.
- DELIMITED-Ausgabeformat (===ATOM===-Blöcke) statt JSON — robuster für Mittelklasse-Modelle, kein Escaping.
### R6: HTML-Extraktion (fertig)
- Stack: **trafilatura ≥2.1.0** mit `output_format="markdown", include_tables=True, include_links=False, include_images=False` — einziger gewarteter Boilerplate-Entferner mit Markdown-Code-Fences + Tabellen (wichtig für Symfony-Doku); Benchmark-Spitzenklasse unter Python-Tools. Roh-HTML-Bytes IMMER mit archivieren (Re-Extraktion ohne Re-Fetch).
- Fetch: httpx mit `follow_redirects=True` (NICHT allow_redirects), explizite Timeouts (connect 10/read 30), ehrlicher UA `creator3/0.1 (+mailto:…)`, Transport-Retries(2) + Backoff für 429/5xx mit Retry-After; **Bytes an trafilatura geben, nie response.text** (Encoding-Fehlerklasse eliminiert); robots.txt via urllib.robotparser, pro Domain gecacht, Disallow → skip + Befund; ~1 req/s/Domain.
- Anker-Stabilität (Architekturregel): Normalisierung GENAU EINMAL bei Ingestion VOR Snapshot-Write: Bytes → trafilatura → ftfy 6.3.1 (NFC, NIE NFKC — verfälscht Zitate) → Snapshot unveränderlich; Zitate nur aus Snapshot; tolerante Normalisierung nur als Lesetransformation beim Vergleich.
- JS-Rendering: YAGNI v1. Detektor: leerer/kurzer Extrakt oder „enable JavaScript" → Befund „braucht Rendering, übersprungen".
- PDF: v1 überspringen mit Befund. Falls später: pypdf (BSD); pymupdf ist AGPL (bewusste Entscheidung nötig).
- Unsicher: Code-Block-Qualität von trafilatura 2.1.0 auf echten Symfony-Seiten → beim ersten Echtlauf prüfen.
### R7: Graph-Frontend (fertig)
- Empfehlung: **hand-gerolltes Hybrid-Rendering** — absolut positionierte HTML-Divs für Knoten (Badges = normales Vue-Templating) + eine SVG-Ebene nur für Kanten (kubische Bezier, `<marker>`-Pfeilspitzen), beide im selben transformierten Container. KEINE Graph-Bibliothek (kein Editor, festes Layout, ~20 Knoten).
- Layout vorberechnet aus YAML-Stufen: Spalte = Stufe, Zeile = Index (~40 Zeilen computed). Mobil: x/y-Tausch → vertikales Layout per Breakpoint (Orientierung wechseln statt schrumpfen).
- Rückwärts-Kanten (Reparatur/Lücken-Loops): feste Offsets pro Loop-Kante, außen herumgeführt — kontrollierter als Auto-Layout.
- Zoom/Pan: Micro-Lib anvaka/panzoom ODER @panzoom/panzoom (~20 Zeilen Anbindung); initial fitView, Pan als Primärgeste, `touch-action: none` nur auf Container (Page-Scroll nicht kapern).
- Live-Zähler: EIN WS-Handler schreibt in zentralen reactive Store `counts[knotenId] = {offen, laufend, fertig, fehler}`; Badges lesen daraus (nur Textknoten-Patches); Graphstruktur als shallowRef/statisch; Events auf ~250 ms koalesziert; Initial-Snapshot + Resync nach Reconnect.
- Klick/Tap → Seitenpanel mit Task-Liste (lazy geladen) — richtiges Touch-Muster, keine Hover-Tooltips.
- Fallback falls Eigenbau hakt: @vue-flow/core 1.48.2 (51 kB gzip), Positionen trotzdem selbst, draggable/connectable=false. Nicht: mermaid (Full-Re-Render), cytoscape/G6/elkjs (überdimensioniert), dagre-d3 (tot).
### R8: Task-Engine (fertig)
- Keine fertige Lib passt (Celery/Dramatiq/Huey/APScheduler/Prefect/LangGraph = Broker/Overkill/falsches Modell). Selbst bauen, ~300-500 Zeilen Engine-Kern. Vorbilder: litequeue-Schema (Task=Zeile, Claim=UPDATE), DBOS/Morling-Checkpoint-Muster, Dagu-YAML, graphlib (stdlib) NUR zur statischen DAG-Validierung beim Config-Laden.
- Scheduling: Hybrid — DB ist die einzige Wahrheit/Queue; Scheduler-Loop blockiert auf asyncio.Event („Wecker", gesetzt bei jedem Task-Abschluss/-Erzeugung) + 2-s-Fallback-Poll. Resume = derselbe Codepfad (Loop starten, DB lesen). Beim Start: Zombie-Reset `laufend → offen` (ein Prozess ⇒ kein Lease/Heartbeat nötig).
- SQLite: WAL, synchronous=NORMAL, busy_timeout 5000, EINE Writer-Connection (eliminiert locked-Probleme architektonisch); Claim = `UPDATE … WHERE id=? AND status='offen'`, rowcount==1; LLM-Calls NIE in Transaktion.
- Idempotenz: tasks UNIQUE(topic, knoten, item) + INSERT OR IGNORE; Reparatur-RUNDE gehört in den Item-Schlüssel (`r2:atom:14`), sonst blockiert UNIQUE die 2. Runde. Atomar in EINER Transaktion: Ergebnis-Zeilen + status='fertig' + Folge-Task-Erzeugung (kein falsch interpretierbarer Zwischenzustand). Datei-Schreiben: Temp + os.replace.
- Zyklen: Normal-Kanten = DAG (graphlib-Check); Rück-Kanten (reparatur|ruecklauf) nur von Gates, stufen-abwärts, sind Task-Erzeugungs-Rezepte (kein Laufzeit-Zyklus). Dreifacher Schutz: max_versuche je Task, max_runden je Gate, Stillstand-Fingerprint (SHA-256 über sortierte Befundmenge; 2× identisch → PAUSE).
- v1: Stufen-seriell mit frei fließendem Graph INNERHALB der Stufe (Gates brauchen Quiesce-Punkt; creator2 beweist, dass das reicht). Barriere-Knoten (`barriere: true`) für globale Schritte (Dedup, Konsens, Ordnung).
- Gate = Knoten-Typ mit deterministischer Registry-Funktion; Trigger: Stufe leer gelaufen → INSERT OR IGNORE Gate-Task `runde:N` (einmal pro Runde); rot → in EINER Transaktion Befunde + Fingerprint + Reparatur-Tasks (Runde+1, gecappt); Stillstand/Cap → runs.status='paused' mit Grund (fail-closed), UI bleibt erreichbar.
- Task-Schema: tasks(id, run_id, topic, knoten, item, status offen|laufend|fertig|fehler, art, versuch, runde, payload, ergebnis, fehler, erzeugt_von, gestartet, beendet, UNIQUE(topic,knoten,item)) + gate_laeufe(run_id, knoten, runde, fingerprint, befunde, UNIQUE(run_id,knoten,runde)).
- YAML-Schema: global{max_parallel, poll_sekunden}; knoten[{id, typ system|llm|gate, worker, skills[], stufe, max_parallel, max_versuche, barriere}]; kanten[{von, nach, art normal|reparatur|ruecklauf}]. Validierung: Worker in Registry, Skill-Dateien existieren, ein Gate pro Stufe (v1).
- Infra vs. Inhalt getrennt (Lektionen 1/2/68): Infra zählt nicht gegen max_versuche, eigenes Retry-Budget → PAUSE.
### R9: MiniMax-API (fertig)
- Endpoint: Anthropic-kompatibel `https://api.minimax.io/anthropic/v1/messages` (offiziell empfohlen; x-api-key wie creator2). Plus `/anthropic/v1/messages/count_tokens`. ACHTUNG: `stop_sequences`/`top_k` werden IGNORIERT → Delimiter im Prompt erzwingen, nicht per stop.
- Modelle Mitte 2026: **MiniMax-M3** (1M Kontext, thinking `adaptive|disabled` — „enabled" gibt 400; einziges Modell mit wirklich abschaltbarem Thinking), M2.7/M2.7-highspeed (Thinking NICHT abschaltbar — erklärt creator2s Denk-Token-Problem!).
- Rollen-Empfehlung: Extraktion + Judge = M3 mit thinking disabled, temp 0.1-0.3; Writer = M3 mit thinking adaptive, temp ~0.7, max_tokens 32000. thinking IMMER explizit setzen (Default widersprüchlich dokumentiert).
- Rate Limits: M3 200 RPM / 10M TPM; M2.x 500 RPM / 20M TPM. Rate-Limit-Fehler auch als Statuscode 1002/1041/2045 (nicht nur HTTP 429). Retry-After NICHT verlässlich → eigenes exponentielles Backoff mit Jitter. Nebenläufigkeit: 8-16 Worker sicher.
- Kein response_format/JSON-Mode bei M2.x/M3 (wird still ignoriert) → DELIMITED + eigener Parser bestätigt alternativlos.
- Prompt-Caching: passiv automatisch ab 512 Input-Tokens (Prefix-Match, keine Write-Gebühr) → stabile Prompt-Präfixe (Rolle+Stil vorn) lohnen sich; usage-Felder cache_read/cache_creation wie creator2 parsen.
- Preis M3: konservativ $0.60/$2.40 pro 1M in/out ansetzen (Quellen widersprüchlich, real messen).
- Embeddings: MiniMax embo-01 ist CN-/Legacy, auf api.minimax.io UNSICHER → für Dedup NICHT auf MiniMax-Embeddings bauen; lexikalisch (Jaccard) + optional lokales Modell.
- Deutsch-Qualität: keine belastbare Quellenlage → kleinen eigenen Deutsch-Eval beim ersten Echtlauf einplanen.
### R10: Guide-Rendering/Lesbarkeit (fertig)
- Stack-Wechsel ggü. creator2: **markdown-it@14.3 (html:false) statt marked** (sicher by default, CommonMark, Plugins) + markdown-it-anchor (Heading-IDs) + dompurify@3.4 vor jedem v-html + **Shiki 4.3 statt highlight.js** (fine-grained: shiki/core + JS-RegExp-Engine ohne WASM, nur benötigte Sprachen php/twig/yaml/bash/sql/json/xml; Dual Themes light/dark per CSS-Variablen; einmal async initialisieren, dann synchron via @shikijs/markdown-it/core).
- Lange Guides: KEINE Virtualisierung nötig (<2 MB); Kapitel-`<section>`s mit `content-visibility: auto` + `contain-intrinsic-size: auto 1200px` (~80 % Render-Ersparnis); HTML einmal rendern und cachen.
- CSS-Kern: Prose max-width 68ch zentriert; font-size clamp(1rem…1.125rem), line-height 1.65; Überschriften 1.25, mehr Luft oben; `scroll-margin-top: 5rem` auf h2/h3 (Sticky-Header!); Tabellen in `.table-wrap{overflow-x:auto}` + td max-width 40ch; pre overflow-x, 0.875em; Dark Mode über Custom Properties + data-theme; @media print (nav aus, break-inside avoid auf pre/table).
- TOC: markdown-it-anchor-Slugs → Headings (H2-H3) aus Tokens extrahieren → Sidebar-TOC (Desktop sticky, mobil Drawer) + IntersectionObserver-Scroll-Spy (~30 Zeilen). Kein Einklappen (bricht Anker + Suche), kein Lesefortschritts-Balken (Deko).
- Writer-Skill-Regeln (aus Instructional Design): 1 Konzept pro H3; **Verweis statt Wiederholung = Ein-Satz-Refresher + Anker-Link** („Wie in [Kapitel 3: X](#anchor) gezeigt, … — hier für Y"); eindeutige Überschriften (Slug-Kollisionen); H2-Einstieg 2-3 Sätze (was/warum/worauf baut es); Beispiel direkt nach Konzept (max ~5 Sätze Erklärung); H2-Kapitel ~1000-2000 Wörter; Tabellen max 3-4 Spalten; Kapitel-Abschluss 3-5 Punkte ohne fremde Inhalte zu wiederholen.
### R11: Dedup + Abdeckung (fertig)
- Grundsatz: deterministisch vor LLM; Ähnlichkeit liefert NUR Kandidaten (Floors niedrig, recall-orientiert), Panel = Präzisions-Gate; Merge nur einstimmig; geprüfte Paare als „verwandt"-Kante persistieren.
- KEIN sentence-transformers in v1. Kandidaten-Kanäle (Union): Anker-Overlap ≥0.5 (einziger Auto-Merge); Titel-Kern exakt + gestemmt; Token-Jaccard (Snowball-German-gestemmt via PyStemmer) auf Definitionen ≥0.3 (gemessener Paraphrasen-Floor); rapidfuzz token_set_ratio auf Titeln ≥85 NUR bei ≥2 Inhaltstoken (Subset-100-Falle; Schwelle kalibrieren); **Akronym-Kanal** ALL-CAPS ↔ Anfangsbuchstaben (Lektion 28 — in creator2 FEHLEND); **Gleicher-Soll-Punkt-Kanal** (alle Atompaare am selben Soll-Punkt — deckt Synonym-Lücke billig ab).
- Guards vor Panel: Negationsmengen-Gleichheit, Kandidaten-Cap, ~10 Paare/Call. Backstop falls Dubletten überleben: model2vec potion-multilingual-128M (MIT, 256-dim, kein torch, cos 0.65). MiniMax embo-01 unsicher — nicht drauf bauen.
- Abdeckung = Nugget-Recall-Prinzip (wissenschaftlich bestätigt, TREC RAG): Coverage % = bestätigte Soll-Punkte mit ≥1 Atom UND ≥1 Baustein / alle bestätigten — deterministisch gezählt, NIE LLM-geschätzt. Checklisten-Recall trennschärfer als Likert-Judges (Deckeneffekte).
- Lückensuche: Suchanfragen aus Punkt-Text + Beleg-Zitat GROUNDEN (freie LLM-Query-Expansion halluziniert bei unbekannten Themen), 2-3 Varianten; Runden-Cap 2, dann Panel-Freispruch persistiert.
- Sättigung schärfer als creator2: Nullrunde zählt nur mit NEUEN Query-Winkeln/Lenses; Cap 3-4 Runden.
- Dopplungs-QA im Text (klein, <100 Zeilen): Satz-Split → norm() → Jaccard aller Satzpaare AUS VERSCHIEDENEN KAPITELN ≥~0.6 → Befund → 2er-Panel „redundant vs. didaktisch gewollt" (Recap ist keine Dopplung). Prävention bleibt Hauptinstrument: exakte Soll-Partition + „bekannt"-Kontext im Writer + inhaltsfreie Intros.
## ALLE 10 RECHERCHEN ABGESCHLOSSEN — Design-Phase folgt.

87
LEKTIONEN.md Normal file
View File

@@ -0,0 +1,87 @@
# Lektionen-Herkunft creator3
Basis: `../creator2/LEKTIONEN.md` (83 bezahlte Lektionen aus creator/creator2).
Übernommen als Anforderungen: 1-6, 10, 12-37, 40, 42-49, 52-83 + die vier
Meta-Regeln. Entfallen (CLI-/PDF-/uni-spezifisch): 7-9, 11, 38-39, 41, 51.
Lektion 50 (API statt CLI) ist die Gründungsentscheidung von creator3.
## Neue Lektionen aus der creator2-Analyse (2026-07-22)
84. **Budget default AN** — creator2s „hartes" Budget war per Default 0=aus und
in allen Echtläufen abgeschaltet. creator3: `RUN_BUDGET_TOKENS=10M` Default
(config.py), Prüfung VOR und NACH jedem Call (llm.py).
85. **Gates überall hart, nicht nur beim Korpus** — creator2 gatete nur E0;
E1-E4 stiegen bei Stillstand trotzdem auf. creator3: jede Stufe hat ein
Gate-Knoten, rot ohne Fortschritt (Fingerprint 2× identisch) → PAUSE
(engine.py `_gate_abschluss`).
86. **Dopplung inhaltlich prüfen, nicht nur Marker** — creator2 garantierte nur
Marker-Einmaligkeit. creator3: Satzpaar-Jaccard über Section-Grenzen +
Panel „redundant vs. didaktisch gewollt" (redundanz.py), Fix ersetzt durch
Ein-Satz-Refresher + Anker-Link.
87. **Akronym↔Langform explizit als Dedup-Kanal** — Lektion 28 war in creator2
nie umgesetzt; Cosinus/Jaccard sehen „HTTP"↔„Hypertext Transfer Protocol"
nicht (dedup.py `_kanal`, textkit.py `ist_akronym_von`).
88. **Nach der Soll-Zuordnung einmal nachdeduplizieren** — der
Gleicher-Soll-Punkt-Kanal kann erst feuern, wenn soll_ids gesetzt sind;
Dedup vor Zuordnung allein übersieht Synonym-Paare (inventar.gate).
89. **Engine denkt in Topics, nicht in Runs** — Tasks/Gates sind topic-scoped;
ein neuer Run auf fertigem Topic ist ein No-Op (Resume-Test: 0 Events).
## Lektionen aus Echtlauf 1 (Pomodoro, 2026-07-22: 353 Soll-Punkte, 2789 Atome,
## 5,4M Tokens für ein Mini-Thema)
90. **Soll-Band ist Pflicht, nicht Deko** — ohne Ziel ≈ √Kandidaten bestätigt
der Konsens jeden Mikro-Punkt mit 2 Blog-Belegen; alles danach skaliert mit
(korpus.soll_konsens: Band + 2. Faltungs-Pass).
91. **Websuche braucht Deckel UND Relevanz-Urteil** — 36 Queries × 8 Treffer
luden 195 Quellen inkl. App-Werbelisten (128 Atome!). Jetzt: QUELLEN_MAX=24,
Wikipedia zuerst, Kurz-Judge filtert Listen/Shops (suche._relevanz_filter).
92. **Extraktion belegnah, nicht flächendeckend** — ganze Quellen × 2 Reader
ergab 1135 Atome ohne Soll-Bezug. Jetzt: nur ±4k-Fenster um Soll-Belege;
Quellen ohne Beleg liefern keine Atome (korpus.gate).
93. **Vollständigkeit braucht einen Gegenspieler: Verdichtung** — Ziel ist das
minimale Abdeckungs-Set („Entfernen eines Atoms erzeugt eine Lücke").
Sammeln allein produziert Splitter (582 Atome, 51k-Wörter-Guide). Jetzt:
Verdichtungs-Knoten je Soll-Punkt (Panel-Schnitt, Fusion vererbt Anker —
Abdeckung bleibt), Granularitäts-Regel im Extraktor, Beleg-Abdeckung als
Kennzahl. Writer-Input als Prosa statt Feldern (TITEL:-Leak, 41 Sections).
94. **Befunde brauchen Identität über Runden** — llm_pruefung fügte denselben
Befund jede Runde als neue Zeile ein; die Eskalations-Kette (geklaert-Flag)
startete endlos von vorn. Jetzt: Dedupe über (art, item, detail-Kern),
finale Zustände (freispruch/eskaliert) blocken Wiedereintrag UND Live-Checks
(guide.befund_merken). Und: Beifang-Atome ohne Soll-Bezug werden nach der
Zuordnung verworfen — sonst 90 Ballast-Atome → 8 Sammelziele → 32 Bausteine.
95. **Verweis heißt Prosa, nicht Link** — „Verweis statt Wiederholung" mit
Anker-Links erzeugte Sprung-Zwang und Link-Flut (Gutachten: Leser will
linear lesen). Jetzt: Rückbezug = Halbsatz ohne Link; det-Checks
link_im_text/atom_echo/h_ebene erzwingen es; Fettzeilen-Echo (Atom-Input
wörtlich + Paraphrase) war die Haupt-Dopplungsquelle.
96. **Abdeckung heißt Beleg-Stellen, nicht Punkte** — ein Atom je Soll-Punkt
genügte formal, ließ aber Facetten aus (Namensherkunft fehlte trotz 7
Belegen). Jetzt: Facetten-Gate — jede Beleg-Stelle braucht ein Atom im
±4k-Umkreis, sonst gezielte Fenster-Nachextraktion (Cap 2, dann
eskaliert). Wikipedia läuft über die Action-API statt robots-blockierter
HTML-Seiten.
97. **Redundanz braucht Phrasen- und Zahlen-Kanäle** — Satz-Jaccard 0,6 übersah
identische Phrasen in längeren Sätzen („Ich melde mich in 20 Minuten" 3×)
und dieselbe Studie in Paraphrase (a20≈a78). Jetzt: 5-Wort-Shingle-Kanal
(redundanz.py), Zahlen+Eigennamen-Signatur als Dedup-Kanal (dedup.py),
Urteil „enthalten" merged Teilmengen-Atome, Ziel-Dedup fängt synonyme
Lernziele („einordnen"/„verorten" → Doppel-Bausteine → Writer-Echos).
98. **Kuratierte Quelle = 1 Beleg genügt** — das ≥2-Belege-Band schützte vor
Blog-Halluzination, warf aber Einzelquellen-Facetten weg (Overflow-
Pomodoros nur in Wikipedia). Jetzt: Wikipedia-Beleg bestätigt allein
(SOLL_MIN_BELEGE_WIKI); Wikipedia wird geseedet statt ersucht und läuft
MIT durch den Relevanz-Judge („Pasta"/„Xinjiang" wurden geladen).
99. **Takt statt Breite** — die Parallel-Drossel (12 gleichzeitig) begrenzte
das Falsche: Rate-Limits zählen Starts pro Minute, nicht offene
Verbindungen (0×429 in 5k Calls). Jetzt: 1 Call-Start je TAKT_SEKUNDEN
(llm._slot), Knoten-Klassen bündeln kurze Aufgaben (lang=1, mittel=3,
kurz=10 Tasks je Call, engine._buendel_claimen + Ergebnis.teil_status).
100. **Bündel-Item-Retry statt Verwerfen** — fehlt ein Item in einer
Bündel-Antwort, geht NUR sein Task auf „neu" (versuch+1, dann sichtbar
„fehler"); Skills erzwingen je Item einen Block (OK/LEER/NICHTS als
explizites Nichts). Dedup: fehlende Paar-Urteile → EIN Nach-Call, danach
parse-Befund statt stiller „verwandt"-Kante. Stille None-Pfade
(kapitel_intro, lernziele, bausteine, anker_fix, luecke) sind jetzt
Raise → Task-Retry. Stille Fehler sind NIE erlaubt.

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
PY := .venv/bin/python
PIP := .venv/bin/pip
install:
python3 -m venv .venv
$(PIP) install -r backend/requirements.txt
cd frontend && npm install
dev:
$(PY) -m uvicorn backend.main:app --reload --port 8000 & cd frontend && npm run dev
test:
CREATOR_FAKE=1 $(PY) -m pytest -x -q
test-e2e:
CREATOR_FAKE=1 $(PY) -m pytest -x -q tests/test_e2e.py
build:
cd frontend && npm run build

0
backend/__init__.py Normal file
View File

73
backend/belege.py Normal file
View File

@@ -0,0 +1,73 @@
"""Beleg-Fenster um Anker (300/+1200): Faktenbasis für Writer und Prüfer.
Überlappende Fenster derselben Quelle werden gemerged (creator2-Muster).
Außerdem: Facetten-Abdeckung — jede Soll-BELEG-Stelle braucht ein Atom in
der Nähe (das feine 100-%-Maß, Gutachten-Lücke „Namensherkunft")."""
from . import config, db, laden, textkit
def beleg_stellen(topic: str) -> list[dict]:
"""Alle auffindbaren Beleg-Stellen bestätigter Soll-Punkte."""
stellen = []
for s in db.query("SELECT id, belege FROM soll WHERE topic=? AND "
"status='bestaetigt' AND freispruch=''", topic):
for beleg in db.uj(s["belege"], []):
quelle = db.one("SELECT * FROM quellen WHERE id=? AND "
"status='geladen'", beleg["quelle"])
if not quelle:
continue
try:
text = laden.snapshot_lesen(quelle)
except OSError:
continue
pos = textkit.finde_zitat(text, beleg["zitat"])
if pos:
stellen.append({"soll_id": s["id"], "quelle_id": quelle["id"],
"start": pos[0], "ende": pos[1],
"zitat": beleg["zitat"][:120]})
return stellen
def unbedeckte_belege(topic: str) -> list[dict]:
"""Beleg-Stellen OHNE aktives Atom im ±EXTRAKT_FENSTER derselben Quelle."""
anker = {}
for a in db.query(
"SELECT an.quelle_id q, an.start s FROM anker an JOIN atome a ON "
"a.id=an.atom_id WHERE a.topic=? AND a.status='aktiv' AND "
"an.start>=0", topic):
anker.setdefault(a["q"], []).append(a["s"])
return [st for st in beleg_stellen(topic)
if not any(abs(s - st["start"]) <= config.EXTRAKT_FENSTER
for s in anker.get(st["quelle_id"], []))]
def abdeckung_prozent(topic: str) -> float | None:
stellen = beleg_stellen(topic)
if not stellen:
return None
frei = len(unbedeckte_belege(topic))
return round((len(stellen) - frei) / len(stellen) * 100, 1)
def fenster_fuer_atome(atom_ids: list[int]) -> str:
spans: dict[int, list[list[int]]] = {}
for aid in atom_ids:
for a in db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", aid):
von = max(0, a["start"] - config.BELEG_FENSTER_VOR)
bis = a["ende"] + config.BELEG_FENSTER_NACH
spans.setdefault(a["quelle_id"], []).append([von, bis])
teile = []
for qid, fenster in spans.items():
quelle = db.one("SELECT * FROM quellen WHERE id=?", qid)
if not quelle or quelle["status"] != "geladen":
continue
text = laden.snapshot_lesen(quelle)
fenster.sort()
gemerged = [fenster[0]]
for von, bis in fenster[1:]:
if von <= gemerged[-1][1]:
gemerged[-1][1] = max(gemerged[-1][1], bis)
else:
gemerged.append([von, bis])
for von, bis in gemerged:
teile.append(f"[Quelle: {quelle['titel'][:60]}]\n{text[von:bis]}")
return "\n\n---\n\n".join(teile)

150
backend/config.py Normal file
View File

@@ -0,0 +1,150 @@
"""Konstanten + .env-Loader. Regel: .env-Datei gewinnt über geerbtes Env
(creator2-Lektion 12: --reload-Master pinnte sonst stale Werte)."""
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def _load_env() -> None:
datei = ROOT / ".env"
if not datei.exists():
return
for zeile in datei.read_text().splitlines():
zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile:
continue
k, v = zeile.split("=", 1)
v = v.split("#", 1)[0].strip()
if v:
os.environ[k.strip()] = v
_load_env()
FAKE = bool(os.getenv("CREATOR_FAKE"))
LLM_LOG = bool(os.getenv("LLM_LOG")) # 1 = Prompt+Antwort je Call in call_texte
DB_PFAD = Path(os.getenv("CREATOR_DB") or ROOT / "storage" / "creator3.db")
KORPUS_DIR = ROOT / "storage" / "korpus"
SKILLS_DIR = ROOT / "skills"
PIPELINE_YAML = ROOT / "pipeline.yaml"
TOPIC_NAME_RE = re.compile(r"^[a-zA-Z0-9äöüÄÖÜß][a-zA-Z0-9äöüÄÖÜß _-]{0,63}$")
# ── LLM / MiniMax (R3/R9) — alles M3; M2.x kann Thinking nicht abschalten ──
LLM_ENDPOINT = "https://api.minimax.io/anthropic/v1/messages"
ROLLEN = {
# thinking IMMER explizit ("enabled" gibt 400; Default widersprüchlich dokumentiert)
"extraktion": dict(model="MiniMax-M3", thinking="disabled", temperature=0.2),
"judge": dict(model="MiniMax-M3", thinking="disabled", temperature=0.1),
"writer": dict(model="MiniMax-M3", thinking="adaptive", temperature=0.7),
}
LLM_MAX_TOKENS = 32_000
MAX_PARALLEL_LLM = int(os.getenv("MAX_PARALLEL_LLM", "12")) # M3: 200 RPM → 8-16 sicher
TAKT_SEKUNDEN = float(os.getenv("TAKT_SEKUNDEN", "3")) # 1 Call-Start je Takt; 0 = Breiten-Drossel
BUENDEL = {"lang": 1, "mittel": 3, "kurz": 10} # Tasks je Call nach Knoten-Klasse
INFRA_MAX_RETRIES = 3
INFRA_BACKOFF_BASE = 8.0 # → 8/16/32 s, mit Jitter (Retry-After unzuverlässig)
INHALT_MAX_RESTARTS = 2
DROSSEL_MIN_BREITE = 4 # 429/1002/1041/2045: Breite halbieren, 20 Erfolge = +1
DROSSEL_ERFOLGE_JE_PLUS = 20
HEDGE_NACH_S = int(os.getenv("HEDGE_NACH_S", "90")) # Zwilling nach max(90, timeout/2); 0 = aus
RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "10000000")) # DEFAULT AN; 0 = aus
PREIS_IN, PREIS_OUT = 0.60, 2.40 # $/1M konservativ (R9), real messen
# Timeouts je Knoten: (basis_s, pro_item_s) — creator2-Lektion 14: nie fix;
# pro_item skaliert mit der Bündel-Größe (n in llm.call)
TIMEOUTS = {
"recherche_plan": (60, 20), "soll_extraktion": (180, 60), "soll_konsens": (240, 2),
"atom_extraktion": (240, 60), "anker_fix": (180, 4), "dedup": (120, 8),
"soll_zuordnung": (120, 3), "luecke": (180, 45), "lernziele": (120, 3),
"bausteine": (120, 3), "ordnung": (180, 3), "writer": (420, 0),
"kapitel_intro": (90, 0), "llm_pruefung": (240, 60), "fix": (300, 0),
"redundanz": (120, 8), "klaerung": (180, 0),
}
TIMEOUT_DEFAULT = (180, 0)
# ── Suche (R2) ──
SUCHE_TIMEOUT_S = 10
SUCHE_RETRIES = 1
CIRCUIT_BREAKER_N = 3
WIKI_MAX_PARALLEL = 3
DDGS_PAUSE_S = 1.5
SUCHE_MAX_TREFFER = 8
# ── Laden (R6) ──
FETCH_CONNECT_S = 10
FETCH_READ_S = 30
DOMAIN_RATE_S = 1.0
SNAPSHOT_MIN_ZEICHEN = 500
# ── Korpus ──
RECHERCHE_LENSES = ("ueberblick", "tutorial", "referenz", "praxis") # generisch!
RECHERCHE_RUNDEN_MAX = int(os.getenv("RECHERCHE_RUNDEN_MAX", "4"))
QUERIES_JE_LENS = 3
SOLL_CHUNK_CHARS = 40_000
SOLL_MIN_BELEGE = 2 # ≥2 unabhängige Quellen je bestätigtem Punkt
SOLL_PUNKTE_MIN = int(os.getenv("SOLL_PUNKTE_MIN", "5"))
SOLL_BAND_FAKTOR = 1.0 # Ziel ≈ √Kandidaten (creator2-Band — Lektion 90)
SOLL_BAND_TOLERANZ = 1.5 # mehr als Ziel×1.5 bestätigt → nochmal falten
QUELLEN_MAX = int(os.getenv("QUELLEN_MAX", "24")) # harter Korpus-Deckel
EXTRAKT_FENSTER = 4_000 # Atome nur ±Fenster um Soll-Belege (belegnah)
# ── Inventar ──
ABSCHNITT_CHARS = 6_000 # 12k riss das 32k-Output-Cap (creator2 empirisch)
READER_JE_ABSCHNITT = 2 # Konsens entsteht über Dedup, nicht Union
CHUNK_HALBIER_TIEFE = 2
ANKER_OVERLAP_MERGE = 0.5
FUZZY_MIN_ZEICHEN = 40
FUZZY_FEHLERQUOTE = 0.08
WORT_MIN = 8
WORT_FEHLERQUOTE = 0.25
ANKER_FIX_BATCH = 20
MERGE_PANEL = 2 # Merge nur einstimmig (Falsch-Merge >> Dublette)
DEDUP_JACCARD = 0.3 # gemessener Paraphrasen-Floor
DEDUP_TITEL_FUZZY = 85 # nur bei ≥2 Inhaltstoken (Subset-100-Falle)
DEDUP_PAARE_PRO_CALL = 10
DEDUP_KANDIDATEN_CAP = 400
VERDICHTUNG_ZIEL = 8 # weiches Ziel Atome je Soll-Punkt (kein Cap!)
LUECKEN_RUNDEN_MAX = 2
WEB_NACHRECHERCHE_RUNDEN = 1
LUECKE_FENSTER = 6_000
BELEG_FENSTER_VOR, BELEG_FENSTER_NACH = 300, 1200
# ── Struktur ──
KAPITEL_SOLL_PUNKTE = 8
BAUSTEIN_MIN_ATOME, BAUSTEIN_MAX_ATOME = 4, 8
ZIELE_CHUNK_ATOME = 30
# ── Guide ──
SECTION_WOERTER_PRO_ATOM = (40, 220) # QA-Band = ×1.25
PRUEF_PANEL = 2
KLAERUNG_PANEL = 3
FIX_MAX_VERSUCHE = 3
REDUNDANZ_JACCARD = 0.5 # Satzpaare zwischen Bausteinen (Paraphrasen!)
REDUNDANZ_SHINGLE = 5 # gemeinsame 5-Wort-Folge = Kandidat
REDUNDANZ_SATZ_MIN_TOKEN = 6
ZIEL_DEDUP_JACCARD = 0.5 # synonyme Lernziele deterministisch mergen
ECHO_JACCARD = 0.7 # 1. Satz nach Marker ≈ Atom-Definition = Echo
SOLL_MIN_BELEGE_WIKI = 1 # kuratierte Quelle (Wikipedia) = 1 Beleg genügt
# ── Engine (R8) ──
POLL_SEKUNDEN = 2.0
MAX_VERSUCHE = 2 # Inhalts-Versuche je Task; Infra zählt separat
STILLSTAND_N = 2 # identischer Befund-Fingerprint n× → PAUSE
GATE_RUNDEN_MAX = {"gate_korpus": 4, "gate_inventar": 8, "gate_struktur": 3, "gate_guide": 12}
# gate_inventar 6: Anker-Fix, 2× Dedup/Lücke, Web-Nachrecherche, Nachzügler-Dedup
# gate_guide 8: Echtlauf konvergierte 45→28→10 — der Stillstand-Fingerprint
# fängt echte Schleifen, das Runden-Limit ist nur die Notbremse dahinter
MAX_PARALLEL_GLOBAL = 24
def timeout_fuer(knoten: str, n: int = 0) -> float:
basis, pro = TIMEOUTS.get(knoten, TIMEOUT_DEFAULT)
return basis + pro * n
def user_agent() -> str:
mail = os.getenv("KONTAKT_MAIL", "")
return f"creator3/0.1 (+mailto:{mail})" if mail else "creator3/0.1"

205
backend/db.py Normal file
View File

@@ -0,0 +1,205 @@
"""SQLite-Schicht: EINE Writer-Connection (eliminiert 'database is locked'
architektonisch, R8), WAL, kurze Transaktionen. Zustand liegt NUR hier —
Resume = Prozess neu starten, fertige Tasks überspringen."""
import json
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any, Callable
from . import config
_conn: sqlite3.Connection | None = None
_lock = threading.RLock()
on_change: Callable[[str, dict], None] | None = None # ws.Hub.push
_LIVE_TABELLEN = {"topics", "runs", "tasks", "gate_laeufe", "befunde"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS topics(
name TEXT PRIMARY KEY, titel TEXT DEFAULT '', status TEXT DEFAULT 'neu',
quellen_max INTEGER, erstellt TEXT DEFAULT (datetime('now')));
CREATE TABLE IF NOT EXISTS runs(
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, status TEXT DEFAULT 'running',
gestartet TEXT, beendet TEXT, stufe TEXT DEFAULT '',
budget_tokens INTEGER DEFAULT 0, grund TEXT DEFAULT '', git_hash TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS tasks(
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, topic TEXT NOT NULL,
knoten TEXT NOT NULL, item TEXT NOT NULL, status TEXT DEFAULT 'offen',
art TEXT DEFAULT '', versuch INTEGER DEFAULT 0, infra_versuch INTEGER DEFAULT 0,
runde INTEGER DEFAULT 0, payload TEXT DEFAULT '{}', ergebnis TEXT DEFAULT '{}',
fehler TEXT DEFAULT '', erzeugt_von TEXT DEFAULT '', gestartet TEXT, beendet TEXT,
UNIQUE(topic, knoten, item));
CREATE INDEX IF NOT EXISTS idx_tasks_disp ON tasks(run_id, status, knoten);
CREATE TABLE IF NOT EXISTS gate_laeufe(
id INTEGER PRIMARY KEY, run_id INTEGER, topic TEXT, knoten TEXT, runde INTEGER,
status TEXT DEFAULT '', fingerprint TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
ts TEXT, UNIQUE(topic, knoten, runde));
CREATE TABLE IF NOT EXISTS events(
id INTEGER PRIMARY KEY, run_id INTEGER, ts TEXT, stufe TEXT DEFAULT '',
knoten TEXT DEFAULT '', item TEXT DEFAULT '', skills TEXT DEFAULT '[]',
skill_hash TEXT DEFAULT '', model TEXT DEFAULT '', role TEXT DEFAULT '',
status TEXT DEFAULT '', 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 call_texte(
event_id INTEGER PRIMARY KEY, prompt TEXT DEFAULT '', antwort TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS quellen(
id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', url TEXT DEFAULT '',
url_norm TEXT DEFAULT '', backend TEXT DEFAULT '', snapshot TEXT DEFAULT '',
roh TEXT DEFAULT '', hash TEXT DEFAULT '', runde INTEGER DEFAULT 0,
zweck TEXT DEFAULT 'korpus', status TEXT DEFAULT 'neu', grund TEXT DEFAULT '',
atome_stand TEXT DEFAULT '', UNIQUE(topic, url_norm));
CREATE TABLE IF NOT EXISTS soll(
id INTEGER PRIMARY KEY, topic TEXT, punkt TEXT, status TEXT DEFAULT 'kandidat',
belege TEXT DEFAULT '[]', kapitel_id INTEGER, geprueft TEXT DEFAULT '[]',
freispruch TEXT DEFAULT '', verdichtet INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS atome(
id INTEGER PRIMARY KEY, topic TEXT, titel TEXT, typ TEXT DEFAULT '',
definition TEXT DEFAULT '', status TEXT DEFAULT 'neu', soll_id INTEGER,
soll_geprueft INTEGER DEFAULT 0,
ziel_id INTEGER, baustein_id INTEGER, ord INTEGER DEFAULT 0, merged_into INTEGER);
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
CREATE TABLE IF NOT EXISTS anker(
id INTEGER PRIMARY KEY, atom_id INTEGER, quelle_id INTEGER,
start INTEGER DEFAULT -1, ende INTEGER DEFAULT -1, zitat TEXT DEFAULT '');
CREATE INDEX IF NOT EXISTS idx_anker_atom ON anker(atom_id);
CREATE TABLE IF NOT EXISTS kanten(
id INTEGER PRIMARY KEY, topic TEXT, von_atom INTEGER, zu_atom INTEGER,
art TEXT DEFAULT 'verwandt', status TEXT DEFAULT 'aktiv',
UNIQUE(von_atom, zu_atom, art));
CREATE TABLE IF NOT EXISTS lernziele(
id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', text TEXT DEFAULT '',
soll_id INTEGER, status TEXT DEFAULT 'neu');
CREATE TABLE IF NOT EXISTS bausteine(
id INTEGER PRIMARY KEY, topic TEXT, ziel_id INTEGER, titel TEXT DEFAULT '',
ord INTEGER DEFAULT 0, kapitel_id INTEGER, status TEXT DEFAULT 'neu');
CREATE TABLE IF NOT EXISTS kapitel(
id INTEGER PRIMARY KEY, topic TEXT, titel TEXT DEFAULT '', intro TEXT DEFAULT '',
ord INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS sections(
baustein_id INTEGER PRIMARY KEY, stage TEXT DEFAULT 'writer', text TEXT DEFAULT '',
qa_hash TEXT DEFAULT '', fix_versuche INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS befunde(
id INTEGER PRIMARY KEY, run_id INTEGER, stufe TEXT DEFAULT '', knoten TEXT DEFAULT '',
art TEXT DEFAULT '', item TEXT DEFAULT '', detail TEXT DEFAULT '',
status TEXT DEFAULT 'offen', quelle TEXT DEFAULT '', runde INTEGER DEFAULT 0,
geklaert INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_befunde_run ON befunde(run_id, stufe);
"""
def connect(pfad: str | None = None) -> None:
global _conn
with _lock:
if _conn is not None:
_conn.close()
ziel = pfad or str(config.DB_PFAD)
if ziel != ":memory:":
config.DB_PFAD.parent.mkdir(parents=True, exist_ok=True)
_conn = sqlite3.connect(ziel, check_same_thread=False)
_conn.row_factory = sqlite3.Row
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA synchronous=NORMAL")
_conn.execute("PRAGMA busy_timeout=5000")
_conn.execute("PRAGMA foreign_keys=ON")
_conn.executescript(SCHEMA)
for migration in ( # Mini-Migrationen für Bestands-DBs
"ALTER TABLE atome ADD COLUMN soll_geprueft INTEGER DEFAULT 0",
"ALTER TABLE topics ADD COLUMN quellen_max INTEGER",
"ALTER TABLE soll ADD COLUMN verdichtet INTEGER DEFAULT 0"):
try:
_conn.execute(migration)
except sqlite3.OperationalError:
pass
_conn.commit()
def reset_for_tests() -> None:
connect(":memory:")
def now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
def j(x: Any) -> str:
return json.dumps(x, ensure_ascii=False)
def uj(s: str, default: Any = None) -> Any:
try:
return json.loads(s)
except (TypeError, ValueError):
return default
def _notify(tabelle: str, row: dict) -> None:
if on_change and tabelle in _LIVE_TABELLEN:
on_change(tabelle, row)
def query(sql: str, *args) -> list[dict]:
with _lock:
return [dict(r) for r in _conn.execute(sql, args).fetchall()]
def one(sql: str, *args) -> dict | None:
rows = query(sql, *args)
return rows[0] if rows else None
def execute(sql: str, *args) -> int:
"""Einzelnes Statement + Commit. Rückgabe: rowcount (für Claim-Muster)."""
with _lock:
cur = _conn.execute(sql, args)
_conn.commit()
tabelle = sql.split()[2] if sql.lstrip().upper().startswith(("UPDATE",)) else ""
_notify(tabelle.lower(), {})
return cur.rowcount
def insert(tabelle: str, ignore: bool = False, **felder) -> int | None:
"""INSERT (OR IGNORE); Rückgabe lastrowid oder None bei ignoriertem Duplikat."""
keys = ", ".join(felder)
qs = ", ".join("?" for _ in felder)
verb = "INSERT OR IGNORE" if ignore else "INSERT"
with _lock:
cur = _conn.execute(f"{verb} INTO {tabelle}({keys}) VALUES({qs})",
tuple(felder.values()))
_conn.commit()
_notify(tabelle, dict(felder))
return cur.lastrowid if cur.rowcount else None
def update(tabelle: str, wo: str, wo_args: tuple, **felder) -> int:
setzt = ", ".join(f"{k}=?" for k in felder)
with _lock:
cur = _conn.execute(f"UPDATE {tabelle} SET {setzt} WHERE {wo}",
tuple(felder.values()) + wo_args)
_conn.commit()
_notify(tabelle, dict(felder))
return cur.rowcount
class tx:
"""Transaktion für atomare Mehrfach-Schreiber (Ergebnis + fertig + Folgetasks).
Innerhalb: cursor.execute; niemals LLM-Calls."""
def __enter__(self):
_lock.acquire()
_conn.execute("BEGIN IMMEDIATE")
return _conn
def __exit__(self, typ, wert, tb):
try:
if typ is None:
_conn.commit()
_notify("tasks", {})
else:
_conn.rollback()
finally:
_lock.release()
return False

189
backend/dedup.py Normal file
View File

@@ -0,0 +1,189 @@
"""Dedup-Barriere (R11): 1) Anker-Overlap ≥0.5 = einziger Auto-Merge.
2) Kandidaten-Kanäle (Union): Titel-Kern, Jaccard gestemmt ≥0.3, Titel-Fuzzy ≥85
(nur ≥2 Inhaltstoken), Akronym-Kanal, Gleicher-Soll-Punkt. 3) 2er-Panel,
Merge NUR einstimmig; geprüfte Paare als 'verwandt'-Kante — nie zweimal Tokens."""
from rapidfuzz import fuzz
from . import config, db, engine, llm, panels, textkit
def _aktive(topic: str) -> list[dict]:
return db.query("SELECT * FROM atome WHERE topic=? AND status='aktiv'", topic)
def _anker(atom_id: int) -> list[dict]:
return db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", atom_id)
def _merge(topic: str, gewinner: dict, verlierer: dict) -> None:
"""Gewinner = längere Definition; Anker wandern, Kanten hängen um."""
if len(verlierer["definition"]) > len(gewinner["definition"]):
gewinner, verlierer = verlierer, gewinner
db.update("atome", "id=?", (verlierer["id"],), status="verworfen",
merged_into=gewinner["id"])
db.update("anker", "atom_id=?", (verlierer["id"],), atom_id=gewinner["id"])
db.execute("UPDATE OR IGNORE kanten SET von_atom=? WHERE von_atom=?",
gewinner["id"], verlierer["id"])
db.execute("UPDATE OR IGNORE kanten SET zu_atom=? WHERE zu_atom=?",
gewinner["id"], verlierer["id"])
db.execute("DELETE FROM kanten WHERE von_atom=zu_atom")
if verlierer["soll_id"] and not gewinner["soll_id"]:
db.update("atome", "id=?", (gewinner["id"],), soll_id=verlierer["soll_id"])
def _auto_merge_anker(topic: str) -> int:
"""Gleiche Quellstelle = gleiches Atom (deterministisch, kein Judge)."""
n = 0
atome = _aktive(topic)
for i, a in enumerate(atome):
if a["status"] != "aktiv":
continue
spans_a = [(x["quelle_id"], x["start"], x["ende"]) for x in _anker(a["id"])]
for b in atome[i + 1:]:
if b["status"] != "aktiv":
continue
spans_b = [(x["quelle_id"], x["start"], x["ende"]) for x in _anker(b["id"])]
if any(qa == qb and textkit.ueberlappung((sa, ea), (sb, eb))
>= config.ANKER_OVERLAP_MERGE
for qa, sa, ea in spans_a for qb, sb, eb in spans_b):
_merge(topic, a, b)
b["status"] = "verworfen"
n += 1
return n
def _kandidaten(topic: str) -> list[tuple[dict, dict, str]]:
atome = _aktive(topic)
geprueft = {(k["von_atom"], k["zu_atom"]) for k in db.query(
"SELECT von_atom, zu_atom FROM kanten WHERE topic=?", topic)}
paare = []
for i, a in enumerate(atome):
for b in atome[i + 1:]:
key = (min(a["id"], b["id"]), max(a["id"], b["id"]))
if key in geprueft:
continue # verwandt-Kante: nie zweimal Tokens
if textkit.negations_menge(a["definition"]) != \
textkit.negations_menge(b["definition"]):
continue # Antonyme messen 0.91-0.95 — harte Vorbedingung
kanal = _kanal(a, b)
if kanal:
paare.append((a, b, kanal))
if len(paare) >= config.DEDUP_KANDIDATEN_CAP:
return paare
return paare
def _signatur(text: str) -> set:
"""Zahlen + Eigennamen — dieselbe Studie in Paraphrase teilt sie
(a20≈a78: 'Biwer', '2023', '25', '35'), auch über Soll-Grenzen."""
import re
zahlen = set(re.findall(r"\b\d{2,4}\b", text))
namen = {w for w in re.findall(r"\b[A-ZÄÖÜ][a-zäöü]{3,}\b", text)}
return zahlen | namen
def _kanal(a: dict, b: dict) -> str:
if textkit.titel_kern(a["titel"]) == textkit.titel_kern(b["titel"]):
return "titel_kern"
if textkit.ist_akronym_von(a["titel"], b["titel"]) or \
textkit.ist_akronym_von(b["titel"], a["titel"]):
return "akronym" # Lektion 28 — fehlte in creator2
if a["soll_id"] and a["soll_id"] == b["soll_id"]:
return "gleicher_soll" # nach Verdichtung wenige Paare — Panel gatet
if len(_signatur(a["definition"]) & _signatur(b["definition"])) >= 2:
return "zahlen_namen"
if textkit.jaccard(a["definition"], b["definition"]) >= config.DEDUP_JACCARD:
return "jaccard"
ta, tb = textkit.tokens(a["titel"]), textkit.tokens(b["titel"])
if len(ta) >= 2 and len(tb) >= 2 and \
fuzz.token_set_ratio(a["titel"], b["titel"]) >= config.DEDUP_TITEL_FUZZY:
return "titel_fuzzy" # nur ≥2 Inhaltstoken (Subset-100-Falle)
return ""
async def _chunk_urteilen(task: dict, chunk: list, tag: str) -> tuple[int, list]:
"""Ein Panel über ≤ DEDUP_PAARE_PRO_CALL Paare. Entscheidet nur Paare, die
in ALLEN gültigen Stimmen vorkommen; fehlende → Rückgabe zum Nachprüfen
(nie still als „keine Dublette" werten)."""
topic = task["topic"]
liste = "\n\n".join(
f"PAAR {j + 1}:\nA[{a['id']}] {a['titel']}: {a['definition'][:200]}\n"
f"B[{b['id']}] {b['titel']}: {b['definition'][:200]}"
for j, (a, b, _) in enumerate(chunk))
async def ruf(p):
text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="dedup",
item=f"{task['item']}:{tag}:p{p}", role="judge", n=len(chunk),
skill_namen=["richter", "deutsch-praezise", "dedup-urteil"],
werte={"paare": liste})
if text is None:
return None
urteile = {}
for b in textkit.bloecke(text, "URTEIL"):
if b.get("paar", "").isdigit():
urteile[int(b["paar"])] = b.get("urteil", "UNSICHER")
return urteile
stimmen = await panels.panel(ruf, config.MERGE_PANEL)
gueltig = [s for s in stimmen if s is not None]
merges, unvollstaendig = 0, []
for j, (a, b, kanal) in enumerate(chunk):
if len(gueltig) < config.MERGE_PANEL or any((j + 1) not in s
for s in gueltig):
unvollstaendig.append((a, b, kanal))
continue
urteile = [s[j + 1] for s in gueltig]
if all(u in ("dublette", "enthalten") for u in urteile):
a_neu = db.one("SELECT * FROM atome WHERE id=?", a["id"])
b_neu = db.one("SELECT * FROM atome WHERE id=?", b["id"])
if a_neu["status"] == "aktiv" and b_neu["status"] == "aktiv":
_merge(topic, a_neu, b_neu) # Gewinner = längere Definition
merges += 1
else: # kein einstimmiges Ja → Gedächtnis-Kante, nie wieder prüfen
db.insert("kanten", ignore=True, topic=topic,
von_atom=min(a["id"], b["id"]),
zu_atom=max(a["id"], b["id"]), art="verwandt")
return merges, unvollstaendig
@engine.worker("dedup.dedup")
async def dedup(task: dict) -> engine.Ergebnis:
topic = task["topic"]
auto = _auto_merge_anker(topic)
paare = _kandidaten(topic)
merges, offen = 0, []
for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL):
chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL]
m, unvollstaendig = await _chunk_urteilen(task, chunk, f"c{i}")
merges += m
offen += unvollstaendig
rest = []
for i in range(0, len(offen), config.DEDUP_PAARE_PRO_CALL):
# EIN Nach-Call nur mit den unentschiedenen Paaren
m, unvollstaendig = await _chunk_urteilen(
task, offen[i:i + config.DEDUP_PAARE_PRO_CALL], f"nach{i}")
merges += m
rest += unvollstaendig
for a, b, _ in rest: # immer noch ohne Urteil → sichtbar, KEINE Kante
item = f"paar:{min(a['id'], b['id'])}:{max(a['id'], b['id'])}"
if not db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse' "
"AND item=?", task["run_id"], item):
db.insert("befunde", run_id=task["run_id"], stufe="inventar",
knoten="dedup", art="parse", item=item,
detail="Paar-Urteil fehlt auch nach Nach-Call",
runde=task["runde"])
stand = (db.one("SELECT COALESCE(MAX(id),0) m FROM atome WHERE topic=? AND "
"status='aktiv'", topic))["m"]
# Zuordnungs-Tasks nur für UNGEPRÜFTE Atome ohne Soll-Punkt — KEINER-Atome
# werden nicht erneut gefragt (Endlosschleifen-Lektion, Echtlauf 2)
ohne = [a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status='aktiv' AND "
"soll_id IS NULL AND soll_geprueft=0", topic)]
neue = [{"knoten": "soll_zuordnung", "item": f"{task['item']}:z{i}",
"payload": {"atom_ids": ohne[i:i + config.ZIELE_CHUNK_ATOME]}}
for i in range(0, len(ohne), config.ZIELE_CHUNK_ATOME)]
return engine.Ergebnis(daten={"auto": auto, "panel_merges": merges,
"kandidaten": len(paare), "stand": stand},
neue_tasks=neue)

360
backend/engine.py Normal file
View File

@@ -0,0 +1,360 @@
"""Task-Engine (R8): DB ist die einzige Wahrheit/Queue. Scheduler blockiert auf
einem Wecker-Event + 2-s-Fallback-Poll. Claim per UPDATE…WHERE status='offen'.
Resume = derselbe Codepfad (Loop starten, DB lesen); Zombie-Reset beim Start.
Ergebnis + status='fertig' + Folgetasks entstehen in EINER Transaktion."""
import asyncio
import hashlib
import importlib
from contextlib import suppress
from dataclasses import dataclass, field
from . import config, db, graph
from .llm import BudgetErschoepft, LaufPause
WORKER: dict[str, object] = {}
_laeufe: dict[str, asyncio.Task] = {}
_wecker: dict[int, asyncio.Event] = {}
def worker(name: str, buendel: bool = False):
def deco(fn):
fn.buendel = buendel # opt-in: Worker verarbeitet t["_buendel"]-Listen
WORKER[name] = fn
return fn
return deco
@dataclass
class Ergebnis:
"""Rückgabe jedes Workers. neue_tasks: {knoten, item, payload?, art?, runde?}
— Ziel muss über eine Kante vom eigenen Knoten erreichbar sein."""
daten: dict = field(default_factory=dict)
neue_tasks: list = field(default_factory=list)
befunde: list = field(default_factory=list) # nur Gates: {art, item, detail}
gate_status: str = "" # nur Gates: gruen | rot
teil_status: dict = field(default_factory=dict) # Bündel: task_id → fertig | neu
def module_laden() -> None:
"""Worker-Module importieren → Registrierung; danach Graph validieren."""
for mod in ("suche", "laden", "korpus", "inventar", "dedup",
"struktur", "guide", "redundanz", "montage"):
importlib.import_module(f"backend.{mod}")
graph.laden(set(WORKER))
def wecken(run_id: int) -> None:
ev = _wecker.get(run_id)
if ev:
ev.set()
# ── Lauf-Verwaltung ──
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")
run = db.one("SELECT * FROM runs WHERE topic=? AND status IN "
"('paused','budget') ORDER BY id DESC LIMIT 1", topic)
if run: # Resume: gleicher Run, gleiche Tasks
run_id = run["id"]
felder = {"status": "running", "grund": ""}
if budget is not None: # Budget-Nachschlag beim Fortsetzen
felder["budget_tokens"] = budget
db.update("runs", "id=?", (run_id,), **felder)
else:
run_id = db.insert("runs", topic=topic, status="running",
gestartet=db.now(),
budget_tokens=budget if budget is not None
else config.RUN_BUDGET_TOKENS)
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id))
return run_id
def lauf_pausieren(topic: str) -> None:
run = _aktiver_run(topic)
if run:
db.update("runs", "id=?", (run["id"],), status="paused", grund="manuell")
wecken(run["id"])
def lauf_stoppen(topic: str) -> None:
run = _aktiver_run(topic)
if run:
db.update("runs", "id=?", (run["id"],), status="stopped", beendet=db.now())
wecken(run["id"])
task = _laeufe.get(topic)
if task and not task.done():
task.cancel()
def _aktiver_run(topic: str) -> dict | None:
return db.one("SELECT * FROM runs WHERE topic=? AND status='running' "
"ORDER BY id DESC LIMIT 1", topic)
# ── Kern-Loop ──
async def _lauf(topic: str, run_id: int) -> None:
g = graph.get()
ev = _wecker[run_id] = asyncio.Event()
sem_global = asyncio.Semaphore(config.MAX_PARALLEL_GLOBAL)
sems = {k.id: asyncio.Semaphore(k.max_parallel) for k in g.knoten.values()}
laufende: set[asyncio.Task] = set()
# Zombie-Reset: ein Prozess ⇒ beim Start ist garantiert nichts in Arbeit
db.execute("UPDATE tasks SET status='offen', fehler='zombie' "
"WHERE topic=? AND status='laufend'", topic)
_start_tasks(topic, run_id)
try:
while True:
run = db.one("SELECT * FROM runs WHERE id=?", run_id)
if run["status"] != "running":
break
stufe_i = _aktuelle_stufe(g, topic)
db.update("runs", "id=? AND stufe!=?", (run_id, g.stufen[stufe_i]),
stufe=g.stufen[stufe_i])
_gate_pruefen(g, topic, run_id, stufe_i)
gestartet = _dispatch(g, topic, run_id, stufe_i, sems, sem_global,
laufende, ev)
if not gestartet and not laufende and _leer(topic):
_abschliessen(g, topic, run_id)
break
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(ev.wait(), timeout=config.POLL_SEKUNDEN)
ev.clear()
laufende = {t for t in laufende if not t.done()}
# Pause/Stop: Laufende sanft auslaufen lassen
if laufende:
await asyncio.gather(*laufende, return_exceptions=True)
finally:
_wecker.pop(run_id, None)
def _leer(topic: str) -> bool:
return not db.one("SELECT id FROM tasks WHERE topic=? AND "
"status IN ('offen','laufend') LIMIT 1", topic)
def _aktuelle_stufe(g: graph.Graph, topic: str) -> int:
for i, stufe in enumerate(g.stufen):
gate = g.gate_der_stufe(stufe)
if gate is None:
return i
gruen = db.one("SELECT id FROM gate_laeufe WHERE topic=? AND knoten=? "
"AND status='gruen'", topic, gate.id)
if not gruen:
return i
return len(g.stufen) - 1
def _dispatch(g, topic, run_id, stufe_i, sems, sem_global, laufende, ev) -> int:
offen = db.query(
"SELECT * FROM tasks WHERE topic=? AND status='offen' ORDER BY runde, id",
topic)
n = 0
for t in offen:
kn = g.knoten.get(t["knoten"])
if kn is None or g.stufen_index(kn.stufe) > stufe_i:
continue
if sems[kn.id].locked() or sem_global.locked():
continue
if kn.barriere and _vorarbeit_offen(g, topic, kn, ausser=t["id"]):
continue
if kn.typ == "gate" and _vorarbeit_offen(g, topic, kn, ausser=t["id"]):
continue
if db.execute("UPDATE tasks SET status='laufend', gestartet=? "
"WHERE id=? AND status='offen'", db.now(), t["id"]) != 1:
continue
t = dict(t)
t["_buendel"] = _buendel_claimen(g, t, kn)
task = asyncio.create_task(
_ausfuehren(g, t, kn, sems[kn.id], sem_global, ev))
laufende.add(task)
n += 1
return n
def _buendel_claimen(g, t: dict, kn: graph.Knoten) -> list[dict]:
"""Bis zu buendel1 weitere offene Tasks gleicher (knoten, art) mit-claimen.
Nur für Worker mit buendel-Opt-in — sonst blieben Mitglieder 'laufend' hängen."""
fn = WORKER[kn.worker]
if kn.buendel <= 1 or not getattr(fn, "buendel", False):
return []
mitglieder = []
kandidaten = db.query(
"SELECT * FROM tasks WHERE topic=? AND knoten=? AND art=? AND "
"status='offen' AND id!=? ORDER BY runde, id LIMIT ?",
t["topic"], t["knoten"], t["art"], t["id"], kn.buendel - 1)
for k in kandidaten:
if db.execute("UPDATE tasks SET status='laufend', gestartet=? "
"WHERE id=? AND status='offen'", db.now(), k["id"]) == 1:
mitglieder.append(dict(k))
return mitglieder
def _vorarbeit_offen(g: graph.Graph, topic: str, kn: graph.Knoten,
ausser: int) -> bool:
"""Barriere/Gate: wartet, bis KEIN offener/laufender Task an Knoten der
eigenen oder früherer Stufen existiert (außer sich selbst)."""
max_i = g.stufen_index(kn.stufe)
ids = [k.id for k in g.knoten.values()
if g.stufen_index(k.stufe) <= max_i and k.id != kn.id]
qs = ",".join("?" for _ in ids)
return bool(db.one(
f"SELECT id FROM tasks WHERE topic=? AND status IN ('offen','laufend') "
f"AND knoten IN ({qs}) AND id!=? LIMIT 1", topic, *ids, ausser))
def _gate_pruefen(g: graph.Graph, topic: str, run_id: int, stufe_i: int) -> None:
"""Stufe leer gelaufen → Gate-Task für Runde N anlegen (idempotent)."""
gate = g.gate_der_stufe(g.stufen[stufe_i])
if gate is None:
return
vorhanden = db.one("SELECT id FROM tasks WHERE topic=? AND knoten=? AND "
"status IN ('offen','laufend')", topic, gate.id)
if vorhanden:
return
if _vorarbeit_offen(g, topic, gate, ausser=-1):
return
runde = (db.one("SELECT COUNT(*) c FROM gate_laeufe WHERE topic=? AND knoten=?",
topic, gate.id) or {"c": 0})["c"] + 1
if runde > config.GATE_RUNDEN_MAX.get(gate.id, 3):
_pausieren(run_id, f"gate_cap:{gate.id}:{runde}")
return
db.insert("tasks", ignore=True, run_id=run_id, topic=topic, knoten=gate.id,
item=f"runde:{runde}", runde=runde, erzeugt_von="engine")
async def _ausfuehren(g, t: dict, kn: graph.Knoten, sem, sem_global, ev) -> None:
fn = WORKER[kn.worker]
alle = [t] + t.get("_buendel", [])
try:
async with sem_global, sem:
erg: Ergebnis = await fn(t)
except (LaufPause, BudgetErschoepft) as e:
status = "budget" if isinstance(e, BudgetErschoepft) else "paused"
for m in alle:
db.update("tasks", "id=?", (m["id"],), status="offen",
fehler=str(e)[:300])
_pausieren(t["run_id"], f"{status}:{e}", status=status)
ev.set()
return
except asyncio.CancelledError:
for m in alle:
db.update("tasks", "id=?", (m["id"],), status="offen", fehler="abbruch")
raise
except Exception as e: # Inhaltsfehler: begrenzte Neuversuche, dann sichtbar
for m in alle:
_task_neu(m, kn, f"{type(e).__name__}: {e}"[:300])
ev.set()
return
_abschluss_schreiben(g, t, kn, erg)
ev.set()
def _task_neu(t: dict, kn: graph.Knoten, fehler: str) -> None:
versuch = t["versuch"] + 1
status = "offen" if versuch < kn.max_versuche else "fehler"
db.update("tasks", "id=?", (t["id"],), status=status, versuch=versuch,
fehler=fehler)
def _abschluss_schreiben(g, t: dict, kn: graph.Knoten, erg: Ergebnis) -> None:
erlaubt = g.erlaubte_ziele(kn.id)
# Bündel-Mitglieder: kaputte Teil-Antworten → neuer Versuch, nie verwerfen
for m in t.get("_buendel", []):
if erg.teil_status.get(m["id"]) == "neu":
_task_neu(m, kn, "buendel_teil_kaputt")
else:
db.update("tasks", "id=?", (m["id"],), status="fertig",
ergebnis=db.j({"buendel_mit": t["id"]}), beendet=db.now())
with db.tx() as c:
if erg.teil_status.get(t["id"]) == "neu":
c.execute("UPDATE tasks SET status=?, versuch=?, fehler=? WHERE id=?",
("offen" if t["versuch"] + 1 < kn.max_versuche else "fehler",
t["versuch"] + 1, "buendel_teil_kaputt", t["id"]))
else:
c.execute(
"UPDATE tasks SET status='fertig', ergebnis=?, beendet=? WHERE id=?",
(db.j(erg.daten), db.now(), t["id"]))
eingefuegt = 0
for nt in erg.neue_tasks:
if nt["knoten"] not in erlaubt:
raise graph.GraphFehler(
f"{kn.id} darf keine Tasks für {nt['knoten']} erzeugen")
cur = c.execute(
"INSERT OR IGNORE INTO tasks(run_id, topic, knoten, item, art, "
"runde, payload, erzeugt_von) VALUES(?,?,?,?,?,?,?,?)",
(t["run_id"], t["topic"], nt["knoten"], nt["item"],
nt.get("art", ""), nt.get("runde", t["runde"]),
db.j(nt.get("payload", {})), kn.id))
eingefuegt += cur.rowcount
if kn.typ == "gate":
_gate_abschluss(c, t, kn, erg, eingefuegt)
def _gate_abschluss(c, t: dict, kn: graph.Knoten, erg: Ergebnis,
neu_eingefuegt: int = 0) -> None:
befunde = sorted(f"{b['art']}|{b['item']}|{b.get('detail', '')[:200]}"
for b in erg.befunde)
fingerprint = hashlib.sha256("\n".join(befunde).encode()).hexdigest()[:16]
c.execute("INSERT OR REPLACE INTO gate_laeufe(run_id, topic, knoten, runde, "
"status, fingerprint, befunde, ts) VALUES(?,?,?,?,?,?,?,?)",
(t["run_id"], t["topic"], kn.id, t["runde"], erg.gate_status,
fingerprint, db.j(erg.befunde), db.now()))
for b in erg.befunde:
# Spiegel-Zeile nur, wenn kein identischer Befund schon offen ist
schon = c.execute(
"SELECT id FROM befunde WHERE art=? AND item=? AND "
"substr(detail,1,200)=? AND status='offen'",
(b["art"], b["item"], b.get("detail", "")[:200])).fetchone()
if not schon:
c.execute("INSERT INTO befunde(run_id, stufe, knoten, art, item, "
"detail, runde) VALUES(?,?,?,?,?,?,?)",
(t["run_id"], kn.stufe, kn.id, b["art"], b["item"],
b.get("detail", "")[:500], t["runde"]))
if erg.gate_status == "gruen":
# Stufe bestanden → offene Befunde dieser Stufe sind Geschichte
c.execute("UPDATE befunde SET status='behoben' WHERE status='offen' AND "
"stufe=? AND run_id IN (SELECT id FROM runs WHERE topic=?)",
(kn.stufe, t["topic"]))
if erg.gate_status == "rot":
# Stillstand: identischer Fingerprint n-mal hintereinander UND keine
# NEUEN Reparatur-Tasks entstanden (die Eskalations-Kette kann bei
# gleicher Befundmenge trotzdem vorankommen: fix → klaerung → neu)
alte = [r["fingerprint"] for r in c.execute(
"SELECT fingerprint FROM gate_laeufe WHERE topic=? AND knoten=? "
"ORDER BY runde DESC LIMIT ?",
(t["topic"], kn.id, config.STILLSTAND_N)).fetchall()]
if neu_eingefuegt == 0:
grund = (f"stillstand:{kn.id}:r{t['runde']}"
if len(alte) >= config.STILLSTAND_N and len(set(alte)) == 1
else f"gate_rot_ohne_reparatur:{kn.id}")
c.execute("UPDATE runs SET status='paused', grund=? WHERE id=?",
(grund, t["run_id"]))
def _pausieren(run_id: int, grund: str, status: str = "paused") -> None:
db.update("runs", "id=? AND status='running'", (run_id,),
status=status, grund=grund[:200])
def _abschliessen(g: graph.Graph, topic: str, run_id: int) -> None:
"""Kein Task mehr offen: done, wenn alle Gates grün + Montage fertig."""
fertig = db.one("SELECT id FROM tasks WHERE topic=? AND knoten='montage' "
"AND status='fertig'", topic)
offen_fehler = db.one("SELECT id FROM tasks WHERE topic=? AND status='fehler' "
"LIMIT 1", topic)
if fertig and not offen_fehler:
db.update("runs", "id=?", (run_id,), status="done", beendet=db.now())
else:
grund = "tasks_mit_fehler" if offen_fehler else "leerlauf_ohne_montage"
_pausieren(run_id, grund)
def _start_tasks(topic: str, run_id: int) -> None:
"""Initiale Tasks der Stufe 1 (idempotent — Resume ist ein No-Op)."""
for lens in config.RECHERCHE_LENSES:
db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="recherche_plan", item=f"r1:lens:{lens}", runde=1,
payload=db.j({"lens": lens}), erzeugt_von="start")

316
backend/fakes.py Normal file
View File

@@ -0,0 +1,316 @@
"""Deterministische Fake-Welt (CREATOR_FAKE=1): ersetzt LLM, Suche und Laden.
Die Fakes LESEN den Prompt — Zitate kommen nur, wenn sie wörtlich im gelieferten
Text stehen (Zitat-Gate wird mitgetestet). Präparierte Eigenschaften:
- Dublette über 2 Quellen (HTTP ↔ Hypertext Transfer Protocol, Akronym-Kanal)
- Soll-Punkt „Fehlerbehandlung" ohne Atom (erst die Web-Nachrecherche deckt ihn)
- Kapitel-übergreifende Satz-Dopplung, die der Fix per Verweis ersetzt
Fehlender Handler → harter Fehler (nie still raten)."""
import re
from . import skills
FUELL = ("Dieser Absatz liefert zusätzlichen Zusammenhang für Lernende. " * 4).strip()
S1 = "Ein Protokoll regelt den Austausch von Nachrichten zwischen zwei Partnern im Netz."
S2 = "HTTP steuert die Übertragung von Webseiten zwischen Browser und Server."
S3 = "Das Hypertext Transfer Protocol überträgt Inhalte im World Wide Web."
S4 = "Kommunikation braucht klare Regeln für beide Seiten."
S5 = ("Beim Wiederholungsmuster wird eine fehlgeschlagene Anfrage nach kurzer "
"Wartezeit erneut gesendet.")
S6A = "Ohne saubere Fehlerbehandlung scheitern Systeme im Alltag."
S7A = "Ein Vorteil ist bessere Konzentration bei der Arbeit."
S7B = "Ein weiterer Vorteil ist weniger Stress im Alltag."
S6B = "Fehlerbehandlung entscheidet über die Zuverlässigkeit eines Systems."
DUP = ("Fehler lassen sich nie ganz vermeiden, entscheidend ist der richtige "
"Umgang mit ihnen.")
P1 = "Grundlagen der Protokolle und Regeln erklären"
P2 = "Das HTTP-Protokoll verstehen"
P3 = "Fehlerbehandlung beherrschen"
QUELLEN = {
"https://beispiel.de/grundlagen":
f"Einführung in das Thema.\n\n{FUELL}\n\n{S1}\n\n{FUELL}\n\n{S2}\n\n"
f"{FUELL}\n\n{S6A}\n\n{FUELL}",
"https://beispiel.de/vertiefung":
f"Vertiefende Betrachtung.\n\n{FUELL}\n\n{S4}\n\n{FUELL}\n\n{S3}\n\n"
f"{FUELL}\n\n{S6B}\n\n{S7A} {S7B}\n\n{FUELL}",
"https://beispiel.de/fehler":
f"Umgang mit Fehlern.\n\n{FUELL}\n\n{S5}\n\n{FUELL}\n\n{FUELL}",
}
SOLL = [(P1, [S1, S4]), (P2, [S2, S3]), (P3, [S6A, S6B])]
ATOME = [ # (titel, typ, definition, beleg-satz)
("Protokoll", "begriff",
"Ein Protokoll ist die Vereinbarung, nach der zwei Partner Nachrichten austauschen.", S1),
("HTTP", "begriff",
"HTTP ist das Protokoll für die Übertragung von Webseiten.", S2),
("Hypertext Transfer Protocol", "begriff",
"Das Hypertext Transfer Protocol dient der Übertragung von Inhalten im Web.", S3),
("Kommunikationsregeln", "aussage",
"Erfolgreiche Kommunikation setzt klare, beidseitig bekannte Regeln voraus.", S4),
("Vorteil Konzentration", "aussage",
"Die Methode verbessert die Konzentration bei der Arbeit.", S7A),
("Vorteil Stressabbau", "aussage",
"Die Methode senkt den empfundenen Stress im Alltag.", S7B),
("Wiederholungsmuster", "verfahren",
"Beim Wiederholungsmuster wird eine fehlgeschlagene Anfrage nach Wartezeit wiederholt.", S5),
]
_ATOM_SOLL = {"Protokoll": P1, "Kommunikationsregeln": P1, "HTTP": P2,
"Hypertext Transfer Protocol": P2, "Wiederholungsmuster": P3,
"Vorteil Konzentration": P1, "Vorteil Stressabbau": P1}
_DUBLETTEN = {frozenset({"HTTP", "Hypertext Transfer Protocol"})}
_ENTHALTEN = {frozenset({"Basisregeln", "Basisregeln und Sonderfaelle"})}
def suche(query: str) -> list[dict]:
q = query.casefold()
if "fehlerbehandlung" in q:
return [{"titel": "Fehlerbehandlung", "url": "https://beispiel.de/fehler",
"backend": "fake"}]
if "r2" in q or " v2" in q:
return [] # Runde 2: nichts Neues → Sättigung
if "ueberblick" in q:
return [{"titel": "Grundlagen", "url": "https://beispiel.de/grundlagen",
"backend": "fake"}]
if "tutorial" in q:
return [{"titel": "Vertiefung", "url": "https://beispiel.de/vertiefung",
"backend": "fake"}]
return []
def laden(url: str) -> str:
return QUELLEN.get(url, "")
def _abschnitt(prompt: str, marke: str) -> str:
m = re.search(rf"{marke}:\n(.*?)(?=\n\n[A-ZÄÖÜ\-]{{3,}}[^a-z]*:\n|\nAusgabeformat|\Z)",
prompt, re.DOTALL)
return m.group(1) if m else prompt
def _ids_liste(text: str) -> list[tuple[int, str]]:
return [(int(m.group(1)), m.group(2).strip())
for m in re.finditer(r"^\[(\d+)\]\s*(.+)$", text, re.MULTILINE)]
# ── Handler je Aufgabe ──
def _nummerierte_abschnitte(p: str) -> dict[int, str]:
"""`=== ABSCHNITT n ===`-Blöcke aus dem Bündel-Prompt lesen."""
return {int(m.group(1)): m.group(2) for m in re.finditer(
r"=== ABSCHNITT (\d+) ===\n(.*?)(?=\n\n=== ABSCHNITT |\n\nRegeln:|\Z)",
p, re.DOTALL)}
def _recherche_plan(p: str) -> str:
runde = re.search(r"\(Runde (\d+)\)", p)
r = runde.group(1) if runde else "1"
m = re.search(r"BLICKWINKEL:\n(.*?)\n\nBereits", p, re.DOTALL)
lenses = re.findall(r"^- (\w+)$", m.group(1) if m else "", re.MULTILINE)
return "\n".join(f"===QUERY===\nLENS: {l}\nTEXT: thema {l} r{r} nr{i}"
for l in (lenses or ["x"]) for i in (1, 2))
def _soll_extraktion(p: str) -> str:
out = []
for i, text in _nummerierte_abschnitte(p).items():
n = 0
for punkt, belege in SOLL:
for beleg in belege:
if beleg in text:
out.append(f"===PUNKT===\nABSCHNITT: {i}\nTEXT: {punkt}\n"
f"BELEG: {beleg}")
n += 1
if not n:
out.append(f"===LEER===\nABSCHNITT: {i}")
return "\n".join(out)
def _soll_konsens(p: str) -> str:
gruppen: dict[str, list[int]] = {}
for i, text in _ids_liste(_abschnitt(p, "KANDIDATEN")):
gruppen.setdefault(text, []).append(i)
return "\n".join(f"===GRUPPE===\nIDS: {','.join(map(str, ids))}\nTEXT: {text}"
for text, ids in gruppen.items())
def _atom_extraktion(p: str) -> str:
out = []
for i, text in _nummerierte_abschnitte(p).items():
n = 0
for titel, typ, definition, satz in ATOME:
if satz in text:
out.append(f"===ATOM===\nABSCHNITT: {i}\nTITEL: {titel}\n"
f"TYP: {typ}\nDEFINITION: {definition}\nZITAT: {satz}")
n += 1
if not n:
out.append(f"===LEER===\nABSCHNITT: {i}")
return "\n".join(out)
def _anker_fix(p: str) -> str:
return "\n".join(f"===FIX===\nID: {i}\nZITAT: VERWERFEN"
for i, _ in _ids_liste(_abschnitt(p, "ATOME")))
def _dedup_urteil(p: str) -> str:
out = []
for m in re.finditer(r"PAAR (\d+):\nA\[\d+\] ([^:]+):.*?\nB\[\d+\] ([^:]+):",
p, re.DOTALL):
nr, ta, tb = m.group(1), m.group(2).strip(), m.group(3).strip()
paar = frozenset({ta, tb})
urteil = ("dublette" if paar in _DUBLETTEN
else "enthalten" if paar in _ENTHALTEN else "verschieden")
out.append(f"===URTEIL===\nPAAR: {nr}\nURTEIL: {urteil}\nGRUND: fake")
return "\n".join(out) or "===URTEIL===\nPAAR: 1\nURTEIL: verschieden\nGRUND: leer"
def _soll_zuordnung(p: str) -> str:
soll_map = {text: i for i, text in _ids_liste(_abschnitt(p, "SOLL-PUNKTE"))}
out = []
for atom_id, zeile in _ids_liste(_abschnitt(p, "ATOME")):
titel = zeile.split(":")[0].strip()
punkt = _ATOM_SOLL.get(titel, "")
ziel = soll_map.get(punkt)
out.append(f"===ZUORDNUNG===\nATOM: {atom_id}\n"
f"SOLL: {ziel if ziel is not None else 'KEINER'}")
return "\n".join(out)
def _luecke_nachextraktion(p: str) -> str:
out = []
for m in re.finditer(r"=== LUECKE ===\nSOLL: (\d+)\nPUNKT: .*?\nFENSTER:\n"
r"(.*?)(?=\n\n=== LUECKE ===|\n\nRegeln:|\Z)",
p, re.DOTALL):
sid, fenster = m.group(1), m.group(2)
if S5 in fenster:
t, typ, d, s = next(a for a in ATOME if a[3] == S5)
out.append(f"===ATOM===\nSOLL: {sid}\nTITEL: {t}\nTYP: {typ}\n"
f"DEFINITION: {d}\nZITAT: {s}")
else:
out.append(f"===NICHTS===\nSOLL: {sid}")
return "\n".join(out)
def _verdichtung(p: str) -> str:
vorteile, andere = [], []
for i, zeile in _ids_liste(_abschnitt(p, "ATOME")):
(vorteile if "Vorteil" in zeile else andere).append(str(i))
out = []
if vorteile:
out.append(f"===EINHEIT===\nIDS: {','.join(vorteile)}")
out += [f"===EINHEIT===\nIDS: {i}" for i in andere]
return "\n".join(out)
def _lernziele(p: str) -> str:
out = []
for m in re.finditer(r"=== SOLL (\d+) ===\nPUNKT: (.*?)\nATOME:\n"
r"(.*?)(?=\n\n=== SOLL |\n\nRegeln:|\Z)", p, re.DOTALL):
i, punkt = m.group(1), m.group(2)
ids = [str(x) for x, _ in _ids_liste(m.group(3))]
out.append(f"===ZIEL===\nSOLL: {i}\nTITEL: {punkt.split()[0][:30]}\n"
f"TEXT: Der Lernende kann {punkt} anwenden.\n"
f"ATOME: {','.join(ids)}")
return "\n".join(out)
def _bausteine(p: str) -> str:
ids = [str(i) for i, _ in _ids_liste(_abschnitt(p, "ATOME (in Quellreihenfolge)"))]
return f"===BAUSTEIN===\nTITEL: Baustein\nATOME: {','.join(ids)}"
def _themen_schnitt(p: str) -> str:
fehler, rest = [], []
for i, text in _ids_liste(_abschnitt(p, "SOLL-PUNKTE")):
(fehler if "Fehlerbehandlung" in text else rest).append(str(i))
out = []
if rest:
out.append(f"===KAPITEL===\nTITEL: Grundlagen\nPUNKTE: {','.join(rest)}")
if fehler:
out.append(f"===KAPITEL===\nTITEL: Fehlerbehandlung\nPUNKTE: {','.join(fehler)}")
return "\n".join(out)
def _ordnung(p: str) -> str:
prior = re.search(r"übernehmen\): ([\d,]+)", p)
return f"===ORDNUNG===\nIDS: {prior.group(1) if prior else ''}"
def _writer(p: str) -> str:
atome = re.findall(r"<!-- atom: (\d+) -->\n\*\*(.+?)\*\* — (.+)", p)
teile = []
for aid, titel, definition in atome:
# Erster Satz bewusst KEIN Definitions-Echo (semantischer det-Check)
teile.append(f"<!-- atom: {aid} -->\n\nSchauen wir uns das Konzept "
f"nun genauer an. {definition} "
f"{FUELL} Damit ist {titel} eingeordnet.")
teile.append(DUP) # gepflanzte Dopplung — Redundanz-Check muss sie finden
return "===TEXT===\n" + "\n\n".join(teile)
def _kapitel_intro(p: str) -> str:
kap = re.search(r"Kapitel „(.+?)\"", p)
return (f"===INTRO===\nDieses Kapitel behandelt {kap.group(1) if kap else 'X'}. "
"Es baut auf dem bisher Gelernten auf.")
def _guide_pruefung(p: str) -> str:
ids = re.findall(r"^=== SECTION (\d+) ===$", p, re.MULTILINE)
return "\n".join(f"===OK===\nSECTION: {i}"
for i in dict.fromkeys(ids)) or "===OK===\nSECTION: 0"
def _guide_fix(p: str) -> str:
section = _abschnitt(p, "SECTION")
zeilen = [z for z in section.splitlines()
if "Fehler lassen sich nie ganz vermeiden" not in z]
zeilen.append("Wie bereits bei den Grundlagen beschrieben, zählt der "
"Umgang mit Fehlern.")
return "===TEXT===\n" + "\n".join(zeilen).strip()
def _redundanz_urteil(p: str) -> str:
out = []
for m in re.finditer(r"PAAR (\d+):\nKapitel A: (.*)\nKapitel B: (.*)", p):
redundant = "Fehler lassen sich nie ganz vermeiden" in m.group(2)
out.append(f"===URTEIL===\nPAAR: {m.group(1)}\n"
f"URTEIL: {'redundant' if redundant else 'didaktisch_gewollt'}")
return "\n".join(out) or "===URTEIL===\nPAAR: 1\nURTEIL: didaktisch_gewollt"
_HANDLER = {
"recherche-plan": _recherche_plan,
"soll-extraktion": _soll_extraktion,
"soll-konsens": _soll_konsens,
"soll-beleg": lambda p: "===BELEG===\nID: 0\nBELEG: KEIN BELEG",
"quellen-relevanz": lambda p: "===RELEVANT===\nIDS: " + ",".join(
m for m in re.findall(r"^\[(\d+)\]", p, re.MULTILINE)),
"atom-extraktion": _atom_extraktion,
"anker-fix": _anker_fix,
"dedup-urteil": _dedup_urteil,
"soll-zuordnung": _soll_zuordnung,
"luecke-nachextraktion": _luecke_nachextraktion,
"luecke-freispruch": lambda p: "===URTEIL===\nURTEIL: unerfuellbar\nGRUND: fake",
"verdichtung": _verdichtung,
"lernziele": _lernziele,
"bausteine": _bausteine,
"themen-schnitt": _themen_schnitt,
"ordnung": _ordnung,
"writer": _writer,
"kapitel-intro": _kapitel_intro,
"guide-pruefung": _guide_pruefung,
"guide-fix": _guide_fix,
"redundanz-urteil": _redundanz_urteil,
"klaerung": lambda p: "===URTEIL===\nURTEIL: kein_mangel\nGRUND: fake",
}
def antwort(prompt: str) -> tuple[int, str, str]:
"""→ (rc, text, err). Fehlender Handler = harter Fehler, nie still raten."""
name = skills.aufgaben_name(prompt)
handler = _HANDLER.get(name)
if handler is None:
return 1, "", f"kein Fake-Handler für Aufgabe '{name}'"
return 0, handler(prompt), ""

154
backend/graph.py Normal file
View File

@@ -0,0 +1,154 @@
"""pipeline.yaml laden + statisch validieren (R8): Normal-Kanten müssen ein DAG
sein (graphlib), Rück-/Reparatur-Kanten nur von Gates und stufen-abwärts,
Worker registriert, Skill-Dateien vorhanden, genau ein Gate je Stufe (außer
letzter). Liefert auch das vorberechnete Layout für die Graphensicht."""
import graphlib
from dataclasses import dataclass, field
import yaml
from . import config, skills
class GraphFehler(Exception):
pass
@dataclass
class Knoten:
id: str
typ: str # system | llm | gate
worker: str
stufe: str
titel: str = ""
beschreibung: str = ""
skills: list = field(default_factory=list)
klasse: str = "lang" # lang | mittel | kurz → Bündel-Größe je Call
max_parallel: int = 4
max_versuche: int = config.MAX_VERSUCHE
barriere: bool = False
@property
def buendel(self) -> int:
return config.BUENDEL[self.klasse]
@dataclass
class Graph:
stufen: list[str]
knoten: dict[str, Knoten]
kanten: list[dict] # {von, nach, art}
def stufen_index(self, stufe: str) -> int:
return self.stufen.index(stufe)
def gate_der_stufe(self, stufe: str) -> Knoten | None:
return next((k for k in self.knoten.values()
if k.typ == "gate" and k.stufe == stufe), None)
def normal_nachfolger(self, knoten_id: str) -> set[str]:
return {k["nach"] for k in self.kanten
if k["von"] == knoten_id and k["art"] == "normal"}
def erlaubte_ziele(self, knoten_id: str) -> set[str]:
"""Alle Knoten, für die dieser Knoten Tasks erzeugen darf."""
return {k["nach"] for k in self.kanten if k["von"] == knoten_id}
def layout(self) -> dict:
"""Fluss-Layout (R7): Zeile = längster Pfad über Normal-Kanten,
Spalte = Spur innerhalb der Zeile. Reine Reparatur-Ziele (ohne
Normal-Kanten) rücken neben ihr Gate."""
vor: dict[str, list[str]] = {k: [] for k in self.knoten}
for ka in self.kanten:
if ka["art"] == "normal":
vor[ka["nach"]].append(ka["von"])
ebene: dict[str, int] = {}
for kid in graphlib.TopologicalSorter(
{k: set(v) for k, v in vor.items()}).static_order():
ebene[kid] = max((ebene[v] + 1 for v in vor[kid]), default=0)
an_normal = {ka[e] for ka in self.kanten if ka["art"] == "normal"
for e in ("von", "nach")}
for ka in self.kanten:
if ka["art"] != "normal" and ka["nach"] not in an_normal:
ebene[ka["nach"]] = ebene[ka["von"]]
pos = {}
spuren: dict[int, int] = {}
for kid in sorted(ebene, key=lambda i: ebene[i]):
k = self.knoten[kid]
spur = spuren.get(ebene[kid], 0)
spuren[ebene[kid]] = spur + 1
pos[kid] = {"zeile": ebene[kid], "spalte": spur, "typ": k.typ,
"barriere": k.barriere, "stufe": k.stufe,
"titel": k.titel or kid,
"beschreibung": k.beschreibung,
"skills": [{"name": s, "art": skills.lade(s)["art"]}
for s in k.skills]}
return {"stufen": self.stufen, "knoten": pos, "kanten": self.kanten}
_graph: Graph | None = None
def laden(worker_namen: set[str] | None = None, pfad=None) -> Graph:
global _graph
daten = yaml.safe_load((pfad or config.PIPELINE_YAML).read_text())
stufen = daten["stufen"]
knoten = {}
for k in daten["knoten"]:
kn = Knoten(**{**k, "skills": k.get("skills", [])})
if kn.id in knoten:
raise GraphFehler(f"Knoten-id doppelt: {kn.id}")
if kn.stufe not in stufen:
raise GraphFehler(f"{kn.id}: unbekannte Stufe {kn.stufe}")
if kn.typ not in ("system", "llm", "gate"):
raise GraphFehler(f"{kn.id}: unbekannter Typ {kn.typ}")
if kn.klasse not in config.BUENDEL:
raise GraphFehler(f"{kn.id}: unbekannte Klasse {kn.klasse}")
knoten[kn.id] = kn
kanten = daten["kanten"]
for kante in kanten:
for ende in ("von", "nach"):
if kante[ende] not in knoten:
raise GraphFehler(f"Kante {kante}: unbekannter Knoten {kante[ende]}")
if kante["art"] not in ("normal", "reparatur", "ruecklauf"):
raise GraphFehler(f"Kante {kante}: unbekannte Art")
if kante["art"] != "normal":
von = knoten[kante["von"]]
if von.typ != "gate":
raise GraphFehler(f"{kante['art']}-Kante nur von Gates: {kante}")
if stufen.index(knoten[kante["nach"]].stufe) > stufen.index(von.stufe):
raise GraphFehler(f"{kante['art']}-Kante muss stufen-abwärts zeigen: {kante}")
# Normal-Kanten: DAG-Pflicht (graphlib erwartet Vorgänger-Format)
vorgaenger: dict[str, set] = {k: set() for k in knoten}
for kante in kanten:
if kante["art"] == "normal":
vorgaenger[kante["nach"]].add(kante["von"])
try:
graphlib.TopologicalSorter(vorgaenger).prepare()
except graphlib.CycleError as e:
raise GraphFehler(f"Zyklus in Normal-Kanten: {e}") from e
for stufe in stufen[:-1]:
gates = [k for k in knoten.values() if k.typ == "gate" and k.stufe == stufe]
if len(gates) != 1:
raise GraphFehler(f"Stufe {stufe}: genau 1 Gate nötig, {len(gates)} gefunden")
for kn in knoten.values():
if kn.typ == "llm" and not kn.skills:
raise GraphFehler(f"{kn.id}: llm-Knoten ohne Skills")
for s in kn.skills:
skills.lade(s) # wirft SkillFehler, wenn Datei fehlt/kaputt
if worker_namen is not None and kn.worker not in worker_namen:
raise GraphFehler(f"{kn.id}: Worker '{kn.worker}' nicht registriert")
_graph = Graph(stufen=stufen, knoten=knoten, kanten=kanten)
return _graph
def get() -> Graph:
if _graph is None:
raise GraphFehler("Graph nicht geladen")
return _graph

460
backend/guide.py Normal file
View File

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

518
backend/inventar.py Normal file
View File

@@ -0,0 +1,518 @@
"""Stufe 2: Atome extrahieren (2 Reader, Anker-Pflicht), Anker-Fix
(deterministisch vor LLM), Soll-Zuordnung, Lücken schließen (Nachextraktion →
Web-Nachrecherche → Freispruch-Panel). Gate zählt Coverage deterministisch."""
from . import config, db, engine, laden, llm, panels, textkit
def _quelle(qid: int) -> dict:
return db.one("SELECT * FROM quellen WHERE id=?", qid)
def _snapshot(qid: int) -> str:
return laden.snapshot_lesen(_quelle(qid))
def _halbieren(m: dict, p: dict, run_id: int) -> list:
"""Output-Cap: Chunk halbieren statt Retry (Lektion!); Tiefe gedeckelt."""
tiefe = p.get("tiefe", 0)
if tiefe >= config.CHUNK_HALBIER_TIEFE:
db.insert("befunde", run_id=run_id, stufe="inventar",
knoten="atom_extraktion", art="quelle_unvollstaendig",
item=m["item"], detail="Cap trotz Halbierung")
return []
halb = p["chars"] // 2
return [{"knoten": "atom_extraktion", "item": f"{m['item']}:h{i}",
"payload": {**p, "offset": p["offset"] + i * halb,
"chars": halb + (p["chars"] % 2 if i else 0),
"tiefe": tiefe + 1}}
for i in (0, 1)]
@engine.worker("inventar.atom_extraktion", buendel=True)
async def atom_extraktion(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
teil, aktive = {}, {} # idx → (mitglied, payload, chunk)
idx = 0
for m in mitglieder:
p = db.uj(m["payload"], {})
# Resume-Guard: Atome dieses Tasks (Item als Herkunft) nicht doppeln
if db.one("SELECT id FROM atome WHERE topic=? AND typ LIKE ? LIMIT 1",
task["topic"], f"%|{m['item']}"):
teil[m["id"]] = "fertig"
continue
text = _snapshot(p["quelle_id"])
idx += 1
aktive[idx] = (m, p, text[p["offset"]:p["offset"] + p["chars"]])
if not aktive:
return engine.Ergebnis(daten={"skip": True}, teil_status=teil)
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="atom_extraktion",
item=task["item"], role="extraktion", n=len(aktive),
skill_namen=["extraktor", "deutsch-praezise", "atom-extraktion"],
werte={"thema": thema,
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
for i, (_, _, c) in aktive.items())})
if antwort is None: # Output-Cap: alle Mitglieder halbieren, keiner verloren
neue = []
for i, (m, p, _) in aktive.items():
neue += _halbieren(m, p, task["run_id"])
teil[m["id"]] = "fertig"
return engine.Ergebnis(daten={"halbiert": True}, neue_tasks=neue,
teil_status=teil)
je_abschnitt: dict[int, list] = {}
for block in textkit.bloecke(antwort, "ATOM"):
a = block.get("abschnitt", "").strip()
if a.isdigit():
je_abschnitt.setdefault(int(a), []).append(block)
leer = {int(b["abschnitt"]) for b in textkit.bloecke(antwort, "LEER")
if b.get("abschnitt", "").strip().isdigit()}
n_gesamt, neue_quellen = 0, []
for i, (m, p, chunk) in aktive.items():
if i not in je_abschnitt and i not in leer:
teil[m["id"]] = "neu" # Abschnitt fehlt in der Antwort → neuer Versuch
continue
for block in je_abschnitt.get(i, []):
titel, zitat = block.get("titel", ""), block.get("zitat", "")
if not titel or not zitat:
continue
span = textkit.finde_zitat(chunk, zitat)
atom_id = db.insert(
"atome", topic=task["topic"], titel=titel[:120],
typ=f"{block.get('typ', 'begriff').split()[0]}|{m['item']}",
definition=block.get("definition", "")[:600], status="neu",
soll_id=p.get("soll_id"))
start, ende = (p["offset"] + span[0], p["offset"] + span[1]) \
if span else (-1, -1)
# Zitat = exakter Quell-Slice (byte-wörtlich), nicht das LLM-Echo
db.insert("anker", atom_id=atom_id, quelle_id=p["quelle_id"],
start=start, ende=ende,
zitat=(chunk[span[0]:span[1]][:600] if span else zitat[:600]))
n_gesamt += 1
teil[m["id"]] = "fertig"
if p["quelle_id"] not in neue_quellen:
neue_quellen.append(p["quelle_id"])
return engine.Ergebnis(daten={"atome": n_gesamt}, teil_status=teil, neue_tasks=[
{"knoten": "anker_fix", "item": f"quelle:{qid}:r{task['runde']}",
"payload": {"quelle_id": qid}} for qid in neue_quellen])
@engine.worker("inventar.anker_fix")
async def anker_fix(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
qid = payload["quelle_id"]
text = _snapshot(qid)
offen = db.query(
"SELECT a.id atom_id, a.titel, an.id anker_id, an.zitat FROM atome a "
"JOIN anker an ON an.atom_id=a.id AND an.quelle_id=? AND an.start=-1 "
"WHERE a.topic=? AND a.status IN ('neu','aktiv')", qid, task["topic"])
det_fix = 0
rest = []
for z in offen: # Stufe 1: deterministisches Re-Match (0-Call-Fall häufig)
span = textkit.finde_zitat(text, z["zitat"])
if span:
db.update("anker", "id=?", (z["anker_id"],), start=span[0], ende=span[1])
det_fix += 1
else:
rest.append(z)
llm_fix = verworfen = 0
for i in range(0, len(rest), config.ANKER_FIX_BATCH):
batch = rest[i:i + config.ANKER_FIX_BATCH]
liste = "\n".join(f"[{z['atom_id']}] {z['titel']}: {z['zitat'][:200]}"
for z in batch)
antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="anker_fix",
item=f"{task['item']}:b{i}", role="extraktion", n=len(batch),
skill_namen=["extraktor", "deutsch-praezise", "anker-fix"],
werte={"atome": liste, "quelltext": text[:config.SOLL_CHUNK_CHARS]})
if antwort is None: # Call-Ausfall: neuer Versuch statt stilles Auslassen
raise RuntimeError("anker_fix ohne Ergebnis")
antworten = {int(b["id"]): b.get("zitat", "") for b in
textkit.bloecke(antwort, "FIX") if b.get("id", "").isdigit()}
for z in batch:
neu = antworten.get(z["atom_id"], "")
span = textkit.finde_zitat(text, neu) if neu and neu != "VERWERFEN" else None
if span:
db.update("anker", "id=?", (z["anker_id"],),
start=span[0], ende=span[1], zitat=neu[:600])
llm_fix += 1
elif neu == "VERWERFEN":
db.update("atome", "id=?", (z["atom_id"],), status="verworfen")
verworfen += 1
# Verankerte sofort aktivieren — Dedup/Zuordnung sehen sonst nichts
db.execute("UPDATE atome SET status='aktiv' WHERE topic=? AND status='neu' "
"AND id IN (SELECT atom_id FROM anker WHERE quelle_id=? AND "
"start>=0)", task["topic"], qid)
return engine.Ergebnis(
daten={"det": det_fix, "llm": llm_fix, "verworfen": verworfen},
neue_tasks=[{"knoten": "dedup", "item": f"r{task['runde']}:dedup"}])
@engine.worker("inventar.soll_zuordnung")
async def soll_zuordnung(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
atome = [a for i in payload["atom_ids"]
if (a := db.one("SELECT * FROM atome WHERE id=? AND status='aktiv' "
"AND soll_id IS NULL", i))]
if not atome:
return engine.Ergebnis(daten={"skip": True})
soll = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'",
task["topic"])
antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="soll_zuordnung",
item=task["item"], role="judge", n=len(atome),
skill_namen=["richter", "deutsch-praezise", "soll-zuordnung"],
werte={"atome": "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}"
for a in atome),
"soll": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)})
if antwort is None:
raise RuntimeError("soll_zuordnung ohne Ergebnis")
soll_ids = {s["id"] for s in soll}
n = 0
for b in textkit.bloecke(antwort, "ZUORDNUNG"):
if not b.get("atom", "").isdigit():
continue
ziel = b.get("soll", "")
if ziel.isdigit() and int(ziel) in soll_ids:
db.update("atome", "id=?", (int(b["atom"]),), soll_id=int(ziel))
n += 1
# ALLE Atome dieses Batches gelten als geprüft — auch die mit KEINER.
# Sonst fragt jede Dedup-Runde dieselben Atome erneut (Endlosschleife).
for a in atome:
db.update("atome", "id=?", (a["id"],), soll_geprueft=1)
return engine.Ergebnis(daten={"zugeordnet": n})
async def _freispruch(task: dict, soll: dict) -> bool:
auszuege = _beleg_fenster(soll)
async def ruf(i):
text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="luecke",
item=f"{task['item']}:p{i}", role="judge",
skill_namen=["richter", "deutsch-praezise", "luecke-freispruch"],
werte={"soll_punkt": soll["punkt"], "auszuege": auszuege[:20000]})
urteile = textkit.bloecke(text or "", "URTEIL")
return urteile[0].get("urteil", "") if urteile else None
stimmen = await panels.panel(ruf, config.MERGE_PANEL)
if panels.einstimmig(stimmen, config.MERGE_PANEL,
lambda s: True if s == "unerfuellbar"
else (False if s == "abgedeckt" else None)):
db.update("soll", "id=?", (soll["id"],),
freispruch=f"Panel einstimmig unerfuellbar (r{task['runde']})")
return True
return False
@engine.worker("inventar.luecke", buendel=True)
async def luecke(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
teil, aktive = {}, {} # soll_id → (mitglied, soll, fenster)
for m in mitglieder:
p = db.uj(m["payload"], {})
soll = db.one("SELECT * FROM soll WHERE id=?", p["soll_id"])
if soll is None:
teil[m["id"]] = "fertig"
continue
if not p.get("fenster") and db.one(
"SELECT id FROM atome WHERE soll_id=? AND status='aktiv'",
soll["id"]):
# (Facetten-Tasks haben ein Fenster und laufen auch bei gedecktem Punkt)
teil[m["id"]] = "fertig" # inzwischen gedeckt
continue
if task["art"] == "freispruch":
# Panel je Soll-Punkt — Bündel läuft sequenziell durch dieselbe Logik
await _freispruch(m, soll)
teil[m["id"]] = "fertig"
continue
# Nachextraktion: gezieltes Facetten-Fenster ODER ±6k um alle Belegstellen
if p.get("fenster"):
f = p["fenster"]
q = _quelle(f["quelle_id"])
fenster = laden.snapshot_lesen(q)[f["von"]:f["bis"]] if q else ""
else:
fenster = _beleg_fenster(soll)
aktive[soll["id"]] = (m, soll, fenster[:30000])
if not aktive:
return engine.Ergebnis(daten={"skip": True}, teil_status=teil)
antwort = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="luecke",
item=task["item"], role="extraktion", n=len(aktive),
skill_namen=["extraktor", "deutsch-praezise", "luecke-nachextraktion"],
werte={"luecken": "\n\n".join(
f"=== LUECKE ===\nSOLL: {sid}\nPUNKT: {s['punkt']}\nFENSTER:\n{f}"
for sid, (_, s, f) in aktive.items())})
if antwort is None:
raise RuntimeError("luecke ohne Ergebnis")
je_soll: dict[int, list] = {}
for block in textkit.bloecke(antwort, "ATOM"):
s = block.get("soll", "").strip()
if s.isdigit():
je_soll.setdefault(int(s), []).append(block)
nichts = {int(b["soll"]) for b in textkit.bloecke(antwort, "NICHTS")
if b.get("soll", "").strip().isdigit()}
gesamt = 0
for sid, (m, soll, _) in aktive.items():
if sid not in je_soll and sid not in nichts:
teil[m["id"]] = "neu" # Soll-Punkt fehlt in der Antwort → neuer Versuch
continue
n = 0
for block in je_soll.get(sid, []):
zitat = block.get("zitat", "")
for beleg in db.uj(soll["belege"], []):
q = _quelle(beleg["quelle"])
if q is None or q["status"] != "geladen":
continue
text_q = laden.snapshot_lesen(q)
span = textkit.finde_zitat(text_q, zitat)
if span:
atom_id = db.insert(
"atome", topic=task["topic"],
titel=block.get("titel", "")[:120],
typ=f"{block.get('typ', 'begriff').split()[0]}|{m['item']}",
definition=block.get("definition", "")[:600],
status="aktiv", soll_id=soll["id"])
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
start=span[0], ende=span[1],
zitat=text_q[span[0]:span[1]][:600])
n += 1
break
teil[m["id"]] = "fertig"
gesamt += n
return engine.Ergebnis(daten={"gefunden": gesamt}, teil_status=teil)
@engine.worker("inventar.verdichtung")
async def verdichtung(task: dict) -> engine.Ergebnis:
"""Minimalität (Ziel 2): Atome eines Soll-Punkts in möglichst wenige
lehrbare Einheiten falten. Fusion NUR im Schnitt beider Panel-Stimmen
(einstimmig, Lektion 18); Anker wandern mit — Abdeckung bleibt."""
from .dedup import _merge
payload = db.uj(task["payload"], {})
soll = db.one("SELECT * FROM soll WHERE id=?", payload["soll_id"])
if soll is None or soll["verdichtet"]:
return engine.Ergebnis(daten={"skip": True})
atome = db.query("SELECT * FROM atome WHERE soll_id=? AND status='aktiv'",
soll["id"])
if len(atome) <= config.VERDICHTUNG_ZIEL:
db.update("soll", "id=?", (soll["id"],), verdichtet=1)
return engine.Ergebnis(daten={"skip": True})
nach_id = {a["id"]: a for a in atome}
liste = "\n".join(f"[{a['id']}] {a['titel']}: {a['definition'][:150]}"
for a in atome)
async def ruf(i):
text = await llm.call(
run_id=task["run_id"], stufe="inventar", knoten="verdichtung",
item=f"{task['item']}:p{i}", role="judge", n=len(atome),
skill_namen=["richter", "deutsch-praezise", "verdichtung"],
werte={"soll_punkt": soll["punkt"], "atome": liste,
"ziel": config.VERDICHTUNG_ZIEL})
if text is None:
return None
gruppen, gesehen = [], set()
for block in textkit.bloecke(text, "EINHEIT"):
ids = [int(x) for x in block.get("ids", "").replace(" ", "").split(",")
if x.isdigit() and int(x) in nach_id and int(x) not in gesehen]
if ids:
gruppen.append(ids)
gesehen |= set(ids)
gruppen += [[a["id"]] for a in atome if a["id"] not in gesehen]
return gruppen
stimmen = [s for s in await panels.panel(ruf, config.MERGE_PANEL) if s]
if len(stimmen) < config.MERGE_PANEL:
raise RuntimeError("verdichtung: Panel unvollständig") # fail-closed
# Schnitt: nur Paare, die BEIDE Stimmen zusammen gruppieren
def paare(gruppen):
out = set()
for g in gruppen:
for i, a in enumerate(g):
for b in g[i + 1:]:
out.add(frozenset((a, b)))
return out
gemeinsam = paare(stimmen[0])
for s in stimmen[1:]:
gemeinsam &= paare(s)
# Connected Components über die einstimmigen Paare
eltern = {a["id"]: a["id"] for a in atome}
def wurzel(x):
while eltern[x] != x:
eltern[x] = eltern[eltern[x]]
x = eltern[x]
return x
for paar in gemeinsam:
a, b = tuple(paar)
eltern[wurzel(a)] = wurzel(b)
gruppen: dict[int, list[int]] = {}
for aid in eltern:
gruppen.setdefault(wurzel(aid), []).append(aid)
fusionen = 0
for mitglieder in gruppen.values():
if len(mitglieder) < 2:
continue
kopf = max(mitglieder, key=lambda i: len(nach_id[i]["definition"]))
for m in mitglieder:
if m == kopf:
continue
k = db.one("SELECT * FROM atome WHERE id=?", kopf)
v = db.one("SELECT * FROM atome WHERE id=?", m)
if k["status"] == "aktiv" and v["status"] == "aktiv":
_merge(task["topic"], k, v)
fusionen += 1
db.update("soll", "id=?", (soll["id"],), verdichtet=1)
uebrig = db.one("SELECT COUNT(*) c FROM atome WHERE soll_id=? AND "
"status='aktiv'", soll["id"])["c"]
return engine.Ergebnis(daten={"vorher": len(atome), "nachher": uebrig,
"fusionen": fusionen})
def _beleg_fenster(soll: dict) -> str:
teile = []
for beleg in db.uj(soll["belege"], []):
q = _quelle(beleg["quelle"])
if q is None or q["status"] != "geladen":
continue
text = laden.snapshot_lesen(q)
span = textkit.finde_zitat(text, beleg["zitat"])
mitte = span[0] if span else 0
von = max(0, mitte - config.LUECKE_FENSTER // 2)
teile.append(text[von:von + config.LUECKE_FENSTER])
return "\n\n---\n\n".join(teile)
@engine.worker("inventar.gate")
async def gate(task: dict) -> engine.Ergebnis:
topic, runde = task["topic"], task["runde"]
befunde, neue = [], []
# (a) aktive/neue Atome ohne Anker → Reparatur anker_fix
unverankert = db.query(
"SELECT DISTINCT an.quelle_id q FROM atome a JOIN anker an ON an.atom_id=a.id "
"WHERE a.topic=? AND a.status IN ('neu','aktiv') AND an.start=-1", topic)
for z in unverankert:
befunde.append({"art": "anker", "item": f"quelle:{z['q']}", "detail": ""})
neue.append({"knoten": "anker_fix", "item": f"quelle:{z['q']}:fix:r{runde}",
"art": "reparatur", "runde": runde + 1,
"payload": {"quelle_id": z["q"]}})
# Atome 'neu' → 'aktiv' (verankerte)
db.execute("UPDATE atome SET status='aktiv' WHERE topic=? AND status='neu' AND "
"id IN (SELECT atom_id FROM anker WHERE start>=0)", topic)
db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND status='neu' "
"AND id NOT IN (SELECT atom_id FROM anker WHERE start>=0)", topic)
# (b) neue Atome seit letztem Dedup → Dedup-Runde
max_atom = (db.one("SELECT COALESCE(MAX(id),0) m FROM atome WHERE topic=? "
"AND status='aktiv'", topic))["m"]
dedup_task = db.one("SELECT id, ergebnis FROM tasks WHERE topic=? AND "
"knoten='dedup' AND status='fertig' ORDER BY id DESC "
"LIMIT 1", topic) or {"id": 0, "ergebnis": "{}"}
letzter_dedup = db.uj(dedup_task["ergebnis"], {}).get("stand", 0)
# Zuordnung NACH dem letzten Dedup → Gleicher-Soll-Kanal konnte noch nicht
# feuern → eine Dedup-Runde nachlegen
zuordnung_danach = db.one(
"SELECT id FROM tasks WHERE topic=? AND knoten='soll_zuordnung' AND "
"status='fertig' AND id>? AND ergebnis LIKE '%zugeordnet%' AND "
"ergebnis NOT LIKE '%\"zugeordnet\": 0%' LIMIT 1",
topic, dedup_task["id"])
if max_atom > letzter_dedup or zuordnung_danach:
# Item trägt den BEWEGLICHEN Zustand mit — sonst sieht der Stillstand-
# Fingerprint echten Fortschritt (neue Zuordnungen) als identisch an
befunde.append({"art": "dedup_ausstehend",
"item": f"stand:{max_atom}:z{(zuordnung_danach or {}).get('id', 0)}",
"detail": ""})
neue.append({"knoten": "dedup", "item": f"g{runde + 1}:dedup",
"art": "reparatur", "runde": runde + 1})
# (c) Coverage: jeder bestätigte Soll-Punkt braucht ≥1 aktives Atom oder Freispruch
luecken = db.query(
"SELECT s.* FROM soll s WHERE s.topic=? AND s.status='bestaetigt' AND "
"s.freispruch='' AND NOT EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv')", topic)
web_runden = db.one(
"SELECT COUNT(DISTINCT item) c FROM tasks WHERE topic=? AND knoten='suche' "
"AND item LIKE 'luecke:%'", topic)["c"]
for s in luecken:
befunde.append({"art": "luecke", "item": f"soll:{s['id']}",
"detail": s["punkt"][:120]})
bisher = db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
"knoten='luecke' AND item LIKE ? AND item NOT LIKE '%:frei:%'",
topic, f"%:soll:{s['id']}")["c"]
if bisher < config.LUECKEN_RUNDEN_MAX:
neue.append({"knoten": "luecke", "item": f"r{runde + 1}:soll:{s['id']}",
"art": "reparatur", "runde": runde + 1,
"payload": {"soll_id": s["id"]}})
elif web_runden < config.WEB_NACHRECHERCHE_RUNDEN * max(1, len(luecken)):
# Web-Nachrecherche: Query GEGROUNDET aus Punkt + Zitat-Kern (R11)
zitat = (db.uj(s["belege"], []) or [{}])[0].get("zitat", "")
query = " ".join(sorted(textkit.tokens(s["punkt"]))[:4] +
sorted(textkit.tokens(zitat))[:3])
neue.append({"knoten": "suche", "item": f"luecke:{s['id']}:r{runde + 1}",
"art": "ruecklauf", "runde": runde + 1,
"payload": {"query": query, "zweck": f"luecke:{s['id']}"}})
else:
neue.append({"knoten": "luecke",
"item": f"r{runde + 1}:frei:soll:{s['id']}",
"art": "freispruch", "runde": runde + 1,
"payload": {"soll_id": s["id"]}})
# (d) Beifang raus: geprüfte Atome ohne Soll-Bezug verwerfen — Abdeckung
# misst sich am Soll; alles andere bläht nur (32-Bausteine-Lektion)
db.execute("UPDATE atome SET status='verworfen' WHERE topic=? AND "
"status='aktiv' AND soll_id IS NULL AND soll_geprueft=1", topic)
# (d2) Facetten HART: jede Beleg-Stelle braucht ein Atom in der Nähe
# (Gutachten: Namensherkunft fehlte trotz 7 Belegen). Cap: 2 gezielte
# Nachextraktionen je Stelle, dann eskaliert (dokumentierte Grenze).
from . import belege as belege_mod
final = {r["item"] for r in db.query(
"SELECT item FROM befunde WHERE art='facette' AND status IN "
"('eskaliert','freispruch') AND run_id IN (SELECT id FROM runs WHERE "
"topic=?)", topic)}
for st in belege_mod.unbedeckte_belege(topic):
key = f"beleg:{st['quelle_id']}:{st['start']}:soll:{st['soll_id']}"
if key in final:
continue
versuche = db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
"knoten='luecke' AND item LIKE ?",
topic, f"%:{key}")["c"]
if versuche >= config.LUECKEN_RUNDEN_MAX:
db.insert("befunde", run_id=task["run_id"], stufe="inventar",
knoten="gate_inventar", art="facette", item=key,
detail=f"unabgedeckt trotz Nachextraktion: {st['zitat']}",
status="eskaliert", runde=runde)
continue
befunde.append({"art": "facette", "item": key,
"detail": st["zitat"][:120]})
von = max(0, st["start"] - config.LUECKE_FENSTER // 2)
neue.append({"knoten": "luecke", "item": f"r{runde + 1}:{key}",
"art": "reparatur", "runde": runde + 1,
"payload": {"soll_id": st["soll_id"],
"fenster": {"quelle_id": st["quelle_id"],
"von": von,
"bis": von + config.LUECKE_FENSTER}}})
# (e) Minimalität: dicke Soll-Punkte einmalig verdichten (Ziel 2)
dicke = db.query(
"SELECT s.id, COUNT(a.id) n FROM soll s JOIN atome a ON a.soll_id=s.id "
"AND a.status='aktiv' WHERE s.topic=? AND s.status='bestaetigt' AND "
"s.verdichtet=0 GROUP BY s.id HAVING n > ?",
topic, config.VERDICHTUNG_ZIEL)
for d in dicke:
befunde.append({"art": "unverdichtet", "item": f"soll:{d['id']}",
"detail": f"{d['n']} Atome"})
neue.append({"knoten": "verdichtung", "item": f"r{runde + 1}:verd:soll:{d['id']}",
"art": "reparatur", "runde": runde + 1,
"payload": {"soll_id": d["id"]}})
if befunde:
return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue)
# grün → Lernziele je Soll-Punkt (+ Sammelziel für Atome ohne Soll)
db.update("topics", "name=?", (topic,), status="inventar_fertig")
neue = [{"knoten": "lernziele", "item": f"soll:{s['id']}",
"payload": {"soll_id": s["id"]}}
for s in db.query("SELECT id FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)]
return engine.Ergebnis(gate_status="gruen", neue_tasks=neue)

232
backend/korpus.py Normal file
View File

@@ -0,0 +1,232 @@
"""Stufe 1: Recherche-Planung → Soll-Extraktion (Zitat-Gate) → Konsens
(Judge gruppiert NUR, der Code zählt ≥2 unabhängige Quellen) → hartes Gate
mit Sättigungslogik (Nullrunde nur mit neuen Queries gültig, R11)."""
import hashlib
from . import config, db, engine, laden, llm, textkit
def _thema(topic: str) -> str:
t = db.one("SELECT titel, name FROM topics WHERE name=?", topic)
return (t.get("titel") or t["name"]) if t else topic
@engine.worker("korpus.recherche_plan", buendel=True)
async def recherche_plan(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
lens_von = {m["id"]: db.uj(m["payload"], {}).get("lens", "")
for m in mitglieder}
seed = []
if task["runde"] == 1:
# Wikipedia-Seed: kuratierter Grundstock ist Pflicht, nicht Suchglück
# (Audit Run 7: wikipedia_de fehlte komplett) — einmalig, idempotent
seed = [{"knoten": "suche", "item": "r1:wikiseed",
"payload": {"seed": True}}]
bisherige = [db.uj(t["ergebnis"], {}).get("query", "") for t in db.query(
"SELECT ergebnis FROM tasks WHERE topic=? AND knoten='suche'", task["topic"])]
text = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="recherche_plan",
item=task["item"], role="judge", n=len(mitglieder),
skill_namen=["planer", "deutsch-praezise", "recherche-plan"],
werte={"thema": _thema(task["topic"]),
"lenses": "\n".join(f"- {l}" for l in lens_von.values()),
"runde": task["runde"],
"bisherige_queries": "\n".join(f"- {q}" for q in bisherige if q) or "-"})
if text is None:
raise RuntimeError("recherche_plan ohne Ergebnis")
je_lens: dict[str, list] = {}
for block in textkit.bloecke(text, "QUERY"):
q = block.get("text", "").strip()
if q:
je_lens.setdefault(block.get("lens", "").strip(), []).append(q)
neue, teil = [], {}
for m in mitglieder:
queries = je_lens.get(lens_von[m["id"]], [])[:config.QUERIES_JE_LENS]
if not queries: # Lens fehlt in der Antwort → neuer Versuch, nie stumm
teil[m["id"]] = "neu"
continue
teil[m["id"]] = "fertig"
for q in queries:
h = hashlib.sha256(q.encode()).hexdigest()[:8]
neue.append({"knoten": "suche", "item": f"r{m['runde']}:q:{h}",
"payload": {"query": q}})
return engine.Ergebnis(daten={"queries": len(neue)},
neue_tasks=seed + neue, teil_status=teil)
@engine.worker("korpus.soll_extraktion", buendel=True)
async def soll_extraktion(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
chunks = {} # idx → (mitglied, quelle, chunk)
for i, m in enumerate(mitglieder, 1):
p = db.uj(m["payload"], {})
quelle = db.one("SELECT * FROM quellen WHERE id=?", p["quelle_id"])
text = laden.snapshot_lesen(quelle)
chunks[i] = (m, quelle, text[p["offset"]:p["offset"] + p["chars"]])
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_extraktion",
item=task["item"], role="extraktion", n=len(mitglieder),
skill_namen=["extraktor", "deutsch-praezise", "soll-extraktion"],
werte={"thema": _thema(task["topic"]),
"abschnitte": "\n\n".join(f"=== ABSCHNITT {i} ===\n{c}"
for i, (_, _, c) in chunks.items())})
if antwort is None:
raise RuntimeError("soll_extraktion ohne Ergebnis")
je_abschnitt: dict[int, list] = {}
for block in textkit.bloecke(antwort, "PUNKT"):
a = block.get("abschnitt", "").strip()
if a.isdigit():
je_abschnitt.setdefault(int(a), []).append(block)
leer = {int(b["abschnitt"]) for b in textkit.bloecke(antwort, "LEER")
if b.get("abschnitt", "").strip().isdigit()}
n_gesamt, teil = 0, {}
for i, (m, quelle, chunk) in chunks.items():
if i not in je_abschnitt and i not in leer:
teil[m["id"]] = "neu" # Abschnitt fehlt in der Antwort → neuer Versuch
continue
# Resume-Idempotenz: Kandidaten dieses Chunks vorher räumen
db.execute("DELETE FROM soll WHERE topic=? AND status='kandidat' AND "
"belege LIKE ?", task["topic"], f'%"chunk": "{m["item"]}"%')
for block in je_abschnitt.get(i, []):
punkt, beleg = block.get("text", ""), block.get("beleg", "")
# Zitat-Gate: Beleg muss wörtlich in der Quelle stehen
if punkt and beleg and textkit.finde_zitat(chunk, beleg):
db.insert("soll", topic=task["topic"], punkt=punkt[:300],
belege=db.j([{"quelle": quelle["id"],
"zitat": beleg[:500], "chunk": m["item"]}]))
n_gesamt += 1
teil[m["id"]] = "fertig"
return engine.Ergebnis(
daten={"punkte": n_gesamt}, teil_status=teil,
neue_tasks=[{"knoten": "soll_konsens", "item": f"r{task['runde']}:konsens"}])
@engine.worker("korpus.soll_konsens")
async def soll_konsens(task: dict) -> engine.Ergebnis:
"""Judge gruppiert, Code zählt. NEU (Lektion 90): Ziel-Band ≈ √Kandidaten —
ohne Band bestätigte creator3 353 Mikro-Punkte für ein Mini-Thema."""
bestaetigt = 0
for _ in range(2): # 2. Pass faltet zu feine Ergebnisse nochmal
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status IN "
"('kandidat','bestaetigt')", task["topic"])
if not punkte:
return engine.Ergebnis(daten={"bestaetigt": 0})
ziel = max(config.SOLL_PUNKTE_MIN,
round(len(punkte) ** 0.5 * config.SOLL_BAND_FAKTOR))
liste = "\n".join(f"[{k['id']}] {k['punkt']}" for k in punkte)
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="soll_konsens",
item=f"{task['item']}:n{len(punkte)}", role="judge", n=len(punkte),
skill_namen=["richter", "deutsch-praezise", "soll-konsens"],
werte={"punkte": liste, "ziel": ziel})
if antwort is None:
raise RuntimeError("soll_konsens ohne Ergebnis")
bekannt = {k["id"]: k for k in punkte}
bestaetigt = 0
for gruppe in textkit.bloecke(antwort, "GRUPPE"):
ids = [int(i) for i in gruppe.get("ids", "").replace(" ", "").split(",")
if i.isdigit() and int(i) in bekannt]
if not ids:
continue
belege = []
for i in ids:
belege += db.uj(bekannt[i]["belege"], [])
quellen = {b["quelle"] for b in belege}
# DER CODE zählt — ≥2 unabhängige Quellen ODER 1 kuratierte
# (Wikipedia = vertrauenswürdig, creator2-uni-Prinzip; sonst
# verlieren Einzelquellen-Facetten wie „Overflow-Pomodoros")
kuratiert = db.one(
"SELECT id FROM quellen WHERE id IN ({}) AND backend LIKE "
"'wikipedia%'".format(",".join("?" * len(quellen))),
*quellen) if quellen else None
noetig = config.SOLL_MIN_BELEGE_WIKI if kuratiert \
else config.SOLL_MIN_BELEGE
if len(quellen) >= noetig:
kopf = ids[0]
db.update("soll", "id=?", (kopf,), status="bestaetigt",
punkt=gruppe.get("text", bekannt[kopf]["punkt"])[:300],
belege=db.j(belege))
for i in ids[1:]:
db.update("soll", "id=?", (i,), status="gefaltet")
bestaetigt += 1
if bestaetigt <= ziel * config.SOLL_BAND_TOLERANZ:
break # im Band — fertig
return engine.Ergebnis(daten={"bestaetigt": bestaetigt})
@engine.worker("korpus.gate")
async def gate(task: dict) -> engine.Ergebnis:
topic, runde = task["topic"], task["runde"]
quellen = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND "
"status='geladen'", topic)["c"]
bestaetigt = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"]
vorher = db.uj((db.one(
"SELECT ergebnis FROM tasks WHERE topic=? AND knoten='gate_korpus' AND "
"runde=? AND status='fertig'", topic, runde - 1) or {}).get("ergebnis", ""),
{}).get("bestaetigt", -1)
befunde, neue = [], []
if quellen == 0:
befunde.append({"art": "korpus_leer", "item": topic,
"detail": "keine geladene Quelle"})
if bestaetigt < config.SOLL_PUNKTE_MIN:
befunde.append({"art": "soll_zu_wenig", "item": topic,
"detail": f"{bestaetigt} < {config.SOLL_PUNKTE_MIN}"})
gesaettigt = bestaetigt == vorher # Nullrunde (mit frischen Queries gelaufen)
if not gesaettigt and runde < config.RECHERCHE_RUNDEN_MAX:
befunde.append({"art": "unsaettigt", "item": topic,
"detail": f"Runde {runde}: {vorher}{bestaetigt}"})
if befunde:
if runde < config.RECHERCHE_RUNDEN_MAX:
neue = [{"knoten": "recherche_plan", "item": f"r{runde + 1}:lens:{lens}",
"runde": runde + 1, "art": "ruecklauf",
"payload": {"lens": lens}}
for lens in config.RECHERCHE_LENSES]
return engine.Ergebnis(gate_status="rot", befunde=befunde,
neue_tasks=neue,
daten={"bestaetigt": bestaetigt})
# grün → Rest-Kandidaten sind entschieden (Sättigung): verwerfen statt
# als unadjudizierter Ballast liegen zu lassen
db.execute("UPDATE soll SET status='verworfen' WHERE topic=? AND "
"status='kandidat'", topic)
# Stufe inventar: Extraktion NUR belegnah (Lektion 92) — 2 Reader je
# Fenster um die Soll-Belege. Quellen ohne Beleg liefern keine Atome.
db.update("topics", "name=?", (topic,), status="korpus_fertig")
belege_je_quelle: dict[int, list] = {}
for s in db.query("SELECT belege FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic):
for b in db.uj(s["belege"], []):
belege_je_quelle.setdefault(b["quelle"], []).append(b["zitat"])
for q in db.query("SELECT * FROM quellen WHERE topic=? AND status='geladen' "
"AND zweck='korpus'", topic):
zitate = belege_je_quelle.get(q["id"])
if not zitate:
continue
text = laden.snapshot_lesen(q)
spans = []
for zitat in zitate:
span = textkit.finde_zitat(text, zitat)
if span:
spans.append([max(0, span[0] - config.EXTRAKT_FENSTER),
min(len(text), span[1] + config.EXTRAKT_FENSTER)])
spans.sort()
gemerged = []
for s in spans:
if gemerged and s[0] <= gemerged[-1][1]:
gemerged[-1][1] = max(gemerged[-1][1], s[1])
else:
gemerged.append(s)
i = 0
for von, bis in gemerged:
for o, c in textkit.abschnitte(text[von:bis]):
for r in range(1, config.READER_JE_ABSCHNITT + 1):
neue.append({"knoten": "atom_extraktion",
"item": f"quelle:{q['id']}:a{i}:r{r}",
"payload": {"quelle_id": q["id"],
"offset": von + o, "chars": len(c)}})
i += 1
return engine.Ergebnis(gate_status="gruen", neue_tasks=neue,
daten={"bestaetigt": bestaetigt})

203
backend/laden.py Normal file
View File

@@ -0,0 +1,203 @@
"""Ingestion (R6): Bytes → trafilatura(markdown) → ftfy NFC → Snapshot.
Normalisierung passiert GENAU EINMAL, danach ist der Snapshot unveränderlich —
alle Verbatim-Zitate stammen ausschließlich aus dem Snapshot."""
import asyncio
import hashlib
import os
import re
import time
import urllib.robotparser
from urllib.parse import urlsplit
import httpx
from . import config, db, engine
_robots: dict[str, urllib.robotparser.RobotFileParser | None] = {}
_letzter_fetch: dict[str, float] = {}
def _text_reparieren(text: str) -> str:
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", " ", text) # VOR ftfy
try:
import ftfy
return ftfy.fix_text(text, normalization="NFC")
except ImportError:
import unicodedata
return unicodedata.normalize("NFC", text)
def _robots_erlaubt(url: str) -> bool:
domain = urlsplit(url).netloc
if domain not in _robots:
rp = urllib.robotparser.RobotFileParser()
try:
rp.set_url(f"https://{domain}/robots.txt")
rp.read()
_robots[domain] = rp
except Exception:
_robots[domain] = None # nicht erreichbar → fail-open
rp = _robots[domain]
return rp is None or rp.can_fetch(config.user_agent(), url)
async def _domain_bremse(url: str) -> None:
domain = urlsplit(url).netloc
delta = time.monotonic() - _letzter_fetch.get(domain, 0)
if delta < config.DOMAIN_RATE_S:
await asyncio.sleep(config.DOMAIN_RATE_S - delta)
_letzter_fetch[domain] = time.monotonic()
_WIKI_RE = re.compile(r"https?://([a-z]{2})\.wikipedia\.org/wiki/(.+)$")
async def _wikipedia_api(url: str) -> str:
"""Wikipedia über die Action-API laden (bot-freundlich; die HTML-Seiten
blockierten per robots.txt die Kernquelle des ersten Echtlaufs)."""
m = _WIKI_RE.match(url)
if not m:
return ""
sprache, titel = m.group(1), m.group(2).split("#")[0]
async with httpx.AsyncClient(
timeout=config.FETCH_READ_S,
headers={"User-Agent": config.user_agent()}) as client:
r = await client.get(
f"https://{sprache}.wikipedia.org/w/api.php",
params={"action": "query", "prop": "extracts", "explaintext": 1,
"format": "json", "redirects": 1, "titles": titel})
r.raise_for_status()
seiten = r.json().get("query", {}).get("pages", {})
return "\n\n".join(p.get("extract", "") for p in seiten.values())
async def _fetch(url: str) -> tuple[bytes, str]:
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(config.FETCH_READ_S, connect=config.FETCH_CONNECT_S),
headers={"User-Agent": config.user_agent()},
transport=httpx.AsyncHTTPTransport(retries=2)) as client:
r = await client.get(url)
r.raise_for_status()
return r.content, r.headers.get("content-type", "")
def _extrahieren(inhalt: bytes) -> str:
import trafilatura
return trafilatura.extract(inhalt, output_format="markdown",
include_tables=True, include_links=False,
include_images=False) or ""
def snapshot_pfad(topic: str, hash_: str) -> str:
return str(config.KORPUS_DIR / topic / f"q{hash_}.md")
def snapshot_lesen(quelle: dict) -> str:
from pathlib import Path
return Path(quelle["snapshot"]).read_text(encoding="utf-8")
def _atomar_schreiben(pfad: str, daten: bytes) -> None:
os.makedirs(os.path.dirname(pfad), exist_ok=True)
tmp = pfad + ".tmp"
with open(tmp, "wb") as f:
f.write(daten)
os.replace(tmp, pfad)
@engine.worker("laden.laden")
async def laden(task: dict) -> engine.Ergebnis:
quelle = db.one("SELECT * FROM quellen WHERE id=?",
db.uj(task["payload"], {}).get("quelle_id"))
if quelle is None:
raise RuntimeError("Quelle fehlt")
if quelle["status"] == "geladen": # Resume
return engine.Ergebnis(daten={"skip": True},
neue_tasks=_folge_tasks(task, quelle))
if config.FAKE:
from . import fakes
text = fakes.laden(quelle["url"])
roh = b""
else:
if quelle["url"].lower().endswith(".pdf"):
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="pdf_uebersprungen")
db.insert("befunde", run_id=task["run_id"], stufe="korpus",
knoten="laden", art="pdf", item=quelle["url"][:120],
detail="PDF in v1 übersprungen")
return engine.Ergebnis(daten={"pdf": True})
if _WIKI_RE.match(quelle["url"]):
try:
text = await _wikipedia_api(quelle["url"])
except Exception:
text = ""
if text:
text = _text_reparieren(text)
return _snapshot_ablegen(task, quelle, text, b"")
# API-Fehler → normaler Weg (inkl. robots-Check) als Fallback
if not _robots_erlaubt(quelle["url"]):
db.update("quellen", "id=?", (quelle["id"],), status="robots")
db.insert("befunde", run_id=task["run_id"], stufe="korpus",
knoten="laden", art="robots", item=quelle["url"][:120])
return engine.Ergebnis(daten={"robots": True})
await _domain_bremse(quelle["url"])
try:
roh, _ = await _fetch(quelle["url"])
except Exception as e:
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund=f"{type(e).__name__}"[:80])
return engine.Ergebnis(daten={"fetch_fehler": str(e)[:120]})
text = _extrahieren(roh)
text = _text_reparieren(text)
return _snapshot_ablegen(task, quelle, text, roh)
def _snapshot_ablegen(task: dict, quelle: dict, text: str,
roh: bytes) -> engine.Ergebnis:
if len(text) < config.SNAPSHOT_MIN_ZEICHEN or "enable javascript" in text.lower():
status = "braucht_js" if text and "javascript" in text.lower() else "leer"
db.update("quellen", "id=?", (quelle["id"],), status=status)
db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="laden",
art=status, item=quelle["url"][:120])
return engine.Ergebnis(daten={status: True})
hash_ = hashlib.sha256(text.encode()).hexdigest()[:16]
dublette = db.one("SELECT id FROM quellen WHERE topic=? AND hash=? AND id!=?",
task["topic"], hash_, quelle["id"])
if dublette:
db.update("quellen", "id=?", (quelle["id"],), status="fehler",
grund="inhalt_dublette")
return engine.Ergebnis(daten={"dublette": True})
pfad = snapshot_pfad(task["topic"], hash_)
_atomar_schreiben(pfad, text.encode("utf-8"))
roh_pfad = ""
if roh:
roh_pfad = pfad.replace(".md", ".html")
_atomar_schreiben(roh_pfad, roh) # Re-Extraktion ohne Re-Fetch (R6)
db.update("quellen", "id=?", (quelle["id"],), status="geladen", hash=hash_,
snapshot=pfad, roh=roh_pfad)
quelle = {**quelle, "snapshot": pfad, "status": "geladen"}
return engine.Ergebnis(daten={"zeichen": len(text)},
neue_tasks=_folge_tasks(task, quelle))
def _folge_tasks(task: dict, quelle: dict) -> list:
"""korpus-Quellen → Soll-Extraktion je 40k-Chunk; Lücken-Quellen →
direkte Atom-Extraktion (Soll steht schon fest)."""
from . import textkit
if quelle["zweck"].startswith("luecke:"):
soll_id = int(quelle["zweck"].split(":")[1])
text = snapshot_lesen(quelle)
return [{"knoten": "atom_extraktion",
"item": f"quelle:{quelle['id']}:a{i}:r1",
"payload": {"quelle_id": quelle["id"], "offset": o,
"chars": len(c), "soll_id": soll_id}}
for i, (o, c) in enumerate(textkit.abschnitte(text))]
text = snapshot_lesen(quelle)
chunks = textkit.abschnitte(text, config.SOLL_CHUNK_CHARS)
return [{"knoten": "soll_extraktion", "item": f"quelle:{quelle['id']}:c{i}",
"payload": {"quelle_id": quelle["id"], "offset": o, "chars": len(c)}}
for i, (o, c) in enumerate(chunks)]

52
backend/ledger.py Normal file
View File

@@ -0,0 +1,52 @@
"""Event-Ledger: eine Zeile je LLM-Versuch (im finally — jeder Ausgang zählt,
auch Hedge-Verlierer). Budget = harte Grenze, geprüft VOR und NACH jedem Call."""
from . import config, db
class BudgetErschoepft(Exception):
pass
def log_call(run_id: int, *, stufe: str = "", knoten: str = "", item: str = "",
skills_liste: list | None = None, skill_hash: str = "",
model: str = "", role: str = "", status: str = "",
dur_ms: int = 0, wait_ms: int = 0,
tokens: dict | None = None, meta: dict | None = None,
prompt: str = "", antwort: str = "") -> int | None:
t = tokens or {}
event_id = db.insert(
"events", run_id=run_id, ts=db.now(), stufe=stufe, knoten=knoten,
item=item, skills=db.j(skills_liste or []), skill_hash=skill_hash,
model=model, role=role, status=status, dur_ms=dur_ms,
wait_ms=wait_ms, tok_in=t.get("input", 0), tok_out=t.get("output", 0),
tok_cache_read=t.get("cache_read", 0),
tok_cache_write=t.get("cache_write", 0), meta=db.j(meta or {}))
if config.LLM_LOG and event_id and (prompt or antwort):
db.insert("call_texte", event_id=event_id, prompt=prompt, antwort=antwort)
return event_id
def verbraucht(run_id: int) -> int:
r = db.one("SELECT COALESCE(SUM(tok_in + tok_out), 0) s FROM events "
"WHERE run_id=?", run_id)
return r["s"]
def budget_pruefen(run_id: int) -> None:
run = db.one("SELECT budget_tokens FROM runs WHERE id=?", run_id)
limit = (run or {}).get("budget_tokens") or 0
if limit > 0 and verbraucht(run_id) >= limit:
raise BudgetErschoepft(f"Budget {limit} Tokens erreicht")
def kennzahlen(run_id: int) -> dict:
zeilen = db.query(
"SELECT stufe, knoten, COUNT(*) calls, SUM(tok_in) tok_in, "
"SUM(tok_out) tok_out, SUM(tok_cache_read) cache_read, "
"SUM(dur_ms) dur_ms, SUM(wait_ms) wait_ms, "
"SUM(status='ok') ok, SUM(status IN ('error','infra','parse','cap')) fehler "
"FROM events WHERE run_id=? GROUP BY stufe, knoten ORDER BY MIN(id)", run_id)
gesamt = verbraucht(run_id)
kosten = sum(z["tok_in"] or 0 for z in zeilen) / 1e6 * config.PREIS_IN + \
sum(z["tok_out"] or 0 for z in zeilen) / 1e6 * config.PREIS_OUT
return {"zeilen": zeilen, "verbraucht": gesamt, "kosten_usd": round(kosten, 2)}

238
backend/llm.py Normal file
View File

@@ -0,0 +1,238 @@
"""MiniMax-Client (R3/R9). Fehlerklassen strikt getrennt: Infra (429/Timeout)
→ 3 Retries mit Backoff 8/16/32 s + Jitter, erschöpft → LaufPause (fail-closed).
Inhalt (leer/kaputt) → begrenzte Restarts, dann None. cap (stop=max_tokens)
→ SOFORT None, kein Retry (deterministisch — Aufrufer halbiert den Chunk).
Ein 429 drosselt ALLE Calls (globaler Cooldown + adaptive Breite)."""
import asyncio
import random
import time
from dataclasses import dataclass, field
import httpx
from . import config, db, skills
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out",
"connection", "network", "overloaded", "unavailable",
'"status_code":1002', '"status_code":1041', '"status_code":2045')
class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen."""
class ManuellePause(LaufPause):
"""Nutzer hat pausiert — Wartende brechen sofort ab, Laufende laufen aus."""
@dataclass
class ApiErgebnis:
rc: int
text: str = ""
err: str = ""
tokens: dict = field(default_factory=dict)
# ── globale Drossel (ein 429 bremst alle) ──
_sem = asyncio.Semaphore(config.MAX_PARALLEL_LLM)
_cooldown_bis = 0.0
_breite = config.MAX_PARALLEL_LLM
_inflight = 0
_erfolge = 0
def drossel_melden(sekunden: float) -> None:
global _cooldown_bis, _breite, _erfolge
_cooldown_bis = max(_cooldown_bis, time.monotonic() + sekunden)
_breite = max(config.DROSSEL_MIN_BREITE, _breite // 2)
_erfolge = 0
def _erfolg_melden() -> None:
global _breite, _erfolge
_erfolge += 1
if _erfolge >= config.DROSSEL_ERFOLGE_JE_PLUS and _breite < config.MAX_PARALLEL_LLM:
_breite += 1
_erfolge = 0
_naechster_start = 0.0
async def _slot() -> float:
"""Cooldown abwarten, dann Takt (1 Start je TAKT_SEKUNDEN) ODER — bei
Takt 0 — adaptive Breite. Rückgabe: Wartezeit in Sekunden."""
global _inflight, _naechster_start
t0 = time.monotonic()
while time.monotonic() < _cooldown_bis:
await asyncio.sleep(min(1.0, _cooldown_bis - time.monotonic()))
if config.TAKT_SEKUNDEN > 0:
while True:
jetzt = time.monotonic()
if jetzt >= _naechster_start:
_naechster_start = max(jetzt, _naechster_start) + config.TAKT_SEKUNDEN
break
await asyncio.sleep(min(0.2, _naechster_start - jetzt))
else:
while _inflight >= _breite:
await asyncio.sleep(0.2)
_inflight += 1
return time.monotonic() - t0
def _slot_frei() -> None:
global _inflight
_inflight = max(0, _inflight - 1)
def _ist_infra(err: str) -> bool:
e = err.lower()
return any(m in e for m in _INFRA_MARKER)
async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis:
opts = dict(config.ROLLEN.get(role) or config.ROLLEN["judge"])
body = {"model": opts.pop("model"), "max_tokens": config.LLM_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
"temperature": opts.pop("temperature"),
"thinking": {"type": opts.pop("thinking")}}
headers = {"x-api-key": __import__("os").environ.get("MINIMAX_API_KEY", ""),
"anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(timeout, connect=config.FETCH_CONNECT_S)) as client:
resp = await asyncio.wait_for(
client.post(config.LLM_ENDPOINT, json=body, headers=headers),
timeout=timeout)
except (httpx.TimeoutException, asyncio.TimeoutError):
return ApiErgebnis(1, err="timeout")
except httpx.HTTPError as e:
return ApiErgebnis(1, err=f"{type(e).__name__}: {e}")
if resp.status_code == 429:
try:
nach = float(resp.headers.get("retry-after", "15"))
except ValueError:
nach = 15.0
drossel_melden(min(nach, 60.0))
return ApiErgebnis(1, err="HTTP 429: rate limited")
if resp.status_code != 200:
return ApiErgebnis(1, err=f"HTTP {resp.status_code}: {resp.text[:300]}")
daten = resp.json()
text = "".join(b.get("text", "") for b in daten.get("content", [])
if b.get("type") == "text")
u = daten.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 _ist_infra(resp.text[:500]) and not text.strip():
return ApiErgebnis(1, err=resp.text[:300], tokens=tokens)
if not text.strip():
return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})",
tokens=tokens)
_erfolg_melden()
return ApiErgebnis(0, text=text, tokens=tokens)
async def _versuch(prompt: str, role: str, timeout: float) -> tuple[ApiErgebnis, bool]:
"""Ein Versuch inkl. Hedge-Zwilling (Lektion 4/5). → (ergebnis, war_hedge)."""
wait_s = await _slot()
try:
haupt = asyncio.ensure_future(_api(prompt, role, timeout))
schwelle = max(config.HEDGE_NACH_S, timeout / 2) if config.HEDGE_NACH_S else None
if schwelle is None:
return await haupt, False
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
if fertig:
return haupt.result(), False
zwilling = asyncio.ensure_future(_api(prompt, role, timeout))
fertig, offen = await asyncio.wait({haupt, zwilling},
return_when=asyncio.FIRST_COMPLETED)
gewinner = fertig.pop()
for t in offen:
t.cancel() # Verlierer wird vom Aufrufer als hedge_cancel verbucht
return gewinner.result(), True
finally:
_slot_frei()
# wait_s in Meta des Aufrufers — hier via Attribut zurückgeben
_versuch.last_wait = wait_s # type: ignore[attr-defined]
async def call(*, run_id: int, stufe: str, knoten: str, item: str,
skill_namen: list[str], werte: dict, role: str = "judge",
n: int = 0) -> str | None:
"""Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap).
Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft."""
prompt, skill_hash, paare = skills.komponieren(skill_namen, werte)
timeout = config.timeout_fuer(knoten, n)
infra_rest = config.INFRA_MAX_RETRIES
inhalt_rest = config.INHALT_MAX_RESTARTS
while True:
run = db.one("SELECT status FROM runs WHERE id=?", run_id)
if run and run["status"] != "running":
raise ManuellePause("Lauf nicht mehr running")
budget_pruefen(run_id)
if config.FAKE:
from . import fakes
t0 = time.monotonic()
rc, text, err = fakes.antwort(prompt)
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
model="fake", role=role, status="ok" if rc == 0 else "error",
dur_ms=int((time.monotonic() - t0) * 1000),
meta={"err": err[:200]} if err else {},
prompt=prompt, antwort=text or err)
if rc != 0:
raise RuntimeError(f"fake: {err}")
return text
t0 = time.monotonic()
status, err, tokens, text = "error", "", {}, ""
hedge = False
try:
res, hedge = await _versuch(prompt, role, timeout)
tokens, err, text = res.tokens, res.err, res.text
if res.rc == 0:
status = "ok"
elif "stop=max_tokens" in err:
status = "cap"
elif _ist_infra(err):
status = "infra"
else:
status = "error"
except asyncio.CancelledError:
status, err = "pause", "abbruch"
raise
finally:
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
model=config.ROLLEN.get(role, {}).get("model", ""), role=role,
status=status, dur_ms=int((time.monotonic() - t0) * 1000),
wait_ms=int(getattr(_versuch, "last_wait", 0) * 1000),
tokens=tokens, meta={"err": err[:300]} if err else {},
prompt=prompt, antwort=text or err)
if hedge: # Verlierer sichtbar machen — sonst fehlen Tokens/Kennzahlen
log_call(run_id, stufe=stufe, knoten=knoten, item=f"{item}-hedge",
skill_hash=skill_hash, role=role, status="hedge_cancel")
budget_pruefen(run_id)
if status == "ok":
return text
if status == "cap":
return None # deterministisch: gleicher Prompt läuft wieder ins Cap
if status == "infra":
infra_rest -= 1
if infra_rest < 0:
raise LaufPause(f"Infra erschöpft: {err[:120]}")
pause = config.INFRA_BACKOFF_BASE * 2 ** (
config.INFRA_MAX_RETRIES - 1 - infra_rest)
pause *= 1 + random.random() * 0.25 # Jitter
drossel_melden(pause)
await asyncio.sleep(pause)
continue
inhalt_rest -= 1
if inhalt_rest < 0:
return None

238
backend/main.py Normal file
View File

@@ -0,0 +1,238 @@
"""FastAPI-App: Topics verwalten, Läufe starten/pausieren, Graph-Snapshot
(kompakt: Zähler statt Monolith), Task-Listen lazy, Guide, Kennzahlen."""
import asyncio
from contextlib import asynccontextmanager
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
from . import config, db, engine, graph, ledger, montage
from .ws import hub
@asynccontextmanager
async def lifespan(app: FastAPI):
db.connect()
engine.module_laden()
hub.bind_loop(asyncio.get_running_loop())
db.on_change = hub.push
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"],
allow_methods=["*"], allow_headers=["*"])
class TopicNeu(BaseModel):
name: str
titel: str = ""
class StartAuftrag(BaseModel):
budget: int | None = None
class TopicEinstellung(BaseModel):
quellen_max: int | None = None # 0 = unbegrenzt, None = Default
@app.patch("/api/topics/{topic}")
async def topic_einstellen(topic: str, e: TopicEinstellung):
if not db.one("SELECT name FROM topics WHERE name=?", topic):
raise HTTPException(404)
if e.quellen_max is not None and (not 0 <= e.quellen_max <= 200
or e.quellen_max == 1):
# 1 Quelle kann nie bestätigen — Konsens braucht ≥2 unabhängige Belege
raise HTTPException(400, "quellen_max: 0 (unbegrenzt) oder 2-200")
db.update("topics", "name=?", (topic,), quellen_max=e.quellen_max)
return {"ok": True}
@app.get("/api/topics")
def topics_liste():
topics = db.query("SELECT * FROM topics ORDER BY erstellt DESC")
for t in topics:
t["run"] = db.one("SELECT id, status, grund, stufe FROM runs WHERE "
"topic=? ORDER BY id DESC LIMIT 1", t["name"])
return topics
@app.post("/api/topics")
def topic_anlegen(auftrag: TopicNeu):
if not config.TOPIC_NAME_RE.match(auftrag.name):
raise HTTPException(400, "ungültiger Name")
if db.one("SELECT name FROM topics WHERE name=?", auftrag.name):
raise HTTPException(409, "Topic existiert")
db.insert("topics", name=auftrag.name, titel=auftrag.titel)
return {"ok": True}
def _pipeline_daten_loeschen(topic: str) -> None:
"""Alle Pipeline-Daten eines Topics räumen (Snapshots inklusive)."""
for tabelle in ("tasks", "gate_laeufe", "quellen", "soll", "atome", "kanten",
"lernziele", "bausteine", "kapitel"):
db.execute(f"DELETE FROM {tabelle} WHERE topic=?", topic)
db.execute("DELETE FROM befunde WHERE run_id IN "
"(SELECT id FROM runs WHERE topic=?)", topic)
db.execute("DELETE FROM sections WHERE baustein_id NOT IN "
"(SELECT id FROM bausteine)")
db.execute("DELETE FROM anker WHERE atom_id NOT IN (SELECT id FROM atome)")
ordner = (config.KORPUS_DIR / topic).resolve()
if ordner.is_dir() and ordner.parent == config.KORPUS_DIR.resolve():
import shutil
shutil.rmtree(ordner) # Guard: nur direkt unter KORPUS_DIR (Lektion!)
@app.delete("/api/topics/{topic}")
async def topic_loeschen(topic: str):
engine.lauf_stoppen(topic)
_pipeline_daten_loeschen(topic)
db.execute("DELETE FROM runs WHERE topic=?", topic)
db.execute("DELETE FROM topics WHERE name=?", topic)
return {"ok": True}
@app.post("/api/topics/{topic}/reset")
async def topic_reset(topic: str):
"""Pipeline auf null: Topic bleibt, Läufe/Ledger bleiben als Historie."""
if not db.one("SELECT name FROM topics WHERE name=?", topic):
raise HTTPException(404)
engine.lauf_stoppen(topic)
_pipeline_daten_loeschen(topic)
# Alte Läufe schließen — sonst resumed der nächste Start den alten Run
# samt altem Budget und Token-Zähler
db.execute("UPDATE runs SET status='stopped', beendet=? WHERE topic=? AND "
"status IN ('running','paused','budget')", db.now(), topic)
db.update("topics", "name=?", (topic,), status="neu")
return {"ok": True}
@app.post("/api/topics/{topic}/start")
async def start(topic: str, auftrag: StartAuftrag):
if not db.one("SELECT name FROM topics WHERE name=?", topic):
raise HTTPException(404)
try:
run_id = engine.lauf_starten(topic, auftrag.budget)
except RuntimeError as e:
raise HTTPException(409, str(e)) from e
return {"run_id": run_id}
@app.post("/api/topics/{topic}/pause")
async def pause(topic: str):
engine.lauf_pausieren(topic)
return {"ok": True}
@app.post("/api/topics/{topic}/stop")
async def stop(topic: str):
engine.lauf_stoppen(topic)
return {"ok": True}
@app.get("/api/topics/{topic}/graph")
def graph_snapshot(topic: str):
g = graph.get()
zaehler = {r["knoten"]: {} for r in []}
zaehler = {}
for r in db.query("SELECT knoten, status, COUNT(*) c FROM tasks WHERE "
"topic=? GROUP BY knoten, status", topic):
zaehler.setdefault(r["knoten"], {})[r["status"]] = r["c"]
gates = {r["knoten"]: {"status": r["status"], "runde": r["runde"]}
for r in db.query(
"SELECT knoten, status, MAX(runde) runde FROM gate_laeufe "
"WHERE topic=? GROUP BY knoten", topic)}
run = db.one("SELECT id, status, grund, stufe, budget_tokens FROM runs "
"WHERE topic=? ORDER BY id DESC LIMIT 1", topic)
if run:
run["verbraucht"] = ledger.verbraucht(run["id"])
return {"layout": g.layout(), "zaehler": zaehler, "gates": gates, "run": run}
@app.get("/api/topics/{topic}/knoten/{knoten}/tasks")
def knoten_tasks(topic: str, knoten: str):
tasks = db.query("SELECT id, item, status, art, runde, versuch, fehler, "
"gestartet, beendet FROM tasks WHERE topic=? AND knoten=? "
"ORDER BY id DESC LIMIT 200", topic, knoten)
befunde = db.query("SELECT art, item, detail, status FROM befunde WHERE "
"knoten=? AND run_id IN (SELECT id FROM runs WHERE topic=?) "
"ORDER BY id DESC LIMIT 100", knoten, topic)
return {"tasks": tasks, "befunde": befunde}
@app.get("/api/topics/{topic}/guide")
def guide_lesen(topic: str):
kapitel = []
for k in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", topic):
sections = db.query(
"SELECT b.id, b.titel, s.text FROM bausteine b LEFT JOIN sections s "
"ON s.baustein_id=b.id WHERE b.kapitel_id=? ORDER BY b.ord", k["id"])
kapitel.append({"id": k["id"], "titel": k["titel"], "intro": k["intro"],
"sections": sections})
return {"kapitel": kapitel,
"fertig": (db.one("SELECT status FROM topics WHERE name=?", topic)
or {}).get("status") == "fertig"}
@app.get("/api/topics/{topic}/kennzahlen")
def kennzahlen(topic: str):
run = db.one("SELECT id, budget_tokens FROM runs WHERE topic=? "
"ORDER BY id DESC LIMIT 1", topic)
if not run:
return {"zeilen": [], "verbraucht": 0, "kosten_usd": 0, "coverage": None}
kz = ledger.kennzahlen(run["id"])
gesamt = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"]
gedeckt = db.one(
"SELECT COUNT(*) c FROM soll s WHERE s.topic=? AND s.status='bestaetigt' "
"AND (s.freispruch!='' OR EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv' AND a.baustein_id IS NOT NULL))",
topic)["c"]
redundanz_offen = db.one(
"SELECT COUNT(*) c FROM befunde WHERE art='redundanz' AND status='offen' "
"AND run_id IN (SELECT id FROM runs WHERE topic=?)", topic)["c"]
eskaliert = db.one(
"SELECT COUNT(*) c FROM befunde WHERE status='eskaliert' AND run_id IN "
"(SELECT id FROM runs WHERE topic=?)", topic)["c"]
atome_aktiv = db.one("SELECT COUNT(*) c FROM atome WHERE topic=? AND "
"status='aktiv'", topic)["c"]
verdichtet = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt' AND verdichtet=1", topic)["c"]
from . import belege as belege_mod
kz["beleg_abdeckung"] = belege_mod.abdeckung_prozent(topic)
kz.update({"coverage": round(gedeckt / gesamt * 100, 1) if gesamt else None,
"soll_gesamt": gesamt, "soll_gedeckt": gedeckt,
"redundanz_offen": redundanz_offen, "eskaliert": eskaliert,
"atome_aktiv": atome_aktiv, "verdichtet_punkte": verdichtet,
"budget": run["budget_tokens"]})
return kz
@app.get("/api/health")
def health():
return {"ok": True}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
hub.connect(ws)
try:
while True:
await ws.receive_text()
except WebSocketDisconnect:
hub.disconnect(ws)
_DIST = config.ROOT / "frontend" / "dist"
if _DIST.exists():
app.mount("/assets", StaticFiles(directory=_DIST / "assets"), name="assets")
@app.get("/")
def index():
return FileResponse(_DIST / "index.html")

59
backend/montage.py Normal file
View File

@@ -0,0 +1,59 @@
"""Stufe 5: Guide-Zusammenbau (Kapitel + Intros + Sections) + deterministische
Abschluss-Checks: jeder Atom-Marker global genau einmal, Anker-Links auflösbar."""
import re
from . import config, db, engine
from .guide import _MARKER_RE, _slug
def guide_pfad(topic: str) -> str:
return str(config.KORPUS_DIR.parent / "guides" / f"{topic}.md")
def bauen(topic: str) -> str:
teile = []
t = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics WHERE name=?",
topic)
teile.append(f"# {t['t']}\n")
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", topic):
teile.append(f"\n## {kap['titel']}\n")
if kap["intro"]:
teile.append(kap["intro"] + "\n")
for b in db.query("SELECT * FROM bausteine WHERE kapitel_id=? ORDER BY ord",
kap["id"]):
sec = db.one("SELECT text FROM sections WHERE baustein_id=?", b["id"])
if sec and sec["text"]:
teile.append(f"\n### {b['titel']}\n")
teile.append(sec["text"] + "\n")
return "\n".join(teile)
@engine.worker("montage.montieren")
async def montieren(task: dict) -> engine.Ergebnis:
topic = task["topic"]
text = bauen(topic)
# Check 1: jeder aktive Atom-Marker genau einmal
marker = [int(m) for m in _MARKER_RE.findall(text)]
aktive = {a["id"] for a in db.query(
"SELECT id FROM atome WHERE topic=? AND status='aktiv' AND "
"baustein_id IS NOT NULL", topic)}
fehler = []
for a in aktive:
if marker.count(a) != 1:
fehler.append(f"Atom-Marker {a}: {marker.count(a)}x")
# Check 2: Anker-Links auflösbar (H2/H3-Slugs)
slugs = {_slug(u) for u in re.findall(r"^#{2,3}\s+(.+)$", text, re.MULTILINE)}
for link in re.findall(r"\]\(#([^)]+)\)", text):
if link not in slugs:
fehler.append(f"toter Anker-Link: #{link}")
if fehler:
for f in fehler:
db.insert("befunde", run_id=task["run_id"], stufe="montage",
knoten="montage", art="montage", item=topic, detail=f[:300])
raise RuntimeError(f"Montage-Checks: {len(fehler)} Fehler")
from .laden import _atomar_schreiben
_atomar_schreiben(guide_pfad(topic), text.encode("utf-8"))
db.update("topics", "name=?", (topic,), status="fertig")
return engine.Ergebnis(daten={"zeichen": len(text),
"kapitel": len(db.query(
"SELECT id FROM kapitel WHERE topic=?", topic))})

33
backend/panels.py Normal file
View File

@@ -0,0 +1,33 @@
"""Panel-Primitive (creator2-Lektionen 15-24): 2 = Konsens-Minimum, Merge nur
einstimmig, Ausfall = keine Stimme = kein Entscheid, Ersatz-Richter bei genau
einem Ausfall."""
from typing import Awaitable, Callable
def einstimmig(stimmen: list, n: int, urteil: Callable) -> bool:
"""True nur bei vollzähligem Panel UND allen Stimmen True.
urteil(s) → True/False/None; None (Ausfall/Parse) = keine Stimme."""
urteile = [urteil(s) for s in stimmen if s is not None]
urteile = [u for u in urteile if u is not None]
return len(urteile) >= n and all(urteile)
async def panel(ruf: Callable[[int], Awaitable], groesse: int) -> list:
"""groesse unabhängige Calls; fällt GENAU eine Stimme aus, läuft ein
Ersatz-Richter (Index -1). None-Stimmen bleiben in der Liste."""
import asyncio
stimmen = list(await asyncio.gather(*(ruf(i) for i in range(groesse))))
gueltig = [s for s in stimmen if s is not None]
if groesse >= 2 and len(gueltig) == groesse - 1:
stimmen.append(await ruf(-1))
return stimmen
def mehrheit(stimmen: list, urteil: Callable) -> bool | None:
"""Mehrheitsentscheid (Klärung, 3er): None wenn keine Mehrheit möglich."""
urteile = [urteil(s) for s in stimmen if s is not None]
urteile = [u for u in urteile if u is not None]
if not urteile:
return None
ja = sum(1 for u in urteile if u)
return ja * 2 > len(urteile)

83
backend/redundanz.py Normal file
View File

@@ -0,0 +1,83 @@
"""Rückwärts-Redundanz-Check (Kernziel Länge minimieren, R11): Satzpaare aus
VERSCHIEDENEN Kapiteln mit Jaccard ≥0.6 → 2er-Panel entscheidet „redundant vs.
didaktisch gewollt" (Recap ist keine Dopplung). Befunde behebt der Fix-Knoten
per Ein-Satz-Refresher + Anker-Link."""
from . import config, db, engine, llm, panels, textkit
def kandidaten_paare(topic: str) -> list[dict]:
saetze = []
for row in db.query(
"SELECT s.baustein_id bid, s.text, b.ord bord, k.ord kord FROM "
"sections s JOIN bausteine b ON b.id=s.baustein_id "
"JOIN kapitel k ON k.id=b.kapitel_id WHERE b.topic=? AND s.text!=''",
topic):
for satz in textkit.satz_split(row["text"]):
toks = textkit.stamm_tokens(satz)
if len(toks) >= config.REDUNDANZ_SATZ_MIN_TOKEN:
saetze.append({"satz": satz, "toks": toks, "bid": row["bid"],
"pos": (row["kord"], row["bord"])})
def shingles(satz: str) -> set:
woerter = textkit.norm(satz).split()
n = config.REDUNDANZ_SHINGLE
return {" ".join(woerter[i:i + n]) for i in range(len(woerter) - n + 1)}
paare = []
for i, a in enumerate(saetze):
for b in saetze[i + 1:]:
if a["bid"] == b["bid"]:
continue # innerhalb einer Section: Sache des Prüfers, nicht hier
schnitt = len(a["toks"] & b["toks"])
jaccard_treffer = schnitt and schnitt / len(a["toks"] | b["toks"]) \
>= config.REDUNDANZ_JACCARD
# Shingle-Kanal: identische Wort-Folge in längeren Sätzen —
# Jaccard übersieht sie, wenn der Restsatz verschieden ist
if jaccard_treffer or (shingles(a["satz"]) & shingles(b["satz"])):
paare.append({"a": a, "b": b})
return paare
@engine.worker("redundanz.pruefen")
async def pruefen(task: dict) -> engine.Ergebnis:
paare = kandidaten_paare(task["topic"])
befunde = 0
for i in range(0, len(paare), config.DEDUP_PAARE_PRO_CALL):
chunk = paare[i:i + config.DEDUP_PAARE_PRO_CALL]
liste = "\n\n".join(
f"PAAR {j + 1}:\nKapitel A: {p['a']['satz']}\nKapitel B: {p['b']['satz']}"
for j, p in enumerate(chunk))
async def ruf(p, liste=liste, n=len(chunk)):
text = await llm.call(
run_id=task["run_id"], stufe="guide", knoten="redundanz",
item=f"{task['item']}:c{i}:p{p}", role="judge", n=n,
skill_namen=["richter", "deutsch-praezise", "redundanz-urteil"],
werte={"paare": liste})
if text is None:
return None
return {int(b["paar"]): b.get("urteil", "UNSICHER")
for b in textkit.bloecke(text, "URTEIL")
if b.get("paar", "").isdigit()}
stimmen = await panels.panel(ruf, config.PRUEF_PANEL)
for j, p in enumerate(chunk):
def urteil(s, j=j):
if s is None or (j + 1) not in s:
return None
u = s[j + 1]
return True if u == "redundant" else (
False if u == "didaktisch_gewollt" else None)
if panels.einstimmig(stimmen, config.PRUEF_PANEL, urteil):
# Der SPÄTERE Baustein trägt den Befund (dort wird verwiesen)
spaeter = max(p["a"], p["b"], key=lambda x: x["pos"])
schon = db.one(
"SELECT id FROM befunde WHERE item=? AND art='redundanz' AND "
"detail=? AND status IN ('offen','eskaliert','freispruch')",
f"baustein:{spaeter['bid']}", spaeter["satz"][:400])
if not schon:
db.insert("befunde", run_id=task["run_id"], stufe="guide",
knoten="redundanz", art="redundanz",
item=f"baustein:{spaeter['bid']}",
detail=spaeter["satz"][:400], runde=task["runde"])
befunde += 1
return engine.Ergebnis(daten={"paare": len(paare), "befunde": befunde})

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
pyyaml>=6.0
trafilatura>=2.1
ftfy>=6.3
ddgs>=9.14
fuzzysearch>=0.8
rapidfuzz>=3.9
PyStemmer>=2.2
pytest>=8.0
pytest-asyncio>=0.24

98
backend/skills.py Normal file
View File

@@ -0,0 +1,98 @@
"""Skill-Loader + Komposition (R5): genau 1 Rolle + 1 Aufgabe + 0..n Stil.
Nur die Aufgabe definiert das Ausgabeformat (Konfliktfreiheit per Konstruktion).
{{platzhalter}} nur im Aufgabe-Body, str.replace statt format_map.
Hashing zweistufig: je Datei sha256[:12], skill_hash über die geordnete Liste."""
import hashlib
import re
from pathlib import Path
from . import config
_FRONT_RE = re.compile(r"^---\n(.*?)\n---\n(.*)$", re.DOTALL)
_PLATZHALTER_RE = re.compile(r"\{\{([a-z_]+)\}\}")
_cache: dict[str, dict] = {}
class SkillFehler(Exception):
pass
def _parse(pfad: Path) -> dict:
text = pfad.read_text(encoding="utf-8")
m = _FRONT_RE.match(text)
if not m:
raise SkillFehler(f"{pfad.name}: kein Frontmatter")
meta: dict = {"platzhalter": []}
for zeile in m.group(1).splitlines():
if ":" not in zeile:
continue
k, v = zeile.split(":", 1)
k, v = k.strip(), v.strip()
if k == "platzhalter":
meta[k] = [p.strip() for p in v.strip("[]").split(",") if p.strip()]
else:
meta[k] = v
for pflicht in ("name", "art", "beschreibung"):
if not meta.get(pflicht):
raise SkillFehler(f"{pfad.name}: Frontmatter-Feld '{pflicht}' fehlt")
if meta["art"] not in ("rolle", "aufgabe", "stil"):
raise SkillFehler(f"{pfad.name}: unbekannte art '{meta['art']}'")
body = m.group(2).strip()
return {**meta, "body": body,
"hash": hashlib.sha256(text.encode()).hexdigest()[:12]}
def lade(name: str) -> dict:
"""Skill per Name laden ('rolle/extraktor' oder nur 'extraktor' mit Suche)."""
if name in _cache and not config.FAKE:
return _cache[name]
if "/" in name:
pfad = config.SKILLS_DIR / f"{name}.md"
else:
treffer = list(config.SKILLS_DIR.glob(f"*/{name}.md"))
if len(treffer) != 1:
raise SkillFehler(f"Skill '{name}': {len(treffer)} Treffer")
pfad = treffer[0]
if not pfad.exists():
raise SkillFehler(f"Skill-Datei fehlt: {pfad}")
_cache[name] = _parse(pfad)
return _cache[name]
def komponieren(skill_namen: list[str], werte: dict) -> tuple[str, str, list]:
"""→ (prompt, skill_hash, [(name, hash)]). Reihenfolge Rolle→Stil→Aufgabe;
Prompt beginnt mit stabilem Präfix (MiniMax-Prompt-Caching ab 512 Tokens)."""
geladen = [lade(n) for n in skill_namen]
rollen = [s for s in geladen if s["art"] == "rolle"]
aufgaben = [s for s in geladen if s["art"] == "aufgabe"]
stile = [s for s in geladen if s["art"] == "stil"]
if len(rollen) != 1 or len(aufgaben) != 1:
raise SkillFehler(f"Komposition braucht genau 1 Rolle + 1 Aufgabe: {skill_namen}")
aufgabe = aufgaben[0]
fehlt = [p for p in aufgabe["platzhalter"] if p not in werte]
if fehlt:
raise SkillFehler(f"Aufgabe '{aufgabe['name']}': Platzhalter fehlen: {fehlt}")
body = aufgabe["body"]
for p in aufgabe["platzhalter"]:
body = body.replace("{{" + p + "}}", str(werte[p]))
rest = _PLATZHALTER_RE.search(body)
if rest:
raise SkillFehler(f"Aufgabe '{aufgabe['name']}': unersetzt: {rest.group(0)}")
teile = [f"<!-- aufgabe:{aufgabe['name']} -->", # Fake-Registry-Schlüssel
"## Rolle", rollen[0]["body"]]
if stile:
teile += ["## Arbeitsweise"] + [s["body"] for s in stile]
teile += ["## Aufgabe", body]
prompt = "\n\n".join(teile)
paare = [(s["name"], s["hash"]) for s in rollen + stile + [aufgabe]]
skill_hash = hashlib.sha256(
"\n".join(f"{n}:{h}" for n, h in paare).encode()).hexdigest()[:12]
return prompt, skill_hash, paare
def aufgaben_name(prompt: str) -> str:
m = re.match(r"<!-- aufgabe:([a-z0-9-]+) -->", prompt)
return m.group(1) if m else ""

344
backend/struktur.py Normal file
View File

@@ -0,0 +1,344 @@
"""Stufe 3: Lernziele je Soll-Punkt → Bausteine (Band 4-8) → Ordnung
(Kapitel-Schnitt + Permutation, exakt validiert, deterministischer Fallback).
Gate: exakte Partition + Coverage (jeder Soll-Punkt ≥1 Baustein)."""
import math
from . import config, db, engine, llm, textkit
def _atom_rang(atom_id: int) -> tuple:
a = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0 "
"ORDER BY quelle_id, start LIMIT 1", atom_id)
return (a["quelle_id"], a["start"]) if a else (9999, 10**9)
@engine.worker("struktur.lernziele", buendel=True)
async def lernziele(task: dict) -> engine.Ergebnis:
mitglieder = [task] + task.get("_buendel", [])
teil, aktive, neue = {}, {}, [] # idx → (mitglied, soll_id, punkt, atome)
idx = 0
for m in mitglieder:
soll_id = db.uj(m["payload"], {}).get("soll_id")
if soll_id:
soll = db.one("SELECT * FROM soll WHERE id=?", soll_id)
atome = db.query("SELECT * FROM atome WHERE soll_id=? AND "
"status='aktiv'", soll_id)
punkt = soll["punkt"]
else:
atome = db.query("SELECT * FROM atome WHERE topic=? AND "
"status='aktiv' AND soll_id IS NULL", task["topic"])
punkt = "Weitere Grundlagen zum Thema"
if not atome:
teil[m["id"]] = "fertig"
continue
if db.one("SELECT id FROM lernziele WHERE topic=? AND "
"COALESCE(soll_id,-1)=COALESCE(?,-1)", task["topic"], soll_id):
teil[m["id"]] = "fertig"
neue += _baustein_tasks(task["topic"], soll_id)
continue
idx += 1
aktive[idx] = (m, soll_id, punkt, atome)
if not aktive:
return engine.Ergebnis(daten={"skip": True}, teil_status=teil,
neue_tasks=neue)
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="lernziele",
item=task["item"], role="judge",
n=sum(len(a) for (_, _, _, a) in aktive.values()),
skill_namen=["planer", "deutsch-praezise", "lernziele"],
werte={"soll_punkte": "\n\n".join(
f"=== SOLL {i} ===\nPUNKT: {punkt}\nATOME:\n" +
"\n".join(f"[{a['id']}] {a['titel']}" for a in atome)
for i, (_, _, punkt, atome) in aktive.items())})
if antwort is None:
raise RuntimeError("lernziele ohne Ergebnis")
je_soll: dict[int, list] = {}
for block in textkit.bloecke(antwort, "ZIEL"):
s = block.get("soll", "").strip()
if s.isdigit():
je_soll.setdefault(int(s), []).append(block)
for i, (m, soll_id, punkt, atome) in aktive.items():
if i not in je_soll:
teil[m["id"]] = "neu" # Soll-Punkt fehlt in der Antwort → neuer Versuch
continue
ids_alle = {a["id"] for a in atome}
zugeordnet: set[int] = set()
for block in je_soll[i]:
atom_ids = [int(x) for x in
block.get("atome", "").replace(" ", "").split(",")
if x.isdigit() and int(x) in ids_alle - zugeordnet]
if not atom_ids:
continue
ziel_id = db.insert("lernziele", topic=task["topic"],
titel=block.get("titel", "")[:80],
text=block.get("text", "")[:300], soll_id=soll_id)
for aid in atom_ids:
db.update("atome", "id=?", (aid,), ziel_id=ziel_id)
zugeordnet |= set(atom_ids)
rest = ids_alle - zugeordnet # deterministisches Sammelziel — nichts verlieren
if rest:
ziel_id = db.insert(
"lernziele", topic=task["topic"], titel="Grundlagen",
soll_id=soll_id,
text=f"Der Lernende kann Grundlagen zu '{punkt}' erklären.")
for aid in rest:
db.update("atome", "id=?", (aid,), ziel_id=ziel_id)
teil[m["id"]] = "fertig"
neue += _baustein_tasks(task["topic"], soll_id)
return engine.Ergebnis(daten={"ziele": True}, teil_status=teil,
neue_tasks=neue)
def _baustein_tasks(topic: str, soll_id) -> list:
return [{"knoten": "bausteine", "item": f"ziel:{z['id']}",
"payload": {"ziel_id": z["id"]}}
for z in db.query("SELECT id FROM lernziele WHERE topic=? AND "
"COALESCE(soll_id,-1)=COALESCE(?,-1)", topic, soll_id)]
@engine.worker("struktur.bausteine")
async def bausteine(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
ziel = db.one("SELECT * FROM lernziele WHERE id=?", payload["ziel_id"])
atome = db.query("SELECT * FROM atome WHERE ziel_id=? AND status='aktiv'",
ziel["id"])
if not atome:
return engine.Ergebnis(daten={"skip": True})
if db.one("SELECT id FROM bausteine WHERE ziel_id=?", ziel["id"]):
return engine.Ergebnis(daten={"skip": True},
neue_tasks=[{"knoten": "ordnung", "item": "ordnung"}])
atome.sort(key=lambda a: _atom_rang(a["id"]))
gruppen: list[list[dict]]
if len(atome) <= config.BAUSTEIN_MAX_ATOME:
gruppen = [atome]
else:
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="bausteine",
item=task["item"], role="judge", n=len(atome),
skill_namen=["planer", "deutsch-praezise", "bausteine"],
werte={"ziel": ziel["text"],
"band": f"{config.BAUSTEIN_MIN_ATOME}-{config.BAUSTEIN_MAX_ATOME}",
"atome": "\n".join(f"[{a['id']}] {a['titel']}" for a in atome)})
if antwort is None: # Call-Ausfall: neuer Versuch statt stiller Fallback
raise RuntimeError("bausteine ohne Ergebnis")
gruppen = _gruppen_validieren(antwort, atome)
for i, gruppe in enumerate(gruppen):
titel = ziel["titel"] or gruppe[0]["titel"]
if len(gruppen) > 1:
titel = f"{titel} (Teil {i + 1})"
bid = db.insert("bausteine", topic=task["topic"], ziel_id=ziel["id"],
titel=titel[:120], ord=i)
for j, a in enumerate(gruppe):
db.update("atome", "id=?", (a["id"],), baustein_id=bid, ord=j)
return engine.Ergebnis(daten={"bausteine": len(gruppen)},
neue_tasks=[{"knoten": "ordnung", "item": "ordnung"}])
def _gruppen_validieren(antwort: str | None, atome: list[dict]) -> list[list[dict]]:
"""LLM-Split validieren (jede ID genau einmal); sonst deterministischer
divmod-Split entlang der Quellreihenfolge."""
nach_id = {a["id"]: a for a in atome}
if antwort:
gruppen, gesehen = [], set()
for block in textkit.bloecke(antwort, "BAUSTEIN"):
ids = [int(i) for i in block.get("atome", "").replace(" ", "").split(",")
if i.isdigit()]
if ids and all(i in nach_id and i not in gesehen for i in ids):
gruppen.append([nach_id[i] for i in ids])
gesehen |= set(ids)
if gesehen == set(nach_id) and all(
len(g) <= config.BAUSTEIN_MAX_ATOME for g in gruppen):
return gruppen
n_gruppen = math.ceil(len(atome) / config.BAUSTEIN_MAX_ATOME)
basis, rest = divmod(len(atome), n_gruppen)
gruppen, pos = [], 0
for i in range(n_gruppen):
k = basis + (1 if i < rest else 0)
gruppen.append(atome[pos:pos + k])
pos += k
return gruppen
def _ziele_dedup(topic: str) -> int:
"""Synonyme Lernziele deterministisch mergen (Audit: „einordnen" vs.
„verorten" erzeugte Doppel-Bausteine → Writer-Echos). Falsch-Merge ist
harmlos: Atome bleiben erhalten, hängen nur am selben Ziel."""
ziele = db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", topic)
merges = 0
for i, a in enumerate(ziele):
if a.get("_weg"):
continue
for b in ziele[i + 1:]:
if b.get("_weg"):
continue
if textkit.titel_kern(a["titel"] or a["text"]) == \
textkit.titel_kern(b["titel"] or b["text"]) or \
textkit.jaccard(a["text"], b["text"]) >= \
config.ZIEL_DEDUP_JACCARD:
db.update("atome", "ziel_id=?", (b["id"],), ziel_id=a["id"])
db.execute("DELETE FROM bausteine WHERE ziel_id=?", b["id"])
db.execute("DELETE FROM lernziele WHERE id=?", b["id"])
b["_weg"] = True
merges += 1
if merges: # verwaiste Baustein-Zuordnungen lösen → Gate-Reparatur baut neu
db.execute("UPDATE atome SET baustein_id=NULL WHERE topic=? AND "
"baustein_id IS NOT NULL AND baustein_id NOT IN "
"(SELECT id FROM bausteine)", topic)
return merges
@engine.worker("struktur.ordnung")
async def ordnung(task: dict) -> engine.Ergebnis:
"""Barriere: Ziel-Dedup, dann Kapitel-Schnitt über Soll-Punkte + Baustein-
Permutation je Kapitel. Prior = Median der Atom-Ränge; exakt validiert."""
topic = task["topic"]
ziel_merges = _ziele_dedup(topic)
soll = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt' "
"ORDER BY id", topic)
db.execute("DELETE FROM kapitel WHERE topic=?", topic) # idempotent neu
kapitel_gruppen = await _kapitel_schnitt(task, soll)
for i, (titel, punkt_ids) in enumerate(kapitel_gruppen):
kid = db.insert("kapitel", topic=topic, titel=titel[:120], ord=i)
for sid in punkt_ids:
db.update("soll", "id=?", (sid,), kapitel_id=kid)
# Bausteine → Kapitel (über Ziel→Soll); Sammel-Bausteine ans letzte Kapitel
letztes = db.one("SELECT id FROM kapitel WHERE topic=? ORDER BY ord DESC "
"LIMIT 1", topic)
for b in db.query("SELECT b.*, z.soll_id sz FROM bausteine b JOIN lernziele z "
"ON z.id=b.ziel_id WHERE b.topic=?", topic):
kap = db.one("SELECT kapitel_id k FROM soll WHERE id=?", b["sz"]) \
if b["sz"] else None
db.update("bausteine", "id=?", (b["id"],),
kapitel_id=(kap or {}).get("k") or letztes["id"])
# Permutation je Kapitel (Prior: Median-Anker-Rang)
for kap in db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", topic):
bs = db.query("SELECT * FROM bausteine WHERE topic=? AND kapitel_id=?",
topic, kap["id"])
prior = sorted(bs, key=lambda b: _baustein_rang(b["id"]))
reihenfolge = prior
if len(bs) > 1:
ids = [b["id"] for b in prior]
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item=f"perm:kapitel:{kap['id']}", role="judge", n=len(bs),
skill_namen=["planer", "deutsch-praezise", "ordnung"],
werte={"art": "Bausteine",
"liste": "\n".join(f"[{b['id']}] {b['titel']}" for b in prior),
"prior": ",".join(map(str, ids))})
perm = _permutation_validieren(antwort, ids)
if perm:
nach_id = {b["id"]: b for b in bs}
reihenfolge = [nach_id[i] for i in perm]
else:
db.insert("befunde", run_id=task["run_id"], stufe="struktur",
knoten="ordnung", art="ordnung_fallback",
item=f"kapitel:{kap['id']}", status="freispruch",
detail="Judge-Permutation ungültig, Prior verwendet")
for i, b in enumerate(reihenfolge):
db.update("bausteine", "id=?", (b["id"],), ord=i, status="geordnet")
return engine.Ergebnis(daten={"kapitel": len(kapitel_gruppen),
"ziel_merges": ziel_merges})
async def _kapitel_schnitt(task: dict, soll: list[dict]) -> list[tuple[str, list[int]]]:
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
ids = [s["id"] for s in soll]
if len(ids) <= config.KAPITEL_SOLL_PUNKTE:
return [(thema, ids)]
antwort = await llm.call(
run_id=task["run_id"], stufe="struktur", knoten="ordnung",
item="themen_schnitt", role="judge", n=len(ids),
skill_namen=["planer", "deutsch-praezise", "themen-schnitt"],
werte={"thema": thema, "richtwert": config.KAPITEL_SOLL_PUNKTE,
"punkte": "\n".join(f"[{s['id']}] {s['punkt']}" for s in soll)})
if antwort:
gruppen, gesehen = [], set()
for block in textkit.bloecke(antwort, "KAPITEL"):
pids = [int(i) for i in block.get("punkte", "").replace(" ", "").split(",")
if i.isdigit() and int(i) in set(ids) - gesehen]
if pids:
gruppen.append((block.get("titel", "Kapitel"), pids))
gesehen |= set(pids)
if gesehen == set(ids): # exakte Partition — sonst Fallback
return gruppen
db.insert("befunde", run_id=task["run_id"], stufe="struktur", knoten="ordnung",
art="themen_fallback", item=task["topic"], status="freispruch",
detail="Schnitt ungültig, √n-Fallback")
n_kap = max(1, round(math.sqrt(len(ids))))
groesse = math.ceil(len(ids) / n_kap)
return [(f"Teil {i + 1}", ids[i * groesse:(i + 1) * groesse])
for i in range(n_kap) if ids[i * groesse:(i + 1) * groesse]]
def _baustein_rang(baustein_id: int) -> tuple:
raenge = sorted(_atom_rang(a["id"]) for a in db.query(
"SELECT id FROM atome WHERE baustein_id=? AND status='aktiv'", baustein_id))
return raenge[len(raenge) // 2] if raenge else (9999, 10**9)
def _permutation_validieren(antwort: str | None, ids: list[int]) -> list[int] | None:
if not antwort:
return None
block = textkit.bloecke(antwort, "ORDNUNG")
if not block:
return None
perm = [int(i) for i in block[0].get("ids", "").replace(" ", "").split(",")
if i.isdigit()]
return perm if sorted(perm) == sorted(ids) else None
@engine.worker("struktur.gate")
async def gate(task: dict) -> engine.Ergebnis:
topic, runde = task["topic"], task["runde"]
befunde, neue = [], []
ohne_baustein = db.query("SELECT id FROM atome WHERE topic=? AND "
"status='aktiv' AND baustein_id IS NULL", topic)
for a in ohne_baustein:
befunde.append({"art": "partition", "item": f"atom:{a['id']}",
"detail": "Atom ohne Baustein"})
zu_gross = db.query(
"SELECT baustein_id b, COUNT(*) n FROM atome WHERE topic=? AND "
"status='aktiv' GROUP BY baustein_id HAVING n>?",
topic, config.BAUSTEIN_MAX_ATOME)
for z in zu_gross:
befunde.append({"art": "band", "item": f"baustein:{z['b']}",
"detail": f"{z['n']} Atome"})
ziel = db.one("SELECT ziel_id z FROM bausteine WHERE id=?", z["b"])
db.execute("DELETE FROM bausteine WHERE id=?", z["b"])
neue.append({"knoten": "bausteine", "item": f"ziel:{ziel['z']}:r{runde + 1}",
"art": "reparatur", "runde": runde + 1,
"payload": {"ziel_id": ziel["z"]}})
# Coverage Stufe 3: jeder bestätigte Soll-Punkt (ohne Freispruch) ≥1 Baustein
ungedeckt = db.query(
"SELECT s.id FROM soll s WHERE s.topic=? AND s.status='bestaetigt' AND "
"s.freispruch='' AND NOT EXISTS (SELECT 1 FROM atome a WHERE a.soll_id=s.id "
"AND a.status='aktiv' AND a.baustein_id IS NOT NULL)", topic)
for s in ungedeckt:
befunde.append({"art": "soll_ohne_baustein", "item": f"soll:{s['id']}",
"detail": ""})
# Kennzahl-Befund (blockiert nicht): Struktur aufgebläht?
n_bausteine = db.one("SELECT COUNT(*) c FROM bausteine WHERE topic=?",
topic)["c"]
n_soll = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"]
if n_soll and n_bausteine > n_soll * 3:
db.insert("befunde", run_id=task["run_id"], stufe="struktur",
knoten="gate_struktur", art="struktur_aufgeblaeht",
item=topic, status="freispruch",
detail=f"{n_bausteine} Bausteine aus {n_soll} Soll-Punkten")
if befunde:
return engine.Ergebnis(gate_status="rot", befunde=befunde, neue_tasks=neue)
db.update("topics", "name=?", (topic,), status="struktur_fertig")
neue = []
for b in db.query("SELECT id FROM bausteine WHERE topic=?", topic):
db.insert("sections", ignore=True, baustein_id=b["id"])
neue.append({"knoten": "writer", "item": f"r1:baustein:{b['id']}",
"payload": {"baustein_id": b["id"]}})
for k in db.query("SELECT id FROM kapitel WHERE topic=?", topic):
neue.append({"knoten": "kapitel_intro", "item": f"kapitel:{k['id']}",
"payload": {"kapitel_id": k["id"]}})
return engine.Ergebnis(gate_status="gruen", neue_tasks=neue)

200
backend/suche.py Normal file
View File

@@ -0,0 +1,200 @@
"""Such-Schicht (R2): Wikipedia de+en immer, dazu Kette Tavily→Brave→ddgs.
Circuit Breaker je Backend; Teilausfall = degraded-Befund, nie Lauf-Crash."""
import asyncio
import os
import re
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import httpx
from . import config, db, engine, llm, textkit
_TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"fbclid", "gclid", "ref"}
_ausfaelle: dict[str, int] = {}
_wiki_sem = asyncio.Semaphore(config.WIKI_MAX_PARALLEL)
def url_normieren(url: str) -> str:
s = urlsplit(url)
query = urlencode([(k, v) for k, v in parse_qsl(s.query)
if k.lower() not in _TRACKING])
return urlunsplit((s.scheme.lower(), s.netloc.lower(),
s.path.rstrip("/") or "/", query, ""))
def _tot(backend: str) -> bool:
return _ausfaelle.get(backend, 0) >= config.CIRCUIT_BREAKER_N
def _ausfall(backend: str) -> None:
_ausfaelle[backend] = _ausfaelle.get(backend, 0) + 1
async def _http_json(url: str, **kw) -> dict | list | None:
async with httpx.AsyncClient(
timeout=config.SUCHE_TIMEOUT_S,
headers={"User-Agent": config.user_agent()}) as client:
r = await client.get(url, **kw)
r.raise_for_status()
return r.json()
async def _wikipedia(query: str) -> list[dict]:
treffer = []
for sprache in ("de", "en"):
backend = f"wikipedia_{sprache}"
if _tot(backend):
continue
try:
async with _wiki_sem:
daten = await _http_json(
f"https://{sprache}.wikipedia.org/w/rest.php/v1/search/page",
params={"q": query, "limit": 4})
for s in (daten or {}).get("pages", []):
treffer.append({
"titel": s.get("title", ""), "backend": backend,
"url": f"https://{sprache}.wikipedia.org/wiki/"
f"{s.get('key', s.get('title', ''))}"})
_ausfaelle[backend] = 0
except Exception:
_ausfall(backend)
return treffer
async def _tavily(query: str) -> list[dict]:
key = os.getenv("TAVILY_API_KEY")
if not key:
return []
async with httpx.AsyncClient(timeout=config.SUCHE_TIMEOUT_S) as client:
r = await client.post("https://api.tavily.com/search",
json={"api_key": key, "query": query,
"max_results": config.SUCHE_MAX_TREFFER})
r.raise_for_status()
return [{"titel": t.get("title", ""), "url": t["url"], "backend": "tavily"}
for t in r.json().get("results", [])]
async def _brave(query: str) -> list[dict]:
key = os.getenv("BRAVE_API_KEY")
if not key:
return []
daten = await _http_json("https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": config.SUCHE_MAX_TREFFER,
"country": "DE", "search_lang": "de"},
headers={"X-Subscription-Token": key})
return [{"titel": t.get("title", ""), "url": t["url"], "backend": "brave"}
for t in (daten or {}).get("web", {}).get("results", [])]
async def _ddgs(query: str) -> list[dict]:
def _sync():
from ddgs import DDGS
return DDGS().text(query, region="de-de",
max_results=config.SUCHE_MAX_TREFFER)
await asyncio.sleep(config.DDGS_PAUSE_S) # Drosselung (ToS-Grauzone)
rows = await asyncio.to_thread(_sync)
return [{"titel": r.get("title", ""), "url": r.get("href") or r.get("link", ""),
"backend": "ddgs"} for r in rows or [] if r.get("href") or r.get("link")]
_KETTE = (("tavily", _tavily), ("brave", _brave), ("ddgs", _ddgs))
async def web_suchen(query: str) -> tuple[list[dict], list[str]]:
"""→ (treffer, fehler). Wikipedia immer; Kette bis zum ersten Backend mit
Ergebnissen (Fallback statt Fan-out, R2)."""
treffer = await _wikipedia(query)
fehler = []
for name, fn in _KETTE:
if _tot(name):
continue
for versuch in range(config.SUCHE_RETRIES + 1):
try:
web = await asyncio.wait_for(fn(query), config.SUCHE_TIMEOUT_S * 2)
if web:
_ausfaelle[name] = 0
return treffer + web, fehler
break # Backend ohne Key/Treffer → nächstes
except Exception as e:
if versuch == config.SUCHE_RETRIES:
_ausfall(name)
fehler.append(f"{name}: {type(e).__name__}")
return treffer, fehler
async def _relevanz_filter(task: dict, thema: str, treffer: list) -> list:
"""Kurzurteil je Trefferliste: Werbe-/App-Listen und Off-Topic raus
(Lektion 91). AUCH Wikipedia-Treffer — Run 7 lud „Pasta" und „Xinjiang"."""
web = treffer
if len(web) <= 2:
return treffer
liste = "\n".join(f"[{i}] {t['titel']}{t['url']}" for i, t in enumerate(web))
antwort = await llm.call(
run_id=task["run_id"], stufe="korpus", knoten="suche",
item=f"{task['item']}:relevanz", role="judge",
skill_namen=["richter", "deutsch-praezise", "quellen-relevanz"],
werte={"thema": thema, "treffer": liste})
if antwort is None:
return treffer # Urteil ausgefallen → lieber behalten als verlieren
bloecke = textkit.bloecke(antwort, "RELEVANT")
ids = {int(i) for i in (bloecke[0].get("ids", "") if bloecke else "")
.replace(" ", "").split(",") if i.isdigit()}
return [t for i, t in enumerate(web) if i in ids]
@engine.worker("suche.suchen")
async def suchen(task: dict) -> engine.Ergebnis:
payload = db.uj(task["payload"], {})
query = payload.get("query", "")
zweck = payload.get("zweck", "korpus")
seed = bool(payload.get("seed"))
if seed:
# Wikipedia-Seed: deterministisch de+en zur Topic-Bezeichnung —
# umgeht Suche, Relevanz-Judge und Quellen-Deckel (2 Pflichtquellen)
from urllib.parse import quote
titel = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
treffer = [{"titel": f"Wikipedia ({s}): {titel}",
"url": f"https://{s}.wikipedia.org/wiki/"
f"{quote(titel.replace(' ', '_'))}",
"backend": f"wikipedia_{s}"} for s in ("de", "en")]
fehler = []
elif config.FAKE:
from . import fakes
treffer, fehler = fakes.suche(query), []
else:
treffer, fehler = await web_suchen(query)
# Wikipedia zuerst (kuratierter Grundstock schlägt Blog-Treffer)
treffer.sort(key=lambda t: not t["backend"].startswith("wikipedia"))
if zweck == "korpus" and treffer and not config.FAKE and not seed:
thema = db.one("SELECT COALESCE(NULLIF(titel,''), name) t FROM topics "
"WHERE name=?", task["topic"])["t"]
treffer = await _relevanz_filter(task, thema, treffer)
# Quellen-Deckel je Topic (0 = unbegrenzt, dann stoppt nur die Sättigung)
einstellung = db.one("SELECT quellen_max FROM topics WHERE name=?",
task["topic"]) or {}
deckel = einstellung.get("quellen_max")
if deckel is None:
deckel = config.QUELLEN_MAX
neue = []
for t in treffer:
if zweck == "korpus" and deckel > 0 and not seed:
vorhanden = db.one("SELECT COUNT(*) c FROM quellen WHERE topic=? AND "
"zweck='korpus' AND status NOT IN "
"('fehler','leer','robots','braucht_js')",
task["topic"])["c"]
if vorhanden >= deckel:
break
url_norm = url_normieren(t["url"])
qid = db.insert("quellen", ignore=True, topic=task["topic"],
titel=t["titel"][:200], url=t["url"], url_norm=url_norm,
backend=t["backend"], runde=task["runde"], zweck=zweck)
if qid:
neue.append({"knoten": "laden", "item": f"quelle:{qid}",
"payload": {"quelle_id": qid}})
if fehler and not treffer:
db.insert("befunde", run_id=task["run_id"], stufe="korpus", knoten="suche",
art="degraded", item=query[:80], detail="; ".join(fehler)[:300])
return engine.Ergebnis(daten={"treffer": len(treffer), "neu": len(neue)},
neue_tasks=neue)

270
backend/textkit.py Normal file
View File

@@ -0,0 +1,270 @@
"""Textwerkzeuge. Regel: NFKC+casefold vor JEDEM Vergleich (Lektion 27).
finde_zitat = 5 Stufen (Hypothes.is-Muster, Lektion 83): exakt → casefold →
alnum-gefaltet → fuzzy (8 %) → Wort-Alignment. Falsch-Anker >> fehlender Anker."""
import re
import unicodedata
from . import config
try:
import Stemmer
_stemmer = Stemmer.Stemmer("german")
except ImportError: # Fallback ohne C-Extension: ungestemmt
_stemmer = None
_WORT_RE = re.compile(r"[a-zäöüß0-9]+")
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne"}
def norm(text: str) -> str:
return " ".join(unicodedata.normalize("NFKC", text).casefold().split())
def tokens(text: str) -> set[str]:
return set(_WORT_RE.findall(norm(text)))
def stamm_tokens(text: str) -> set[str]:
t = _WORT_RE.findall(norm(text))
return set(_stemmer.stemWords(t)) if _stemmer else set(t)
def jaccard(a: str, b: str, gestemmt: bool = True) -> float:
ta, tb = (stamm_tokens(a), stamm_tokens(b)) if gestemmt else (tokens(a), tokens(b))
if not ta or not tb:
return 0.0
return len(ta & tb) / len(ta | tb)
def negations_menge(text: str) -> frozenset:
"""Antonyme messen 0.91-0.95 Cosinus (Lektion 31) — harte Merge-Vorbedingung."""
t = tokens(text)
return frozenset(w for w in t if w in _NEGATION or w.startswith("nicht"))
def titel_kern(titel: str) -> str:
"""Alnum-Faltung (NFKD, Kombinationszeichen raus) — deterministischer
Dubletten-Kandidat neben der Ähnlichkeit."""
d = unicodedata.normalize("NFKD", titel).casefold()
return "".join(c for c in d if c.isalnum())
def ist_akronym_von(kurz: str, lang: str) -> bool:
"""Akronym↔Expansion (Lektion 28 — fehlte in creator2): 'HTTP'
'Hypertext Transfer Protocol'."""
k = titel_kern(kurz)
woerter = _WORT_RE.findall(norm(lang))
if len(k) < 2 or len(woerter) < 2 or len(k) > len(woerter) + 2:
return False
initialen = "".join(w[0] for w in woerter)
return k == initialen or k == "".join(w[0] for w in woerter if len(w) > 2)
def abschnitte(text: str, max_chars: int = config.ABSCHNITT_CHARS) -> list[tuple[int, str]]:
"""Split an Absatzgrenzen (lost-in-the-middle-Guard) → [(offset, chunk)]."""
ergebnis, start = [], 0
while start < len(text):
ende = min(start + max_chars, len(text))
if ende < len(text):
bruch = text.rfind("\n\n", start, ende)
if bruch > start + max_chars // 4:
ende = bruch
ergebnis.append((start, text[start:ende]))
start = ende
return [(o, c) for o, c in ergebnis if c.strip()]
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
_SATZ_RE = re.compile(r"(?<=[.!?])\s+(?=[A-ZÄÖÜ])")
def satz_split(text: str) -> list[str]:
saetze = []
for absatz in text.split("\n"):
absatz = absatz.strip()
if not absatz or absatz.startswith(("#", "```", "|", "<!--")):
continue
saetze += [s.strip() for s in _SATZ_RE.split(absatz) if s.strip()]
return saetze
# ── Positions-Maps für Anker-Suche ──
def _map_bauen(text: str, wandler) -> tuple[str, list[int]]:
teile, positionen = [], []
for i, zeichen in enumerate(text):
for t in wandler(zeichen):
teile.append(t)
positionen.append(i)
return "".join(teile), positionen
def _ws_falten(text: str) -> tuple[str, list[int]]:
out, pos, war_ws = [], [], False
for i, c in enumerate(text):
if c.isspace():
if not war_ws and out:
out.append(" ")
pos.append(i)
war_ws = True
else:
out.append(c)
pos.append(i)
war_ws = False
return "".join(out), pos
def _locker(text: str) -> tuple[str, list[int]]:
"""Nur alnum, NFKD-Basisbuchstaben (dekomponierte Umlaute! Lektion 82)."""
def wandler(c: str):
for d in unicodedata.normalize("NFKD", c).casefold():
if d.isalnum() and not unicodedata.combining(d):
yield d
return _map_bauen(text, wandler)
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
zitat = zitat.strip()
if not zitat:
return None
# Stufe 1+2: whitespace-tolerant, dann casefold
t_flach, t_pos = _ws_falten(text)
z_flach, _ = _ws_falten(zitat)
for haystack, needle in ((t_flach, z_flach),
(t_flach.casefold(), z_flach.casefold())):
i = haystack.find(needle)
if i >= 0:
return t_pos[i], t_pos[min(i + len(needle), len(t_pos)) - 1] + 1
# Stufe 3: alnum-gefaltet
t_locker, tl_pos = _locker(text)
z_locker, _ = _locker(zitat)
if len(z_locker) >= 10:
erst = t_locker.find(z_locker)
if erst >= 0 and (len(z_locker) >= 20
or t_locker.find(z_locker, erst + 1) < 0):
return tl_pos[erst], tl_pos[min(erst + len(z_locker), len(tl_pos)) - 1] + 1
# Stufe 4: fuzzy (Levenshtein auf der Faltung)
span = _fuzzy_span(t_locker, tl_pos, z_locker)
if span and negations_menge(text[span[0]:span[1]]) == negations_menge(zitat):
return span
# Stufe 5: Wort-Alignment
span = _wort_span(text, zitat)
if span and negations_menge(text[span[0]:span[1]]) == negations_menge(zitat):
return span
return None
def _fuzzy_span(t_locker: str, tl_pos: list[int], z_locker: str):
if len(z_locker) < config.FUZZY_MIN_ZEICHEN:
return None
try:
from fuzzysearch import find_near_matches
except ImportError:
return None
dist = max(1, int(len(z_locker) * config.FUZZY_FEHLERQUOTE))
treffer = find_near_matches(z_locker, t_locker, max_l_dist=dist)
if not treffer:
return None
treffer.sort(key=lambda m: m.dist)
best = treffer[0]
# Eindeutigkeits-Guard: nicht-überlappender Zweittreffer fast gleicher Distanz
for m in treffer[1:]:
if m.start >= best.end or m.end <= best.start:
if m.dist <= best.dist + 1:
return None
break
return tl_pos[best.start], tl_pos[min(best.end, len(tl_pos)) - 1] + 1
def _woerter_mit_spans(text: str) -> list[tuple[str, int, int]]:
out = []
for m in re.finditer(r"\S+", text):
gefaltet = "".join(c for c in unicodedata.normalize("NFKD", m.group()).casefold()
if c.isalnum() and not unicodedata.combining(c))
if gefaltet:
out.append((gefaltet, m.start(), m.end()))
return out
def _wort_span(text: str, zitat: str):
z_woerter = [w for w, _, _ in _woerter_mit_spans(zitat)]
if len(z_woerter) < config.WORT_MIN:
return None
t_woerter = _woerter_mit_spans(text)
if not t_woerter:
return None
limit = int(len(z_woerter) * config.WORT_FEHLERQUOTE)
# Kandidaten über das seltenste Zitat-Wort (kein Voll-Scan)
haeufigkeit = {}
for w, _, _ in t_woerter:
haeufigkeit[w] = haeufigkeit.get(w, 0) + 1
selten = min((w for w in z_woerter if w in haeufigkeit),
key=lambda w: haeufigkeit[w], default=None)
if selten is None:
return None
beste, beste_d = None, limit + 1
for i, (w, _, _) in enumerate(t_woerter):
if w != selten:
continue
von = max(0, i - len(z_woerter))
bis = min(len(t_woerter), i + len(z_woerter))
fenster = [x[0] for x in t_woerter[von:bis]]
d = _wort_distanz(fenster, z_woerter, limit)
if d is not None and d < beste_d:
beste, beste_d = (von, bis), d
elif d is not None and d == beste_d and beste and von != beste[0]:
return None # mehrdeutig
if beste is None:
return None
return t_woerter[beste[0]][1], t_woerter[beste[1] - 1][2]
def _wort_distanz(a: list[str], b: list[str], limit: int) -> int | None:
zeile = list(range(len(b) + 1))
for i, wa in enumerate(a, 1):
neu = [i]
for j, wb in enumerate(b, 1):
neu.append(min(zeile[j] + 1, neu[-1] + 1,
zeile[j - 1] + (wa != wb)))
if min(neu) > limit:
return None
zeile = neu
return zeile[-1] if zeile[-1] <= limit else None
# ── DELIMITED-Parser (===BLOCK===-Format, R5/R9: kein JSON) ──
def bloecke(text: str, marke: str) -> list[dict]:
"""'===MARKE===\nKEY: wert\n' → [{key: wert}]. Mehrzeilige Werte bis zum
nächsten GROSS-KEY. Unbekannte Zeilen hängen am letzten Key."""
ergebnis = []
teile = re.split(rf"^===\s*{re.escape(marke)}\s*===\s*$", text,
flags=re.MULTILINE)
for teil in teile[1:]:
felder: dict[str, str] = {}
key = None
for zeile in teil.splitlines():
if zeile.strip().startswith("==="):
break
m = re.match(r"^([A-ZÄÖÜ_]{2,20}):\s?(.*)$", zeile)
if m:
key = m.group(1).lower()
felder[key] = m.group(2)
elif key and zeile.strip():
felder[key] += "\n" + zeile
if felder:
ergebnis.append({k: v.strip() for k, v in felder.items()})
return ergebnis
def block_text(text: str, marke: str) -> str:
"""Inhalt zwischen ===MARKE=== und dem nächsten ===…=== (oder Ende)."""
m = re.search(rf"^===\s*{re.escape(marke)}\s*===\s*$(.*?)(?=^===|\Z)", text,
flags=re.MULTILINE | re.DOTALL)
return m.group(1).strip() if m else ""

46
backend/ws.py Normal file
View File

@@ -0,0 +1,46 @@
"""WS-Hub (R1): EINE Queue + EIN Sender-Task — Broadcasts bleiben geordnet.
Payload ist nur ein dirty-Signal {typ, topic, bereich}; der Client lädt selbst."""
import asyncio
import json
class Hub:
def __init__(self) -> None:
self._clients: set = set()
self._queue: asyncio.Queue | None = None
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
self._queue = asyncio.Queue()
loop.create_task(self._sende_schleife())
def push(self, tabelle: str, row: dict) -> None:
"""Thread-sicher aus der DB-Schicht aufrufbar."""
if self._loop is None or self._queue is None:
return
bereich = {"tasks": "graph", "gate_laeufe": "graph", "befunde": "graph",
"runs": "run", "topics": "run"}.get(tabelle, "graph")
msg = json.dumps({"typ": "dirty", "bereich": bereich}, ensure_ascii=False)
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)
async def _sende_schleife(self) -> None:
while True:
msg = await self._queue.get()
tot = []
for ws in list(self._clients):
try:
await ws.send_text(msg)
except Exception:
tot.append(ws)
for ws in tot:
self._clients.discard(ws)
def connect(self, ws) -> None:
self._clients.add(ws)
def disconnect(self, ws) -> None:
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>creator3</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2106
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "creator3-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@panzoom/panzoom": "^4.6.0",
"@shikijs/markdown-it": "^4.3.1",
"dompurify": "^3.4.7",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"shiki": "^4.3.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}

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

@@ -0,0 +1,182 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from "vue";
import { api, wsVerbinden } from "./api.js";
import { layout, store } from "./store.js";
import Graph from "./components/Graph.vue";
import Guide from "./components/Guide.vue";
import Kennzahlen from "./components/Kennzahlen.vue";
const tab = ref("graph");
const navOffen = ref(false);
const fehler = ref("");
const neuName = ref("");
let wsTrennen = null;
let debounceId = null;
let pollId = null;
const laeuft = computed(() => store.run?.status === "running");
const quellenMax = computed(() => {
const t = store.topics.find((x) => x.name === store.topic);
return t?.quellen_max ?? 24;
});
async function quellenSetzen(ev) {
// Regler-Position 1 = ∞ (API-Wert 0); echte Deckel beginnen bei 2,
// denn 1 Quelle kann nie bestätigen (Konsens braucht 2 unabhängige)
const pos = parseInt(ev.target.value, 10);
const wert = pos === 1 ? 0 : pos;
try {
await api.einstellen(store.topic, wert);
await topicsLaden();
} catch (e) {
fehler.value = String(e);
}
}
async function topicsLaden() {
store.topics = await api.topics();
if (!store.topic && store.topics.length) store.topic = store.topics[0].name;
}
async function graphLaden() {
if (!store.topic) return;
try {
const g = await api.graph(store.topic);
layout.value = g.layout;
store.zaehler = g.zaehler;
store.gates = g.gates;
store.run = g.run;
} catch (e) {
fehler.value = String(e);
}
}
function onWs(ev) {
if (ev.typ === "verbunden") return alles();
clearTimeout(debounceId);
debounceId = setTimeout(() => {
graphLaden();
topicsLaden();
}, 250);
}
async function alles() {
await topicsLaden();
await graphLaden();
}
async function waehlen(name) {
store.topic = name;
navOffen.value = false;
await graphLaden();
}
async function anlegen() {
if (!neuName.value.trim()) return;
try {
await api.anlegen(neuName.value.trim(), "");
neuName.value = "";
await alles();
} catch (e) {
fehler.value = String(e);
}
}
async function aktion(fn) {
fehler.value = "";
try {
await fn(store.topic);
await graphLaden();
} catch (e) {
fehler.value = String(e);
}
}
async function starten() {
let budget = null;
const r = store.run;
const knapp = r && r.budget_tokens > 0 &&
(r.verbraucht || 0) >= r.budget_tokens * 0.9;
if (r?.status === "budget" || (r?.status === "paused" && knapp)) {
const alt = r.budget_tokens || 2000000;
const eingabe = window.prompt(
`Budget ${r.status === "budget" ? "erschöpft" : "fast erschöpft"} ` +
`(${((r.verbraucht || 0) / 1e6).toFixed(1)}M verbraucht). ` +
"Neues Token-Budget für diesen Lauf:",
String(alt + 2000000),
);
if (eingabe === null) return;
budget = parseInt(eingabe, 10) || null;
}
await aktion((t) => api.start(t, budget));
}
async function reset() {
if (!window.confirm(`${store.topic}" komplett zurücksetzen? ` +
"Quellen, Atome und Guide werden gelöscht."))
return;
await aktion(api.reset);
await topicsLaden();
}
onMounted(() => {
alles();
wsTrennen = wsVerbinden(onWs);
pollId = setInterval(() => laeuft.value && graphLaden(), 5000);
});
onUnmounted(() => {
wsTrennen?.();
clearInterval(pollId);
});
</script>
<template>
<div class="app">
<header class="topbar">
<button class="burger" @click="navOffen = !navOffen"></button>
<nav class="tabs">
<button :class="{ aktiv: tab === 'graph' }" @click="tab = 'graph'">Pipeline</button>
<button :class="{ aktiv: tab === 'guide' }" @click="tab = 'guide'">Guide</button>
<button :class="{ aktiv: tab === 'kennzahlen' }" @click="tab = 'kennzahlen'">
Kennzahlen
</button>
</nav>
<span class="spacer" />
<span v-if="store.run" class="runstatus" :data-status="store.run.status">
{{ store.run.status }}<template v-if="store.run.grund"> · {{ store.run.grund }}</template>
</span>
<button v-if="!laeuft" class="primaer" :disabled="!store.topic"
@click="starten()">Start</button>
<button v-else @click="aktion(api.pause)">Pause</button>
<button v-if="!laeuft && store.topic" title="Pipeline auf null zurücksetzen"
@click="reset()">Reset</button>
<label v-if="store.topic" class="quellen-regler"
:title="'Quellen-Deckel — ganz links: unbegrenzt, es stoppt nur die Sättigung'">
<span>Quellen: <b>{{ quellenMax === 0 ? "∞" : quellenMax }}</b></span>
<input type="range" min="1" max="48" step="1"
:value="quellenMax === 0 ? 1 : quellenMax"
:disabled="laeuft" @change="quellenSetzen($event)" />
</label>
</header>
<aside class="sidebar" :class="{ offen: navOffen }">
<form @submit.prevent="anlegen">
<input v-model="neuName" placeholder="Neues Thema…" />
</form>
<ul>
<li v-for="t in store.topics" :key="t.name"
:class="{ aktiv: t.name === store.topic }" @click="waehlen(t.name)">
<span>{{ t.titel || t.name }}</span>
<small>{{ t.status }}</small>
</li>
</ul>
</aside>
<main>
<p v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }}</p>
<Graph v-if="tab === 'graph'" />
<Guide v-else-if="tab === 'guide'" />
<Kennzahlen v-else />
</main>
</div>
</template>

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

@@ -0,0 +1,69 @@
// Alle Pfad-Parameter durch encodeURIComponent (creator2-Lektion: '#'/'/'
// im Topic-Namen machten Topics unlöschbar).
const BASE = import.meta.env.DEV ? "http://localhost:8000" : "";
async function req(pfad, opts = {}) {
const r = await fetch(BASE + pfad, {
headers: { "Content-Type": "application/json" },
...opts,
});
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
return r.json();
}
const e = encodeURIComponent;
export const api = {
topics: () => req("/api/topics"),
anlegen: (name, titel) =>
req("/api/topics", { method: "POST", body: JSON.stringify({ name, titel }) }),
loeschen: (t) => req(`/api/topics/${e(t)}`, { method: "DELETE" }),
start: (t, budget) =>
req(`/api/topics/${e(t)}/start`, {
method: "POST",
body: JSON.stringify({ budget }),
}),
pause: (t) => req(`/api/topics/${e(t)}/pause`, { method: "POST" }),
stop: (t) => req(`/api/topics/${e(t)}/stop`, { method: "POST" }),
reset: (t) => req(`/api/topics/${e(t)}/reset`, { method: "POST" }),
einstellen: (t, quellen_max) =>
req(`/api/topics/${e(t)}`, {
method: "PATCH",
body: JSON.stringify({ quellen_max }),
}),
graph: (t) => req(`/api/topics/${e(t)}/graph`),
knotenTasks: (t, k) => req(`/api/topics/${e(t)}/knoten/${e(k)}/tasks`),
guide: (t) => req(`/api/topics/${e(t)}/guide`),
kennzahlen: (t) => req(`/api/topics/${e(t)}/kennzahlen`),
};
// Reconnect mit Cleanup-Flag; onopen → synthetisches Event zum Resync.
export function wsVerbinden(onEvent) {
let offen = true;
let ws = null;
function verbinden() {
if (!offen) return;
const url =
(location.protocol === "https:" ? "wss://" : "ws://") +
(import.meta.env.DEV ? "localhost:8000" : location.host) +
"/ws";
try {
ws = new WebSocket(url);
} catch {
setTimeout(verbinden, 2000);
return;
}
ws.onopen = () => onEvent({ typ: "verbunden" });
ws.onmessage = (m) => {
try {
onEvent(JSON.parse(m.data));
} catch {}
};
ws.onclose = () => offen && setTimeout(verbinden, 2000);
}
verbinden();
return () => {
offen = false;
ws?.close();
};
}

View File

@@ -0,0 +1,59 @@
<script setup>
// Einfache Kachel-Ansicht: alle Knoten in Fluss-Reihenfolge,
// Titel + Task-Anzahl als Badge. Klick öffnet das Detail-Panel.
import { computed, ref } from "vue";
import { layout, store } from "../store.js";
import KnotenPanel from "./KnotenPanel.vue";
const gewaehlt = ref("");
const knoten = computed(() => {
if (!layout.value) return [];
return Object.entries(layout.value.knoten)
.map(([id, k]) => ({
id,
...k,
z: store.zaehler[id] || {},
gate: store.gates[id],
}))
.sort((a, b) => a.zeile - b.zeile || a.spalte - b.spalte)
.map((k) => ({
...k,
gesamt: (k.z.offen || 0) + (k.z.laufend || 0) + (k.z.fertig || 0) +
(k.z.fehler || 0),
}));
});
function statusKlasse(k) {
if (k.typ === "gate") return "gate-" + (k.gate?.status || "offen");
if ((k.z.fehler || 0) > 0) return "hat-fehler";
if ((k.z.laufend || 0) > 0) return "laeuft";
if ((k.z.offen || 0) > 0) return "wartet";
if ((k.z.fertig || 0) > 0) return "fertig";
return "";
}
function zahlKlasse(k) {
if (k.z.fehler) return "b-fehler";
if (k.z.laufend) return "b-laufend";
if (k.z.offen) return "b-offen";
return "b-fertig";
}
</script>
<template>
<div class="graph-wrap">
<div class="kacheln">
<button v-for="k in knoten" :key="k.id" class="knoten"
:class="[statusKlasse(k), { gewaehlt: k.id === gewaehlt }]"
@click="gewaehlt = k.id">
<span class="kn-titel">{{ k.titel }}</span>
<span class="kn-beschr">{{ k.beschreibung }}</span>
<span v-if="k.gate" class="kn-stufe">Runde {{ k.gate.runde }}</span>
<b v-if="k.gesamt" class="kn-zahl" :class="zahlKlasse(k)">{{
k.gesamt }}</b>
</button>
</div>
<KnotenPanel v-if="gewaehlt" :knoten="gewaehlt" @zu="gewaehlt = ''" />
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup>
// Guide-Ansicht (R10): 68ch-Prose, Kapitel-Sections mit content-visibility,
// TOC + IntersectionObserver-Scroll-Spy. HTML einmal rendern und cachen.
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { api } from "../api.js";
import { initMarkdown, render, slugify } from "../markdown.js";
import { store } from "../store.js";
const daten = ref(null);
const bereit = ref(false);
const aktiv = ref("");
const cache = new Map();
let beobachter = null;
let seq = 0;
async function laden() {
const meine = ++seq;
await initMarkdown();
bereit.value = true;
if (!store.topic) return;
const d = await api.guide(store.topic);
if (meine !== seq) return;
cache.clear();
daten.value = d;
await nextTick();
spyAufsetzen();
}
function html(key, text) {
if (!cache.has(key)) cache.set(key, render(text));
return cache.get(key);
}
const toc = computed(
() =>
daten.value?.kapitel.map((k) => ({
id: "kap-" + k.id,
titel: k.titel,
sections: k.sections.map((s) => ({ id: slugify(s.titel), titel: s.titel })),
})) || [],
);
function spyAufsetzen() {
beobachter?.disconnect();
beobachter = new IntersectionObserver(
(eintraege) => {
for (const e of eintraege) if (e.isIntersecting) aktiv.value = e.target.id;
},
{ rootMargin: "0px 0px -70% 0px" },
);
document
.querySelectorAll(".prose section[id]")
.forEach((el) => beobachter.observe(el));
}
function springe(id) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth" });
}
watch(() => store.topic, laden);
watch(
() => store.run?.status,
(neu, alt) => alt === "running" && neu !== "running" && laden(),
);
onMounted(laden);
onUnmounted(() => beobachter?.disconnect());
</script>
<template>
<div class="guide-wrap">
<nav class="toc">
<template v-for="k in toc" :key="k.id">
<a :class="{ aktiv: aktiv === k.id }" @click="springe(k.id)">{{
k.titel
}}</a>
<a v-for="s in k.sections" :key="s.id" class="toc-sub"
:class="{ aktiv: aktiv === s.id }" @click="springe(s.id)">{{
s.titel
}}</a>
</template>
</nav>
<article v-if="daten && bereit" class="prose">
<p v-if="!daten.kapitel.length" class="leer">
Noch kein Guide Lauf starten.
</p>
<section v-for="k in daten.kapitel" :id="'kap-' + k.id" :key="k.id"
class="kapitel">
<h2>{{ k.titel }}</h2>
<p v-if="k.intro" class="intro">{{ k.intro }}</p>
<section v-for="s in k.sections" :id="slugify(s.titel)" :key="s.id">
<h3>{{ s.titel }}</h3>
<div v-html="html(s.id + ':' + (s.text || '').length, s.text)" />
</section>
</section>
</article>
</div>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { api } from "../api.js";
import { store } from "../store.js";
const kz = ref(null);
async function laden() {
if (store.topic) kz.value = await api.kennzahlen(store.topic);
}
watch(() => store.topic, laden);
watch(() => store.run?.status, laden);
onMounted(laden);
function fmtDauer(ms) {
const s = (ms || 0) / 1000;
return s >= 60 ? (s / 60).toFixed(1) + " m" : s.toFixed(1) + " s";
}
function fmtTok(n) {
n = n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
const gesamt = computed(() => {
const g = { calls: 0, ok: 0, fehler: 0, tok_in: 0, tok_out: 0,
cache_read: 0, dur_ms: 0, wait_ms: 0 };
for (const z of kz.value?.zeilen || []) {
for (const k of Object.keys(g)) g[k] += z[k] || 0;
}
return g;
});
</script>
<template>
<div v-if="kz" class="kennzahlen">
<div class="kacheln">
<div class="kachel">
<b>{{ kz.coverage ?? "" }}<small v-if="kz.coverage !== null">%</small></b>
<span>Abdeckung ({{ kz.soll_gedeckt }}/{{ kz.soll_gesamt }} Soll-Punkte)</span>
</div>
<div class="kachel">
<b>{{ kz.redundanz_offen }}</b>
<span>offene Dopplungen</span>
</div>
<div class="kachel">
<b>{{ kz.eskaliert ?? 0 }}</b>
<span>eskalierte Befunde (Grenze dokumentiert)</span>
</div>
<div class="kachel">
<b>{{ kz.atome_aktiv ?? "" }}</b>
<span>aktive Atome ({{ kz.verdichtet_punkte ?? 0 }}/{{ kz.soll_gesamt }}
Punkte verdichtet)</span>
</div>
<div class="kachel">
<b>{{ kz.beleg_abdeckung ?? "" }}<small
v-if="kz.beleg_abdeckung !== null">%</small></b>
<span>Beleg-Abdeckung (Atome nahe jedem Soll-Beleg)</span>
</div>
<div class="kachel">
<b>{{ fmtTok(kz.verbraucht) }}</b>
<span>Tokens<template v-if="kz.budget"> / {{ fmtTok(kz.budget) }} Budget</template></span>
</div>
<div class="kachel">
<b>${{ kz.kosten_usd }}</b>
<span>geschätzte Kosten</span>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Stufe</th><th>Knoten</th><th>Calls</th><th>ok</th><th>Fehler</th>
<th>Tok ein</th><th>Tok aus</th><th>Cache</th><th>Dauer</th><th>Warte</th>
</tr>
</thead>
<tbody>
<tr v-for="(z, i) in kz.zeilen" :key="i">
<td>{{ z.stufe }}</td>
<td>{{ z.knoten }}</td>
<td>{{ z.calls }}</td>
<td>{{ z.ok }}</td>
<td>{{ z.fehler }}</td>
<td>{{ fmtTok(z.tok_in) }}</td>
<td>{{ fmtTok(z.tok_out) }}</td>
<td>{{ fmtTok(z.cache_read) }}</td>
<td>{{ fmtDauer(z.dur_ms) }}</td>
<td>{{ fmtDauer(z.wait_ms) }}</td>
</tr>
<tr v-if="kz.zeilen.length" class="gesamt">
<td>Gesamt</td>
<td></td>
<td>{{ gesamt.calls }}</td>
<td>{{ gesamt.ok }}</td>
<td>{{ gesamt.fehler }}</td>
<td>{{ fmtTok(gesamt.tok_in) }}</td>
<td>{{ fmtTok(gesamt.tok_out) }}</td>
<td>{{ fmtTok(gesamt.cache_read) }}</td>
<td>{{ fmtDauer(gesamt.dur_ms) }}</td>
<td>{{ fmtDauer(gesamt.wait_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
// Seitenpanel: Task-Liste eines Knotens, lazy geladen (kein State-Monolith).
import { computed, ref, watch } from "vue";
import { api } from "../api.js";
import { layout, store } from "../store.js";
const props = defineProps({ knoten: String });
defineEmits(["zu"]);
const info = computed(() => layout.value?.knoten[props.knoten] || {});
const skills = computed(() => info.value.skills || []);
const daten = ref(null);
let seq = 0;
async function laden() {
const meine = ++seq;
const d = await api.knotenTasks(store.topic, props.knoten);
if (meine === seq) daten.value = d;
}
watch(() => props.knoten, laden, { immediate: true });
</script>
<template>
<aside class="panel">
<header>
<strong>{{ info.titel || knoten }}</strong>
<button @click="$emit('zu')">×</button>
</header>
<p v-if="info.beschreibung" class="kn-beschr">{{ info.beschreibung }}</p>
<span v-if="skills.length" class="sk-badges">
<i v-for="s in skills" :key="s.name" :data-art="s.art">{{ s.name }}</i>
</span>
<template v-if="daten">
<h4 v-if="daten.befunde.length">Befunde</h4>
<ul class="befunde">
<li v-for="(b, i) in daten.befunde" :key="i" :data-status="b.status">
<b>{{ b.art }}</b> {{ b.item }}
<small v-if="b.detail">{{ b.detail }}</small>
<em>{{ b.status }}</em>
</li>
</ul>
<h4>Tasks ({{ daten.tasks.length }})</h4>
<ul class="tasks">
<li v-for="t in daten.tasks" :key="t.id" :data-status="t.status">
<code>{{ t.item }}</code>
<em>{{ t.status }}</em>
<small v-if="t.fehler">{{ t.fehler }}</small>
</li>
</ul>
</template>
</aside>
</template>

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

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

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

@@ -0,0 +1,61 @@
// markdown-it (html:false) + anchor + DOMPurify + Shiki Dual-Theme (R10).
// Shiki einmal async initialisieren, danach synchron rendern.
import DOMPurify from "dompurify";
import MarkdownIt from "markdown-it";
import anchor from "markdown-it-anchor";
import { fromHighlighter } from "@shikijs/markdown-it/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
export function slugify(s) {
return s
.normalize("NFKC")
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9äöüß-]/g, "");
}
const md = new MarkdownIt({ html: false, linkify: true });
md.use(anchor, { slugify, level: [2, 3] });
// Tabellen scrollen in eigenem Wrapper (Seite scrollt nie horizontal)
md.renderer.rules.table_open = () => '<div class="table-wrap"><table>';
md.renderer.rules.table_close = () => "</table></div>";
let bereit = null;
export function initMarkdown() {
if (bereit) return bereit;
bereit = createHighlighterCore({
themes: [
import("shiki/themes/github-light.mjs"),
import("shiki/themes/github-dark.mjs"),
],
langs: [
import("shiki/langs/php.mjs"),
import("shiki/langs/twig.mjs"),
import("shiki/langs/yaml.mjs"),
import("shiki/langs/bash.mjs"),
import("shiki/langs/sql.mjs"),
import("shiki/langs/json.mjs"),
import("shiki/langs/xml.mjs"),
import("shiki/langs/javascript.mjs"),
import("shiki/langs/python.mjs"),
],
engine: createJavaScriptRegexEngine(),
}).then((hl) => {
md.use(
fromHighlighter(hl, {
themes: { light: "github-light", dark: "github-dark" },
defaultLanguage: "text",
fallbackLanguage: "text",
}),
);
});
return bereit;
}
export function render(text) {
const ohneMarker = (text || "").replace(/<!--\s*atom:\s*\d+\s*-->/g, "");
return DOMPurify.sanitize(md.render(ohneMarker));
}

13
frontend/src/store.js Normal file
View File

@@ -0,0 +1,13 @@
// Zentraler Zustand: Zähler-Snapshot (reaktiv) + statische Graphstruktur
// (shallowRef — nur die Zähler patchen, kein Full-Redraw, R7).
import { reactive, shallowRef } from "vue";
export const store = reactive({
topics: [],
topic: "",
zaehler: {},
gates: {},
run: null,
});
export const layout = shallowRef(null);

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

@@ -0,0 +1,267 @@
/* Custom-Properties-Themes (dark default), EIN Breakpoint 768px (R1/R10). */
:root {
--bg: #14161a;
--bg2: #1c1f25;
--rand: #2b303a;
--text: #d8dce3;
--text2: #8b93a1;
--akzent: #5b9dff;
--gruen: #3fb47d;
--gelb: #d9a441;
--rot: #d9534f;
--lese-text: #cfd4dc;
}
:root[data-theme="light"] {
--bg: #fafafa;
--bg2: #ffffff;
--rand: #dfe3e8;
--text: #23272e;
--text2: #667085;
--lese-text: #2b3037;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: "Inter Variable", system-ui, sans-serif;
font-size: 15px;
}
button {
background: var(--bg2);
color: var(--text);
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.35rem 0.7rem;
cursor: pointer;
}
button.primaer { background: var(--akzent); color: #fff; border: none; }
input {
background: var(--bg2);
color: var(--text);
border: 1px solid var(--rand);
border-radius: 6px;
padding: 0.4rem 0.6rem;
width: 100%;
}
.app {
display: grid;
grid-template: "top top" auto "side main" 1fr / 230px 1fr;
height: 100%;
}
.topbar {
grid-area: top;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.5rem 0.9rem;
border-bottom: 1px solid var(--rand);
background: var(--bg2);
}
.topbar .spacer { flex: 1; }
.tabs { display: flex; gap: 0.3rem; }
.tabs button { border: none; background: none; color: var(--text2); }
.tabs button.aktiv { color: var(--text); border-bottom: 2px solid var(--akzent); border-radius: 0; }
.burger { display: none; }
.runstatus { font-size: 0.8rem; color: var(--text2); max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quellen-regler { display: flex; align-items: center; gap: 0.45rem; font-size: 0.8rem; color: var(--text2); }
.quellen-regler input { width: 110px; accent-color: var(--akzent); }
.quellen-regler b { color: var(--text); }
.runstatus[data-status="running"] { color: var(--gruen); }
.runstatus[data-status="paused"], .runstatus[data-status="budget"] { color: var(--gelb); }
.runstatus[data-status="failed"] { color: var(--rot); }
.sidebar {
grid-area: side;
border-right: 1px solid var(--rand);
padding: 0.7rem;
overflow-y: auto;
}
.sidebar ul { list-style: none; margin: 0.7rem 0; padding: 0; }
.sidebar li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.45rem 0.5rem;
border-radius: 6px;
cursor: pointer;
}
.sidebar li.aktiv { background: var(--bg2); }
.sidebar small { color: var(--text2); }
main { grid-area: main; overflow: auto; position: relative; }
.fehler {
margin: 0.6rem;
padding: 0.5rem 0.8rem;
background: color-mix(in srgb, var(--rot) 18%, transparent);
border: 1px solid var(--rot);
border-radius: 6px;
cursor: pointer;
}
/* ── Knoten-Kacheln ── */
.graph-wrap { display: flex; height: 100%; }
.kacheln {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 0.7rem;
padding: 1rem;
align-content: start;
}
.knoten {
position: relative;
min-height: 96px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
padding: 0.65rem 0.75rem;
text-align: left;
border-radius: 9px;
border: 1.5px solid var(--rand);
background: var(--bg2);
}
.kn-beschr { font-size: 0.74rem; color: var(--text2); line-height: 1.35; }
.knoten .kn-titel { font-size: 0.9rem; padding-right: 2.2rem; }
.kn-zahl {
position: absolute;
right: 0.6rem;
top: 0.6rem;
font-size: 0.74rem;
font-weight: 600;
padding: 0 0.4rem;
border-radius: 8px;
}
.panel .kn-beschr { margin: 0.35rem 0 0; }
.knoten.gewaehlt { outline: 2px solid var(--akzent); }
.knoten.laeuft { border-color: var(--akzent); }
.knoten.wartet { border-color: var(--text2); }
.knoten.fertig { border-color: var(--gruen); }
.knoten.hat-fehler { border-color: var(--rot); }
.knoten.gate-gruen { border-color: var(--gruen); background: color-mix(in srgb, var(--gruen) 12%, var(--bg2)); }
.knoten.gate-rot { border-color: var(--rot); background: color-mix(in srgb, var(--rot) 12%, var(--bg2)); }
.kn-titel { font-weight: 600; font-size: 0.82rem; }
.kn-stufe { font-size: 0.68rem; color: var(--text2); }
.sk-badges { display: flex; flex-wrap: wrap; gap: 3px 4px; margin: 0.5rem 0 0.2rem; }
.sk-badges i {
font-style: normal;
font-size: 0.68rem;
line-height: 1.6;
padding: 0 0.4rem;
border-radius: 7px;
border: 1px solid var(--rand);
color: var(--text2);
white-space: nowrap;
}
.sk-badges i[data-art="rolle"] { border-color: var(--akzent); color: var(--akzent); }
.sk-badges i[data-art="aufgabe"] { border-color: var(--gruen); color: var(--gruen); }
.sk-badges i[data-art="stil"] { border-color: var(--gelb); color: var(--gelb); }
.b-offen { background: var(--rand); }
.b-laufend { background: var(--akzent); color: #fff; }
.b-fertig { background: var(--gruen); color: #fff; }
.b-fehler { background: var(--rot); color: #fff; }
.panel {
width: 330px;
border-left: 1px solid var(--rand);
overflow-y: auto;
padding: 0.7rem;
background: var(--bg2);
}
.panel header { display: flex; justify-content: space-between; align-items: center; }
.panel ul { list-style: none; padding: 0; }
.panel li {
padding: 0.35rem 0.4rem;
border-bottom: 1px solid var(--rand);
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.8rem;
}
.panel li em { margin-left: auto; color: var(--text2); font-style: normal; }
.panel li[data-status="fehler"] em, .panel li[data-status="offen"] em { color: var(--rot); }
.panel li[data-status="fertig"] em, .panel li[data-status="behoben"] em { color: var(--gruen); }
.panel small { flex-basis: 100%; color: var(--text2); }
/* ── Guide (Lesetypografie, R10) ── */
.guide-wrap { display: flex; gap: 1rem; height: 100%; }
.toc {
width: 230px;
flex-shrink: 0;
position: sticky;
top: 0;
align-self: flex-start;
max-height: 100%;
overflow-y: auto;
padding: 1rem 0.5rem;
display: flex;
flex-direction: column;
}
.toc a { padding: 0.25rem 0.5rem; color: var(--text2); cursor: pointer; border-radius: 5px; font-size: 0.85rem; }
.toc a.toc-sub { padding-left: 1.3rem; font-size: 0.8rem; }
.toc a.aktiv { color: var(--text); background: var(--bg2); }
.prose {
max-width: 68ch;
margin-inline: auto;
padding: 1.5rem 1rem 5rem;
font-size: clamp(1rem, 0.95rem + 0.3vw, 1.125rem);
line-height: 1.65;
color: var(--lese-text);
}
.prose h2, .prose h3 { line-height: 1.25; margin: 2em 0 0.6em; scroll-margin-top: 5rem; color: var(--text); }
.prose .intro { color: var(--text2); font-style: italic; }
.prose section.kapitel { content-visibility: auto; contain-intrinsic-size: auto 1200px; }
.prose a { color: var(--akzent); }
.prose pre { overflow-x: auto; padding: 1rem; border-radius: 8px; font-size: 0.875em; line-height: 1.5; }
.prose :not(pre) > code { background: var(--bg2); padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
.table-wrap { overflow-x: auto; }
.table-wrap table { border-collapse: collapse; width: 100%; }
.table-wrap th, .table-wrap td { border: 1px solid var(--rand); padding: 0.4rem 0.6rem; max-width: 40ch; overflow-wrap: anywhere; text-align: left; }
.table-wrap tr.gesamt td { font-weight: 600; background: var(--bg2); }
.leer { color: var(--text2); text-align: center; margin-top: 4rem; }
:root:not([data-theme="light"]) .prose .shiki,
:root:not([data-theme="light"]) .prose .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
/* ── Kennzahlen ── */
.kennzahlen { padding: 1rem; max-width: 900px; margin-inline: auto; }
.kacheln { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0.7rem; margin-bottom: 1.2rem; }
.kachel { background: var(--bg2); border: 1px solid var(--rand); border-radius: 9px; padding: 0.8rem; display: flex; flex-direction: column; gap: 0.2rem; }
.kachel b { font-size: 1.5rem; }
.kachel span { color: var(--text2); font-size: 0.8rem; }
.kennzahlen th, .kennzahlen td { font-size: 0.82rem; white-space: nowrap; }
/* ── Mobil ── */
@media (max-width: 768px) {
.app { grid-template: "top" auto "main" 1fr / 1fr; }
.burger { display: block; }
.sidebar {
position: fixed;
z-index: 20;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 300px);
background: var(--bg);
transform: translateX(-100%);
transition: transform 0.2s;
}
.sidebar.offen { transform: none; }
.toc { display: none; }
.panel { position: fixed; z-index: 15; right: 0; top: 48px; bottom: 0; width: min(90vw, 330px); }
.runstatus { display: none; }
}
@media print {
.topbar, .sidebar, .toc, .panel { display: none !important; }
.prose { max-width: none; }
.prose h2, .prose h3 { break-after: avoid; }
.prose pre, .table-wrap { break-inside: avoid; }
.prose p { orphans: 3; widows: 3; }
}

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

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

119
pipeline.yaml Normal file
View File

@@ -0,0 +1,119 @@
# Pipeline-Graph creator3. Stufen strikt seriell; innerhalb einer Stufe fließt
# der Graph frei. Gates schließen ihre Stufe ab (deterministisch, hart).
# kanten.art: normal (DAG-Pflicht) | reparatur | ruecklauf (nur von Gates).
stufen: [korpus, inventar, struktur, guide, montage]
knoten:
- {id: recherche_plan, typ: llm, worker: korpus.recherche_plan, stufe: korpus,
titel: "Recherche-Plan", beschreibung: "Plant die Suchanfragen zum Thema",
skills: [planer, deutsch-praezise, recherche-plan], max_parallel: 16, klasse: kurz}
- {id: suche, typ: system, worker: suche.suchen, stufe: korpus, max_parallel: 4,
titel: "Websuche", beschreibung: "Sucht Quellen im Web, filtert Off-Topic raus"}
- {id: laden, typ: system, worker: laden.laden, stufe: korpus, max_parallel: 4,
titel: "Quellen laden", beschreibung: "Lädt Quellen und friert den Text ein"}
- {id: soll_extraktion, typ: llm, worker: korpus.soll_extraktion, stufe: korpus,
titel: "Soll-Punkte sammeln", beschreibung: "Zieht aus jeder Quelle, was der Guide abdecken soll",
skills: [extraktor, deutsch-praezise, soll-extraktion], max_parallel: 16, klasse: mittel}
- {id: soll_konsens, typ: llm, worker: korpus.soll_konsens, stufe: korpus,
titel: "Soll-Konsens", beschreibung: "Bestätigt Punkte mit genug unabhängigen Belegen",
skills: [richter, deutsch-praezise, soll-konsens], barriere: true}
- {id: gate_korpus, typ: gate, worker: korpus.gate, stufe: korpus,
titel: "Gate Korpus", beschreibung: "Prüft Quellen und Soll, lässt sonst nachsuchen"}
- {id: atom_extraktion, typ: llm, worker: inventar.atom_extraktion, stufe: inventar,
titel: "Atome extrahieren", beschreibung: "Holt die kleinsten Lerneinheiten aus den Quellen",
skills: [extraktor, deutsch-praezise, atom-extraktion], max_parallel: 16, klasse: mittel}
- {id: anker_fix, typ: llm, worker: inventar.anker_fix, stufe: inventar,
titel: "Anker reparieren", beschreibung: "Repariert Zitate, die nicht zur Quelle passen",
skills: [extraktor, deutsch-praezise, anker-fix], max_parallel: 16}
- {id: dedup, typ: llm, worker: dedup.dedup, stufe: inventar,
titel: "Dubletten-Urteil", beschreibung: "Findet doppelte Atome und verschmilzt sie",
skills: [richter, deutsch-praezise, dedup-urteil], barriere: true, klasse: kurz}
- {id: soll_zuordnung, typ: llm, worker: inventar.soll_zuordnung, stufe: inventar,
titel: "Soll-Zuordnung", beschreibung: "Ordnet jedes Atom einem Soll-Punkt zu",
skills: [richter, deutsch-praezise, soll-zuordnung], max_parallel: 16, klasse: kurz}
- {id: luecke, typ: llm, worker: inventar.luecke, stufe: inventar,
titel: "Lücken füllen", beschreibung: "Extrahiert nach, wo Soll-Punkte ohne Atom sind",
skills: [extraktor, deutsch-praezise, luecke-nachextraktion], max_parallel: 16, klasse: mittel}
- {id: verdichtung, typ: llm, worker: inventar.verdichtung, stufe: inventar,
titel: "Verdichtung", beschreibung: "Verschmilzt Splitter-Atome zum minimalen Set",
skills: [richter, deutsch-praezise, verdichtung], max_parallel: 16, klasse: kurz}
- {id: gate_inventar, typ: gate, worker: inventar.gate, stufe: inventar,
titel: "Gate Atome", beschreibung: "Prüft Abdeckung und Dubletten, stößt Reparaturen an"}
- {id: lernziele, typ: llm, worker: struktur.lernziele, stufe: struktur,
titel: "Lernziele", beschreibung: "Formuliert Lernziele aus den Atomen",
skills: [planer, deutsch-praezise, lernziele], max_parallel: 16, klasse: kurz}
- {id: bausteine, typ: llm, worker: struktur.bausteine, stufe: struktur,
titel: "Bausteine", beschreibung: "Bündelt Atome je Lernziel zu Bausteinen",
skills: [planer, deutsch-praezise, bausteine], max_parallel: 16}
- {id: ordnung, typ: llm, worker: struktur.ordnung, stufe: struktur,
titel: "Reihenfolge", beschreibung: "Bringt Bausteine in Lernreihenfolge und Kapitel",
skills: [planer, deutsch-praezise, ordnung], barriere: true}
- {id: gate_struktur, typ: gate, worker: struktur.gate, stufe: struktur,
titel: "Gate Struktur", beschreibung: "Prüft die Gliederung, lässt Bausteine nachbessern"}
- {id: writer, typ: llm, worker: guide.writer, stufe: guide,
titel: "Schreiben", beschreibung: "Schreibt eine Guide-Section je Baustein",
skills: [autor, deutsch-praezise, guide-lesbarkeit, writer], max_parallel: 16}
- {id: kapitel_intro, typ: llm, worker: guide.kapitel_intro, stufe: guide, max_parallel: 16,
titel: "Kapitel-Intros", beschreibung: "Schreibt die Einleitung je Kapitel",
skills: [autor, deutsch-praezise, kapitel-intro]}
- {id: det_pruefung, typ: system, worker: guide.det_pruefung, stufe: guide, max_parallel: 8,
titel: "Format-Prüfung", beschreibung: "Prüft Sections deterministisch auf Regelverstöße"}
- {id: llm_pruefung, typ: llm, worker: guide.llm_pruefung, stufe: guide,
titel: "Inhalts-Prüfung", beschreibung: "Prüft Sections auf Fehler gegen die Quellen-Anker",
skills: [richter, deutsch-praezise, guide-pruefung], max_parallel: 16, klasse: mittel}
- {id: fix, typ: llm, worker: guide.fix, stufe: guide,
titel: "Korrektur", beschreibung: "Behebt gefundene Befunde in den Sections",
skills: [autor, deutsch-praezise, guide-lesbarkeit, guide-fix], max_parallel: 16}
- {id: redundanz, typ: llm, worker: redundanz.pruefen, stufe: guide,
titel: "Dopplungs-Check", beschreibung: "Sucht Wiederholungen zwischen den Sections",
skills: [richter, deutsch-praezise, redundanz-urteil], barriere: true, klasse: kurz}
- {id: gate_guide, typ: gate, worker: guide.gate, stufe: guide,
titel: "Gate Guide", beschreibung: "Prüft den Text, stößt Fixes und Re-Prüfung an"}
- {id: montage, typ: system, worker: montage.montieren, stufe: montage,
titel: "Montage", beschreibung: "Fügt alle Teile zum fertigen Guide zusammen"}
kanten:
- {von: recherche_plan, nach: suche, art: normal}
- {von: suche, nach: laden, art: normal}
- {von: laden, nach: soll_extraktion, art: normal}
- {von: laden, nach: atom_extraktion, art: normal} # nur zweck=luecke-Quellen
- {von: soll_extraktion, nach: soll_konsens, art: normal}
- {von: soll_konsens, nach: gate_korpus, art: normal}
- {von: gate_korpus, nach: atom_extraktion, art: normal}
- {von: atom_extraktion, nach: anker_fix, art: normal}
- {von: anker_fix, nach: dedup, art: normal}
- {von: dedup, nach: soll_zuordnung, art: normal}
- {von: soll_zuordnung, nach: luecke, art: normal}
- {von: luecke, nach: gate_inventar, art: normal}
- {von: soll_zuordnung, nach: gate_inventar, art: normal}
- {von: gate_inventar, nach: lernziele, art: normal}
- {von: lernziele, nach: bausteine, art: normal}
- {von: bausteine, nach: ordnung, art: normal}
- {von: ordnung, nach: gate_struktur, art: normal}
- {von: gate_struktur, nach: writer, art: normal}
- {von: gate_struktur, nach: kapitel_intro, art: normal}
- {von: writer, nach: det_pruefung, art: normal}
- {von: det_pruefung, nach: llm_pruefung, art: normal}
- {von: llm_pruefung, nach: fix, art: normal}
- {von: fix, nach: redundanz, art: normal}
- {von: llm_pruefung, nach: redundanz, art: normal}
- {von: kapitel_intro, nach: redundanz, art: normal}
- {von: redundanz, nach: gate_guide, art: normal}
- {von: gate_guide, nach: montage, art: normal}
- {von: gate_korpus, nach: recherche_plan, art: ruecklauf} # neue Runde, neue Lenses
- {von: gate_inventar, nach: suche, art: ruecklauf} # Web-Nachrecherche für Lücken
- {von: gate_korpus, nach: soll_konsens, art: reparatur} # Beleg-Nachsuche
- {von: gate_inventar, nach: anker_fix, art: reparatur}
- {von: gate_inventar, nach: luecke, art: reparatur}
- {von: gate_inventar, nach: verdichtung, art: reparatur} # Minimalität: Mini-Atome fusionieren
- {von: gate_inventar, nach: dedup, art: reparatur} # 2. Dedup-Runde für Lücken-Atome
- {von: gate_struktur, nach: bausteine, art: reparatur}
- {von: gate_guide, nach: fix, art: reparatur}
- {von: gate_guide, nach: writer, art: reparatur} # Neuschreiben bei kaputten Markern
- {von: gate_guide, nach: det_pruefung, art: reparatur} # Re-Prüfung gefixter Sections
- {von: gate_guide, nach: redundanz, art: reparatur}

4
pytest.ini Normal file
View File

@@ -0,0 +1,4 @@
[pytest]
testpaths = tests
asyncio_mode = auto
addopts = -q

View File

@@ -0,0 +1,21 @@
---
name: anker-fix
art: aufgabe
beschreibung: Verlorene Zitate von Atomen im Quelltext wiederfinden
platzhalter: [atome, quelltext]
---
Für folgende Atome wurde das Beleg-Zitat im Quelltext nicht wörtlich gefunden. Finde je Atom die richtige Textstelle.
ATOME:
{{atome}}
QUELLTEXT:
{{quelltext}}
Regeln: ZITAT zeichengetreu aus dem Quelltext (mindestens ein voller Satz); gibt es keine passende Stelle, schreibe exakt VERWERFEN.
Ausgabeformat — GENAU so, ein Block pro Atom:
===FIX===
ID: <Atom-ID>
ZITAT: <wörtliches Zitat oder VERWERFEN>

View File

@@ -0,0 +1,24 @@
---
name: atom-extraktion
art: aufgabe
beschreibung: Wissensatome (Titel, Typ, Definition, Beleg) aus Abschnitten extrahieren
platzhalter: [thema, abschnitte]
---
Extrahiere aus den nummerierten Abschnitten alle Wissensatome zum Thema „{{thema}}". Ein Atom ist die kleinste eigenständig lernbare Einheit.
{{abschnitte}}
Regeln: TITEL 2-6 Wörter; TYP ist begriff, aussage oder verfahren; DEFINITION 1-2 Sätze, nur aus dem Abschnitt ableitbar; ZITAT ist die Textstelle (zeichengetreu, mindestens ein voller Satz) aus GENAU dem angegebenen Abschnitt; ABSCHNITT ist die Nummer des Abschnitts.
Granularität: Ein Atom muss ALLEIN lehr- und prüfbar sein. Aufzählungspunkte derselben Kategorie (mehrere Vorteile, Tipps, Beispiele) sind zusammen EIN Atom mit einer Sammel-Definition. Lieber wenige gehaltvolle Atome als viele Splitter.
Ausgabeformat — GENAU so, ein Block pro Atom, keine Prosa. Liefert ein Abschnitt keine Atome, schreibe für ihn stattdessen einen LEER-Block. JEDER Abschnitt muss in der Ausgabe vorkommen:
===ATOM===
ABSCHNITT: <Nummer>
TITEL: …
TYP: …
DEFINITION: …
ZITAT: …
===LEER===
ABSCHNITT: <Nummer>

View File

@@ -0,0 +1,18 @@
---
name: bausteine
art: aufgabe
beschreibung: Zu große/kleine Atom-Gruppen in Bausteine à 4-8 Atome schneiden
platzhalter: [ziel, atome, band]
---
Teile die Atome des Lernziels „{{ziel}}" in Bausteine mit je {{band}} Atomen. Ein Baustein ist eine didaktische Einheit, die man am Stück lernt.
ATOME (in Quellreihenfolge):
{{atome}}
Regeln: jede Atom-ID genau einmal; Reihenfolge innerhalb eines Bausteins beibehalten; TITEL beschreibt den Baustein-Inhalt.
Ausgabeformat — GENAU so, ein Block pro Baustein:
===BAUSTEIN===
TITEL: <2-6 Wörter>
ATOME: <kommagetrennte Atom-IDs>

View File

@@ -0,0 +1,19 @@
---
name: dedup-urteil
art: aufgabe
beschreibung: Atom-Paare beurteilen: dublette, verwandt oder verschieden
platzhalter: [paare]
---
Beurteile je Paar: lehren beide Atome DASSELBE (dublette)? Steckt der Inhalt des einen VOLLSTÄNDIG im anderen (enthalten)? Sind es verwandte, aber verschiedene Konzepte (verwandt)? Oder haben sie nichts miteinander zu tun (verschieden)? Auch dieselbe Studie oder dieselbe Aussage in anderen Worten ist eine dublette.
PAARE:
{{paare}}
Regeln: dublette NUR, wenn ein Lernender durch Weglassen eines der beiden nichts verlöre; bei Unsicherheit UNSICHER.
Ausgabeformat — GENAU so, ein Block pro Paar:
===URTEIL===
PAAR: <Paar-Nummer>
URTEIL: dublette | enthalten | verwandt | verschieden | UNSICHER
GRUND: <ein Satz>

View File

@@ -0,0 +1,23 @@
---
name: guide-fix
art: aufgabe
beschreibung: Befunde in einer Section minimalinvasiv beheben
platzhalter: [section, befunde, belege]
---
Behebe die Befunde in der Section. Ändere NUR, was für die Behebung nötig ist.
SECTION:
{{section}}
BEFUNDE:
{{befunde}}
BELEGE (Faktenbasis):
{{belege}}
Regeln: Marker-Zeilen `<!-- atom: id -->` unverändert lassen; bei Redundanz-Befunden die Wiederholung ersatzlos streichen oder durch einen knappen Prosa-Halbsatz ersetzen („wie bereits beim … beschrieben") — KEINE Links; keine H1-H3, keine Fettzeilen-Definitionen; Zahlen nur mit ehrlicher Quellenangabe.
Ausgabeformat:
===TEXT===
<die komplette korrigierte Section>

View File

@@ -0,0 +1,21 @@
---
name: guide-pruefung
art: aufgabe
beschreibung: Sections gegen ihre Belege prüfen: falsch, luecke, stil
platzhalter: [sections]
---
Prüfe jede Guide-Section gegen ihre Belege.
{{sections}}
Suche NUR: (a) falsch = Aussage widerspricht den Belegen, ist unbelegt erfunden, oder verkauft eine Zahl ohne konkrete Studie als pauschale Studienaussage bzw. erfindet Bedingungen dazu; (b) luecke = ein Atom des Bausteins wird nicht wirklich erklärt; (c) stil = grober Verstoß (englische Passagen, Floskeln, kaputtes Markdown). Kein Geschmacks-Feedback. SECTION ist die angegebene Section-ID.
Ausgabeformat — GENAU so, keine Prosa. Je Section: ein Block pro Befund; hat eine Section keine Befunde, schreibe für sie einen OK-Block. JEDE Section muss in der Ausgabe vorkommen:
===BEFUND===
SECTION: <Section-ID>
ART: falsch | luecke | stil
STELLE: <wörtliches Kurzzitat aus der Section>
DETAIL: <ein Satz>
===OK===
SECTION: <Section-ID>

View File

@@ -0,0 +1,20 @@
---
name: kapitel-intro
art: aufgabe
beschreibung: Inhaltsfreien 2-3-Satz-Einstieg für ein Kapitel schreiben
platzhalter: [kapitel, sections, vorherige]
---
Schreibe 2-3 Einstiegssätze für das Kapitel „{{kapitel}}": Was lernt man hier und warum jetzt.
SECTION-TITEL DES KAPITELS:
{{sections}}
BEREITS GELESENE KAPITEL (nur DIESE dürfen als Rückbezug vorkommen — gibt es keine, dann KEIN Rückbezug):
{{vorherige}}
Regeln: KEINE fachlichen Inhalte vorwegnehmen; KEINE Links; keine Floskeln wie „schlägt die Brücke", „des betrachteten Systems", „Lerngruppe" — konkret zum Thema bleiben.
Ausgabeformat:
===INTRO===
<2-3 Sätze>

View File

@@ -0,0 +1,19 @@
---
name: klaerung
art: aufgabe
beschreibung: Stichentscheid zu einem eskalierten Befund
platzhalter: [befund, belege]
---
Zu folgendem Befund sind sich zwei Prüfer uneins. Entscheide anhand der Belege.
BEFUND:
{{befund}}
BELEGE:
{{belege}}
Ausgabeformat — GENAU so:
===URTEIL===
URTEIL: mangel | kein_mangel
GRUND: <ein Satz>

View File

@@ -0,0 +1,19 @@
---
name: lernziele
art: aufgabe
beschreibung: Prüfbare Lernziele aus Atomen bilden, jedes Atom einem Ziel zuordnen
platzhalter: [soll_punkte]
---
Bilde je Soll-Punkt aus seinen Atomen 1-3 prüfbare Lernziele und ordne jedes Atom genau einem Ziel zu.
{{soll_punkte}}
Regeln: Lernziel = ein Satz „Der Lernende kann …"; jede Atom-ID genau einmal, nur bei Zielen ihres eigenen Soll-Punkts; SOLL ist die Nummer des Soll-Punkts.
Ausgabeformat — GENAU so, ein Block pro Ziel. JEDER Soll-Punkt braucht mindestens einen Block:
===ZIEL===
SOLL: <Nummer>
TITEL: <2-5 Wörter>
TEXT: <Der Lernende kann …>
ATOME: <kommagetrennte Atom-IDs>

View File

@@ -0,0 +1,18 @@
---
name: luecke-freispruch
art: aufgabe
beschreibung: Urteil: ist ein Soll-Punkt in den Quellen wirklich nicht abgedeckt?
platzhalter: [soll_punkt, auszuege]
---
Der folgende Soll-Punkt hat nach mehreren Suchrunden kein Wissensatom. Urteile: Ist er in den Quellen-Auszügen wirklich NICHT abgedeckt (dann ist der Punkt für diesen Korpus unerfüllbar), oder ist er doch abgedeckt?
SOLL-PUNKT: {{soll_punkt}}
QUELLEN-AUSZÜGE:
{{auszuege}}
Ausgabeformat — GENAU so:
===URTEIL===
URTEIL: unerfuellbar | abgedeckt | UNSICHER
GRUND: <ein Satz>

View File

@@ -0,0 +1,22 @@
---
name: luecke-nachextraktion
art: aufgabe
beschreibung: Gezielt Atome zu unabgedeckten Soll-Punkten extrahieren
platzhalter: [luecken]
---
Zu den folgenden Soll-Punkten fehlen Wissensatome. Extrahiere aus dem jeweiligen Textfenster gezielt Atome, die genau den Punkt abdecken.
{{luecken}}
Regeln: wie bei der Atom-Extraktion (TITEL/TYP/DEFINITION/ZITAT, Zitat zeichengetreu aus dem Fenster des Punkts); Aufzählungspunkte derselben Kategorie sind zusammen EIN Atom; SOLL ist die ID des Soll-Punkts.
Ausgabeformat — GENAU so, keine Prosa. Deckt ein Fenster seinen Punkt nicht ab, schreibe für ihn einen NICHTS-Block. JEDER Soll-Punkt muss in der Ausgabe vorkommen:
===ATOM===
SOLL: <Soll-ID>
TITEL: …
TYP: …
DEFINITION: …
ZITAT: …
===NICHTS===
SOLL: <Soll-ID>

19
skills/aufgabe/ordnung.md Normal file
View File

@@ -0,0 +1,19 @@
---
name: ordnung
art: aufgabe
beschreibung: Elemente in Lernreihenfolge bringen (exakte Permutation)
platzhalter: [art, liste, prior]
---
Ordne die folgenden {{art}} in die beste Lernreihenfolge: Definition/Begriff/Herkunft zuerst, dann Grundlagen und Regeln, dann Anwendung, zuletzt Bewertung — Voraussetzungen immer vor Abhängigem.
ELEMENTE:
{{liste}}
Vorschlag nach Quellreihenfolge (nur bei Gleichwertigkeit übernehmen): {{prior}}
Regeln: Ausgabe ist eine EXAKTE Permutation aller IDs — jede genau einmal.
Ausgabeformat — GENAU so:
===ORDNUNG===
IDS: <alle IDs kommagetrennt in Zielreihenfolge>

View File

@@ -0,0 +1,17 @@
---
name: quellen-relevanz
art: aufgabe
beschreibung: Suchtreffer auf inhaltliche Relevanz für ein Lernthema filtern
platzhalter: [thema, treffer]
---
Unten stehen Web-Suchtreffer für einen Lern-Guide zum Thema „{{thema}}". Behalte nur Treffer, die inhaltlich LEHREN (Erklärungen, Anleitungen, Hintergründe).
TREFFER:
{{treffer}}
Aussortieren: Produkt-/App-Listen, Shops, Werbeseiten, Foren-Schnipsel, Off-Topic — auch Job-, HR-, Karriere- und Ratgeberportale, deren Kern ein ANDERES Thema ist (die das Thema nur beiläufig erwähnen).
Ausgabeformat — GENAU so:
===RELEVANT===
IDS: <kommagetrennte Nummern der behaltenen Treffer>

View File

@@ -0,0 +1,21 @@
---
name: recherche-plan
art: aufgabe
beschreibung: Suchanfragen je Blickwinkel für ein Thema erzeugen
platzhalter: [thema, lenses, runde, bisherige_queries]
---
Erzeuge je Blickwinkel 3 Web-Suchanfragen zum Thema „{{thema}}" (Runde {{runde}}).
BLICKWINKEL:
{{lenses}}
Bereits verwendete Suchanfragen (NICHT wiederholen, neue Winkel finden):
{{bisherige_queries}}
Regeln: kurze, konkrete Suchanfragen (2-6 Wörter); deutsch oder englisch je nachdem, wo bessere Quellen zu erwarten sind; LENS ist exakt einer der gegebenen Blickwinkel-Namen.
Ausgabeformat — GENAU so, ein Block pro Suchanfrage, keine Prosa; JEDER Blickwinkel bekommt seine Blöcke:
===QUERY===
LENS: <Blickwinkel-Name>
TEXT: …

View File

@@ -0,0 +1,16 @@
---
name: redundanz-urteil
art: aufgabe
beschreibung: Satzpaare beurteilen: redundant oder didaktisch gewollt
platzhalter: [paare]
---
Je Paar stammen zwei ähnliche Sätze aus VERSCHIEDENEN Kapiteln des Guides. Urteile: redundant (dasselbe wird doppelt GELEHRT — einer sollte durch Verweis ersetzt werden) oder didaktisch_gewollt (kurzer Refresher/Überleitung, in Ordnung).
PAARE:
{{paare}}
Ausgabeformat — GENAU so, ein Block pro Paar:
===URTEIL===
PAAR: <Nummer>
URTEIL: redundant | didaktisch_gewollt | UNSICHER

View File

@@ -0,0 +1,21 @@
---
name: soll-beleg
art: aufgabe
beschreibung: Gebündelte Beleg-Nachsuche für offene Soll-Punkte in einer Quelle
platzhalter: [punkte, quelltext]
---
Suche für jeden der folgenden Soll-Punkte ein wörtliches Beleg-Zitat im Quelltext.
PUNKTE:
{{punkte}}
QUELLTEXT:
{{quelltext}}
Regeln: BELEG zeichengetreu aus dem Quelltext; findet sich keiner, schreibe exakt KEIN BELEG.
Ausgabeformat — GENAU so, ein Block pro Punkt:
===BELEG===
ID: <Punkt-ID>
BELEG: <wörtliches Zitat oder KEIN BELEG>

View File

@@ -0,0 +1,20 @@
---
name: soll-extraktion
art: aufgabe
beschreibung: Soll-Punkte (was ein Guide abdecken muss) mit wörtlichem Beleg extrahieren
platzhalter: [thema, abschnitte]
---
Lies die nummerierten Quelltext-Abschnitte und extrahiere je Abschnitt, welche Punkte ein vollständiger Lern-Guide zum Thema „{{thema}}" abdecken muss und die der Abschnitt belegt.
{{abschnitte}}
Regeln: nur Punkte, die der jeweilige Abschnitt wirklich behandelt; BELEG ist ein wörtliches Zitat (1-2 Sätze, zeichengetreu) aus GENAU diesem Abschnitt; ABSCHNITT ist die Nummer des Abschnitts.
Ausgabeformat — GENAU so, ein Block pro Punkt, keine Prosa. Liefert ein Abschnitt keine Punkte, schreibe für ihn stattdessen einen LEER-Block. JEDER Abschnitt muss in der Ausgabe vorkommen:
===PUNKT===
ABSCHNITT: <Nummer>
TEXT: <der Soll-Punkt in einem Satz>
BELEG: <wörtliches Zitat>
===LEER===
ABSCHNITT: <Nummer>

View File

@@ -0,0 +1,18 @@
---
name: soll-konsens
art: aufgabe
beschreibung: Soll-Punkt-Kandidaten in etwa {{ziel}} Gruppen gleicher Aussage falten
platzhalter: [punkte, ziel]
---
Unten stehen Soll-Punkt-Kandidaten mit IDs. Falte sie auf UNGEFÄHR {{ziel}} Gruppen. Eine Gruppe = ein Lernthema; Kandidaten mit derselben oder eng verwandter Aussage gehören zusammen. Du gruppierst NUR — ob eine Gruppe bestätigt wird, entscheidet nicht du.
KANDIDATEN:
{{punkte}}
Regeln: Gruppen, deren Aussage NICHT zum Thema des Guides gehört, gibst du NICHT aus (ihre IDs einfach weglassen); jede ausgegebene ID genau einmal; LIEBER GRÖBER als feiner — kleinteilige Aspekte desselben Themas gehören in EINE Gruppe; TEXT = beste, übergreifende Formulierung der Gruppe in einem Satz.
Ausgabeformat — GENAU so, ein Block pro Gruppe:
===GRUPPE===
IDS: <kommagetrennte IDs>
TEXT: <ein Satz>

View File

@@ -0,0 +1,21 @@
---
name: soll-zuordnung
art: aufgabe
beschreibung: Atome den Soll-Punkten zuordnen
platzhalter: [atome, soll]
---
Ordne jedes Atom genau einem Soll-Punkt zu.
ATOME:
{{atome}}
SOLL-PUNKTE:
{{soll}}
Regeln: passt kein Punkt wirklich, schreibe KEINER; nutze nur die gegebenen IDs.
Ausgabeformat — GENAU so, ein Block pro Atom:
===ZUORDNUNG===
ATOM: <Atom-ID>
SOLL: <Soll-ID oder KEINER>

View File

@@ -0,0 +1,18 @@
---
name: themen-schnitt
art: aufgabe
beschreibung: Soll-Punkte in Kapitel partitionieren
platzhalter: [thema, punkte, richtwert]
---
Partitioniere die Soll-Punkte des Themas „{{thema}}" in Kapitel (Richtwert {{richtwert}} Punkte pro Kapitel).
SOLL-PUNKTE:
{{punkte}}
Regeln: jede Punkt-ID GENAU einmal; Kapitel fachlich kohärent; TITEL kurz, ohne Nummerierung; Reihenfolge didaktisch: Definition, Begriff und Herkunft ZUERST, dann Regeln/Ablauf, dann Anwendung, zuletzt Bewertung/Grenzen.
Ausgabeformat — GENAU so, ein Block pro Kapitel, in Lernreihenfolge:
===KAPITEL===
TITEL: <2-5 Wörter>
PUNKTE: <kommagetrennte Punkt-IDs>

View File

@@ -0,0 +1,17 @@
---
name: verdichtung
art: aufgabe
beschreibung: Atome eines Soll-Punkts in möglichst wenige lehrbare Einheiten partitionieren
platzhalter: [soll_punkt, atome, ziel]
---
Unten stehen Wissensatome zum Soll-Punkt „{{soll_punkt}}". Partitioniere sie in so WENIGE lehrbare Einheiten wie möglich (Richtwert: höchstens {{ziel}}). Eine Einheit bündelt Atome, die man sinnvoll ZUSAMMEN lehrt — z. B. mehrere Vorteile, mehrere Tipps, mehrere Varianten derselben Sache.
ATOME:
{{atome}}
Regeln: jede Atom-ID genau einmal; eine eigene Einheit gibt es NUR, wenn sie etwas lehrt, das keine andere Einheit lehrt; im Zweifel zusammenlegen.
Ausgabeformat — GENAU so, ein Block pro Einheit:
===EINHEIT===
IDS: <kommagetrennte Atom-IDs>

23
skills/aufgabe/writer.md Normal file
View File

@@ -0,0 +1,23 @@
---
name: writer
art: aufgabe
beschreibung: Guide-Section für einen Baustein schreiben
platzhalter: [baustein, atome, belege, bekannt, band]
---
Schreibe die Guide-Section für den Baustein „{{baustein}}".
ATOME (mit Marker-Zeile — übernimm jede Marker-Zeile UNVERÄNDERT an die Stelle, wo das Atom gelehrt wird; die Kernaussagen arbeitest du in eigenen Fließtext ein, NIE wörtlich zitieren):
{{atome}}
BELEGE (Faktenbasis — nichts behaupten, was hier nicht steht):
{{belege}}
BEREITS BEHANDELT (nur als knapper Prosa-Halbsatz erwähnen, falls nötig — NIE erneut erklären, KEINE Links):
{{bekannt}}
Regeln: Länge {{band}} Wörter; jeder Marker `<!-- atom: id -->` genau einmal; KEINE Markdown-Links; keine Überschrift für den Baustein-Titel und keine H1-H3 (Unterpunkte als ####); Feldnamen/Rohformate aus der Eingabe nie übernehmen; Zahlen und Statistiken nur mit ehrlicher Quellenangabe aus den Belegen („laut Anbieter X", „eine Studie der Universität Y") — nie pauschal „Studien zufolge", nichts dazuerfinden.
Ausgabeformat — GENAU so, keine Prosa außerhalb:
===TEXT===
<der Section-Text als Markdown>

10
skills/rolle/autor.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: autor
art: rolle
beschreibung: Didaktischer Autor — schreibt nur aus den gegebenen Atomen, verlinkt Bekanntes
---
Du schreibst Lehrtext ausschließlich aus den gelieferten Wissensatomen und Belegen. Regeln:
- Keine Fakten hinzufügen, die nicht in Atomen oder Belegen stehen.
- Bereits behandelte Konzepte NICHT erneut erklären — stattdessen ein Ein-Satz-Refresher mit Verweis.
- Jede fachliche Aussage muss sich auf ein geliefertes Atom oder einen Beleg stützen.

10
skills/rolle/extraktor.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: extraktor
art: rolle
beschreibung: Sorgfältiger Extraktor — übernimmt nur wörtlich Belegbares aus dem Quelltext
---
Du extrahierst Wissen aus einem gegebenen Quelltext. Regeln:
- Übernimm ausschließlich, was im Quelltext steht. Nichts erfinden, nichts aus eigenem Wissen ergänzen.
- Zitate gibst du ZEICHENGETREU wieder — exakt wie im Quelltext, ohne Umformulierung, ohne Korrektur.
- Was unklar oder unvollständig ist, lässt du weg.

10
skills/rolle/planer.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: planer
art: rolle
beschreibung: Struktur-Planer — exakte Partitionen, jede Eingabe genau einmal
---
Du triffst Strukturentscheidungen (gruppieren, ordnen, zuteilen). Regeln:
- Jedes Eingabe-Element erscheint GENAU EINMAL in deiner Ausgabe — nichts weglassen, nichts doppeln, nichts erfinden.
- Verwende ausschließlich die gegebenen IDs.
- Antworte exakt im geforderten Format.

10
skills/rolle/richter.md Normal file
View File

@@ -0,0 +1,10 @@
---
name: richter
art: rolle
beschreibung: Kalter Richter — urteilt binär, gibt bei Unsicherheit keine Stimme ab
---
Du fällst Urteile über vorgelegte Fälle. Regeln:
- Urteile nur anhand der vorgelegten Belege, nie aus eigenem Wissen.
- Antworte exakt im geforderten Format, ohne Begründungs-Prosa außerhalb der vorgesehenen Felder.
- Wenn die Belege nicht ausreichen, antworte mit UNSICHER statt zu raten.

View File

@@ -0,0 +1,9 @@
---
name: deutsch-praezise
art: stil
beschreibung: Deutsch, präzise, Fachbegriffe beim ersten Auftreten kurz erklärt
---
- Antworte auf Deutsch. Etablierte Fachtermini bleiben stehen; ganze englische Sätze nicht.
- Präzise und knapp: keine Füllwörter, keine Meta-Kommentare, keine Anreden.
- Fachbegriffe beim ersten Auftreten in einem Halbsatz erklären.

View File

@@ -0,0 +1,13 @@
---
name: guide-lesbarkeit
art: stil
beschreibung: Lesbarkeits-Regeln für Guide-Text (linear lesbar, keine Links, keine Dopplung)
---
- Ein Konzept pro Abschnitt; nie zwei neue Ideen gleichzeitig einführen.
- Der Text wird LINEAR gelesen: KEINE Links im Fließtext, kein „Wie in [X] gezeigt". Rückbezug auf früher Gelehrtes = knapper Halbsatz in Prosa („wie beim Vierer-Rhythmus beschrieben") — und nur, wenn er dem Verständnis dient.
- Bereits Gelehrtes NIE erneut erklären. Auch keine Meta-Sätze darüber („wird hier nicht erneut aufgegriffen", „schlägt die Brücke", „dieses Kapitel behandelt").
- Gelieferte Kern-Definitionen EINARBEITEN, nie als Fettzeile zitieren und danach paraphrasieren — jede Information genau einmal.
- Erklärung vor Beispiel: erst max. 5 Sätze Konzept, direkt danach das Beispiel.
- Unterpunkte innerhalb des Textes als H4 (####) oder Aufzählung — H1 bis H3 setzt die Montage, nie du.
- Tabellen nur mit maximal 4 Spalten und kurzen Zellen; sonst Liste.

0
tests/__init__.py Normal file
View File

48
tests/conftest.py Normal file
View File

@@ -0,0 +1,48 @@
import os
import sys
from pathlib import Path
os.environ["CREATOR_FAKE"] = "1"
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest
from backend import config, db, engine, llm
# Kleine Fake-Welt → kleine Schwellen
config.SOLL_PUNKTE_MIN = 3
config.RECHERCHE_RUNDEN_MAX = 2
config.KAPITEL_SOLL_PUNKTE = 2
config.LUECKEN_RUNDEN_MAX = 1
config.POLL_SEKUNDEN = 0.05
config.DOMAIN_RATE_S = 0
config.DDGS_PAUSE_S = 0
config.QUERIES_JE_LENS = 2
config.VERDICHTUNG_ZIEL = 1 # zwingt den Verdichtungs-Pfad in der Fake-Welt
config.TAKT_SEKUNDEN = 0 # kein 3s-Takt in Tests
engine.module_laden()
@pytest.fixture(autouse=True)
def frische_db():
db.reset_for_tests()
llm._cooldown_bis = 0.0
llm._breite = config.MAX_PARALLEL_LLM
llm._inflight = 0
llm._naechster_start = 0.0
engine._laeufe.clear()
yield
def topic_anlegen(name="testthema", titel="Testthema"):
db.insert("topics", name=name, titel=titel)
return name
async def lauf_bis_ende(topic: str, budget: int | None = None, timeout=30):
import asyncio
run_id = engine.lauf_starten(topic, budget)
task = engine._laeufe[topic]
await asyncio.wait_for(task, timeout)
return db.one("SELECT * FROM runs WHERE id=?", run_id)

114
tests/test_buendel.py Normal file
View File

@@ -0,0 +1,114 @@
"""Bündeln: Claim mehrerer Tasks je Call, Teil-Antwort-Retry statt Verwerfen."""
import asyncio
from backend import db, engine, graph, guide
from .conftest import topic_anlegen
def _tasks_anlegen(topic, run_id, n=5):
return [db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item=f"p{i}", payload=db.j({"baustein_id": i}))
for i in range(n)]
def test_buendel_claim_mittel_nimmt_drei():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id)
g = graph.get()
db.execute("UPDATE tasks SET status='laufend' WHERE id=?", ids[0])
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
mitglieder = engine._buendel_claimen(g, t, g.knoten["llm_pruefung"])
assert len(mitglieder) == 2 # Klasse mittel = 3 → Haupt-Task + 2
laufend = db.query("SELECT id FROM tasks WHERE topic=? AND status='laufend'",
topic)
assert len(laufend) == 3
assert db.one("SELECT COUNT(*) c FROM tasks WHERE topic=? AND "
"status='offen'", topic)["c"] == 2
def test_buendel_claim_nur_fuer_optin_worker():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
for i in range(3): # writer: Klasse lang → nie bündeln
db.insert("tasks", run_id=run_id, topic=topic, knoten="writer",
item=f"w{i}", payload=db.j({"baustein_id": i}))
g = graph.get()
t = dict(db.one("SELECT * FROM tasks WHERE topic=? AND item='w0'", topic))
assert engine._buendel_claimen(g, t, g.knoten["writer"]) == []
def test_teil_kaputt_wird_neu_dann_fehler():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id, n=2)
db.execute("UPDATE tasks SET status='laufend' WHERE topic=?", topic)
g = graph.get()
kn = g.knoten["llm_pruefung"]
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
erg = engine.Ergebnis(daten={"befunde": 0},
teil_status={ids[0]: "fertig", ids[1]: "neu"})
engine._abschluss_schreiben(g, t, kn, erg)
haupt = db.one("SELECT * FROM tasks WHERE id=?", ids[0])
teil = db.one("SELECT * FROM tasks WHERE id=?", ids[1])
assert haupt["status"] == "fertig"
assert teil["status"] == "offen" and teil["versuch"] == 1
# zweiter kaputter Durchlauf → sichtbar 'fehler' (max_versuche=2)
db.execute("UPDATE tasks SET status='laufend' WHERE id=?", ids[1])
t2 = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t2["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
engine._abschluss_schreiben(g, t2, kn, engine.Ergebnis(
teil_status={ids[1]: "neu"}))
assert db.one("SELECT status FROM tasks WHERE id=?", ids[1])["status"] == "fehler"
async def test_llm_pruefung_buendel_teilantwort(monkeypatch):
"""Fehlt eine Section in der Antwort, geht NUR ihr Task auf 'neu'."""
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
b1 = db.insert("bausteine", topic=topic, titel="B1")
b2 = db.insert("bausteine", topic=topic, titel="B2")
db.insert("sections", baustein_id=b1, text="Text eins.")
db.insert("sections", baustein_id=b2, text="Text zwei.")
t1 = db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item="p1", payload=db.j({"baustein_id": b1}), status="laufend")
t2 = db.insert("tasks", run_id=run_id, topic=topic, knoten="llm_pruefung",
item="p2", payload=db.j({"baustein_id": b2}), status="laufend")
from backend import llm as llm_mod
async def antwort(**kw):
assert f"=== SECTION {b2} ===" in kw["werte"]["sections"]
return f"===OK===\nSECTION: {b1}" # b2 fehlt in beiden Stimmen
monkeypatch.setattr(llm_mod, "call", antwort)
task = dict(db.one("SELECT * FROM tasks WHERE id=?", t1))
task["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", t2))]
erg = await guide.llm_pruefung(task)
assert erg.teil_status[t1] == "fertig"
assert erg.teil_status[t2] == "neu"
async def test_worker_exception_setzt_buendel_zurueck():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
ids = _tasks_anlegen(topic, run_id, n=2)
db.execute("UPDATE tasks SET status='laufend' WHERE topic=?", topic)
g = graph.get()
async def kaputt(task):
raise RuntimeError("boom")
alt = engine.WORKER["guide.llm_pruefung"]
engine.WORKER["guide.llm_pruefung"] = kaputt
try:
t = dict(db.one("SELECT * FROM tasks WHERE id=?", ids[0]))
t["_buendel"] = [dict(db.one("SELECT * FROM tasks WHERE id=?", ids[1]))]
await engine._ausfuehren(g, t, g.knoten["llm_pruefung"],
asyncio.Semaphore(1), asyncio.Semaphore(1),
asyncio.Event())
finally:
engine.WORKER["guide.llm_pruefung"] = alt
for tid in ids:
z = db.one("SELECT * FROM tasks WHERE id=?", tid)
assert z["status"] == "offen" and z["versuch"] == 1

View File

@@ -0,0 +1,209 @@
"""Dedup-Kanäle (Akronym!, Subset-Falle, verwandt-Gedächtnis) + Dopplungs-Detektor."""
from backend import db, dedup, redundanz, textkit
from .conftest import topic_anlegen
def _atom(topic, titel, definition, soll_id=None, quelle=1, start=0, ende=10):
aid = db.insert("atome", topic=topic, titel=titel, definition=definition,
status="aktiv", soll_id=soll_id)
db.insert("anker", atom_id=aid, quelle_id=quelle, start=start, ende=ende,
zitat="z")
return aid
def test_auto_merge_anker_overlap():
topic = topic_anlegen()
a = _atom(topic, "A", "Definition eins", quelle=1, start=0, ende=100)
b = _atom(topic, "A2", "Definition eins etwas länger", quelle=1, start=20,
ende=110)
dedup._auto_merge_anker(topic)
aktive = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'", topic)
assert len(aktive) == 1
assert db.one("SELECT merged_into FROM atome WHERE status='verworfen'")[
"merged_into"] in (a, b)
def test_akronym_kanal_liefert_kandidat():
topic = topic_anlegen()
a = _atom(topic, "DNS", "Das System löst Namen zu Adressen auf.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Domain Name System", "Es übersetzt Domainnamen in Adressen.",
quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "akronym"
for p in paare)
def test_titel_fuzzy_subset_falle():
topic = topic_anlegen()
_atom(topic, "Zuhören", "Aufmerksames Aufnehmen von Gesagtem im Gespräch.",
quelle=1, start=0, ende=10)
_atom(topic, "Aktives Zuhören", "Technik der Gesprächsführung mit Rückfragen.",
quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
# Ein-Wort-Titel „Zuhören" darf NICHT über die Fuzzy-100-Subset-Falle kommen
assert not any(p[2] == "titel_fuzzy" for p in paare)
def test_verwandt_kante_verhindert_neupruefung():
topic = topic_anlegen()
a = _atom(topic, "Begriff Alpha", "Gemeinsame Wörter in dieser Definition hier.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Begriff Alpha", "Gemeinsame Wörter in dieser Definition dort.",
quelle=2, start=50, ende=60)
assert dedup._kandidaten(topic)
db.insert("kanten", topic=topic, von_atom=min(a, b), zu_atom=max(a, b),
art="verwandt")
assert dedup._kandidaten(topic) == [] # nie zweimal Tokens
def test_negations_guard_blockiert():
topic = topic_anlegen()
_atom(topic, "Determinismus", "Der Automat ist deterministisch aufgebaut.",
quelle=1, start=0, ende=10)
_atom(topic, "Determinismus ", "Der Automat ist nicht deterministisch aufgebaut.",
quelle=2, start=0, ende=10)
assert dedup._kandidaten(topic) == []
async def test_keiner_atome_nicht_erneut_gefragt():
"""Endlosschleifen-Guard: geprüfte Atome ohne Soll erzeugen keine neuen
Zuordnungs-Tasks mehr (Echtlauf 2: dedup↔zuordnung drehte sich im Kreis)."""
from backend import inventar
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
db.insert("soll", topic=topic, punkt="Punkt", status="bestaetigt")
aid = _atom(topic, "Fremdes Atom", "Definition ohne Bezug zum Punkt hier.")
task = {"run_id": run_id, "topic": topic, "runde": 1, "item": "z0",
"payload": db.j({"atom_ids": [aid]})}
# Fake-Zuordnung antwortet KEINER (Titel unbekannt) → Atom bleibt ohne Soll
erg = await inventar.soll_zuordnung(task)
assert erg.daten["zugeordnet"] == 0
atom = db.one("SELECT soll_id, soll_geprueft FROM atome WHERE id=?", aid)
assert atom["soll_id"] is None and atom["soll_geprueft"] == 1
# Dedup erzeugt für dieses Atom KEINE neue Zuordnungs-Aufgabe mehr
erg2 = await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
assert erg2.neue_tasks == []
def test_zahlen_namen_kanal():
"""Dieselbe Studie in Paraphrase (Audit: a20≈a78 Biwer 2023)."""
topic = topic_anlegen()
sid1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt")
sid2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt")
a = _atom(topic, "Studie zu Pausen",
"Biwer et al. 2023 verglichen 25 mit 35 Studierenden bei Pausen.",
soll_id=sid1, quelle=1, start=0, ende=10)
b = _atom(topic, "Selbstgesteuerte Pausen",
"In der Untersuchung von Biwer aus 2023 zeigten 35 Personen mehr "
"Ermüdung.", soll_id=sid2, quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "zahlen_namen"
for p in paare)
def test_gleicher_soll_ohne_jaccard_huerde():
topic = topic_anlegen()
sid = db.insert("soll", topic=topic, punkt="P", status="bestaetigt")
a = _atom(topic, "Ziel je Runde",
"Vor jeder Runde einen konkreten Zielsatz festhalten.",
soll_id=sid, quelle=1, start=0, ende=10)
b = _atom(topic, "Erfolgskriterium",
"Was hinterher anders sein soll, wird vorab notiert.",
soll_id=sid, quelle=2, start=0, ende=10)
paare = dedup._kandidaten(topic)
assert any({p[0]["id"], p[1]["id"]} == {a, b} and p[2] == "gleicher_soll"
for p in paare)
async def test_enthalten_merged_in_den_umfassenderen():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
sid = db.insert("soll", topic=topic, punkt="P", status="bestaetigt")
kurz = _atom(topic, "Basisregeln", "Kurze Definition.",
soll_id=sid, quelle=1, start=0, ende=10)
lang = _atom(topic, "Basisregeln und Sonderfaelle",
"Deutlich längere Definition mit allen Sonderfällen und mehr "
"Kontext für Lernende.", soll_id=sid, quelle=2, start=0, ende=10)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
gewinner = db.one("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic)
assert gewinner["id"] == lang # längere Definition gewinnt
assert db.one("SELECT merged_into FROM atome WHERE id=?", kurz)[
"merged_into"] == lang
def test_redundanz_shingle_kanal():
"""Identische 5-Wort-Folge in sonst verschiedenen Sätzen (Audit:
„Ich melde mich in 20 Minuten" 3×, von Jaccard übersehen)."""
topic = topic_anlegen()
_section(topic, 0, "Bei Störungen hilft der Satz Ich melde mich in zwanzig "
"Minuten als freundliche Antwort im Büro.")
_section(topic, 1, "Kollegen akzeptieren meist ein kurzes Ich melde mich "
"in zwanzig Minuten und arbeiten dann weiter an ihren "
"eigenen Aufgaben im Projekt.")
paare = redundanz.kandidaten_paare(topic)
assert len(paare) == 1
def _section(topic, kapitel_ord, text, ziel_suffix=""):
kid = db.insert("kapitel", topic=topic, titel=f"K{kapitel_ord}", ord=kapitel_ord)
zid = db.insert("lernziele", topic=topic, text="z" + ziel_suffix)
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel=f"B{kapitel_ord}",
kapitel_id=kid, ord=0)
db.insert("sections", baustein_id=bid, text=text)
return bid
def test_redundanz_detektor_findet_dopplung():
topic = topic_anlegen()
satz = "Ein Protokoll regelt den Austausch von Nachrichten zwischen Partnern."
_section(topic, 0, f"Einleitung hier. {satz}")
spaeter = _section(topic, 1, f"Anderes Kapitel beginnt. {satz}")
paare = redundanz.kandidaten_paare(topic)
assert len(paare) == 1
assert max(paare[0]["a"], paare[0]["b"], key=lambda x: x["pos"])["bid"] == spaeter
def test_redundanz_gleiche_section_kein_paar():
topic = topic_anlegen()
satz = "Ein Protokoll regelt den Austausch von Nachrichten zwischen Partnern."
_section(topic, 0, f"{satz} Noch etwas Text dazwischen. {satz}")
assert redundanz.kandidaten_paare(topic) == []
def test_redundanz_verschiedene_saetze_kein_paar():
topic = topic_anlegen()
_section(topic, 0, "Ein Protokoll regelt den Nachrichtenaustausch der Partner.")
_section(topic, 1, "Kapitel zwei behandelt ein völlig anderes Feld der Praxis.")
assert redundanz.kandidaten_paare(topic) == []
async def test_fehlendes_urteil_nachcall_dann_befund(monkeypatch):
"""Fehlt ein Paar-Urteil, folgt EIN Nach-Call; bleibt es aus → sichtbarer
parse-Befund und KEINE verwandt-Kante (Paar bleibt künftig prüfbar)."""
from backend import llm as llm_mod
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
a = _atom(topic, "DNS", "Das System löst Namen zu Adressen auf.",
quelle=1, start=0, ende=10)
b = _atom(topic, "Domain Name System", "Es übersetzt Domainnamen.",
quelle=2, start=0, ende=10)
calls = {"n": 0}
async def ohne_urteil(**kw):
calls["n"] += 1
return "===URTEIL===\nPAAR: 99\nURTEIL: verschieden\nGRUND: x"
monkeypatch.setattr(llm_mod, "call", ohne_urteil)
await dedup.dedup({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:dedup", "payload": "{}"})
assert calls["n"] == 4 # 2 Panel-Stimmen + 2 Nach-Call-Stimmen
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='parse'",
run_id)
assert db.query("SELECT id FROM kanten WHERE topic=?", topic) == []
aktiv = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv'",
topic)
assert {r["id"] for r in aktiv} == {a, b} # nichts still verschmolzen

120
tests/test_e2e.py Normal file
View File

@@ -0,0 +1,120 @@
"""Fake-E2E: kompletter Graph in Sekunden. Prüft Kernziele am DB-Endzustand:
Coverage 100 % (Lücke über Web-Nachrecherche geschlossen), Dublette gemerged,
Dopplung entfernt, Marker global einmalig, Resume = 0 neue Events."""
from pathlib import Path
from backend import db, fakes, montage
from .conftest import lauf_bis_ende, topic_anlegen
def _pruefe_endzustand(topic: str):
t = db.one("SELECT * FROM topics WHERE name=?", topic)
assert t["status"] == "fertig"
# Coverage: jeder bestätigte Soll-Punkt hat ≥1 aktives Atom mit Baustein
luecken = db.query(
"SELECT s.id FROM soll s WHERE s.topic=? AND s.status='bestaetigt' AND "
"s.freispruch='' AND NOT EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv' AND a.baustein_id IS NOT NULL)", topic)
assert luecken == []
assert db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"] == 3
# Dublette gemerged: HTTP-Paar existiert nur einmal aktiv
http = db.query("SELECT titel FROM atome WHERE topic=? AND status='aktiv' AND "
"titel IN ('HTTP', 'Hypertext Transfer Protocol')", topic)
assert len(http) == 1
merged = db.one("SELECT id FROM atome WHERE topic=? AND merged_into IS NOT NULL",
topic)
assert merged is not None
guide = Path(montage.guide_pfad(topic)).read_text()
# Marker global genau einmal je Atom
aktive = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv' AND "
"baustein_id IS NOT NULL", topic)
for a in aktive:
assert guide.count(f"<!-- atom: {a['id']} -->") == 1
# Dopplung raus, Rückbezug als Prosa — KEINE Links im Guide (linear lesbar)
assert guide.count(fakes.DUP) <= 1
assert "Wie bereits bei den Grundlagen beschrieben" in guide
assert "](#" not in guide
import re as _re
assert not _re.search(r"^\*\*[^*\n]+\*\*\s+—", guide, _re.MULTILINE)
# Verdichtung: Splitter-Atome („Vorteil …") sind zu EINEM fusioniert
vorteile = db.query("SELECT id FROM atome WHERE topic=? AND status='aktiv' "
"AND titel LIKE 'Vorteil%'", topic)
assert len(vorteile) == 1
starts = db.query("SELECT DISTINCT start FROM anker WHERE atom_id=?",
vorteile[0]["id"])
assert len(starts) == 2 # beide Belegstellen geerbt (Leser-Duplikate egal)
# Kein Format-Leak im Guide
assert "TITEL:" not in guide and "DEFINITION:" not in guide
assert "## Fehlerbehandlung" in guide
# Lücken-Atom kam über Web-Nachrecherche
assert db.one("SELECT id FROM quellen WHERE topic=? AND zweck LIKE 'luecke:%' "
"AND status='geladen'", topic) is not None
async def test_kompletter_lauf():
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "done", run["grund"]
_pruefe_endzustand(topic)
async def test_resume_keine_neuen_events():
topic = topic_anlegen()
run1 = await lauf_bis_ende(topic)
assert run1["status"] == "done"
vorher = db.one("SELECT COUNT(*) c FROM events")["c"]
run2 = await lauf_bis_ende(topic)
assert run2["status"] == "done"
assert db.one("SELECT COUNT(*) c FROM events")["c"] == vorher
async def test_pause_und_fortsetzen():
import asyncio
from backend import engine
topic = topic_anlegen()
run_id = engine.lauf_starten(topic)
await asyncio.sleep(0.3)
engine.lauf_pausieren(topic)
await asyncio.wait_for(engine._laeufe[topic], 10)
zwischen = db.one("SELECT * FROM runs WHERE id=?", run_id)
if zwischen["status"] == "done":
return # Lauf war schneller als die Pause — ok
assert zwischen["status"] == "paused"
run = await lauf_bis_ende(topic)
assert run["status"] == "done"
assert run["id"] == run_id # Resume nutzt denselben Run
_pruefe_endzustand(topic)
# keine Task-Duplikate
doppel = db.query("SELECT topic, knoten, item, COUNT(*) c FROM tasks "
"GROUP BY topic, knoten, item HAVING c > 1")
assert doppel == []
async def test_reset_und_neulauf():
from backend import config, engine
from backend.main import _pipeline_daten_loeschen
topic = topic_anlegen()
run1 = await lauf_bis_ende(topic)
assert run1["status"] == "done"
# Reset muss auch pausierte Läufe schließen (sonst Resume mit altem Budget)
db.update("runs", "id=?", (run1["id"],), status="paused")
_pipeline_daten_loeschen(topic)
db.execute("UPDATE runs SET status='stopped' WHERE topic=? AND "
"status IN ('running','paused','budget')", topic)
db.update("topics", "name=?", (topic,), status="neu")
assert db.one("SELECT COUNT(*) c FROM runs WHERE topic=? AND "
"status='paused'", topic)["c"] == 0
assert db.one("SELECT COUNT(*) c FROM tasks WHERE topic=?", topic)["c"] == 0
assert db.one("SELECT COUNT(*) c FROM atome WHERE topic=?", topic)["c"] == 0
assert not (config.KORPUS_DIR / topic).exists()
run2 = await lauf_bis_ende(topic)
assert run2["status"] == "done"
_pruefe_endzustand(topic)
async def test_events_haben_skill_hash():
topic = topic_anlegen()
await lauf_bis_ende(topic)
ohne = db.query("SELECT id FROM events WHERE status='ok' AND skill_hash=''")
assert ohne == []

85
tests/test_engine.py Normal file
View File

@@ -0,0 +1,85 @@
"""Engine-Kern: atomarer Claim, Zombie-Reset, Idempotenz, Kanten-Validierung."""
import pytest
from backend import db, engine, graph
from .conftest import topic_anlegen
def test_claim_atomar():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="suche", item="x")
assert db.execute("UPDATE tasks SET status='laufend' WHERE id=? AND "
"status='offen'", tid) == 1
# zweiter Claim verliert
assert db.execute("UPDATE tasks SET status='laufend' WHERE id=? AND "
"status='offen'", tid) == 0
def test_task_unique_idempotent():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
a = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r1:q:abc")
b = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r1:q:abc")
assert a is not None and b is None
# Runde im Item-Schlüssel: 2. Runde blockiert nicht
c = db.insert("tasks", ignore=True, run_id=run_id, topic=topic,
knoten="suche", item="r2:q:abc")
assert c is not None
async def test_zombie_reset_beim_start():
import asyncio
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="stopped")
db.insert("tasks", run_id=run_id, topic=topic, knoten="montage",
item="montage", status="laufend")
run_id2 = engine.lauf_starten(topic)
await asyncio.sleep(0.1)
t = db.one("SELECT status, fehler FROM tasks WHERE topic=? AND item='montage'",
topic)
assert t["status"] != "laufend" or t["fehler"] != "" # Zombie zurückgesetzt
engine.lauf_stoppen(topic)
with __import__("contextlib").suppress(asyncio.CancelledError):
await engine._laeufe[topic]
assert run_id2 != run_id
def test_kanten_validierung():
g = graph.get()
kn = g.knoten["suche"]
t = {"id": 1, "run_id": 1, "topic": "x", "knoten": "suche", "item": "i",
"runde": 1}
erg = engine.Ergebnis(neue_tasks=[{"knoten": "montage", "item": "m"}])
with pytest.raises(graph.GraphFehler):
engine._abschluss_schreiben(g, t, kn, erg)
async def test_infra_zaehlt_nicht_gegen_versuch():
"""LaufPause aus einem Worker setzt den Task zurück auf offen (kein versuch+1)
und pausiert den Run."""
import asyncio
async def kaputt(task):
from backend.llm import LaufPause
raise LaufPause("test")
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="suche",
item="x", status="laufend", versuch=0)
g = graph.get()
alt = engine.WORKER["suche.suchen"]
engine.WORKER["suche.suchen"] = kaputt
try:
t = dict(db.one("SELECT * FROM tasks WHERE id=?", tid))
await engine._ausfuehren(g, t, g.knoten["suche"], asyncio.Semaphore(1),
asyncio.Semaphore(1), asyncio.Event())
finally:
engine.WORKER["suche.suchen"] = alt
task = db.one("SELECT * FROM tasks WHERE id=?", tid)
assert task["status"] == "offen"
assert task["versuch"] == 0 # Infra zählt nicht als Inhalts-Versuch
assert db.one("SELECT status FROM runs WHERE id=?", run_id)["status"] == "paused"

282
tests/test_gates.py Normal file
View File

@@ -0,0 +1,282 @@
"""Gates: blockieren hart, Stillstand → Pause, Coverage-Formel deterministisch."""
import asyncio
from backend import db, engine, graph
from .conftest import lauf_bis_ende, topic_anlegen
async def test_gate_blockiert_hart_und_stillstand_pausiert(monkeypatch):
"""Konsens liefert nie bestätigte Punkte → gate_korpus bleibt rot →
nach Runden-Cap/Stillstand pausiert der Lauf. Stufe 2 startet NIE."""
from backend import korpus
async def konsens_kaputt(task):
return engine.Ergebnis(daten={"bestaetigt": 0})
monkeypatch.setitem(engine.WORKER, "korpus.soll_konsens", konsens_kaputt)
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "paused"
assert "gate" in run["grund"] or "stillstand" in run["grund"]
# Kein einziger Task jenseits der Korpus-Stufe
g = graph.get()
spaeter = [k.id for k in g.knoten.values() if k.stufe != "korpus"]
qs = ",".join("?" for _ in spaeter)
assert db.one(f"SELECT id FROM tasks WHERE topic=? AND knoten IN ({qs}) "
"LIMIT 1", topic, *spaeter) is None
# Status-Marker wurde nie gesetzt
assert db.one("SELECT status FROM topics WHERE name=?", topic)["status"] == "neu"
async def test_stillstand_nur_ohne_neue_tasks():
"""Gleicher Fingerprint, aber NEUE Reparatur-Tasks → kein Pause-Abbruch;
erst wenn nichts Neues mehr entsteht, greift der Stillstand."""
import asyncio
from backend import engine, graph
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
g = graph.get()
kn = g.knoten["gate_guide"]
befund = [{"art": "redundanz", "item": "baustein:1", "detail": "x"}]
async def gate_lauf(runde, tasks):
tid = db.insert("tasks", run_id=run_id, topic=topic, knoten="gate_guide",
item=f"runde:{runde}", runde=runde, status="laufend")
t = dict(db.one("SELECT * FROM tasks WHERE id=?", tid))
engine._abschluss_schreiben(
g, t, kn, engine.Ergebnis(gate_status="rot", befunde=befund,
neue_tasks=tasks))
await gate_lauf(1, [{"knoten": "fix", "item": "r2:fix:1"}])
await gate_lauf(2, [{"knoten": "fix", "item": "r3:klaerung:1"}]) # NEU → ok
assert db.one("SELECT status FROM runs WHERE id=?", run_id)[
"status"] == "running"
await gate_lauf(3, [{"knoten": "fix", "item": "r3:klaerung:1"}]) # ignoriert
assert db.one("SELECT status FROM runs WHERE id=?", run_id)[
"status"] == "paused"
async def test_gate_rot_ohne_reparatur_pausiert(monkeypatch):
"""Gate rot ohne Reparatur-Rezepte darf nicht endlos drehen → Pause."""
from backend import korpus
async def gate_rot(task):
return engine.Ergebnis(gate_status="rot", befunde=[
{"art": "test", "item": "x", "detail": str(task["runde"])}])
monkeypatch.setitem(engine.WORKER, "korpus.gate", gate_rot)
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "paused"
async def test_coverage_formel():
topic = topic_anlegen()
run = await lauf_bis_ende(topic)
assert run["status"] == "done"
gesamt = db.one("SELECT COUNT(*) c FROM soll WHERE topic=? AND "
"status='bestaetigt'", topic)["c"]
gedeckt = db.one(
"SELECT COUNT(*) c FROM soll s WHERE s.topic=? AND s.status='bestaetigt' "
"AND (s.freispruch!='' OR EXISTS (SELECT 1 FROM atome a WHERE "
"a.soll_id=s.id AND a.status='aktiv' AND a.baustein_id IS NOT NULL))",
topic)["c"]
assert gesamt == gedeckt == 3 # Coverage 100 %, deterministisch gezählt
async def test_resume_mit_budget_nachschlag():
import asyncio
from backend import engine
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="budget", budget_tokens=100)
run_id2 = engine.lauf_starten(topic, budget=500)
assert run_id2 == run_id # gleicher Run wird fortgesetzt
assert db.one("SELECT budget_tokens FROM runs WHERE id=?", run_id)[
"budget_tokens"] == 500
engine.lauf_stoppen(topic)
with __import__("contextlib").suppress(asyncio.CancelledError):
await engine._laeufe[topic]
async def test_gate_guide_neuschreiben_bei_kaputten_markern():
"""Marker-Defekt + Fix erschöpft → Gate erzeugt Writer-Neuschreiben-Task;
veraltete Marker-Befunde zählen nicht mehr als offen."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
zid = db.insert("lernziele", topic=topic, text="z")
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Atom X", status="aktiv",
baustein_id=bid, definition="d")
db.insert("sections", baustein_id=bid, text="Text ohne Marker.",
fix_versuche=3)
erg = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
assert erg.gate_status == "rot"
writer_tasks = [t for t in erg.neue_tasks if t["knoten"] == "writer"]
assert writer_tasks and writer_tasks[0]["payload"]["neu"] is True
# Neuschreiben resettet den Fix-Cap und ersetzt den Text
await guide.writer({"run_id": run_id, "topic": topic, "runde": 2,
"item": f"r2:baustein:{bid}",
"payload": db.j({"baustein_id": bid, "neu": True})})
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", bid)
assert f"<!-- atom: {aid} -->" in sec["text"]
assert sec["fix_versuche"] == 0
async def test_eskalations_exit_beendet_karussell():
"""Nach Fix-Cap + Klärung + 1 Neuschreiben wird der Befund eskaliert —
das Gate blockt nicht ewig (Echtlauf: 5 Sections kreisten bis Runde 8)."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
zid = db.insert("lernziele", topic=topic, text="z")
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Atom X", status="aktiv",
baustein_id=bid, definition="d")
db.insert("sections", baustein_id=bid,
text=f"<!-- atom: {aid} -->\nText.", fix_versuche=3)
db.insert("befunde", run_id=run_id, stufe="guide", art="falsch",
item=f"baustein:{bid}", detail="x", geklaert=1)
# 1 Neuschreiben wurde schon versucht:
db.insert("tasks", run_id=run_id, topic=topic, knoten="writer",
item=f"r9:baustein:{bid}", art="reparatur", status="fertig")
erg = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
b = db.one("SELECT status FROM befunde WHERE item=?", f"baustein:{bid}")
assert b["status"] == "eskaliert"
assert not [t for t in erg.neue_tasks if t["knoten"] in ("fix", "writer")]
async def test_atom_echo_semantisch():
"""Fettzeile ohne Gedankenstrich umging den Regex-Check (Audit Z.294/297) —
der semantische Vergleich mit der Atom-Definition fängt sie."""
from backend import guide
topic = topic_anlegen()
zid = db.insert("lernziele", topic=topic, text="z")
kid = db.insert("kapitel", topic=topic, titel="K", ord=0)
bid = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B",
kapitel_id=kid, ord=0)
aid = db.insert("atome", topic=topic, titel="Pausenregeln", status="aktiv",
baustein_id=bid,
definition="Empfohlene Pausen sind Dehnen, Meditation und "
"kurze Spaziergänge an der frischen Luft.")
echo_text = (f"<!-- atom: {aid} -->\n"
"**Empfohlene Pausen sind Dehnen, Meditation und kurze "
"Spaziergänge an der frischen Luft.** Danach mehr. "
+ "Wort " * 40)
assert any(b["art"] == "atom_echo"
for b in guide._det_befunde(topic, bid, echo_text))
sauber = (f"<!-- atom: {aid} -->\n"
"Gute Pausen leben von Bewegung und bewusstem Abschalten. "
+ "Wort " * 40)
assert not any(b["art"] == "atom_echo"
for b in guide._det_befunde(topic, bid, sauber))
async def test_vorwaerts_ignoriert_verlinkte_erwaehnung():
from backend import guide
topic = topic_anlegen()
kid1 = db.insert("kapitel", topic=topic, titel="K1", ord=0)
kid2 = db.insert("kapitel", topic=topic, titel="K2", ord=1)
zid = db.insert("lernziele", topic=topic, text="z")
bid1 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B1",
kapitel_id=kid1, ord=0)
bid2 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B2",
kapitel_id=kid2, ord=0)
a1 = db.insert("atome", topic=topic, titel="Frühes Konzept", status="aktiv",
baustein_id=bid1)
db.insert("atome", topic=topic, titel="Langes Spätkonzept", status="aktiv",
baustein_id=bid2)
mit_link = (f"<!-- atom: {a1} -->\nSiehe [Langes Spätkonzept](#b2) später. "
+ "Wort " * 40)
assert not [b for b in guide._det_befunde(topic, bid1, mit_link)
if b["art"] == "vorwaerts"]
ohne_link = f"<!-- atom: {a1} -->\nDas Langes Spätkonzept kommt. " + "Wort " * 40
assert [b for b in guide._det_befunde(topic, bid1, ohne_link)
if b["art"] == "vorwaerts"]
async def test_gate_verwirft_soll_lose_atome():
from backend import inventar
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
geprueft = db.insert("atome", topic=topic, titel="Beifang", status="aktiv",
soll_geprueft=1)
frisch = db.insert("atome", topic=topic, titel="Frisch", status="aktiv",
soll_geprueft=0)
await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
assert db.one("SELECT status FROM atome WHERE id=?", geprueft)[
"status"] == "verworfen"
assert db.one("SELECT status FROM atome WHERE id=?", frisch)[
"status"] == "aktiv" # ungeprüfte bleiben
async def test_befund_dedupe_erhaelt_klaerungsstand():
from backend.guide import befund_merken
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 1) is True
db.execute("UPDATE befunde SET geklaert=1")
# gleiche Runde später: KEINE neue Zeile, geklaert bleibt
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 2) is False
rows = db.query("SELECT geklaert FROM befunde WHERE art='luecke'")
assert len(rows) == 1 and rows[0]["geklaert"] == 1
# behoben → Regression darf neu aufmachen
db.execute("UPDATE befunde SET status='behoben'")
assert befund_merken(run_id, "llm_pruefung", "luecke", "baustein:1",
"Detail X", 3) is True
async def test_finale_det_befunde_blocken_nicht():
"""Eskalierter/freigesprochener Vorwärtsverweis hält das Gate nicht mehr rot."""
from backend import guide
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
kid1 = db.insert("kapitel", topic=topic, titel="K1", ord=0)
kid2 = db.insert("kapitel", topic=topic, titel="K2", ord=1)
zid = db.insert("lernziele", topic=topic, text="z")
bid1 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B1",
kapitel_id=kid1, ord=0)
bid2 = db.insert("bausteine", topic=topic, ziel_id=zid, titel="B2",
kapitel_id=kid2, ord=0)
a1 = db.insert("atome", topic=topic, titel="Frühes Thema", status="aktiv",
baustein_id=bid1)
a2 = db.insert("atome", topic=topic, titel="Langes Spätkonzept",
status="aktiv", baustein_id=bid2)
db.insert("sections", baustein_id=bid1,
text=f"<!-- atom: {a1} -->\nDas Langes Spätkonzept kommt. "
+ "Wort " * 50)
db.insert("sections", baustein_id=bid2,
text=f"<!-- atom: {a2} -->\n" + "Wort " * 50)
db.insert("kapitel", topic=topic, titel="egal", ord=2)
erg1 = await guide.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
vor = [b for b in erg1.befunde if b["art"] == "vorwaerts"]
assert vor # blockt zunächst
db.insert("befunde", run_id=run_id, stufe="guide", art="vorwaerts",
item=vor[0]["item"], detail=vor[0]["detail"][:500],
status="freispruch")
erg2 = await guide.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not [b for b in erg2.befunde if b["art"] == "vorwaerts"]
async def test_budget_stoppt_lauf():
topic = topic_anlegen()
run = await lauf_bis_ende(topic, budget=1)
# Fake-Calls kosten 0 Tokens — Budget greift nur mit echten Tokens.
# Simuliere Verbrauch und prüfe den Stopp-Pfad direkt:
if run["status"] == "done":
db.insert("events", run_id=run["id"], tok_in=5, tok_out=5)
from backend.ledger import BudgetErschoepft, budget_pruefen
import pytest
with pytest.raises(BudgetErschoepft):
budget_pruefen(run["id"])

44
tests/test_graph.py Normal file
View File

@@ -0,0 +1,44 @@
"""Graph-Validierung: DAG-Pflicht, Rück-Kanten nur von Gates, Skills vorhanden."""
import pytest
import yaml
from backend import config, engine, graph
def test_pipeline_yaml_valide():
g = graph.laden(set(engine.WORKER))
assert len(g.knoten) >= 20
assert g.stufen == ["korpus", "inventar", "struktur", "guide", "montage"]
for stufe in g.stufen[:-1]:
assert g.gate_der_stufe(stufe) is not None
def _mit_kante(extra) -> None:
daten = yaml.safe_load(config.PIPELINE_YAML.read_text())
daten["kanten"].append(extra)
import tempfile
from pathlib import Path
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
yaml.safe_dump(daten, f)
pfad = Path(f.name)
try:
graph.laden(set(engine.WORKER), pfad=pfad)
finally:
pfad.unlink()
graph.laden(set(engine.WORKER)) # Original wiederherstellen
def test_zyklus_wird_abgelehnt():
with pytest.raises(graph.GraphFehler, match="Zyklus"):
_mit_kante({"von": "montage", "nach": "recherche_plan", "art": "normal"})
def test_ruecklauf_nur_von_gates():
with pytest.raises(graph.GraphFehler, match="nur von Gates"):
_mit_kante({"von": "writer", "nach": "suche", "art": "ruecklauf"})
def test_layout_vollstaendig():
lay = graph.get().layout()
assert set(lay["knoten"]) == set(graph.get().knoten)
assert all("spalte" in v and "zeile" in v for v in lay["knoten"].values())

22
tests/test_korpus.py Normal file
View File

@@ -0,0 +1,22 @@
"""Konsens: 1 kuratierter Wikipedia-Beleg genügt, 1 Blog-Beleg nicht."""
from backend import db, korpus
from .conftest import topic_anlegen
async def test_wiki_einzelbeleg_bestaetigt():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
q_wiki = db.insert("quellen", topic=topic, url="https://de.wikipedia.org/w",
url_norm="u1", backend="wikipedia_de", status="geladen")
q_blog = db.insert("quellen", topic=topic, url="https://blog.de/x",
url_norm="u2", backend="ddgs", status="geladen")
db.insert("soll", topic=topic, punkt="Overflow-Pomodoros erklären",
belege=db.j([{"quelle": q_wiki, "zitat": "z1"}]))
db.insert("soll", topic=topic, punkt="Nur Blog-These",
belege=db.j([{"quelle": q_blog, "zitat": "z2"}]))
await korpus.soll_konsens({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:konsens", "payload": "{}"})
wiki = db.one("SELECT status FROM soll WHERE punkt LIKE 'Overflow%'")
blog = db.one("SELECT status FROM soll WHERE punkt LIKE 'Nur Blog%'")
assert wiki["status"] == "bestaetigt" # kuratierte Quelle: 1 Beleg reicht
assert blog["status"] == "kandidat" # Blog allein bestätigt nicht

134
tests/test_llm.py Normal file
View File

@@ -0,0 +1,134 @@
"""LLM-Disziplin: Budget-Vorab-Stopp, Infra→Pause fail-closed, cap ohne Retry,
Drossel, Ledger im finally."""
import pytest
from backend import config, db, llm
from backend.ledger import BudgetErschoepft
from .conftest import topic_anlegen
SKILLS = ["richter", "deutsch-praezise", "klaerung"]
WERTE = {"befund": "x", "belege": "y"}
@pytest.fixture
def echt_modus(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
monkeypatch.setattr(config, "INFRA_BACKOFF_BASE", 0.01)
llm._cooldown_bis = 0.0
def _run(status="running", budget=0):
topic = topic_anlegen()
return db.insert("runs", topic=topic, status=status, budget_tokens=budget)
async def _call(run_id, **kw):
return await llm.call(run_id=run_id, stufe="t", knoten="klaerung", item="i",
skill_namen=SKILLS, werte=WERTE, **kw)
async def test_budget_vorab_stopp(echt_modus, monkeypatch):
run_id = _run(budget=100)
db.insert("events", run_id=run_id, tok_in=80, tok_out=30) # schon drüber
async def nie(*a, **k):
raise AssertionError("Call darf nicht abgesetzt werden")
monkeypatch.setattr(llm, "_api", nie)
with pytest.raises(BudgetErschoepft):
await _call(run_id)
async def test_infra_erschoepft_wird_pause(echt_modus, monkeypatch):
run_id = _run()
async def immer_429(*a, **k):
return llm.ApiErgebnis(1, err="HTTP 429: rate limited")
monkeypatch.setattr(llm, "_api", immer_429)
with pytest.raises(llm.LaufPause):
await _call(run_id)
# jeder Versuch im Ledger, Status infra
events = db.query("SELECT status FROM events WHERE run_id=?", run_id)
assert len(events) == config.INFRA_MAX_RETRIES + 1
assert all(e["status"] == "infra" for e in events)
async def test_cap_kein_retry(echt_modus, monkeypatch):
run_id = _run()
zaehler = {"n": 0}
async def cap(*a, **k):
zaehler["n"] += 1
return llm.ApiErgebnis(1, err="leere Antwort (stop=max_tokens)",
tokens={"input": 10, "output": 5})
monkeypatch.setattr(llm, "_api", cap)
assert await _call(run_id) is None
assert zaehler["n"] == 1 # deterministisch — kein Neuversuch
e = db.one("SELECT status, tok_in FROM events WHERE run_id=?", run_id)
assert e["status"] == "cap" and e["tok_in"] == 10 # Tokens trotz Fehler erfasst
async def test_drossel_halbiert_und_erholt(echt_modus):
llm._breite = 12
llm.drossel_melden(0.01)
assert llm._breite == 6
llm.drossel_melden(0.01)
assert llm._breite == max(config.DROSSEL_MIN_BREITE, 3)
for _ in range(config.DROSSEL_ERFOLGE_JE_PLUS):
llm._erfolg_melden()
assert llm._breite >= config.DROSSEL_MIN_BREITE + 1
async def test_manuelle_pause_bricht_wartende(echt_modus):
run_id = _run(status="paused")
with pytest.raises(llm.ManuellePause):
await _call(run_id)
async def test_llm_log_speichert_texte(echt_modus, monkeypatch):
monkeypatch.setattr(config, "LLM_LOG", True)
run_id = _run()
async def ok(*a, **k):
return llm.ApiErgebnis(0, text="ANTWORTTEXT",
tokens={"input": 5, "output": 3})
monkeypatch.setattr(llm, "_api", ok)
assert await _call(run_id) == "ANTWORTTEXT"
zeile = db.one("SELECT ct.prompt, ct.antwort FROM call_texte ct "
"JOIN events e ON e.id=ct.event_id WHERE e.run_id=?", run_id)
assert "aufgabe:klaerung" in zeile["prompt"]
assert zeile["antwort"] == "ANTWORTTEXT"
async def test_ohne_llm_log_keine_texte(echt_modus, monkeypatch):
run_id = _run()
async def ok(*a, **k):
return llm.ApiErgebnis(0, text="X")
monkeypatch.setattr(llm, "_api", ok)
await _call(run_id)
assert db.one("SELECT COUNT(*) c FROM call_texte")["c"] == 0
async def test_inhaltsfehler_begrenzte_restarts(echt_modus, monkeypatch):
run_id = _run()
zaehler = {"n": 0}
async def kaputt(*a, **k):
zaehler["n"] += 1
return llm.ApiErgebnis(1, err="leere Antwort (stop=end_turn)")
monkeypatch.setattr(llm, "_api", kaputt)
assert await _call(run_id) is None
assert zaehler["n"] == config.INHALT_MAX_RESTARTS + 1
async def test_takt_pacing(monkeypatch):
"""Bei aktivem Takt startet der zweite Call frühestens TAKT später."""
import time
monkeypatch.setattr(config, "TAKT_SEKUNDEN", 0.05)
llm._naechster_start = 0.0
t0 = time.monotonic()
await llm._slot()
llm._slot_frei()
await llm._slot()
llm._slot_frei()
assert time.monotonic() - t0 >= 0.05

44
tests/test_skills.py Normal file
View File

@@ -0,0 +1,44 @@
"""Skill-Komposition: 1+1+n erzwungen, Platzhalter validiert, Hash stabil."""
import pytest
from backend import skills
def test_komposition_reihenfolge():
prompt, h, paare = skills.komponieren(
["extraktor", "deutsch-praezise", "atom-extraktion"],
{"thema": "X", "abschnitte": "Y"})
assert prompt.startswith("<!-- aufgabe:atom-extraktion -->")
assert prompt.index("## Rolle") < prompt.index("## Arbeitsweise") \
< prompt.index("## Aufgabe")
assert "{{" not in prompt
assert len(h) == 12 and len(paare) == 3
def test_zwei_rollen_verboten():
with pytest.raises(skills.SkillFehler, match="genau 1 Rolle"):
skills.komponieren(["extraktor", "richter", "atom-extraktion"],
{"thema": "X", "abschnitte": "Y"})
def test_fehlender_platzhalter():
with pytest.raises(skills.SkillFehler, match="Platzhalter fehlen"):
skills.komponieren(["extraktor", "atom-extraktion"], {"thema": "X"})
def test_hash_stabil_und_aendert_sich():
_, h1, _ = skills.komponieren(["extraktor", "atom-extraktion"],
{"thema": "A", "abschnitte": "B"})
_, h2, _ = skills.komponieren(["extraktor", "atom-extraktion"],
{"thema": "ANDERS", "abschnitte": "B"})
assert h1 == h2 # Hash hängt an den Skill-DATEIEN, nicht am Input
_, h3, _ = skills.komponieren(["extraktor", "deutsch-praezise",
"atom-extraktion"],
{"thema": "A", "abschnitte": "B"})
assert h3 != h1 # andere Skill-Menge → anderer Hash
def test_aufgaben_name():
prompt, _, _ = skills.komponieren(["richter", "klaerung"],
{"befund": "x", "belege": "y"})
assert skills.aufgaben_name(prompt) == "klaerung"

34
tests/test_struktur.py Normal file
View File

@@ -0,0 +1,34 @@
"""Ziel-Dedup: synonyme Lernziele werden deterministisch gemerged."""
from backend import db, struktur
from .conftest import topic_anlegen
def test_ziele_dedup_merged_synonyme():
topic = topic_anlegen()
z1 = db.insert("lernziele", topic=topic, titel="Grundlagen einordnen",
text="Der Lernende kann Pomodoro-Grundlagen einordnen.")
z2 = db.insert("lernziele", topic=topic, titel="Grundlagen verorten",
text="Der Lernende kann Pomodoro-Grundlagen verorten.")
z3 = db.insert("lernziele", topic=topic, titel="Anwendung",
text="Der Lernende kann die Technik im Alltag anwenden.")
b2 = db.insert("bausteine", topic=topic, ziel_id=z2, titel="B2")
a1 = db.insert("atome", topic=topic, titel="A1", status="aktiv", ziel_id=z2,
baustein_id=b2)
merges = struktur._ziele_dedup(topic)
assert merges == 1
# z2 weg, sein Atom hängt jetzt an z1, Baustein-Zuordnung gelöst
assert db.one("SELECT id FROM lernziele WHERE id=?", z2) is None
atom = db.one("SELECT ziel_id, baustein_id FROM atome WHERE id=?", a1)
assert atom["ziel_id"] == z1 and atom["baustein_id"] is None
assert db.one("SELECT id FROM lernziele WHERE id=?", z3) is not None
def test_ziele_dedup_verschont_verschiedene():
topic = topic_anlegen()
db.insert("lernziele", topic=topic, titel="Pausen gestalten",
text="Der Lernende kann Pausen erholsam gestalten.")
db.insert("lernziele", topic=topic, titel="Werkzeuge nutzen",
text="Der Lernende kann passende Apps und Timer auswählen.")
assert struktur._ziele_dedup(topic) == 0
assert db.one("SELECT COUNT(*) c FROM lernziele WHERE topic=?",
topic)["c"] == 2

167
tests/test_suche_laden.py Normal file
View File

@@ -0,0 +1,167 @@
"""Suche: Fallback-Kette, Circuit Breaker, URL-Dedupe. Laden: Snapshot stabil."""
import pytest
from backend import config, db, laden, suche
from .conftest import topic_anlegen
def test_url_normieren():
assert suche.url_normieren("HTTPS://Beispiel.de/Pfad/?utm_source=x&a=1") == \
"https://beispiel.de/Pfad?a=1"
assert suche.url_normieren("https://beispiel.de/seite/") == \
suche.url_normieren("https://beispiel.de/seite")
async def test_fallback_kette(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
suche._ausfaelle.clear()
async def leer_wiki(q):
return []
async def kaputt(q):
raise RuntimeError("down")
async def liefert(q):
return [{"titel": "T", "url": "https://x.de/a", "backend": "b2"}]
monkeypatch.setattr(suche, "_wikipedia", leer_wiki)
monkeypatch.setattr(suche, "_KETTE", (("b1", kaputt), ("b2", liefert)))
monkeypatch.setattr(config, "SUCHE_RETRIES", 0)
treffer, fehler = await suche.web_suchen("query")
assert [t["backend"] for t in treffer] == ["b2"]
assert fehler == ["b1: RuntimeError"]
async def test_circuit_breaker(monkeypatch):
monkeypatch.setattr(config, "FAKE", False)
monkeypatch.setattr(config, "SUCHE_RETRIES", 0)
suche._ausfaelle.clear()
aufrufe = {"n": 0}
async def leer_wiki(q):
return []
async def kaputt(q):
aufrufe["n"] += 1
raise RuntimeError("down")
monkeypatch.setattr(suche, "_wikipedia", leer_wiki)
monkeypatch.setattr(suche, "_KETTE", (("b1", kaputt),))
for _ in range(config.CIRCUIT_BREAKER_N + 3):
await suche.web_suchen("q")
assert aufrufe["n"] == config.CIRCUIT_BREAKER_N # danach übersprungen
async def test_suche_dedupliziert_urls():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
t = {"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"query": "thema ueberblick r1"})}
erg1 = await suche.suchen({**t})
erg2 = await suche.suchen({**t})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 0 # gleiche URL → kein zweiter Laden-Task
async def test_relevanz_filter_wirft_listen_raus(monkeypatch):
"""Läuft OHNE Fake-Kurzschluss — genau der Pfad, der im Echtlauf brach."""
monkeypatch.setattr(config, "FAKE", False)
from backend import llm as llm_mod
async def urteil(**kw):
assert "quellen-relevanz" in kw["skill_namen"]
assert "Pasta" in kw["werte"]["treffer"] # Wiki läuft MIT durchs Urteil
return "===RELEVANT===\nIDS: 0,1,3"
monkeypatch.setattr(llm_mod, "call", urteil)
treffer = [
{"titel": "Pomodoro-Technik Wikipedia",
"url": "https://de.wikipedia.org/wiki/X", "backend": "wikipedia_de"},
{"titel": "Guter Artikel", "url": "https://a.de/1", "backend": "ddgs"},
{"titel": "Pasta", "url": "https://en.wikipedia.org/wiki/Pasta",
"backend": "wikipedia_en"},
{"titel": "Anleitung", "url": "https://a.de/3", "backend": "ddgs"},
]
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
task = {"run_id": run_id, "topic": topic, "item": "t", "runde": 1}
ergebnis = await suche._relevanz_filter(task, "Thema", treffer)
urls = [t["url"] for t in ergebnis]
assert "https://de.wikipedia.org/wiki/X" in urls
assert "https://a.de/1" in urls and "https://a.de/3" in urls
assert "https://en.wikipedia.org/wiki/Pasta" not in urls # Off-Topic-Wiki raus
async def test_wikipedia_seed_umgeht_deckel(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen(name="seedtest", titel="Pomodoro Technik")
run_id = db.insert("runs", topic=topic, status="running")
erg = await suche.suchen({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:wikiseed",
"payload": db.j({"seed": True})})
assert erg.daten["neu"] == 2 # de+en trotz Deckel 1
urls = [q["url"] for q in db.query(
"SELECT url FROM quellen WHERE topic=?", topic)]
assert any("de.wikipedia.org/wiki/Pomodoro_Technik" in u for u in urls)
assert any("en.wikipedia.org" in u for u in urls)
# idempotent: zweiter Lauf legt nichts Neues an
erg2 = await suche.suchen({"run_id": run_id, "topic": topic, "runde": 1,
"item": "r1:wikiseed",
"payload": db.j({"seed": True})})
assert erg2.daten["neu"] == 0
async def test_quellen_deckel(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
basis = {"run_id": run_id, "topic": topic, "runde": 1, "item": "t"}
erg1 = await suche.suchen({**basis,
"payload": db.j({"query": "thema ueberblick r1"})})
erg2 = await suche.suchen({**basis,
"payload": db.j({"query": "thema tutorial r1"})})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 0 # Deckel erreicht — keine neue Quelle
async def test_quellen_deckel_pro_topic(monkeypatch):
monkeypatch.setattr(config, "QUELLEN_MAX", 1)
topic = topic_anlegen()
db.update("topics", "name=?", (topic,), quellen_max=0) # 0 = unbegrenzt
run_id = db.insert("runs", topic=topic, status="running")
basis = {"run_id": run_id, "topic": topic, "runde": 1, "item": "t"}
erg1 = await suche.suchen({**basis,
"payload": db.j({"query": "thema ueberblick r1"})})
erg2 = await suche.suchen({**basis,
"payload": db.j({"query": "thema tutorial r1"})})
assert erg1.daten["neu"] == 1
assert erg2.daten["neu"] == 1 # Topic-Einstellung schlägt globalen Deckel
async def test_laden_snapshot_stabil():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
qid = db.insert("quellen", topic=topic, url="https://beispiel.de/grundlagen",
url_norm="https://beispiel.de/grundlagen", zweck="korpus")
t = {"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})}
erg1 = await laden.laden({**t})
q = db.one("SELECT * FROM quellen WHERE id=?", qid)
assert q["status"] == "geladen" and q["snapshot"]
inhalt1 = laden.snapshot_lesen(q)
erg2 = await laden.laden({**t}) # Resume: kein Neuschreiben
assert erg2.daten.get("skip")
assert laden.snapshot_lesen(q) == inhalt1
assert len(erg1.neue_tasks) >= 1 # Soll-Extraktions-Chunks
async def test_laden_leere_quelle_befund():
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
qid = db.insert("quellen", topic=topic, url="https://unbekannt.example/x",
url_norm="https://unbekannt.example/x")
erg = await laden.laden({"run_id": run_id, "topic": topic, "runde": 1,
"payload": db.j({"quelle_id": qid})})
assert erg.neue_tasks == []
assert db.one("SELECT status FROM quellen WHERE id=?", qid)["status"] == "leer"
assert db.one("SELECT id FROM befunde WHERE run_id=? AND art='leer'", run_id)

50
tests/test_textkit.py Normal file
View File

@@ -0,0 +1,50 @@
"""Anker-Matching (5 Stufen), Akronym-Kanal, Jaccard, Parser."""
from backend import textkit
def test_finde_zitat_exakt_und_whitespace():
text = "Der Satz steht\nhier drin. Und mehr."
span = textkit.finde_zitat(text, "Der Satz steht hier drin.")
assert span and text[span[0]:span[1]].startswith("Der")
def test_finde_zitat_casefold_und_umlaute():
text = "Die ÜBERTRAGUNG von Daten ist zentral für jedes Netzwerk im Betrieb."
assert textkit.finde_zitat(text, "die übertragung von daten ist zentral")
def test_finde_zitat_fuzzy_mit_negations_guard():
basis = ("Eine Sprache ist regulär, wenn ein endlicher Automat sie "
"akzeptiert und verarbeitet.")
text = f"Einleitung. {basis} Schluss."
leicht_anders = basis.replace("verarbeitet", "verarbeiten kann")
assert textkit.finde_zitat(text, leicht_anders)
# Negation im Zitat, nicht im Text → KEIN Anker (Antonym-Falle)
negiert = basis.replace("ist regulär", "ist nicht regulär")
assert textkit.finde_zitat(text, negiert) is None
def test_akronym_kanal():
assert textkit.ist_akronym_von("DNS", "Domain Name System")
assert not textkit.ist_akronym_von("HTTP", "Hypertext Markup Language")
assert not textkit.ist_akronym_von("A", "Anfang")
def test_jaccard_gestemmt():
a = "Die Führung von Teams erfordert Erfahrung."
b = "Teams führen erfordert erfahrene Personen."
assert textkit.jaccard(a, b) > textkit.jaccard(a, "Völlig anderes Thema hier.")
def test_bloecke_parser():
text = ("Prosa davor\n===ATOM===\nTITEL: A\nDEFINITION: Zeile 1\nZeile 2\n"
"===ATOM===\nTITEL: B\nDEFINITION: x\n")
bloecke = textkit.bloecke(text, "ATOM")
assert len(bloecke) == 2
assert bloecke[0]["titel"] == "A"
assert "Zeile 2" in bloecke[0]["definition"]
def test_ueberlappung():
assert textkit.ueberlappung((0, 100), (50, 150)) == 0.5
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0

122
tests/test_verdichtung.py Normal file
View File

@@ -0,0 +1,122 @@
"""Verdichtung: Fusion nur im Schnitt beider Panel-Stimmen, Anker-Vererbung,
verdichtet-Flag, Coverage bleibt erhalten."""
from backend import config, db, inventar
from .conftest import topic_anlegen
def _welt(topic, n_vorteile=3):
run_id = db.insert("runs", topic=topic, status="running")
sid = db.insert("soll", topic=topic, punkt="Vorteile erklären",
status="bestaetigt")
ids = []
for i in range(n_vorteile):
aid = db.insert("atome", topic=topic, titel=f"Vorteil Nr{i}",
definition=f"Definition des Vorteils Nummer {i} hier.",
status="aktiv", soll_id=sid)
db.insert("anker", atom_id=aid, quelle_id=i % 2 + 1, start=i * 100,
ende=i * 100 + 50, zitat=f"z{i}")
ids.append(aid)
return run_id, sid, ids
def _task(run_id, topic, sid):
return {"run_id": run_id, "topic": topic, "runde": 1,
"item": f"r1:verd:soll:{sid}", "payload": db.j({"soll_id": sid})}
async def test_fusion_mit_anker_vererbung():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
erg = await inventar.verdichtung(_task(run_id, topic, sid))
assert erg.daten["nachher"] == 1 # Fake gruppiert alle "Vorteil"-Atome
kopf = db.one("SELECT id FROM atome WHERE soll_id=? AND status='aktiv'", sid)
assert db.one("SELECT COUNT(*) c FROM anker WHERE atom_id=?",
kopf["id"])["c"] == 3 # alle Anker geerbt
verworfen = db.query("SELECT merged_into FROM atome WHERE soll_id=? AND "
"status='verworfen'", sid)
assert len(verworfen) == 2
assert db.one("SELECT verdichtet FROM soll WHERE id=?", sid)["verdichtet"] == 1
# Coverage bleibt: Punkt hat weiter ≥1 aktives Atom
assert db.one("SELECT id FROM atome WHERE soll_id=? AND status='aktiv'", sid)
async def test_nur_einstimmige_paare_fusionieren(monkeypatch):
"""Stimme A gruppiert {1,2,3}, Stimme B nur {1,2} → fusioniert wird nur
das einstimmige Paar (1,2)."""
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
antworten = iter([
f"===EINHEIT===\nIDS: {ids[0]},{ids[1]},{ids[2]}",
f"===EINHEIT===\nIDS: {ids[0]},{ids[1]}\n===EINHEIT===\nIDS: {ids[2]}",
])
async def zwei_stimmen(**kw):
return next(antworten)
from backend import llm
monkeypatch.setattr(llm, "call", zwei_stimmen)
erg = await inventar.verdichtung(_task(run_id, topic, sid))
assert erg.daten["fusionen"] == 1
assert erg.daten["nachher"] == 2 # {0,1} fusioniert, 2 bleibt allein
async def test_flag_verhindert_zweiten_lauf():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
await inventar.verdichtung(_task(run_id, topic, sid))
erg2 = await inventar.verdichtung({**_task(run_id, topic, sid),
"item": "r2:verd"})
assert erg2.daten.get("skip") is True
async def test_facetten_check_erzeugt_fenster_task(tmp_path):
"""Beleg-Stelle ohne Atom in der Nähe → luecke-Task mit gezieltem Fenster;
nach LUECKEN_RUNDEN_MAX Versuchen → eskaliert (dokumentierte Grenze)."""
from backend import config as cfg
topic = topic_anlegen()
run_id = db.insert("runs", topic=topic, status="running")
weit = "Anfangssatz mit Atomnähe. " + "Füllwort " * 2000 + \
"Die Namensherkunft stammt von der Tomatenuhr aus Italien."
pfad = tmp_path / "q.md"
pfad.write_text(weit, encoding="utf-8")
qid = db.insert("quellen", topic=topic, url="https://x.de/a",
url_norm="https://x.de/a", status="geladen",
snapshot=str(pfad))
sid = db.insert("soll", topic=topic, punkt="Herkunft erklären",
status="bestaetigt", verdichtet=1,
belege=db.j([{"quelle": qid,
"zitat": "Die Namensherkunft stammt von der "
"Tomatenuhr aus Italien."}]))
aid = db.insert("atome", topic=topic, titel="Anfang", status="aktiv",
soll_id=sid)
db.insert("anker", atom_id=aid, quelle_id=qid, start=0, ende=20, zitat="z")
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
fac = [t for t in erg.neue_tasks if t["knoten"] == "luecke"
and t["payload"].get("fenster")]
assert fac, "Facetten-Lücke muss gezielten Fenster-Task erzeugen"
assert fac[0]["payload"]["fenster"]["quelle_id"] == qid
assert any(b["art"] == "facette" for b in erg.befunde)
# Cap: 2 erfolglose Versuche simulieren → Eskalation statt Endlosschleife
key = fac[0]["item"].split(":", 1)[1]
for r in (2, 3):
db.insert("tasks", run_id=run_id, topic=topic, knoten="luecke",
item=f"r{r}:{key}", status="fertig")
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not any(b["art"] == "facette" for b in erg2.befunde)
assert db.one("SELECT status FROM befunde WHERE art='facette'")[
"status"] == "eskaliert"
async def test_gate_erzeugt_verdichtungs_task():
topic = topic_anlegen()
run_id, sid, ids = _welt(topic)
erg = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 1,
"item": "runde:1", "payload": "{}"})
verd = [t for t in erg.neue_tasks if t["knoten"] == "verdichtung"]
assert verd and verd[0]["payload"]["soll_id"] == sid
# nach Verdichtung: kein neuer Task mehr
db.update("soll", "id=?", (sid,), verdichtet=1)
erg2 = await inventar.gate({"run_id": run_id, "topic": topic, "runde": 2,
"item": "runde:2", "payload": "{}"})
assert not [t for t in erg2.neue_tasks if t["knoten"] == "verdichtung"]