init
This commit is contained in:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Pflicht: welcher Provider-Stack standardmäßig läuft (claude | minimax | lokal)
|
||||
DEFAULT_PROVIDER=minimax
|
||||
|
||||
# MiniMax (opencode + direkte Text-API)
|
||||
MINIMAX_API_KEY=
|
||||
|
||||
# Rollen-Routing über Stacks hinweg (optional): z.B. jeden Judge-Call zu Claude
|
||||
# ROLE_JUDGE=claude
|
||||
# ROLE_GUIDE=claude
|
||||
|
||||
# Nebenläufigkeit (optional)
|
||||
# MAX_CONCURRENT_AGENTS=16
|
||||
# MAX_CONCURRENT_API_AGENTS=28
|
||||
|
||||
# Token-Budget pro Lauf (Summe in+out; 0 = kein Limit)
|
||||
# RUN_BUDGET_TOKENS=5000000
|
||||
|
||||
# Fake-Modus: alle LLM-Calls deterministisch (Tests/Frontend-Smoke)
|
||||
# CREATOR_FAKE_AGENTS=1
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
storage/
|
||||
topics/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.pytest_cache/
|
||||
90
DESIGN.md
Normal file
90
DESIGN.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Design creator2
|
||||
|
||||
Beschlossen am 2026-07-09 (Diskussion Nutzer + Claude). Ersetzt `../creator`.
|
||||
|
||||
## Warum Neubau
|
||||
|
||||
Der Vorgänger gruppierte nach Entitäts-Ähnlichkeit (Clustering → Naming → Umbrella → Dedup
|
||||
→ Degradier-Pass). Der Guide las sich als Aneinanderreihung von Bausteinen. Viele Fixes
|
||||
waren Symptomreparatur an dieser Kerneinheit. creator2 wechselt das Kriterium: gruppiert
|
||||
wird nach „lernt sich gut zusammen" (Backward Design), nicht nach „ist dieselbe Entität".
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
E0 Korpus Themen: N Recherche-Sweeps (Curricula/Lehrbuch/Syllabus/Übungen) → Snapshots
|
||||
Uni: Dateien aus topics/<name>/ (txt/md/pdf) → Snapshots
|
||||
Soll-Extraktion je Quelle → Konsens (Punkt braucht ≥2 unabhängige Quellen)
|
||||
Themen: Runden bis Sättigung (Runde ohne neuen Soll-Punkt) → Auto-Freeze
|
||||
E1 Inventar Abschnitte (~12k Zeichen) → 2 Reader je Abschnitt → Atome mit Verbatim-Anker
|
||||
Dedup: Anker-Overlap (auto) → Embedding-Kandidaten → 2er-Panel (einstimmig)
|
||||
Soll-Zuordnung je Atom; Soll-Punkt ohne Atom → gezielte Nachextraktion
|
||||
E2 Artefakte je Soll-Punkt-Gruppe: Flashcards+Beispiel pro Atom → Verify-Panel gegen Anker → Fix
|
||||
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen
|
||||
→ Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
|
||||
→ topologische Ordnung (Bausteine + Atome darin)
|
||||
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker) → det. Checks → Prüfer → Fix
|
||||
Assembly in Baustein-Ordnung
|
||||
```
|
||||
|
||||
Jede Ebene endet mit `qa.messen(ebene)`; `auto_loop` fährt QA→Repair bis Note 10,0,
|
||||
Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene.
|
||||
|
||||
## Datenmodell (SQLite, backend/db.py)
|
||||
|
||||
- `topics(name, titel, art thema|uni, status, provider)`
|
||||
- `runs(id, topic, status running|paused|done|failed|budget, gestartet, beendet, git_hash, git_dirty, budget_tokens, grund)`
|
||||
- `events(run_id, ts, ebene, stage, item, template, template_hash, provider, model, role, status, dur_ms, wait_ms, tok_in, tok_out, tok_cache_read, tok_cache_write, meta)` — Ledger, append-only
|
||||
- `quellen(id, topic, art web|datei, titel, url, snapshot, hash, runde, status)`
|
||||
- `soll(id, topic, punkt, status kandidat|bestaetigt, belege JSON [{quelle,zitat}])`
|
||||
- `atome(id, topic, titel, typ, definition, level, status, soll_id, ziel_id, baustein_id, ord, merged_into)`
|
||||
- `anker(atom_id, quelle_id, start, ende, zitat)`
|
||||
- `kanten(topic, von_atom, zu_atom, art braucht|verwandt, status)`
|
||||
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
|
||||
- `lernziele(id, topic, text, soll_id, status)`
|
||||
- `bausteine(id, topic, ziel_id, titel, ord, status)`
|
||||
- `sections(baustein_id, stage, text_kompakt, text_lang, befunde JSON)`
|
||||
- `leitner(artefakt_id, box, faellig, historie)`
|
||||
- `befunde(run_id, ebene, art, item, detail, status offen|repariert|freigesprochen)`
|
||||
|
||||
Alles status-basiert → Resume = Lauf neu starten, fertige Items werden übersprungen.
|
||||
|
||||
## QA (backend/qa.py)
|
||||
|
||||
**Invarianten (deterministisch, gated):**
|
||||
- E0: jeder bestätigte Soll-Punkt hat ≥2 Belege aus verschiedenen Quellen; Korpus nicht leer
|
||||
- E1: jedes Atom hat Anker (Zitat verbatim in Quelle gefunden), Definition, Typ, genau einen
|
||||
Soll-Punkt; jeder Soll-Punkt ≥1 Atom
|
||||
- E2: jedes Atom ≥1 verifizierte Flashcard; Beispiel-Status entschieden
|
||||
- E3: Partition (jedes Atom genau ein Baustein via Ziel), Bandgröße, DAG azyklisch,
|
||||
keine Kante von spätem zu frühem Baustein
|
||||
- E4: jeder Atom-Marker vorhanden, Ziel-Anker im Text, Längenband, keine Vorwärtsverweise
|
||||
(Titel eines späteren Atoms fällt nicht früher)
|
||||
|
||||
**LLM-Stichproben (nur wo nötig, immer 2 unabhängige Bestätiger):** fachlich-falsch im Guide.
|
||||
|
||||
Note = 10 · (1 − gewichtete Befundquote); 10,0 nur bei null offenen Befunden.
|
||||
|
||||
## Nebenläufigkeit
|
||||
|
||||
Einfache Wellen (`asyncio.gather` + Semaphoren in agents.py), keine Streaming-Engine.
|
||||
Panels = n parallele Calls + Konsensregel. Hedge: Slot ohne Ergebnis nach
|
||||
max(90 s, timeout/2) bekommt einen Zwilling, erstes valides Ergebnis gewinnt.
|
||||
Live-Board trotzdem: jeder Statuswechsel → WebSocket-Broadcast.
|
||||
|
||||
## Provider
|
||||
|
||||
Wie Vorgänger: `claude`-CLI, `opencode` (MiniMax), MiniMax-Text-API direkt (tool-lose Calls).
|
||||
Rollen quick|judge|guide|fast je Provider-Stack, Routing via env (`ROLE_JUDGE=claude`).
|
||||
`CREATOR_FAKE_AGENTS=1` → fake_agents.py.
|
||||
|
||||
## Frontend (Vue 3 + Vite)
|
||||
|
||||
Tabs: **Board** (Spalten = Ebenen, ALLE Karten, scrollbar, live via WS) · **Guide**
|
||||
(marked+KaTeX+highlight, Level-Filter) · **Üben** (Leitner-Boxen) · **Kennzahlen**
|
||||
(Tokens/Zeit je Ebene/Stage/Template aus dem Ledger).
|
||||
|
||||
## Bewusst gestrichen
|
||||
|
||||
Delta-Läufe (Material vollständig), Abnahme-Benchmark, Projekt-/Link-Quellen,
|
||||
Chat/Prüfungs-Features, ELO/Stufen-Ansichten (Level-Feld existiert, Ansichten später).
|
||||
115
LEKTIONEN.md
Normal file
115
LEKTIONEN.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Bezahlte Lektionen aus creator (Vorgänger)
|
||||
|
||||
Extrahiert 2026-07-09 aus dem alten Code. Format: **Regel** — Grund (Beleg alt: Datei:Zeile).
|
||||
Diese Regeln sind Anforderungen an creator2, kein übernommener Code.
|
||||
|
||||
## Infra-Fehler
|
||||
|
||||
1. **Infra-Fehler (429/Timeout/Netz) getrennt von Inhaltsfehlern zählen** — sonst frisst ein 429 die inhaltlichen Restarts und der Lauf läuft fail-open weiter (pipeline.py:80).
|
||||
2. **Erschöpfte Infra-Retries ⇒ Lauf-PAUSE (fortsetzbar), nie Teilergebnis** — Quorum verhungerte sonst still (pipeline.py:304).
|
||||
3. **Retries + Backoff zentral: 3×, 8/16/32 s** (config.py:180).
|
||||
4. **Stall-Hedge: genau EIN Zwilling nach Schwelle, erster valider gewinnt** — Stalls verbrannten 160–230 s Timeout (pipeline.py:225).
|
||||
5. **Hedge-Schwelle = max(90 s, timeout/2)** — pauschale 90 s hedgten gesunde lange Calls (pipeline.py:196).
|
||||
6. **Judge-Timeouts eng (p50 6–72 s), Retry heilt in Sekunden** (config.py:201).
|
||||
7. **Prozessgruppen-Kill (killpg + start_new_session)** — CLI-Kinder hielten sonst Pipes offen (agents.py:287).
|
||||
8. **CLI-Starts staffeln (Token-Bucket)** — OpenCode-Session-DB: "database is locked" (agents.py:154).
|
||||
9. **RAM-Gate vor CLI-Spawns (~310 MB RSS je Prozess)** (agents.py:195).
|
||||
10. **Concurrency-Slot VOR dem Spawn erwerben** — Queue-Wartezeit zählt nicht gegen den Timeout (agents.py:76).
|
||||
11. **Agent-Slots nach Pipeline-Stufe priorisieren** (früh vor spät) — nur nötig bei Streaming; Wellen-Design umgeht es.
|
||||
12. **.env-Datei gewinnt über geerbtes Env** — --reload-Master pinnte stale Werte stundenlang (config.py:13).
|
||||
13. **Provider-Routen pro Rolle empirisch wählen** — kalt-Endpoint stallte 20 % der Judges (config.py:244).
|
||||
14. **Timeouts = Basis + pro-Item·n**, nie fix (config.py:195).
|
||||
|
||||
## Judge-Rauschen
|
||||
|
||||
15. **Hochgewichtete LLM-Urteile: Verdacht + 2 unabhängige Bestätiger** — Einzel-Judge pendelte 0↔5 Befunde (guide_qa.py:138).
|
||||
16. **Instabile Klassifikations-Judges: Zweitpass auf die Verdachtsmenge** (qa.py:516).
|
||||
17. **Mehrheit bei Recall-kritischen Entscheidungen** — Einstimmigkeit verwarf reale Einzelfunde (board_inventory.py:580).
|
||||
18. **Einstimmigkeit bei irreversiblen Merges** (board_inventory.py:1195).
|
||||
19. **Detektor-vs-Judge-Dissens: dritter Stichentscheid (2/3), Freispruch persistieren** (repair.py:82).
|
||||
20. **Panel-Ausfall: Ersatz-Richter vor fail-open** (block_calls.py:358).
|
||||
21. **Panels: 2 = Konsens-Minimum, 3 nur wo Rauschen teuer** (config.py:162).
|
||||
22. **Quorum-Rennen mit Grace-Fenster** — Sofort-Kill warf fast fertige Stimmen weg (pipeline.py:174).
|
||||
23. **Klärungs-Loops hart deckeln, Schlussrunde muss alles entscheiden** (config.py:128).
|
||||
24. **Billige Konsensregeln (≥2 Nennungen) deterministisch im Code, nicht per LLM** (board_inventory.py:546).
|
||||
|
||||
## Fehlmerge-Schutz
|
||||
|
||||
25. **Relationen (A≤B) über beide Operanden + Richtung individuieren, Konflikte vetoen** — "SAT≤Clique" vs "3-SAT≤Clique" hat hohen Cosinus (blocks.py:1150).
|
||||
26. **Deterministische Guards nur so streng wie beweisbar; Bedeutung dem belegten Judge** (blocks.py:1157).
|
||||
27. **Casefold + NFKC vor jedem Ähnlichkeitsvergleich** — "VERTEX COVER" fiel unter die Schwelle (board_inventory.py:1133).
|
||||
28. **Acronym↔Expansion explizit als Kandidaten einspeisen** — Cosinus ~0.53, nie Kandidat (blocks.py:1093).
|
||||
29. **Auto-Merge nur über ordnungsunabhängigen Canonical-Key; leerer Key ⇒ nie** (blocks.py:1106).
|
||||
30. **Embedding-Backstop vetot strukturlose Fehl-Demotes** (config.py:99).
|
||||
31. **Negations-Guard: Antonyme messen 0.91–0.95 Cosinus** — gleiche Negationsmenge als harte Merge-Vorbedingung (blocks.py:681).
|
||||
32. **Frei generierte Titel gegen Korpus verankern** — Drift zu Lehrbuch-Kanon (board_inventory.py:121).
|
||||
33. **Quorum schützt nicht vor Ko-Halluzination** — Titel ohne Korpus-Anker separat belegen (board_inventory.py:576).
|
||||
34. **Kandidatenblöcke in Größe cappen** — sonst Riesenkomponente, instabile Judge-Listen (config.py:56).
|
||||
35. **Containment-Zuordnung nur bei genau einem signifikanten Ganzwort-Treffer** (blocks.py:1250).
|
||||
36. **Token/Stem-Match statt Substring** — "bergang" ⊂ "Übergang" (qa.py:243).
|
||||
37. **Benannte Aussagen (Reduktionen, Sätze) strukturell vor Demotion schützen** (blocks.py:1322).
|
||||
|
||||
## PDF/Text
|
||||
|
||||
38. **Nie blind einem PDF-Extraktor vertrauen** — pymupdf4llm droppt Formeln still; pdftotext ist treu aber strukturarm. v1 nutzt pdftotext (Treue > Struktur); zweiter Extraktor + Verlust-Guard bei Bedarf (blocks.py:349).
|
||||
39. **PDF-Spacing-Artefakte entzerren ("H ITTING S ET"), mit Frequenzbeleg** (blocks.py:373).
|
||||
40. **Logik-Versions-Marker für abgeleitete Artefakte** — mtime-Cache überlebte Algorithmus-Änderungen (blocks.py:385).
|
||||
41. **Optionale Abhängigkeiten (OCR) prüfen, still degradieren** (blocks.py:308).
|
||||
42. **Titel-Splits klammerbewusst** (textkit.py:54).
|
||||
43. **Unicode-Dash-Varianten tolerant normalisieren, ASCII-Hyphen schützen** (textkit.py:115).
|
||||
44. **Marker-Vergleiche gegen Escaping härten (Backslashes strippen)** (guide_qa.py:36).
|
||||
45. **Metadaten-Header vor Coverage-Detektion strippen** — Phantom-Lücken (qa.py:104).
|
||||
46. **Regex-Fänge auf Umbruch-Fortsetzung prüfen** (qa.py:210).
|
||||
47. **Große Quelltexte in ~12k-Abschnitte splitten** — lost in the middle (blocks.py:437).
|
||||
|
||||
## Token-Fresser
|
||||
|
||||
48. **Evidenz INLINE in Judge-Prompts, nie Judges selbst suchen lassen** — 82 % der Lauf-Tokens waren Cache-Reads aus Judge-Tool-Loops (config.py:143). WICHTIGSTE EINZELLEKTION.
|
||||
49. **Strukturierten Output als Antworttext, robust parsen; Tool-Writes nur Fallback** — 40 von 64 Turns in einem Write/Validate-Loop (blocks.py:599).
|
||||
50. **Tool-lose Calls über die direkte API statt CLI-Prozess** (agents.py:242).
|
||||
51. **MCP-Server nur für full-Agenten** — ~3×300 MB pro Prozess (agents.py:529).
|
||||
52. **Zusammengehörige Schritte in EINEM Call bündeln** (guide_board.py:7).
|
||||
53. **Token-Verbrauch auch bei Fehlern/Timeouts erfassen** (agents.py:487).
|
||||
54. **Rückkopplungslisten (Lücken/Fixes) über Schnittmenge filtern und hart cappen** — 107 "Lücken" auf 216 Subs (blocks.py:786).
|
||||
55. **Chunk-/Split-Größen zentral und tunebar** (config.py:151).
|
||||
|
||||
## Prompt-Design
|
||||
|
||||
56. **Faktenbasis vorgeben, VERBATIM-Zitate verlangen, jeden Claim gaten** (Guide-Writer-Board.md:10).
|
||||
57. **Maschinen-Marker als exakte Struktur-Invariante, unsichtbar, nie Überschrift** (Guide-Writer-Board.md:14).
|
||||
58. **Längenbudget als HARTE Obergrenze formulieren, nicht als Ziel** (Guide-Writer-Board.md:27).
|
||||
59. **Ausgabekanal + Format ausschließlich vorschreiben: "NUR JSON, keine Fences, keine Datei"** (Guide-Outline.md:21).
|
||||
60. **Fixierte Eingabemengen einfrieren: "Copy VERBATIM, do NOT add/remove/split/merge/rename"** (Subblock-Anreichern.md:12).
|
||||
61. **Aggregat-Titel: nur Eigenschaften, die auf ALLE Mitglieder zutreffen** (Blocks-Gruppierung.md:14).
|
||||
62. **Namen nur aus Begriffen der Quelle, nie Lehrbuch-Oberbegriff** (Blocks-Naming.md:8).
|
||||
63. **Katalognummern ("Satz 7.13") sind keine Titel** (Blocks-Sanierung.md:11).
|
||||
64. **Quellgebundene Schritte: NUR die gelieferten Auszüge, kein Web, keine PDFs** (Blocks-Source-Inline.md:1).
|
||||
65. **Bewertungs-Prompts: unbeantwortbare Fragen erkennen, Korrektheit in anderen Worten zählt** (Block-Rating-Critique.md:22).
|
||||
66. **Reservierte Parser-Zeichen dem Modell verbieten** (Blocks-Gruppierung.md:16).
|
||||
|
||||
## Übergreifende Guards
|
||||
|
||||
67. **Destruktive Entscheidungen: im Zweifel behalten (fail-open)** (repair.py:8).
|
||||
68. **Infra-Erschöpfung: fail-closed (Pause)** — die Wahl pro Fehlerklasse bewusst treffen (pipeline.py:330).
|
||||
69. **Freisprüche persistieren** — sonst pendelt die Note ewig unter 10 (qa.py:437).
|
||||
70. **Note deterministisch aus gemessenen Quoten, nie vom LLM schätzen lassen** (qa.py:294).
|
||||
71. **Qualitäts-Gate vor teuren Downstream-Phasen** (config.py:132).
|
||||
72. **QA nutzt EIGENE Detektoren/Schwellen, nie die der Pipeline** — geteilte blinde Flecken (qa.py:1).
|
||||
73. **Fix-Schwellen an Sektionsgröße koppeln; Kritisches immer fixen** (config.py:170).
|
||||
74. **Schema-Parser defensiv gegen Judge-Overreport** — 65-Einträge-Vollinventar (guide_board.py:66).
|
||||
75. **Detektor-Messlatte und Fix-Auftrag teilen dieselbe Formel** — sonst unfixbare Befunde (guide_qa.py:75).
|
||||
76. **Zwei Retry-Ebenen: Race-Restart vs. Item-Backoff/Dead-Letter, sichtbar + requeue-bar** (kanban.py:135).
|
||||
77. **Auto-Loop: Ziel erreicht / echter Stillstand / hartes Limit — transiente Verschlechterung zulassen** (auto_loop.py:31).
|
||||
78. **Guard-Strenge an Fehlerkosten ausrichten** — reversible Fehl-Merges brauchen keine harten Vetos (board_inventory.py:1427).
|
||||
79. **Nach parallelen Judges ein Reconcile-Pass gegen Duplikat-Parents** (config.py:90).
|
||||
80. **Zielgrößen sublinear (√n) statt statischer Bänder** (config.py:94).
|
||||
81. **Fakten-Checks: exakt zitierte Quellstellen einblenden, kein Keyword-Pack** (blocks.py:549).
|
||||
82. **Unicode am Import normalisieren (NFC + Kontrollzeichen→Leerzeichen), Matching diakritik-fest (NFKD, Kombinationszeichen droppen)** — pdftotext liefert dekomponierte Umlaute und Steuerbytes für Sonderglyphen (ε→\x0f), LLM-Zitate präkomponierte Zeichen bzw. Müll-Echos; per-Zeichen-NFKC komponiert nie → 59/96 aak-Atome „ohne Anker" trotz wörtlicher Zitate (creator2: korpus.py:_snapshot_schreiben, textkit.py:_locker_mit_map).
|
||||
83. **Text-Reparatur und Zitat-Anchoring nicht selbst erfinden: ftfy am Import, fuzzysearch als letzte Matching-Stufe (Hypothes.is-Muster exakt→locker→fuzzy)** — Reader „verschönern" Zitate (Listing-Zeilennummern weg, verlorene Glyphen rekonstruiert); Fuzzy NUR mit Mindestlänge, harter Fehlerquote und Eindeutigkeits-Guard gegen Doppelgänger-Passagen (SubSetSum vs. Partition teilen den Satzanfang); Steuerzeichen VOR ftfy zu Leerzeichen (ftfy löscht sie und verklebt Wörter) (creator2: korpus.py:_text_reparieren, textkit.py:_fuzzy_span).
|
||||
|
||||
## Meta
|
||||
|
||||
- Jede Guard/Schwelle trägt ihren gemessenen Beleg im Kommentar.
|
||||
- Tunebare Parameter und QA-Messlatte strikt trennen (Messlatte nie im Suchraum).
|
||||
- Deterministisch vor LLM: Konsens, Kandidaten, Guards im Code; der Judge ist Präzisions-Gate für Zweifelsfälle.
|
||||
- Provider-Ausfall darf nie den Lauf reißen — Stacks unabhängig.
|
||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
||||
.PHONY: install dev stop test test-e2e build
|
||||
|
||||
install:
|
||||
pip install --break-system-packages fastapi "uvicorn[standard]" httpx pytest pytest-asyncio ftfy fuzzysearch
|
||||
cd frontend && npm install
|
||||
@echo "Optional: pip install sentence-transformers (Embedding-Kandidaten; ohne läuft lexikalischer Fallback)."
|
||||
@echo "Optional: poppler-utils für PDF-Quellen (pdftotext)."
|
||||
|
||||
dev:
|
||||
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
||||
@set -a; [ -f .env ] && . ./.env; set +a; \
|
||||
cd backend && uvicorn main:app --reload --port 8000 &
|
||||
@cd frontend && npx vite --port 5173
|
||||
|
||||
stop:
|
||||
-@pkill -f "uvicorn main:app" 2>/dev/null
|
||||
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||
@echo "gestoppt."
|
||||
|
||||
test:
|
||||
cd backend && python -m pytest ../tests -x -q
|
||||
|
||||
test-e2e:
|
||||
cd backend && CREATOR_FAKE_AGENTS=1 python -m pytest ../tests/test_e2e.py -x -q
|
||||
|
||||
build:
|
||||
cd frontend && npm run build
|
||||
369
backend/agents.py
Normal file
369
backend/agents.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Provider-Schicht: ein Agent-Call via Claude-CLI, OpenCode (MiniMax/Ollama) oder
|
||||
direkter MiniMax-Text-API. Stacks sind unabhängig — fehlt ein Binary/Key, fällt nur
|
||||
dieser Provider aus.
|
||||
|
||||
Bezahlte Lektionen des Vorgängers, hier eingebaut statt nachgepatcht:
|
||||
- OpenCode-Starts staffeln (Session-DB-Kollision: "database is locked" bei Parallelstart)
|
||||
- Prozessgruppen-Kill (CLI-Kinder halten sonst Pipes offen, communicate() hängt)
|
||||
- RAM-Gate vor CLI-Spawns (~310 MB RSS je Prozess)
|
||||
- Prompt via Tempdatei statt argv (ARG_MAX)
|
||||
- Token-Accounting auch bei Timeout/Fehler — Verschwendung bleibt sichtbar
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_API_AGENTS,
|
||||
OPENCODE_START_DELAY, PROVIDERS, RAM_MIN_FREE_PCT, resolve_role)
|
||||
|
||||
log = logging.getLogger("creator2.agents")
|
||||
|
||||
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
|
||||
_api_sem = asyncio.Semaphore(MAX_CONCURRENT_API_AGENTS)
|
||||
_active: dict[str, float] = {} # key → Startzeit (Anzeige)
|
||||
_prozesse: dict[str, asyncio.subprocess.Process] = {}
|
||||
_abgebrochen: set[str] = set() # Key-Präfixe abgebrochener Läufe
|
||||
|
||||
# ── Globale 429-Bremse: Backoff pro Call reicht nicht — 28 Parallel-Calls
|
||||
# kollidieren nach der Wartezeit sofort wieder. Ein 429 drosselt deshalb ALLE:
|
||||
# Cooldown für neue Starts + API-Breite halbieren; Erfolge heben sie langsam. ──
|
||||
_cooldown_bis = 0.0 # time.monotonic(): vorher startet nichts Neues
|
||||
_api_breite = MAX_CONCURRENT_API_AGENTS
|
||||
_api_inflight = 0
|
||||
_api_erfolge = 0
|
||||
|
||||
|
||||
def drossel_melden(sekunden: float) -> None:
|
||||
global _cooldown_bis, _api_breite, _api_erfolge
|
||||
_cooldown_bis = max(_cooldown_bis, time.monotonic() + sekunden)
|
||||
neu = max(4, _api_breite // 2)
|
||||
if neu != _api_breite:
|
||||
log.warning("429-Bremse: API-Breite %d → %d, Cooldown %.0fs", _api_breite, neu, sekunden)
|
||||
_api_breite = neu
|
||||
_api_erfolge = 0
|
||||
|
||||
|
||||
def _erfolg_melden() -> None:
|
||||
global _api_erfolge, _api_breite
|
||||
_api_erfolge += 1
|
||||
if _api_erfolge >= 20 and _api_breite < MAX_CONCURRENT_API_AGENTS:
|
||||
_api_breite += 1 # langsame Erholung: 20 Erfolge kaufen +1 Breite
|
||||
_api_erfolge = 0
|
||||
|
||||
|
||||
async def _drossel_warten(key: str) -> None:
|
||||
while True:
|
||||
rest = _cooldown_bis - time.monotonic()
|
||||
if rest <= 0 or _ist_abgebrochen(key):
|
||||
return
|
||||
await asyncio.sleep(min(rest, 1.0))
|
||||
|
||||
|
||||
async def _api_slot(key: str) -> None:
|
||||
"""Cooldown + adaptive Breite (unterhalb des festen Semaphors)."""
|
||||
global _api_inflight
|
||||
while True:
|
||||
await _drossel_warten(key)
|
||||
if _api_inflight < _api_breite:
|
||||
_api_inflight += 1
|
||||
return
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
class AgentErgebnis:
|
||||
__slots__ = ("rc", "text", "err", "tokens", "wait_s")
|
||||
|
||||
def __init__(self, rc: int, text: str, err: str, tokens: dict | None = None):
|
||||
self.rc, self.text, self.err, self.tokens = rc, text, err, tokens or {}
|
||||
self.wait_s = 0.0 # Queue-Zeit (Semaphore/Drossel) — run_agent füllt sie
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.rc == 0 and bool(self.text.strip())
|
||||
|
||||
|
||||
def aktive_agenten() -> list[dict]:
|
||||
now = time.time()
|
||||
return sorted([{"key": k, "laufzeit": round(now - t, 1)} for k, t in _active.items()],
|
||||
key=lambda a: -a["laufzeit"])
|
||||
|
||||
|
||||
def abbrechen(prefix: str) -> None:
|
||||
"""Alle laufenden + wartenden Agenten dieses Präfixes stoppen."""
|
||||
_abgebrochen.add(prefix)
|
||||
for key, p in list(_prozesse.items()):
|
||||
if key.startswith(prefix):
|
||||
_kill(p)
|
||||
|
||||
|
||||
def abbruch_aufheben(prefix: str) -> None:
|
||||
_abgebrochen.discard(prefix)
|
||||
|
||||
|
||||
def _ist_abgebrochen(key: str) -> bool:
|
||||
return any(key.startswith(p) for p in _abgebrochen)
|
||||
|
||||
|
||||
def provider_verfuegbar(provider: str) -> bool:
|
||||
cfg = PROVIDERS.get(provider)
|
||||
if not cfg:
|
||||
return False
|
||||
if shutil.which(cfg["cli"]) is None:
|
||||
return False
|
||||
if cfg.get("env_key") and not os.environ.get(cfg["env_key"]):
|
||||
return False
|
||||
if cfg.get("check_url"):
|
||||
try:
|
||||
urllib.request.urlopen(cfg["check_url"], timeout=1)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
||||
role: str = "quick", capabilities: str = "none") -> AgentErgebnis:
|
||||
"""Ein Call. capabilities: none (nur Text) | files (Read/Write/Bash) | full (+Web).
|
||||
Wirft asyncio.TimeoutError bei Timeout (Infra-Behandlung macht llm.py)."""
|
||||
if os.getenv("CREATOR_FAKE_AGENTS"):
|
||||
import fake_agents
|
||||
return await fake_agents.antwort(key, prompt, capabilities)
|
||||
if _ist_abgebrochen(key):
|
||||
return AgentErgebnis(1, "", "abgebrochen")
|
||||
run_provider = provider
|
||||
provider, model = resolve_role(run_provider, role)
|
||||
if provider != run_provider and not provider_verfuegbar(provider):
|
||||
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
|
||||
if not model:
|
||||
return AgentErgebnis(1, "", f"kein Modell für Rolle {role} ({provider})")
|
||||
use_api = (capabilities == "none" and PROVIDERS[provider]["cli"] == "opencode"
|
||||
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
|
||||
and bool(os.environ.get("MINIMAX_API_KEY")))
|
||||
sem = _api_sem if use_api else _batch_sem
|
||||
global _api_inflight
|
||||
warte_start = time.time() # Queue-Zeit getrennt ausweisen (Ledger wait_ms)
|
||||
async with sem:
|
||||
if _ist_abgebrochen(key):
|
||||
return AgentErgebnis(1, "", "abgebrochen")
|
||||
_active[key] = time.time()
|
||||
try:
|
||||
if use_api:
|
||||
await _api_slot(key)
|
||||
wait_s = time.time() - warte_start
|
||||
try:
|
||||
res = await _text_api(key, prompt, timeout, model)
|
||||
finally:
|
||||
_api_inflight -= 1
|
||||
if res.rc == 0:
|
||||
_erfolg_melden()
|
||||
res.wait_s = wait_s
|
||||
return res
|
||||
await _drossel_warten(key)
|
||||
wait_s = time.time() - warte_start
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
res = await _opencode(key, prompt, timeout, model, capabilities)
|
||||
else:
|
||||
res = await _claude_cli(key, prompt, timeout, model, capabilities)
|
||||
res.wait_s = wait_s
|
||||
return res
|
||||
finally:
|
||||
_active.pop(key, None)
|
||||
|
||||
|
||||
# ── CLI-Pfade ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_CLAUDE_TOOLS = {"full": "Write,Bash,Read,WebSearch,WebFetch", "files": "Read,Bash,Write", "none": None}
|
||||
_OPENCODE_AGENTS = {"full": "full", "files": "files", "none": "text"}
|
||||
|
||||
_start_lock = asyncio.Lock()
|
||||
_next_start = 0.0
|
||||
|
||||
|
||||
async def _start_slot() -> None:
|
||||
"""Token-Bucket: CLI-Starts um OPENCODE_START_DELAY spreizen, ohne globalen Konvoi."""
|
||||
global _next_start
|
||||
loop = asyncio.get_running_loop()
|
||||
async with _start_lock:
|
||||
now = loop.time()
|
||||
bei = max(now, _next_start)
|
||||
_next_start = bei + OPENCODE_START_DELAY
|
||||
await asyncio.sleep(max(0.0, bei - now))
|
||||
|
||||
|
||||
def _ram_frei_pct() -> float | None:
|
||||
try:
|
||||
text = Path("/proc/meminfo").read_text()
|
||||
m = dict(re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE))
|
||||
return int(m["MemAvailable"]) * 100 / int(m["MemTotal"])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _ram_gate(key: str) -> bool:
|
||||
"""Unter RAM_MIN_FREE_PCT frei warten neue Spawns (fail-open ohne /proc)."""
|
||||
if RAM_MIN_FREE_PCT <= 0:
|
||||
return True
|
||||
while True:
|
||||
if _ist_abgebrochen(key):
|
||||
return False
|
||||
pct = _ram_frei_pct()
|
||||
if pct is None or pct >= RAM_MIN_FREE_PCT or not _prozesse:
|
||||
return True
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
def _kill(process) -> None:
|
||||
try:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
try:
|
||||
process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
async def _spawn(key: str, cmd: list[str], stdin_data: bytes | None, timeout: int,
|
||||
env: dict | None = None) -> AgentErgebnis:
|
||||
if not await _ram_gate(key):
|
||||
return AgentErgebnis(1, "", "abgebrochen")
|
||||
await _start_slot()
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True, env=env)
|
||||
track = key
|
||||
n = 2
|
||||
while track in _prozesse:
|
||||
track = f"{key}~{n}"
|
||||
n += 1
|
||||
_prozesse[track] = process
|
||||
start = time.monotonic()
|
||||
try:
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(input=stdin_data), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
_kill(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
raise
|
||||
log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start)
|
||||
return AgentErgebnis(process.returncode or 0, stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"))
|
||||
finally:
|
||||
if _prozesse.get(track) is process:
|
||||
del _prozesse[track]
|
||||
|
||||
|
||||
async def _claude_cli(key: str, prompt: str, timeout: int, model: str, capabilities: str) -> AgentErgebnis:
|
||||
cmd = ["claude", "-p", "--model", model]
|
||||
if _CLAUDE_TOOLS.get(capabilities):
|
||||
cmd += ["--allowedTools", _CLAUDE_TOOLS[capabilities]]
|
||||
cmd += ["--dangerously-skip-permissions"]
|
||||
return await _spawn(key, cmd, prompt.encode(), timeout)
|
||||
|
||||
|
||||
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
|
||||
|
||||
|
||||
def _opencode_tokens(key: str) -> dict | None:
|
||||
"""Token-Zähler der Session (run --title=key). Best effort, nie fatal."""
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
|
||||
try:
|
||||
row = con.execute(
|
||||
"SELECT tokens_input, tokens_output, tokens_cache_read, tokens_cache_write"
|
||||
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1", (key,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
except Exception:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
return {"input": int(row[0] or 0), "output": int(row[1] or 0),
|
||||
"cache_read": int(row[2] or 0), "cache_write": int(row[3] or 0)}
|
||||
|
||||
|
||||
async def _opencode(key: str, prompt: str, timeout: int, model: str, capabilities: str) -> AgentErgebnis:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
|
||||
f.write(prompt)
|
||||
pfad = Path(f.name)
|
||||
cmd = ["opencode", "run",
|
||||
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||
"-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
||||
"--dangerously-skip-permissions", "--title", key, "-f", str(pfad)]
|
||||
try:
|
||||
res = await _spawn(key, cmd, None, timeout)
|
||||
res.text = _clean_opencode(res.text)
|
||||
res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {}
|
||||
return res
|
||||
finally:
|
||||
pfad.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _clean_opencode(text: str) -> str:
|
||||
text = _ANSI.sub("", text)
|
||||
lines = text.splitlines()
|
||||
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
# ── Direkte MiniMax-Text-API (spart den ~310-MB-Prozess für tool-lose Calls) ──
|
||||
|
||||
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
|
||||
_API_MAX_TOKENS = 32_000
|
||||
_API_MODEL_OPTS = {
|
||||
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
|
||||
# thinking AUS: ungebremst dachte das Modell bei großen Extraktions-Chunks
|
||||
# 8k–32k Output-Tokens lang (aak Lauf 15: 4 Calls liefen ins 32k-Cap, leere
|
||||
# Antwort nach bis zu 44 min — Extraktion war 53 von 63 min der Ebene).
|
||||
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3,
|
||||
"thinking": {"type": "disabled"}},
|
||||
}
|
||||
|
||||
|
||||
async def _text_api(key: str, prompt: str, timeout: int, model: str) -> AgentErgebnis:
|
||||
body = {"model": model.split("/", 1)[1], "max_tokens": _API_MAX_TOKENS,
|
||||
"messages": [{"role": "user", "content": prompt}], **_API_MODEL_OPTS.get(model, {})}
|
||||
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": "2023-06-01"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
|
||||
resp = await asyncio.wait_for(client.post(_API_URL, json=body, headers=headers), timeout=timeout)
|
||||
except httpx.TimeoutException:
|
||||
raise asyncio.TimeoutError
|
||||
except httpx.HTTPError as e:
|
||||
return AgentErgebnis(1, "", f"{type(e).__name__}: {e}")
|
||||
if resp.status_code == 429:
|
||||
try:
|
||||
retry_after = float(resp.headers.get("retry-after", "15"))
|
||||
except ValueError:
|
||||
retry_after = 15.0
|
||||
drossel_melden(min(retry_after, 60.0))
|
||||
return AgentErgebnis(1, "", "HTTP 429: rate limited")
|
||||
if resp.status_code != 200:
|
||||
return AgentErgebnis(1, "", f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
|
||||
u = data.get("usage") or {}
|
||||
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
|
||||
"cache_read": int(u.get("cache_read_input_tokens") or 0),
|
||||
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
|
||||
if not text.strip():
|
||||
return AgentErgebnis(1, "", f"leere Antwort (stop={data.get('stop_reason')})", tokens)
|
||||
return AgentErgebnis(0, text, "", tokens)
|
||||
172
backend/artefakte.py
Normal file
172
backend/artefakte.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Ebene 2: Artefakte. Pro Atom 2 Flashcards + 1 Worked Example, generiert in
|
||||
Soll-Punkt-Gruppen (Kontext teilen spart Tokens — Lektion 52), verifiziert von einem
|
||||
2er-Panel GEGEN DIE ANKER-ZITATE (inline, Lektion 48). Einstimmig ok → verifiziert;
|
||||
sonst ein Fix + Re-Verify durch einen Judge; danach verifiziert oder verworfen."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import db
|
||||
import llm
|
||||
from config import ARTEFAKT_CHUNK_ATOME, VERIFY_PANEL
|
||||
|
||||
log = logging.getLogger("creator2.artefakte")
|
||||
|
||||
EBENE = "artefakte"
|
||||
|
||||
# Karten müssen ohne den Quelltext funktionieren (aak Lauf 8: 17 verifizierte
|
||||
# Karten fragten „was steht im Beleg"). Eng gefasst, damit Fachwörter nicht
|
||||
# matchen: „Belegung" (Aussagenlogik), „Quelle" (Flussnetzwerke), „belegen".
|
||||
_QUELLEN_REFERENZ = re.compile(
|
||||
r"\bbeleg(e|s)?\b|musterlösung|quelltext|laut (quelle|text|skript)"
|
||||
r"|(aus|in) de[rm] (quelle|text|skript)|aufgabenkontext|hausaufgabe"
|
||||
r"|präsenzaufgabe|klausur\w*|\baufgabe \d|\bserie \d|\bblatt \d",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _referenziert_quelle(inhalt: dict) -> bool:
|
||||
return _QUELLEN_REFERENZ.search(" ".join(str(v) for v in inhalt.values())) is not None
|
||||
|
||||
|
||||
def _zitate(atom_id: int) -> str:
|
||||
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0", (atom_id,))
|
||||
return "\n".join(f"> {r['zitat']}" for r in rows) or "(kein Anker)"
|
||||
|
||||
|
||||
def _atom_block(a: dict) -> str:
|
||||
return (f"ATOM {a['id']}: {a['titel']} [{a['typ']}]\n"
|
||||
f"Definition: {a['definition']}\nBelege:\n{_zitate(a['id'])}")
|
||||
|
||||
|
||||
def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]:
|
||||
"""Atome je Soll-Punkt gruppiert, gestückelt. nur_ohne: nur Atome ohne Artefakte."""
|
||||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
|
||||
if nur_ohne:
|
||||
# verworfene zählen nicht — sonst kann der Repair nach Verwurf nie
|
||||
# nachgenerieren (Lauf 8: 5 Atome blieben dauerhaft ohne Flashcard)
|
||||
atome = [a for a in atome if not db.one(
|
||||
"SELECT id FROM artefakte WHERE atom_id=? AND status!='verworfen'"
|
||||
" LIMIT 1", (a["id"],))]
|
||||
gruppen: dict = {}
|
||||
for a in atome:
|
||||
gruppen.setdefault(a["soll_id"], []).append(a)
|
||||
out = []
|
||||
for gruppe in gruppen.values():
|
||||
out += [gruppe[i:i + ARTEFAKT_CHUNK_ATOME]
|
||||
for i in range(0, len(gruppe), ARTEFAKT_CHUNK_ATOME)]
|
||||
return out
|
||||
|
||||
|
||||
async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
bloecke = "\n\n".join(_atom_block(a) for a in chunk)
|
||||
res = await llm.call(ctx, stage="artefakt", template="Artefakt-Generate",
|
||||
werte={"atome": bloecke}, role="guide",
|
||||
n=len(chunk), item=f"g{chunk[0]['id']}", erwartet=list)
|
||||
gueltig = {a["id"] for a in chunk}
|
||||
for e in res or []:
|
||||
atom_id = e.get("atom")
|
||||
typ = str(e.get("typ", "")).strip()
|
||||
if atom_id not in gueltig or typ not in ("flashcard", "beispiel"):
|
||||
continue
|
||||
inhalt = {k: str(e.get(k, "")) for k in ("frage", "antwort", "text")}
|
||||
if _referenziert_quelle(inhalt):
|
||||
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
|
||||
continue
|
||||
db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat")
|
||||
|
||||
|
||||
async def _verifizieren(ctx: llm.Kontext, chunk: list[dict]) -> None:
|
||||
"""Panel prüft alle Kandidaten des Chunks gegen die Belege. Verwerfen ist
|
||||
destruktiv → nur nach Fix + Re-Verify (im Zweifel behalten, Lektion 67)."""
|
||||
kandidaten = []
|
||||
for a in chunk:
|
||||
for k in db.query("SELECT * FROM artefakte WHERE atom_id=? AND status='kandidat'",
|
||||
(a["id"],)):
|
||||
if _referenziert_quelle(db.uj(k["inhalt"])): # deterministisch, vor dem Panel
|
||||
db.update("artefakte", "id", k["id"], status="verworfen")
|
||||
else:
|
||||
kandidaten.append(k)
|
||||
if not kandidaten:
|
||||
return
|
||||
je_atom = {a["id"]: a for a in chunk}
|
||||
liste = "\n\n".join(
|
||||
f"ARTEFAKT {k['id']} (Atom {k['atom_id']}, {k['typ']}): {k['inhalt']}\n"
|
||||
f"Belege:\n{_zitate(k['atom_id'])}" for k in kandidaten)
|
||||
stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="verify", template="Artefakt-Verify",
|
||||
werte={"artefakte": liste}, role="judge",
|
||||
n=len(kandidaten), item=f"v{chunk[0]['id']}", erwartet=list)
|
||||
for k in kandidaten:
|
||||
urteile = []
|
||||
maengel = []
|
||||
for stimme in stimmen:
|
||||
for e in stimme:
|
||||
if isinstance(e, dict) and e.get("artefakt") == k["id"]:
|
||||
urteile.append(bool(e.get("ok")))
|
||||
if e.get("mangel"):
|
||||
maengel.append(str(e["mangel"]))
|
||||
break
|
||||
if len(urteile) >= VERIFY_PANEL and all(urteile):
|
||||
db.update("artefakte", "id", k["id"], status="verifiziert")
|
||||
elif urteile:
|
||||
await _fixen(ctx, k, maengel, je_atom.get(k["atom_id"], {}))
|
||||
# keine gültige Stimme (Panel-Ausfall) → Kandidat bleibt, nächste Runde prüft
|
||||
|
||||
|
||||
async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> None:
|
||||
res = await llm.call(ctx, stage="artefakt_fix", template="Artefakt-Fix",
|
||||
schritt="fix", role="guide", item=f"f{k['id']}",
|
||||
werte={"artefakt": k["inhalt"], "typ": k["typ"],
|
||||
"maengel": "\n".join(f"- {m}" for m in maengel) or "-",
|
||||
"belege": _zitate(k["atom_id"])},
|
||||
erwartet=dict)
|
||||
if res:
|
||||
inhalt = {kk: str(res.get(kk, "")) for kk in ("frage", "antwort", "text")}
|
||||
if _referenziert_quelle(inhalt):
|
||||
db.update("artefakte", "id", k["id"], status="verworfen")
|
||||
return
|
||||
db.update("artefakte", "id", k["id"], inhalt=db.j(inhalt))
|
||||
urteil = await llm.call(ctx, stage="reverify", template="Artefakt-Verify",
|
||||
schritt="verify", role="judge", item=f"rv{k['id']}",
|
||||
werte={"artefakte": f"ARTEFAKT {k['id']} (Atom {k['atom_id']},"
|
||||
f" {k['typ']}): {db.j(res) if res else k['inhalt']}\n"
|
||||
f"Belege:\n{_zitate(k['atom_id'])}"},
|
||||
erwartet=list)
|
||||
ok = any(isinstance(e, dict) and e.get("artefakt") == k["id"] and e.get("ok")
|
||||
for e in urteil or [])
|
||||
db.update("artefakte", "id", k["id"], status="verifiziert" if ok else "verworfen")
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
neu = _chunks(ctx.topic, nur_ohne=True)
|
||||
await asyncio.gather(*(_generieren(ctx, c) for c in neu))
|
||||
alle = _chunks(ctx.topic, nur_ohne=False)
|
||||
await asyncio.gather(*(_verifizieren(ctx, c) for c in alle))
|
||||
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde = []
|
||||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen')", (ctx.topic,))
|
||||
for a in atome:
|
||||
karten = db.query("SELECT status FROM artefakte WHERE atom_id=? AND typ='flashcard'",
|
||||
(a["id"],))
|
||||
if not any(k["status"] == "verifiziert" for k in karten):
|
||||
befunde.append({"art": "atom_ohne_flashcard", "item": str(a["id"]),
|
||||
"detail": a["titel"]})
|
||||
offen = db.query("SELECT id FROM artefakte WHERE atom_id=? AND status='kandidat'",
|
||||
(a["id"],))
|
||||
for k in offen:
|
||||
befunde.append({"art": "artefakt_unentschieden", "item": str(k["id"]),
|
||||
"detail": a["titel"]})
|
||||
return befunde
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
if not befunde:
|
||||
return False
|
||||
await bauen(ctx) # idempotent: generiert Fehlendes nach, prüft Offenes
|
||||
return True
|
||||
27
backend/auto_loop.py
Normal file
27
backend/auto_loop.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""QA→Repair-Loop-Primitiv, DB-frei. Drei Abbruchbedingungen (Lektion 77):
|
||||
Ziel erreicht / echter Stillstand (nichts bewegt UND keine Verbesserung) / hartes
|
||||
Limit. Transiente Verschlechterung ist erlaubt — eine bewegte Runde darf die Note
|
||||
kurzfristig senken."""
|
||||
|
||||
import logging
|
||||
|
||||
from config import LOOP_MAX_ITER
|
||||
|
||||
log = logging.getLogger("creator2.auto_loop")
|
||||
|
||||
VOLL = 10.0
|
||||
|
||||
|
||||
async def auto_repair_loop(ebene: str, erst_note: float, reparieren,
|
||||
max_iter: int = LOOP_MAX_ITER) -> tuple[float, str]:
|
||||
"""reparieren() → (neue_note, bewegt). → (note, grund fertig|stillstand|limit)."""
|
||||
note = erst_note
|
||||
for i in range(max_iter):
|
||||
if note >= VOLL:
|
||||
return note, "fertig"
|
||||
neue_note, bewegt = await reparieren()
|
||||
log.info("%s: Iteration %d — Note %.1f → %.1f, bewegt=%s", ebene, i + 1, note, neue_note, bewegt)
|
||||
if not bewegt and neue_note <= note:
|
||||
return neue_note, "stillstand"
|
||||
note = neue_note
|
||||
return note, "fertig" if note >= VOLL else "limit"
|
||||
158
backend/config.py
Normal file
158
backend/config.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Zentrale Konfiguration. Tuning-Konstanten leben hier, QA-Messlatten in qa.py
|
||||
(die Messlatte darf nie Teil eines Suchraums werden)."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "templates"
|
||||
STORAGE_DIR = PROJECT_ROOT / "storage"
|
||||
TOPICS_DIR = PROJECT_ROOT / "topics" # Uni-Quelldateien: topics/<name>/*.txt|md|pdf
|
||||
KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus/<topic>/q<id>.md
|
||||
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
||||
DB_PATH = STORAGE_DIR / os.getenv("CREATOR_DB", "creator2.db") # Override für Smoke-Läufe
|
||||
|
||||
|
||||
def _load_env(path: Path) -> None:
|
||||
"""Mini-.env-Loader. Die DATEI gewinnt über geerbtes Env — ein --reload-Master
|
||||
behält sonst sein Start-Environment und pinnt stille Altwerte über .env-Edits."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
_load_env(PROJECT_ROOT / ".env")
|
||||
|
||||
# ── Nebenläufigkeit ───────────────────────────────────────────────────────────
|
||||
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # CLI-Prozesse (~310 MB RSS)
|
||||
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28")) # direkte API, ~0 RAM
|
||||
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20")) # unter x % frei warten CLI-Spawns
|
||||
OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5")) # Session-DB-Kollisionen
|
||||
|
||||
# ── Infra-Fehler: nie fail-open ───────────────────────────────────────────────
|
||||
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Call, dann Lauf-PAUSE
|
||||
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
|
||||
HEDGE_NACH_S = 90 # Slot ohne Ergebnis nach max(dies, timeout/2) → Zwilling; 0 = aus
|
||||
|
||||
# ── Budget ────────────────────────────────────────────────────────────────────
|
||||
RUN_BUDGET_TOKENS = int(os.getenv("RUN_BUDGET_TOKENS", "0")) # in+out pro Lauf; 0 = aus
|
||||
|
||||
# ── Ebene 0: Korpus & Soll ────────────────────────────────────────────────────
|
||||
RECHERCHE_LENSES = ("curriculum", "lehrbuch", "syllabus", "uebungen") # 1 Agent je Lens/Runde
|
||||
RECHERCHE_RUNDEN_MAX = 3 # Sättigungsloop-Cap (Themen)
|
||||
SOLL_MIN_BELEGE = 2 # Punkt bestätigt ab so vielen unabhängigen Quellen
|
||||
SOLL_CHUNK_CHARS = 40_000 # Quelltext pro Soll-Extraktions-Call
|
||||
# Themenband der Soll-Punkte: k = c·√(Kandidaten), weiches Band [0.7k, 1.3k] (Lektion 80).
|
||||
# Gemessen (aak): ohne Band faltete der Konsens 254 Atome auf 7 Punkte → Kapitel ohne
|
||||
# Leitstruktur, Grundlagen mischten sich in Spezialthemen.
|
||||
SOLL_PUNKTE_PER_SQRT = 1.0
|
||||
SOLL_PUNKTE_MIN = 5
|
||||
|
||||
# ── Ebene 1: Inventar ─────────────────────────────────────────────────────────
|
||||
ABSCHNITT_CHARS = 12_000 # lost-in-the-middle-Guard
|
||||
READER_JE_ABSCHNITT = 2 # unabhängige Reader (Konsens entsteht über Dedup, nicht Union)
|
||||
ANKER_OVERLAP_MERGE = 0.5 # Span-Überlappung ab der zwei Atome automatisch mergen
|
||||
# Fuzzy-Anker-Stufe 4 (Lektion 83, Hypothes.is-Muster exakt→locker→fuzzy):
|
||||
FUZZY_MIN_ZEICHEN = 40 # kürzere Zitate nie fuzzy (alnum-gefaltet nicht mehr eindeutig)
|
||||
FUZZY_FEHLERQUOTE = 0.08 # max. Edit-Distanz als Anteil der Zitatlänge
|
||||
MERGE_PANEL = 2 # Judges pro Merge-Kandidat — Merge nur einstimmig
|
||||
MERGE_KANDIDAT_COS = 0.75 # Definitions-Ähnlichkeit ab der ein Paar zum Panel geht
|
||||
MERGE_KANDIDAT_JACCARD = 0.3 # Schwelle im lexikalischen Fallback (misst viel niedriger als Kosinus)
|
||||
LUECKEN_RUNDEN_MAX = 2 # gezielte Nachextraktion pro Soll-Lücke
|
||||
|
||||
# ── Ebene 2: Artefakte ────────────────────────────────────────────────────────
|
||||
ARTEFAKT_CHUNK_ATOME = 8 # Atome pro Generate-Call (ein Soll-Punkt, gestückelt)
|
||||
VERIFY_PANEL = 2 # unabhängige Prüfer, einstimmig = verifiziert
|
||||
|
||||
# ── Ebene 3: Struktur ─────────────────────────────────────────────────────────
|
||||
BAUSTEIN_MIN_ATOME = 4
|
||||
BAUSTEIN_MAX_ATOME = 8
|
||||
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
|
||||
|
||||
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
|
||||
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
||||
|
||||
# ── Auto-Loop ─────────────────────────────────────────────────────────────────
|
||||
LOOP_MAX_ITER = 10
|
||||
|
||||
# ── Timeouts je Schritt: (Basis-Sekunden, Sekunden pro Item) ─────────────────
|
||||
TIMEOUTS = {
|
||||
"recherche": (900, 0),
|
||||
"soll": (300, 0),
|
||||
"soll_konsens": (300, 2),
|
||||
"extraktion": (450, 0),
|
||||
"merge": (150, 8),
|
||||
"soll_zuordnung": (300, 8),
|
||||
"luecke": (450, 0),
|
||||
"artefakt": (450, 20),
|
||||
"verify": (200, 10),
|
||||
"fix": (300, 15),
|
||||
"ziele": (300, 6),
|
||||
"kapitel": (200, 2),
|
||||
"zyklus": (150, 0),
|
||||
"writer": (450, 60),
|
||||
"pruefer": (600, 5),
|
||||
"qa_judge": (600, 0),
|
||||
}
|
||||
|
||||
|
||||
def timeout_fuer(step: str, n: int = 0) -> int:
|
||||
base, per = TIMEOUTS[step]
|
||||
return int(base + per * n)
|
||||
|
||||
|
||||
# ── Provider-Stacks (unabhängig; jeder kann jederzeit fehlen) ─────────────────
|
||||
# Rollen: quick = Massenarbeit (Extraktion), judge = kalte Urteile,
|
||||
# guide = große Generierung (Writer), fast = Interaktion/Leichtes.
|
||||
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
|
||||
PROVIDERS = {
|
||||
"claude": {
|
||||
"cli": "claude",
|
||||
"guide": "claude-opus-4-8[1m]",
|
||||
"fast": "claude-sonnet-4-6",
|
||||
"judge": "claude-sonnet-4-6",
|
||||
"quick": "claude-sonnet-4-6",
|
||||
"env_key": None,
|
||||
},
|
||||
"minimax": {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||
# native Route für Judges: der kalt-Endpoint stallte 20 % der Judge-Calls (Vorgänger, 2026-07-04)
|
||||
"judge": "minimax/MiniMax-M3",
|
||||
"quick": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||
"env_key": "MINIMAX_API_KEY",
|
||||
},
|
||||
"lokal": {
|
||||
"cli": "opencode",
|
||||
"guide": "ollama/qwen3.6:27b",
|
||||
"fast": "ollama/qwen3.5:9b",
|
||||
"judge": "ollama/qwen3.5:9b",
|
||||
"quick": "ollama/qwen3.5:9b",
|
||||
"env_key": None,
|
||||
"check_url": "http://localhost:11434/api/tags",
|
||||
},
|
||||
}
|
||||
|
||||
ROLE_ROUTING = {r: os.getenv(f"ROLE_{r.upper()}", "") for r in ("quick", "judge", "guide", "fast")}
|
||||
|
||||
|
||||
def resolve_role(run_provider: str, role: str) -> tuple[str, str]:
|
||||
"""→ (provider, model). Reines Routing; Verfügbarkeits-Fallback macht agents.run_agent."""
|
||||
target = ROLE_ROUTING.get(role, "") or run_provider
|
||||
provider, _, model = target.partition(":")
|
||||
if provider not in PROVIDERS:
|
||||
provider, model = run_provider, ""
|
||||
if not model:
|
||||
model = PROVIDERS.get(provider, {}).get(role, "")
|
||||
return provider, model
|
||||
190
backend/db.py
Normal file
190
backend/db.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""SQLite-Schicht. Aller Pipeline-Zustand lebt HIER (Resume-Prinzip: jeder Schritt
|
||||
ist idempotent, weil er nur offene Status-Werte weiterverarbeitet).
|
||||
|
||||
Synchronen Zugriff bewusst: DB-Arbeit ist gegen LLM-Latenz vernachlässigbar.
|
||||
`on_change` (von main.py gesetzt) meldet Statuswechsel ans Live-Board."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from config import DB_PATH
|
||||
|
||||
on_change = None # callable(tabelle: str, row: dict) | None — nie fatal
|
||||
|
||||
_lock = threading.RLock()
|
||||
_con: sqlite3.Connection | None = None
|
||||
|
||||
|
||||
def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
|
||||
global _con
|
||||
with _lock:
|
||||
if _con is not None:
|
||||
return _con
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
_con = sqlite3.connect(path, check_same_thread=False)
|
||||
_con.row_factory = sqlite3.Row
|
||||
_con.execute("PRAGMA journal_mode=WAL")
|
||||
_con.execute("PRAGMA foreign_keys=ON")
|
||||
_init_schema(_con)
|
||||
return _con
|
||||
|
||||
|
||||
def reset_for_tests(path: str = ":memory:") -> None:
|
||||
"""Frische DB (Tests): bestehende Verbindung verwerfen, neue öffnen."""
|
||||
global _con
|
||||
with _lock:
|
||||
if _con is not None:
|
||||
_con.close()
|
||||
_con = None
|
||||
connect(path)
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS topics(
|
||||
name TEXT PRIMARY KEY, titel TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('thema','uni')),
|
||||
status TEXT NOT NULL DEFAULT 'neu', provider TEXT NOT NULL DEFAULT '',
|
||||
auto TEXT NOT NULL DEFAULT '{}', erstellt TEXT DEFAULT (datetime('now')));
|
||||
CREATE TABLE IF NOT EXISTS runs(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'running',
|
||||
gestartet TEXT DEFAULT (datetime('now')), beendet TEXT, ebene TEXT DEFAULT '',
|
||||
git_hash TEXT DEFAULT '', git_dirty INTEGER DEFAULT 0, budget_tokens INTEGER DEFAULT 0, grund TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS events(
|
||||
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ts TEXT DEFAULT (datetime('now')),
|
||||
ebene TEXT NOT NULL, stage TEXT NOT NULL, item TEXT DEFAULT '', template TEXT DEFAULT '',
|
||||
template_hash TEXT DEFAULT '', provider TEXT DEFAULT '', model TEXT DEFAULT '', role TEXT DEFAULT '',
|
||||
status TEXT NOT NULL, dur_ms INTEGER DEFAULT 0, wait_ms INTEGER DEFAULT 0,
|
||||
tok_in INTEGER DEFAULT 0, tok_out INTEGER DEFAULT 0, tok_cache_read INTEGER DEFAULT 0,
|
||||
tok_cache_write INTEGER DEFAULT 0, meta TEXT DEFAULT '{}');
|
||||
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id);
|
||||
CREATE TABLE IF NOT EXISTS quellen(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, art TEXT NOT NULL CHECK(art IN ('web','datei')),
|
||||
titel TEXT NOT NULL, url TEXT DEFAULT '', snapshot TEXT NOT NULL, hash TEXT DEFAULT '',
|
||||
runde INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu',
|
||||
rolle TEXT NOT NULL DEFAULT 'stoff');
|
||||
CREATE TABLE IF NOT EXISTS soll(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
|
||||
intro TEXT NOT NULL DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS atome(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL, typ TEXT NOT NULL DEFAULT '',
|
||||
definition TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'M',
|
||||
status TEXT NOT NULL DEFAULT 'neu', soll_id INTEGER, ziel_id INTEGER, baustein_id INTEGER,
|
||||
ord INTEGER DEFAULT 0, merged_into INTEGER, braucht TEXT NOT NULL DEFAULT '[]');
|
||||
CREATE INDEX IF NOT EXISTS idx_atome_topic ON atome(topic);
|
||||
CREATE TABLE IF NOT EXISTS anker(
|
||||
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, quelle_id INTEGER NOT NULL,
|
||||
start INTEGER NOT NULL, ende INTEGER NOT NULL, zitat TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_anker_atom ON anker(atom_id);
|
||||
CREATE TABLE IF NOT EXISTS kanten(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, von_atom INTEGER NOT NULL, zu_atom INTEGER NOT NULL,
|
||||
art TEXT NOT NULL CHECK(art IN ('braucht','verwandt')), status TEXT NOT NULL DEFAULT 'aktiv',
|
||||
UNIQUE(von_atom, zu_atom, art));
|
||||
CREATE TABLE IF NOT EXISTS artefakte(
|
||||
id INTEGER PRIMARY KEY, atom_id INTEGER NOT NULL, typ TEXT NOT NULL CHECK(typ IN ('flashcard','beispiel')),
|
||||
inhalt TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'kandidat');
|
||||
CREATE INDEX IF NOT EXISTS idx_artefakte_atom ON artefakte(atom_id);
|
||||
CREATE TABLE IF NOT EXISTS lernziele(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, text TEXT NOT NULL, soll_id INTEGER,
|
||||
titel TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'neu');
|
||||
CREATE TABLE IF NOT EXISTS bausteine(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, ziel_id INTEGER NOT NULL, titel TEXT NOT NULL,
|
||||
ord INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu', kapitel_id INTEGER);
|
||||
CREATE TABLE IF NOT EXISTS kapitel(
|
||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
||||
art TEXT NOT NULL DEFAULT 'judge');
|
||||
CREATE TABLE IF NOT EXISTS sections(
|
||||
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
||||
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
|
||||
qa_hash TEXT DEFAULT '');
|
||||
CREATE TABLE IF NOT EXISTS leitner(
|
||||
artefakt_id INTEGER PRIMARY KEY, box INTEGER NOT NULL DEFAULT 1,
|
||||
faellig TEXT DEFAULT (date('now')), historie TEXT DEFAULT '[]');
|
||||
CREATE TABLE IF NOT EXISTS befunde(
|
||||
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, ebene TEXT NOT NULL, art TEXT NOT NULL,
|
||||
item TEXT DEFAULT '', detail TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'offen');
|
||||
"""
|
||||
|
||||
# Tabellen, deren Änderungen das Live-Board interessieren.
|
||||
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
|
||||
"lernziele", "bausteine", "kapitel", "sections", "befunde"}
|
||||
|
||||
|
||||
def _init_schema(con: sqlite3.Connection) -> None:
|
||||
con.executescript(_SCHEMA)
|
||||
# Migration für Bestands-DBs: CREATE IF NOT EXISTS ergänzt keine neuen Spalten.
|
||||
for zusatz in ("ALTER TABLE soll ADD COLUMN intro TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE lernziele ADD COLUMN titel TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE quellen ADD COLUMN rolle TEXT NOT NULL DEFAULT 'stoff'",
|
||||
"ALTER TABLE sections ADD COLUMN qa_hash TEXT DEFAULT ''",
|
||||
"ALTER TABLE topics ADD COLUMN auto TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE topics ADD COLUMN resets TEXT NOT NULL DEFAULT '{}'",
|
||||
"ALTER TABLE bausteine ADD COLUMN kapitel_id INTEGER",
|
||||
"ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'"):
|
||||
try:
|
||||
con.execute(zusatz)
|
||||
except sqlite3.OperationalError:
|
||||
pass # Spalte existiert schon
|
||||
con.commit()
|
||||
|
||||
|
||||
def _notify(tabelle: str, row: dict) -> None:
|
||||
if on_change is not None and tabelle in _LIVE_TABELLEN:
|
||||
try:
|
||||
on_change(tabelle, row)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _rowdict(row: sqlite3.Row | None) -> dict | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def query(sql: str, params: tuple = ()) -> list[dict]:
|
||||
con = connect()
|
||||
with _lock:
|
||||
return [dict(r) for r in con.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def one(sql: str, params: tuple = ()) -> dict | None:
|
||||
con = connect()
|
||||
with _lock:
|
||||
return _rowdict(con.execute(sql, params).fetchone())
|
||||
|
||||
|
||||
def execute(sql: str, params: tuple = ()) -> int:
|
||||
"""→ lastrowid. Kein Notify — für Ledger/Interna."""
|
||||
con = connect()
|
||||
with _lock:
|
||||
cur = con.execute(sql, params)
|
||||
con.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def insert(tabelle: str, **felder) -> int:
|
||||
keys = ", ".join(felder)
|
||||
marks = ", ".join("?" * len(felder))
|
||||
rid = execute(f"INSERT INTO {tabelle}({keys}) VALUES({marks})", tuple(felder.values()))
|
||||
_notify(tabelle, {"id": rid, **felder})
|
||||
return rid
|
||||
|
||||
|
||||
def update(tabelle: str, id_feld: str, id_wert, **felder) -> None:
|
||||
sets = ", ".join(f"{k}=?" for k in felder)
|
||||
execute(f"UPDATE {tabelle} SET {sets} WHERE {id_feld}=?", (*felder.values(), id_wert))
|
||||
_notify(tabelle, {id_feld: id_wert, **felder})
|
||||
|
||||
|
||||
def j(obj) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False)
|
||||
|
||||
|
||||
def uj(text: str | None, default=None):
|
||||
if not text:
|
||||
return default if default is not None else []
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return default if default is not None else []
|
||||
67
backend/embedding.py
Normal file
67
backend/embedding.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Ähnlichkeit für Merge-KANDIDATEN — entscheidet nie selbst (das tut das Panel).
|
||||
sentence-transformers wenn vorhanden, sonst lexikalischer Jaccard-Fallback.
|
||||
Verglichen werden DEFINITIONEN, nie Titel (Lektion: Titel-Embeddings rauschen)."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("creator2.embedding")
|
||||
|
||||
_modell = None
|
||||
_geladen = False
|
||||
|
||||
|
||||
def _lade_modell():
|
||||
global _modell, _geladen
|
||||
if _geladen:
|
||||
return _modell
|
||||
_geladen = True
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
_modell = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||
log.info("Embedding-Modell geladen")
|
||||
except Exception:
|
||||
log.warning("kein Embedding-Modell — lexikalischer Jaccard-Fallback"
|
||||
" (findet Paraphrasen-Dubletten deutlich schlechter);"
|
||||
" Fix: pip install sentence-transformers")
|
||||
_modell = None
|
||||
return _modell
|
||||
|
||||
|
||||
_WORT = re.compile(r"[a-zäöüß0-9]+")
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return set(_WORT.findall(text.lower()))
|
||||
|
||||
|
||||
def _jaccard(a: str, b: str) -> float:
|
||||
ta, tb = _tokens(a), _tokens(b)
|
||||
if not ta or not tb:
|
||||
return 0.0
|
||||
return len(ta & tb) / len(ta | tb)
|
||||
|
||||
|
||||
def aehnlichkeit_matrix(texte: list[str]) -> list[list[float]]:
|
||||
"""Paarweise Ähnlichkeit [0..1]. Symmetrisch, Diagonale 1."""
|
||||
modell = _lade_modell()
|
||||
n = len(texte)
|
||||
if modell is not None:
|
||||
import numpy as np
|
||||
emb = modell.encode(texte, normalize_embeddings=True)
|
||||
return (emb @ emb.T).tolist()
|
||||
return [[1.0 if i == j else _jaccard(texte[i], texte[j]) for j in range(n)] for i in range(n)]
|
||||
|
||||
|
||||
def kandidaten_paare(texte: list[str], floor: float,
|
||||
fallback_floor: float | None = None) -> list[tuple[int, int, float]]:
|
||||
"""Alle Index-Paare mit Ähnlichkeit ≥ Schwelle, absteigend sortiert.
|
||||
Im Jaccard-Fallback gilt fallback_floor: echte Paraphrasen messen dort nur
|
||||
~0.3–0.5 — die Kosinus-Schwelle ließe sie alle durchrutschen (aak Lauf 8:
|
||||
13 Panel-Calls bei 26 überlebenden Dubletten-Gruppen)."""
|
||||
m = aehnlichkeit_matrix(texte)
|
||||
if _modell is None and fallback_floor is not None:
|
||||
floor = fallback_floor
|
||||
paare = [(i, j, m[i][j]) for i in range(len(texte)) for j in range(i + 1, len(texte))
|
||||
if m[i][j] >= floor]
|
||||
return sorted(paare, key=lambda p: -p[2])
|
||||
245
backend/fake_agents.py
Normal file
245
backend/fake_agents.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Deterministische Fake-Antworten auf run_agent-Ebene (CREATOR_FAKE_AGENTS=1):
|
||||
kompletter Generierungspfad in Sekunden, ohne LLM. Die Fakes erkennen das Template
|
||||
am Marker `<!-- template:Name -->` und LESEN den Prompt (Zitate müssen wörtlich im
|
||||
gelieferten Text stehen, ids müssen existieren) — sonst testet der E2E-Pfad nichts."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from agents import AgentErgebnis
|
||||
|
||||
# Die Fake-Welt: drei Atome, zwei Soll-Punkte, eine Voraussetzungskette.
|
||||
SATZ_DFA = "Ein deterministischer endlicher Automat (DFA) besteht aus Zuständen und Übergängen."
|
||||
SATZ_POTENZ = "Die Potenzmengenkonstruktion wandelt einen NFA in einen DFA um."
|
||||
SATZ_PUMPING = "Das Pumping-Lemma beweist, dass eine Sprache nicht regulär ist."
|
||||
FAKE_TEXT = f"{SATZ_DFA}\n\n{SATZ_POTENZ}\n\n{SATZ_PUMPING}\n"
|
||||
|
||||
ATOME = [
|
||||
{"titel": "Deterministischer endlicher Automat", "typ": "begriff",
|
||||
"definition": SATZ_DFA, "zitat": SATZ_DFA, "level": "E", "braucht": []},
|
||||
{"titel": "Potenzmengenkonstruktion", "typ": "verfahren",
|
||||
"definition": SATZ_POTENZ, "zitat": SATZ_POTENZ, "level": "M",
|
||||
"braucht": ["Deterministischer endlicher Automat"]},
|
||||
{"titel": "Pumping-Lemma", "typ": "aussage",
|
||||
"definition": SATZ_PUMPING, "zitat": SATZ_PUMPING, "level": "S",
|
||||
"braucht": ["Deterministischer endlicher Automat"]},
|
||||
]
|
||||
PUNKTE = [("Endliche Automaten", (SATZ_DFA, SATZ_POTENZ)),
|
||||
("Nachweis von Nicht-Regularität", (SATZ_PUMPING,))]
|
||||
|
||||
_TEMPLATE = re.compile(r"<!--\s*template:([\w-]+)\s*-->")
|
||||
|
||||
|
||||
def _text_nach(prompt: str, marke: str) -> str:
|
||||
pos = prompt.find(marke)
|
||||
return prompt[pos + len(marke):] if pos >= 0 else ""
|
||||
|
||||
|
||||
def _ids(text: str) -> list[int]:
|
||||
return [int(m) for m in re.findall(r"^(\d+):", text, re.MULTILINE)]
|
||||
|
||||
|
||||
def _ok(daten) -> AgentErgebnis:
|
||||
return AgentErgebnis(0, json.dumps(daten, ensure_ascii=False), "",
|
||||
{"input": 100, "output": 50})
|
||||
|
||||
|
||||
async def antwort(key: str, prompt: str, capabilities: str) -> AgentErgebnis:
|
||||
m = _TEMPLATE.search(prompt)
|
||||
name = m.group(1) if m else ""
|
||||
fn = _HANDLER.get(name)
|
||||
if fn is None:
|
||||
return AgentErgebnis(1, "", f"fake: kein Handler für Template {name!r}")
|
||||
return _ok(fn(prompt))
|
||||
|
||||
|
||||
def _recherche(prompt: str):
|
||||
lens = re.search(r"\(Lens\): \*\*(\w+)\*\*", prompt)
|
||||
lens = lens.group(1) if lens else "x"
|
||||
inhalt = FAKE_TEXT + f"\nHinweis der Lens {lens}: " + "Übung macht den Meister. " * 40
|
||||
return [{"titel": f"Fake-Quelle {lens}", "url": f"https://fake/{lens}", "inhalt": inhalt}]
|
||||
|
||||
|
||||
def _soll(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
return [{"punkt": p, "zitat": s[0]} for p, s in PUNKTE if s[0] in text]
|
||||
|
||||
|
||||
def _soll_konsens(prompt: str):
|
||||
zeilen = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "KANDIDATEN:"), re.MULTILINE)
|
||||
gruppen: dict[str, list[int]] = {}
|
||||
for i, punkt in zeilen:
|
||||
gruppen.setdefault(punkt.strip(), []).append(int(i))
|
||||
return [{"punkt": p, "kandidaten": ids} for p, ids in gruppen.items()]
|
||||
|
||||
|
||||
def _soll_beleg(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
for _, saetze in PUNKTE:
|
||||
if saetze[0] in text and saetze[0] in prompt:
|
||||
return {"zitat": saetze[0]}
|
||||
return {"zitat": ""}
|
||||
|
||||
|
||||
def _extraktion(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
return [a for a in ATOME if a["zitat"] in text]
|
||||
|
||||
|
||||
def _merge(prompt: str):
|
||||
paare = re.findall(r"PAAR (\d+):\nA\(id \d+\): ([^—]+)—.*?\nB\(id \d+\): ([^—]+)—",
|
||||
prompt, re.DOTALL)
|
||||
return [{"paar": int(n), "gleich": a.strip() == b.strip()} for n, a, b in paare]
|
||||
|
||||
|
||||
def _zuordnung_map(prompt: str) -> dict[str, int]:
|
||||
soll = re.findall(r"^(\d+): (.+)$", _text_nach(prompt, "SOLL-PUNKTE"), re.MULTILINE)
|
||||
out = {}
|
||||
for i, punkt in soll:
|
||||
out[punkt.strip()] = int(i)
|
||||
return out
|
||||
|
||||
|
||||
def _soll_zuordnung(prompt: str):
|
||||
punkte = _zuordnung_map(prompt)
|
||||
atome = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME"), re.MULTILINE)
|
||||
out = []
|
||||
for i, titel in atome:
|
||||
t = titel.lower()
|
||||
punkt = ("Nachweis von Nicht-Regularität" if "pumping" in t else "Endliche Automaten")
|
||||
out.append({"atom": int(i), "soll": punkte.get(punkt, next(iter(punkte.values()), 0))})
|
||||
return out
|
||||
|
||||
|
||||
def _soll_stich(prompt: str):
|
||||
punkte = _zuordnung_map(prompt)
|
||||
return {"soll": next(iter(punkte.values()), "fremd")}
|
||||
|
||||
|
||||
def _anker_fix(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
for a in ATOME:
|
||||
if a["titel"] in prompt and a["zitat"] in text:
|
||||
return {"zitat": a["zitat"]}
|
||||
return {"zitat": ""}
|
||||
|
||||
|
||||
def _anker_fix_batch(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
zeilen = re.findall(r"^(\d+): (.+?) —", _text_nach(prompt, "ATOME:"), re.MULTILINE)
|
||||
out = []
|
||||
for i, titel in zeilen:
|
||||
for a in ATOME:
|
||||
if a["titel"] == titel.strip() and a["zitat"] in text:
|
||||
out.append({"atom": int(i), "zitat": a["zitat"]})
|
||||
return out
|
||||
|
||||
|
||||
def _luecke(prompt: str):
|
||||
text = _text_nach(prompt, "QUELLTEXT:")
|
||||
return [{k: a[k] for k in ("titel", "typ", "definition", "zitat")}
|
||||
for a in ATOME if a["zitat"] in text]
|
||||
|
||||
|
||||
def _artefakt_generate(prompt: str):
|
||||
ids = [int(i) for i in re.findall(r"ATOM (\d+):", prompt)]
|
||||
out = []
|
||||
for i in ids:
|
||||
out += [{"atom": i, "typ": "flashcard", "frage": f"Was besagt Atom {i}?",
|
||||
"antwort": "Siehe Beleg — deterministische Fake-Antwort."},
|
||||
{"atom": i, "typ": "flashcard", "frage": f"Wozu dient Atom {i}?",
|
||||
"antwort": "Zum Lernen — deterministische Fake-Antwort."},
|
||||
{"atom": i, "typ": "beispiel", "text": f"Beispiel zu Atom {i}: Schritt 1, Schritt 2."}]
|
||||
return out
|
||||
|
||||
|
||||
def _artefakt_verify(prompt: str):
|
||||
ids = [int(i) for i in re.findall(r"ARTEFAKT (\d+)", prompt)]
|
||||
return [{"artefakt": i, "ok": True, "mangel": ""} for i in ids]
|
||||
|
||||
|
||||
def _artefakt_fix(prompt: str):
|
||||
return {"frage": "Fix-Frage?", "antwort": "Fix-Antwort.", "text": "Fix-Beispiel."}
|
||||
|
||||
|
||||
def _lernziele(prompt: str):
|
||||
punkt = re.search(r"Soll-Punkt: „(.+?)“", prompt) or re.search(r'Soll-Punkt: „(.+?)"', prompt)
|
||||
name = punkt.group(1) if punkt else "das Thema"
|
||||
ids = _ids(_text_nach(prompt, "ATOME:"))
|
||||
return [{"ziel": f"Kann {name} anwenden und erklären", "titel": name, "atome": ids}]
|
||||
|
||||
|
||||
def _kapitel_intro(prompt: str):
|
||||
punkt = re.search(r"Kapitel „(.+?)“", prompt) or re.search(r'Kapitel „(.+?)"', prompt)
|
||||
return {"intro": f"Dieses Kapitel behandelt {punkt.group(1) if punkt else 'das Thema'}"
|
||||
" in aufeinander aufbauenden Abschnitten."}
|
||||
|
||||
|
||||
def _kapitel_schnitt(prompt: str):
|
||||
ids = [int(i) for i in re.findall(r"^(\d+): ", _text_nach(prompt, "BAUSTEINE:"),
|
||||
re.MULTILINE)]
|
||||
grenzen = [ids[min(i + 1, len(ids) - 1)] for i in range(0, len(ids), 2)]
|
||||
return [{"titel": f"Themenblock {n + 1}", "bis": g}
|
||||
for n, g in enumerate(dict.fromkeys(grenzen))]
|
||||
|
||||
|
||||
def _zyklus(prompt: str):
|
||||
m = re.search(r"^(\d+)→(\d+):", _text_nach(prompt, "ZYKLUS:"), re.MULTILINE)
|
||||
return {"von": int(m.group(1)), "zu": int(m.group(2))} if m else {}
|
||||
|
||||
|
||||
def _writer(prompt: str):
|
||||
ziel = re.search(r"Lernziel: (.+)", prompt)
|
||||
marker = re.findall(r"MARKER \(exakt so übernehmen\): (<!-- atom: \d+ \| (.+?) \| [EMS] -->)",
|
||||
prompt)
|
||||
defs = re.findall(r"Definition: (.+)", prompt)
|
||||
fragen = re.findall(r"^- (.+\?)$", prompt, re.MULTILINE)[:2]
|
||||
lo = int(re.search(r"hat (\d+)–", prompt).group(1)) if re.search(r"hat (\d+)–", prompt) else 40
|
||||
absaetze = [f"Ziel dieser Einheit: {ziel.group(1) if ziel else 'Lernen'}. Formal: $n \\ge 0$."]
|
||||
fuellung = "Daran anknüpfend gehen wir das Schritt für Schritt am Beispiel durch. " * 5
|
||||
for (mk, titel), d in zip(marker, defs):
|
||||
absaetze.append(f"#### {titel} verstehen\n{mk}\n{d} {fuellung}")
|
||||
absaetze.append("**Kernpunkte:**\n" + "\n".join(f"- {t} sitzt" for _, t in marker))
|
||||
if fragen:
|
||||
absaetze.append("**Prüfe dich:**\n" + "\n".join(f"- {f}" for f in fragen))
|
||||
lang = "\n\n".join(absaetze)
|
||||
while len(lang.split()) < lo:
|
||||
lang += "\nNoch ein Übungssatz zur Vertiefung des Gelernten."
|
||||
return {"kompakt": f"- Kurzfassung in {len(marker)} Schritten\n- alles Wesentliche sitzt",
|
||||
"lang": lang}
|
||||
|
||||
|
||||
def _pruefer(prompt: str):
|
||||
return {"befunde": []}
|
||||
|
||||
|
||||
def _fix(prompt: str):
|
||||
kompakt = _text_nach(prompt, "SECTION (kompakt):").split("SECTION (lang):")[0].strip()
|
||||
lang = _text_nach(prompt, "SECTION (lang):").split("Regeln:")[0].strip()
|
||||
return {"kompakt": kompakt, "lang": lang}
|
||||
|
||||
|
||||
def _qa_falsch(prompt: str):
|
||||
return []
|
||||
|
||||
|
||||
def _qa_falsch_check(prompt: str):
|
||||
nummern = [int(i) for i in re.findall(r"^(\d+):", _text_nach(prompt, "CLAIMS:"), re.MULTILINE)]
|
||||
return [{"claim": i, "falsch": False} for i in nummern]
|
||||
|
||||
|
||||
_HANDLER = {
|
||||
"Korpus-Recherche": _recherche, "Korpus-Soll": _soll,
|
||||
"Korpus-Soll-Konsens": _soll_konsens, "Korpus-Soll-Beleg": _soll_beleg,
|
||||
"Atom-Extraktion": _extraktion, "Atom-Extraktion-Aufgaben": _extraktion,
|
||||
"Atom-Merge": _merge,
|
||||
"Atom-Soll-Zuordnung": _soll_zuordnung, "Atom-Soll-Stich": _soll_stich,
|
||||
"Atom-Anker-Fix": _anker_fix, "Atom-Anker-Fix-Batch": _anker_fix_batch,
|
||||
"Atom-Luecke": _luecke, "Soll-Abgedeckt": lambda p: {"abgedeckt": True},
|
||||
"Artefakt-Generate": _artefakt_generate, "Artefakt-Verify": _artefakt_verify,
|
||||
"Artefakt-Fix": _artefakt_fix, "Lernziele": _lernziele, "Kapitel-Intro": _kapitel_intro,
|
||||
"Kapitel-Schnitt": _kapitel_schnitt,
|
||||
"Kanten-Zyklus": _zyklus,
|
||||
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
|
||||
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
||||
}
|
||||
408
backend/guide.py
Normal file
408
backend/guide.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""Ebene 4: Guide. Eine Section pro Baustein, Stages seriell je Karte:
|
||||
writer → pruefer → fix → done (persistiert in sections.stage, resume-fähig).
|
||||
|
||||
Der Writer bekommt die komplette Faktenbasis INLINE (Definitionen, Anker-Zitate,
|
||||
verifizierte Beispiele — Lektion 48) und muss Marker als unsichtbare Invariante
|
||||
setzen (Lektion 57). Der Prüfer ist EIN verschmolzener Call (Fakten+Coverage,
|
||||
Lektion 52); deterministische Checks laufen davor im Code."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import db
|
||||
import llm
|
||||
import textkit
|
||||
from config import SECTION_WOERTER_PRO_ATOM
|
||||
|
||||
log = logging.getLogger("creator2.guide")
|
||||
|
||||
EBENE = "guide"
|
||||
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
||||
|
||||
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
|
||||
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
|
||||
KRITISCH = ("marker_fehlend", "ziel_ohne_anker", "fachlich_falsch")
|
||||
|
||||
|
||||
def _bausteine(topic: str) -> list[dict]:
|
||||
return db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
|
||||
|
||||
def _atome_von(baustein_id: int) -> list[dict]:
|
||||
return db.query("SELECT * FROM atome WHERE baustein_id=? AND status NOT IN"
|
||||
" ('gemerged','verworfen') ORDER BY ord", (baustein_id,))
|
||||
|
||||
|
||||
def _zitate(atom_id: int, max_n: int = 3) -> list[str]:
|
||||
rows = db.query("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT ?",
|
||||
(atom_id, max_n))
|
||||
return [r["zitat"] for r in rows]
|
||||
|
||||
|
||||
def _beispiel(atom_id: int) -> str:
|
||||
row = db.one("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='beispiel'"
|
||||
" AND status='verifiziert' LIMIT 1", (atom_id,))
|
||||
return db.uj(row["inhalt"], {}).get("text", "") if row else ""
|
||||
|
||||
|
||||
def _fragen_pool(atom_id: int, max_n: int = 2) -> list[str]:
|
||||
rows = db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||
" AND status='verifiziert' LIMIT ?", (atom_id, max_n))
|
||||
return [f for r in rows if (f := db.uj(r["inhalt"], {}).get("frage", "").strip())]
|
||||
|
||||
|
||||
def _atom_paket(a: dict) -> str:
|
||||
zitate = "\n".join(f"> {z}" for z in _zitate(a["id"])) or "(kein Zitat)"
|
||||
beispiel = _beispiel(a["id"])
|
||||
teil = (f"MARKER (exakt so übernehmen): <!-- atom: {a['id']} | {a['titel']} | {a['level']} -->\n"
|
||||
f"Definition: {a['definition']}\nBelege (VERBATIM zitierbar):\n{zitate}")
|
||||
if beispiel:
|
||||
teil += f"\nVerifiziertes Beispiel (Werte wörtlich übernehmen):\n{beispiel}"
|
||||
fragen = _fragen_pool(a["id"])
|
||||
if fragen:
|
||||
teil += "\nFRAGEN-POOL:\n" + "\n".join(f"- {f}" for f in fragen)
|
||||
return teil
|
||||
|
||||
|
||||
def _laenge_band(n_atome: int) -> tuple[int, int]:
|
||||
lo, hi = SECTION_WOERTER_PRO_ATOM
|
||||
return lo * n_atome, hi * n_atome
|
||||
|
||||
|
||||
# ── Stages ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
||||
atome = _atome_von(b["id"])
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
res = await llm.call(ctx, stage="writer", template="Guide-Writer",
|
||||
werte={"titel": b["titel"], "ziel": ziel["text"],
|
||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||
"min_woerter": lo, "max_woerter": hi},
|
||||
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
|
||||
if res is None:
|
||||
return "writer" # Stage bleibt, nächster Lauf versucht erneut
|
||||
kompakt, lang = str(res.get("kompakt", "")), str(res.get("lang", ""))
|
||||
fehlend = _marker_fehlend(lang, atome)
|
||||
if fehlend: # ein gezielter Zweitversuch mit explizitem Mangel
|
||||
res2 = await llm.call(ctx, stage="writer2", template="Guide-Writer",
|
||||
schritt="writer", role="guide", n=len(atome),
|
||||
item=f"b{b['id']}-2",
|
||||
werte={"titel": b["titel"], "ziel": ziel["text"],
|
||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||
"min_woerter": lo, "max_woerter": hi},
|
||||
erwartet=dict)
|
||||
if res2 and not _marker_fehlend(str(res2.get("lang", "")), atome):
|
||||
kompakt, lang = str(res2.get("kompakt", "")), str(res2.get("lang", ""))
|
||||
db.update("sections", "baustein_id", b["id"], text_kompakt=kompakt, text_lang=lang)
|
||||
return "pruefer"
|
||||
|
||||
|
||||
def _marker_fehlend(text: str, atome: list[dict]) -> list[int]:
|
||||
da = {int(m) for m in _MARKER.findall(text)}
|
||||
return [a["id"] for a in atome if a["id"] not in da]
|
||||
|
||||
|
||||
def _det_auftraege(topic: str, b: dict, lang: str) -> list[str]:
|
||||
"""Deterministische Checks vor dem Judge: Marker, Länge, Vorwärtsverweise."""
|
||||
atome = _atome_von(b["id"])
|
||||
auftraege = [f"KRITISCH: Marker für Atom {i} fehlt — exakt einfügen."
|
||||
for i in _marker_fehlend(lang, atome)]
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
woerter = len(lang.split())
|
||||
if woerter > hi:
|
||||
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
||||
elif woerter < lo:
|
||||
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
|
||||
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel): beim"
|
||||
f" ersten Auftreten mit einem Halbsatz einordnen („… dazu später mehr“)"
|
||||
f" oder entfernen — nie unerklärt verwenden."
|
||||
for t in _vorwaertsverweise(topic, b, lang)]
|
||||
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
|
||||
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
||||
auftraege.append(f"{zitate} Blockquote-Zeile(n) („>“) im Text: Inhalt in eigenen"
|
||||
f" Worten in den Fließtext einarbeiten, Zitat-Format entfernen.")
|
||||
if "6=" in lang:
|
||||
auftraege.append("PDF-Artefakt „6=“ im Text: gemeint ist Ungleichheit —"
|
||||
" durch $\\neq$ ersetzen.")
|
||||
for nr, absatz in enumerate(lang.split("\n\n"), 1):
|
||||
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
|
||||
auftraege.append(f"Absatz {nr}: ungerade Anzahl $-Zeichen — jede Formel"
|
||||
f" braucht öffnendes UND schließendes $ (Beispiel-Fehler:"
|
||||
f" „$[.“ statt „$[$.“).")
|
||||
ohne_mathe = re.sub(r"\$\$[\s\S]*?\$\$|\$[^$\n]*\$", "", lang)
|
||||
nackt = sorted(set(re.findall(
|
||||
r"\\(?:times|Sigma|Gamma|subseteq|neq|leq|geq|cup|cap|mid|forall|exists"
|
||||
r"|mathbb|frac|text|dots|ldots|quad|qquad|bar|setminus)\b", ohne_mathe)))
|
||||
if nackt: # KaTeX rendert nur innerhalb von $…$ — nackte Befehle bleiben Rohtext
|
||||
auftraege.append(f"LaTeX ohne $-Delimiter im Text ({', '.join(nackt[:5])}…):"
|
||||
f" jede Formel vollständig in $…$ bzw. $$…$$ einschließen.")
|
||||
return auftraege
|
||||
|
||||
|
||||
def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
||||
"""Titel von Atomen SPÄTERER KAPITEL im Text (innerhalb eines Kapitels sind
|
||||
Verweise normal — nur Kapitel-Sprünge stören den Lesefluss). Nur signifikante
|
||||
Titel (≥2 Tokens oder ≥6 Zeichen — Lektion 35), Ganzwort, ohne Marker-Zeilen."""
|
||||
kap_ord = {k["id"]: k["ord"] for k in
|
||||
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
||||
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
|
||||
mein_kapitel = je_baustein.get(b["id"], 0)
|
||||
text = textkit.norm(_MARKER.sub("", lang))
|
||||
treffer = []
|
||||
for a in db.query(
|
||||
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
|
||||
" WHERE bb.topic=? AND a.status NOT IN ('gemerged','verworfen')", (topic,)):
|
||||
if je_baustein.get(a["bid"], 0) <= mein_kapitel:
|
||||
continue
|
||||
t = textkit.norm(a["titel"])
|
||||
if len(t) < 6 and len(t.split()) < 2:
|
||||
continue
|
||||
if re.search(rf"(?<![a-zäöüß0-9]){re.escape(t)}(?![a-zäöüß0-9])", text):
|
||||
treffer.append(a["titel"])
|
||||
return treffer
|
||||
|
||||
|
||||
async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
atome = _atome_von(b["id"])
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"])
|
||||
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
||||
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
|
||||
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
|
||||
schritt="pruefer", role="judge", n=len(atome),
|
||||
item=f"b{b['id']}{tag}",
|
||||
werte={"ziel": ziel["text"], "fakten": fakten,
|
||||
"kompakt": sec["text_kompakt"], "lang": sec["text_lang"]},
|
||||
erwartet=dict)
|
||||
for e in (res or {}).get("befunde", []):
|
||||
art = str(e.get("art", "")).strip()
|
||||
detail = str(e.get("detail", "")).strip()
|
||||
if art in ("falsch", "luecke"):
|
||||
auftraege.append(f"KRITISCH ({art}): {detail}")
|
||||
elif art == "stil" and detail:
|
||||
auftraege.append(detail)
|
||||
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
|
||||
return "fix" if auftraege else "done"
|
||||
|
||||
|
||||
async def _stage_fix(ctx: llm.Kontext, b: dict) -> str:
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
auftraege = db.uj(sec["befunde"])
|
||||
if not auftraege:
|
||||
return "done"
|
||||
atome = _atome_von(b["id"])
|
||||
fakten = "\n\n".join(_atom_paket(a) for a in atome)
|
||||
res = await llm.call(ctx, stage="fix", template="Guide-Fix",
|
||||
role="guide", n=len(auftraege), item=f"b{b['id']}",
|
||||
werte={"kompakt": sec["text_kompakt"], "lang": sec["text_lang"],
|
||||
"auftraege": "\n".join(f"- {a}" for a in auftraege),
|
||||
"fakten": fakten},
|
||||
erwartet=dict)
|
||||
kritisch = any(a.startswith("KRITISCH") for a in auftraege)
|
||||
if res:
|
||||
lang = str(res.get("lang", ""))
|
||||
if not _marker_fehlend(lang, atome): # Fix darf die Marker-Invariante nie brechen
|
||||
db.update("sections", "baustein_id", b["id"],
|
||||
text_kompakt=str(res.get("kompakt", "")), text_lang=lang, befunde=db.j([]))
|
||||
if kritisch: # genau EIN Re-Check; Rest fängt die Ebenen-QA
|
||||
await _stage_pruefer(ctx, b, tag="-re")
|
||||
return "done"
|
||||
db.update("sections", "baustein_id", b["id"], befunde=db.j([]))
|
||||
return "done" # Fix fehlgeschlagen → Original behalten (im Zweifel behalten)
|
||||
|
||||
|
||||
_STAGES = {"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
|
||||
|
||||
|
||||
async def _karte(ctx: llm.Kontext, b: dict) -> None:
|
||||
db.execute("INSERT OR IGNORE INTO sections(baustein_id) VALUES(?)", (b["id"],))
|
||||
for _ in range(6): # Stages seriell; Schutz gegen Stage-Schleifen
|
||||
sec = db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
if sec["stage"] == "done":
|
||||
db.update("bausteine", "id", b["id"], status="fertig")
|
||||
return
|
||||
naechste = await _STAGES[sec["stage"]](ctx, b)
|
||||
if naechste == sec["stage"]:
|
||||
return # kein Fortschritt (Writer erschöpft) → Karte offen lassen
|
||||
db.update("sections", "baustein_id", b["id"], stage=naechste)
|
||||
db.update("bausteine", "id", b["id"], status="fertig")
|
||||
|
||||
|
||||
async def _kapitel_intros(ctx: llm.Kontext) -> None:
|
||||
"""Advance Organizer je Kapitel: 2–4 Sätze Landkarte, KEINE Inhalte
|
||||
(Redundancy-Effekt). Idempotent — gefüllte Intros bleiben."""
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (ctx.topic,))}
|
||||
alle = _bausteine(ctx.topic)
|
||||
|
||||
async def einer(kap: dict) -> None:
|
||||
bausteine = [b for b in alle if b["kapitel_id"] == kap["id"]]
|
||||
if not bausteine:
|
||||
return
|
||||
liste = "\n".join(f"- {b['titel']}: {ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for b in bausteine)
|
||||
res = await llm.call(ctx, stage="kapitel", template="Kapitel-Intro",
|
||||
werte={"titel": kap["titel"], "bausteine": liste},
|
||||
role="judge", n=len(bausteine), item=f"k{kap['id']}",
|
||||
erwartet=dict)
|
||||
intro = str((res or {}).get("intro", "")).strip()
|
||||
if intro:
|
||||
db.update("kapitel", "id", kap["id"], intro=intro)
|
||||
|
||||
offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,))
|
||||
await asyncio.gather(*(einer(k) for k in offen))
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig"
|
||||
or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or
|
||||
{"stage": "writer"})["stage"] != "done"]
|
||||
await asyncio.gather(*(_karte(ctx, b) for b in offen))
|
||||
await _kapitel_intros(ctx)
|
||||
|
||||
|
||||
def kapitel_struktur(topic: str) -> list[dict]:
|
||||
"""Kapitel (Struktur-Ebene) entlang der Baustein-Ordnung; die Segmente sind
|
||||
kontiguierlich, also wechselt H2 genau an den Kapitel-Grenzen."""
|
||||
kaps = {k["id"]: k for k in
|
||||
db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))}
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
kapitel: list[dict] = []
|
||||
for b in _bausteine(topic):
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None
|
||||
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
|
||||
k = kaps.get(k_id, {})
|
||||
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
|
||||
"intro": k.get("intro", ""), "sections": []})
|
||||
kapitel[-1]["sections"].append({
|
||||
"baustein": b["id"], "titel": b["titel"],
|
||||
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
||||
"kompakt": sec["text_kompakt"] if sec else "",
|
||||
"lang": sec["text_lang"] if sec else ""})
|
||||
return kapitel
|
||||
|
||||
|
||||
def guide_markdown(topic: str) -> str:
|
||||
t = db.one("SELECT titel FROM topics WHERE name=?", (topic,))
|
||||
teile = [f"# {t['titel'] if t else topic}"]
|
||||
for kap in kapitel_struktur(topic):
|
||||
teile.append(f"## {kap['titel']}")
|
||||
if kap["intro"]:
|
||||
teile.append(kap["intro"])
|
||||
for s in kap["sections"]:
|
||||
if s["lang"]:
|
||||
teile.append(f"### {s['titel']}\n\n{s['lang']}")
|
||||
return "\n\n".join(teile)
|
||||
|
||||
|
||||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _stopfrei(text: str) -> set[str]:
|
||||
stop = {"der", "die", "das", "und", "oder", "kann", "eine", "einen", "für", "von",
|
||||
"mit", "den", "dem", "sich", "auf", "aus", "sind", "wird", "werden", "lernende"}
|
||||
return {t for t in textkit.tokens(text) if len(t) >= 4 and t not in stop}
|
||||
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde = []
|
||||
for b in _bausteine(ctx.topic):
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
if not sec or not sec["text_lang"]:
|
||||
befunde.append({"art": "section_fehlt", "item": str(b["id"]), "detail": b["titel"]})
|
||||
continue
|
||||
lang = sec["text_lang"]
|
||||
atome = _atome_von(b["id"])
|
||||
for i in _marker_fehlend(lang, atome):
|
||||
befunde.append({"art": "marker_fehlend", "item": str(b["id"]),
|
||||
"detail": f"Atom {i}"})
|
||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
|
||||
if ziel:
|
||||
noetig = _stopfrei(ziel["text"])
|
||||
# Writer formuliert per Design frei — Schwelle 1/3 statt 1/2, und
|
||||
# kompakt + Titel zählen mit (Ziel-Wörter erscheinen paraphrasiert).
|
||||
da = textkit.tokens(f"{b['titel']} {sec['text_kompakt']} {lang}")
|
||||
if noetig and len(noetig & da) / len(noetig) < 0.34:
|
||||
befunde.append({"art": "ziel_ohne_anker", "item": str(b["id"]),
|
||||
"detail": ziel["text"][:120]})
|
||||
lo, hi = _laenge_band(len(atome))
|
||||
w = len(lang.split())
|
||||
if not lo <= w <= hi * 1.25: # QA-Band weiter als das Fix-Band (Lektion 75)
|
||||
befunde.append({"art": "laenge", "item": str(b["id"]), "detail": f"{w} Wörter"})
|
||||
for t in _vorwaertsverweise(ctx.topic, b, lang):
|
||||
befunde.append({"art": "vorwaerts", "item": str(b["id"]), "detail": t})
|
||||
return befunde
|
||||
|
||||
|
||||
async def messen_llm(ctx: llm.Kontext) -> list[dict]:
|
||||
"""Fachlich-falsch-Stichprobe: Verdacht + ZWEI unabhängige Bestätiger —
|
||||
nur dreifach bestätigte Claims zählen (Lektion 15). Unveränderte Sections
|
||||
werden übersprungen (qa_hash) — die Prüfung lief sonst jede Repair-Iteration
|
||||
über ALLE Sections (gemessen: 806 QA-Calls für 50 Sections)."""
|
||||
import hashlib
|
||||
ctx.ebene = EBENE
|
||||
befunde = []
|
||||
offene_falsch = {b["item"] for b in db.query(
|
||||
"SELECT item FROM befunde WHERE run_id=? AND ebene=? AND art='fachlich_falsch'"
|
||||
" AND status='offen'", (ctx.run_id, EBENE))}
|
||||
|
||||
async def eine(b: dict) -> None:
|
||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||
if not sec or not sec["text_lang"]:
|
||||
return
|
||||
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
|
||||
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
|
||||
return # unverändert und sauber — kein Re-Check
|
||||
db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, b["id"]))
|
||||
atome = _atome_von(b["id"])
|
||||
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome)
|
||||
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
|
||||
schritt="qa_judge", role="judge", item=f"b{b['id']}",
|
||||
werte={"fakten": fakten, "text": sec["text_lang"]},
|
||||
erwartet=list)
|
||||
claims = [str(c.get("claim", "")).strip() for c in verdacht or []
|
||||
if isinstance(c, dict) and c.get("claim")]
|
||||
if not claims:
|
||||
return
|
||||
stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check",
|
||||
schritt="qa_judge", role="judge", item=f"b{b['id']}",
|
||||
werte={"fakten": fakten,
|
||||
"claims": "\n".join(f"{i + 1}: {c}"
|
||||
for i, c in enumerate(claims))},
|
||||
erwartet=list)
|
||||
for i, claim in enumerate(claims, 1):
|
||||
ja = sum(1 for s in stimmen for e in s
|
||||
if isinstance(e, dict) and e.get("claim") == i and e.get("falsch"))
|
||||
if ja >= 2:
|
||||
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
|
||||
"detail": claim[:200]})
|
||||
|
||||
await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic)))
|
||||
return befunde
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
|
||||
Stil-Befunde → direkt in den Fix (genau ein Rewrite)."""
|
||||
ctx.ebene = EBENE
|
||||
betroffen: dict[int, list[str]] = {}
|
||||
for b in befunde:
|
||||
try:
|
||||
b_id = int(b["item"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
betroffen.setdefault(b_id, []).append(b["art"])
|
||||
for b_id, arten in betroffen.items():
|
||||
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
|
||||
stage = "writer" if "section_fehlt" in arten else "pruefer"
|
||||
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
|
||||
else:
|
||||
auftraege = [f"Behebe: {a}" for a in arten]
|
||||
db.update("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||||
db.update("bausteine", "id", b_id, status="repair")
|
||||
if betroffen:
|
||||
await bauen(ctx)
|
||||
return bool(betroffen)
|
||||
456
backend/inventar.py
Normal file
456
backend/inventar.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""Ebene 1: Inventar. Atome aus dem Korpus extrahieren — mit Verbatim-Anker als
|
||||
Identitätskern. Dedup dreistufig (Lektionen 18/25/31):
|
||||
1. Anker-Überlappung → Auto-Merge (deterministisch, gleiche Quellstelle = gleiches Atom)
|
||||
2. Embedding-Kandidaten auf DEFINITIONEN (nie Titel) + Negations-Guard
|
||||
3. 2er-Judge-Panel, Merge nur EINSTIMMIG; sonst „verwandt"-Kante (Falsch-Merge >> Dublette)
|
||||
Danach Soll-Zuordnung je Atom; Soll-Punkte ohne Atom lösen gezielte Nachextraktion aus."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import db
|
||||
import embedding
|
||||
import korpus
|
||||
import llm
|
||||
import textkit
|
||||
from config import (ANKER_OVERLAP_MERGE, LUECKEN_RUNDEN_MAX, MERGE_KANDIDAT_COS,
|
||||
MERGE_KANDIDAT_JACCARD,
|
||||
MERGE_PANEL, READER_JE_ABSCHNITT, ZIELE_CHUNK_ATOME)
|
||||
|
||||
log = logging.getLogger("creator2.inventar")
|
||||
|
||||
EBENE = "inventar"
|
||||
TYPEN = ("begriff", "aussage", "verfahren")
|
||||
LEVELS = ("E", "M", "S")
|
||||
PAAR_CHUNK = 40 # Merge-Paare pro Judge-Call (Lektion 34/55)
|
||||
|
||||
|
||||
def aktive_atome(topic: str) -> list[dict]:
|
||||
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen') ORDER BY id", (topic,))
|
||||
|
||||
|
||||
# ── Extraktion ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict) -> None:
|
||||
# Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset),
|
||||
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion.
|
||||
schon = db.one(
|
||||
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
|
||||
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
|
||||
" LIMIT 1", (ctx.topic, quelle["id"]))
|
||||
if schon:
|
||||
db.update("quellen", "id", quelle["id"], status="atome")
|
||||
return
|
||||
text = korpus.quelltext(quelle)
|
||||
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
|
||||
template = ("Atom-Extraktion-Aufgaben" if quelle.get("rolle") == "aufgaben"
|
||||
else "Atom-Extraktion")
|
||||
|
||||
async def reader(offset: int, chunk: str, r: int) -> None:
|
||||
res = await llm.call(ctx, stage="extraktion", template=template,
|
||||
werte={"topic": ctx.topic, "quelle": quelle["titel"], "text": chunk},
|
||||
role="quick", item=f"q{quelle['id']}-o{offset}-r{r}", erwartet=list)
|
||||
for a in res or []:
|
||||
titel = str(a.get("titel", "")).strip()
|
||||
definition = str(a.get("definition", "")).strip()
|
||||
zitat = str(a.get("zitat", "")).strip()
|
||||
typ = str(a.get("typ", "")).strip().lower()
|
||||
level = str(a.get("level", "M")).strip().upper()
|
||||
if not titel or not definition or typ not in TYPEN:
|
||||
continue
|
||||
span = textkit.finde_zitat(chunk, zitat)
|
||||
braucht = [str(t).strip() for t in a.get("braucht", []) if str(t).strip()]
|
||||
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
||||
definition=definition,
|
||||
level=level if level in LEVELS else "M",
|
||||
status="neu" if span else "ohne_anker",
|
||||
braucht=db.j(braucht))
|
||||
if span:
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
|
||||
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
||||
elif zitat:
|
||||
# Fehlgeschlagenes Zitat NICHT wegwerfen (start=-1 = unverankert):
|
||||
# der Repair matcht es erst deterministisch neu, LLM nur als Fallback.
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=quelle["id"],
|
||||
start=-1, ende=-1, zitat=zitat)
|
||||
|
||||
stuecke = textkit.abschnitte(text)
|
||||
await asyncio.gather(*(reader(off, chunk, r)
|
||||
for off, chunk in stuecke
|
||||
for r in range(READER_JE_ABSCHNITT)))
|
||||
db.update("quellen", "id", quelle["id"], status="atome")
|
||||
|
||||
|
||||
# ── Dedup ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _wurzel(atom_id: int) -> int:
|
||||
"""merged_into-Kette bis zum lebenden Atom folgen (Merge-Ketten im selben Lauf)."""
|
||||
for _ in range(20):
|
||||
row = db.one("SELECT merged_into FROM atome WHERE id=?", (atom_id,))
|
||||
if not row or not row["merged_into"]:
|
||||
return atom_id
|
||||
atom_id = row["merged_into"]
|
||||
return atom_id
|
||||
|
||||
|
||||
def _merge(topic: str, gewinner: int, verlierer: int) -> None:
|
||||
"""Anker + braucht wandern zum Gewinner; der Verlierer bleibt als gemerged sichtbar."""
|
||||
gewinner, verlierer = _wurzel(gewinner), _wurzel(verlierer)
|
||||
if gewinner == verlierer:
|
||||
return
|
||||
g = db.one("SELECT * FROM atome WHERE id=?", (gewinner,))
|
||||
v = db.one("SELECT * FROM atome WHERE id=?", (verlierer,))
|
||||
if not g or not v or g["status"] == "verworfen" or v["status"] == "verworfen":
|
||||
return
|
||||
db.execute("UPDATE anker SET atom_id=? WHERE atom_id=?", (gewinner, verlierer))
|
||||
braucht = sorted(set(db.uj(g["braucht"]) + db.uj(v["braucht"])))
|
||||
db.update("atome", "id", gewinner, braucht=db.j(braucht))
|
||||
db.update("atome", "id", verlierer, status="gemerged", merged_into=gewinner)
|
||||
# Kanten einzeln umhängen: ein blindes UPDATE kollidiert mit dem UNIQUE-Index,
|
||||
# wenn der Gewinner dieselbe Kante schon hat; Selbstkanten entstehen beim
|
||||
# Merge zweier verbundener Atome — beide Fälle werden gelöscht statt verschoben.
|
||||
for k in db.query("SELECT * FROM kanten WHERE von_atom=? OR zu_atom=?",
|
||||
(verlierer, verlierer)):
|
||||
von = gewinner if k["von_atom"] == verlierer else k["von_atom"]
|
||||
zu = gewinner if k["zu_atom"] == verlierer else k["zu_atom"]
|
||||
doppel = db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art=?"
|
||||
" AND id!=?", (von, zu, k["art"], k["id"]))
|
||||
if von == zu or doppel:
|
||||
db.execute("DELETE FROM kanten WHERE id=?", (k["id"],))
|
||||
else:
|
||||
db.execute("UPDATE kanten SET von_atom=?, zu_atom=? WHERE id=?",
|
||||
(von, zu, k["id"]))
|
||||
|
||||
|
||||
def _anker_dedup(topic: str) -> None:
|
||||
"""Gleiche Quellstelle = gleiches Atom. Deterministisch, kein Judge (Lektion: Anker
|
||||
ist die Identität). Gewinner = längere Definition."""
|
||||
atome = aktive_atome(topic)
|
||||
anker = {a["id"]: db.query("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a["id"],))
|
||||
for a in atome}
|
||||
je_def = {a["id"]: a["definition"] for a in atome}
|
||||
ids = [a["id"] for a in atome if anker[a["id"]]]
|
||||
for i, a_id in enumerate(ids):
|
||||
for b_id in ids[i + 1:]:
|
||||
if db.one("SELECT status FROM atome WHERE id=?", (a_id,))["status"] == "gemerged":
|
||||
break
|
||||
if db.one("SELECT status FROM atome WHERE id=?", (b_id,))["status"] == "gemerged":
|
||||
continue
|
||||
passt = any(ka["quelle_id"] == kb["quelle_id"]
|
||||
and textkit.ueberlappung((ka["start"], ka["ende"]),
|
||||
(kb["start"], kb["ende"])) >= ANKER_OVERLAP_MERGE
|
||||
for ka in anker[a_id] for kb in anker[b_id])
|
||||
if passt:
|
||||
gew, ver = (a_id, b_id) if len(je_def[a_id]) >= len(je_def[b_id]) else (b_id, a_id)
|
||||
_merge(topic, gew, ver)
|
||||
|
||||
|
||||
def _geprueft(a: int, b: int) -> bool:
|
||||
lo, hi = min(a, b), max(a, b)
|
||||
return db.one("SELECT id FROM kanten WHERE von_atom=? AND zu_atom=? AND art='verwandt'",
|
||||
(lo, hi)) is not None
|
||||
|
||||
|
||||
async def _judge_dedup(ctx: llm.Kontext) -> None:
|
||||
"""Kandidaten (Definitions-Ähnlichkeit ≥ Floor ODER gleicher Titel-Kern,
|
||||
gleiche Negationsmenge) ans 2er-Panel. Einstimmig gleich → Merge. Alles
|
||||
andere → „verwandt" (persistiert, damit dasselbe Paar nie zweimal Tokens
|
||||
kostet). Titel-Kern-Paare sind deterministisch — Dubletten aus
|
||||
verschiedenen Quellen haben disjunkte Anker und paraphrasierte
|
||||
Definitionen, das Embedding allein übersah sie (aak Lauf 8: 26 Gruppen)."""
|
||||
atome = aktive_atome(topic := ctx.topic)
|
||||
if len(atome) < 2:
|
||||
return
|
||||
je_kern: dict[str, list[int]] = {}
|
||||
for idx, a in enumerate(atome):
|
||||
if kern := textkit.titel_kern(a["titel"]):
|
||||
je_kern.setdefault(kern, []).append(idx)
|
||||
titel_paare = [(g[x], g[y], 1.0) for g in je_kern.values() if len(g) > 1
|
||||
for x in range(len(g)) for y in range(x + 1, len(g))]
|
||||
paare = embedding.kandidaten_paare([a["definition"] for a in atome],
|
||||
MERGE_KANDIDAT_COS, MERGE_KANDIDAT_JACCARD)
|
||||
offen = []
|
||||
gesehen: set[tuple[int, int]] = set()
|
||||
for i, j, cos in titel_paare + paare:
|
||||
a, b = atome[i], atome[j]
|
||||
if (a["id"], b["id"]) in gesehen:
|
||||
continue
|
||||
gesehen.add((a["id"], b["id"]))
|
||||
if textkit.negations_menge(a["definition"]) != textkit.negations_menge(b["definition"]):
|
||||
continue # Antonyme messen 0.91–0.95 (Lektion 31)
|
||||
if _geprueft(a["id"], b["id"]):
|
||||
continue
|
||||
offen.append((a, b))
|
||||
if not offen:
|
||||
return
|
||||
|
||||
async def chunk_urteil(chunk: list) -> None:
|
||||
liste = "\n".join(
|
||||
f"PAAR {n}:\nA(id {a['id']}): {a['titel']} — {a['definition']}\n"
|
||||
f" Beleg A: {_erster_anker(a['id'])}\n"
|
||||
f"B(id {b['id']}): {b['titel']} — {b['definition']}\n"
|
||||
f" Beleg B: {_erster_anker(b['id'])}"
|
||||
for n, (a, b) in enumerate(chunk, 1))
|
||||
stimmen = await llm.panel(ctx, MERGE_PANEL, stage="merge", template="Atom-Merge",
|
||||
werte={"paare": liste}, role="judge",
|
||||
n=len(chunk), item=f"c{chunk[0][0]['id']}", erwartet=list)
|
||||
for n, (a, b) in enumerate(chunk, 1):
|
||||
urteile = []
|
||||
for stimme in stimmen:
|
||||
for e in stimme:
|
||||
if isinstance(e, dict) and e.get("paar") == n:
|
||||
urteile.append(bool(e.get("gleich")))
|
||||
break
|
||||
if len(urteile) == MERGE_PANEL and all(urteile):
|
||||
gew, ver = ((a["id"], b["id"]) if len(a["definition"]) >= len(b["definition"])
|
||||
else (b["id"], a["id"]))
|
||||
_merge(topic, gew, ver)
|
||||
else:
|
||||
lo, hi = min(a["id"], b["id"]), max(a["id"], b["id"])
|
||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art, status)"
|
||||
" VALUES(?,?,?,'verwandt','nein')", (topic, lo, hi))
|
||||
|
||||
chunks = [offen[i:i + PAAR_CHUNK] for i in range(0, len(offen), PAAR_CHUNK)]
|
||||
await asyncio.gather(*(chunk_urteil(c) for c in chunks))
|
||||
|
||||
|
||||
def _erster_anker(atom_id: int) -> str:
|
||||
row = db.one("SELECT zitat FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (atom_id,))
|
||||
return (row["zitat"][:300] if row else "(kein Anker)")
|
||||
|
||||
|
||||
def _kanten_aufloesen(topic: str) -> None:
|
||||
"""braucht-Titel → Atom-IDs (Norm-Gleichheit; Unauflösbares fällt still weg)."""
|
||||
atome = aktive_atome(topic)
|
||||
je_norm = {textkit.norm(a["titel"]): a["id"] for a in atome}
|
||||
for a in atome:
|
||||
for titel in db.uj(a["braucht"]):
|
||||
ziel = je_norm.get(textkit.norm(titel))
|
||||
if ziel and ziel != a["id"]:
|
||||
db.execute("INSERT OR IGNORE INTO kanten(topic, von_atom, zu_atom, art)"
|
||||
" VALUES(?,?,?,'braucht')", (topic, a["id"], ziel))
|
||||
|
||||
|
||||
# ── Soll-Zuordnung ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None:
|
||||
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
|
||||
if not punkte:
|
||||
return
|
||||
atome = [a for a in aktive_atome(ctx.topic) if not (nur_offene and a["soll_id"])]
|
||||
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
|
||||
gueltig = {p["id"] for p in punkte}
|
||||
|
||||
async def chunk_zuordnen(chunk: list) -> None:
|
||||
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in chunk)
|
||||
res = await llm.call(ctx, stage="soll_zuordnung", template="Atom-Soll-Zuordnung",
|
||||
werte={"soll": soll_liste, "atome": liste}, role="judge",
|
||||
n=len(chunk), item=f"z{chunk[0]['id']}", erwartet=list)
|
||||
je_atom = {e.get("atom"): e.get("soll") for e in res or [] if isinstance(e, dict)}
|
||||
for a in chunk:
|
||||
sid = je_atom.get(a["id"])
|
||||
if sid in gueltig:
|
||||
db.update("atome", "id", a["id"], soll_id=sid)
|
||||
|
||||
chunks = [atome[i:i + ZIELE_CHUNK_ATOME] for i in range(0, len(atome), ZIELE_CHUNK_ATOME)]
|
||||
await asyncio.gather(*(chunk_zuordnen(c) for c in chunks))
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='extrahiert'", (ctx.topic,))
|
||||
await asyncio.gather(*(_extrahiere_quelle(ctx, q) for q in offene))
|
||||
_anker_dedup(ctx.topic)
|
||||
await _judge_dedup(ctx)
|
||||
_kanten_aufloesen(ctx.topic)
|
||||
await _soll_zuordnen(ctx)
|
||||
|
||||
|
||||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde = []
|
||||
atome = aktive_atome(ctx.topic)
|
||||
belegte_soll = set()
|
||||
for a in atome:
|
||||
if a["status"] == "ohne_anker" or not db.one(
|
||||
"SELECT id FROM anker WHERE atom_id=? AND start>=0 LIMIT 1", (a["id"],)):
|
||||
befunde.append({"art": "atom_ohne_anker", "item": str(a["id"]), "detail": a["titel"]})
|
||||
if not a["soll_id"]:
|
||||
befunde.append({"art": "atom_ohne_soll", "item": str(a["id"]), "detail": a["titel"]})
|
||||
else:
|
||||
belegte_soll.add(a["soll_id"])
|
||||
for p in db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
|
||||
if p["id"] not in belegte_soll:
|
||||
befunde.append({"art": "soll_ohne_atom", "item": str(p["id"]), "detail": p["punkt"]})
|
||||
return befunde
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
|
||||
async def einer(b: dict) -> bool:
|
||||
try:
|
||||
item = int(b["item"])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if b["art"] == "atom_ohne_soll":
|
||||
return await _soll_stichentscheid(ctx, item)
|
||||
if b["art"] == "soll_ohne_atom":
|
||||
return await _luecke_schliessen(ctx, item)
|
||||
return False
|
||||
|
||||
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
|
||||
# Lauf-Tokens); Rest PARALLEL (Semaphoren deckeln die echte Last).
|
||||
anker_ids = [int(b["item"]) for b in befunde
|
||||
if b["art"] == "atom_ohne_anker" and str(b["item"]).isdigit()]
|
||||
bewegt = await _anker_fixen_batch(ctx, anker_ids) if anker_ids else False
|
||||
rest = [b for b in befunde if b["art"] != "atom_ohne_anker"]
|
||||
bewegt |= any(await llm.alle(einer(b) for b in rest))
|
||||
if bewegt:
|
||||
_anker_dedup(ctx.topic)
|
||||
await _judge_dedup(ctx)
|
||||
_kanten_aufloesen(ctx.topic)
|
||||
await _soll_zuordnen(ctx)
|
||||
return bewegt
|
||||
|
||||
|
||||
ANKER_FIX_CHUNK = 20 # Atome pro Batch-Call (ein Quelltext-Abschnitt trägt viele Fixes)
|
||||
|
||||
|
||||
async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
|
||||
"""Stufe 1 deterministisch (kostenlos): gespeicherte, unverankerte Zitate
|
||||
(start=-1) mit der toleranten Suche gegen alle Quellen neu matchen.
|
||||
Stufe 2 gebündelt: EIN Call je (Quelltext-Abschnitt × Atom-Chunk) statt
|
||||
Atom×Quelle einzeln (war 83 % der Lauf-Tokens). Ohne Fund → verworfen —
|
||||
ein Atom ohne Quellstelle verletzt das Kernprinzip."""
|
||||
offen: dict[int, dict] = {}
|
||||
for atom_id in atom_ids:
|
||||
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
|
||||
if a and a["status"] not in ("gemerged", "verworfen"):
|
||||
offen[atom_id] = a
|
||||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||
texte = {q["id"]: korpus.quelltext(q) for q in quellen}
|
||||
|
||||
for atom_id in list(offen):
|
||||
for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)):
|
||||
treffer = next(((qid, span) for qid, text in texte.items()
|
||||
if (span := textkit.finde_zitat(text, roh["zitat"]))), None)
|
||||
if treffer:
|
||||
db.update("anker", "id", roh["id"], quelle_id=treffer[0],
|
||||
start=treffer[1][0], ende=treffer[1][1])
|
||||
db.update("atome", "id", atom_id, status="neu")
|
||||
offen.pop(atom_id, None)
|
||||
break
|
||||
|
||||
async def abschnitt_call(q: dict, offset: int, chunk_text: str, ids: list[int]) -> None:
|
||||
liste = "\n".join(f"{i}: {offen[i]['titel']} — {offen[i]['definition']}"
|
||||
for i in ids if i in offen)
|
||||
if not liste:
|
||||
return
|
||||
res = await llm.call(ctx, stage="anker_fix", template="Atom-Anker-Fix-Batch",
|
||||
schritt="fix", role="judge", n=len(ids),
|
||||
item=f"q{q['id']}-o{offset}",
|
||||
werte={"atome": liste, "text": chunk_text}, erwartet=list)
|
||||
for e in res or []:
|
||||
atom_id = e.get("atom")
|
||||
zitat = str(e.get("zitat", "")).strip()
|
||||
if atom_id not in offen or not zitat:
|
||||
continue
|
||||
span = textkit.finde_zitat(texte[q["id"]], zitat)
|
||||
if span:
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
|
||||
start=span[0], ende=span[1], zitat=zitat)
|
||||
db.update("atome", "id", atom_id, status="neu")
|
||||
offen.pop(atom_id, None)
|
||||
|
||||
for q in quellen:
|
||||
if not offen:
|
||||
break
|
||||
for offset, chunk_text in textkit.abschnitte(texte[q["id"]], 40_000):
|
||||
ids = list(offen)
|
||||
if not ids:
|
||||
break
|
||||
await llm.alle(abschnitt_call(q, offset, chunk_text, ids[i:i + ANKER_FIX_CHUNK])
|
||||
for i in range(0, len(ids), ANKER_FIX_CHUNK))
|
||||
for atom_id in offen:
|
||||
db.update("atome", "id", atom_id, status="verworfen")
|
||||
return bool(atom_ids)
|
||||
|
||||
|
||||
async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool:
|
||||
"""2er-Panel entscheidet: Punkt zuordnen oder fremd. Verwerfen (destruktiv) nur
|
||||
einstimmig — im Zweifel behalten (Lektion 67), dann bekommt es den Mehrheits-Punkt."""
|
||||
a = db.one("SELECT * FROM atome WHERE id=?", (atom_id,))
|
||||
if not a or a["status"] in ("gemerged", "verworfen") or a["soll_id"]:
|
||||
return False
|
||||
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
|
||||
soll_liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in punkte)
|
||||
stimmen = await llm.panel(ctx, 2, stage="soll_stich", template="Atom-Soll-Stich",
|
||||
schritt="soll_zuordnung", role="judge", item=f"a{atom_id}",
|
||||
werte={"titel": a["titel"], "definition": a["definition"],
|
||||
"beleg": _erster_anker(atom_id), "soll": soll_liste},
|
||||
erwartet=dict)
|
||||
urteile = [s.get("soll", "fremd") for s in stimmen]
|
||||
if urteile and all(u == "fremd" for u in urteile):
|
||||
db.update("atome", "id", atom_id, status="verworfen")
|
||||
return True
|
||||
gueltig = {p["id"] for p in punkte}
|
||||
for u in urteile:
|
||||
if u in gueltig:
|
||||
db.update("atome", "id", atom_id, soll_id=u)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool:
|
||||
"""Gezielte Nachextraktion an der Belegstelle; nach LUECKEN_RUNDEN_MAX entscheidet
|
||||
ein 2er-Panel, ob der Punkt durch bestehende Atome abgedeckt ist (Freispruch,
|
||||
persistiert — Lektion 69)."""
|
||||
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
|
||||
if not p or p["status"] != "bestaetigt":
|
||||
return False
|
||||
runden = db.one( # bisherige Nachextraktions-Versuche: aus dem Ledger, topic-weit
|
||||
"SELECT COUNT(*) AS n FROM events e JOIN runs r ON e.run_id=r.id"
|
||||
" WHERE r.topic=? AND e.stage='luecke' AND e.item=?", (ctx.topic, f"s{soll_id}"))["n"]
|
||||
if runden >= LUECKEN_RUNDEN_MAX:
|
||||
atome = aktive_atome(ctx.topic)
|
||||
liste = "\n".join(f"- {a['titel']}: {a['definition']}" for a in atome[:200])
|
||||
stimmen = await llm.panel(ctx, 2, stage="luecke_stich", template="Soll-Abgedeckt",
|
||||
schritt="soll_zuordnung", role="judge", item=f"s{soll_id}",
|
||||
werte={"punkt": p["punkt"], "atome": liste}, erwartet=dict)
|
||||
if stimmen and all(bool(s.get("abgedeckt")) for s in stimmen):
|
||||
db.update("soll", "id", soll_id, status="abgedeckt")
|
||||
return True
|
||||
return False
|
||||
belege = db.uj(p["belege"])
|
||||
if not belege:
|
||||
return False
|
||||
q = db.one("SELECT * FROM quellen WHERE id=?", (belege[0]["quelle"],))
|
||||
if not q:
|
||||
return False
|
||||
text = korpus.quelltext(q)
|
||||
pos = textkit.finde_zitat(text, belege[0]["zitat"])
|
||||
fenster = text[max(0, (pos[0] if pos else 0) - 6000):(pos[1] if pos else 0) + 6000]
|
||||
res = await llm.call(ctx, stage="luecke", template="Atom-Luecke",
|
||||
role="quick", item=f"s{soll_id}",
|
||||
werte={"punkt": p["punkt"], "quelle": q["titel"], "text": fenster},
|
||||
erwartet=list)
|
||||
neu = False
|
||||
for a in res or []:
|
||||
titel = str(a.get("titel", "")).strip()
|
||||
definition = str(a.get("definition", "")).strip()
|
||||
zitat = str(a.get("zitat", "")).strip()
|
||||
typ = str(a.get("typ", "")).strip().lower()
|
||||
span = textkit.finde_zitat(fenster, zitat)
|
||||
if not titel or not definition or typ not in TYPEN or span is None:
|
||||
continue
|
||||
offset = text.find(fenster)
|
||||
atom_id = db.insert("atome", topic=ctx.topic, titel=titel, typ=typ,
|
||||
definition=definition, level="M", status="neu",
|
||||
soll_id=soll_id, braucht=db.j([]))
|
||||
db.insert("anker", atom_id=atom_id, quelle_id=q["id"],
|
||||
start=offset + span[0], ende=offset + span[1], zitat=zitat)
|
||||
neu = True
|
||||
return neu
|
||||
53
backend/jsonx.py
Normal file
53
backend/jsonx.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Robustes JSON-Parsen von LLM-Antworten: Modelle liefern Fences, Prosa drumherum
|
||||
oder Präfixe — wir suchen das erste vollständige JSON-Objekt/-Array."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
||||
|
||||
|
||||
def parse(text: str):
|
||||
"""→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry."""
|
||||
if not text:
|
||||
return None
|
||||
m = _FENCE.search(text)
|
||||
if m:
|
||||
text = m.group(1)
|
||||
text = text.strip()
|
||||
# Das FRÜHESTE Klammerzeichen entscheidet: sonst gewinnt ein {…} im Array-Inneren.
|
||||
erste = sorted((("{", "}"), ("[", "]")),
|
||||
key=lambda p: text.find(p[0]) if p[0] in text else len(text))
|
||||
for start_ch, end_ch in erste:
|
||||
start = text.find(start_ch)
|
||||
if start < 0:
|
||||
continue
|
||||
depth = 0
|
||||
in_str = False
|
||||
esc = False
|
||||
for i in range(start, len(text)):
|
||||
c = text[i]
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if c == "\\":
|
||||
esc = in_str
|
||||
continue
|
||||
if c == '"':
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if c == start_ch:
|
||||
depth += 1
|
||||
elif c == end_ch:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
try:
|
||||
return json.loads(text[start:i + 1])
|
||||
except ValueError:
|
||||
break
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
378
backend/korpus.py
Normal file
378
backend/korpus.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Ebene 0: Korpus & Soll. Selbst-verifizierend, null Eingriffspunkte.
|
||||
|
||||
Themen: je Runde ein Recherche-Agent pro Lens (Curriculum/Lehrbuch/Syllabus/Übungen)
|
||||
→ Quellen-Snapshots lokal. Uni: Dateien aus topics/<name>/ werden die Quellen.
|
||||
Danach Soll-Extraktion je Quelle, Konsens deterministisch im Code (thema: Punkt
|
||||
bestätigt ab Belegen aus ≥2 unabhängigen Quellen — Lektion 24; uni: 1 Beleg,
|
||||
das Zitat-Gate reicht), Runden bis Sättigung."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
import db
|
||||
import llm
|
||||
import textkit
|
||||
from config import (KORPUS_DIR, RECHERCHE_LENSES, RECHERCHE_RUNDEN_MAX,
|
||||
SOLL_CHUNK_CHARS, SOLL_MIN_BELEGE, SOLL_PUNKTE_MIN,
|
||||
SOLL_PUNKTE_PER_SQRT, TOPICS_DIR)
|
||||
|
||||
log = logging.getLogger("creator2.korpus")
|
||||
|
||||
EBENE = "korpus"
|
||||
|
||||
|
||||
# Quellen-Rolle: aufgaben-Quellen liefern keine eigenen Konzept-Instanzen als Atome
|
||||
# (Lektion aak: 76 % der Atome kamen aus Aufgabensammlungen — der Guide dozierte
|
||||
# Aufgabentexte). Heuristik am Import; generische Muster, keine Domänen-Regeln.
|
||||
_AUFGABEN_MUSTER = re.compile(
|
||||
r"aufgab|klausur|serie|übung|uebung|präsenz|praesenz|blatt|exercise|exam|sheet|homework",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _rolle(titel: str, lens: str | None = None) -> str:
|
||||
if lens == "uebungen":
|
||||
return "aufgaben"
|
||||
return "aufgaben" if _AUFGABEN_MUSTER.search(titel) else "stoff"
|
||||
|
||||
|
||||
def quelltext(quelle: dict) -> str:
|
||||
try:
|
||||
return Path(quelle["snapshot"]).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
# C0/C1-Kontrollzeichen außer \n und \t: PDF-Schriften mappen Sonderglyphen (ε) auf
|
||||
# Steuerbytes — der Reader echot sie als Müll („ž") und das Zitat wird unmatchbar.
|
||||
# Ersetzung durch LEERZEICHEN muss VOR ftfy laufen: ftfy löscht Steuerzeichen ersatzlos
|
||||
# und verklebt sonst die Nachbarwörter.
|
||||
_KONTROLLZEICHEN = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
||||
|
||||
|
||||
def _text_reparieren(inhalt: str) -> str:
|
||||
"""Byte-Schicht am Import heilen (Lektionen 82/83): Steuerzeichen→Leerzeichen,
|
||||
dann ftfy (Mojibake, Ligaturen fi→fi, C1, NFC). Ohne ftfy → NFC-Fallback —
|
||||
gleiches Muster wie der sentence-transformers-Fallback."""
|
||||
inhalt = _KONTROLLZEICHEN.sub(" ", inhalt)
|
||||
try:
|
||||
import ftfy
|
||||
return ftfy.fix_text(inhalt, normalization="NFC")
|
||||
except ImportError:
|
||||
return unicodedata.normalize("NFC", inhalt)
|
||||
|
||||
|
||||
def _snapshot_schreiben(topic: str, inhalt: str) -> tuple[str, str] | None:
|
||||
"""→ (pfad, hash) oder None bei Duplikat (gleicher Inhalt schon registriert).
|
||||
Text-Reparatur am Import (Lektion 82/83): pdftotext liefert dekomponierte
|
||||
Umlaute, Steuerbytes und Ligaturen — ohne Bereinigung scheitert die
|
||||
Zitat-Suche an unsichtbar anderen Bytes."""
|
||||
inhalt = _text_reparieren(inhalt)
|
||||
h = hashlib.sha256(inhalt.encode()).hexdigest()[:16]
|
||||
if db.one("SELECT id FROM quellen WHERE topic=? AND hash=?", (topic, h)):
|
||||
return None
|
||||
ordner = KORPUS_DIR / topic
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
pfad = ordner / f"q-{h}.md"
|
||||
pfad.write_text(inhalt, encoding="utf-8")
|
||||
return str(pfad), h
|
||||
|
||||
|
||||
def _datei_lesen(pfad: Path) -> str:
|
||||
"""txt/md direkt; PDF via pdftotext (treu, strukturarm — Lektion 38)."""
|
||||
if pfad.suffix.lower() == ".pdf":
|
||||
if shutil.which("pdftotext") is None:
|
||||
log.warning("pdftotext fehlt — %s übersprungen", pfad.name)
|
||||
return ""
|
||||
res = subprocess.run(["pdftotext", "-layout", str(pfad), "-"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
return res.stdout if res.returncode == 0 else ""
|
||||
try:
|
||||
return pfad.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
async def _uni_quellen(ctx: llm.Kontext) -> int:
|
||||
"""Dateien aus topics/<name>/ als Quellen registrieren. → Anzahl neu."""
|
||||
ordner = TOPICS_DIR / ctx.topic
|
||||
neu = 0
|
||||
for pfad in sorted(ordner.glob("*")) if ordner.is_dir() else []:
|
||||
if pfad.suffix.lower() not in (".txt", ".md", ".pdf"):
|
||||
continue
|
||||
# .pdf überspringen, wenn eine gleichnamige .txt daneben liegt (vorkonvertiert)
|
||||
if pfad.suffix.lower() == ".pdf" and pfad.with_suffix(".txt").exists():
|
||||
continue
|
||||
inhalt = _datei_lesen(pfad)
|
||||
if not inhalt.strip():
|
||||
continue
|
||||
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||||
if snap is None:
|
||||
continue
|
||||
db.insert("quellen", topic=ctx.topic, art="datei", titel=pfad.name,
|
||||
snapshot=snap[0], hash=snap[1], status="neu", rolle=_rolle(pfad.name))
|
||||
neu += 1
|
||||
return neu
|
||||
|
||||
|
||||
async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
|
||||
"""Ein full-Agent je Lens. → Anzahl neuer Quellen."""
|
||||
async def eine(lens: str) -> list[dict]:
|
||||
res = await llm.call(ctx, stage="recherche", template="Korpus-Recherche",
|
||||
werte={"topic": ctx.topic, "lens": lens},
|
||||
role="quick", caps="full", item=f"r{runde}-{lens}",
|
||||
erwartet=list)
|
||||
return res or []
|
||||
|
||||
ergebnisse = await asyncio.gather(*(eine(lens) for lens in RECHERCHE_LENSES))
|
||||
neu = 0
|
||||
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
|
||||
for q in quellen:
|
||||
inhalt = str(q.get("inhalt", "")).strip()
|
||||
if len(inhalt) < 500: # leere/dünne Funde sind keine Quelle
|
||||
continue
|
||||
snap = _snapshot_schreiben(ctx.topic, inhalt)
|
||||
if snap is None:
|
||||
continue
|
||||
titel = str(q.get("titel", ""))[:200]
|
||||
db.insert("quellen", topic=ctx.topic, art="web", titel=titel,
|
||||
url=str(q.get("url", ""))[:500], snapshot=snap[0], hash=snap[1],
|
||||
runde=runde, status="neu", rolle=_rolle(titel, lens))
|
||||
neu += 1
|
||||
return neu
|
||||
|
||||
|
||||
async def _soll_extrahieren(ctx: llm.Kontext) -> None:
|
||||
"""Je neue Quelle Soll-Kandidaten ziehen (Quelltext INLINE — Lektion 48)."""
|
||||
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,))
|
||||
|
||||
async def eine(q: dict) -> None:
|
||||
text = quelltext(q)
|
||||
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
|
||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": ctx.topic, "quelle": q["titel"], "text": chunk},
|
||||
role="judge", item=f"q{q['id']}-{i}", erwartet=list)
|
||||
for kand in res or []:
|
||||
punkt = str(kand.get("punkt", "")).strip()
|
||||
zitat = str(kand.get("zitat", "")).strip()
|
||||
if not punkt or textkit.finde_zitat(chunk, zitat) is None:
|
||||
continue # Beleg muss wörtlich in der Quelle stehen
|
||||
db.insert("soll", topic=ctx.topic, punkt=punkt, status="kandidat",
|
||||
belege=db.j([{"quelle": q["id"], "zitat": zitat}]))
|
||||
db.update("quellen", "id", q["id"], status="extrahiert")
|
||||
|
||||
await asyncio.gather(*(eine(q) for q in offene))
|
||||
|
||||
|
||||
def _min_belege(topic: str) -> int:
|
||||
"""uni: Korpus ist vertrauenswürdig und das Zitat-Gate fängt Halluzinationen —
|
||||
1 Beleg genügt (die ≥2-Regel warf Skript-only-Stoff weg, aak: Definition P,
|
||||
FPTAS, Cook-Levin). thema: ≥2 unabhängige Quellen, weil eine einzelne
|
||||
Web-Quelle selbst falsch sein kann."""
|
||||
if db.one("SELECT art FROM topics WHERE name=?", (topic,))["art"] == "uni":
|
||||
return 1
|
||||
n = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (topic,))["n"]
|
||||
return min(SOLL_MIN_BELEGE, max(1, n))
|
||||
|
||||
|
||||
async def _konsens(ctx: llm.Kontext) -> int:
|
||||
"""Kandidaten zu Punkten falten. Der Judge GRUPPIERT nur — die Zählung
|
||||
(≥ min unabhängige Quellen) macht der Code. Untergrenze k = c·√(Kandidaten):
|
||||
ohne sie faltete der Konsens 254 Atome auf 7 Punkte (aak) — zu grob als
|
||||
Kapitel-Ebene. Nach oben offen — eine Obergrenze drückte den Judge dazu,
|
||||
Kandidaten still wegzulassen (aak: LPT-Scheduling fehlte im Soll).
|
||||
Vollständigkeits-Invariante: jede id wird zugeordnet oder explizit
|
||||
abgelehnt; Übrige bekommen Nachrunden. → Anzahl bestätigt."""
|
||||
kandidaten = db.query("SELECT * FROM soll WHERE topic=? AND"
|
||||
" status IN ('kandidat','gefaltet')", (ctx.topic,))
|
||||
if not kandidaten:
|
||||
return 0
|
||||
je_id = {k["id"]: k for k in kandidaten}
|
||||
k_soll = max(SOLL_PUNKTE_MIN, round(SOLL_PUNKTE_PER_SQRT * len(kandidaten) ** 0.5))
|
||||
band = str(max(3, int(0.7 * k_soll)))
|
||||
min_belege = _min_belege(ctx.topic)
|
||||
|
||||
async def falten(kand: list[dict], hinweis: str, tag: str) -> list:
|
||||
liste = "\n".join(f"{k['id']}: {k['punkt']}" for k in kand)
|
||||
res = await llm.call(ctx, stage="soll_konsens", template="Korpus-Soll-Konsens",
|
||||
werte={"topic": ctx.topic, "kandidaten": liste,
|
||||
"band": band, "hinweis": hinweis},
|
||||
role="judge", n=len(kand), item=tag, erwartet=list)
|
||||
return res or []
|
||||
|
||||
punkte: list[dict] = [] # {"punkt": str, "ids": [int]}
|
||||
je_punkt: dict[str, dict] = {}
|
||||
zugeordnet: set[int] = set()
|
||||
abgelehnt: set[int] = set()
|
||||
|
||||
def einsortieren(res: list) -> None:
|
||||
for gruppe in res:
|
||||
if not isinstance(gruppe, dict):
|
||||
continue
|
||||
ids = [i for i in gruppe.get("kandidaten", []) if isinstance(i, int)
|
||||
and i in je_id and i not in zugeordnet and i not in abgelehnt]
|
||||
if not ids:
|
||||
continue
|
||||
if gruppe.get("abgelehnt"):
|
||||
abgelehnt.update(ids)
|
||||
continue
|
||||
zugeordnet.update(ids)
|
||||
punkt = str(gruppe.get("punkt", "")).strip() or je_id[ids[0]]["punkt"]
|
||||
kern = punkt.casefold()
|
||||
if kern in je_punkt:
|
||||
je_punkt[kern]["ids"].extend(ids)
|
||||
else:
|
||||
je_punkt[kern] = {"punkt": punkt, "ids": ids}
|
||||
punkte.append(je_punkt[kern])
|
||||
|
||||
res = await falten(kandidaten, "", "k1")
|
||||
gruppen = sum(1 for g in res if isinstance(g, dict) and not g.get("abgelehnt"))
|
||||
if gruppen < 0.5 * k_soll and len(kandidaten) >= 2 * SOLL_PUNKTE_MIN:
|
||||
nochmal = await falten(
|
||||
kandidaten,
|
||||
f"ACHTUNG: Der letzte Versuch hat zu grob gruppiert ({gruppen} Punkte)."
|
||||
f" Trenne feiner — verschiedene Teilthemen NICHT zusammenfassen.", "k2")
|
||||
if len(nochmal) > len(res):
|
||||
res = nochmal
|
||||
einsortieren(res)
|
||||
|
||||
for runde in (1, 2): # Vollständigkeit: fehlende ids gezielt nachfassen
|
||||
fehlend = [k for i, k in je_id.items()
|
||||
if i not in zugeordnet and i not in abgelehnt]
|
||||
if not fehlend:
|
||||
break
|
||||
vorhanden = "\n".join(f"- {p['punkt']}" for p in punkte)
|
||||
einsortieren(await falten(
|
||||
fehlend,
|
||||
"NACHRUNDE — diese Kandidaten sind noch keinem Punkt zugeordnet."
|
||||
" Ordne JEDEN zu: an einen BESTEHENDEN Punkt (punkt wörtlich"
|
||||
" wiederverwenden) oder als neue Gruppe. Ablehnen nur, wenn es kein"
|
||||
" lernbares Teilthema ist.\nBESTEHENDE PUNKTE:\n" + vorhanden,
|
||||
f"n{runde}"))
|
||||
|
||||
bestaetigt = []
|
||||
for p in punkte:
|
||||
belege = [b for i in p["ids"] for b in db.uj(je_id[i]["belege"])]
|
||||
if len({b["quelle"] for b in belege}) >= min_belege:
|
||||
bestaetigt.append((p, belege))
|
||||
for r in db.query("SELECT id FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
|
||||
db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) # idempotent: neu aufbauen
|
||||
for p, belege in bestaetigt:
|
||||
db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt",
|
||||
belege=db.j(belege))
|
||||
ok_ids = {i for p, _ in bestaetigt for i in p["ids"]}
|
||||
for i, k in je_id.items(): # Buchführung: kein Kandidat verschwindet still
|
||||
status = ("abgelehnt" if i in abgelehnt else
|
||||
"gefaltet" if i in ok_ids else "kandidat")
|
||||
if status != k["status"]:
|
||||
db.update("soll", "id", i, status=status)
|
||||
return len(bestaetigt)
|
||||
|
||||
|
||||
def _rollen_auffrischen(topic: str) -> None:
|
||||
"""Rolle für Bestands-Quellen nachziehen (Reset behält die Zeilen; die Heuristik
|
||||
kam ggf. erst später dazu). Nur Hochstufung stoff→aufgaben — eine Lens-basierte
|
||||
aufgaben-Rolle wird nie zurückgestuft."""
|
||||
for q in db.query("SELECT * FROM quellen WHERE topic=? AND rolle='stoff'", (topic,)):
|
||||
if _rolle(q["titel"]) == "aufgaben":
|
||||
db.update("quellen", "id", q["id"], rolle="aufgaben")
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
_rollen_auffrischen(ctx.topic)
|
||||
topic = db.one("SELECT * FROM topics WHERE name=?", (ctx.topic,))
|
||||
if topic["status"] not in ("neu", "korpus"):
|
||||
return # Ebene fertig — Resume überspringt
|
||||
db.update("topics", "name", ctx.topic, status="korpus")
|
||||
if topic["art"] == "uni":
|
||||
await _uni_quellen(ctx)
|
||||
await _soll_extrahieren(ctx)
|
||||
await _konsens(ctx)
|
||||
else:
|
||||
vorher = -1
|
||||
for runde in range(1, RECHERCHE_RUNDEN_MAX + 1):
|
||||
await _recherche_runde(ctx, runde)
|
||||
await _soll_extrahieren(ctx)
|
||||
jetzt = await _konsens(ctx)
|
||||
if jetzt <= vorher >= 0: # Sättigung: Runde ohne neuen bestätigten Punkt
|
||||
break
|
||||
vorher = jetzt
|
||||
db.update("topics", "name", ctx.topic, status="korpus_fertig") # Auto-Freeze
|
||||
|
||||
|
||||
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
"""Invarianten, deterministisch. → Befunde [{art, item, detail}]."""
|
||||
befunde = []
|
||||
n_quellen = db.one("SELECT COUNT(*) AS n FROM quellen WHERE topic=?", (ctx.topic,))["n"]
|
||||
if n_quellen == 0:
|
||||
befunde.append({"art": "korpus_leer", "item": "", "detail": "keine Quellen"})
|
||||
punkte = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,))
|
||||
if not punkte and n_quellen:
|
||||
befunde.append({"art": "soll_leer", "item": "", "detail": "kein bestätigter Soll-Punkt"})
|
||||
min_belege = _min_belege(ctx.topic)
|
||||
for p in punkte:
|
||||
quellen = {b["quelle"] for b in db.uj(p["belege"])}
|
||||
if len(quellen) < min_belege:
|
||||
befunde.append({"art": "soll_wenig_belege", "item": str(p["id"]),
|
||||
"detail": f"{p['punkt']}: {len(quellen)} < {min_belege}"})
|
||||
if punkte: # Konsens lief: übrige Kandidaten sind unerledigt, kein stiller Verlust
|
||||
for k in db.query("SELECT * FROM soll WHERE topic=? AND status='kandidat'",
|
||||
(ctx.topic,)):
|
||||
befunde.append({"art": "soll_kandidat_offen", "item": str(k["id"]),
|
||||
"detail": k["punkt"]})
|
||||
return befunde
|
||||
|
||||
|
||||
async def _beleg_nachsuchen(ctx: llm.Kontext, soll_id: int) -> bool:
|
||||
"""Gezielter Beleg-Judge in noch nicht zitierten Quellen; ein bestätigter
|
||||
Punkt ohne Fund wird zum Kandidaten zurückgestuft (Konsens-Regel bleibt
|
||||
hart). → True nur, wenn sich etwas geändert hat."""
|
||||
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
|
||||
if not p:
|
||||
return False
|
||||
belegte = {bl["quelle"] for bl in db.uj(p["belege"])}
|
||||
andere = [q for q in db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||
if q["id"] not in belegte]
|
||||
for q in andere:
|
||||
res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg",
|
||||
schritt="soll", role="judge", item=f"s{p['id']}-q{q['id']}",
|
||||
werte={"punkt": p["punkt"],
|
||||
"text": quelltext(q)[:SOLL_CHUNK_CHARS]},
|
||||
erwartet=dict)
|
||||
zitat = str((res or {}).get("zitat", "")).strip()
|
||||
if zitat and textkit.finde_zitat(quelltext(q), zitat) is not None:
|
||||
belege = db.uj(p["belege"]) + [{"quelle": q["id"], "zitat": zitat}]
|
||||
db.update("soll", "id", p["id"], belege=db.j(belege))
|
||||
return True
|
||||
if p["status"] == "bestaetigt":
|
||||
db.update("soll", "id", p["id"], status="kandidat")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
punkte = [int(b["item"]) for b in befunde if b["art"] == "soll_wenig_belege"]
|
||||
offene = [int(b["item"]) for b in befunde if b["art"] == "soll_kandidat_offen"]
|
||||
bewegt = any(await llm.alle(_beleg_nachsuchen(ctx, s) for s in punkte + offene))
|
||||
if offene: # neu falten: die Nachrunden im Konsens erzwingen die Zuordnung
|
||||
await _konsens(ctx)
|
||||
noch = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'",
|
||||
(ctx.topic,))
|
||||
bewegt = bewegt or len(noch) < len(offene)
|
||||
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
|
||||
topic = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))
|
||||
if topic["art"] == "thema":
|
||||
await _recherche_runde(ctx, runde=99)
|
||||
await _soll_extrahieren(ctx)
|
||||
await _konsens(ctx)
|
||||
bewegt = True
|
||||
return bewegt
|
||||
61
backend/ledger.py
Normal file
61
backend/ledger.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Event-Ledger: jeder LLM-Call wird eine append-only-Zeile in `events`.
|
||||
Kennzahlen sind Queries darauf; das Budget zählt live mit."""
|
||||
|
||||
import subprocess
|
||||
|
||||
import db
|
||||
from config import PROJECT_ROOT
|
||||
|
||||
|
||||
class BudgetErschoepft(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def git_stand() -> tuple[str, bool]:
|
||||
"""(hash, dirty) des Repos — fürs Lauf-Protokoll; Fehler → ('', False)."""
|
||||
try:
|
||||
h = subprocess.run(["git", "rev-parse", "HEAD"], cwd=PROJECT_ROOT,
|
||||
capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
d = subprocess.run(["git", "status", "--porcelain"], cwd=PROJECT_ROOT,
|
||||
capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
return h, bool(d)
|
||||
except Exception:
|
||||
return "", False
|
||||
|
||||
|
||||
def log_call(run_id: int, *, ebene: str, stage: str, item: str = "", template: str = "",
|
||||
template_hash: str = "", provider: str = "", model: str = "", role: str = "",
|
||||
status: str, dur_ms: int = 0, wait_ms: int = 0, tokens: dict | None = None,
|
||||
meta: dict | None = None) -> None:
|
||||
t = tokens or {}
|
||||
db.execute(
|
||||
"INSERT INTO events(run_id, ebene, stage, item, template, template_hash, provider,"
|
||||
" model, role, status, dur_ms, wait_ms, tok_in, tok_out, tok_cache_read, tok_cache_write, meta)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(run_id, ebene, stage, item, template, template_hash, provider, model, role, status,
|
||||
dur_ms, wait_ms, int(t.get("input", 0)), int(t.get("output", 0)),
|
||||
int(t.get("cache_read", 0)), int(t.get("cache_write", 0)), db.j(meta or {})))
|
||||
|
||||
|
||||
def verbraucht(run_id: int) -> int:
|
||||
row = db.one("SELECT COALESCE(SUM(tok_in+tok_out),0) AS s FROM events WHERE run_id=?", (run_id,))
|
||||
return int(row["s"]) if row else 0
|
||||
|
||||
|
||||
def budget_pruefen(run_id: int) -> None:
|
||||
"""Nach jedem Call. Hartes Limit: wirft statt weiterzubrennen."""
|
||||
run = db.one("SELECT budget_tokens FROM runs WHERE id=?", (run_id,))
|
||||
limit = int(run["budget_tokens"]) if run else 0
|
||||
if limit and verbraucht(run_id) >= limit:
|
||||
raise BudgetErschoepft(f"Token-Budget erreicht ({limit})")
|
||||
|
||||
|
||||
def kennzahlen(run_id: int) -> list[dict]:
|
||||
return db.query(
|
||||
"SELECT ebene, stage, template, COUNT(*) AS calls,"
|
||||
" SUM(tok_in) AS tok_in, SUM(tok_out) AS tok_out,"
|
||||
" SUM(tok_cache_read) AS cache_read, SUM(dur_ms) AS dur_ms, SUM(wait_ms) AS wait_ms,"
|
||||
" SUM(status='ok') AS ok, SUM(status='timeout') AS timeouts,"
|
||||
" SUM(status IN ('error','infra')) AS fehler"
|
||||
" FROM events WHERE run_id=? GROUP BY ebene, stage, template ORDER BY MIN(id)",
|
||||
(run_id,))
|
||||
161
backend/llm.py
Normal file
161
backend/llm.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Call-Schicht über agents.py: Template-Rendering, JSON-Parsen, Infra-Behandlung,
|
||||
Hedge, Panels, Ledger, Budget.
|
||||
|
||||
Fehlerklassen bewusst getrennt (Lektion 1/2/68):
|
||||
- Inhaltsfehler (ungültiges JSON, leere Antwort) → begrenzte Restarts, dann None.
|
||||
- Infra-Fehler (429/Timeout/Netz) → eigener Zähler mit Backoff, erschöpft → LaufPause
|
||||
(fail-closed: der Lauf pausiert fortsetzbar, statt mit Teilergebnis weiterzulaufen)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import agents
|
||||
import jsonx
|
||||
import ledger
|
||||
import prompts
|
||||
from config import HEDGE_NACH_S, INFRA_BACKOFF_BASE, INFRA_MAX_RETRIES, timeout_fuer
|
||||
|
||||
log = logging.getLogger("creator2.llm")
|
||||
|
||||
MAX_RESTARTS = 2 # inhaltliche Neuversuche pro Call
|
||||
|
||||
|
||||
class LaufPause(Exception):
|
||||
"""Infrastruktur erschöpft — Lauf pausieren, nicht weiterrechnen."""
|
||||
|
||||
|
||||
class Kontext:
|
||||
"""Ein Lauf: wandert durch alle Ebenen, trägt Ledger-Zuordnung."""
|
||||
|
||||
def __init__(self, run_id: int, topic: str, provider: str):
|
||||
self.run_id = run_id
|
||||
self.topic = topic
|
||||
self.provider = provider
|
||||
self.ebene = ""
|
||||
|
||||
|
||||
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out", "connection",
|
||||
"network", "overloaded", "unavailable", "database is locked")
|
||||
|
||||
|
||||
def _ist_infra(err: str) -> bool:
|
||||
e = err.lower()
|
||||
return any(m in e for m in _INFRA_MARKER)
|
||||
|
||||
|
||||
async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
|
||||
role: str, caps: str) -> agents.AgentErgebnis:
|
||||
"""Ein Agent-Call mit Stall-Hedge: läuft der Slot max(HEDGE_NACH_S, timeout/2)
|
||||
ohne Ergebnis, startet genau EIN Zwilling; das erste valide Ergebnis gewinnt."""
|
||||
schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0
|
||||
|
||||
async def einer(k: str):
|
||||
return await agents.run_agent(k, prompt, timeout, provider=ctx.provider,
|
||||
role=role, capabilities=caps)
|
||||
|
||||
haupt = asyncio.ensure_future(einer(key))
|
||||
if not schwelle:
|
||||
return await haupt
|
||||
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
||||
if fertig:
|
||||
return haupt.result()
|
||||
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
|
||||
try:
|
||||
for aufgabe in asyncio.as_completed([haupt, zwilling]):
|
||||
res = await aufgabe
|
||||
if res.ok:
|
||||
return res
|
||||
return res # beide fertig, keins ok → letztes Ergebnis zur Diagnose
|
||||
finally:
|
||||
for t in (haupt, zwilling):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
|
||||
async def call(ctx: Kontext, *, stage: str, template: str, werte: dict,
|
||||
role: str = "judge", caps: str = "none", schritt: str | None = None,
|
||||
n: int = 0, item: str = "", erwartet=dict):
|
||||
"""Ein LLM-Call → geparstes JSON (erwartet: dict|list) oder roher Text (erwartet=str).
|
||||
None nur nach erschöpften inhaltlichen Restarts. Ledger + Budget immer."""
|
||||
prompt, thash = prompts.render(template, **werte)
|
||||
timeout = timeout_fuer(schritt or stage, n)
|
||||
infra_rest = INFRA_MAX_RETRIES
|
||||
inhalt_rest = MAX_RESTARTS
|
||||
versuch = 0
|
||||
while True:
|
||||
versuch += 1
|
||||
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
||||
start = time.monotonic()
|
||||
status, tokens, err, wait_ms = "error", None, "", 0
|
||||
try:
|
||||
res = await _roher_call(key, prompt, timeout, ctx, role, caps)
|
||||
tokens, err = res.tokens, res.err
|
||||
wait_ms = int(res.wait_s * 1000)
|
||||
if res.ok:
|
||||
if erwartet is str:
|
||||
status = "ok"
|
||||
return res.text
|
||||
daten = jsonx.parse(res.text)
|
||||
if daten is not None and isinstance(daten, erwartet):
|
||||
status = "ok"
|
||||
return daten
|
||||
status, err = "parse", f"unparsbar/falscher Typ: {res.text[:200]}"
|
||||
elif _ist_infra(res.err):
|
||||
status = "infra"
|
||||
else:
|
||||
status = "error"
|
||||
err = res.err
|
||||
except asyncio.TimeoutError:
|
||||
status = "infra"
|
||||
err = "timeout"
|
||||
finally:
|
||||
ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=item,
|
||||
template=template, template_hash=thash, role=role,
|
||||
provider=ctx.provider, status=status,
|
||||
dur_ms=int((time.monotonic() - start) * 1000),
|
||||
wait_ms=wait_ms,
|
||||
tokens=tokens, meta={"err": err[:300]} if err else None)
|
||||
ledger.budget_pruefen(ctx.run_id)
|
||||
if status == "infra":
|
||||
infra_rest -= 1
|
||||
if infra_rest < 0:
|
||||
raise LaufPause(f"{stage}: Infrastruktur erschöpft ({err})")
|
||||
pause = INFRA_BACKOFF_BASE * 2 ** (INFRA_MAX_RETRIES - infra_rest - 1)
|
||||
agents.drossel_melden(pause) # global: auch Timeouts/CLI-429 bremsen alle
|
||||
log.warning("%s: Infra-Fehler (%s), Pause %.0fs", key, err[:80], pause)
|
||||
await asyncio.sleep(pause)
|
||||
continue
|
||||
inhalt_rest -= 1
|
||||
if inhalt_rest < 0:
|
||||
log.warning("%s: inhaltlich erschöpft (%s)", key, err[:120])
|
||||
return None
|
||||
log.info("%s: Neuversuch (%s)", key, err[:80])
|
||||
|
||||
|
||||
async def alle(coros) -> list:
|
||||
"""gather-Variante, die bei der ersten Exception (LaufPause/Budget) die
|
||||
Geschwister ABBRICHT — sonst brennen Hunderte laufende Repair-Tasks weiter."""
|
||||
tasks = [asyncio.ensure_future(c) for c in coros]
|
||||
try:
|
||||
return await asyncio.gather(*tasks)
|
||||
except BaseException:
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
raise
|
||||
|
||||
|
||||
async def panel(ctx: Kontext, groesse: int, **kw) -> list:
|
||||
"""`groesse` unabhängige Calls, alle Ergebnisse (None-gefiltert). Fällt genau eine
|
||||
Stimme eines 2er-Panels aus, ersetzt EIN Ersatz-Richter (Lektion 20), dann
|
||||
entscheidet der Aufrufer über Konsens."""
|
||||
item = kw.get("item", "")
|
||||
stimmen = await asyncio.gather(*(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
|
||||
for i in range(groesse)))
|
||||
gueltig = [s for s in stimmen if s is not None]
|
||||
if len(gueltig) == groesse - 1 and groesse >= 2:
|
||||
ersatz = await call(ctx, **{**kw, "item": f"{item}-pE"})
|
||||
if ersatz is not None:
|
||||
gueltig.append(ersatz)
|
||||
return gueltig
|
||||
259
backend/main.py
Normal file
259
backend/main.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""FastAPI-App: REST + WebSocket-Live-Board + statisches Frontend.
|
||||
Der State-Snapshot liefert IMMER alle Karten (kein 20er-Cap) — das Frontend
|
||||
scrollt je Spalte; Deltas kommen über /ws."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
import agents
|
||||
import db
|
||||
import guide
|
||||
import ledger
|
||||
import pipeline
|
||||
from config import FRONTEND_DIST
|
||||
from ws import hub
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("creator2.main")
|
||||
|
||||
app = FastAPI(title="creator2")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"],
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
db.connect()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
db.on_change = hub.push
|
||||
|
||||
|
||||
class TopicNeu(BaseModel):
|
||||
name: str
|
||||
titel: str = ""
|
||||
art: str = "thema" # thema | uni
|
||||
provider: str = ""
|
||||
|
||||
|
||||
class StartAuftrag(BaseModel):
|
||||
budget: int | None = None
|
||||
|
||||
|
||||
class UebenAntwort(BaseModel):
|
||||
artefakt_id: int
|
||||
richtig: bool
|
||||
|
||||
|
||||
class AutoFlag(BaseModel):
|
||||
ebene: str
|
||||
an: bool
|
||||
|
||||
|
||||
def _ebenen_info(t: dict, run: dict | None) -> list[dict]:
|
||||
"""Je Ebene: fertig/laeuft/auto + Tokens (Läufe seit letztem Entfernen)
|
||||
+ Dauer (letzter Lauf)."""
|
||||
flags = db.uj(t["auto"], {})
|
||||
resets = db.uj(t["resets"], {})
|
||||
out = []
|
||||
for name, _, marke in pipeline.EBENEN:
|
||||
rows = db.query(
|
||||
"SELECT SUM(e.tok_in+e.tok_out) AS tok,"
|
||||
" (julianday(MAX(e.ts))-julianday(MIN(e.ts)))*86400 AS dauer"
|
||||
" FROM events e JOIN runs r ON e.run_id=r.id"
|
||||
" WHERE r.topic=? AND e.ebene=? AND e.id>? GROUP BY e.run_id ORDER BY e.run_id",
|
||||
(t["name"], name, resets.get(name, 0)))
|
||||
out.append({
|
||||
"name": name, "fertig": pipeline._fertig_ab(t["status"], marke),
|
||||
"laeuft": bool(run and run["status"] == "running" and run["ebene"] == name),
|
||||
"auto": bool(flags.get(name, True)),
|
||||
"tokens": sum(int(r["tok"] or 0) for r in rows),
|
||||
"dauer_s": int(rows[-1]["dauer"] or 0) if rows else 0,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/topics")
|
||||
def topics_liste():
|
||||
out = []
|
||||
for t in db.query("SELECT * FROM topics ORDER BY erstellt DESC"):
|
||||
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (t["name"],))
|
||||
out.append({**t, "run": run})
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/api/topics")
|
||||
def topic_anlegen(auftrag: TopicNeu):
|
||||
if not auftrag.name.strip():
|
||||
raise HTTPException(400, "Name fehlt")
|
||||
if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)):
|
||||
raise HTTPException(409, "Topic existiert")
|
||||
if auftrag.art not in ("thema", "uni"):
|
||||
raise HTTPException(400, "art muss thema|uni sein")
|
||||
from config import DEFAULT_PROVIDER
|
||||
db.insert("topics", name=auftrag.name, titel=auftrag.titel or auftrag.name,
|
||||
art=auftrag.art, provider=auftrag.provider or DEFAULT_PROVIDER)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{topic}/start")
|
||||
async def lauf_start(topic: str, auftrag: StartAuftrag):
|
||||
# async: lauf_starten hängt den Lauf per ensure_future an DIESEN Event-Loop
|
||||
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
try:
|
||||
run_id = pipeline.lauf_starten(topic, auftrag.budget)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(409, str(e))
|
||||
return {"run_id": run_id}
|
||||
|
||||
|
||||
@app.post("/api/topics/{topic}/stop")
|
||||
async def lauf_stop(topic: str):
|
||||
pipeline.lauf_stoppen(topic)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/topics/{topic}/state")
|
||||
def state(topic: str):
|
||||
t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
|
||||
if not t:
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
run = db.one("SELECT * FROM runs WHERE topic=? ORDER BY id DESC", (topic,))
|
||||
atome = db.query("SELECT * FROM atome WHERE topic=? ORDER BY id", (topic,))
|
||||
fuer_atom = {}
|
||||
for a in db.query(
|
||||
"SELECT atom_id, SUM(status='verifiziert') AS ok, COUNT(*) AS n FROM artefakte"
|
||||
" WHERE atom_id IN (SELECT id FROM atome WHERE topic=?) GROUP BY atom_id", (topic,)):
|
||||
fuer_atom[a["atom_id"]] = {"verifiziert": a["ok"], "gesamt": a["n"]}
|
||||
return {
|
||||
"topic": t, "run": run,
|
||||
"ebenen": _ebenen_info(t, run),
|
||||
"verbraucht": ledger.verbraucht(run["id"]) if run else 0,
|
||||
"quellen": db.query("SELECT id, art, titel, url, runde, status, rolle FROM quellen"
|
||||
" WHERE topic=? ORDER BY id", (topic,)),
|
||||
# Kandidaten nur zeigen, solange der Konsens noch nichts bestätigt hat
|
||||
"soll": db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
|
||||
" ORDER BY id", (topic,))
|
||||
or db.query("SELECT * FROM soll WHERE topic=? ORDER BY id", (topic,)),
|
||||
"atome": [{**a, "artefakte": fuer_atom.get(a["id"], {"verifiziert": 0, "gesamt": 0})}
|
||||
for a in atome],
|
||||
"lernziele": db.query("SELECT * FROM lernziele WHERE topic=? ORDER BY id", (topic,)),
|
||||
"bausteine": db.query(
|
||||
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
|
||||
" WHERE b.topic=? ORDER BY b.ord", (topic,)),
|
||||
"befunde": db.query(
|
||||
"SELECT * FROM befunde WHERE run_id=? AND status='offen' ORDER BY id",
|
||||
(run["id"],)) if run else [],
|
||||
"agenten": agents.aktive_agenten(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/topics/{topic}/guide")
|
||||
def guide_holen(topic: str):
|
||||
# kein markdown-Feld: das Frontend rendert aus kapitel; Roh-Markdown
|
||||
# verdoppelte nur die Payload (aak: +0,43 MB ungenutzt)
|
||||
return {"kapitel": guide.kapitel_struktur(topic)}
|
||||
|
||||
|
||||
@app.post("/api/topics/{topic}/soll-reset")
|
||||
async def soll_reset(topic: str):
|
||||
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
pipeline.soll_reset(topic)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{topic}/voll-reset")
|
||||
async def voll_reset(topic: str):
|
||||
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
pipeline.voll_reset(topic)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.patch("/api/topics/{topic}/auto")
|
||||
async def auto_setzen(topic: str, flag: AutoFlag):
|
||||
t = db.one("SELECT auto FROM topics WHERE name=?", (topic,))
|
||||
if not t:
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
flags = db.uj(t["auto"], {})
|
||||
flags[flag.ebene] = flag.an
|
||||
db.update("topics", "name", topic, auto=db.j(flags))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/topics/{topic}/ebene/{ebene}/entfernen")
|
||||
async def ebene_entfernen(topic: str, ebene: str):
|
||||
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
try:
|
||||
pipeline.ebenen_entfernen(topic, ebene)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/topics/{topic}")
|
||||
async def topic_loeschen(topic: str):
|
||||
if not db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||
raise HTTPException(404, "unbekanntes Topic")
|
||||
pipeline.topic_loeschen(topic)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/topics/{topic}/ueben")
|
||||
def ueben_faellig(topic: str):
|
||||
rows = db.query(
|
||||
"SELECT ar.id, ar.inhalt, a.titel, a.level, COALESCE(l.box,1) AS box,"
|
||||
" COALESCE(l.faellig, date('now')) AS faellig"
|
||||
" FROM artefakte ar JOIN atome a ON ar.atom_id=a.id"
|
||||
" LEFT JOIN leitner l ON l.artefakt_id=ar.id"
|
||||
" WHERE a.topic=? AND ar.typ='flashcard' AND ar.status='verifiziert'"
|
||||
" AND COALESCE(l.faellig, date('now')) <= date('now') ORDER BY box, ar.id", (topic,))
|
||||
return [{**r, "inhalt": db.uj(r["inhalt"], {})} for r in rows]
|
||||
|
||||
|
||||
_INTERVALLE = {1: 0, 2: 1, 3: 3, 4: 7, 5: 14} # Leitner: Tage bis zur Wiedervorlage
|
||||
|
||||
|
||||
@app.post("/api/ueben/antwort")
|
||||
def ueben_antwort(a: UebenAntwort):
|
||||
row = db.one("SELECT * FROM leitner WHERE artefakt_id=?", (a.artefakt_id,))
|
||||
box = min(5, (row["box"] if row else 1) + 1) if a.richtig else 1
|
||||
tage = _INTERVALLE[box]
|
||||
if row:
|
||||
db.execute("UPDATE leitner SET box=?, faellig=date('now', ?) WHERE artefakt_id=?",
|
||||
(box, f"+{tage} day", a.artefakt_id))
|
||||
else:
|
||||
db.execute("INSERT INTO leitner(artefakt_id, box, faellig) VALUES(?,?,date('now', ?))",
|
||||
(a.artefakt_id, box, f"+{tage} day"))
|
||||
return {"box": box}
|
||||
|
||||
|
||||
@app.get("/api/runs/{run_id}/kennzahlen")
|
||||
def kennzahlen(run_id: int):
|
||||
return {"zeilen": ledger.kennzahlen(run_id), "verbraucht": ledger.verbraucht(run_id)}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket(ws: WebSocket):
|
||||
await hub.connect(ws)
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text() # Client sendet nichts Relevantes; hält die Verbindung
|
||||
except WebSocketDisconnect:
|
||||
hub.disconnect(ws)
|
||||
|
||||
|
||||
if FRONTEND_DIST.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(FRONTEND_DIST / "index.html")
|
||||
198
backend/pipeline.py
Normal file
198
backend/pipeline.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Orchestrierung: fünf Ebenen strikt seriell, je Ebene bauen → QA → Repair-Loop
|
||||
bis 100 %/Stillstand/Limit. Resume ist der Normalfall: der Fortschritt liegt
|
||||
komplett in der DB (topics.status + Item-Status), ein neuer Start überspringt
|
||||
Fertiges. Infra-Erschöpfung pausiert den Lauf (fail-closed), Budget stoppt hart."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import agents
|
||||
import artefakte
|
||||
import auto_loop
|
||||
import db
|
||||
import guide
|
||||
import inventar
|
||||
import korpus
|
||||
import ledger
|
||||
import llm
|
||||
import qa
|
||||
import struktur
|
||||
from config import RUN_BUDGET_TOKENS
|
||||
|
||||
log = logging.getLogger("creator2.pipeline")
|
||||
|
||||
EBENEN = [
|
||||
("korpus", korpus, "korpus_fertig"),
|
||||
("inventar", inventar, "inventar_fertig"),
|
||||
("artefakte", artefakte, "artefakte_fertig"),
|
||||
("struktur", struktur, "struktur_fertig"),
|
||||
("guide", guide, "fertig"),
|
||||
]
|
||||
_ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_fertig",
|
||||
"artefakte_fertig", "struktur_fertig", "fertig"]
|
||||
_laeufe: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def _fertig_ab(status: str, marke: str) -> bool:
|
||||
try:
|
||||
return _ORDNUNG.index(status) >= _ORDNUNG.index(marke)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def lauf_starten(topic: str, budget: int | None = None) -> int:
|
||||
if topic in _laeufe and not _laeufe[topic].done():
|
||||
raise RuntimeError("Lauf läuft bereits")
|
||||
agents.abbruch_aufheben(f"{topic}-")
|
||||
git_hash, dirty = ledger.git_stand()
|
||||
run_id = db.insert("runs", topic=topic, status="running", git_hash=git_hash,
|
||||
git_dirty=int(dirty),
|
||||
budget_tokens=budget if budget is not None else RUN_BUDGET_TOKENS)
|
||||
_laeufe[topic] = asyncio.ensure_future(_lauf(topic, run_id))
|
||||
return run_id
|
||||
|
||||
|
||||
def lauf_stoppen(topic: str) -> None:
|
||||
agents.abbrechen(f"{topic}-")
|
||||
task = _laeufe.get(topic)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
run = db.one("SELECT id FROM runs WHERE topic=? AND status='running'"
|
||||
" ORDER BY id DESC", (topic,))
|
||||
if run:
|
||||
db.update("runs", "id", run["id"], status="stopped", beendet="jetzt")
|
||||
|
||||
|
||||
_ROLLBACK = {"guide": "struktur_fertig", "struktur": "artefakte_fertig",
|
||||
"artefakte": "inventar_fertig", "inventar": "korpus_fertig", "korpus": "neu"}
|
||||
|
||||
|
||||
def ebenen_entfernen(topic: str, ebene: str) -> None:
|
||||
"""Ergebnisse einer Ebene UND aller nachgelagerten verwerfen (rechts hängt an
|
||||
links), Topic-Status entsprechend zurückrollen. Quellen-Snapshots bleiben,
|
||||
außer bei korpus."""
|
||||
if ebene not in _ROLLBACK:
|
||||
raise ValueError(f"unbekannte Ebene: {ebene}")
|
||||
lauf_stoppen(topic)
|
||||
stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene)
|
||||
db.execute("DELETE FROM sections WHERE baustein_id IN"
|
||||
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
|
||||
db.execute("UPDATE bausteine SET status='neu' WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE kapitel SET intro='' WHERE topic=?", (topic,)) # Intro ist Guide-Text
|
||||
if stufe >= 1: # struktur
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
|
||||
(topic,))
|
||||
if stufe >= 2: # artefakte
|
||||
db.execute("DELETE FROM leitner WHERE artefakt_id IN (SELECT ar.id FROM artefakte ar"
|
||||
" JOIN atome a ON ar.atom_id=a.id WHERE a.topic=?)", (topic,))
|
||||
db.execute("DELETE FROM artefakte WHERE atom_id IN"
|
||||
" (SELECT id FROM atome WHERE topic=?)", (topic,))
|
||||
if stufe >= 3: # inventar
|
||||
db.execute("DELETE FROM anker WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)",
|
||||
(topic,))
|
||||
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE quellen SET status='extrahiert' WHERE topic=?", (topic,))
|
||||
if stufe >= 4: # korpus
|
||||
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM quellen WHERE topic=?", (topic,))
|
||||
import shutil
|
||||
from config import KORPUS_DIR
|
||||
shutil.rmtree(KORPUS_DIR / topic, ignore_errors=True)
|
||||
# Token-/Dauer-Anzeige der entfernten Ebenen nullen: Events bleiben (Ledger ist
|
||||
# append-only), aber die Zählung beginnt hinter dem Reset-Marker neu.
|
||||
letzte = db.one("SELECT MAX(id) AS m FROM events")["m"] or 0
|
||||
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
|
||||
for name in ["guide", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]:
|
||||
resets[name] = letzte
|
||||
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))
|
||||
|
||||
|
||||
def topic_loeschen(topic: str) -> None:
|
||||
lauf_stoppen(topic)
|
||||
ebenen_entfernen(topic, "korpus")
|
||||
for run in db.query("SELECT id FROM runs WHERE topic=?", (topic,)):
|
||||
db.execute("DELETE FROM events WHERE run_id=?", (run["id"],))
|
||||
db.execute("DELETE FROM befunde WHERE run_id=?", (run["id"],))
|
||||
db.execute("DELETE FROM runs WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM topics WHERE name=?", (topic,))
|
||||
|
||||
|
||||
def voll_reset(topic: str) -> None:
|
||||
"""Alles Generierte verwerfen (auch Atome + Artefakte); Quellen-Snapshots und
|
||||
Lauf-Historie bleiben. Für Neuextraktion nach Extraktions-Regeländerungen."""
|
||||
lauf_stoppen(topic)
|
||||
soll_reset(topic)
|
||||
db.execute("DELETE FROM leitner WHERE artefakt_id IN (SELECT ar.id FROM artefakte ar"
|
||||
" JOIN atome a ON ar.atom_id=a.id WHERE a.topic=?)", (topic,))
|
||||
db.execute("DELETE FROM artefakte WHERE atom_id IN"
|
||||
" (SELECT id FROM atome WHERE topic=?)", (topic,))
|
||||
db.execute("DELETE FROM anker WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)",
|
||||
(topic,))
|
||||
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
|
||||
db.update("topics", "name", topic, status="neu")
|
||||
|
||||
|
||||
def soll_reset(topic: str) -> None:
|
||||
"""Soll/Struktur/Guide verwerfen, Atome + Artefakte behalten. Der nächste Lauf
|
||||
baut das Soll neu; der Extraktions-Guard in inventar verhindert Doppel-Atome."""
|
||||
lauf_stoppen(topic)
|
||||
db.execute("DELETE FROM sections WHERE baustein_id IN"
|
||||
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM lernziele WHERE topic=?", (topic,))
|
||||
db.execute("DELETE FROM soll WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE atome SET soll_id=NULL, ziel_id=NULL, baustein_id=NULL, ord=0"
|
||||
" WHERE topic=?", (topic,))
|
||||
db.execute("UPDATE quellen SET status='neu' WHERE topic=?", (topic,))
|
||||
db.update("topics", "name", topic, status="korpus")
|
||||
|
||||
|
||||
async def _lauf(topic: str, run_id: int) -> None:
|
||||
t = db.one("SELECT * FROM topics WHERE name=?", (topic,))
|
||||
ctx = llm.Kontext(run_id, topic, t["provider"])
|
||||
try:
|
||||
for name, modul, marke in EBENEN:
|
||||
status = db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"]
|
||||
if _fertig_ab(status, marke):
|
||||
continue
|
||||
db.update("runs", "id", run_id, ebene=name)
|
||||
await modul.bauen(ctx)
|
||||
erst_note, _ = await qa.messen(ctx, name)
|
||||
|
||||
async def reparieren(n=name, m=modul):
|
||||
offene = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=?"
|
||||
" AND status='offen'", (run_id, n))
|
||||
bewegt = await m.reparieren(ctx, offene)
|
||||
neue_note, _ = await qa.messen(ctx, n)
|
||||
return neue_note, bewegt
|
||||
|
||||
note, grund = await auto_loop.auto_repair_loop(name, erst_note, reparieren)
|
||||
log.info("%s/%s: Note %.1f (%s)", topic, name, note, grund)
|
||||
db.update("topics", "name", topic, status=marke)
|
||||
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
|
||||
if not flags.get(name, True) and marke != "fertig":
|
||||
db.update("runs", "id", run_id, status="done", beendet="jetzt",
|
||||
grund=f"Auto-Stopp nach {name}")
|
||||
return
|
||||
db.update("runs", "id", run_id, status="done", beendet="jetzt")
|
||||
except llm.LaufPause as e:
|
||||
agents.abbrechen(f"{topic}-") # Nachzügler-Tasks dürfen keine Tokens mehr ziehen
|
||||
db.update("runs", "id", run_id, status="paused", grund=str(e)[:300], beendet="jetzt")
|
||||
log.warning("%s: Lauf pausiert — %s", topic, e)
|
||||
except ledger.BudgetErschoepft as e:
|
||||
agents.abbrechen(f"{topic}-")
|
||||
db.update("runs", "id", run_id, status="budget", grund=str(e)[:300], beendet="jetzt")
|
||||
log.warning("%s: Budget erreicht", topic)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
agents.abbrechen(f"{topic}-")
|
||||
db.update("runs", "id", run_id, status="failed", grund=f"{type(e).__name__}: {e}"[:300],
|
||||
beendet="jetzt")
|
||||
log.exception("%s: Lauf fehlgeschlagen", topic)
|
||||
29
backend/prompts.py
Normal file
29
backend/prompts.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Template-Lader: templates/<Name>.md, Platzhalter {name}, Datei-Hash je Call
|
||||
(Ledger — uncommittete Prompt-Änderungen bleiben nachvollziehbar)."""
|
||||
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
|
||||
from config import TEMPLATES_DIR
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _lade(name: str) -> tuple[str, str]:
|
||||
text = (TEMPLATES_DIR / f"{name}.md").read_text(encoding="utf-8")
|
||||
return text, hashlib.sha256(text.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def render(name: str, **werte) -> tuple[str, str]:
|
||||
"""→ (prompt, template_hash). format_map, damit geschweifte Klammern in
|
||||
JSON-Beispielen doppelt geschrieben werden ({{ }})."""
|
||||
text, h = _lade(name)
|
||||
return text.format_map(_Sicher(werte)), h
|
||||
|
||||
|
||||
class _Sicher(dict):
|
||||
def __missing__(self, key):
|
||||
raise KeyError(f"Template-Platzhalter fehlt: {key}")
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
_lade.cache_clear()
|
||||
87
backend/qa.py
Normal file
87
backend/qa.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""QA-Rahmen. Zwei Sorten Messung, strikt getrennt (Kernprinzip):
|
||||
- Invarianten: deterministisch, je Ebene in deren messen() — können echt 100 % erreichen.
|
||||
- Soll-QA: Abgleich gegen das eingefrorene Soll (steckt in den Invarianten von E1:
|
||||
soll_ohne_atom / atom_ohne_soll — Judges vergleichen, raten nie).
|
||||
Die Note ist eine deterministische Formel aus gemessenen Quoten (Lektion 70),
|
||||
nie eine LLM-Schätzung. 10,0 gibt es nur bei null offenen Befunden."""
|
||||
|
||||
import logging
|
||||
|
||||
import artefakte
|
||||
import db
|
||||
import guide
|
||||
import inventar
|
||||
import korpus
|
||||
import llm
|
||||
import struktur
|
||||
|
||||
log = logging.getLogger("creator2.qa")
|
||||
|
||||
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
|
||||
"struktur": struktur, "guide": guide}
|
||||
|
||||
# Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
|
||||
GEWICHTE = {
|
||||
"korpus_leer": 3.0, "soll_leer": 3.0, "soll_wenig_belege": 1.5,
|
||||
"soll_kandidat_offen": 3.0,
|
||||
"atom_ohne_anker": 3.0, "atom_ohne_soll": 1.5, "soll_ohne_atom": 3.0,
|
||||
"atom_ohne_flashcard": 1.5, "artefakt_unentschieden": 0.5,
|
||||
"partition": 3.0, "band": 0.5, "vorwaerts_kante": 1.5, "zyklus": 3.0,
|
||||
"baustein_ohne_kapitel": 3.0, "kapitel_leer": 1.5, "kapitel_fallback": 1.5,
|
||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "ziel_ohne_anker": 3.0,
|
||||
"fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5,
|
||||
}
|
||||
|
||||
|
||||
def _basis(topic: str, ebene: str) -> int:
|
||||
"""Bezugsgröße der Quoten je Ebene (geprüfte Items)."""
|
||||
z = {"korpus": "SELECT COUNT(*) n FROM soll WHERE topic=? AND status='bestaetigt'",
|
||||
"inventar": "SELECT COUNT(*) n FROM atome WHERE topic=?"
|
||||
" AND status NOT IN ('gemerged','verworfen')",
|
||||
"artefakte": "SELECT COUNT(*) n FROM atome WHERE topic=?"
|
||||
" AND status NOT IN ('gemerged','verworfen')",
|
||||
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
|
||||
" AND status NOT IN ('gemerged','verworfen')",
|
||||
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
|
||||
row = db.one(z, (topic,))
|
||||
return max(1, int(row["n"]) if row else 1)
|
||||
|
||||
|
||||
def note(befunde: list[dict], basis: int) -> float:
|
||||
if not befunde:
|
||||
return 10.0
|
||||
je_art: dict[str, int] = {}
|
||||
for b in befunde:
|
||||
je_art[b["art"]] = je_art.get(b["art"], 0) + 1
|
||||
abzug = sum(GEWICHTE.get(art, 1.5) * min(1.0, n / basis) for art, n in je_art.items())
|
||||
return round(min(9.9, max(0.0, 10.0 - abzug)), 1)
|
||||
|
||||
|
||||
def _persistieren(ctx: llm.Kontext, ebene: str, befunde: list[dict]) -> None:
|
||||
"""Offene Alt-Befunde, die nicht mehr auftreten → repariert; Neues → offen.
|
||||
Bestehende offene Zeilen bleiben (kein Duplikat)."""
|
||||
alte = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=? AND status='offen'",
|
||||
(ctx.run_id, ebene))
|
||||
neu_keys = {(b["art"], str(b["item"])) for b in befunde}
|
||||
alt_keys = set()
|
||||
for alt in alte:
|
||||
key = (alt["art"], alt["item"])
|
||||
if key in neu_keys:
|
||||
alt_keys.add(key)
|
||||
else:
|
||||
db.update("befunde", "id", alt["id"], status="repariert")
|
||||
for b in befunde:
|
||||
if (b["art"], str(b["item"])) not in alt_keys:
|
||||
db.insert("befunde", run_id=ctx.run_id, ebene=ebene, art=b["art"],
|
||||
item=str(b["item"]), detail=b["detail"][:500], status="offen")
|
||||
|
||||
|
||||
async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]:
|
||||
modul = MODULE[ebene]
|
||||
befunde = modul.messen(ctx)
|
||||
if ebene == "guide" and mit_llm:
|
||||
befunde = befunde + await guide.messen_llm(ctx)
|
||||
_persistieren(ctx, ebene, befunde)
|
||||
n = note(befunde, _basis(ctx.topic, ebene))
|
||||
log.info("QA %s/%s: Note %.1f, %d Befunde", ctx.topic, ebene, n, len(befunde))
|
||||
return n, befunde
|
||||
424
backend/struktur.py
Normal file
424
backend/struktur.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""Ebene 3: Struktur. Backward Design: Lernziele aus den Atomen je Soll-Punkt
|
||||
(Zuordnung im selben Call — Lektion 52), dann wird Struktur RECHENBAR:
|
||||
Baustein-Schnitt = Partition der Ziel-Gruppen ins Größenband, Reihenfolge =
|
||||
topologische Sortierung des braucht-Graphen. Zyklen bricht ein Judge an der
|
||||
schwächsten Kante (deterministischer Fallback)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
import db
|
||||
import llm
|
||||
from config import (BAUSTEIN_MAX_ATOME, BAUSTEIN_MIN_ATOME, KAPITEL_BAUSTEINE,
|
||||
ZIELE_CHUNK_ATOME)
|
||||
|
||||
log = logging.getLogger("creator2.struktur")
|
||||
|
||||
EBENE = "struktur"
|
||||
|
||||
|
||||
def _atome(topic: str) -> list[dict]:
|
||||
return db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen') ORDER BY id", (topic,))
|
||||
|
||||
|
||||
def _braucht_kanten(topic: str) -> list[dict]:
|
||||
return db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
|
||||
(topic,))
|
||||
|
||||
|
||||
# ── Lernziele ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
||||
atome = [a for a in _atome(ctx.topic) if not a["ziel_id"]]
|
||||
gruppen: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
gruppen[a["soll_id"]].append(a)
|
||||
|
||||
async def eine_gruppe(soll_id, gruppe: list[dict]) -> None:
|
||||
punkt = db.one("SELECT punkt FROM soll WHERE id=?", (soll_id,)) or {"punkt": ctx.topic}
|
||||
rest = gruppe
|
||||
for runde in ("1", "2"): # Schlussrunde MUSS alles entscheiden (Lektion 23)
|
||||
if not rest:
|
||||
return
|
||||
chunk = rest[:ZIELE_CHUNK_ATOME]
|
||||
liste = "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in chunk)
|
||||
res = await llm.call(ctx, stage="ziele", template="Lernziele",
|
||||
werte={"punkt": punkt["punkt"], "atome": liste,
|
||||
"pflicht": "Jedes Atom MUSS genau einem Ziel"
|
||||
" zugeordnet sein." if runde == "2" else ""},
|
||||
role="judge", n=len(chunk),
|
||||
item=f"s{soll_id}-r{runde}", erwartet=list)
|
||||
gueltig = {a["id"] for a in chunk}
|
||||
zugeordnet: set[int] = set()
|
||||
for z in res or []:
|
||||
ids = [i for i in z.get("atome", []) if isinstance(i, int)
|
||||
and i in gueltig and i not in zugeordnet]
|
||||
text = str(z.get("ziel", "")).strip()
|
||||
if not ids or not text:
|
||||
continue
|
||||
ziel_id = db.insert("lernziele", topic=ctx.topic, text=text,
|
||||
titel=str(z.get("titel", "")).strip()[:80],
|
||||
soll_id=soll_id, status="aktiv")
|
||||
for i in ids:
|
||||
db.update("atome", "id", i, ziel_id=ziel_id)
|
||||
zugeordnet.update(ids)
|
||||
rest = [a for a in gruppe if a["id"] not in zugeordnet
|
||||
and not db.one("SELECT ziel_id FROM atome WHERE id=?",
|
||||
(a["id"],))["ziel_id"]]
|
||||
if rest: # nach der Schlussrunde: deterministisches Sammelziel statt Lücke
|
||||
ziel_id = db.insert("lernziele", topic=ctx.topic,
|
||||
text=f"Grundlagen: {punkt['punkt']}",
|
||||
soll_id=soll_id, status="aktiv")
|
||||
for a in rest:
|
||||
db.update("atome", "id", a["id"], ziel_id=ziel_id)
|
||||
|
||||
await asyncio.gather(*(eine_gruppe(s, g) for s, g in gruppen.items()))
|
||||
|
||||
|
||||
# ── Zyklen brechen ────────────────────────────────────────────────────────────
|
||||
|
||||
def _finde_zyklus(knoten: list[int], kanten: list[tuple[int, int]]) -> list[tuple[int, int]] | None:
|
||||
"""Ein Zyklus als Kantenliste, oder None (iterative DFS mit Farben)."""
|
||||
nach = defaultdict(list)
|
||||
for v, z in kanten:
|
||||
nach[v].append(z)
|
||||
farbe = {k: 0 for k in knoten} # 0 weiß, 1 grau, 2 schwarz
|
||||
eltern: dict[int, int] = {}
|
||||
for start in knoten:
|
||||
if farbe[start]:
|
||||
continue
|
||||
stapel = [(start, iter(nach[start]))]
|
||||
farbe[start] = 1
|
||||
while stapel:
|
||||
v, it = stapel[-1]
|
||||
fortschritt = False
|
||||
for z in it:
|
||||
if z not in farbe:
|
||||
continue
|
||||
if farbe[z] == 0:
|
||||
farbe[z] = 1
|
||||
eltern[z] = v
|
||||
stapel.append((z, iter(nach[z])))
|
||||
fortschritt = True
|
||||
break
|
||||
if farbe[z] == 1: # Rückkante → Zyklus rekonstruieren
|
||||
pfad = [(v, z)]
|
||||
k = v
|
||||
while k != z:
|
||||
pfad.append((eltern[k], k))
|
||||
k = eltern[k]
|
||||
return pfad
|
||||
if not fortschritt:
|
||||
farbe[v] = 2
|
||||
stapel.pop()
|
||||
return None
|
||||
|
||||
|
||||
async def _zyklen_brechen(ctx: llm.Kontext) -> None:
|
||||
while True:
|
||||
atome = _atome(ctx.topic)
|
||||
ids = [a["id"] for a in atome]
|
||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
|
||||
if k["von_atom"] in ids and k["zu_atom"] in ids]
|
||||
zyklus = _finde_zyklus(ids, kanten)
|
||||
if not zyklus:
|
||||
return
|
||||
je_id = {a["id"]: a for a in atome}
|
||||
liste = "\n".join(f"{v}→{z}: {je_id[v]['titel']} braucht {je_id[z]['titel']}"
|
||||
for v, z in zyklus)
|
||||
res = await llm.call(ctx, stage="zyklus", template="Kanten-Zyklus",
|
||||
werte={"kanten": liste}, role="judge",
|
||||
item=f"z{zyklus[0][0]}", erwartet=dict)
|
||||
wahl = None
|
||||
if res:
|
||||
paar = (res.get("von"), res.get("zu"))
|
||||
if paar in zyklus:
|
||||
wahl = paar
|
||||
if wahl is None: # deterministischer Fallback: kleinste Kante
|
||||
wahl = min(zyklus)
|
||||
db.execute("UPDATE kanten SET status='gebrochen' WHERE topic=? AND von_atom=?"
|
||||
" AND zu_atom=? AND art='braucht'", (ctx.topic, wahl[0], wahl[1]))
|
||||
log.info("Zyklus gebrochen: %s→%s", wahl[0], wahl[1])
|
||||
|
||||
|
||||
# ── Bausteine schneiden + ordnen ──────────────────────────────────────────────
|
||||
|
||||
def _topo(knoten: list[int], kanten: list[tuple[int, int]],
|
||||
rang: dict[int, tuple]) -> list[int]:
|
||||
"""Kahn; kanten (v, z) = v braucht z ⇒ z kommt vor v. Ties nach rang (stabil).
|
||||
Kanten STRIKT auf die Knotenmenge filtern — Kanten zu fremden Atomen zogen
|
||||
sonst fremde Knoten in die Ausgabe und verdrängten Gruppenmitglieder
|
||||
(gemessen aak: 22 Atome ohne Baustein)."""
|
||||
kn = set(knoten)
|
||||
vorher = defaultdict(set)
|
||||
nachher = defaultdict(set)
|
||||
for v, z in kanten:
|
||||
if v in kn and z in kn:
|
||||
vorher[v].add(z)
|
||||
nachher[z].add(v)
|
||||
offen = sorted([k for k in knoten if not vorher[k]], key=lambda k: rang[k])
|
||||
out = []
|
||||
erledigt: set[int] = set()
|
||||
while offen:
|
||||
k = offen.pop(0)
|
||||
out.append(k)
|
||||
erledigt.add(k)
|
||||
neu = []
|
||||
for n in nachher[k]:
|
||||
if not (vorher[n] - erledigt) and n not in erledigt and n not in offen and n not in neu:
|
||||
neu.append(n)
|
||||
offen = sorted(offen + neu, key=lambda kk: rang[kk])
|
||||
out += sorted([k for k in knoten if k not in erledigt], key=lambda k: rang[k])
|
||||
return out
|
||||
|
||||
|
||||
def _anker_rang(topic: str) -> dict[int, tuple]:
|
||||
"""Natürliche Quellreihenfolge als Tie-Break: (quelle_id, start) des ersten Ankers."""
|
||||
out = {}
|
||||
for a in _atome(topic):
|
||||
row = db.one("SELECT quelle_id, start FROM anker WHERE atom_id=? AND start>=0"
|
||||
" ORDER BY quelle_id, start LIMIT 1", (a["id"],))
|
||||
out[a["id"]] = (row["quelle_id"], row["start"]) if row else (999_999, a["id"])
|
||||
return out
|
||||
|
||||
|
||||
def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
||||
"""Deterministisch, idempotent: alte Zuordnung verwerfen, neu schneiden.
|
||||
Ziel-Gruppen ins Band bringen — kleine Ziele desselben Soll-Punkts mergen
|
||||
(das Atom wechselt sein Ziel, Baustein bleibt EIN Ziel), große entlang der
|
||||
Topo-Reihenfolge splitten ("Teil n")."""
|
||||
topic = ctx.topic
|
||||
db.execute("DELETE FROM bausteine WHERE topic=?", (topic,))
|
||||
atome = _atome(topic)
|
||||
rang = _anker_rang(topic)
|
||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(topic)]
|
||||
|
||||
gruppen: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
gruppen[a["ziel_id"]].append(a)
|
||||
|
||||
# kleine Ziele mergen (kleinste zuerst). Partnerwahl thematisch statt per
|
||||
# Soll-Punkt — das Soll ist seit der Entkopplung reine Checkliste (152 feine
|
||||
# Punkte hätten sonst keine Merge-Partner): meiste braucht-Kanten zwischen
|
||||
# den Gruppen, sonst Nachbar in der Quellreihenfolge.
|
||||
def kanten_score(g1: list[dict], g2: list[dict]) -> int:
|
||||
ids1 = {a["id"] for a in g1}
|
||||
ids2 = {a["id"] for a in g2}
|
||||
return sum(1 for v, z in kanten
|
||||
if (v in ids1 and z in ids2) or (v in ids2 and z in ids1))
|
||||
|
||||
def gruppen_rang(g: list[dict]) -> tuple:
|
||||
return min(rang[a["id"]] for a in g)
|
||||
|
||||
geaendert = True
|
||||
while geaendert:
|
||||
geaendert = False
|
||||
kleine = sorted([z for z, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||
key=lambda z: len(gruppen[z]))
|
||||
for z in kleine:
|
||||
passende = [p for p in gruppen if p != z
|
||||
and len(gruppen[p]) + len(gruppen[z]) <= BAUSTEIN_MAX_ATOME]
|
||||
if not passende:
|
||||
continue
|
||||
eigener = gruppen_rang(gruppen[z])
|
||||
|
||||
def naehe(pp: int) -> tuple:
|
||||
r = gruppen_rang(gruppen[pp])
|
||||
return (-kanten_score(gruppen[z], gruppen[pp]),
|
||||
(abs(r[0] - eigener[0]), abs(r[1] - eigener[1])),
|
||||
len(gruppen[pp]))
|
||||
|
||||
p = min(passende, key=naehe)
|
||||
for a in gruppen[z]:
|
||||
db.update("atome", "id", a["id"], ziel_id=p)
|
||||
gruppen[p] += gruppen.pop(z)
|
||||
db.update("lernziele", "id", z, status="gemerged")
|
||||
geaendert = True
|
||||
break
|
||||
|
||||
# Bausteine anlegen (große Ziele splitten), Atome zuordnen
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
for ziel_id, gruppe in gruppen.items():
|
||||
ordnung = _topo([a["id"] for a in gruppe], kanten, rang)
|
||||
z = ziele.get(ziel_id, {})
|
||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
||||
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
|
||||
groesse = -(-len(gruppe) // n_teile)
|
||||
for teil in range(n_teile):
|
||||
teil_ids = ordnung[teil * groesse:(teil + 1) * groesse]
|
||||
if not teil_ids:
|
||||
continue
|
||||
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel_id, titel=b_titel,
|
||||
status="neu")
|
||||
for pos, atom_id in enumerate(teil_ids):
|
||||
db.update("atome", "id", atom_id, baustein_id=b_id, ord=pos)
|
||||
|
||||
_bausteine_ordnen(topic, kanten, rang)
|
||||
|
||||
|
||||
def _bausteine_ordnen(topic: str, kanten: list[tuple[int, int]], rang: dict) -> None:
|
||||
"""Baustein-DAG aus aggregierten Atom-Kanten; Zyklen deterministisch an der
|
||||
schwächsten Aggregat-Kante gebrochen (wenigste Atom-Kanten)."""
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
je_atom = {a["id"]: a["baustein_id"] for a in _atome(topic)}
|
||||
gewicht: dict = defaultdict(int)
|
||||
for v, z in kanten: # v braucht z ⇒ Baustein(z) vor Baustein(v)
|
||||
bv, bz = je_atom.get(v), je_atom.get(z)
|
||||
if bv and bz and bv != bz:
|
||||
gewicht[(bv, bz)] += 1
|
||||
b_kanten = set(gewicht)
|
||||
b_ids = [b["id"] for b in bausteine]
|
||||
# Tie-Break: Quellposition des Bausteins (früheste Belegstelle); Kanten gewinnen
|
||||
# weiter. Kapitel entstehen erst NACH der Ordnung (Entkopplung vom Soll).
|
||||
atome = _atome(topic)
|
||||
b_rang = {}
|
||||
for b in bausteine:
|
||||
b_rang[b["id"]] = min((rang.get(a["id"], (9, 9)) for a in atome
|
||||
if a["baustein_id"] == b["id"]), default=(9, 9))
|
||||
while True:
|
||||
zyklus = _finde_zyklus(b_ids, list(b_kanten))
|
||||
if not zyklus:
|
||||
break
|
||||
schwach = min(zyklus, key=lambda k: (gewicht[k], k))
|
||||
b_kanten.discard(schwach)
|
||||
# Die Atom-Kanten hinter der geopferten Aggregat-Kante stilllegen — sonst
|
||||
# meldet die QA sie ewig als Vorwärts-Kante (Baustein-Zyklen kann keine
|
||||
# Ordnung vollständig rückwärts auflösen).
|
||||
bv, bz = schwach
|
||||
db.execute("UPDATE kanten SET status='geopfert' WHERE topic=? AND art='braucht'"
|
||||
" AND status='aktiv'"
|
||||
" AND von_atom IN (SELECT id FROM atome WHERE baustein_id=?)"
|
||||
" AND zu_atom IN (SELECT id FROM atome WHERE baustein_id=?)",
|
||||
(topic, bv, bz))
|
||||
for pos, b_id in enumerate(_topo(b_ids, list(b_kanten), b_rang)):
|
||||
db.update("bausteine", "id", b_id, ord=pos)
|
||||
|
||||
|
||||
async def _kapitel_bilden(ctx: llm.Kontext) -> None:
|
||||
"""Kapitel = kontiguierliche Segmente der geordneten Baustein-Folge. Das Soll
|
||||
ist seit der Entkopplung reine Checkliste — Kapitel sind Struktur. Der Judge
|
||||
liefert nur GRENZEN (titel + bis-id) — id-Listen schrieb er unvollständig
|
||||
(aak Lauf 17: 516 Tokens für 68 Bausteine → alles verworfen). Der Code
|
||||
erzwingt Reihenfolge + Lückenlosigkeit; ein ungültiges Ergebnis bekommt
|
||||
EINEN Retry, danach √n-Fallback mit Befund (kapitel_fallback). Idempotent."""
|
||||
topic = ctx.topic
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
if not bausteine:
|
||||
return
|
||||
ziele = {z["id"]: z for z in db.query("SELECT * FROM lernziele WHERE topic=?", (topic,))}
|
||||
liste = "\n".join(f"{b['id']}: {b['titel']} — {ziele.get(b['ziel_id'], {}).get('text', '')}"
|
||||
for b in bausteine)
|
||||
richtwert = max(3, round(len(bausteine) / KAPITEL_BAUSTEINE))
|
||||
folge = [b["id"] for b in bausteine]
|
||||
pos_von = {bid: i for i, bid in enumerate(folge)}
|
||||
|
||||
async def schneiden(hinweis: str, tag: str) -> list[tuple[str, list[int]]] | None:
|
||||
res = await llm.call(ctx, stage="kapitel_schnitt", template="Kapitel-Schnitt",
|
||||
schritt="kapitel", role="judge", n=len(bausteine),
|
||||
item=tag, erwartet=list,
|
||||
werte={"bausteine": liste, "richtwert": str(richtwert),
|
||||
"hinweis": hinweis})
|
||||
segmente: list[tuple[str, list[int]]] = []
|
||||
start = 0
|
||||
for gruppe in res or []:
|
||||
if not isinstance(gruppe, dict):
|
||||
return None
|
||||
titel = str(gruppe.get("titel", "")).strip()
|
||||
ende = pos_von.get(gruppe.get("bis"))
|
||||
if not titel or ende is None or ende < start:
|
||||
return None
|
||||
segmente.append((titel, folge[start:ende + 1]))
|
||||
start = ende + 1
|
||||
return segmente if segmente and start == len(folge) else None
|
||||
|
||||
segmente = await schneiden("", "kapitel")
|
||||
if segmente is None:
|
||||
segmente = await schneiden(
|
||||
"ACHTUNG: Der letzte Versuch war ungültig. Jede Zeile braucht"
|
||||
" \"titel\" und \"bis\" (eine id aus der Liste, streng aufsteigend);"
|
||||
" das letzte \"bis\" MUSS die letzte id der Liste sein.", "kapitel-2")
|
||||
art = "judge"
|
||||
if segmente is None: # deterministischer Fallback: gleichmäßige √n-Segmente
|
||||
art = "fallback"
|
||||
je_id = {b["id"]: b for b in bausteine}
|
||||
n = max(3, round(len(folge) ** 0.5))
|
||||
groesse = -(-len(folge) // n)
|
||||
segmente = [(je_id[teil[0]]["titel"], teil)
|
||||
for i in range(n) if (teil := folge[i * groesse:(i + 1) * groesse])]
|
||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||
for pos, (titel, ids) in enumerate(segmente):
|
||||
k_id = db.insert("kapitel", topic=topic, titel=titel[:120], ord=pos, art=art)
|
||||
for b_id in ids:
|
||||
db.update("bausteine", "id", b_id, kapitel_id=k_id)
|
||||
|
||||
|
||||
async def bauen(ctx: llm.Kontext) -> None:
|
||||
ctx.ebene = EBENE
|
||||
await _ziele_bilden(ctx)
|
||||
await _zyklen_brechen(ctx)
|
||||
_bausteine_schneiden(ctx)
|
||||
await _kapitel_bilden(ctx)
|
||||
|
||||
|
||||
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||
befunde = []
|
||||
atome = _atome(ctx.topic)
|
||||
for a in atome:
|
||||
if not a["ziel_id"] or not a["baustein_id"]:
|
||||
befunde.append({"art": "partition", "item": str(a["id"]), "detail": a["titel"]})
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (ctx.topic,))
|
||||
je_baustein: dict = defaultdict(list)
|
||||
for a in atome:
|
||||
je_baustein[a["baustein_id"]].append(a)
|
||||
# Band-Unterschreitung nur, wo ein Merge MÖGLICH gewesen wäre (kombiniert
|
||||
# ≤ Band) — dieselbe Bedingung wie im Schnitt; eine unerfüllbare Messlatte
|
||||
# pendelt nur die Note (Lektion 75).
|
||||
for b in bausteine:
|
||||
n = len(je_baustein[b["id"]])
|
||||
partner = any(bb["id"] != b["id"]
|
||||
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
|
||||
for bb in bausteine)
|
||||
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
||||
befunde.append({"art": "band", "item": str(b["id"]),
|
||||
"detail": f"{b['titel']}: {n} Atome"})
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (ctx.topic,))
|
||||
kap_ids = {k["id"] for k in kaps}
|
||||
for b in bausteine:
|
||||
if b["kapitel_id"] not in kap_ids:
|
||||
befunde.append({"art": "baustein_ohne_kapitel", "item": str(b["id"]),
|
||||
"detail": b["titel"]})
|
||||
belegt = {b["kapitel_id"] for b in bausteine}
|
||||
for k in kaps:
|
||||
if k["id"] not in belegt:
|
||||
befunde.append({"art": "kapitel_leer", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
if k["art"] == "fallback": # stiller Fallback wäre unsichtbarer Qualitätsverlust
|
||||
befunde.append({"art": "kapitel_fallback", "item": str(k["id"]),
|
||||
"detail": k["titel"]})
|
||||
ord_von = {b["id"]: b["ord"] for b in bausteine}
|
||||
for k in _braucht_kanten(ctx.topic):
|
||||
bv = next((a["baustein_id"] for a in atome if a["id"] == k["von_atom"]), None)
|
||||
bz = next((a["baustein_id"] for a in atome if a["id"] == k["zu_atom"]), None)
|
||||
if bv and bz and bv != bz and ord_von.get(bz, 0) > ord_von.get(bv, 0):
|
||||
befunde.append({"art": "vorwaerts_kante", "item": str(k["id"]),
|
||||
"detail": f"Atom {k['von_atom']} braucht {k['zu_atom']} (später)"})
|
||||
ids = [a["id"] for a in atome]
|
||||
kanten = [(k["von_atom"], k["zu_atom"]) for k in _braucht_kanten(ctx.topic)
|
||||
if k["von_atom"] in ids and k["zu_atom"] in ids]
|
||||
if _finde_zyklus(ids, kanten):
|
||||
befunde.append({"art": "zyklus", "item": "", "detail": "braucht-Graph hat Zyklus"})
|
||||
return befunde
|
||||
|
||||
|
||||
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||
ctx.ebene = EBENE
|
||||
if not befunde:
|
||||
return False
|
||||
await _ziele_bilden(ctx) # fängt Partition-Lücken
|
||||
await _zyklen_brechen(ctx) # fängt Zyklen
|
||||
_bausteine_schneiden(ctx) # Neu-Schnitt fängt Band + Vorwärts-Kanten
|
||||
await _kapitel_bilden(ctx) # fängt Kapitel-Befunde
|
||||
return True
|
||||
155
backend/textkit.py
Normal file
155
backend/textkit.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Deterministische Text-Helfer: Normalisierung, Verbatim-Zitatsuche mit Offset-
|
||||
Mapping, Abschnitts-Split. Lektionen 27 (Casefold+NFKC), 31 (Negations-Guard),
|
||||
43 (Dash-Toleranz), 47 (Abschnitte)."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from config import ABSCHNITT_CHARS, FUZZY_FEHLERQUOTE, FUZZY_MIN_ZEICHEN
|
||||
|
||||
_WS = re.compile(r"\s+")
|
||||
_WORT = re.compile(r"[a-zäöüß0-9]+")
|
||||
|
||||
|
||||
def norm(text: str) -> str:
|
||||
"""NFKC + casefold + Whitespace-Faltung — für jeden Ähnlichkeits-/Gleichheitsvergleich."""
|
||||
return _WS.sub(" ", unicodedata.normalize("NFKC", text).casefold()).strip()
|
||||
|
||||
|
||||
def tokens(text: str) -> set[str]:
|
||||
return set(_WORT.findall(norm(text)))
|
||||
|
||||
|
||||
_NEGATION = {"nicht", "kein", "keine", "keinen", "keiner", "nie", "niemals", "ohne", "un"}
|
||||
|
||||
|
||||
def negations_menge(text: str) -> frozenset[str]:
|
||||
"""Antonyme messen 0.91–0.95 Cosinus — gleiche Negationsmenge ist harte
|
||||
Merge-Vorbedingung."""
|
||||
return frozenset(t for t in tokens(text) if t in _NEGATION or t.startswith("nicht"))
|
||||
|
||||
|
||||
def _normiert_mit_map(text: str) -> tuple[str, list[int]]:
|
||||
"""Whitespace-gefaltete Kopie + Map: Position in Kopie → Position im Original."""
|
||||
out: list[str] = []
|
||||
mapping: list[int] = []
|
||||
in_ws = False
|
||||
for i, c in enumerate(text):
|
||||
if c.isspace():
|
||||
if not in_ws and out:
|
||||
out.append(" ")
|
||||
mapping.append(i)
|
||||
in_ws = True
|
||||
else:
|
||||
out.append(c)
|
||||
mapping.append(i)
|
||||
in_ws = False
|
||||
return "".join(out), mapping
|
||||
|
||||
|
||||
def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
||||
"""Nur Buchstaben/Ziffern (NFKD + casefold, Kombinationszeichen raus) + Map auf
|
||||
Original-Positionen. Für die dritte Matching-Stufe: PDF-Extrakte und Reader variieren
|
||||
Interpunktion, Anführungszeichen, Dash-Typen und Trennungen — der Wortlaut bleibt
|
||||
gleich. NFKD statt NFKC (Lektion 82): pdftotext liefert DEKOMPONIERTE Umlaute
|
||||
(„a"+Kombinationspunkt), das LLM-Zitat präkomponierte („ä") — pro Einzelzeichen
|
||||
komponiert NFKC nie. Also BEIDE Seiten auf den Basisbuchstaben falten (ä→a);
|
||||
ohne das scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
|
||||
out: list[str] = []
|
||||
mapping: list[int] = []
|
||||
for i, c in enumerate(text):
|
||||
for cn in unicodedata.normalize("NFKD", c).casefold():
|
||||
if cn.isalnum() and not unicodedata.combining(cn):
|
||||
out.append(cn)
|
||||
mapping.append(i)
|
||||
return "".join(out), mapping
|
||||
|
||||
|
||||
_LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig
|
||||
|
||||
|
||||
def finde_zitat(text: str, zitat: str) -> tuple[int, int] | None:
|
||||
"""Zitat im Quelltext finden. Vier Stufen (Hypothes.is-Muster, Lektion 83):
|
||||
1. exakt (nur Whitespace-tolerant), 2. + casefold (PDF-Small-Caps),
|
||||
3. alnum-gefaltet (Interpunktion/Dashes/Quotes egal, Wortlaut identisch),
|
||||
4. fuzzy auf der Faltung (fuzzysearch, harte Fehlerquote + Eindeutigkeits-Guard) —
|
||||
fängt Reader-„Verschönerungen": weggelassene Listing-Zeilennummern, rekonstruierte
|
||||
Glyphen, geglättete Formeln. → (start, ende) im Original oder None.
|
||||
Paraphrasen bleiben draußen (Distanzschranke, keine Wort-Umstellungen)."""
|
||||
if not zitat.strip():
|
||||
return None
|
||||
haystack, mapping = _normiert_mit_map(text)
|
||||
needle = _WS.sub(" ", zitat).strip()
|
||||
pos = haystack.find(needle)
|
||||
if pos < 0:
|
||||
pos = haystack.casefold().find(needle.casefold())
|
||||
if pos >= 0:
|
||||
start = mapping[pos]
|
||||
ende = mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1
|
||||
return start, ende
|
||||
hay_l, map_l = _locker_mit_map(text)
|
||||
ndl_l, _ = _locker_mit_map(zitat)
|
||||
if len(ndl_l) < _LOCKER_MIN_ZEICHEN:
|
||||
return None
|
||||
pos = hay_l.find(ndl_l)
|
||||
if pos < 0:
|
||||
return _fuzzy_span(hay_l, map_l, ndl_l)
|
||||
return map_l[pos], map_l[min(pos + len(ndl_l) - 1, len(map_l) - 1)] + 1
|
||||
|
||||
|
||||
def _fuzzy_span(hay_l: str, map_l: list[int], ndl_l: str) -> tuple[int, int] | None:
|
||||
"""Stufe 4: Levenshtein-Substring auf der alnum-Faltung. Nur ab FUZZY_MIN_ZEICHEN,
|
||||
Distanz ≤ FUZZY_FEHLERQUOTE·Länge. Eindeutigkeits-Guard (Falsch-Anker >> fehlender
|
||||
Anker): gibt es einen NICHT überlappenden Zweittreffer fast gleicher Distanz → None
|
||||
(Realfall: SubSetSum- und Partition-Definition teilen den Satzanfang)."""
|
||||
if len(ndl_l) < FUZZY_MIN_ZEICHEN:
|
||||
return None
|
||||
try:
|
||||
from fuzzysearch import find_near_matches
|
||||
except ImportError:
|
||||
return None
|
||||
max_dist = max(2, int(len(ndl_l) * FUZZY_FEHLERQUOTE))
|
||||
treffer = find_near_matches(ndl_l, hay_l, max_l_dist=max_dist)
|
||||
if not treffer:
|
||||
return None
|
||||
best = min(treffer, key=lambda m: (m.dist, m.start))
|
||||
schranke = best.dist + max(3, int(len(ndl_l) * 0.05))
|
||||
for m in treffer:
|
||||
if (m.end <= best.start or m.start >= best.end) and m.dist <= schranke:
|
||||
return None # mehrdeutig — zwei getrennte, fast gleich gute Stellen
|
||||
return map_l[best.start], map_l[min(best.end - 1, len(map_l) - 1)] + 1
|
||||
|
||||
|
||||
def titel_kern(titel: str) -> str:
|
||||
"""Alnum-gefalteter Titel (NFKC + casefold) — gleicher Kern über
|
||||
Schreibvarianten hinweg (U+2011-Dashes, Spacing, Groß/Klein). Dient als
|
||||
deterministischer Dubletten-Kandidaten-Generator neben dem Embedding."""
|
||||
kern, _ = _locker_mit_map(titel)
|
||||
return kern
|
||||
|
||||
|
||||
def abschnitte(text: str, max_chars: int = ABSCHNITT_CHARS) -> list[tuple[int, str]]:
|
||||
"""Text an Absatzgrenzen in ≤max_chars-Abschnitte splitten (lost in the middle).
|
||||
→ [(offset, abschnitt)]."""
|
||||
if len(text) <= max_chars:
|
||||
return [(0, text)]
|
||||
out: list[tuple[int, str]] = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
ende = min(start + max_chars, len(text))
|
||||
if ende < len(text):
|
||||
brk = text.rfind("\n\n", start, ende)
|
||||
if brk <= start:
|
||||
brk = text.rfind("\n", start, ende)
|
||||
if brk > start:
|
||||
ende = brk
|
||||
out.append((start, text[start:ende]))
|
||||
start = ende
|
||||
return out
|
||||
|
||||
|
||||
def ueberlappung(a: tuple[int, int], b: tuple[int, int]) -> float:
|
||||
"""Span-Überlappung relativ zum kürzeren Span [0..1]."""
|
||||
schnitt = max(0, min(a[1], b[1]) - max(a[0], b[0]))
|
||||
kuerzer = min(a[1] - a[0], b[1] - b[0])
|
||||
return schnitt / kuerzer if kuerzer > 0 else 0.0
|
||||
41
backend/ws.py
Normal file
41
backend/ws.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Live-Board-Hub: Statuswechsel aus der DB-Schicht → alle WebSocket-Clients.
|
||||
Kein Polling; das Frontend hält den Snapshot und wendet Deltas an."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("creator2.ws")
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self):
|
||||
self._clients: set = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
async def connect(self, websocket) -> None:
|
||||
await websocket.accept()
|
||||
self._clients.add(websocket)
|
||||
|
||||
def disconnect(self, websocket) -> None:
|
||||
self._clients.discard(websocket)
|
||||
|
||||
def push(self, tabelle: str, row: dict) -> None:
|
||||
"""Thread-sicher (DB-Schicht ruft synchron): auf den Loop dispatchen."""
|
||||
if not self._clients or self._loop is None:
|
||||
return
|
||||
msg = json.dumps({"tabelle": tabelle, "row": row}, ensure_ascii=False, default=str)
|
||||
self._loop.call_soon_threadsafe(lambda: asyncio.ensure_future(self._send_all(msg)))
|
||||
|
||||
async def _send_all(self, msg: str) -> None:
|
||||
for ws in list(self._clients):
|
||||
try:
|
||||
await ws.send_text(msg)
|
||||
except Exception:
|
||||
self._clients.discard(ws)
|
||||
|
||||
|
||||
hub = Hub()
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>creator2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1211
frontend/package-lock.json
generated
Normal file
1211
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "creator2-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"dompurify": "^3.4.7",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.4",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"vue": "^3.5.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.8"
|
||||
}
|
||||
}
|
||||
185
frontend/src/App.vue
Normal file
185
frontend/src/App.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { api, wsVerbinden } from './api.js'
|
||||
import Board from './components/Board.vue'
|
||||
import Guide from './components/Guide.vue'
|
||||
import Ueben from './components/Ueben.vue'
|
||||
import Kennzahlen from './components/Kennzahlen.vue'
|
||||
|
||||
const topics = ref([])
|
||||
const topic = ref('')
|
||||
const state = ref(null)
|
||||
const tab = ref('board')
|
||||
const neuName = ref('')
|
||||
const neuArt = ref('thema')
|
||||
const fehler = ref('')
|
||||
const theme = ref(localStorage.getItem('theme') || 'auto')
|
||||
const navAuf = ref(localStorage.getItem('navAuf') !== '0')
|
||||
|
||||
function navToggle() {
|
||||
navAuf.value = !navAuf.value
|
||||
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
|
||||
}
|
||||
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
|
||||
|
||||
let wsSchliessen = null
|
||||
let timer = null
|
||||
const media = window.matchMedia('(prefers-color-scheme: light)')
|
||||
|
||||
function themeAnwenden() {
|
||||
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
|
||||
document.documentElement.dataset.theme =
|
||||
theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
watch(theme, themeAnwenden)
|
||||
media.addEventListener('change', themeAnwenden)
|
||||
|
||||
async function topicsLaden() {
|
||||
topics.value = await api.topics()
|
||||
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
|
||||
}
|
||||
|
||||
async function stateLaden() {
|
||||
if (!topic.value) { state.value = null; return }
|
||||
try {
|
||||
state.value = await api.state(topic.value)
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
function onWs() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(stateLaden, 400)
|
||||
}
|
||||
|
||||
async function anlegen() {
|
||||
if (!neuName.value.trim()) return
|
||||
try {
|
||||
await api.topicAnlegen({ name: neuName.value.trim(), art: neuArt.value })
|
||||
const name = neuName.value.trim()
|
||||
neuName.value = ''
|
||||
await topicsLaden()
|
||||
topic.value = name
|
||||
await stateLaden()
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
const menue = ref(null) // Kontextmenü: { x, y, name }
|
||||
|
||||
function menueOeffnen(e, name) {
|
||||
menue.value = { x: e.clientX, y: e.clientY, name }
|
||||
}
|
||||
|
||||
async function menueLoeschen() {
|
||||
const name = menue.value?.name
|
||||
menue.value = null
|
||||
if (!name) return
|
||||
try {
|
||||
await api.topicLoeschen(name)
|
||||
if (topic.value === name) topic.value = ''
|
||||
await topicsLaden()
|
||||
await stateLaden()
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
async function starten() {
|
||||
try { fehler.value = ''; await api.start(topic.value); await stateLaden() }
|
||||
catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
async function stoppen() {
|
||||
await api.stop(topic.value)
|
||||
await stateLaden()
|
||||
}
|
||||
|
||||
async function waehlen(name) {
|
||||
topic.value = name
|
||||
await stateLaden()
|
||||
}
|
||||
|
||||
let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kommen
|
||||
|
||||
onMounted(async () => {
|
||||
themeAnwenden()
|
||||
await topicsLaden()
|
||||
await stateLaden()
|
||||
wsSchliessen = wsVerbinden(onWs)
|
||||
poll = setInterval(() => {
|
||||
if (state.value?.run?.status === 'running') stateLaden()
|
||||
}, 5000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
wsSchliessen && wsSchliessen()
|
||||
clearInterval(poll)
|
||||
media.removeEventListener('change', themeAnwenden)
|
||||
})
|
||||
|
||||
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
|
||||
|
||||
function runBadge(run) {
|
||||
if (!run) return null
|
||||
const farbe = { running: 'blau', done: 'gruen', paused: 'gelb', budget: 'gelb',
|
||||
failed: 'rot', stopped: 'rot' }[run.status] || ''
|
||||
return { farbe, text: run.status + (run.ebene && run.status === 'running' ? ` · ${run.ebene}` : '') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="topics">
|
||||
<div v-for="t in topics" :key="t.name" class="topic-eintrag"
|
||||
:class="{ aktiv: t.name === topic }" @click="waehlen(t.name)"
|
||||
@contextmenu.prevent="menueOeffnen($event, t.name)">
|
||||
<span>{{ t.titel }}</span>
|
||||
<span v-if="t.run?.status === 'running'" class="badge blau">läuft</span>
|
||||
<span v-else-if="t.status === 'fertig'" class="badge gruen">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="neu">
|
||||
<input v-model="neuName" placeholder="neues Thema…" @keyup.enter="anlegen" />
|
||||
<div style="display: flex; gap: 6px">
|
||||
<select v-model="neuArt" style="flex: 1">
|
||||
<option value="thema">thema</option><option value="uni">uni</option>
|
||||
</select>
|
||||
<button class="sekundaer" @click="anlegen">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
|
||||
@click="menue = null" @contextmenu.prevent="menue = null">
|
||||
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
|
||||
<button class="rot" @click.stop="menueLoeschen">Thema „{{ menue.name }}" löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hauptbereich">
|
||||
<button class="nav-toggle" :title="navAuf ? 'Navigation einklappen' : 'Navigation anzeigen'"
|
||||
@click="navToggle">☰</button>
|
||||
<div class="topbar">
|
||||
<div class="tabs">
|
||||
<button v-for="t in ['board', 'guide', 'üben', 'kennzahlen']" :key="t"
|
||||
:class="{ aktiv: tab === t }" @click="tab = t">{{ t }}</button>
|
||||
</div>
|
||||
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
|
||||
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
|
||||
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
|
||||
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
|
||||
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
|
||||
<span v-if="fehler" class="badge rot">{{ fehler }}</span>
|
||||
<span style="flex: 1"></span>
|
||||
<select v-model="theme" title="Farbschema">
|
||||
<option value="hell">hell</option>
|
||||
<option value="dunkel">dunkel</option>
|
||||
<option value="papier">papier</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inhalt">
|
||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
|
||||
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
||||
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
||||
<Kennzahlen v-else :run="state?.run" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
frontend/src/api.js
Normal file
44
frontend/src/api.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const BASE = import.meta.env.DEV ? 'http://localhost:8000' : ''
|
||||
|
||||
async function req(pfad, opts = {}) {
|
||||
const res = await fetch(BASE + pfad, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
topics: () => req('/api/topics'),
|
||||
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
|
||||
start: (topic, budget) =>
|
||||
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
|
||||
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||
setAuto: (topic, ebene, an) =>
|
||||
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
||||
ebeneEntfernen: (topic, ebene) =>
|
||||
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
|
||||
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
|
||||
state: (topic) => req(`/api/topics/${topic}/state`),
|
||||
guide: (topic) => req(`/api/topics/${topic}/guide`),
|
||||
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
|
||||
antwort: (artefakt_id, richtig) =>
|
||||
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
|
||||
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
|
||||
}
|
||||
|
||||
export function wsVerbinden(onEvent) {
|
||||
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `ws://${location.host}`) + '/ws'
|
||||
let ws
|
||||
const verbinden = () => {
|
||||
ws = new WebSocket(url)
|
||||
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
|
||||
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
|
||||
ws.onclose = () => setTimeout(verbinden, 2000)
|
||||
}
|
||||
verbinden()
|
||||
return () => ws && ws.close()
|
||||
}
|
||||
177
frontend/src/components/Board.vue
Normal file
177
frontend/src/components/Board.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
// Ebenen-Kanban: 5 Spalten = Pipeline links→rechts, jede Spalte selbst steuerbar.
|
||||
// Befunde + laufende Agenten als Leiste ÜBER dem Board.
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ state: Object, topic: String })
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
||||
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
||||
const jetzt = ref(Date.now())
|
||||
const empfangen = ref(Date.now())
|
||||
const ebenenStart = ref({}) // Ebene → lokaler Start-Anker (epoch ms), monoton
|
||||
watch(() => props.state, () => {
|
||||
empfangen.value = Date.now()
|
||||
const neu = {}
|
||||
for (const e of props.state?.ebenen || []) {
|
||||
if (!e.laeuft) continue
|
||||
// Server-Dauer ist eine Untergrenze (Event-zu-Event); frühester Anker gewinnt,
|
||||
// damit die Anzeige beim nächsten State-Load nicht auf 0 zurückspringt
|
||||
const ausServer = Date.now() - (e.dauer_s || 0) * 1000
|
||||
const bisher = ebenenStart.value[e.name]
|
||||
neu[e.name] = bisher ? Math.min(bisher, ausServer) : ausServer
|
||||
}
|
||||
ebenenStart.value = neu
|
||||
})
|
||||
const ticker = setInterval(() => { jetzt.value = Date.now() }, 1000)
|
||||
onUnmounted(() => clearInterval(ticker))
|
||||
const extraSek = computed(() => Math.max(0, (jetzt.value - empfangen.value) / 1000))
|
||||
|
||||
function ebenenDauer(key) {
|
||||
const i = info(key)
|
||||
if (i.laeuft && ebenenStart.value[key])
|
||||
return Math.max(i.dauer_s || 0, Math.round((jetzt.value - ebenenStart.value[key]) / 1000))
|
||||
return i.dauer_s || 0
|
||||
}
|
||||
|
||||
const EBENEN = [
|
||||
{ key: 'korpus', titel: 'Quellen' },
|
||||
{ key: 'inventar', titel: 'Atome' },
|
||||
{ key: 'artefakte', titel: 'Artefakte' },
|
||||
{ key: 'struktur', titel: 'Bausteine' },
|
||||
{ key: 'guide', titel: 'Guide' },
|
||||
]
|
||||
|
||||
const statusFarbe = (s) => ({
|
||||
bestaetigt: 'gruen', verifiziert: 'gruen', fertig: 'gruen', done: 'gruen', aktiv: 'gruen',
|
||||
extrahiert: 'blau', atome: 'blau', kandidat: 'gelb', abgedeckt: 'gelb',
|
||||
repair: 'gelb', ohne_anker: 'rot', verworfen: 'rot',
|
||||
}[s] || '')
|
||||
|
||||
const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
|
||||
|
||||
const aktiveAtome = computed(() =>
|
||||
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
|
||||
|
||||
function karten(key) {
|
||||
const s = props.state
|
||||
if (!s) return []
|
||||
if (key === 'korpus') {
|
||||
return [
|
||||
...s.quellen.map((q) => ({ id: `q${q.id}`, text: q.titel,
|
||||
badges: [[q.status, statusFarbe(q.status)], [q.rolle, q.rolle === 'aufgaben' ? 'gelb' : 'blau']] })),
|
||||
...s.soll.map((p) => ({ id: `s${p.id}`, text: p.punkt,
|
||||
badges: [[p.status, statusFarbe(p.status)]] })),
|
||||
]
|
||||
}
|
||||
if (key === 'inventar') {
|
||||
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
||||
badges: [[a.status, statusFarbe(a.status)], [`${a.typ} · ${a.level}`, '']] }))
|
||||
}
|
||||
if (key === 'artefakte') {
|
||||
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
||||
badges: [[`${a.artefakte.verifiziert}/${a.artefakte.gesamt} verifiziert`,
|
||||
a.artefakte.verifiziert > 0 ? 'gruen' : '']] }))
|
||||
}
|
||||
if (key === 'struktur') {
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.status, statusFarbe(b.status)]] }))
|
||||
}
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
||||
}
|
||||
|
||||
function generierenErlaubt(idx) {
|
||||
if (!props.state || props.state.run?.status === 'running') return false
|
||||
const e = info(EBENEN[idx].key)
|
||||
if (e.fertig) return false
|
||||
return EBENEN.slice(0, idx).every((v) => info(v.key).fertig)
|
||||
}
|
||||
|
||||
const menue = ref(null) // Spalten-Kontextmenü: { x, y, key, titel, idx }
|
||||
|
||||
function menueOeffnen(ev, e, idx) {
|
||||
menue.value = { x: ev.clientX, y: ev.clientY, key: e.key, titel: e.titel, idx }
|
||||
}
|
||||
|
||||
async function menueGenerieren() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m || !generierenErlaubt(m.idx)) return
|
||||
await api.start(props.topic)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
async function menueEntfernen() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m) return
|
||||
await api.ebeneEntfernen(props.topic, m.key)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
async function autoSetzen(key, ev) {
|
||||
await api.setAuto(props.topic, key, ev.target.checked)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`)
|
||||
const mmss = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
||||
const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : n)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="state">
|
||||
<div v-if="state.agenten.length" class="leiste">
|
||||
<strong style="font-size: 12px; color: var(--dim); white-space: nowrap">
|
||||
Agenten ({{ state.agenten.length }})
|
||||
</strong>
|
||||
<span v-for="a in state.agenten" :key="a.key" class="badge blau"
|
||||
:title="a.key">{{ mmss(a.laufzeit + extraSek) }}</span>
|
||||
</div>
|
||||
<div class="board">
|
||||
<div v-for="(e, idx) in EBENEN" :key="e.key" class="spalte"
|
||||
@contextmenu.prevent="menueOeffnen($event, e, idx)">
|
||||
<div class="spaltekopf">
|
||||
<div class="zeile">
|
||||
<span class="titel">{{ e.titel }}
|
||||
<span class="badge">{{ karten(e.key).length }}</span></span>
|
||||
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
|
||||
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="zeile">
|
||||
<span class="badge">{{ k(info(e.key).tokens || 0) }} Tok</span>
|
||||
<span class="badge">{{ dauer(ebenenDauer(e.key)) }}</span>
|
||||
<label><input type="checkbox" :checked="info(e.key).auto"
|
||||
@change="autoSetzen(e.key, $event)" /> Auto</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="liste">
|
||||
<div v-for="kt in karten(e.key)" :key="kt.id" class="karte">
|
||||
{{ kt.text }}
|
||||
<div class="meta">
|
||||
<span v-for="([txt, farbe], i) in kt.badges" :key="i"
|
||||
class="badge" :class="farbe">{{ txt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
|
||||
@click="menue = null" @contextmenu.prevent="menue = null">
|
||||
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
|
||||
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
|
||||
Generieren
|
||||
</button>
|
||||
<button class="rot" @click.stop="menueEntfernen">
|
||||
„{{ menue.titel }}" + folgende Ebenen entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="seite">Kein Thema gewählt — links anlegen.</div>
|
||||
</template>
|
||||
159
frontend/src/components/Guide.vue
Normal file
159
frontend/src/components/Guide.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ topic: String, state: Object })
|
||||
const daten = ref(null)
|
||||
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
|
||||
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
|
||||
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
|
||||
const LEVEL_NAME = { E: 'Umfang: nur Einstieg', M: 'Umfang: bis Mittel', S: 'Umfang: alles' }
|
||||
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
|
||||
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||
const aktiv = ref(0)
|
||||
const kapEls = []
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
daten.value = await api.guide(props.topic)
|
||||
}
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
|
||||
|
||||
// Markdown+KaTeX sind teuer (66 Sections, ~3.400 Formeln): einmal rendern,
|
||||
// dann aus dem Cache — sonst rechnet jeder Scroll-Tick alles neu (~600 ms).
|
||||
const htmlCache = new Map()
|
||||
watch(daten, () => htmlCache.clear())
|
||||
|
||||
function html(s) {
|
||||
const key = `${ansicht.value}|${level.value}|${s.baustein}`
|
||||
if (!htmlCache.has(key)) {
|
||||
htmlCache.set(key, ansicht.value === 'kompakt'
|
||||
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
|
||||
}
|
||||
return htmlCache.get(key)
|
||||
}
|
||||
|
||||
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
|
||||
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
|
||||
|
||||
function hoehe(i) {
|
||||
const st = stats.value.je[i]
|
||||
const sections = daten.value?.kapitel?.[i]?.sections?.length || 1
|
||||
return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180)
|
||||
}
|
||||
|
||||
function setAnsicht(a) {
|
||||
ansicht.value = a
|
||||
laden()
|
||||
}
|
||||
|
||||
function levelToggle() {
|
||||
const folge = ['E', 'M', 'S']
|
||||
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
|
||||
laden()
|
||||
}
|
||||
|
||||
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
|
||||
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
|
||||
const stats = computed(() => {
|
||||
const je = (daten.value?.kapitel || []).map((kap) => {
|
||||
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
|
||||
let formeln = 0
|
||||
for (const s of kap.sections) {
|
||||
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
|
||||
: gefiltert(s.lang, level.value))
|
||||
woerter += st.woerter
|
||||
formeln += st.displayFormeln
|
||||
}
|
||||
return { woerter, minuten: woerter / 150 + (formeln * 5) / 60 }
|
||||
})
|
||||
return {
|
||||
je,
|
||||
woerter: je.reduce((a, k) => a + k.woerter, 0),
|
||||
minuten: je.reduce((a, k) => a + k.minuten, 0),
|
||||
}
|
||||
})
|
||||
|
||||
function zeit(min) {
|
||||
const m = Math.max(1, Math.round(min))
|
||||
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
|
||||
}
|
||||
|
||||
function setKapEl(i, el) {
|
||||
if (el) kapEls[i] = el
|
||||
}
|
||||
|
||||
async function springe(i) {
|
||||
aktiv.value = i // erst rendern (Platzhalter → Inhalt), dann springen
|
||||
await nextTick()
|
||||
kapEls[i]?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
|
||||
function onScroll(e) {
|
||||
const top = e.target.scrollTop + 130
|
||||
let a = 0
|
||||
kapEls.forEach((el, i) => { if (el && el.offsetTop <= top) a = i })
|
||||
aktiv.value = a
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-layout" :class="{ fokus }">
|
||||
<nav v-if="daten?.kapitel?.length" class="guide-nav">
|
||||
<div class="schalter-zeile">
|
||||
<div class="schalter">
|
||||
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
|
||||
@click="setAnsicht('erklaerend')">
|
||||
<svg width="14" height="12" viewBox="0 0 14 12">
|
||||
<path d="M0 1h14M0 3.5h14M0 6h14M0 8.5h14M0 11h9"
|
||||
stroke="currentColor" stroke-width="1.4" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
<button :class="{ aktiv: ansicht === 'kompakt' }" title="kompakt (wenig Text)"
|
||||
@click="setAnsicht('kompakt')">
|
||||
<svg width="14" height="12" viewBox="0 0 14 12">
|
||||
<path d="M0 2.5h14M0 6h8" stroke="currentColor" stroke-width="1.4" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="schalter-solo" :title="LEVEL_NAME[level]" @click="levelToggle">
|
||||
{{ LEVEL_ICON[level] }}
|
||||
</button>
|
||||
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
|
||||
@click="fokus = true">⛶</button>
|
||||
</div>
|
||||
<div class="fortschritt">
|
||||
<div>Kapitel {{ aktiv + 1 }} / {{ daten.kapitel.length }}</div>
|
||||
<div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div>
|
||||
</div>
|
||||
<a v-for="(kap, i) in daten.kapitel" :key="i" :class="{ aktiv: i === aktiv }"
|
||||
@click="springe(i)">
|
||||
<span>{{ i + 1 }}. {{ kap.titel }}</span>
|
||||
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="seite guide-text" @scroll="onScroll">
|
||||
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
|
||||
@click="fokus = false">✕</button>
|
||||
<template v-if="daten">
|
||||
<div v-for="(kap, i) in daten.kapitel" :key="i" class="kapitel"
|
||||
:ref="(el) => setKapEl(i, el)">
|
||||
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
|
||||
<template v-if="sichtbar(i)">
|
||||
<p v-if="kap.intro && ansicht === 'erklaerend'"
|
||||
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
|
||||
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
|
||||
<h3 style="margin-top: 0">{{ s.titel }}</h3>
|
||||
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
|
||||
</div>
|
||||
<div v-if="!daten.kapitel.length" class="guide-section">Noch kein Guide generiert.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
50
frontend/src/components/Kennzahlen.vue
Normal file
50
frontend/src/components/Kennzahlen.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ run: Object })
|
||||
const daten = ref(null)
|
||||
|
||||
async function laden() {
|
||||
if (!props.run) return
|
||||
daten.value = await api.kennzahlen(props.run.id)
|
||||
}
|
||||
watch(() => props.run?.id, laden, { immediate: true })
|
||||
|
||||
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n)
|
||||
const s = (ms) => `${(ms / 1000).toFixed(0)}s`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seite">
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 12px">
|
||||
<h2 style="margin: 0; flex: 1">Kennzahlen {{ run ? `(Lauf ${run.id})` : '' }}</h2>
|
||||
<span v-if="daten" class="badge">{{ k(daten.verbraucht) }} Tokens gesamt</span>
|
||||
<button class="sekundaer" @click="laden">Neu laden</button>
|
||||
</div>
|
||||
<table v-if="daten" class="kennzahlen">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ebene / Stage / Template</th><th>Calls</th><th>ok</th><th>Timeout</th>
|
||||
<th>Fehler</th><th>Tok in</th><th>Tok out</th><th>Cache</th>
|
||||
<th>Dauer</th><th>Wartezeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="z in daten.zeilen" :key="z.ebene + z.stage + z.template">
|
||||
<td>{{ z.ebene }} / {{ z.stage }} <span style="color: var(--dim)">{{ z.template }}</span></td>
|
||||
<td>{{ z.calls }}</td>
|
||||
<td>{{ z.ok }}</td>
|
||||
<td :style="z.timeouts ? 'color: var(--gelb)' : ''">{{ z.timeouts }}</td>
|
||||
<td :style="z.fehler ? 'color: var(--rot)' : ''">{{ z.fehler }}</td>
|
||||
<td>{{ k(z.tok_in || 0) }}</td>
|
||||
<td>{{ k(z.tok_out || 0) }}</td>
|
||||
<td>{{ k(z.cache_read || 0) }}</td>
|
||||
<td>{{ s(z.dur_ms || 0) }}</td>
|
||||
<td>{{ s(z.wait_ms || 0) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else style="color: var(--dim)">Noch kein Lauf.</p>
|
||||
</div>
|
||||
</template>
|
||||
49
frontend/src/components/Ueben.vue
Normal file
49
frontend/src/components/Ueben.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
import { render } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ topic: String })
|
||||
const stapel = ref([])
|
||||
const zeigeAntwort = ref(false)
|
||||
const fertigGelernt = ref(0)
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
stapel.value = await api.ueben(props.topic)
|
||||
zeigeAntwort.value = false
|
||||
fertigGelernt.value = 0
|
||||
}
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
|
||||
async function antworten(richtig) {
|
||||
const karte = stapel.value[0]
|
||||
await api.antwort(karte.id, richtig)
|
||||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||||
if (richtig) fertigGelernt.value++
|
||||
zeigeAntwort.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seite">
|
||||
<div v-if="stapel.length" class="flash">
|
||||
<div class="meta" style="color: var(--dim); margin-bottom: 8px">
|
||||
{{ stapel[0].titel }} · Box {{ stapel[0].box }} · noch {{ stapel.length }} fällig
|
||||
</div>
|
||||
<div class="frage" v-html="render(stapel[0].inhalt.frage)"></div>
|
||||
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">Antwort zeigen</button>
|
||||
<template v-else>
|
||||
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
||||
<div style="display: flex; gap: 10px; justify-content: center">
|
||||
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
|
||||
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flash">
|
||||
<p>Keine fälligen Karten. {{ fertigGelernt ? `${fertigGelernt} gelernt — stark.` : '' }}</p>
|
||||
<button class="sekundaer" @click="laden">Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '@fontsource-variable/inter'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
102
frontend/src/markdown.js
Normal file
102
frontend/src/markdown.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
import katex from 'katex'
|
||||
import DOMPurify from 'dompurify'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
const marked = new Marked(
|
||||
markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const l = hljs.getLanguage(lang) ? lang : 'plaintext'
|
||||
return hljs.highlight(code, { language: l }).value
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
function kx(tex, displayMode) {
|
||||
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
|
||||
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt
|
||||
return katex.renderToString(tex, { displayMode }).replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function mathe(text) {
|
||||
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
|
||||
return text
|
||||
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
}
|
||||
|
||||
export function render(text) {
|
||||
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
||||
}
|
||||
|
||||
const RANG = { E: 0, M: 1, S: 2 }
|
||||
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
|
||||
|
||||
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
|
||||
export function lesestat(text) {
|
||||
let displayFormeln = 0
|
||||
const t = (text || '')
|
||||
.replace(MARKER, ' ')
|
||||
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
|
||||
.replace(/\$[^$\n]+?\$/g, ' Formel ')
|
||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||
return { woerter, displayFormeln }
|
||||
}
|
||||
|
||||
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
|
||||
export function segmente(lang) {
|
||||
const out = []
|
||||
let letzt = 0
|
||||
let aktuell = { level: null, titel: '', text: '' }
|
||||
for (const m of lang.matchAll(MARKER)) {
|
||||
aktuell.text = lang.slice(letzt, m.index)
|
||||
out.push(aktuell)
|
||||
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
|
||||
letzt = m.index + m[0].length
|
||||
}
|
||||
aktuell.text = lang.slice(letzt)
|
||||
out.push(aktuell)
|
||||
return out
|
||||
}
|
||||
|
||||
export function gefiltert(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'S') return lang
|
||||
return segmente(lang)
|
||||
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
|
||||
.map((s) => s.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
|
||||
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
|
||||
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
|
||||
export function renderGestuft(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
|
||||
const seg = segmente(lang)
|
||||
const letzt = seg[seg.length - 1]
|
||||
const i = letzt.text.indexOf('**Kernpunkte:**')
|
||||
if (i >= 0) {
|
||||
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
|
||||
letzt.text = letzt.text.slice(0, i)
|
||||
}
|
||||
return seg.map((s) => {
|
||||
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
|
||||
const html = render(s.text)
|
||||
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
|
||||
return gelernt ? `<div class="gelernt">${html}</div>` : html
|
||||
}).join('')
|
||||
}
|
||||
243
frontend/src/style.css
Normal file
243
frontend/src/style.css
Normal file
@@ -0,0 +1,243 @@
|
||||
:root {
|
||||
--bg: #10141a;
|
||||
--panel: #171d26;
|
||||
--karte: #1f2733;
|
||||
--rand: #2c3646;
|
||||
--text: #dce3ec;
|
||||
--dim: #8b98a9;
|
||||
--akzent: #4da3ff;
|
||||
--gruen: #3fc380;
|
||||
--gelb: #e8c34a;
|
||||
--rot: #e06060;
|
||||
--badge-gruen-bg: #1d3a2c;
|
||||
--badge-gelb-bg: #3a331d;
|
||||
--badge-rot-bg: #3a1d1d;
|
||||
--badge-blau-bg: #1d2b3a;
|
||||
--code-bg: #0d1117;
|
||||
}
|
||||
:root { --lese-text: #c9d2df; } /* Guide-Fließtext: weicher als --text */
|
||||
:root[data-theme="light"] { --lese-text: #333747; }
|
||||
:root[data-theme="sepia"] {
|
||||
--bg: #f0e7d3;
|
||||
--panel: #f9f2df;
|
||||
--karte: #efe4c9;
|
||||
--rand: #d9cba8;
|
||||
--text: #443b2c;
|
||||
--dim: #83765c;
|
||||
--akzent: #7a5c2e;
|
||||
--gruen: #4f7a42;
|
||||
--gelb: #8f6b14;
|
||||
--rot: #a6442e;
|
||||
--badge-gruen-bg: #e2ecd5;
|
||||
--badge-gelb-bg: #f0e6c2;
|
||||
--badge-rot-bg: #f0d8cd;
|
||||
--badge-blau-bg: #e8e0c8;
|
||||
--code-bg: #ede3ca;
|
||||
--lese-text: #443b2c;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f8;
|
||||
--panel: #ffffff;
|
||||
--karte: #eef1f5;
|
||||
--rand: #d4dae2;
|
||||
--text: #1c2430;
|
||||
--dim: #5c6a7c;
|
||||
--akzent: #1a73d6;
|
||||
--gruen: #1d8a55;
|
||||
--gelb: #9a7b12;
|
||||
--rot: #c23b3b;
|
||||
--badge-gruen-bg: #dcf2e6;
|
||||
--badge-gelb-bg: #f6eecb;
|
||||
--badge-rot-bg: #f6d9d9;
|
||||
--badge-blau-bg: #dcebfa;
|
||||
--code-bg: #f0f2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
#app { height: 100vh; display: flex; }
|
||||
button {
|
||||
background: var(--akzent); color: #fff; border: 0; border-radius: 6px;
|
||||
padding: 6px 14px; cursor: pointer; font: inherit;
|
||||
}
|
||||
button.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
|
||||
button.klein { padding: 2px 8px; font-size: 12px; }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
input, select {
|
||||
background: var(--karte); color: var(--text); border: 1px solid var(--rand);
|
||||
border-radius: 6px; padding: 6px 10px; font: inherit;
|
||||
}
|
||||
|
||||
/* Sidebar-Navigation */
|
||||
.sidebar {
|
||||
width: 220px; min-width: 220px; background: var(--panel);
|
||||
border-right: 1px solid var(--rand); display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
|
||||
.sidebar .topic-eintrag {
|
||||
display: flex; align-items: center; gap: 6px; padding: 7px 10px;
|
||||
border-radius: 6px; cursor: pointer; color: var(--dim);
|
||||
}
|
||||
.sidebar .topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
|
||||
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
|
||||
.kontextmenue {
|
||||
position: fixed; background: var(--panel); border: 1px solid var(--rand);
|
||||
border-radius: 8px; padding: 4px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.kontextmenue button {
|
||||
display: block; width: 100%; text-align: left; background: none;
|
||||
color: var(--text); border: 0; padding: 7px 12px; border-radius: 6px;
|
||||
}
|
||||
.kontextmenue button:hover:not(:disabled) { background: var(--karte); }
|
||||
.kontextmenue button.rot { color: var(--rot); }
|
||||
|
||||
.hauptbereich { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.topbar {
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand);
|
||||
}
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tabs button { background: transparent; color: var(--dim); }
|
||||
.tabs button.aktiv { background: var(--karte); color: var(--text); }
|
||||
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.badge {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
font-size: 11px; background: var(--karte); color: var(--dim);
|
||||
}
|
||||
.badge.gruen { background: var(--badge-gruen-bg); color: var(--gruen); }
|
||||
.badge.gelb { background: var(--badge-gelb-bg); color: var(--gelb); }
|
||||
.badge.rot { background: var(--badge-rot-bg); color: var(--rot); }
|
||||
.badge.blau { background: var(--badge-blau-bg); color: var(--akzent); }
|
||||
|
||||
/* Befunde/Agenten-Leiste über dem Board */
|
||||
.leiste {
|
||||
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--rand); background: var(--panel);
|
||||
min-height: 40px; max-height: 130px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Board: Ebenen-Spalten links→rechts, Listen VOLLSTÄNDIG scrollbar */
|
||||
.board { display: flex; gap: 10px; padding: 12px; overflow-x: auto; flex: 1; min-height: 0; }
|
||||
.spalte {
|
||||
min-width: 265px; max-width: 320px; flex: 1; display: flex; flex-direction: column;
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
|
||||
}
|
||||
.spaltekopf { padding: 8px 10px; border-bottom: 1px solid var(--rand); display: grid; gap: 6px; }
|
||||
.spaltekopf .zeile { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.spaltekopf .titel { font-weight: 600; font-size: 13px; flex: 1; }
|
||||
.spaltekopf label { font-size: 12px; color: var(--dim); display: flex; gap: 4px; align-items: center; }
|
||||
.spalte .liste { overflow-y: auto; flex: 1; padding: 8px; }
|
||||
.karte {
|
||||
background: var(--karte); border: 1px solid var(--rand); border-radius: 6px;
|
||||
padding: 6px 9px; margin-bottom: 6px; font-size: 13px;
|
||||
}
|
||||
.karte .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
||||
|
||||
.seite { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.seite.schmal { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* Guide: Kapitel-Navigation + Lesetypografie (~70 Zeichen/Zeile, 17px) */
|
||||
.guide-layout { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||
.guide-nav {
|
||||
width: 260px; min-width: 220px; overflow-y: auto; padding: 14px 10px;
|
||||
border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px;
|
||||
}
|
||||
.guide-nav .schalter-zeile {
|
||||
display: flex; gap: 6px; align-items: center; padding: 2px 4px 10px;
|
||||
}
|
||||
.schalter { display: flex; border: 1px solid var(--rand); border-radius: 6px; overflow: hidden; }
|
||||
.schalter button {
|
||||
background: none; color: var(--dim); border: 0; border-radius: 0;
|
||||
padding: 4px 9px; font-size: 12px; line-height: 1;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.schalter button.aktiv { background: var(--karte); color: var(--text); }
|
||||
.schalter-solo {
|
||||
background: none; color: var(--dim); border: 1px solid var(--rand);
|
||||
border-radius: 6px; padding: 3px 9px; font-size: 12px;
|
||||
}
|
||||
.guide-nav .fortschritt {
|
||||
color: var(--dim); font-size: 12px; padding: 0 8px 10px;
|
||||
border-bottom: 1px solid var(--rand); margin-bottom: 8px;
|
||||
}
|
||||
.guide-nav a {
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
padding: 5px 8px; border-radius: 6px;
|
||||
color: var(--dim); cursor: pointer; line-height: 1.35;
|
||||
}
|
||||
.guide-nav a .zeit { font-size: 11px; white-space: nowrap; opacity: 0.75; }
|
||||
.guide-nav a:hover { color: var(--text); }
|
||||
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
|
||||
.seite.guide-text {
|
||||
position: relative; max-width: 680px; margin: 0 auto;
|
||||
scrollbar-width: none; /* Scrollbalken unsichtbar, Scrollen bleibt */
|
||||
font-family: 'Inter Variable', system-ui, sans-serif;
|
||||
font-size: 19px; line-height: 1.55; color: var(--lese-text);
|
||||
hyphens: auto; -webkit-hyphens: auto;
|
||||
}
|
||||
/* Fokus-Modus: Navigation weg, Karten-Rahmen aufgelöst — reiner Artikel-Fluss */
|
||||
.guide-layout.fokus .guide-nav { display: none; }
|
||||
.guide-layout.fokus .guide-section {
|
||||
border: 0; background: none; padding: 0 0 6px; margin-bottom: 4px;
|
||||
}
|
||||
body.vollbild .sidebar, body.vollbild .topbar { display: none; }
|
||||
body.vollbild .nav-toggle { display: none; }
|
||||
/* Eingeklappt: Themen-Sidebar + Topbar weg — die Kapitelübersicht bleibt */
|
||||
body.nav-zu .sidebar, body.nav-zu .topbar { display: none; }
|
||||
body.nav-zu .guide-nav { padding-top: 44px; }
|
||||
.nav-toggle {
|
||||
position: fixed; top: 6px; left: 8px; z-index: 20;
|
||||
background: none; color: var(--dim); border: 0; padding: 4px 8px; font-size: 16px;
|
||||
}
|
||||
.nav-toggle:hover { color: var(--text); }
|
||||
.sidebar .topics { padding-top: 40px; } /* Platz für das fixe ☰ links oben */
|
||||
.fokus-aus {
|
||||
position: fixed; top: 10px; right: 16px; z-index: 5;
|
||||
background: none; color: var(--dim); border: 0;
|
||||
padding: 4px 8px; font-size: 16px; opacity: 0.6;
|
||||
}
|
||||
.fokus-aus:hover { opacity: 1; }
|
||||
.seite.guide-text::-webkit-scrollbar { display: none; }
|
||||
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
|
||||
.guide-text .kapitel { scroll-margin-top: 8px; }
|
||||
.guide-text .kapitel-titel { margin: 30px 0 6px; }
|
||||
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
|
||||
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
|
||||
.guide-text .kompakt {
|
||||
background: none; padding: 0; color: var(--lese-text);
|
||||
font-size: 17px; line-height: 1.55;
|
||||
}
|
||||
.guide-text .kompakt li { margin: 5px 0; }
|
||||
.guide-section {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
|
||||
padding: 16px 22px; margin-bottom: 16px;
|
||||
}
|
||||
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
|
||||
.markdown .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
|
||||
.markdown blockquote {
|
||||
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
|
||||
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
|
||||
}
|
||||
.markdown blockquote p { margin: 4px 0; }
|
||||
.markdown :is(code):not(.hljs) { background: var(--karte); padding: 1px 5px; border-radius: 4px; }
|
||||
.markdown pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; }
|
||||
.markdown table { border-collapse: collapse; }
|
||||
.markdown td, .markdown th { border: 1px solid var(--rand); padding: 4px 10px; }
|
||||
|
||||
.flash {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 28px; max-width: 620px; margin: 40px auto; text-align: center;
|
||||
}
|
||||
.flash .frage { font-size: 18px; margin-bottom: 18px; }
|
||||
.flash .antwort { background: var(--karte); border-radius: 8px; padding: 14px; margin: 14px 0; }
|
||||
|
||||
table.kennzahlen { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
table.kennzahlen th, table.kennzahlen td {
|
||||
border-bottom: 1px solid var(--rand); padding: 5px 10px; text-align: right;
|
||||
}
|
||||
table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: left; }
|
||||
table.kennzahlen th { color: var(--dim); }
|
||||
6
frontend/vite.config.js
Normal file
6
frontend/vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
16
templates/Artefakt-Fix.md
Normal file
16
templates/Artefakt-Fix.md
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- template:Artefakt-Fix -->
|
||||
Dieses Artefakt (Typ {typ}) hat Mängel. Behebe sie in EINEM Durchgang.
|
||||
|
||||
ARTEFAKT:
|
||||
{artefakt}
|
||||
|
||||
MÄNGEL:
|
||||
{maengel}
|
||||
|
||||
BELEGE (einzige erlaubte Faktenbasis — nichts erfinden):
|
||||
{belege}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences). Für flashcard:
|
||||
{{"frage": "…", "antwort": "…"}}
|
||||
Für beispiel:
|
||||
{{"text": "…"}}
|
||||
26
templates/Artefakt-Generate.md
Normal file
26
templates/Artefakt-Generate.md
Normal file
@@ -0,0 +1,26 @@
|
||||
<!-- template:Artefakt-Generate -->
|
||||
Unten Atome eines Lernthemas (Titel, Definition, Beleg-Zitate).
|
||||
|
||||
Erzeuge pro Atom:
|
||||
- 2 Flashcards (typ "flashcard"): eine Frage + präzise Antwort. Die Antwort stützt sich
|
||||
NUR auf Definition und Belege — nichts erfinden, nicht neu recherchieren.
|
||||
- 1 Worked Example (typ "beispiel", Feld "text"): ein EINFACHES durchgerechnetes
|
||||
Beispiel — die kleinste Instanz, die das Atom klärt. Keine mehrstufigen oder
|
||||
verschachtelten Konstruktionen. Alle Werte/Aussagen müssen aus den Belegen ableitbar
|
||||
sein. Wenn die Belege KEIN sinnvolles einfaches Beispiel hergeben, weglassen.
|
||||
|
||||
Regeln:
|
||||
- Atom-ids wörtlich übernehmen, keine erfinden, keine Atome auslassen (außer Beispiel s. o.).
|
||||
- Fragen müssen aus der Antwort heraus fair beantwortbar sein (keine Fangfragen,
|
||||
keine Rezitation exakter Formulierungen verlangen).
|
||||
- SELBSTSTÄNDIGKEIT: Jede Karte muss ohne die Quelle funktionieren. Wörter wie
|
||||
„Beleg", „Quelle", „Musterlösung", „Skript", „Aufgabe 3" sind verboten. Die Frage
|
||||
fragt Wissen ab („Was ist X?", „Warum gilt Y?"), nie was in einem Text steht,
|
||||
erwähnt oder gestellt wird.
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||
[{{"atom": 12, "typ": "flashcard", "frage": "…", "antwort": "…"}},
|
||||
{{"atom": 12, "typ": "beispiel", "text": "…"}}]
|
||||
18
templates/Artefakt-Verify.md
Normal file
18
templates/Artefakt-Verify.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- template:Artefakt-Verify -->
|
||||
Unten Artefakte (Flashcards/Beispiele) mit den Beleg-Zitaten ihres Atoms.
|
||||
|
||||
Prüfe jedes Artefakt GEGEN DIE BELEGE:
|
||||
- ok=false, wenn eine Aussage den Belegen widerspricht oder nicht aus ihnen ableitbar ist.
|
||||
- ok=false, wenn die Frage aus der Antwort heraus nicht fair beantwortbar ist.
|
||||
- ok=false, wenn Frage oder Antwort auf Beleg, Quelle, Musterlösung oder einen
|
||||
Aufgabenkontext verweist — Karten müssen ohne den Quelltext selbstständig
|
||||
funktionieren und Wissen abfragen, nicht Textinhalte.
|
||||
- Formulierungsgeschmack ist KEIN Mangel — inhaltlich korrekt in anderen Worten zählt voll.
|
||||
- Bei ok=false: „mangel" = ein konkreter, behebbarer Satz.
|
||||
- Antworte für JEDES Artefakt (id wörtlich übernehmen).
|
||||
|
||||
ARTEFAKTE:
|
||||
{artefakte}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"artefakt": 5, "ok": true, "mangel": ""}}]
|
||||
17
templates/Atom-Anker-Fix-Batch.md
Normal file
17
templates/Atom-Anker-Fix-Batch.md
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- template:Atom-Anker-Fix-Batch -->
|
||||
Unten Atome ohne Quellen-Anker (Format „id: titel — definition") und ein Quelltext-Abschnitt.
|
||||
|
||||
Suche für JEDES Atom die Passage im Abschnitt, die es belegt, und kopiere sie
|
||||
WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Regeln:
|
||||
- Nur melden, wenn die Passage das Atom wirklich belegt. Steht ein Atom nicht in
|
||||
diesem Abschnitt: weglassen.
|
||||
- Nichts paraphrasieren, nichts erfinden. Ids wörtlich übernehmen.
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"atom": 12, "zitat": "…"}}]
|
||||
12
templates/Atom-Anker-Fix.md
Normal file
12
templates/Atom-Anker-Fix.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<!-- template:Atom-Anker-Fix -->
|
||||
Atom: „{titel}" — {definition}
|
||||
|
||||
Suche im QUELLTEXT unten die Passage, die dieses Atom belegt, und kopiere sie
|
||||
WÖRTLICH (exakt, Zeichen für Zeichen, 5–40 Wörter). Wenn der Text das Atom
|
||||
NICHT enthält: leeres Zitat. Nichts paraphrasieren.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"zitat": "…"}}
|
||||
25
templates/Atom-Extraktion-Aufgaben.md
Normal file
25
templates/Atom-Extraktion-Aufgaben.md
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- template:Atom-Extraktion-Aufgaben -->
|
||||
Thema: „{topic}". Quelle: „{quelle}" — eine AUFGABENSAMMLUNG (Übungen/Klausuren).
|
||||
|
||||
Extrahiere die GEÜBTEN KONZEPTE, nicht die Aufgaben. Für jede Aufgabe im Quelltext:
|
||||
Welches Konzept/Verfahren/welche Aussage wird hier geprüft? DAS wird das Atom.
|
||||
|
||||
Regeln:
|
||||
- „titel"/„definition": das Konzept ALLGEMEIN, destilliert — OHNE Instanzdetails der
|
||||
Aufgabe (konkrete Zahlen, erfundene Namen, Story-Verkleidungen, Punktzahlen).
|
||||
Beispiel: Die Aufgabe „Zeigen Sie, dass Problem X mit deg ≥ 42 einen Hamiltonkreis…"
|
||||
ergibt das Atom „NP-Vollständigkeit des Hamiltonkreis-Problems" — nie „X mit deg ≥ 42".
|
||||
- Aufgabeninstanzen, Aufgabennummern, Punktzahlen sind KEINE Atome und gehören in
|
||||
kein Feld außer dem Zitat.
|
||||
- „typ": begriff | aussage | verfahren. „level": E | M | S.
|
||||
- „zitat": die Aufgabenstelle WÖRTLICH kopiert (5–40 Wörter) — sie ist der Beleg,
|
||||
dass dieses Konzept geübt wird.
|
||||
- Prüft eine Aufgabe mehrere Konzepte, wird jedes ein eigenes Atom.
|
||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext, nichts erfinden.
|
||||
- „braucht": Titel anderer Atome DIESES Auszugs, die Voraussetzung sind. Leer, wenn keine.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||
[{{"titel": "…", "typ": "aussage", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]
|
||||
27
templates/Atom-Extraktion.md
Normal file
27
templates/Atom-Extraktion.md
Normal file
@@ -0,0 +1,27 @@
|
||||
<!-- template:Atom-Extraktion -->
|
||||
Thema: „{topic}". Quelle: „{quelle}".
|
||||
|
||||
Extrahiere aus dem QUELLTEXT unten ALLE Atome. Ein Atom ist die kleinste eigenständig
|
||||
lernbare Einheit, mit Typ:
|
||||
- **begriff** — eine Definition (z. B. „DFA")
|
||||
- **aussage** — Satz, Lemma, Eigenschaft (z. B. „Pumping-Lemma")
|
||||
- **verfahren** — Schritt-für-Schritt-Methode (z. B. „Potenzmengenkonstruktion")
|
||||
|
||||
Test: Kann man es einzeln abprüfen? Dann Atom. Beispiele und Übungsaufgaben sind
|
||||
KEINE Atome. Regeln:
|
||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext. Kein externes Wissen, keine Websuche, nichts erfinden.
|
||||
- „titel": Konzeptname aus den Begriffen der Quelle. Katalognummern („Satz 7.13") sind
|
||||
KEIN Titel; kein Lehrbuch-Oberbegriff, der nicht im Text steht.
|
||||
- „definition": 1–2 Sätze, eigenständig verständlich (dient später dem Dubletten-Vergleich).
|
||||
- „zitat": die Kernpassage WÖRTLICH aus dem Quelltext kopiert (exakt, Zeichen für Zeichen,
|
||||
5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
||||
- „level": E (Einstieg) | M (Mittel) | S (Schwer).
|
||||
- „braucht": Titel anderer Atome DIESES Auszugs, die man vorher verstanden haben muss
|
||||
(nur echte Voraussetzungen, keine bloße Verwandtschaft). Leer, wenn keine.
|
||||
- Vollständigkeit zählt: JEDES lernbare Konzept des Auszugs wird ein Atom.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…", "level": "M", "braucht": []}}]
|
||||
14
templates/Atom-Luecke.md
Normal file
14
templates/Atom-Luecke.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- template:Atom-Luecke -->
|
||||
Soll-Punkt ohne Atome: „{punkt}". Quelle: „{quelle}".
|
||||
|
||||
Der QUELLTEXT unten enthält die Belegstelle dieses Punkts. Extrahiere die Atome
|
||||
(kleinste lernbare Einheiten) GENAU ZU DIESEM PUNKT. Gleiche Regeln wie immer:
|
||||
- typ: begriff | aussage | verfahren. Beispiele/Aufgaben sind keine Atome.
|
||||
- „zitat": Kernpassage WÖRTLICH kopiert (5–40 Wörter). Ohne wörtliches Zitat kein Atom.
|
||||
- Nur was im Text steht, nichts erfinden. Keine Katalognummern als Titel.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"titel": "…", "typ": "begriff", "definition": "…", "zitat": "…"}}]
|
||||
18
templates/Atom-Merge.md
Normal file
18
templates/Atom-Merge.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- template:Atom-Merge -->
|
||||
Unten Paare von Atomen (je Titel, Definition, Beleg-Zitat aus der Quelle).
|
||||
|
||||
Entscheide pro Paar: Meinen A und B EXAKT dasselbe Konzept (gleich=true) oder nicht
|
||||
(gleich=false)? Regeln:
|
||||
- Vergleiche die BELEGE und Definitionen, nicht die Titelwörter.
|
||||
- gleich=true NUR bei identischem Gegenstand. Verwandt, Teilaspekt, Spezialfall,
|
||||
gleiche Aussage über verschiedene Objekte (z. B. Pumping-Lemma regulär vs. kontextfrei),
|
||||
umgekehrte Richtung einer Beziehung → gleich=false.
|
||||
- Ein falscher Merge zerstört Information; eine stehengebliebene Dublette ist harmlos.
|
||||
IM ZWEIFEL gleich=false.
|
||||
- Antworte für JEDES Paar.
|
||||
|
||||
PAARE:
|
||||
{paare}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"paar": 1, "gleich": false}}]
|
||||
13
templates/Atom-Soll-Stich.md
Normal file
13
templates/Atom-Soll-Stich.md
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- template:Atom-Soll-Stich -->
|
||||
Atom: „{titel}" — {definition}
|
||||
Beleg aus der Quelle: {beleg}
|
||||
|
||||
SOLL-PUNKTE (Format „id: punkt"):
|
||||
{soll}
|
||||
|
||||
Stichentscheid: Gehört dieses Atom zum Thema? Wenn ja, nenne die id des passendsten
|
||||
Soll-Punkts. Wenn es wirklich themenfremd ist: "fremd". Du MUSST entscheiden.
|
||||
Der Beleg zeigt, dass es im Material steht — Zweifel sprechen für Zuordnung, nicht für fremd.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"soll": 3}} oder {{"soll": "fremd"}}
|
||||
15
templates/Atom-Soll-Zuordnung.md
Normal file
15
templates/Atom-Soll-Zuordnung.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<!-- template:Atom-Soll-Zuordnung -->
|
||||
SOLL-PUNKTE (Format „id: punkt"):
|
||||
{soll}
|
||||
|
||||
ATOME (Format „id: titel — definition"):
|
||||
{atome}
|
||||
|
||||
Ordne JEDES Atom genau EINEM Soll-Punkt zu (dem thematisch passendsten).
|
||||
Regeln:
|
||||
- Nur die ids oben verwenden, keine erfinden.
|
||||
- Passt wirklich KEIN Punkt (Atom ist themenfremd), setze "soll": 0.
|
||||
- Antworte für jedes Atom.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"atom": 12, "soll": 3}}]
|
||||
28
templates/Guide-Fix.md
Normal file
28
templates/Guide-Fix.md
Normal file
@@ -0,0 +1,28 @@
|
||||
<!-- template:Guide-Fix -->
|
||||
Überarbeite diese Guide-Section in EINEM Durchgang unter ALLEN Aufträgen unten.
|
||||
|
||||
AUFTRÄGE:
|
||||
{auftraege}
|
||||
|
||||
FAKTENBASIS (einzige Wahrheit; zitierte Fakten wörtlich):
|
||||
{fakten}
|
||||
|
||||
SECTION (kompakt):
|
||||
{kompakt}
|
||||
|
||||
SECTION (lang):
|
||||
{lang}
|
||||
|
||||
Regeln:
|
||||
- Alle Marker-Zeilen (<!-- atom: … -->) EXAKT erhalten bzw. fehlende exakt einfügen —
|
||||
je Atom genau eine, unsichtbar, keine Überschrift.
|
||||
- Struktur erhalten: `####`-Zwischenüberschriften, „**Kernpunkte:**"- und
|
||||
„**Prüfe dich:**"-Block am Ende; andere Überschriften-Ebenen bleiben verboten.
|
||||
- Mathe in KaTeX (`$…$` / `$$…$$`) setzen bzw. belassen.
|
||||
- Nur die beauftragten Stellen ändern; den Rest wörtlich erhalten.
|
||||
- Frei und didaktisch formulieren (Deutsch); jede Zahl/Behauptung muss aus der
|
||||
Faktenbasis ableitbar sein. Keine Aufgabennummern/Punktzahlen/„Zeigen Sie…".
|
||||
- Nichts erfinden; Korrekturen ausschließlich aus der Faktenbasis.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"kompakt": "…", "lang": "…"}}
|
||||
32
templates/Guide-Pruefer.md
Normal file
32
templates/Guide-Pruefer.md
Normal file
@@ -0,0 +1,32 @@
|
||||
<!-- template:Guide-Pruefer -->
|
||||
Prüfe diese Guide-Section in EINEM Durchgang. Lernziel: {ziel}
|
||||
|
||||
FAKTENBASIS (einzige Wahrheit — Definitionen + wörtliche Belege):
|
||||
{fakten}
|
||||
|
||||
SECTION (kompakt):
|
||||
{kompakt}
|
||||
|
||||
SECTION (lang):
|
||||
{lang}
|
||||
|
||||
Prüfe:
|
||||
1. FAKTEN: Jeder inhaltliche Claim (Zahl, Name, Behauptung) muss aus der Faktenbasis
|
||||
ABLEITBAR sein. Widerspruch oder klar Erfundenes → art "falsch". Freie, sinngemäße
|
||||
Formulierung ist ausdrücklich ERWÜNSCHT und kein Befund — der Text soll lehren,
|
||||
nicht abschreiben. Belegte/korrekte Claims NICHT auflisten.
|
||||
2. COVERAGE: Fehlt ein Atom-Inhalt, sodass das Lernziel nicht erreichbar ist → art "luecke".
|
||||
3. LESBARKEIT: Grobe Mängel (Gedankensprünge, unerklärte Fachbegriffe, fremdsprachige
|
||||
Passagen, Aufgaben-Metadaten wie Aufgabennummern/Punktzahlen/„Zeigen Sie…") →
|
||||
art "stil", als konkreter, behebbarer Auftrag. Geschmack ist kein Befund.
|
||||
4. SELBSTSTÄNDIGKEIT: Der Text verweist auf seine Quellen („laut/gemäß Beleg",
|
||||
„im Skript", „aus der Hausaufgabe/Präsenzaufgabe bekannt", „die Quelle
|
||||
betrachtet…") → art "stil". Der Leser kennt keine Quellen; solche Verweise
|
||||
sind durch die Aussage selbst zu ersetzen. Normale Wendungen wie „ein Beleg
|
||||
dafür, dass" (= Nachweis) sind KEIN Befund.
|
||||
|
||||
„detail" = ein Satz, der den Befund für einen Fix-Agenten eindeutig macht.
|
||||
Keine Befunde → leere Liste.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"befunde": [{{"art": "falsch", "detail": "…"}}]}}
|
||||
53
templates/Guide-Writer.md
Normal file
53
templates/Guide-Writer.md
Normal file
@@ -0,0 +1,53 @@
|
||||
<!-- template:Guide-Writer -->
|
||||
Schreibe EINE Guide-Section für den Baustein „{titel}".
|
||||
Lernziel: {ziel}
|
||||
|
||||
Unten die Atome mit Marker, Definition, Belegen, ggf. Beispiel und Fragen-Pool.
|
||||
|
||||
Regeln (hart):
|
||||
1. LEHREN, NICHT ABSCHREIBEN: Formuliere frei und didaktisch in eigenen Worten —
|
||||
Lehrbuchton, auf Deutsch (fremdsprachige Belege sinngemäß übersetzen). Die
|
||||
Faktenbasis unten ist die einzige Quelle der Wahrheit: Jede Zahl, jeder Name,
|
||||
jede Behauptung muss aus ihr ABLEITBAR sein. Nichts erfinden, nichts
|
||||
dazurecherchieren — ein Fakten-Gate prüft jeden Claim.
|
||||
Aufgaben-Metadaten sind im Lehrtext VERBOTEN: keine Aufgabennummern, Punktzahlen,
|
||||
„Zeigen Sie…"-Formulierungen, keine Story-Verkleidungen aus Übungsaufgaben.
|
||||
KEINE Blockquotes („>"): Belege nie wörtlich einblocken — Inhalt gehört in
|
||||
eigenen Worten in den Fließtext.
|
||||
Der Text darf seine Quellen NIE erwähnen: kein „laut Beleg", „gemäß dem Beleg",
|
||||
„im Skript", „aus der Hausaufgabe bekannt" — der Leser kennt keine Quellen,
|
||||
der Text steht für sich.
|
||||
2. MARKER: Übernimm für jedes Atom seine Marker-Zeile EXAKT wie angegeben, direkt vor
|
||||
dem Absatz, der das Atom behandelt. Der Marker ist eine unsichtbare Schnittstelle,
|
||||
KEINE sichtbare Überschrift. Jeder Marker genau einmal. Kein Atom auslassen.
|
||||
3. STRUKTUR von „lang" (in dieser Reihenfolge):
|
||||
- Erster Satz direkt zur Sache — kein Vorgeplänkel, keine Motivation-Floskeln.
|
||||
- Absätze GLEICHMÄSSIG kurz: 40–90 Wörter. Nie über ~110 Wörter — dann teilen.
|
||||
- Je Konzept eine `####`-Zwischenüberschrift (Inhaltsaussage, nicht nur Stichwort) —
|
||||
nur bei echtem Konzeptwechsel, nicht nach jedem zweiten Absatz.
|
||||
Andere Überschriften-Ebenen (#, ##, ###) sind VERBOTEN.
|
||||
- Atom-Reihenfolge einhalten (sie ist didaktisch sortiert). EINFACHE Beispiele an
|
||||
der passenden Stelle einweben, wo sie das Atom klären — kleinste sinnvolle
|
||||
Instanz, Werte müssen aus der Faktenbasis ableitbar sein. Komplexe oder
|
||||
mehrstufige Beispiele weglassen oder auf ihren Kern vereinfachen.
|
||||
- Kohäsion: Jeder Absatz knüpft explizit an den vorigen an (Konnektor oder
|
||||
Übergangssatz). Fachbegriffe konsistent wiederverwenden — keine Synonym-Variation.
|
||||
- Abschluss: „**Kernpunkte:**" mit 3–5 Stichpunkten (nur Verdichtung, KEINE neuen
|
||||
Formulierungen) und „**Prüfe dich:**" mit genau 2 Fragen aus dem FRAGEN-POOL
|
||||
(wörtlich übernehmen, ohne Antworten).
|
||||
4. MATHE: Mathematische Ausdrücke in KaTeX setzen: inline `$…$`, abgesetzt `$$…$$`
|
||||
(z. B. `$0^{{2n}}1^{{2n}}$` statt roher Notation). NIE nackte LaTeX-Befehle
|
||||
ohne Delimiter in den Fließtext (`\leq`, `\text{{…}}`, Mengenklammern brauchen
|
||||
IMMER umschließende `$…$`) — ohne Delimiter rendert nichts.
|
||||
5. LÄNGE: „lang" hat {min_woerter}–{max_woerter} Wörter. Die Obergrenze ist ein HARTES
|
||||
Limit, kein Zielwert. Minimal schlägt ausführlich: jeder Satz zahlt aufs Lernziel
|
||||
ein, kein Fülltext, keine Wiederholungen.
|
||||
6. „kompakt": 2–3 Stichpunkte (Markdown-Liste) — Verdichtung der Section, KEINE
|
||||
Paraphrase, keine Marker.
|
||||
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht.
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa, KEINE Datei):
|
||||
{{"kompakt": "…", "lang": "…"}}
|
||||
12
templates/Kanten-Zyklus.md
Normal file
12
templates/Kanten-Zyklus.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<!-- template:Kanten-Zyklus -->
|
||||
Der Voraussetzungs-Graph enthält einen Zyklus (unten die Kanten, Format
|
||||
„von→zu: X braucht Y").
|
||||
|
||||
Wähle die SCHWÄCHSTE Kante — die, deren Voraussetzungs-Behauptung am wenigsten
|
||||
zwingend ist (eher Verwandtschaft als echte Voraussetzung). Genau eine.
|
||||
|
||||
ZYKLUS:
|
||||
{kanten}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences), mit den ids der gewählten Kante:
|
||||
{{"von": 12, "zu": 7}}
|
||||
17
templates/Kapitel-Intro.md
Normal file
17
templates/Kapitel-Intro.md
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- template:Kapitel-Intro -->
|
||||
Schreibe einen Advance Organizer für das Guide-Kapitel „{titel}".
|
||||
|
||||
Unten die Abschnitte des Kapitels (Titel: Lernziel).
|
||||
|
||||
Regeln:
|
||||
- 2–4 Sätze: Was kommt in diesem Kapitel, wie hängen die Abschnitte zusammen,
|
||||
worauf baut es auf.
|
||||
- NUR Landkarte, KEINE Inhalte vorwegnehmen — nichts erklären, nichts definieren
|
||||
(das wäre Redundanz zum Kapiteltext).
|
||||
- Kein Vorgeplänkel, keine Motivation-Floskeln („In diesem spannenden Kapitel…").
|
||||
|
||||
ABSCHNITTE:
|
||||
{bausteine}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"intro": "…"}}
|
||||
19
templates/Kapitel-Schnitt.md
Normal file
19
templates/Kapitel-Schnitt.md
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- template:Kapitel-Schnitt -->
|
||||
Unten die BAUSTEINE eines Lern-Guides in ihrer endgültigen Lese-Reihenfolge
|
||||
(Format „id: titel — lernziel").
|
||||
|
||||
Setze Kapitelgrenzen. Regeln:
|
||||
- Ein Kapitel = ein zusammenhängendes Teilthema. Grenze dort, wo ein neues beginnt.
|
||||
- Antworte mit EINER Zeile je Kapitel: "titel" plus "bis" = id des LETZTEN
|
||||
Bausteins dieses Kapitels. Die Reihenfolge ist fix — du setzt nur Grenzen.
|
||||
Das letzte "bis" muss die letzte id der Liste sein.
|
||||
- Richtwert: etwa {richtwert} Kapitel — weiche ab, wo Themenwechsel es verlangen.
|
||||
- „titel" = kurze Inhaltsaussage über ALLE Bausteine des Kapitels (kein
|
||||
Baustein-Titel-Duplikat, keine Nummerierung).
|
||||
{hinweis}
|
||||
|
||||
BAUSTEINE:
|
||||
{bausteine}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"titel": "…", "bis": 12}}, {{"titel": "…", "bis": 27}}]
|
||||
16
templates/Korpus-Recherche.md
Normal file
16
templates/Korpus-Recherche.md
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- template:Korpus-Recherche -->
|
||||
Du bist Recherche-Agent für das Lernthema „{topic}". Deine Perspektive (Lens): **{lens}**.
|
||||
|
||||
- curriculum: Was lehren Universitäts-Curricula/Modulhandbücher zu diesem Thema?
|
||||
- lehrbuch: Kapitelgliederungen + Kerninhalte der Standard-Lehrbücher.
|
||||
- syllabus: Kurs-Syllabi/Vorlesungspläne realer Kurse.
|
||||
- uebungen: Übungssammlungen/Aufgabentypen, die zu diesem Thema geübt werden.
|
||||
|
||||
Auftrag: Finde über die Websuche 2–3 hochwertige Quellen aus deiner Lens und extrahiere deren
|
||||
INHALT (nicht nur Links): Gliederung, zentrale Begriffe, Aussagen, Verfahren — als
|
||||
zusammenhängender Fließtext/Markdown, so wörtlich wie möglich an der Quelle.
|
||||
Mindestens 500 Wörter Inhalt pro Quelle, sonst Quelle weglassen. Nichts erfinden —
|
||||
nur was wirklich in der Quelle steht.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa davor/danach, KEINE Datei schreiben):
|
||||
[{{"titel": "…", "url": "…", "inhalt": "…"}}]
|
||||
12
templates/Korpus-Soll-Beleg.md
Normal file
12
templates/Korpus-Soll-Beleg.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<!-- template:Korpus-Soll-Beleg -->
|
||||
Soll-Punkt: „{punkt}"
|
||||
|
||||
Suche im QUELLTEXT unten eine Passage, die diesen Punkt belegt. Wenn es eine gibt:
|
||||
kopiere sie WÖRTLICH (exakt, 5–30 Wörter). Wenn der Text den Punkt NICHT behandelt:
|
||||
leeres Zitat. Nichts paraphrasieren, nichts erfinden.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"zitat": "…"}}
|
||||
25
templates/Korpus-Soll-Konsens.md
Normal file
25
templates/Korpus-Soll-Konsens.md
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- template:Korpus-Soll-Konsens -->
|
||||
Thema: „{topic}". Unten Soll-Punkt-KANDIDATEN aus verschiedenen Quellen (Format „id: punkt").
|
||||
|
||||
Gruppiere Kandidaten, die DASSELBE Teilthema meinen (Paraphrasen, Synonyme,
|
||||
Acronym vs. Ausschreibung). Regeln:
|
||||
- Du GRUPPIERST nur — ob ein Punkt bestätigt wird, entscheidet der Code (Quellenzählung).
|
||||
- VOLLSTÄNDIGKEIT: JEDE Kandidaten-id muss genau einmal vorkommen — in einer
|
||||
Themen-Gruppe oder in einer Ablehnungs-Gruppe. Nichts stillschweigend weglassen.
|
||||
- Ablehnen ("abgelehnt": true + "grund") darfst du nur, was kein eigenständig
|
||||
lernbares Teilthema ist: Meta-Überschriften („Einführung"), reine Aufgabeninstanzen.
|
||||
- MINDESTENS {band} Punkte, nach oben offen — im Zweifel feiner trennen. Die Punkte
|
||||
sind eine Prüf-CHECKLISTE („was gehört alles zum Thema"), keine Kapitel — jeder
|
||||
Punkt muss einzeln belegbar und prüfbar sein.
|
||||
- Verschiedene Teilthemen sind verschiedene Punkte: NICHT über-mergen. Ein Oberbegriff,
|
||||
der mehrere lernbare Teilthemen verschluckt (z. B. „NP-Vollständigkeit" für zehn
|
||||
einzelne Reduktionen), ist ein Fehler. Im Zweifel getrennt lassen.
|
||||
- Ids wörtlich übernehmen, keine erfinden.
|
||||
- „punkt" = die klarste Formulierung der Gruppe (aus den Kandidaten wählen, nicht neu erfinden).
|
||||
{hinweis}
|
||||
|
||||
KANDIDATEN:
|
||||
{kandidaten}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"punkt": "…", "kandidaten": [1, 2]}}, {{"abgelehnt": true, "grund": "…", "kandidaten": [3]}}]
|
||||
17
templates/Korpus-Soll.md
Normal file
17
templates/Korpus-Soll.md
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- template:Korpus-Soll -->
|
||||
Thema: „{topic}". Quelle: „{quelle}".
|
||||
|
||||
Extrahiere aus dem QUELLTEXT unten die Soll-Punkte: die prüfungsrelevanten Teilthemen,
|
||||
die dieses Material zum Thema abdeckt. Ein Soll-Punkt ist ein kurzes Teilthema
|
||||
(3–10 Wörter), kein ganzer Satz. Regeln:
|
||||
- Arbeite AUSSCHLIESSLICH mit dem Quelltext unten. Kein externes Wissen, nichts erfinden.
|
||||
- Zu jedem Punkt ein Beleg-Zitat: eine Passage WÖRTLICH aus dem Quelltext kopiert
|
||||
(exakt, Zeichen für Zeichen, 5–30 Wörter). Ohne wörtlichen Beleg keinen Punkt melden.
|
||||
- Katalognummern („Satz 7.13", „Kapitel 3") sind keine Punkte — der Inhalt zählt.
|
||||
- Metadaten, Organisatorisches, Literaturlisten sind keine Soll-Punkte.
|
||||
|
||||
QUELLTEXT:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences, keine Prosa):
|
||||
[{{"punkt": "…", "zitat": "…"}}]
|
||||
20
templates/Lernziele.md
Normal file
20
templates/Lernziele.md
Normal file
@@ -0,0 +1,20 @@
|
||||
<!-- template:Lernziele -->
|
||||
Soll-Punkt: „{punkt}". Unten die Atome dieses Punkts (Format „id: titel — definition").
|
||||
|
||||
Backward Design: Formuliere Lernziele („Kann …") und ordne JEDES Atom genau einem
|
||||
Ziel zu. Ein Ziel = eine didaktische Einheit, die man in einer Sitzung lernt.
|
||||
Regeln:
|
||||
- Ziele beschreiben Können, nicht Stoff („Kann reguläre Sprachen mit dem Pumping-Lemma
|
||||
widerlegen", nicht „Pumping-Lemma").
|
||||
- Atome, die man ZUSAMMEN lernt (Voraussetzungskette, gleicher Aufgabentyp), gehören
|
||||
in EIN Ziel. Richtwert 4–8 Atome pro Ziel — kein hartes Limit, Kohärenz geht vor.
|
||||
- Atom-ids wörtlich übernehmen, keine erfinden, jede id höchstens einmal.
|
||||
- „titel": ein Kapitelname zum Ziel — 3–6 Wörter, Substantiv-Stil (z. B.
|
||||
„Reduktion von SAT auf CLIQUE"), KEIN „Kann…"-Satz, keine Katalognummern.
|
||||
{pflicht}
|
||||
|
||||
ATOME:
|
||||
{atome}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"ziel": "Kann …", "titel": "…", "atome": [12, 13]}}]
|
||||
13
templates/QA-Guide-Falsch-Check.md
Normal file
13
templates/QA-Guide-Falsch-Check.md
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- template:QA-Guide-Falsch-Check -->
|
||||
Bestätigungs-Pass: Unten Verdachts-Claims (Format „nr: claim") und die Faktenbasis.
|
||||
Entscheide pro Claim: Widerspricht er der Faktenbasis WIRKLICH (falsch=true)?
|
||||
Sei streng gegen den Verdacht — nur ein klarer Widerspruch zählt. Antworte für jeden Claim.
|
||||
|
||||
FAKTENBASIS:
|
||||
{fakten}
|
||||
|
||||
CLAIMS:
|
||||
{claims}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"claim": 1, "falsch": false}}]
|
||||
14
templates/QA-Guide-Falsch.md
Normal file
14
templates/QA-Guide-Falsch.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- template:QA-Guide-Falsch -->
|
||||
Unabhängiges Audit: Finde in der Guide-Section unten Claims, die der Faktenbasis
|
||||
WIDERSPRECHEN (fachlich falsch). Nur echte Widersprüche — fehlende Belegung allein
|
||||
ist kein Befund, Formulierungsvarianten sind keiner. Zitiere den Claim wörtlich.
|
||||
Keine Funde → leere Liste.
|
||||
|
||||
FAKTENBASIS:
|
||||
{fakten}
|
||||
|
||||
SECTION:
|
||||
{text}
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
[{{"claim": "…"}}]
|
||||
14
templates/Soll-Abgedeckt.md
Normal file
14
templates/Soll-Abgedeckt.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- template:Soll-Abgedeckt -->
|
||||
Soll-Punkt: „{punkt}"
|
||||
|
||||
Vorhandene Atome des Themas:
|
||||
{atome}
|
||||
|
||||
Gezielte Nachextraktion hat für diesen Punkt keine eigenen Atome gefunden.
|
||||
Entscheide: Ist der Punkt durch die vorhandenen Atome INHALTLICH bereits abgedeckt
|
||||
(z. B. weil er ein Ober-/Unterthema eines anderen Punkts ist)?
|
||||
- abgedeckt=true nur, wenn die Atome den Punkt wirklich tragen.
|
||||
- abgedeckt=false, wenn hier tatsächlich Stoff fehlt.
|
||||
|
||||
Antworte NUR mit JSON (keine Code-Fences):
|
||||
{{"abgedeckt": false}}
|
||||
33
tests/conftest.py
Normal file
33
tests/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "backend"))
|
||||
|
||||
import db # noqa: E402
|
||||
import embedding # noqa: E402
|
||||
import korpus # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_welt(tmp_path, monkeypatch):
|
||||
"""Jeder Test: frische In-Memory-DB, Fake-Agenten, Korpus/Topics in tmp.
|
||||
Embedding fest auf Jaccard-Fallback — kein Modell-Download, deterministisch."""
|
||||
monkeypatch.setenv("CREATOR_FAKE_AGENTS", "1")
|
||||
monkeypatch.setattr(embedding, "_geladen", True)
|
||||
monkeypatch.setattr(embedding, "_modell", None)
|
||||
monkeypatch.setattr(korpus, "KORPUS_DIR", tmp_path / "korpus")
|
||||
monkeypatch.setattr(korpus, "TOPICS_DIR", tmp_path / "topics")
|
||||
db.on_change = None
|
||||
db.reset_for_tests(":memory:")
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def topic_anlegen(name="fake-thema", art="thema"):
|
||||
db.insert("topics", name=name, titel=name, art=art, provider="minimax")
|
||||
return name
|
||||
|
||||
|
||||
def run_anlegen(topic, budget=0):
|
||||
return db.insert("runs", topic=topic, status="running", budget_tokens=budget)
|
||||
44
tests/test_artefakte.py
Normal file
44
tests/test_artefakte.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Ebene 2: Quellen-Referenz-Guard — Karten müssen ohne den Quelltext
|
||||
funktionieren (Lauf 8: 17 verifizierte Karten fragten „was steht im Beleg")."""
|
||||
|
||||
import artefakte
|
||||
import db
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def test_referenz_muster_trifft_nur_referenzen():
|
||||
schlecht = [
|
||||
{"frage": "Welchen Namen trägt die Technik, die im Beleg erwähnt wird?",
|
||||
"antwort": "k-Enumeration.", "text": ""},
|
||||
{"frage": "Was ist MAX-3-SAT?",
|
||||
"antwort": "Aus dem Beleg geht nur hervor, dass es in Hausaufgabe 13.1 vorkommt.",
|
||||
"text": ""},
|
||||
{"frage": "Welche Aufgabe wird in Aufgabe 1 gestellt?", "antwort": "x", "text": ""},
|
||||
{"frage": "Wie groß ist |V'|?", "antwort": "Laut Musterlösung genau |V|.", "text": ""},
|
||||
]
|
||||
gut = [ # Fachwörter dürfen NICHT matchen (generisch bleiben)
|
||||
{"frage": "Was ist eine aussagenlogische Belegung?",
|
||||
"antwort": "Eine Zuordnung von Wahrheitswerten zu Variablen.", "text": ""},
|
||||
{"frage": "Was verbindet ein Fluss mit Quelle und Senke?",
|
||||
"antwort": "Er belegt jede Kante mit einem Wert unter der Kapazität.", "text": ""},
|
||||
]
|
||||
assert all(artefakte._referenziert_quelle(k) for k in schlecht)
|
||||
assert not any(artefakte._referenziert_quelle(k) for k in gut)
|
||||
|
||||
|
||||
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
|
||||
topic = topic_anlegen("guard")
|
||||
run = run_anlegen(topic)
|
||||
a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d",
|
||||
status="neu", braucht=db.j([]))
|
||||
k = db.insert("artefakte", atom_id=a, typ="flashcard", status="kandidat",
|
||||
inhalt=db.j({"frage": "Was steht im Beleg?", "antwort": "x", "text": ""}))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "artefakte"
|
||||
atom = db.one("SELECT * FROM atome WHERE id=?", (a,))
|
||||
await artefakte._verifizieren(ctx, [atom]) # Guard greift VOR dem Panel
|
||||
assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "verworfen"
|
||||
# verworfene zählen nicht als versorgt: Repair generiert für das Atom nach
|
||||
chunks = artefakte._chunks(topic, nur_ohne=True)
|
||||
assert [x["id"] for c in chunks for x in c] == [a]
|
||||
187
tests/test_bausteine.py
Normal file
187
tests/test_bausteine.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Deterministische Bausteine: textkit, jsonx, auto_loop, QA-Note."""
|
||||
|
||||
import auto_loop
|
||||
import jsonx
|
||||
import qa
|
||||
import textkit
|
||||
|
||||
|
||||
def test_finde_zitat_whitespace_tolerant():
|
||||
text = "Der Automat\nbesteht aus Zuständen."
|
||||
span = textkit.finde_zitat(text, "Der Automat besteht aus Zuständen.")
|
||||
assert span is not None
|
||||
start, ende = span
|
||||
assert text[start:ende].split() == ["Der", "Automat", "besteht", "aus", "Zuständen."]
|
||||
|
||||
|
||||
def test_finde_zitat_case_fallback_und_fehlschlag():
|
||||
assert textkit.finde_zitat("DER AUTOMAT LÄUFT", "der Automat läuft") is not None
|
||||
assert textkit.finde_zitat("völlig anderer Text", "der Automat läuft") is None
|
||||
|
||||
|
||||
def test_finde_zitat_locker_interpunktion():
|
||||
# PDF-Extrakte variieren Interpunktion/Dashes/Quotes — Wortlaut gleich → Treffer
|
||||
text = "Es gilt: „Ein Automat – mit Zuständen – akzeptiert reguläre Sprachen."
|
||||
zitat = 'Ein Automat, mit Zuständen, akzeptiert reguläre Sprachen'
|
||||
span = textkit.finde_zitat(text, zitat)
|
||||
assert span is not None
|
||||
assert "Automat" in text[span[0]:span[1]]
|
||||
# Paraphrase (andere Wörter) bleibt draußen
|
||||
assert textkit.finde_zitat(text, "Ein DFA mit Zuständen akzeptiert alle Sprachen") is None
|
||||
# zu kurze Zitate matchen locker nicht (Eindeutigkeit)
|
||||
assert textkit.finde_zitat("abc def", "ab, c") is None
|
||||
|
||||
|
||||
def test_finde_zitat_dekomponierte_umlaute():
|
||||
"""Lektion 82: pdftotext liefert dekomponierte Umlaute (a+U+0308), das LLM-Zitat
|
||||
präkomponierte (ä) — die Locker-Stufe faltet beide auf den Basisbuchstaben.
|
||||
Vorher scheiterte fast jedes deutsche Zitat (aak: 59/96 Atome ohne Anker)."""
|
||||
import unicodedata
|
||||
text = unicodedata.normalize("NFD", "Satz 6.25. 3-SAT ist NP-vollständig. Beweis folgt später.")
|
||||
span = textkit.finde_zitat(text, "Satz 6.25. 3-SAT ist NP-vollständig.")
|
||||
assert span is not None
|
||||
assert text[span[0]:span[1]].startswith("Satz 6.25")
|
||||
# umgekehrt: präkomponierter Text, dekomponiertes Zitat
|
||||
assert textkit.finde_zitat(
|
||||
"Die Erfüllbarkeit boolescher Ausdrücke ist zentral.",
|
||||
unicodedata.normalize("NFD", "Erfüllbarkeit boolescher Ausdrücke ist zentral")) is not None
|
||||
|
||||
|
||||
def test_snapshot_schreiben_normalisiert_nfc():
|
||||
"""Korpus-Snapshots werden am Import NFC-normalisiert — die exakte Zitat-Stufe
|
||||
darf nicht an unsichtbar anderen Bytes scheitern."""
|
||||
import unicodedata
|
||||
import korpus
|
||||
roh = unicodedata.normalize("NFD", "NP-vollständig und erfüllbar")
|
||||
assert not unicodedata.is_normalized("NFC", roh)
|
||||
pfad, _h = korpus._snapshot_schreiben("nfc-test", roh)
|
||||
inhalt = open(pfad, encoding="utf-8").read()
|
||||
assert unicodedata.is_normalized("NFC", inhalt)
|
||||
assert inhalt == unicodedata.normalize("NFC", roh)
|
||||
|
||||
|
||||
def test_snapshot_schreiben_ersetzt_kontrollzeichen():
|
||||
"""PDF-Schriften mappen Sonderglyphen (ε) auf Steuerbytes — der Reader echot sie
|
||||
als Müll und das Zitat wird unmatchbar. Import ersetzt sie durch Leerzeichen;
|
||||
\\n und \\t bleiben (Layout)."""
|
||||
import korpus
|
||||
pfad, _h = korpus._snapshot_schreiben("ctrl-test", "Algorithmus (A\x0f )\nZeile\tzwei\x07!")
|
||||
inhalt = open(pfad, encoding="utf-8").read()
|
||||
assert inhalt == "Algorithmus (A )\nZeile\tzwei !"
|
||||
|
||||
|
||||
def test_finde_zitat_fuzzy_listing_zeilennummern():
|
||||
"""Stufe 4 (Lektion 83): pdftotext streut Listing-Zeilennummern in den Text,
|
||||
das Reader-Zitat lässt sie weg — fuzzy mit harter Distanzschranke matcht trotzdem."""
|
||||
text = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m)\n 1 for i=1 to m do\n"
|
||||
" 2 Ei = 0; Bi = ∅;\n 3 od\n 4 for j=1 to n do\n"
|
||||
" 5 wähle i mit Ei minimal;\n 6 od\n")
|
||||
zitat = ("Algorithmus ListScheduling(L=(J1 , . . . , Jn ),m) for i=1 to m do Ei = 0; "
|
||||
"Bi = ∅; od for j=1 to n do wähle i mit Ei minimal; od")
|
||||
span = textkit.finde_zitat(text, zitat)
|
||||
assert span is not None
|
||||
assert text[span[0]:span[1]].startswith("Algorithmus ListScheduling")
|
||||
|
||||
|
||||
def test_finde_zitat_fuzzy_rekonstruierte_glyphe():
|
||||
"""PDF verlor die ε-Glyphe („(A )"), der Reader zitiert „(Aε)" — 1 Zeichen
|
||||
Differenz auf langem Zitat → Treffer."""
|
||||
text = ("Man nennt eine solche Familie von Algorithmen (A ) ein vollständiges "
|
||||
"polynomielles Approximationsschema (FPTAS).")
|
||||
zitat = ("Man nennt eine solche Familie von Algorithmen (Aε) ein vollständiges "
|
||||
"polynomielles Approximationsschema (FPTAS).")
|
||||
assert textkit.finde_zitat(text, zitat) is not None
|
||||
|
||||
|
||||
def test_finde_zitat_fuzzy_mehrdeutig_bleibt_draussen():
|
||||
"""Eindeutigkeits-Guard: zwei fast identische Definitionen (SubSetSum/Partition-
|
||||
Muster) → kein Anker statt Falsch-Anker."""
|
||||
a = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl K. Entscheide: Gibt es eine Teilmenge S?"
|
||||
b = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl X. Entscheide: Gibt es eine Teilmenge T?"
|
||||
zitat = "Gegeben: n ganze Zahlen c1 , . . . , cn und eine Zahl Q. Entscheide: Gibt es eine Teilmenge U?"
|
||||
assert textkit.finde_zitat(a + "\n\nDazwischen steht anderer Text.\n\n" + b, zitat) is None
|
||||
|
||||
|
||||
def test_finde_zitat_fuzzy_grenzen():
|
||||
"""Paraphrase über der Distanzschranke bleibt draußen; Kurz-Zitate nie fuzzy."""
|
||||
text = "Der List-Scheduling-Algorithmus verteilt Jobs der Reihe nach auf die am wenigsten belastete Maschine."
|
||||
assert textkit.finde_zitat(text, "List Scheduling weist jeden Job der aktuell "
|
||||
"günstigsten Maschine in Reihenfolge zu und stoppt") is None
|
||||
assert textkit.finde_zitat("kurzer Text über ähm Dinge", "kurzer Test über ähm Dinge") is None
|
||||
|
||||
|
||||
def test_snapshot_schreiben_ftfy_mojibake():
|
||||
"""ftfy am Import: Mojibake und Ligaturen werden repariert (Lektion 83)."""
|
||||
import importlib.util
|
||||
import pytest
|
||||
if importlib.util.find_spec("ftfy") is None:
|
||||
pytest.skip("ftfy nicht installiert — NFC-Fallback deckt test_snapshot_schreiben_normalisiert_nfc")
|
||||
import korpus
|
||||
pfad, _h = korpus._snapshot_schreiben("ftfy-test", "effiziente NP-vollständige Suffixe")
|
||||
inhalt = open(pfad, encoding="utf-8").read()
|
||||
assert "vollständige" in inhalt # Mojibake ä → ä
|
||||
assert "Suffixe" in inhalt # Ligatur ffi → ffi
|
||||
|
||||
|
||||
def test_abschnitte_und_ueberlappung():
|
||||
text = ("Absatz eins.\n\n" * 50) + ("Absatz zwei.\n\n" * 50)
|
||||
teile = textkit.abschnitte(text, max_chars=300)
|
||||
assert all(len(t) <= 300 for _, t in teile)
|
||||
assert "".join(t for _, t in teile) == text
|
||||
assert textkit.ueberlappung((0, 10), (5, 15)) == 0.5
|
||||
assert textkit.ueberlappung((0, 10), (20, 30)) == 0.0
|
||||
|
||||
|
||||
def test_negations_guard():
|
||||
assert textkit.negations_menge("L ist nicht regulär") != textkit.negations_menge("L ist regulär")
|
||||
|
||||
|
||||
def test_jsonx_fences_und_prosa():
|
||||
assert jsonx.parse('```json\n{"a": 1}\n```') == {"a": 1}
|
||||
assert jsonx.parse('Hier das Ergebnis: [{"b": "x — y"}] Danke!') == [{"b": "x — y"}]
|
||||
assert jsonx.parse("kein json") is None
|
||||
assert jsonx.parse('{"s": "mit \\"quote\\" und }"}') == {"s": 'mit "quote" und }'}
|
||||
|
||||
|
||||
async def test_auto_loop_bedingungen():
|
||||
note, grund = await auto_loop.auto_repair_loop("t", 10.0, None)
|
||||
assert grund == "fertig"
|
||||
|
||||
async def stillstand():
|
||||
return 8.0, False
|
||||
note, grund = await auto_loop.auto_repair_loop("t", 8.0, stillstand)
|
||||
assert grund == "stillstand"
|
||||
|
||||
zaehler = {"n": 0}
|
||||
|
||||
async def livelock():
|
||||
zaehler["n"] += 1
|
||||
return 8.0, True # bewegt, aber nie besser → hartes Limit greift
|
||||
note, grund = await auto_loop.auto_repair_loop("t", 7.0, livelock, max_iter=4)
|
||||
assert grund == "limit" and zaehler["n"] == 4
|
||||
|
||||
|
||||
def test_qa_note_formel():
|
||||
assert qa.note([], 10) == 10.0
|
||||
n = qa.note([{"art": "marker_fehlend", "item": "1", "detail": ""}], 10)
|
||||
assert n < 10.0
|
||||
viele = [{"art": "atom_ohne_anker", "item": str(i), "detail": ""} for i in range(50)]
|
||||
assert 0.0 <= qa.note(viele, 10) <= 9.9
|
||||
|
||||
|
||||
def test_det_auftraege_blockquote_und_artefakt():
|
||||
import db
|
||||
import guide
|
||||
from conftest import topic_anlegen
|
||||
topic = topic_anlegen("detcheck")
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b_id = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu")
|
||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="d",
|
||||
status="neu", baustein_id=b_id, braucht=db.j([]))
|
||||
lang = (f"<!-- atom: {a} | A | E -->\n" + "Fließtext. " * 45
|
||||
+ "\n> wörtliches Rohzitat aus der Quelle\nEs gilt u 6= v."
|
||||
+ "\n\nDas Blank-Symbol $[. ist speziell und liegt in $[ \\in \\Gamma$.")
|
||||
auftraege = guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang)
|
||||
text = " ".join(auftraege)
|
||||
assert "Blockquote" in text and "6=" in text
|
||||
assert "ungerade Anzahl $-Zeichen" in text
|
||||
54
tests/test_budget_infra.py
Normal file
54
tests/test_budget_infra.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Budget-Stopp (hart) und Infra-Fail-closed (Lauf-Pause statt Teilergebnis)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import agents
|
||||
import ledger
|
||||
import llm
|
||||
import pytest
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
async def test_budget_stoppt_hart(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic, budget=120) # Fake-Call kostet 150 Tokens
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
with pytest.raises(ledger.BudgetErschoepft):
|
||||
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert ledger.verbraucht(run) >= 120 # der Verbrauch bleibt sichtbar
|
||||
|
||||
|
||||
async def test_infra_pause_statt_fail_open(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
|
||||
async def immer_429(key, prompt, timeout, **kw):
|
||||
return agents.AgentErgebnis(1, "", "HTTP 429: rate limited")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", immer_429)
|
||||
monkeypatch.setattr(llm, "INFRA_BACKOFF_BASE", 0.01)
|
||||
with pytest.raises(llm.LaufPause):
|
||||
await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
stati = [e["status"] for e in
|
||||
__import__("db").query("SELECT status FROM events WHERE run_id=?", (run,))]
|
||||
assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger
|
||||
|
||||
|
||||
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "test"
|
||||
|
||||
async def unsinn(key, prompt, timeout, **kw):
|
||||
return agents.AgentErgebnis(0, "kein json", "")
|
||||
|
||||
monkeypatch.setattr(agents, "run_agent", unsinn)
|
||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
||||
werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list)
|
||||
assert res is None # begrenzte Restarts, dann None — kein Absturz, keine Pause
|
||||
167
tests/test_e2e.py
Normal file
167
tests/test_e2e.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Fake-E2E: kompletter Generierungspfad in Sekunden. Thema (Web-Recherche) und
|
||||
Uni (Dateien), plus Resume-Idempotenz."""
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
|
||||
import db
|
||||
import guide
|
||||
import korpus
|
||||
import pipeline
|
||||
from conftest import topic_anlegen
|
||||
from fake_agents import FAKE_TEXT
|
||||
|
||||
|
||||
async def _lauf_komplett(topic):
|
||||
run_id = pipeline.lauf_starten(topic)
|
||||
await pipeline._laeufe[topic]
|
||||
return run_id
|
||||
|
||||
|
||||
def _pruefe_endzustand(topic, run_id):
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "fertig"
|
||||
run = db.one("SELECT * FROM runs WHERE id=?", (run_id,))
|
||||
assert run["status"] == "done"
|
||||
offen = db.query("SELECT * FROM befunde WHERE run_id=? AND status='offen'", (run_id,))
|
||||
assert offen == [], f"offene Befunde: {offen}"
|
||||
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||
" ('gemerged','verworfen')", (topic,))
|
||||
assert len(atome) == 3 # die Fake-Welt hat exakt drei Atome
|
||||
assert all(a["soll_id"] and a["ziel_id"] and a["baustein_id"] for a in atome)
|
||||
for a in atome:
|
||||
assert db.one("SELECT id FROM anker WHERE atom_id=?", (a["id"],))
|
||||
karten = db.query("SELECT * FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||
" AND status='verifiziert'", (a["id"],))
|
||||
assert karten
|
||||
md = guide.guide_markdown(topic)
|
||||
for a in atome:
|
||||
assert f"<!-- atom: {a['id']} |" in md
|
||||
# Kapitel-Struktur: H2 = Kapitel (Struktur-Ebene, mit Intro), H3 = Baustein
|
||||
assert "\n## " in "\n" + md and "### " in md
|
||||
assert "**Kernpunkte:**" in md and "**Prüfe dich:**" in md
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert kaps, "keine Kapitel gebildet"
|
||||
for k in kaps:
|
||||
assert k["intro"], f"Kapitel-Intro fehlt: {k['titel']}"
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert all(b["kapitel_id"] for b in bausteine)
|
||||
folge = [b["kapitel_id"] for b in bausteine]
|
||||
segmente = [k for k, _ in itertools.groupby(folge)]
|
||||
assert len(segmente) == len(set(segmente)) == len(kaps) # kontiguierlich, lückenlos
|
||||
for b in db.query("SELECT titel FROM bausteine WHERE topic=?", (topic,)):
|
||||
assert not b["titel"].startswith("Kann"), b["titel"] # Kurztitel, kein Ziel-Satz
|
||||
events = db.query("SELECT * FROM events WHERE run_id=?", (run_id,))
|
||||
assert events and all(e["template_hash"] for e in events if e["template"])
|
||||
|
||||
|
||||
async def test_e2e_thema():
|
||||
topic = topic_anlegen("fake-thema", art="thema")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
# zwei kleine Ziele → Merge über Soll-Grenzen (Entkopplung): EIN Baustein
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,))
|
||||
assert len(bausteine) == 1
|
||||
|
||||
|
||||
async def test_e2e_uni(tmp_path):
|
||||
ordner = korpus.TOPICS_DIR / "fake-uni"
|
||||
ordner.mkdir(parents=True)
|
||||
(ordner / "skript.txt").write_text(FAKE_TEXT, encoding="utf-8")
|
||||
(ordner / "uebung.txt").write_text(FAKE_TEXT + "\nSerie 1.\n", encoding="utf-8")
|
||||
topic = topic_anlegen("fake-uni", art="uni")
|
||||
run_id = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run_id)
|
||||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (topic,))
|
||||
assert len(quellen) == 2 and all(q["art"] == "datei" for q in quellen)
|
||||
rollen = {q["titel"]: q["rolle"] for q in quellen}
|
||||
assert rollen["skript.txt"] == "stoff" and rollen["uebung.txt"] == "aufgaben"
|
||||
|
||||
|
||||
async def test_voll_reset_extrahiert_neu():
|
||||
topic = topic_anlegen("fake-vollreset", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
pipeline.voll_reset(topic)
|
||||
assert db.query("SELECT * FROM atome WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM artefakte WHERE atom_id IN"
|
||||
" (SELECT id FROM atome WHERE topic=?)", (topic,)) == []
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2) # kompletter Neuaufbau inkl. neuer Atome
|
||||
|
||||
|
||||
async def test_soll_reset_kein_doppel():
|
||||
topic = topic_anlegen("fake-reset", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
|
||||
pipeline.soll_reset(topic)
|
||||
assert db.query("SELECT * FROM soll WHERE topic=?", (topic,)) == []
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # Extraktions-Guard: keine Doppel-Atome
|
||||
|
||||
|
||||
async def test_auto_stopp_und_fortsetzen():
|
||||
topic = topic_anlegen("fake-auto", art="thema")
|
||||
db.update("topics", "name", topic, auto=db.j({"inventar": False}))
|
||||
run1 = await _lauf_komplett(topic)
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "inventar_fertig"
|
||||
r = db.one("SELECT * FROM runs WHERE id=?", (run1,))
|
||||
assert r["status"] == "done" and "Auto-Stopp" in r["grund"]
|
||||
db.update("topics", "name", topic, auto=db.j({})) # Auto wieder an → durchlaufen
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
|
||||
|
||||
async def test_ebenen_entfernen_kaskade():
|
||||
topic = topic_anlegen("fake-entfernen", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
pipeline.ebenen_entfernen(topic, "struktur")
|
||||
assert db.query("SELECT * FROM bausteine WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM kapitel WHERE topic=?", (topic,)) == []
|
||||
assert db.query("SELECT * FROM lernziele WHERE topic=?", (topic,)) == []
|
||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
|
||||
# Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben)
|
||||
resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {})
|
||||
assert set(resets) == {"struktur", "guide"}
|
||||
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
|
||||
run2 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run2)
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # Atome blieben erhalten
|
||||
|
||||
|
||||
async def test_topic_loeschen():
|
||||
topic = topic_anlegen("fake-weg", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
pipeline.topic_loeschen(topic)
|
||||
for tabelle in ("topics", "quellen", "soll", "atome", "lernziele", "bausteine",
|
||||
"kapitel", "runs"):
|
||||
feld = "name" if tabelle == "topics" else "topic"
|
||||
assert db.query(f"SELECT * FROM {tabelle} WHERE {feld}=?", (topic,)) == [], tabelle
|
||||
|
||||
|
||||
async def test_resume_idempotent():
|
||||
topic = topic_anlegen("fake-resume", art="thema")
|
||||
run1 = await _lauf_komplett(topic)
|
||||
_pruefe_endzustand(topic, run1)
|
||||
atome_vorher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
calls_vorher = len(db.query("SELECT id FROM events", ()))
|
||||
|
||||
run2 = pipeline.lauf_starten(topic) # zweiter Lauf auf fertigem Topic
|
||||
await pipeline._laeufe[topic]
|
||||
assert db.one("SELECT status FROM runs WHERE id=?", (run2,))["status"] == "done"
|
||||
atome_nachher = {a["id"] for a in db.query(
|
||||
"SELECT id FROM atome WHERE topic=? AND status NOT IN ('gemerged','verworfen')", (topic,))}
|
||||
assert atome_nachher == atome_vorher # nichts dupliziert
|
||||
assert len(db.query("SELECT id FROM events", ())) == calls_vorher # keine neuen LLM-Calls
|
||||
94
tests/test_inventar.py
Normal file
94
tests/test_inventar.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Anker-Repair: unverankerte Zitate (start=-1) werden erst deterministisch
|
||||
neu gematcht — ohne LLM-Call."""
|
||||
|
||||
import db
|
||||
import inventar
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def test_merge_kollision_und_kette():
|
||||
topic = topic_anlegen("mergekante")
|
||||
a = db.insert("atome", topic=topic, titel="A", typ="begriff", definition="lang genug",
|
||||
status="neu", braucht=db.j([]))
|
||||
b = db.insert("atome", topic=topic, titel="B", typ="begriff", definition="kurz",
|
||||
status="neu", braucht=db.j([]))
|
||||
c = db.insert("atome", topic=topic, titel="C", typ="begriff", definition="x",
|
||||
status="neu", braucht=db.j([]))
|
||||
# beide haben dieselbe Kante zu C → blindes Umhängen würde UNIQUE verletzen
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, c))
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, c))
|
||||
# und eine Kante zwischen den Merge-Partnern → würde Selbstkante
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, a))
|
||||
inventar._merge(topic, a, b)
|
||||
kanten = db.query("SELECT * FROM kanten WHERE topic=?", (topic,))
|
||||
assert len(kanten) == 1 and kanten[0]["von_atom"] == a and kanten[0]["zu_atom"] == c
|
||||
# Merge-Kette: (B, C) — B ist schon gemerged, Wurzel A übernimmt
|
||||
inventar._merge(topic, b, c)
|
||||
assert db.one("SELECT merged_into FROM atome WHERE id=?", (c,))["merged_into"] == a
|
||||
|
||||
|
||||
async def test_anker_rematch_ohne_llm(tmp_path):
|
||||
topic = topic_anlegen("rematch")
|
||||
run = run_anlegen(topic)
|
||||
snap = tmp_path / "q.md"
|
||||
snap.write_text("Vorher. Der Satz steht hier drin. Nachher.", encoding="utf-8")
|
||||
q = db.insert("quellen", topic=topic, art="datei", titel="q",
|
||||
snapshot=str(snap), hash="h", status="atome")
|
||||
a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d",
|
||||
status="ohne_anker", braucht=db.j([]))
|
||||
# Extraktion fand das Zitat nicht (Whitespace-Differenz), hat es aber gespeichert
|
||||
db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1,
|
||||
zitat="Der Satz steht hier drin.")
|
||||
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
assert await inventar._anker_fixen_batch(ctx, [a]) is True
|
||||
row = db.one("SELECT * FROM anker WHERE atom_id=? AND start>=0", (a,))
|
||||
assert row is not None
|
||||
assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "neu"
|
||||
assert db.query("SELECT id FROM events WHERE run_id=?", (run,)) == [] # kein LLM-Call
|
||||
assert inventar.messen(ctx) == [] or all(
|
||||
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
|
||||
|
||||
|
||||
def test_titel_kern_faltet_schreibvarianten():
|
||||
import textkit
|
||||
assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
|
||||
assert textkit.titel_kern("FeedbackVertexSet ∈ NP") == textkit.titel_kern("Feedback Vertex Set ∈ NP")
|
||||
assert textkit.titel_kern("!!!") == ""
|
||||
|
||||
|
||||
def test_fallback_nutzt_jaccard_schwelle(monkeypatch):
|
||||
import embedding
|
||||
a = "Polynomialzeitreduktionen sind transitiv wenn L1 auf L2 und L2 auf L3 reduzierbar sind"
|
||||
b = ("Polynomialzeitreduktionen sind transitiv wenn eine Sprache L1 auf L2"
|
||||
" und L2 auf L3 in Polynomialzeit reduzierbar ist")
|
||||
# Kosinus-Schwelle allein: Paraphrase rutscht durch (aak Lauf 8)
|
||||
assert embedding.kandidaten_paare([a, b], 0.75) == []
|
||||
paare = embedding.kandidaten_paare([a, b], 0.75, 0.3)
|
||||
assert [(i, j) for i, j, _ in paare] == [(0, 1)]
|
||||
|
||||
|
||||
async def test_titel_dublette_wird_gemerged_trotz_ferner_definition():
|
||||
topic = topic_anlegen("titeldup")
|
||||
run = run_anlegen(topic)
|
||||
# identischer Titel, Definitionen lexikalisch fern (Jaccard < 0.3):
|
||||
# nur der Titel-Kern-Generator bringt das Paar ans Panel
|
||||
a = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||
definition="Entscheidungsproblem, ob ein Graph ein Vertex Cover"
|
||||
" der Größe höchstens k enthält.",
|
||||
status="neu", braucht=db.j([]))
|
||||
b = db.insert("atome", topic=topic, titel="VERTEX COVER Problem", typ="begriff",
|
||||
definition="Gefragt wird nach einer Knotenmenge, die jede Kante"
|
||||
" abdeckt und maximal k Elemente hat.",
|
||||
status="neu", braucht=db.j([]))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "inventar"
|
||||
await inventar._judge_dedup(ctx)
|
||||
stati = {r["id"]: r["status"] for r in db.query(
|
||||
"SELECT id, status FROM atome WHERE topic=?", (topic,))}
|
||||
assert sorted(stati.values()) == ["gemerged", "neu"]
|
||||
105
tests/test_korpus.py
Normal file
105
tests/test_korpus.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Ebene 0, Konsens: Vollständigkeits-Invariante (kein Kandidat verschwindet
|
||||
still), explizite Ablehnung, uni-Beleg-Regel (1 Beleg reicht)."""
|
||||
|
||||
import re
|
||||
|
||||
import db
|
||||
import fake_agents
|
||||
import korpus
|
||||
import llm
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def _seed(art="uni", n_quellen=1):
|
||||
topic = topic_anlegen("k-test", art=art)
|
||||
quellen = [db.insert("quellen", topic=topic, art="datei", titel=f"q{i}.txt",
|
||||
snapshot=f"q{i}.txt", hash=f"h{i}",
|
||||
status="extrahiert", rolle="stoff")
|
||||
for i in range(n_quellen)]
|
||||
ctx = llm.Kontext(run_anlegen(topic), topic, "minimax")
|
||||
ctx.ebene = "korpus"
|
||||
return topic, quellen, ctx
|
||||
|
||||
|
||||
def _kandidat(topic, punkt, quelle):
|
||||
return db.insert("soll", topic=topic, punkt=punkt, status="kandidat",
|
||||
belege=db.j([{"quelle": quelle, "zitat": f"Zitat {punkt}"}]))
|
||||
|
||||
|
||||
def _ids(prompt):
|
||||
return [int(i) for i in re.findall(r"^(\d+): ", prompt.split("KANDIDATEN:")[1],
|
||||
re.MULTILINE)]
|
||||
|
||||
|
||||
async def test_nachrunde_ordnet_vergessene_zu(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
b = _kandidat(topic, "Beta", q)
|
||||
c = _kandidat(topic, "Gamma", q)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt: # nur die fehlende id wird angeboten
|
||||
assert _ids(prompt) == [c] and "Alpha" in prompt
|
||||
return [{"punkt": "Alpha", "kandidaten": [c]}] # wörtlich → Merge
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]},
|
||||
{"punkt": "Beta", "kandidaten": [b]}] # c stillschweigend vergessen
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 2 # uni: 1 Quelle reicht als Beleg
|
||||
alpha = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'"
|
||||
" AND punkt='Alpha'", (topic,))
|
||||
assert len(db.uj(alpha["belege"])) == 2 # Gamma-Beleg in Alpha aufgegangen
|
||||
for i in (a, b, c):
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (i,))["status"] == "gefaltet"
|
||||
assert korpus.messen(ctx) == []
|
||||
|
||||
|
||||
async def test_ablehnung_ist_explizit(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
m = _kandidat(topic, "Einführung ins Thema", q)
|
||||
|
||||
def judge(prompt):
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]},
|
||||
{"abgelehnt": True, "grund": "Meta-Überschrift", "kandidaten": [m]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 1
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (m,))["status"] == "abgelehnt"
|
||||
assert korpus.messen(ctx) == [] # abgelehnt ist erledigt, kein Befund
|
||||
|
||||
|
||||
async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
|
||||
topic, (q,), ctx = _seed()
|
||||
a = _kandidat(topic, "Alpha", q)
|
||||
c = _kandidat(topic, "Gamma", q)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt:
|
||||
return [] # Judge verweigert die Zuordnung dauerhaft
|
||||
return [{"punkt": "Alpha", "kandidaten": [a]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
await korpus._konsens(ctx)
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (c,))["status"] == "kandidat"
|
||||
befunde = korpus.messen(ctx)
|
||||
assert [b["art"] for b in befunde] == ["soll_kandidat_offen"]
|
||||
assert befunde[0]["item"] == str(c)
|
||||
|
||||
|
||||
async def test_thema_braucht_zwei_quellen(monkeypatch):
|
||||
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
||||
x1 = _kandidat(topic, "X", q1)
|
||||
x2 = _kandidat(topic, "X (Synonym)", q2)
|
||||
y = _kandidat(topic, "Y", q1)
|
||||
|
||||
def judge(prompt):
|
||||
if "NACHRUNDE" in prompt:
|
||||
return []
|
||||
return [{"punkt": "X", "kandidaten": [x1, x2]},
|
||||
{"punkt": "Y", "kandidaten": [y]}]
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge)
|
||||
assert await korpus._konsens(ctx) == 1 # X: 2 Quellen; Y: nur 1 → kein Punkt
|
||||
assert db.one("SELECT status FROM soll WHERE id=?", (y,))["status"] == "kandidat"
|
||||
assert {b["art"] for b in korpus.messen(ctx)} == {"soll_kandidat_offen"}
|
||||
127
tests/test_struktur.py
Normal file
127
tests/test_struktur.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Struktur-Ebene: Topo-Sortierung, Zyklenbruch, Band-Schnitt — mit synthetischen Atomen."""
|
||||
|
||||
import db
|
||||
import llm
|
||||
import struktur
|
||||
from conftest import run_anlegen, topic_anlegen
|
||||
|
||||
|
||||
def _atom(topic, titel, ziel_id=None, soll_id=1):
|
||||
return db.insert("atome", topic=topic, titel=titel, typ="begriff",
|
||||
definition=f"Definition {titel}", status="neu",
|
||||
soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([]))
|
||||
|
||||
|
||||
def test_topo_ordnung():
|
||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
||||
assert struktur._topo([1, 2, 3], [(3, 1), (2, 3)], rang) == [1, 3, 2]
|
||||
|
||||
|
||||
def test_topo_fremde_kanten_bleiben_draussen():
|
||||
# Kante zu Atom 3 (nicht in der Gruppe) darf weder 3 hineinziehen
|
||||
# noch Gruppenmitglieder verdrängen (aak-Bug: 22 Atome ohne Baustein)
|
||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||
out = struktur._topo([1, 2], [(1, 3), (2, 1)], rang)
|
||||
assert out == [1, 2]
|
||||
|
||||
|
||||
def test_zyklus_finden():
|
||||
assert struktur._finde_zyklus([1, 2], [(1, 2), (2, 1)]) is not None
|
||||
assert struktur._finde_zyklus([1, 2, 3], [(2, 1), (3, 2)]) is None
|
||||
|
||||
|
||||
async def test_zyklen_brechen_und_schneiden():
|
||||
topic = topic_anlegen()
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
a = _atom(topic, "A", ziel, soll)
|
||||
b = _atom(topic, "B", ziel, soll)
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, b))
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, b, a))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
await struktur._zyklen_brechen(ctx)
|
||||
aktiv = db.query("SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'",
|
||||
(topic,))
|
||||
assert len(aktiv) == 1 # genau eine Kante gebrochen
|
||||
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
atome = struktur._atome(topic)
|
||||
assert all(x["baustein_id"] for x in atome)
|
||||
|
||||
|
||||
async def test_band_split():
|
||||
topic = topic_anlegen("band")
|
||||
run = run_anlegen(topic)
|
||||
soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([]))
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv")
|
||||
for i in range(18): # 18 Atome in EINEM Ziel → muss in ≤8er-Teile splitten
|
||||
_atom(topic, f"A{i}", ziel, soll)
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert len(bausteine) >= 3
|
||||
for bs in bausteine:
|
||||
n = db.one("SELECT COUNT(*) n FROM atome WHERE baustein_id=?", (bs["id"],))["n"]
|
||||
assert 4 <= n <= 8
|
||||
befunde = struktur.messen(ctx)
|
||||
assert not [x for x in befunde if x["art"] in ("band", "partition", "zyklus")]
|
||||
|
||||
|
||||
async def test_merge_ohne_soll_schranke_und_kapitel():
|
||||
# Entkopplung: kleines Ziel merged über Soll-Punkt-Grenzen (Partner per Kante);
|
||||
# Kapitel entstehen danach als kontiguierliche Segmente
|
||||
topic = topic_anlegen("kapitel")
|
||||
run = run_anlegen(topic)
|
||||
s1 = db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([]))
|
||||
s2 = db.insert("soll", topic=topic, punkt="P2", status="bestaetigt", belege=db.j([]))
|
||||
z1 = db.insert("lernziele", topic=topic, text="Kann P1", soll_id=s1, status="aktiv")
|
||||
z2 = db.insert("lernziele", topic=topic, text="Kann P2", soll_id=s2, status="aktiv")
|
||||
a = _atom(topic, "A", z1, s1) # 1-Atom-Ziel, anderer Soll-Punkt als z2
|
||||
b0 = _atom(topic, "B0", z2, s2)
|
||||
for i in (1, 2, 3):
|
||||
_atom(topic, f"B{i}", z2, s2)
|
||||
db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')",
|
||||
(topic, a, b0))
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
struktur._bausteine_schneiden(ctx)
|
||||
atome = struktur._atome(topic)
|
||||
assert len({x["baustein_id"] for x in atome}) == 1 # gemerged trotz fremdem Soll
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
bausteine = db.query("SELECT * FROM bausteine WHERE topic=?", (topic,))
|
||||
assert bausteine and all(x["kapitel_id"] for x in bausteine)
|
||||
assert struktur.messen(ctx) == []
|
||||
|
||||
|
||||
async def test_kapitel_retry_und_fallback(monkeypatch):
|
||||
import fake_agents
|
||||
topic = topic_anlegen("kapfall")
|
||||
run = run_anlegen(topic)
|
||||
ziel = db.insert("lernziele", topic=topic, text="Kann X", status="aktiv")
|
||||
b = [db.insert("bausteine", topic=topic, ziel_id=ziel, titel=f"B{i}", ord=i,
|
||||
status="neu") for i in range(4)]
|
||||
ctx = llm.Kontext(run, topic, "minimax")
|
||||
ctx.ebene = "struktur"
|
||||
|
||||
aufrufe = {"n": 0}
|
||||
def judge(prompt):
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1:
|
||||
return [{"titel": "kaputt", "bis": 999999}] # ungültige Grenze
|
||||
return [{"titel": "Gutes Kapitel", "bis": b[-1]}]
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt", judge)
|
||||
await struktur._kapitel_bilden(ctx)
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert aufrufe["n"] == 2 and len(kaps) == 1 and kaps[0]["art"] == "judge"
|
||||
|
||||
monkeypatch.setitem(fake_agents._HANDLER, "Kapitel-Schnitt",
|
||||
lambda p: [{"titel": "x", "bis": 999999}])
|
||||
await struktur._kapitel_bilden(ctx) # beide Versuche ungültig → Fallback
|
||||
kaps = db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))
|
||||
assert kaps and all(k["art"] == "fallback" for k in kaps)
|
||||
assert "kapitel_fallback" in {bf["art"] for bf in struktur.messen(ctx)}
|
||||
Reference in New Issue
Block a user