From 0a41166ccaf3c4086f1cd5f76ccef0ef029abcf9 Mon Sep 17 00:00:00 2001 From: team3 Date: Fri, 10 Jul 2026 15:43:11 +0200 Subject: [PATCH] init --- .env.example | 19 + .gitignore | 8 + DESIGN.md | 90 ++ LEKTIONEN.md | 115 +++ Makefile | 27 + backend/agents.py | 369 ++++++++ backend/artefakte.py | 172 ++++ backend/auto_loop.py | 27 + backend/config.py | 158 ++++ backend/db.py | 190 ++++ backend/embedding.py | 67 ++ backend/fake_agents.py | 245 +++++ backend/guide.py | 408 ++++++++ backend/inventar.py | 456 +++++++++ backend/jsonx.py | 53 ++ backend/korpus.py | 378 ++++++++ backend/ledger.py | 61 ++ backend/llm.py | 161 ++++ backend/main.py | 259 +++++ backend/pipeline.py | 198 ++++ backend/prompts.py | 29 + backend/qa.py | 87 ++ backend/struktur.py | 424 +++++++++ backend/textkit.py | 155 +++ backend/ws.py | 41 + frontend/index.html | 12 + frontend/package-lock.json | 1211 ++++++++++++++++++++++++ frontend/package.json | 24 + frontend/src/App.vue | 185 ++++ frontend/src/api.js | 44 + frontend/src/components/Board.vue | 177 ++++ frontend/src/components/Guide.vue | 159 ++++ frontend/src/components/Kennzahlen.vue | 50 + frontend/src/components/Ueben.vue | 49 + frontend/src/main.js | 6 + frontend/src/markdown.js | 102 ++ frontend/src/style.css | 243 +++++ frontend/vite.config.js | 6 + pytest.ini | 3 + templates/Artefakt-Fix.md | 16 + templates/Artefakt-Generate.md | 26 + templates/Artefakt-Verify.md | 18 + templates/Atom-Anker-Fix-Batch.md | 17 + templates/Atom-Anker-Fix.md | 12 + templates/Atom-Extraktion-Aufgaben.md | 25 + templates/Atom-Extraktion.md | 27 + templates/Atom-Luecke.md | 14 + templates/Atom-Merge.md | 18 + templates/Atom-Soll-Stich.md | 13 + templates/Atom-Soll-Zuordnung.md | 15 + templates/Guide-Fix.md | 28 + templates/Guide-Pruefer.md | 32 + templates/Guide-Writer.md | 53 ++ templates/Kanten-Zyklus.md | 12 + templates/Kapitel-Intro.md | 17 + templates/Kapitel-Schnitt.md | 19 + templates/Korpus-Recherche.md | 16 + templates/Korpus-Soll-Beleg.md | 12 + templates/Korpus-Soll-Konsens.md | 25 + templates/Korpus-Soll.md | 17 + templates/Lernziele.md | 20 + templates/QA-Guide-Falsch-Check.md | 13 + templates/QA-Guide-Falsch.md | 14 + templates/Soll-Abgedeckt.md | 14 + tests/conftest.py | 33 + tests/test_artefakte.py | 44 + tests/test_bausteine.py | 187 ++++ tests/test_budget_infra.py | 54 ++ tests/test_e2e.py | 167 ++++ tests/test_inventar.py | 94 ++ tests/test_korpus.py | 105 ++ tests/test_struktur.py | 127 +++ 72 files changed, 7772 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DESIGN.md create mode 100644 LEKTIONEN.md create mode 100644 Makefile create mode 100644 backend/agents.py create mode 100644 backend/artefakte.py create mode 100644 backend/auto_loop.py create mode 100644 backend/config.py create mode 100644 backend/db.py create mode 100644 backend/embedding.py create mode 100644 backend/fake_agents.py create mode 100644 backend/guide.py create mode 100644 backend/inventar.py create mode 100644 backend/jsonx.py create mode 100644 backend/korpus.py create mode 100644 backend/ledger.py create mode 100644 backend/llm.py create mode 100644 backend/main.py create mode 100644 backend/pipeline.py create mode 100644 backend/prompts.py create mode 100644 backend/qa.py create mode 100644 backend/struktur.py create mode 100644 backend/textkit.py create mode 100644 backend/ws.py create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api.js create mode 100644 frontend/src/components/Board.vue create mode 100644 frontend/src/components/Guide.vue create mode 100644 frontend/src/components/Kennzahlen.vue create mode 100644 frontend/src/components/Ueben.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/markdown.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js create mode 100644 pytest.ini create mode 100644 templates/Artefakt-Fix.md create mode 100644 templates/Artefakt-Generate.md create mode 100644 templates/Artefakt-Verify.md create mode 100644 templates/Atom-Anker-Fix-Batch.md create mode 100644 templates/Atom-Anker-Fix.md create mode 100644 templates/Atom-Extraktion-Aufgaben.md create mode 100644 templates/Atom-Extraktion.md create mode 100644 templates/Atom-Luecke.md create mode 100644 templates/Atom-Merge.md create mode 100644 templates/Atom-Soll-Stich.md create mode 100644 templates/Atom-Soll-Zuordnung.md create mode 100644 templates/Guide-Fix.md create mode 100644 templates/Guide-Pruefer.md create mode 100644 templates/Guide-Writer.md create mode 100644 templates/Kanten-Zyklus.md create mode 100644 templates/Kapitel-Intro.md create mode 100644 templates/Kapitel-Schnitt.md create mode 100644 templates/Korpus-Recherche.md create mode 100644 templates/Korpus-Soll-Beleg.md create mode 100644 templates/Korpus-Soll-Konsens.md create mode 100644 templates/Korpus-Soll.md create mode 100644 templates/Lernziele.md create mode 100644 templates/QA-Guide-Falsch-Check.md create mode 100644 templates/QA-Guide-Falsch.md create mode 100644 templates/Soll-Abgedeckt.md create mode 100644 tests/conftest.py create mode 100644 tests/test_artefakte.py create mode 100644 tests/test_bausteine.py create mode 100644 tests/test_budget_infra.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_inventar.py create mode 100644 tests/test_korpus.py create mode 100644 tests/test_struktur.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..31f8189 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6317852 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.env +storage/ +topics/ +frontend/node_modules/ +frontend/dist/ +.pytest_cache/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..42cdd73 --- /dev/null +++ b/DESIGN.md @@ -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// (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). diff --git a/LEKTIONEN.md b/LEKTIONEN.md new file mode 100644 index 0000000..9018820 --- /dev/null +++ b/LEKTIONEN.md @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b8a00df --- /dev/null +++ b/Makefile @@ -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 diff --git a/backend/agents.py b/backend/agents.py new file mode 100644 index 0000000..7660fbc --- /dev/null +++ b/backend/agents.py @@ -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) diff --git a/backend/artefakte.py b/backend/artefakte.py new file mode 100644 index 0000000..f739ba7 --- /dev/null +++ b/backend/artefakte.py @@ -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 diff --git a/backend/auto_loop.py b/backend/auto_loop.py new file mode 100644 index 0000000..c8d6084 --- /dev/null +++ b/backend/auto_loop.py @@ -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" diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..9968bd9 --- /dev/null +++ b/backend/config.py @@ -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//*.txt|md|pdf +KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus//q.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 diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..964d0c2 --- /dev/null +++ b/backend/db.py @@ -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 [] diff --git a/backend/embedding.py b/backend/embedding.py new file mode 100644 index 0000000..c47b9e1 --- /dev/null +++ b/backend/embedding.py @@ -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]) diff --git a/backend/fake_agents.py b/backend/fake_agents.py new file mode 100644 index 0000000..d7db1a7 --- /dev/null +++ b/backend/fake_agents.py @@ -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 `` 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"") + + +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\): ()", + 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, +} diff --git a/backend/guide.py b/backend/guide.py new file mode 100644 index 0000000..00454c9 --- /dev/null +++ b/backend/guide.py @@ -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"\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"(? 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) diff --git a/backend/inventar.py b/backend/inventar.py new file mode 100644 index 0000000..8d128e2 --- /dev/null +++ b/backend/inventar.py @@ -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 diff --git a/backend/jsonx.py b/backend/jsonx.py new file mode 100644 index 0000000..178cca9 --- /dev/null +++ b/backend/jsonx.py @@ -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 diff --git a/backend/korpus.py b/backend/korpus.py new file mode 100644 index 0000000..3fa8c73 --- /dev/null +++ b/backend/korpus.py @@ -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// 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// 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 diff --git a/backend/ledger.py b/backend/ledger.py new file mode 100644 index 0000000..cf03cf3 --- /dev/null +++ b/backend/ledger.py @@ -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,)) diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..b722b2f --- /dev/null +++ b/backend/llm.py @@ -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 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b97ca33 --- /dev/null +++ b/backend/main.py @@ -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") diff --git a/backend/pipeline.py b/backend/pipeline.py new file mode 100644 index 0000000..57818cc --- /dev/null +++ b/backend/pipeline.py @@ -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) diff --git a/backend/prompts.py b/backend/prompts.py new file mode 100644 index 0000000..31a04a0 --- /dev/null +++ b/backend/prompts.py @@ -0,0 +1,29 @@ +"""Template-Lader: templates/.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() diff --git a/backend/qa.py b/backend/qa.py new file mode 100644 index 0000000..46da468 --- /dev/null +++ b/backend/qa.py @@ -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 diff --git a/backend/struktur.py b/backend/struktur.py new file mode 100644 index 0000000..52fbf81 --- /dev/null +++ b/backend/struktur.py @@ -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 diff --git a/backend/textkit.py b/backend/textkit.py new file mode 100644 index 0000000..7c363da --- /dev/null +++ b/backend/textkit.py @@ -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 diff --git a/backend/ws.py b/backend/ws.py new file mode 100644 index 0000000..c1497ca --- /dev/null +++ b/backend/ws.py @@ -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() diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c408d5e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + creator2 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..256917c --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1211 @@ +{ + "name": "creator2-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "creator2-frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/katex": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz", + "integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz", + "integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..90218ad --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ad34798 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,185 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..9c7da46 --- /dev/null +++ b/frontend/src/api.js @@ -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() +} diff --git a/frontend/src/components/Board.vue b/frontend/src/components/Board.vue new file mode 100644 index 0000000..f25d645 --- /dev/null +++ b/frontend/src/components/Board.vue @@ -0,0 +1,177 @@ + + + diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue new file mode 100644 index 0000000..3ff6ce5 --- /dev/null +++ b/frontend/src/components/Guide.vue @@ -0,0 +1,159 @@ + + + diff --git a/frontend/src/components/Kennzahlen.vue b/frontend/src/components/Kennzahlen.vue new file mode 100644 index 0000000..ee8586a --- /dev/null +++ b/frontend/src/components/Kennzahlen.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/components/Ueben.vue b/frontend/src/components/Ueben.vue new file mode 100644 index 0000000..97e0bb1 --- /dev/null +++ b/frontend/src/components/Ueben.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..2abc215 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' +import '@fontsource-variable/inter' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/markdown.js b/frontend/src/markdown.js new file mode 100644 index 0000000..bede527 --- /dev/null +++ b/frontend/src/markdown.js @@ -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 = //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 ? `
${html}
` : html + }).join('') +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..1624902 --- /dev/null +++ b/frontend/src/style.css @@ -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); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..c40aa3c --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], +}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/templates/Artefakt-Fix.md b/templates/Artefakt-Fix.md new file mode 100644 index 0000000..474646c --- /dev/null +++ b/templates/Artefakt-Fix.md @@ -0,0 +1,16 @@ + +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": "…"}} diff --git a/templates/Artefakt-Generate.md b/templates/Artefakt-Generate.md new file mode 100644 index 0000000..5b44370 --- /dev/null +++ b/templates/Artefakt-Generate.md @@ -0,0 +1,26 @@ + +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": "…"}}] diff --git a/templates/Artefakt-Verify.md b/templates/Artefakt-Verify.md new file mode 100644 index 0000000..e2e0bf4 --- /dev/null +++ b/templates/Artefakt-Verify.md @@ -0,0 +1,18 @@ + +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": ""}}] diff --git a/templates/Atom-Anker-Fix-Batch.md b/templates/Atom-Anker-Fix-Batch.md new file mode 100644 index 0000000..088a087 --- /dev/null +++ b/templates/Atom-Anker-Fix-Batch.md @@ -0,0 +1,17 @@ + +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": "…"}}] diff --git a/templates/Atom-Anker-Fix.md b/templates/Atom-Anker-Fix.md new file mode 100644 index 0000000..b35872a --- /dev/null +++ b/templates/Atom-Anker-Fix.md @@ -0,0 +1,12 @@ + +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": "…"}} diff --git a/templates/Atom-Extraktion-Aufgaben.md b/templates/Atom-Extraktion-Aufgaben.md new file mode 100644 index 0000000..b2735e6 --- /dev/null +++ b/templates/Atom-Extraktion-Aufgaben.md @@ -0,0 +1,25 @@ + +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": []}}] diff --git a/templates/Atom-Extraktion.md b/templates/Atom-Extraktion.md new file mode 100644 index 0000000..302cb4d --- /dev/null +++ b/templates/Atom-Extraktion.md @@ -0,0 +1,27 @@ + +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": []}}] diff --git a/templates/Atom-Luecke.md b/templates/Atom-Luecke.md new file mode 100644 index 0000000..df8d9db --- /dev/null +++ b/templates/Atom-Luecke.md @@ -0,0 +1,14 @@ + +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": "…"}}] diff --git a/templates/Atom-Merge.md b/templates/Atom-Merge.md new file mode 100644 index 0000000..745227e --- /dev/null +++ b/templates/Atom-Merge.md @@ -0,0 +1,18 @@ + +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}}] diff --git a/templates/Atom-Soll-Stich.md b/templates/Atom-Soll-Stich.md new file mode 100644 index 0000000..49fe889 --- /dev/null +++ b/templates/Atom-Soll-Stich.md @@ -0,0 +1,13 @@ + +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"}} diff --git a/templates/Atom-Soll-Zuordnung.md b/templates/Atom-Soll-Zuordnung.md new file mode 100644 index 0000000..43ae207 --- /dev/null +++ b/templates/Atom-Soll-Zuordnung.md @@ -0,0 +1,15 @@ + +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}}] diff --git a/templates/Guide-Fix.md b/templates/Guide-Fix.md new file mode 100644 index 0000000..f24536a --- /dev/null +++ b/templates/Guide-Fix.md @@ -0,0 +1,28 @@ + +Ü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 () 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": "…"}} diff --git a/templates/Guide-Pruefer.md b/templates/Guide-Pruefer.md new file mode 100644 index 0000000..fd80842 --- /dev/null +++ b/templates/Guide-Pruefer.md @@ -0,0 +1,32 @@ + +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": "…"}}]}} diff --git a/templates/Guide-Writer.md b/templates/Guide-Writer.md new file mode 100644 index 0000000..bb1a199 --- /dev/null +++ b/templates/Guide-Writer.md @@ -0,0 +1,53 @@ + +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": "…"}} diff --git a/templates/Kanten-Zyklus.md b/templates/Kanten-Zyklus.md new file mode 100644 index 0000000..441c541 --- /dev/null +++ b/templates/Kanten-Zyklus.md @@ -0,0 +1,12 @@ + +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}} diff --git a/templates/Kapitel-Intro.md b/templates/Kapitel-Intro.md new file mode 100644 index 0000000..7c18fb9 --- /dev/null +++ b/templates/Kapitel-Intro.md @@ -0,0 +1,17 @@ + +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": "…"}} diff --git a/templates/Kapitel-Schnitt.md b/templates/Kapitel-Schnitt.md new file mode 100644 index 0000000..a08f146 --- /dev/null +++ b/templates/Kapitel-Schnitt.md @@ -0,0 +1,19 @@ + +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}}] diff --git a/templates/Korpus-Recherche.md b/templates/Korpus-Recherche.md new file mode 100644 index 0000000..d19c091 --- /dev/null +++ b/templates/Korpus-Recherche.md @@ -0,0 +1,16 @@ + +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": "…"}}] diff --git a/templates/Korpus-Soll-Beleg.md b/templates/Korpus-Soll-Beleg.md new file mode 100644 index 0000000..25b4975 --- /dev/null +++ b/templates/Korpus-Soll-Beleg.md @@ -0,0 +1,12 @@ + +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": "…"}} diff --git a/templates/Korpus-Soll-Konsens.md b/templates/Korpus-Soll-Konsens.md new file mode 100644 index 0000000..e697388 --- /dev/null +++ b/templates/Korpus-Soll-Konsens.md @@ -0,0 +1,25 @@ + +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]}}] diff --git a/templates/Korpus-Soll.md b/templates/Korpus-Soll.md new file mode 100644 index 0000000..6b64178 --- /dev/null +++ b/templates/Korpus-Soll.md @@ -0,0 +1,17 @@ + +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": "…"}}] diff --git a/templates/Lernziele.md b/templates/Lernziele.md new file mode 100644 index 0000000..4f013b9 --- /dev/null +++ b/templates/Lernziele.md @@ -0,0 +1,20 @@ + +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]}}] diff --git a/templates/QA-Guide-Falsch-Check.md b/templates/QA-Guide-Falsch-Check.md new file mode 100644 index 0000000..c88c82f --- /dev/null +++ b/templates/QA-Guide-Falsch-Check.md @@ -0,0 +1,13 @@ + +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}}] diff --git a/templates/QA-Guide-Falsch.md b/templates/QA-Guide-Falsch.md new file mode 100644 index 0000000..e4c0604 --- /dev/null +++ b/templates/QA-Guide-Falsch.md @@ -0,0 +1,14 @@ + +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": "…"}}] diff --git a/templates/Soll-Abgedeckt.md b/templates/Soll-Abgedeckt.md new file mode 100644 index 0000000..af92ee3 --- /dev/null +++ b/templates/Soll-Abgedeckt.md @@ -0,0 +1,14 @@ + +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}} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c642adf --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_artefakte.py b/tests/test_artefakte.py new file mode 100644 index 0000000..c4fc69f --- /dev/null +++ b/tests/test_artefakte.py @@ -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] diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py new file mode 100644 index 0000000..e5aa52b --- /dev/null +++ b/tests/test_bausteine.py @@ -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"\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 diff --git a/tests/test_budget_infra.py b/tests/test_budget_infra.py new file mode 100644 index 0000000..555c1f1 --- /dev/null +++ b/tests/test_budget_infra.py @@ -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 diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..a3eebd2 --- /dev/null +++ b/tests/test_e2e.py @@ -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"