From cb68cd671b2a4c57ca3b0baf44641e263843091d Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 16:07:36 +0200 Subject: [PATCH] init --- .env.example | 18 + .gitignore | 7 + DESIGN.md | 210 +++ LEKTIONEN.md | 87 + Makefile | 19 + backend/__init__.py | 0 backend/belege.py | 73 + backend/config.py | 150 ++ backend/db.py | 205 +++ backend/dedup.py | 189 ++ backend/engine.py | 360 ++++ backend/fakes.py | 316 ++++ backend/graph.py | 154 ++ backend/guide.py | 460 +++++ backend/inventar.py | 518 ++++++ backend/korpus.py | 232 +++ backend/laden.py | 203 +++ backend/ledger.py | 52 + backend/llm.py | 238 +++ backend/main.py | 238 +++ backend/montage.py | 59 + backend/panels.py | 33 + backend/redundanz.py | 83 + backend/requirements.txt | 12 + backend/skills.py | 98 ++ backend/struktur.py | 344 ++++ backend/suche.py | 200 +++ backend/textkit.py | 270 +++ backend/ws.py | 46 + frontend/index.html | 12 + frontend/package-lock.json | 2106 +++++++++++++++++++++++ frontend/package.json | 23 + frontend/src/App.vue | 182 ++ frontend/src/api.js | 69 + frontend/src/components/Graph.vue | 59 + frontend/src/components/Guide.vue | 97 ++ frontend/src/components/Kennzahlen.vue | 108 ++ frontend/src/components/KnotenPanel.vue | 51 + frontend/src/main.js | 6 + frontend/src/markdown.js | 61 + frontend/src/store.js | 13 + frontend/src/style.css | 267 +++ frontend/vite.config.js | 6 + pipeline.yaml | 119 ++ pytest.ini | 4 + skills/aufgabe/anker-fix.md | 21 + skills/aufgabe/atom-extraktion.md | 24 + skills/aufgabe/bausteine.md | 18 + skills/aufgabe/dedup-urteil.md | 19 + skills/aufgabe/guide-fix.md | 23 + skills/aufgabe/guide-pruefung.md | 21 + skills/aufgabe/kapitel-intro.md | 20 + skills/aufgabe/klaerung.md | 19 + skills/aufgabe/lernziele.md | 19 + skills/aufgabe/luecke-freispruch.md | 18 + skills/aufgabe/luecke-nachextraktion.md | 22 + skills/aufgabe/ordnung.md | 19 + skills/aufgabe/quellen-relevanz.md | 17 + skills/aufgabe/recherche-plan.md | 21 + skills/aufgabe/redundanz-urteil.md | 16 + skills/aufgabe/soll-beleg.md | 21 + skills/aufgabe/soll-extraktion.md | 20 + skills/aufgabe/soll-konsens.md | 18 + skills/aufgabe/soll-zuordnung.md | 21 + skills/aufgabe/themen-schnitt.md | 18 + skills/aufgabe/verdichtung.md | 17 + skills/aufgabe/writer.md | 23 + skills/rolle/autor.md | 10 + skills/rolle/extraktor.md | 10 + skills/rolle/planer.md | 10 + skills/rolle/richter.md | 10 + skills/stil/deutsch-praezise.md | 9 + skills/stil/guide-lesbarkeit.md | 13 + tests/__init__.py | 0 tests/conftest.py | 48 + tests/test_buendel.py | 114 ++ tests/test_dedup_redundanz.py | 209 +++ tests/test_e2e.py | 120 ++ tests/test_engine.py | 85 + tests/test_gates.py | 282 +++ tests/test_graph.py | 44 + tests/test_korpus.py | 22 + tests/test_llm.py | 134 ++ tests/test_skills.py | 44 + tests/test_struktur.py | 34 + tests/test_suche_laden.py | 167 ++ tests/test_textkit.py | 50 + tests/test_verdichtung.py | 122 ++ 88 files changed, 10029 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 LEKTIONEN.md create mode 100644 Makefile create mode 100644 backend/__init__.py create mode 100644 backend/belege.py create mode 100644 backend/config.py create mode 100644 backend/db.py create mode 100644 backend/dedup.py create mode 100644 backend/engine.py create mode 100644 backend/fakes.py create mode 100644 backend/graph.py create mode 100644 backend/guide.py create mode 100644 backend/inventar.py create mode 100644 backend/korpus.py create mode 100644 backend/laden.py create mode 100644 backend/ledger.py create mode 100644 backend/llm.py create mode 100644 backend/main.py create mode 100644 backend/montage.py create mode 100644 backend/panels.py create mode 100644 backend/redundanz.py create mode 100644 backend/requirements.txt create mode 100644 backend/skills.py create mode 100644 backend/struktur.py create mode 100644 backend/suche.py create mode 100644 backend/textkit.py create mode 100644 backend/ws.py create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/Graph.vue create mode 100644 frontend/src/components/Guide.vue create mode 100644 frontend/src/components/Kennzahlen.vue create mode 100644 frontend/src/components/KnotenPanel.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/markdown.js create mode 100644 frontend/src/store.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js create mode 100644 pipeline.yaml create mode 100644 pytest.ini create mode 100644 skills/aufgabe/anker-fix.md create mode 100644 skills/aufgabe/atom-extraktion.md create mode 100644 skills/aufgabe/bausteine.md create mode 100644 skills/aufgabe/dedup-urteil.md create mode 100644 skills/aufgabe/guide-fix.md create mode 100644 skills/aufgabe/guide-pruefung.md create mode 100644 skills/aufgabe/kapitel-intro.md create mode 100644 skills/aufgabe/klaerung.md create mode 100644 skills/aufgabe/lernziele.md create mode 100644 skills/aufgabe/luecke-freispruch.md create mode 100644 skills/aufgabe/luecke-nachextraktion.md create mode 100644 skills/aufgabe/ordnung.md create mode 100644 skills/aufgabe/quellen-relevanz.md create mode 100644 skills/aufgabe/recherche-plan.md create mode 100644 skills/aufgabe/redundanz-urteil.md create mode 100644 skills/aufgabe/soll-beleg.md create mode 100644 skills/aufgabe/soll-extraktion.md create mode 100644 skills/aufgabe/soll-konsens.md create mode 100644 skills/aufgabe/soll-zuordnung.md create mode 100644 skills/aufgabe/themen-schnitt.md create mode 100644 skills/aufgabe/verdichtung.md create mode 100644 skills/aufgabe/writer.md create mode 100644 skills/rolle/autor.md create mode 100644 skills/rolle/extraktor.md create mode 100644 skills/rolle/planer.md create mode 100644 skills/rolle/richter.md create mode 100644 skills/stil/deutsch-praezise.md create mode 100644 skills/stil/guide-lesbarkeit.md create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_buendel.py create mode 100644 tests/test_dedup_redundanz.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_gates.py create mode 100644 tests/test_graph.py create mode 100644 tests/test_korpus.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_skills.py create mode 100644 tests/test_struktur.py create mode 100644 tests/test_suche_laden.py create mode 100644 tests/test_textkit.py create mode 100644 tests/test_verdichtung.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08d6222 --- /dev/null +++ b/.env.example @@ -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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf94f5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +node_modules/ +storage/ +.env +frontend/dist/ +.pytest_cache/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..b573b27 --- /dev/null +++ b/DESIGN.md @@ -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 ``-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:, 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 ``, 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, ``-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-`
`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. diff --git a/LEKTIONEN.md b/LEKTIONEN.md new file mode 100644 index 0000000..2576de0 --- /dev/null +++ b/LEKTIONEN.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2b30712 --- /dev/null +++ b/Makefile @@ -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 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/belege.py b/backend/belege.py new file mode 100644 index 0000000..274b21c --- /dev/null +++ b/backend/belege.py @@ -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) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..d01fdd7 --- /dev/null +++ b/backend/config.py @@ -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" diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..f391501 --- /dev/null +++ b/backend/db.py @@ -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 diff --git a/backend/dedup.py b/backend/dedup.py new file mode 100644 index 0000000..a8cf7fb --- /dev/null +++ b/backend/dedup.py @@ -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) diff --git a/backend/engine.py b/backend/engine.py new file mode 100644 index 0000000..608c9ee --- /dev/null +++ b/backend/engine.py @@ -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 buendel−1 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") diff --git a/backend/fakes.py b/backend/fakes.py new file mode 100644 index 0000000..8a2739e --- /dev/null +++ b/backend/fakes.py @@ -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"\n\*\*(.+?)\*\* — (.+)", p) + teile = [] + for aid, titel, definition in atome: + # Erster Satz bewusst KEIN Definitions-Echo (semantischer det-Check) + teile.append(f"\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), "" diff --git a/backend/graph.py b/backend/graph.py new file mode 100644 index 0000000..19f7e03 --- /dev/null +++ b/backend/graph.py @@ -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 diff --git a/backend/guide.py b/backend/guide.py new file mode 100644 index 0000000..1c5a64f --- /dev/null +++ b/backend/guide.py @@ -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"") + + +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"\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*\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 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"}]) diff --git a/backend/inventar.py b/backend/inventar.py new file mode 100644 index 0000000..0e1aad1 --- /dev/null +++ b/backend/inventar.py @@ -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) diff --git a/backend/korpus.py b/backend/korpus.py new file mode 100644 index 0000000..a6770ed --- /dev/null +++ b/backend/korpus.py @@ -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}) diff --git a/backend/laden.py b/backend/laden.py new file mode 100644 index 0000000..2414a01 --- /dev/null +++ b/backend/laden.py @@ -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)] diff --git a/backend/ledger.py b/backend/ledger.py new file mode 100644 index 0000000..c04f32c --- /dev/null +++ b/backend/ledger.py @@ -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)} diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..357ff1f --- /dev/null +++ b/backend/llm.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c5ab70c --- /dev/null +++ b/backend/main.py @@ -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") diff --git a/backend/montage.py b/backend/montage.py new file mode 100644 index 0000000..9eebe2d --- /dev/null +++ b/backend/montage.py @@ -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))}) diff --git a/backend/panels.py b/backend/panels.py new file mode 100644 index 0000000..54d4491 --- /dev/null +++ b/backend/panels.py @@ -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) diff --git a/backend/redundanz.py b/backend/redundanz.py new file mode 100644 index 0000000..e9f83b9 --- /dev/null +++ b/backend/redundanz.py @@ -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}) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..05fa82e --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/skills.py b/backend/skills.py new file mode 100644 index 0000000..be8c3da --- /dev/null +++ b/backend/skills.py @@ -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"", # 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"", prompt) + return m.group(1) if m else "" diff --git a/backend/struktur.py b/backend/struktur.py new file mode 100644 index 0000000..4750637 --- /dev/null +++ b/backend/struktur.py @@ -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) diff --git a/backend/suche.py b/backend/suche.py new file mode 100644 index 0000000..410088d --- /dev/null +++ b/backend/suche.py @@ -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) diff --git a/backend/textkit.py b/backend/textkit.py new file mode 100644 index 0000000..b27c569 --- /dev/null +++ b/backend/textkit.py @@ -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(("#", "```", "|", "/g, ""); + return DOMPurify.sanitize(md.render(ohneMarker)); +} diff --git a/frontend/src/store.js b/frontend/src/store.js new file mode 100644 index 0000000..3b3f362 --- /dev/null +++ b/frontend/src/store.js @@ -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); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..84ff908 --- /dev/null +++ b/frontend/src/style.css @@ -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; } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..307689b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,6 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vue()], +}); diff --git a/pipeline.yaml b/pipeline.yaml new file mode 100644 index 0000000..41d2c0c --- /dev/null +++ b/pipeline.yaml @@ -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} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..783889b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +addopts = -q diff --git a/skills/aufgabe/anker-fix.md b/skills/aufgabe/anker-fix.md new file mode 100644 index 0000000..f680879 --- /dev/null +++ b/skills/aufgabe/anker-fix.md @@ -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: +ZITAT: diff --git a/skills/aufgabe/atom-extraktion.md b/skills/aufgabe/atom-extraktion.md new file mode 100644 index 0000000..264c890 --- /dev/null +++ b/skills/aufgabe/atom-extraktion.md @@ -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: +TITEL: … +TYP: … +DEFINITION: … +ZITAT: … +===LEER=== +ABSCHNITT: diff --git a/skills/aufgabe/bausteine.md b/skills/aufgabe/bausteine.md new file mode 100644 index 0000000..b39e347 --- /dev/null +++ b/skills/aufgabe/bausteine.md @@ -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: diff --git a/skills/aufgabe/dedup-urteil.md b/skills/aufgabe/dedup-urteil.md new file mode 100644 index 0000000..9d2fd58 --- /dev/null +++ b/skills/aufgabe/dedup-urteil.md @@ -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: +URTEIL: dublette | enthalten | verwandt | verschieden | UNSICHER +GRUND: diff --git a/skills/aufgabe/guide-fix.md b/skills/aufgabe/guide-fix.md new file mode 100644 index 0000000..086261a --- /dev/null +++ b/skills/aufgabe/guide-fix.md @@ -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 `` 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=== + diff --git a/skills/aufgabe/guide-pruefung.md b/skills/aufgabe/guide-pruefung.md new file mode 100644 index 0000000..e89b5ed --- /dev/null +++ b/skills/aufgabe/guide-pruefung.md @@ -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: +ART: falsch | luecke | stil +STELLE: +DETAIL: +===OK=== +SECTION: diff --git a/skills/aufgabe/kapitel-intro.md b/skills/aufgabe/kapitel-intro.md new file mode 100644 index 0000000..3c97df1 --- /dev/null +++ b/skills/aufgabe/kapitel-intro.md @@ -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> diff --git a/skills/aufgabe/klaerung.md b/skills/aufgabe/klaerung.md new file mode 100644 index 0000000..3ae20c1 --- /dev/null +++ b/skills/aufgabe/klaerung.md @@ -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: diff --git a/skills/aufgabe/lernziele.md b/skills/aufgabe/lernziele.md new file mode 100644 index 0000000..92966c9 --- /dev/null +++ b/skills/aufgabe/lernziele.md @@ -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: +TITEL: <2-5 Wörter> +TEXT: +ATOME: diff --git a/skills/aufgabe/luecke-freispruch.md b/skills/aufgabe/luecke-freispruch.md new file mode 100644 index 0000000..482396a --- /dev/null +++ b/skills/aufgabe/luecke-freispruch.md @@ -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: diff --git a/skills/aufgabe/luecke-nachextraktion.md b/skills/aufgabe/luecke-nachextraktion.md new file mode 100644 index 0000000..ef8c7f3 --- /dev/null +++ b/skills/aufgabe/luecke-nachextraktion.md @@ -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: +TITEL: … +TYP: … +DEFINITION: … +ZITAT: … +===NICHTS=== +SOLL: diff --git a/skills/aufgabe/ordnung.md b/skills/aufgabe/ordnung.md new file mode 100644 index 0000000..868aaa2 --- /dev/null +++ b/skills/aufgabe/ordnung.md @@ -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: diff --git a/skills/aufgabe/quellen-relevanz.md b/skills/aufgabe/quellen-relevanz.md new file mode 100644 index 0000000..bbe0f0d --- /dev/null +++ b/skills/aufgabe/quellen-relevanz.md @@ -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: diff --git a/skills/aufgabe/recherche-plan.md b/skills/aufgabe/recherche-plan.md new file mode 100644 index 0000000..820fd40 --- /dev/null +++ b/skills/aufgabe/recherche-plan.md @@ -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: +TEXT: … diff --git a/skills/aufgabe/redundanz-urteil.md b/skills/aufgabe/redundanz-urteil.md new file mode 100644 index 0000000..564def4 --- /dev/null +++ b/skills/aufgabe/redundanz-urteil.md @@ -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: +URTEIL: redundant | didaktisch_gewollt | UNSICHER diff --git a/skills/aufgabe/soll-beleg.md b/skills/aufgabe/soll-beleg.md new file mode 100644 index 0000000..d281abe --- /dev/null +++ b/skills/aufgabe/soll-beleg.md @@ -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: +BELEG: diff --git a/skills/aufgabe/soll-extraktion.md b/skills/aufgabe/soll-extraktion.md new file mode 100644 index 0000000..d2308f8 --- /dev/null +++ b/skills/aufgabe/soll-extraktion.md @@ -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: +TEXT: +BELEG: +===LEER=== +ABSCHNITT: diff --git a/skills/aufgabe/soll-konsens.md b/skills/aufgabe/soll-konsens.md new file mode 100644 index 0000000..4e0b4d2 --- /dev/null +++ b/skills/aufgabe/soll-konsens.md @@ -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: +TEXT: diff --git a/skills/aufgabe/soll-zuordnung.md b/skills/aufgabe/soll-zuordnung.md new file mode 100644 index 0000000..c6d69b2 --- /dev/null +++ b/skills/aufgabe/soll-zuordnung.md @@ -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: +SOLL: diff --git a/skills/aufgabe/themen-schnitt.md b/skills/aufgabe/themen-schnitt.md new file mode 100644 index 0000000..0370590 --- /dev/null +++ b/skills/aufgabe/themen-schnitt.md @@ -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: diff --git a/skills/aufgabe/verdichtung.md b/skills/aufgabe/verdichtung.md new file mode 100644 index 0000000..9fb5761 --- /dev/null +++ b/skills/aufgabe/verdichtung.md @@ -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: diff --git a/skills/aufgabe/writer.md b/skills/aufgabe/writer.md new file mode 100644 index 0000000..1195522 --- /dev/null +++ b/skills/aufgabe/writer.md @@ -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 `` 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=== + diff --git a/skills/rolle/autor.md b/skills/rolle/autor.md new file mode 100644 index 0000000..bf5e6ef --- /dev/null +++ b/skills/rolle/autor.md @@ -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. diff --git a/skills/rolle/extraktor.md b/skills/rolle/extraktor.md new file mode 100644 index 0000000..498b37c --- /dev/null +++ b/skills/rolle/extraktor.md @@ -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. diff --git a/skills/rolle/planer.md b/skills/rolle/planer.md new file mode 100644 index 0000000..b27afbe --- /dev/null +++ b/skills/rolle/planer.md @@ -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. diff --git a/skills/rolle/richter.md b/skills/rolle/richter.md new file mode 100644 index 0000000..cd5b6b6 --- /dev/null +++ b/skills/rolle/richter.md @@ -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. diff --git a/skills/stil/deutsch-praezise.md b/skills/stil/deutsch-praezise.md new file mode 100644 index 0000000..78abee3 --- /dev/null +++ b/skills/stil/deutsch-praezise.md @@ -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. diff --git a/skills/stil/guide-lesbarkeit.md b/skills/stil/guide-lesbarkeit.md new file mode 100644 index 0000000..80f9423 --- /dev/null +++ b/skills/stil/guide-lesbarkeit.md @@ -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. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f42f31f --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_buendel.py b/tests/test_buendel.py new file mode 100644 index 0000000..8a92fe5 --- /dev/null +++ b/tests/test_buendel.py @@ -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 diff --git a/tests/test_dedup_redundanz.py b/tests/test_dedup_redundanz.py new file mode 100644 index 0000000..1317266 --- /dev/null +++ b/tests/test_dedup_redundanz.py @@ -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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..669960f --- /dev/null +++ b/tests/test_e2e.py @@ -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"") == 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 == [] diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..48702c4 --- /dev/null +++ b/tests/test_engine.py @@ -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" diff --git a/tests/test_gates.py b/tests/test_gates.py new file mode 100644 index 0000000..86a607a --- /dev/null +++ b/tests/test_gates.py @@ -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"" 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"\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"\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"\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"\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"\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"\nDas Langes Spätkonzept kommt. " + + "Wort " * 50) + db.insert("sections", baustein_id=bid2, + text=f"\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"]) diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..b467644 --- /dev/null +++ b/tests/test_graph.py @@ -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()) diff --git a/tests/test_korpus.py b/tests/test_korpus.py new file mode 100644 index 0000000..3071ca8 --- /dev/null +++ b/tests/test_korpus.py @@ -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 diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..574ff92 --- /dev/null +++ b/tests/test_llm.py @@ -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 diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..0d5b891 --- /dev/null +++ b/tests/test_skills.py @@ -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("") + 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" diff --git a/tests/test_struktur.py b/tests/test_struktur.py new file mode 100644 index 0000000..3214681 --- /dev/null +++ b/tests/test_struktur.py @@ -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 diff --git a/tests/test_suche_laden.py b/tests/test_suche_laden.py new file mode 100644 index 0000000..6ef6980 --- /dev/null +++ b/tests/test_suche_laden.py @@ -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) diff --git a/tests/test_textkit.py b/tests/test_textkit.py new file mode 100644 index 0000000..f2a2bf4 --- /dev/null +++ b/tests/test_textkit.py @@ -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 diff --git a/tests/test_verdichtung.py b/tests/test_verdichtung.py new file mode 100644 index 0000000..e34802f --- /dev/null +++ b/tests/test_verdichtung.py @@ -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"]