Compare commits

171 Commits

Author SHA1 Message Date
team3
2178c6faf4 update 2026-07-08 23:46:35 +02:00
team3
f9d77a113b update 2026-07-08 21:14:33 +02:00
team3
9a6ab0937b update 2026-07-08 18:55:36 +02:00
team3
224ba1ed4f update 2026-07-07 19:10:30 +02:00
team3
27364d0789 update 2026-07-07 02:37:12 +02:00
team3
f2bc6bcedb update 2026-07-07 02:25:29 +02:00
team3
41f96330fd update 2026-07-07 01:07:10 +02:00
team3
f0c1bdacfa update 2026-07-07 00:25:23 +02:00
team3
f2feb1dbd0 update 2026-07-06 20:57:41 +02:00
team3
1441c01f2c update 2026-07-06 20:30:15 +02:00
team3
9c55b05373 update 2026-07-06 18:53:43 +02:00
team3
72a82ed9dd update 2026-07-06 18:25:57 +02:00
team3
73ac3e532f update 2026-07-06 17:32:52 +02:00
team3
cc27e53b9e update 2026-07-06 14:44:20 +02:00
team3
f4c5116abb update 2026-07-05 23:03:00 +02:00
team3
ed5cf146c3 update 2026-07-05 20:43:25 +02:00
Team3
250ea0b764 refactor 2026-07-05 15:26:22 +02:00
Team3
07e14fb82e update 2026-07-05 13:26:32 +02:00
Team3
fb3e967fdb update 2026-07-05 13:07:12 +02:00
Team3
6a765b7d89 update 2026-07-05 11:21:29 +02:00
Team3
9d5940e21f update 2026-07-05 11:12:41 +02:00
Team3
54cb6f10e7 update 2026-07-05 10:52:36 +02:00
Team3
9b6bfa45c6 update 2026-07-05 10:38:24 +02:00
Team3
db58d52567 update 2026-07-05 00:54:21 +02:00
Team3
219993ffd5 update 2026-07-05 00:38:03 +02:00
Team3
d25824229e update 2026-07-05 00:25:53 +02:00
Team3
4105146c59 update 2026-07-04 23:47:04 +02:00
Team3
2d9ca00b47 update 2026-07-04 21:27:47 +02:00
Team3
c05421a8c1 update 2026-07-04 19:20:48 +02:00
Team3
92c69c1561 update 2026-07-04 16:50:50 +02:00
team3
8488737303 Training-Harness (ACO, Multi-Fidelity), Prüfstand-Benchmark, Agenten-README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:47:19 +02:00
team3
8d8f6c8e51 update 2026-07-04 12:21:45 +02:00
team3
2f5d5b9ca1 update 2026-07-04 03:25:02 +02:00
team3
c4caf31ed0 update 2026-07-04 02:32:31 +02:00
team3
91b0d00aa1 update 2026-07-03 12:50:32 +02:00
team3
9754cbcfae update 2026-07-03 11:55:38 +02:00
team3
abcadd145d update 2026-07-03 11:45:27 +02:00
team3
285317927d update 2026-07-02 22:48:57 +02:00
team3
41c9f29a37 update 2026-07-02 03:05:57 +02:00
root
afa8b36105 update 2026-07-01 20:00:57 +00:00
team3
a2a5da25df update 2026-06-30 00:40:10 +02:00
team3
3669fd8d0a update 2026-06-30 00:36:41 +02:00
team3
c794fcaccf update 2026-06-30 00:14:18 +02:00
team3
3e3559aa8f update 2026-06-29 18:47:37 +02:00
team3
3382a426cf update 2026-06-29 18:23:25 +02:00
team3
12f97031e8 update 2026-06-29 17:11:23 +02:00
team3
87fc9b209a update 2026-06-29 15:35:41 +02:00
team3
753b95b689 update 2026-06-29 01:46:34 +02:00
team3
bf7ebe045b update 2026-06-29 00:09:24 +02:00
team3
07b6736ced update 2026-06-28 17:05:18 +02:00
root
5b71fcbcd7 update 2026-06-28 12:03:24 +00:00
team3
8c69c44b37 update 2026-06-27 11:08:36 +02:00
team3
2266606ca2 update 2026-06-27 10:05:37 +02:00
team3
3f15df7e9b update 2026-06-27 10:03:02 +02:00
team3
8a43839f18 update 2026-06-27 09:50:50 +02:00
team3
f018ca5048 update 2026-06-27 00:58:46 +02:00
team3
2a2026c9ae update 2026-06-25 18:17:37 +02:00
team3
e0a5f4956f update 2026-06-24 22:33:12 +02:00
team3
ccbe7765d4 update 2026-06-24 21:52:31 +02:00
team3
e0f1b59707 update 2026-06-24 21:27:11 +02:00
team3
c802c7fd02 update 2026-06-24 20:38:45 +02:00
team3
c1310a31cd update 2026-06-24 19:46:18 +02:00
team3
2ba76e40dd update 2026-06-24 19:41:34 +02:00
team3
d223cc6a07 update 2026-06-24 19:15:25 +02:00
team3
55c5063da7 update 2026-06-24 14:29:40 +02:00
team3
46ae62a335 update 2026-06-24 13:19:40 +02:00
team3
fed25795cc update 2026-06-24 12:47:47 +02:00
team3
324d2e73b4 update 2026-06-24 12:35:10 +02:00
team3
caa9314de5 update 2026-06-24 11:56:12 +02:00
team3
e985f07696 update 2026-06-24 09:38:20 +02:00
team3
d389584b58 update 2026-06-24 09:25:14 +02:00
team3
3b1e84e17d update 2026-06-24 07:52:05 +02:00
team3
12125d0f2d update 2026-06-24 07:19:01 +02:00
team3
9e888c216a update 2026-06-23 21:44:28 +02:00
team3
6a5a1cccd9 update 2026-06-23 19:26:38 +02:00
team3
cec8d1ee5f update 2026-06-23 16:23:12 +02:00
team3
cd1c926599 update 2026-06-23 12:04:17 +02:00
team3
9a02e8dc76 update 2026-06-23 11:44:56 +02:00
team3
0eaa72c536 update 2026-06-23 11:20:44 +02:00
team3
c07e1e9e30 update 2026-06-23 11:12:31 +02:00
team3
8fe0e8581f update 2026-06-23 09:58:43 +02:00
team3
28d0b494cd update 2026-06-22 22:21:23 +02:00
team3
b3b5dbf37d update 2026-06-22 17:43:31 +02:00
team3
c5543c0529 update 2026-06-22 16:30:39 +02:00
team3
36b7aabdb0 update 2026-06-22 15:23:39 +02:00
team3
0951337a29 update 2026-06-22 14:26:07 +02:00
team3
bd1de0770a update 2026-06-22 14:13:12 +02:00
team3
613a4a2b1f update 2026-06-22 13:44:33 +02:00
team3
0adc223c0a update 2026-06-22 12:55:52 +02:00
team3
04900eef55 update 2026-06-22 12:24:44 +02:00
team3
2d1a3d3e37 update 2026-06-22 11:36:42 +02:00
team3
4f1f8d18d0 update 2026-06-22 10:13:30 +02:00
team3
b54f5e23f3 update 2026-06-22 06:07:22 +02:00
team3
bf9eea967b update 2026-06-21 23:01:14 +02:00
team3
2e33fa5c47 update 2026-06-21 22:53:54 +02:00
team3
b4c686f74f update 2026-06-21 22:06:02 +02:00
team3
50b1c81bca update 2026-06-21 20:02:06 +02:00
team3
8ba4b94498 update 2026-06-21 20:00:13 +02:00
team3
885c4811d0 update 2026-06-21 19:26:01 +02:00
team3
befd509c08 update 2026-06-21 18:32:55 +02:00
team3
3965bc5bf9 update 2026-06-21 17:27:10 +02:00
team3
920046b774 update 2026-06-21 16:30:54 +02:00
team3
89a853981d update 2026-06-21 16:22:13 +02:00
team3
76ae2161e1 update 2026-06-21 15:46:23 +02:00
team3
1910f730d2 update 2026-06-21 15:38:10 +02:00
team3
20831e1399 update 2026-06-21 13:27:19 +02:00
team3
ba529e61a8 update 2026-06-19 14:13:22 +02:00
team3
0868a768a2 update 2026-06-19 14:04:19 +02:00
team3
26e097dc5a update 2026-06-19 12:59:13 +02:00
team3
3168ec9094 update 2026-06-19 09:51:47 +02:00
team3
1fba9ba898 update 2026-06-19 09:06:45 +02:00
team3
bf6ab18ffd update 2026-06-18 20:24:24 +02:00
root
d80b22d53e update 2026-06-18 15:28:14 +00:00
team3
f24054ba56 update 2026-06-18 17:22:03 +02:00
team3
30b28290ca update 2026-06-18 17:19:08 +02:00
team3
a17ea155b8 update 2026-06-18 17:11:24 +02:00
team3
ed006976d9 update 2026-06-18 16:51:38 +02:00
team3
d067cb8b54 update 2026-06-18 16:45:33 +02:00
team3
9c0f622e0c update 2026-06-18 14:30:21 +02:00
team3
d8932c90d6 update 2026-06-18 12:50:01 +02:00
team3
3df5976d3d update 2026-06-18 12:22:34 +02:00
team3
b8f08ed71b update 2026-06-18 12:06:22 +02:00
team3
af6fb7a9c2 update 2026-06-18 11:54:49 +02:00
team3
59d108a13a update 2026-06-18 11:46:09 +02:00
team3
993552efe5 update 2026-06-18 11:37:17 +02:00
team3
9f7553edbc update 2026-06-18 11:25:48 +02:00
team3
4b0cf1ef29 update 2026-06-18 09:58:45 +02:00
team3
1aba312242 update 2026-06-18 09:42:29 +02:00
team3
229880b7e6 update 2026-06-18 00:00:24 +02:00
team3
3c839861e7 update 2026-06-17 23:17:52 +02:00
team3
dce9156ac8 update 2026-06-17 22:56:24 +02:00
team3
0ff33271a0 update 2026-06-17 19:59:06 +02:00
team3
c487e9cfcd update 2026-06-17 18:05:25 +02:00
team3
2f702477c7 update 2026-06-17 17:40:05 +02:00
team3
18b0a32f1e update 2026-06-17 17:02:44 +02:00
team3
d81bba9644 update 2026-06-17 17:01:41 +02:00
team3
cc68bdd242 update 2026-06-17 17:01:11 +02:00
team3
229ed93c5d update 2026-06-17 16:50:28 +02:00
team3
61a22a4d9f update 2026-06-17 16:41:04 +02:00
team3
76a405bab9 update 2026-06-17 15:31:56 +02:00
team3
7a524ddb99 update 2026-06-17 15:25:24 +02:00
team3
b3097a6143 update 2026-06-17 15:08:44 +02:00
team3
a503285b6f update 2026-06-17 14:25:05 +02:00
team3
5268c77fde update 2026-06-17 13:38:38 +02:00
team3
f30c7a6456 update 2026-06-17 13:30:09 +02:00
team3
0a96705453 update 2026-06-17 12:55:00 +02:00
team3
5fea458d66 update 2026-06-17 12:32:47 +02:00
team3
390ec12bbf update 2026-06-17 11:54:45 +02:00
team3
e8214598ea update 2026-06-17 11:21:12 +02:00
team3
d6b748cb94 update 2026-06-17 11:09:28 +02:00
team3
c00580c261 update 2026-06-15 18:51:50 +02:00
team3
b592944497 update 2026-06-15 18:27:08 +02:00
team3
466818c47c update 2026-06-15 17:02:53 +02:00
team3
19b520a3b1 update 2026-06-15 16:09:38 +02:00
team3
44e04315cc update 2026-06-15 15:43:32 +02:00
team3
47be019f02 update 2026-06-15 15:19:40 +02:00
team3
a6d249acf6 update 2026-06-15 12:35:09 +02:00
team3
003f6d3b8e update 2026-06-15 08:59:06 +02:00
team3
33a4440404 update 2026-06-15 08:46:28 +02:00
team3
25a07ede4d update 2026-06-14 22:53:10 +02:00
team3
08dd0ccd69 update 2026-06-14 22:43:54 +02:00
team3
6e5d673ca7 update 2026-06-14 22:09:48 +02:00
team3
54adcdc50c update 2026-06-14 17:57:40 +02:00
team3
77fd6156f6 update 2026-06-14 15:18:56 +02:00
team3
143e6d6f7c update 2026-06-14 14:55:44 +02:00
team3
2b89e21cd3 update 2026-06-14 14:02:27 +02:00
team3
822f6ee3e9 update 2026-06-14 12:24:49 +02:00
team3
8382d6f27a update 2026-06-12 17:53:49 +02:00
team3
a7fd345bb6 update 2026-06-12 17:46:30 +02:00
team3
0ba708dc54 update 2026-06-12 17:22:12 +02:00
team3
78d5833fe4 update 2026-06-12 17:18:42 +02:00
217 changed files with 61481 additions and 5144 deletions

View File

@@ -1,3 +1,6 @@
# Standard-Provider für alle Agenten (Pflicht — es gibt keinen Code-Default)
DEFAULT_PROVIDER=minimax
# Datei nach .env kopieren (wird nicht committet).
# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen.
@@ -5,3 +8,21 @@ CLAUDE_CODE_OAUTH_TOKEN=
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
MINIMAX_API_KEY=
# Optional — Rollen-Mixing über Anbieter-Grenzen (Standard: die UI-Auswahl gilt für alles).
# Wert: Provider-Name ("claude"/"minimax"/"lokal") oder "provider:modell".
#ROLE_QUICK=
#ROLE_JUDGE=
#ROLE_GUIDE=
#ROLE_FAST=
# Parallelität (Defaults: siehe backend/config.py). Prozess-Tier = opencode-Prozesse
# (~310 MB RSS pro Agent), API-Tier = direkte MiniMax-Calls für tool-lose Agenten (~0 RAM).
# Auf 8-GB-Maschinen Prozess-Limit klein halten; das API-Tier darf hoch.
#MAX_CONCURRENT_AGENTS=8
#MAX_CONCURRENT_AGENTS_PER_TOPIC=24
#MAX_CONCURRENT_API_AGENTS=28
# opencode-Starts warten, wenn weniger als dieser Anteil RAM frei ist (0 = aus).
#RAM_MIN_FREE_PCT=20
# Kill-Switch: 0 = Text-Calls wieder über opencode-Prozesse statt direkter API.
#CREATOR_TEXT_API=0

1092
AUFTRAG-inventar-fix.md Normal file

File diff suppressed because it is too large Load Diff

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# Ziele
- Auswahl der Bausteine / Subbausteine optimieren: Also Entfernen würde eine Lücke erzeugen und Hinzufügen würde eine Dopplung erzeugen
- Performance maximieren: Der Makespan soll so gering wie möglich sein
- Tokenverbrauch minimieren: Gesamtverbraucht der Tokens soll so gering wie möglich sein
- Korrektheit: Faschinformationen so gerin wie möglich halten
# Soll folgende Probleme lösen
- Perfektionismus: Ich brauche die Info was 100% vom Thema ist
- Kontrolle: Ich muss wissen wie weit ich bin, um es für mich zu planen und tracken
- Optimale Auswahl: Zu große Auswahl verlängert die Bearbeitungszeit und die Motivation bricht weg, wenn ich zu wenig Fortschritt sehe; Zu gerine Auswahl triggert den Perfektionismus
- Tokenverbrauch: Je weniger Token verbaucht werden, desto stärkere Modelle kann ich nehmen
- Performance: Bei zu langer Generierungen erschöpft meine Geduld

View File

@@ -14,6 +14,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gnupg \
poppler-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g @anthropic-ai/claude-code opencode-ai \
@@ -23,7 +26,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN useradd -m -u 1000 app
COPY backend/requirements.txt /app/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
# torch als CPU-Build (~190 MB) — eigener Index, sonst zieht pip die CUDA-Variante (~2,5 GB).
# Fürs Lesbarkeits-Gate (transformers nutzt das vorhandene torch).
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install --no-cache-dir -r /app/backend/requirements.txt
# Chromium + OS-Libs für Playwright (als root) in ein gemeinsames, welt-lesbares Verzeichnis.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN python3 -m playwright install --with-deps chromium \
&& chmod -R a+rX /ms-playwright
COPY --chown=app:app backend/ /app/backend/
COPY --chown=app:app templates/ /app/templates/

View File

@@ -1,10 +1,10 @@
.PHONY: install dev prod stop logs remove auth sync sync-projects projects searxng ollama
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama qa test test-e2e train train-init train-server
COMPOSE = docker compose
auth:
@mkdir -p .claude-data storage projects
@chown -R 1000:1000 .claude-data storage projects
@mkdir -p .claude-data storage projects uni
@chown -R 1000:1000 .claude-data storage projects uni
@grep -q "^CLAUDE_CODE_OAUTH_TOKEN=.\+" .env 2>/dev/null \
|| echo "WARNUNG: CLAUDE_CODE_OAUTH_TOKEN fehlt in .env (Claude-Provider inaktiv)."
@grep -q "^MINIMAX_API_KEY=.\+" .env 2>/dev/null \
@@ -12,8 +12,12 @@ auth:
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
install:
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura pymupdf4llm
pip install --break-system-packages torch --index-url https://download.pytorch.org/whl/cpu
python3 -m playwright install chromium
@echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen."
@which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils
@which tesseract >/dev/null 2>&1 || sudo apt-get install -y tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
cd frontend && npm install
npm install -g opencode-ai
@mkdir -p $(HOME)/.config/opencode
@@ -43,10 +47,11 @@ stop:
logs:
$(COMPOSE) logs -f
remove: stop
remove:
@read -p "storage/ (DB + alle Nutzdaten) wirklich löschen? [y/N] " a && [ "$$a" = "y" ]
@echo "Lösche Datenbank und generierte Dateien..."
rm -rf storage/*
@echo "Fertig."
@echo "Fertig. (Server ggf. separat stoppen: make stop)"
searxng:
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng
@@ -58,17 +63,79 @@ ollama:
ollama pull qwen3.5:9b
@echo "Ollama bereit — Provider 'Lokal' ist aktiv (Modelle anpassen: backend/config.py + dev-ops/opencode.json)."
sync:
@mkdir -p storage/themen
# Remote wird für die DB-Kopie gestoppt (sonst reißt der Snapshot mitten im Schreibvorgang)
# und danach wieder gestartet.
sync: stop
@mkdir -p storage/topics uni
@rm -f storage/creator.db-shm storage/creator.db-wal
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db storage/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/themen/ storage/themen/
@echo "Sync abgeschlossen."
-rsync -avz --progress root@178.104.67.87:/var/www/creator/storage/creator.db-wal storage/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/storage/topics/ storage/topics/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/uni/ uni/
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d'
@echo "Sync abgeschlossen — Remote läuft wieder."
# Projekte vom Server holen: `make sync-projects` (auch: `make sync projects`).
# Ohne --delete — lokale Projekte, die es auf dem Server nicht gibt, bleiben.
# Mit --delete — exakter Spiegel: lokale Projekte ohne Server-Pendant werden gelöscht.
sync-projects:
@mkdir -p projects
rsync -avz --progress root@178.104.67.87:/var/www/creator/projects/ projects/
rsync -avz --progress --delete root@178.104.67.87:/var/www/creator/projects/ projects/
@echo "Projekt-Sync abgeschlossen."
sync-all: sync sync-projects
@echo "Voller Sync (Themen + Projekte) abgeschlossen."
# Gegenstück zu sync-all: lokalen Stand auf den Server schieben (Local → Remote).
# Stoppt den Remote-Server (DB-Sicherheit), pusht DB + Themen + Projekte, startet ihn neu.
# --delete = exakter Spiegel: Remote-Dateien ohne lokales Pendant werden gelöscht.
sync-all-reverse: stop
@echo "ACHTUNG: überschreibt den Remote-Stand (DB, topics/, uni/, projects/) mit dem lokalen."
@[ -f storage/creator.db ] || { echo "Keine lokale DB — abgebrochen."; exit 1; }
ssh root@178.104.67.87 'cd /var/www/creator && docker compose down'
ssh root@178.104.67.87 'rm -f /var/www/creator/storage/creator.db-shm /var/www/creator/storage/creator.db-wal'
rsync -avz --progress storage/creator.db root@178.104.67.87:/var/www/creator/storage/
@[ -f storage/creator.db-wal ] && rsync -avz --progress storage/creator.db-wal root@178.104.67.87:/var/www/creator/storage/ || true
rsync -avz --progress --delete storage/topics/ root@178.104.67.87:/var/www/creator/storage/topics/
rsync -avz --progress --delete uni/ root@178.104.67.87:/var/www/creator/uni/
rsync -avz --progress --delete projects/ root@178.104.67.87:/var/www/creator/projects/
ssh root@178.104.67.87 'cd /var/www/creator && docker compose up -d --build'
@echo "Reverse-Sync abgeschlossen — Remote läuft wieder."
# QA-Report über einen abgeschlossenen Lauf (read-only): make qa TOPIC=aak
qa:
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make qa TOPIC=<thema> [LLM=1]"; exit 1; }
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && python3 qa.py "$(TOPIC)" $(if $(LLM),--llm,)
# Guide-QA über einen gebauten Guide (read-only): make qa-guide TOPIC=Markdown [LLM=1]
qa-guide:
@[ -n "$(TOPIC)" ] || { echo "Nutzung: make qa-guide TOPIC=<thema> [LLM=1]"; exit 1; }
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && python3 guide_qa.py "$(TOPIC)" $(if $(LLM),--llm,)
projects: sync-projects
# Backend-Testsuite (Injektionstests + Fake-E2E, keine echten Agenten)
test:
cd backend && python3 -m pytest tests/ -q
# Nur die Fake-E2E-Läufe (kompletter Generierungspfad in Sekunden)
test-e2e:
cd backend && python3 -m pytest tests/test_e2e_fake.py -q
# Parameter-Training auf Mini-Themen: make train [TRIALS=40] [STUNDEN=12]
# Achtung: jeder Trial ist ein echter Mini-Lauf (MiniMax-Tokens, Minuten).
train:
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
# Training im Container starten (auf dem Server ausführen; detached, überlebt ssh-Abbruch).
# Fortschritt: storage/train/<sitzung>/trials.jsonl
train-server:
docker exec -d creator python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
# Frozen-Inventar-Vorlage für das Training bauen (einmalig, echter Mini-Lauf)
train-init:
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && python3 train.py --init

183
README.md
View File

@@ -1,18 +1,171 @@
Bausteine finden
- 1+ Agenten suchen Bausteine zum Thema
- Baustein bekommt die Einstufung kern, wichtig und rest
# Creator
MiniGuide generieren
- 1 Agent erstellt den Guide
- Nur Themen (kern) verwenden
- MiniGuide-Format bestimmt den Stil
KI-Lernguide-Generator: Aus einem Thema, Uni-Skript, Projektordner oder Web-Link entsteht
ein vollständiger, belegter Lernguide mit Übungssystem. FastAPI-Backend (`backend/`),
Vue-Frontend (`frontend/`), SQLite (`storage/creator.db`). MiniMax generiert über die
OpenCode-CLI, Judge-Agenten prüfen jede Stufe.
Guide generieren
- 1 Agent erstellt den Guide
- Nur Themen (kern+wichtig) verwenden
- Guide-Format bestimmt den Stil
**Diese README ist das Onboarding für den nächsten KI-Agenten.** Endnutzer-Features stehen
unten. Persistente Detail-Notizen liegen im Claude-Memory des Projekts; dieses Dokument
trägt das Wesentliche.
Flow
- Bausteine finden
- HTML erstellen
- Code prüfen
## Wofür das Projekt gebaut wird (die Gründe des Betreibers)
- Lernen mit **Entscheidungsautomatik statt Wahlfreiheit**: Das System zerlegt, priorisiert
und prüft — der Lernende folgt dem Pfad, statt ihn zu bauen.
- **100-%-Zerlegung** des Stoffs in Bausteine und Subbausteine, jede Aussage mit Beleg.
- **MECE-Nordstern** (Kern des Projekts, wörtlich): „Man kann keinen Baustein entfernen,
ohne eine Lücke zu erzeugen, und keinen hinzufügen, ohne dass eine Dopplung entsteht."
- Ideal: „Die Pipeline läuft durch, es ist eine 10/10, die Inhalte sind super —
nicht zu viel, nicht zu wenig, korrekt."
## Entwicklungsphase: die vier Optimierungsziele
Alle Arbeit optimiert, themenunabhängig:
1. **Qualität** — Korrektheit maximieren.
2. **Auswahl** — Lücken und Dopplungen minimieren (MECE).
3. **Performance** — Gesamtlaufzeit minimieren.
4. **Tokenverbrauch** — Generierung günstig halten.
Fixes gehören in die **Pipeline** (Generierung). QA misst nur — Detektor-Konstanten und
Notengewichte sind nie Teil einer Optimierung (Messinvarianz).
## Architektur in einer Minute
Drei Kanban-Boards (Engine: `kanban.py`, Karten in SQLite, resümierbar):
1. **Inventar** (`board_inventory.py`): Research-Reader → Titel-Ingest → Cluster →
Konsens-Gate (+ Anker-Beleg gegen Kanon-Halluzination) → Naming (darf abstrahieren,
Anker-Pflicht) → Fragment-Filter → Dedup → Gruppierung → fertige Blöcke.
Danach QA-Gate (Note < 9.5 pausiert vor Board 2).
2. **Artefakte** (`board_artefacts.py`): Subbausteine finden (Panel-Konsens) → Facts
(extract-once, Grounding für alles Spätere) → In-Block-Konsolidierung + Lücken-Nachfass
→ Levels → Relevanz → Cross-Block-Dedup (Barriere, gechunkt) → Fragen → Flashcards/
Beispiele → Finalize (DB-Spiegel, Hygiene) → Outline.
3. **Guide** (`guide_board.py`): Lernziele → Writer (Marker-Format, Längen-Budget) →
Fakten-Gate (CoVe; „falsch" fixt immer, „unbelegt" ab Schwelle) → Coverage → Lesbarkeit.
Agenten-Rollen: `quick`/`fast` generieren, `judge` prüft (native MiniMax-Route — die
kalt-Route stallte 20 % der Calls), `guide` schreibt. Große JSON-Antworten kommen als
TEXT zurück (`_sink_or_file`) — Datei-schreibende Agenten verloren 40 Runden in
JSON-Reparatur-Schleifen.
## Arbeitsregeln (verbindlich, aus Erfahrung destilliert)
- **Nie committen/pushen.** Der Betreiber committet selbst.
- **Ändern nur auf Auftrag.** Beobachtungen berichten, nicht eigenmächtig fixen.
Ergebnisse nie nachträglich schönen.
- **Kein Backend-Edit bei laufendem Flow**: vorher `curl -s localhost:8000/api/blocks/active`
== `[]` prüfen — `backend/*.py`-Edits triggern den uvicorn-Reload und killen Läufe.
`templates/` und `Makefile` sind gefahrlos.
- **Generisch bleiben**: keine Domänen-Sonderregeln in Pipeline/Prompts. Quellen-Spezifika
löst der Import.
- **Ursachen statt Symptome**: erst messen (Events, QA-Reports, OpenCode-Session-DB),
dann fixen. Kein Raten.
- **Fragen zuerst beantworten**, dann handeln. Antworten knapp und auf Deutsch.
- Keine Bewertungen fremder KI-Modelle/Provider (Geschwindigkeit, Qualität).
- `.env` enthält echte API-Keys — nie exponieren.
## Werkzeuge für Entwicklung und Diagnose
| Kommando | Zweck |
|---|---|
| `make test` | ganze Suite (~240 Tests, ~20 s), inkl. Fake-E2E |
| `make test-e2e` | nur Fake-E2E: kompletter Generierungspfad in Sekunden, ohne LLM |
| `make qa TOPIC=… [LLM=1]` | Inventar-/Artefakt-QA read-only, Note 010 |
| `make qa-guide TOPIC=… [LLM=1]` | Guide-QA |
| `make train-init` | Frozen-Inventar-Vorlage für das Training bauen (einmalig) |
| `make train [TRIALS] [STUNDEN] [AMEISEN]` | Ameisen-Optimierung der Parameter (anytime) |
| `CREATOR_FAKE_AGENTS=1 make dev` | Server antwortet aus der Fake-Welt — UI-Smoke in Sekunden |
| `CREATOR_PARAMS='{"X":1}'` | Parameter-Override pro Prozess (Registry: `backend/train_params.py`) |
Diagnose-Quellen: `events`-Tabelle (Agent-Dauern/Tokens/Status je Lauf),
`storage/qa/<topic>/*.json` (Report-Historie), `arbeit/lauf-summary.json`,
OpenCode-Session-DB (`~/.local/share/opencode/opencode.db` — Turns/Tokens je Agent).
## Training (`make train`)
Ameisen-Algorithmus (ACO), anytime: Pheromon-Gewichte je Parameter-Stufe steuern die
Kandidaten; je länger er läuft, desto gezielter die Tests. Drei Fidelity-Stufen:
F0 Fake-E2E (0,5 s, Invarianten + Struktur-Proxy), F1 Frozen-Inventar (~58 min, Board 2
auf kopiertem Inventar), F2 Volllauf mit Soll-Abgleich gegen
`benchmarks/pruefstand/soll.json` (konstruiertes Thema mit bekannter Lösung und
eingebauten Fallen). Übernahme nur nach Bestätigungslauf. Ergebnis:
`storage/train/aco/{report.md, beste_params.json}`; Übernahme nach `config.py` ist
manuell.
## Entwicklungs-Meilensteine (was schon gelernt wurde)
- MECE-Regelkreis: In-Block-Konsolidierung (2-Judge-Panel, Einstimmigkeit), Lücken-Nachfass
mit hartem Beleg-Gate, Cross-Block-Dedup mit Stichentscheid — Sub-Zahl 425→~213 bei
steigender Note.
- Drei stabile QA-Noten (Inventar/Artefakte/Guide) mit Bestätiger-Pässen gegen
Judge-Rauschen; Repair arbeitet Befunde gezielt ab.
- Resume-Dateien tragen einen Sub-Satz-Hash — Re-Runs übernehmen nie stale Ergebnisse;
Finalize löscht Alt-Reste (Lösch-Hygiene überall).
- Facts-Nachfass: kein consensus-Sub ohne Grounding (sonst flutet das Fakten-Gate).
- Judge-Stalls (20 % Timeouts) lagen an einer Provider-Route — Messen vor Raten.
- Naming darf abstrahieren, aber nur korpus-verankert (Kanon-Halluzinations-Schutz).
## Offene Ideen / nächste Schritte
- Training auf dem Server laufen lassen (siehe unten), wirksame Parameter übernehmen.
- Facts-Chunks parallelisieren; Live-Aktivität (Token-Zähler) an laufenden Karten zeigen.
- Bekannte Cross-Dubletten knapp unter dem 0.75-Kandidaten-Floor.
- Roadmap-Lernarchitektur: ein Guide + Stufen-Ansichten, ELO-Score mit wachsendem Cap.
## Server-Betrieb mit 8 GB RAM
- `.env`: `MAX_CONCURRENT_AGENTS=6`, `MAX_CONCURRENT_AGENTS_PER_TOPIC=6`.
- Training: `make train AMEISEN=1` (jeder parallele Trial lädt das Embedding-Modell, ~1 GB).
- Embedding + Readability halten zusammen ~1 GB im Backend-Prozess. 24 GB Swap anlegen.
---
# Features (Endnutzer-Sicht)
## Quellen
- Freies Thema: die KI recherchiert den Stoff selbst im Web.
- Uni-Skript: lädt ein Skript und liest es abschnittsweise.
- Projektordner: nutzt eigene Dateien als Quelle.
- Web-Link: crawlt eine Seite und filtert relevante Inhalte.
## Inhalt erstellen
- Zerlegt den Stoff in einzelne Lern-Konzepte (Bausteine).
- Führt doppelt genannte Konzepte automatisch zusammen.
- Entfernt Bruchstücke, die keine eigenen Themen sind.
- Gliedert jeden Baustein in Teilpunkte.
- Belegt jeden Teilpunkt mit Fakten aus der Quelle.
- Stuft Teilpunkte nach Schwierigkeit ein (Anfänger/Fortgeschritten/Experte).
- Trennt Randthemen vom Kern.
- Ordnet Bausteine in Kapitel mit Voraussetzungs-Reihenfolge.
- Schreibt daraus einen lesbaren Guide.
- Drei Guide-Varianten: fokussiert, komplett, nur Randthemen.
- Erzeugt Karteikarten und durchgerechnete Beispiele.
## Guide-Ansicht
- Zeigt Mathe-Formeln korrekt gerendert.
- Umschalten zwischen kurzer und ausführlicher Darstellung.
- Filtert den Guide nach Lernstufe.
- Prüft schwer lesbare Sätze und vereinfacht sie.
- Einzelne Abschnitte auf Knopfdruck neu prüfen.
## Lernen & Prüfen
- Übungsfragen je Baustein generieren.
- Vier Frageformen: Quiz, Lückentext, Freitext, Erklären.
- KI bewertet Freitext-Antworten mit Begründung.
- Punkte-System mit Stufen und Streak je Baustein.
- Themenweite Gesamt-Prüfung über alle Bausteine.
- Chat-Nachfrage zu jedem Baustein.
- Fokus-Modus: Guide und Prüfung nebeneinander.
## Eigene Elemente
- Eigene Lern-Notizen per Stichwort erstellen lassen.
- Notizen im Chat anpassen; KI prüft Lücken und Stil.
## Steuerung
- KI-Anbieter wählen: Claude, MiniMax, lokal.
- Generierung ab jedem Schritt neu starten.
- Ab jedem Schritt löschen, ohne neu zu generieren.
- Laufende Generierung abbrechen oder fortsetzen.
- Live-Fortschritt sehen.
- Dark Mode.

View File

@@ -1,31 +1,227 @@
"""Provider-Schicht: führt Agent-Aufrufe über die Claude-CLI oder OpenCode (MiniMax) aus.
"""Provider layer: runs agent calls via the Claude CLI or OpenCode (MiniMax).
Beide Runner sind unabhängig. Fehlt ein Binary/Key, schlägt nur der
jeweilige Provider fehl — der andere läuft unverändert weiter.
Both runners are independent. If a binary/key is missing, only the
respective provider fails — the other keeps running unchanged.
Role routing: config.resolve_role maps (run_provider, role) → (provider, model)
ACROSS stacks, so one run can generate on MiniMax and judge on Claude. If the
routed provider is unavailable, the call falls back to the run's provider.
"""
import asyncio
import heapq
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from config import PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_INTERACTIVE
import httpx
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS,
MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role)
log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_api: set[str] = set() # agent_keys of running direct-API calls (no process to kill)
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
# Deckelt die realen CLI-Prozesse — unabhängig von der Pipeline-Semaphore in
# generator.py. Acquire passiert VOR dem Spawn, damit Wartezeit in der Queue
# nicht gegen den Agent-Timeout zählt.
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
def active_agents(scope_prefix: str | None = None) -> list[dict]:
"""Currently running agents and how long they've been running. Filter by key prefix
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
now = time.time()
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
for k, t in list(_active_started.items())
if (k in _active_processes or k in _active_api)
and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
"-outline")
def agent_ebene(key: str) -> str:
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
# in the semaphore queue are also stopped immediately on abort instead of still starting.
_cancelled_prefixes: set[str] = set()
def cancel_scope(prefix: str) -> None:
_cancelled_prefixes.add(prefix)
def clear_scope(prefix: str) -> None:
_cancelled_prefixes.discard(prefix)
def _scope_cancelled(agent_key: str) -> bool:
return any(agent_key.startswith(p) for p in _cancelled_prefixes)
# Caps the real CLI processes — independent of the pipeline semaphore in
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
# does not count against the agent timeout.
class _PrioritySemaphore:
"""asyncio.Semaphore variant: when slots are scarce, the LOWEST priority number is served first
(FIFO within the same priority). Lets earlier pipeline columns grab agents before later ones."""
def __init__(self, value: int):
self._value = value
self._waiters: list = [] # heap of [priority, seq, future]
self._seq = 0
async def acquire(self, priority: int = 100):
if self._value > 0:
self._value -= 1
return
fut = asyncio.get_event_loop().create_future()
entry = [priority, self._seq, fut]
self._seq += 1
heapq.heappush(self._waiters, entry)
try:
await fut # release() hands us the slot directly (no value change)
except BaseException:
entry[2] = None # tombstone so release() skips this dead waiter
if fut.done() and not fut.cancelled():
self.release() # granted just before we were cancelled → pass it on
raise
def release(self):
while self._waiters:
entry = heapq.heappop(self._waiters)
if entry[2] is not None and not entry[2].done():
entry[2].set_result(None) # hand the slot straight to the highest-priority waiter
return
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # process tier (~310 MB RSS each)
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # direct-API tier (~0 RAM)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
# MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. Priority-based too, so the
# per-topic queue can't undo the global priority when one topic is the only load.
_topic_sems: dict[str, _PrioritySemaphore] = {}
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
"dedup", "grouping", "gruppierung", "supplement", "outline", "artifact",
"question", "relevance", "level", "facts", "subblock")
def _agent_priority(key: str) -> int:
for i, tag in enumerate(_STAGE_PRIORITY):
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
return i
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
"""Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally.
The per-topic cap is shared across both tiers; only the global cap is tiered (process vs API)."""
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
global_sem = _batch_sem_api if api else _batch_sem
if topic_sem is not None:
await topic_sem.acquire(priority)
await global_sem.acquire(priority)
try:
yield
finally:
global_sem.release()
if topic_sem is not None:
topic_sem.release()
# Space OpenCode starts: processes starting simultaneously collide on the internal
# session DB ("database is locked", exit after <1s). Token bucket instead of a lock
# held through spawn+sleep: the lock only assigns a start slot, the sleep happens
# outside — a wave of starts is spaced by OPENCODE_START_DELAY without a global convoy.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = float(os.getenv("OPENCODE_START_DELAY", "0.5"))
_opencode_next_start = 0.0
async def _opencode_slot() -> None:
global _opencode_next_start
loop = asyncio.get_running_loop()
async with _opencode_start_lock:
now = loop.time()
start_at = max(now, _opencode_next_start)
_opencode_next_start = start_at + _OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, start_at - now))
# RAM-adaptive admission for opencode spawns (~310 MB RSS each): below RAM_MIN_FREE_PCT
# free memory new processes wait instead of starting. Gates admission only — running
# processes are never touched.
_RAM_GATE_FLOOR = 2 # below this many running: always admit (deadlock guard)
_RAM_PER_PROC_KB = 350 * 1024 # commit estimate for a batch agent (opencode/claude, no MCP)
_RAM_PER_PROC_FULL_KB = 1250 * 1024 # `full` agent: opencode + 3 MCP servers (~300 MB each)
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
_RAM_POLL_S = 2.0
_cli_running = 0 # CLI spawns (opencode AND claude) — the deadlock-floor counter
_cli_recent_starts: list[tuple[float, int]] = [] # (monotonic ts, est_kb) of recent admissions
def _meminfo() -> tuple[int, int] | None:
"""(MemAvailable_kB, MemTotal_kB) from /proc/meminfo; None → gate fails open (non-Linux)."""
try:
text = Path("/proc/meminfo").read_text()
except OSError:
return None
m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
if "MemTotal" not in m or "MemAvailable" not in m:
return None
return int(m["MemAvailable"]), int(m["MemTotal"])
async def _ram_gate(agent_key: str, est_kb: int) -> bool:
"""True = start admitted (commit registered), False = scope cancelled while waiting.
Gates ALL CLI spawns (opencode + claude). `est_kb` = this spawn's RAM estimate (a `full`
agent drags 3 MCP servers). Check and commit-append happen in the same synchronous block
(no await between) — concurrent waiters cannot double-admit on the same free RAM."""
if RAM_MIN_FREE_PCT <= 0:
return True
waited = False
while True:
if _scope_cancelled(agent_key):
return False
mem = _meminfo()
if mem is None or _cli_running < _RAM_GATE_FLOOR:
break # fail open / floor
avail_kb, total_kb = mem
now = time.monotonic()
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
if now - t < _RAM_COMMIT_WINDOW_S]
committed_kb = sum(kb for _, kb in _cli_recent_starts)
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
break
if not waited:
log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb)
waited = True
await asyncio.sleep(_RAM_POLL_S)
_cli_recent_starts.append((time.monotonic(), est_kb))
return True
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools
_CLAUDE_TOOLS = {
"full": "Write,Bash,Read,WebSearch,WebFetch",
@@ -34,7 +230,7 @@ _CLAUDE_TOOLS = {
"none": None,
}
# Capability → OpenCode-Agent (Tool-Rechte in dev-ops/opencode.json definiert)
# Capability → OpenCode agent (tool permissions defined in dev-ops/opencode.json)
_OPENCODE_AGENTS = {
"full": "full",
"files": "files",
@@ -43,6 +239,17 @@ _OPENCODE_AGENTS = {
}
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
"""Direct API path only for tool-less, non-streaming MiniMax calls. Model-prefix check
instead of cli check: "lokal" (ollama) also runs via opencode. Missing key or
CREATOR_TEXT_API=0 (kill switch) falls back to the opencode process path."""
return (capabilities == "none" and on_line is None
and PROVIDERS[provider]["cli"] == "opencode"
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
and bool(os.environ.get("MINIMAX_API_KEY"))
and os.getenv("CREATOR_TEXT_API", "1") != "0")
def provider_available(provider: str) -> bool:
cfg = PROVIDERS.get(provider)
if not cfg:
@@ -61,15 +268,49 @@ def provider_available(provider: str) -> bool:
return True
# Availability cache for role routing: the routed target is probed at most once per
# TTL (check_url providers would otherwise block the loop on every call).
_avail_cache: dict[str, tuple[float, bool]] = {}
_AVAIL_TTL = 60.0
def _available_cached(provider: str) -> bool:
now = time.monotonic()
hit = _avail_cache.get(provider)
if hit and now - hit[0] < _AVAIL_TTL:
return hit[1]
ok = provider_available(provider)
_avail_cache[provider] = (now, ok)
return ok
def _kill(process) -> None:
"""Kill the agent and its child processes via the process group (otherwise the
children spawned by the CLI survive, keep the pipes open and block communicate())."""
try:
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
process.kill()
except ProcessLookupError:
pass
def kill_process(agent_key_prefix: str) -> None:
"""Killt alle aktiven Prozesse, deren Key mit dem Prefix beginnt (deckt -plan/-w1… ab)."""
"""Kill all active processes whose key starts with the prefix (covers -plan/-w1…)."""
for key, process in list(_active_processes.items()):
if process.returncode is not None: # tote Einträge beim Iterieren aufräumen
if process.returncode is not None: # clean up dead entries while iterating
_active_processes.pop(key, None)
_active_started.pop(key, None)
continue
if key.startswith(agent_key_prefix):
log.debug("kill agent %s", key)
process.kill()
_kill(process)
# Event sink for the pipeline history (injected by main.py lifespan as database.add_event —
# agents.py stays DB-free). Called fire-and-forget for every finished BATCH agent.
on_event = None
async def run_agent(
@@ -80,91 +321,291 @@ async def run_agent(
role: str = "fast",
capabilities: str = "none",
lane: str = "batch",
scope: str | None = None,
on_line=None,
label: str = "",
) -> tuple[int, str, str]:
if os.getenv("CREATOR_FAKE_AGENTS"): # Sekunden-Smoke: deterministische Antworten statt LLM
import fake_agents
return await fake_agents.respond(agent_key, prompt, capabilities)
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled"
if provider not in PROVIDERS:
return 1, "", f"Unbekannter Provider: {provider}"
if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' nicht installiert (Provider: {provider})"
sem = _interactive_sem if lane == "interactive" else _batch_sem
async with sem:
if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities)
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
return 1, "", f"Unknown provider: {provider}"
run_provider = provider
provider, model = resolve_role(run_provider, role)
if provider != run_provider and not _available_cached(provider):
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
if not model:
return 1, "", f"No model for role '{role}' (provider: {provider})"
use_api = _use_text_api(provider, model, capabilities, on_line)
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
queued = time.monotonic()
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key), api=use_api)
async with gate:
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled"
wait_ms = int((time.monotonic() - queued) * 1000)
start = time.monotonic()
status = "error"
rc = None
err_tail = ""
api_tokens = None
try:
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
if use_api:
rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
res = (rc_, out_, err_)
elif PROVIDERS[provider]["cli"] == "opencode":
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
else:
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
rc = res[0]
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
if rc not in (0, None) and rc >= 0:
err_tail = (res[2] or res[1] or "").strip()[-300:] # diagnosis: rc=1 without stderr is opaque
return res
except asyncio.TimeoutError:
status = "timeout"
raise
except asyncio.CancelledError:
status = "cancelled"
raise
finally:
if on_event is not None and scope is not None: # batch pipeline only, never fatal
try:
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
meta["board"] = agent_ebene(agent_key)
if err_tail:
meta["stderr"] = err_tail
if api_tokens: # direct-API path: usage from the response, even on rc!=0
meta["tokens"] = api_tokens
elif PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
meta["tokens"] = tok
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
status=status, dur_ms=int((time.monotonic() - start) * 1000),
wait_ms=wait_ms, meta=meta)
except Exception:
log.debug("on_event failed", exc_info=True)
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int) -> tuple[int, str, str]:
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
start = time.monotonic()
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_active_processes[agent_key] = process
async def spawn():
return await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE if stdin_data is not None else asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, # own process group → killpg also kills child processes
env=env,
)
global _cli_running
if stagger:
if not await _ram_gate(agent_key, est_kb):
return 1, "", "cancelled" # like the cancelled path in run_agent
await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced
process = await spawn()
if stagger:
_cli_running += 1
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
track_key = agent_key
n = 2
while track_key in _active_processes:
track_key = f"{agent_key}~{n}"
n += 1
_active_processes[track_key] = process
_active_started[track_key] = time.time()
_active_labels[track_key] = label
try:
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
if on_line is not None:
# Streaming path: read stdout line by line, hand each raw line to on_line LIVE.
out_chunks: list[str] = []
async def _pump():
async for raw in process.stdout:
s = raw.decode("utf-8", errors="replace")
out_chunks.append(s)
try:
on_line(s)
except Exception:
log.debug("on_line callback failed", exc_info=True)
await asyncio.wait_for(_pump(), timeout=timeout)
await process.wait()
stderr_b = await process.stderr.read()
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
except asyncio.TimeoutError:
process.kill()
_kill(process)
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
pass
log.info("agent %s: Timeout nach %ds", agent_key, timeout)
log.info("agent %s: timeout after %ds", agent_key, timeout)
raise
log.info(
"agent %s: exit %s nach %.1fs (%d Bytes stdout)",
"agent %s: exit %s after %.1fs (%d bytes stdout)",
agent_key, process.returncode, time.monotonic() - start, len(stdout),
)
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally:
# Pop nur bei Identität: ein Slot-Restart unter demselben Key darf den
# NEUEN Prozess nicht aus dem Tracking werfen.
if _active_processes.get(agent_key) is process:
del _active_processes[agent_key]
if stagger:
_cli_running -= 1
# Pop only on identity: a slot restart under the same key must not evict
# the NEW process from tracking.
if _active_processes.get(track_key) is process:
del _active_processes[track_key]
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str, label: str = "") -> tuple[int, str, str]:
cfg = PROVIDERS["claude"]
cmd = [cfg["cli"], "-p", "--model", cfg[role]]
cmd = [cfg["cli"], "-p", "--model", model]
tools = _CLAUDE_TOOLS.get(capabilities)
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
# stagger=True: claude-CLI (~310 MB) also passes the RAM gate — previously ungated.
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
def _session_tokens(agent_key: str) -> dict | None:
"""Token counters of the newest OpenCode session titled `agent_key` (set via run --title).
Best-effort read-only lookup — None when DB/row is missing; never fails the agent.
Timeouts count too: their sessions consumed tokens, that waste should be visible."""
try:
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
try:
row = con.execute(
"SELECT tokens_input, tokens_output, tokens_reasoning,"
" tokens_cache_read, tokens_cache_write"
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1",
(agent_key,)).fetchone()
finally:
con.close()
except Exception:
return None
if row is None:
return None
keys = ("input", "output", "reasoning", "cache_read", "cache_write")
return {k: int(v or 0) for k, v in zip(keys, row)}
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
cfg = PROVIDERS[provider]
# Prompt über Tempdatei statt argv (ARG_MAX-Schutz bei großen Projekt-Prompts)
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
f.write(prompt)
prompt_path = Path(f.name)
# Positional-Message MUSS vor -f stehen: -f ist ein Array-Flag und
# frisst sonst den Text als zweiten Dateinamen ("File not found").
# The positional message MUST come before -f: -f is an array flag and
# would otherwise eat the text as a second file name ("File not found").
cmd = [
cfg["cli"], "run",
"Folge exakt den Anweisungen in der angehängten Datei. Sie sind der vollständige Auftrag.",
"-m", cfg[role],
"-m", model,
"--agent", _OPENCODE_AGENTS.get(capabilities, "text"),
"--dangerously-skip-permissions",
"--title", agent_key, # token accounting: joins the OpenCode session to our event
"-f", str(prompt_path),
]
if on_line is not None:
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
env = None
if capabilities != "full":
# Batch agents (files/readonly/text) never use the web MCPs, but opencode starts
# every configured MCP server PER PROCESS (~3 procs / ~300 MB each). Point them
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout)
return rc, _clean_opencode_output(stdout), stderr
# `full` keeps the 3 MCP servers (~300 MB each) → charge the gate accordingly.
est_kb = _RAM_PER_PROC_FULL_KB if capabilities == "full" else _RAM_PER_PROC_KB
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env, est_kb=est_kb)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)
# Direct text-API path (MiniMax, Anthropic Messages format): saves the ~310 MB RSS opencode
# process for tool-less calls. The native "minimax" and "minimax-kalt" opencode providers both
# resolve to this base URL — only the per-model options differ. Options source of truth:
# dev-ops/opencode.json (+ -slim); keep in sync on changes there.
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_VERSION = "2023-06-01"
_API_MAX_TOKENS = 32_000 # required Messages field; generate calls are long
_API_MODEL_OPTS = { # prefix "minimax" (native) → endpoint defaults (no entry)
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3},
}
async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
label: str = "") -> tuple[int, str, str, dict | None]:
"""→ (rc, text, err, tokens). Tokens come straight from the response usage (same key set
as _session_tokens) and are returned even on rc!=0 — waste stays visible."""
body = {
"model": model.split("/", 1)[1], # without the opencode provider prefix
"max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
**_API_MODEL_OPTS.get(model, {}),
}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
track_key = agent_key
n = 2
while track_key in _active_api:
track_key = f"{agent_key}~{n}"
n += 1
_active_api.add(track_key)
_active_started[track_key] = time.time()
_active_labels[track_key] = label
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
resp = await asyncio.wait_for( # belt: hard wall-clock cap like the process path
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
except httpx.TimeoutException:
raise asyncio.TimeoutError # contract: run_agent/_race handle TimeoutError
except httpx.HTTPError as e:
return 1, "", f"{type(e).__name__}: {e}", None
finally:
_active_api.discard(track_key)
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
if resp.status_code != 200:
return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
data = resp.json()
# Only text blocks count — thinking blocks (native route) are skipped.
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
u = data.get("usage") or {}
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
"reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
if not text.strip():
return 1, "", f"empty response (stop_reason={data.get('stop_reason')})", tokens
err = "stop_reason=max_tokens (truncated)" if data.get("stop_reason") == "max_tokens" else ""
return 0, text, err, tokens
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _clean_opencode_output(text: str) -> str:
"""Entfernt ANSI-Codes und den führenden Banner ("> agent · modell")."""
"""Strip ANSI codes and the leading banner ("> agent · model")."""
text = _ANSI_RE.sub("", text)
lines = text.splitlines()
while lines and (not lines[0].strip() or lines[0].lstrip().startswith(">")):

43
backend/auto_loop.py Normal file
View File

@@ -0,0 +1,43 @@
"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10),
Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist.
Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen:
`messen()` liefert die aktuelle QA-Note (010), `reparieren()` behebt die Befunde.
So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar.
"""
import logging
log = logging.getLogger("creator.auto_loop")
VOLL = 10.0 # 100 % = 10.0/10
MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht
async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict:
"""Loop QA→Repair für eine Ebene.
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (010).
reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel-
messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges,
Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch
mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist
erst, wenn NICHTS mehr passiert UND die Note nicht steigt.
Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
"""
if erst_note >= VOLL:
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
note = erst_note
for runde in range(1, max_iter + 1):
neu, bewegt = await reparieren()
log.info("[%s] Auto-Loop Runde %d: %.1f%.1f (%s)",
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
if neu >= VOLL:
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
note = max(note, neu)
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}

View File

@@ -1,367 +0,0 @@
"""Bausteine-Pipeline: 4x Recherche (3 nötig) → 2x Auswahl (1) → Prüfung — reines Inventar, unsortiert."""
import asyncio
import logging
import shutil
import subprocess
from pathlib import Path
from agents import kill_process
from config import DEFAULT_PROVIDER
from fsutil import atomic_write_text
from jsonio import read_json_file as _json_datei
from paths import arbeit_dir, bausteine_path, project_dir
from pipeline import (
CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race,
_semaphore, _timeout, run_single_slot,
)
from textkit import _eindeutige_titel, _parse_auswahl, _titel, _titel_aufloesen, _titel_index
log = logging.getLogger("creator.bausteine")
_bausteine_progress: dict[str, str] = {}
_bausteine_errors: dict[str, str] = {}
_bausteine_cancelled: set[str] = set()
_bausteine_step: dict[str, int] = {}
BAUSTEINE_STEPS = ("Recherche", "Auswahl", "Prüfung")
def _bausteine_steps(topic: str) -> tuple:
"""Projekte haben einen 4. Schritt: Themenfeld-Ergänzung per Web-Recherche."""
if project_dir(topic).is_dir():
return BAUSTEINE_STEPS + ("Ergänzung",)
return BAUSTEINE_STEPS
def _bausteine_files(topic: str) -> dict:
arbeit = arbeit_dir(topic)
return {
"final": bausteine_path(topic),
"arbeit": arbeit,
"recherche": [arbeit / f"recherche-{i}.md" for i in (1, 2, 3, 4)],
"auswahl": [arbeit / f"auswahl-{i}.md" for i in (1, 2)],
"auswahl_check": arbeit / "auswahl-check.json",
"ergaenzung": arbeit / "ergaenzung.json",
}
def _alle_slot_dateien(files: dict) -> list[Path]:
return [*files["recherche"], *files["auswahl"], files["auswahl_check"], files["ergaenzung"]]
def cancel_bausteine(topic: str) -> bool:
if topic not in _bausteine_progress:
return False
_bausteine_cancelled.add(topic)
kill_process(f"bausteine-{topic}-")
return True
def _resume_step(topic: str) -> int:
"""Erster noch offener Schritt anhand der persistierten Zwischendateien."""
files = _bausteine_files(topic)
if sum(p.exists() for p in files["recherche"]) < 3:
return 0
if not any(p.exists() for p in files["auswahl"]):
return 1
if not files["auswahl_check"].exists():
return 2
if project_dir(topic).is_dir() and not files["ergaenzung"].exists():
return 3
return len(_bausteine_steps(topic))
def bausteine_status(topic: str) -> dict:
steps = _bausteine_steps(topic)
ready = bausteine_path(topic).exists()
generating = topic in _bausteine_progress
partial = False
if generating:
current = _bausteine_step.get(topic)
states = [
"pending" if current is None else "done" if i < current else "active" if i == current else "pending"
for i in range(len(steps))
]
elif ready:
states = ["done"] * len(steps)
else:
nxt = _resume_step(topic)
partial = nxt > 0
states = ["done" if i < nxt else "pending" for i in range(len(steps))]
return {
"ready": ready,
"generating": generating,
"progress": _bausteine_progress.get(topic),
"error": _bausteine_errors.get(topic),
"partial": partial,
"steps": [{"label": label, "state": s} for label, s in zip(steps, states)],
}
def active_bausteine() -> list[dict]:
return [{"topic": t, "progress": p} for t, p in _bausteine_progress.items()]
def reset_bausteine(topic: str) -> None:
files = _bausteine_files(topic)
files["final"].unlink(missing_ok=True)
shutil.rmtree(files["arbeit"], ignore_errors=True)
_bausteine_errors.pop(topic, None)
def _ergaenzung_schema(data):
"""{"bausteine": [{"titel", "beschreibung"}]} → Liste (leer erlaubt) · sonst None."""
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
return None
out = []
for b in data["bausteine"]:
if not isinstance(b, dict) or not isinstance(b.get("titel"), str) or not isinstance(b.get("beschreibung"), str):
return None
titel, beschreibung = b["titel"].strip(), b["beschreibung"].strip()
if not titel:
return None
out.append((titel, beschreibung))
return out
def _pdfs_konvertieren(project: Path) -> None:
"""PDFs im Projekt in .txt wandeln (pdftotext) — Agenten lesen Text statt Seiten-Bildern.
Wird vor jeder Projekt-Generierung aufgerufen; konvertiert nur, wenn die
.txt fehlt oder älter als das PDF ist. Das Original bleibt unangetastet.
Fehlt pdftotext und das Projekt enthält PDFs → harter Fehler statt
unzuverlässigem Direkt-Lese-Modus (MiniMax-Bilderlimit, Vision-Kosten).
"""
pdfs = list(project.rglob("*.pdf"))
if not pdfs:
return
if shutil.which("pdftotext") is None:
raise RuntimeError("pdftotext fehlt (poppler-utils installieren) — PDFs im Projekt können nicht gelesen werden")
for pdf in pdfs:
txt = pdf.with_suffix(".txt")
if txt.exists() and txt.stat().st_mtime >= pdf.stat().st_mtime:
continue
try:
subprocess.run(["pdftotext", "-layout", str(pdf), str(txt)], check=True, timeout=120)
_log(project.name, f"PDF konvertiert: {pdf.name}{txt.name}")
except Exception as e:
raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e
def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str:
if project:
source = _prompt("Bausteine-Quelle-Projekt", project=project)
else:
source = _prompt("Bausteine-Quelle-Thema", topic=topic)
return _prompt(
"Bausteine-Recherche",
topic=topic, source=source, bausteine_path=out_path, extra=_extra(instructions),
)
def _file_payload(path: Path):
"""Gültig, wenn die Slot-Datei existiert und nummerierte Einträge enthält."""
if not path.exists():
return None
text = path.read_text(encoding="utf-8")
return text if _parse_auswahl(text) else None
def _auswahl_payload(path: Path):
if not path.exists():
return None
text = path.read_text(encoding="utf-8")
entries = _parse_auswahl(text)
return (text, entries) if entries else None
def _auswahl_check_schema(data):
"""{"nachtraege": [...], "streichen": [...]} — None bei Schema-Verstoß."""
if not isinstance(data, dict):
return None
nach = data.get("nachtraege", [])
streich = data.get("streichen", [])
if not isinstance(nach, list) or not isinstance(streich, list):
return None
if not all(isinstance(x, str) for x in [*nach, *streich]):
return None
return {"nachtraege": nach, "streichen": streich}
async def generate_bausteine(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
if topic in _bausteine_progress:
return
_bausteine_progress[topic] = "Wartend…"
_bausteine_errors.pop(topic, None)
files = _bausteine_files(topic)
final_path = files["final"]
project = project_dir(topic) if project_dir(topic).is_dir() else None
def set_p(msg: str, step: int | None = None) -> None:
_bausteine_progress[topic] = msg
if step is not None:
_bausteine_step[topic] = step
def is_cancelled() -> bool:
return topic in _bausteine_cancelled
def abgebrochen() -> None:
_bausteine_errors[topic] = "Abgebrochen — Fortschritt bleibt erhalten"
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled)
try:
async with _semaphore:
files["arbeit"].mkdir(parents=True, exist_ok=True)
if project:
await asyncio.to_thread(_pdfs_konvertieren, project)
# „Neu erstellen": fertige Bausteine → kompletter Frischstart.
# Sonst sind Slot-Dateien Reste eines Abbruchs/Fehlers → Resume.
if final_path.exists():
for p_alt in _alle_slot_dateien(files):
p_alt.unlink(missing_ok=True)
# Schritt 1: 4 Recherche-Agenten, 3 gültige nötig — vorhandene Slot-Dateien zählen
recherchen: list[str] = []
offen = []
for i, path in enumerate(files["recherche"], 1):
text = _file_payload(path)
if text is not None and len(recherchen) < 3:
recherchen.append(text)
else:
offen.append((i, path))
vorhanden = len(recherchen)
set_p(f"Recherche läuft ({vorhanden}/3 gültig)…", step=0)
if vorhanden < 3:
caps = "files" if project else "full"
slots = [
{
"key": f"bausteine-{topic}-recherche-{i}",
"prompt": _build_recherche_prompt(topic, path, instructions, project),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=path: _file_payload(p)),
}
for i, path in offen
]
neue = await _race(
topic, "Recherche", slots, 3 - vorhanden, _timeout("recherche"), provider,
on_update=lambda c: set_p(f"Recherche läuft ({vorhanden + c}/3 gültig)…"),
cancelled=is_cancelled,
)
if is_cancelled():
abgebrochen()
return
if neue is None:
_bausteine_errors[topic] = "Recherche fehlgeschlagen (Quorum nicht erreicht)"
return
recherchen += neue
# Schritt 2: 2 Auswahl-Agenten, der erste gewinnt — vorhandene gültige Datei wird übernommen
n_est = max(len(_parse_auswahl(t)) for t in recherchen)
bestehende = next((res for p in files["auswahl"] if (res := _auswahl_payload(p)) is not None), None)
if bestehende is not None:
flat, entries = bestehende
else:
set_p("Konsolidiere Recherche…", step=1)
results_block = "\n\n".join(f"### Recherche {i}\n\n{text}" for i, text in enumerate(recherchen, 1))
slots = [
{
"key": f"bausteine-{topic}-auswahl-{i}",
"prompt": _prompt("Bausteine-Auswahl", topic=topic, results=results_block, out_path=path),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=path: _auswahl_payload(p)),
}
for i, path in enumerate(files["auswahl"], 1)
]
auswahl = await _race(topic, "Auswahl", slots, 1, _timeout("auswahl", n_est), provider, cancelled=is_cancelled)
if is_cancelled():
abgebrochen()
return
if auswahl is None:
_bausteine_errors[topic] = "Auswahl fehlgeschlagen (kein gültiges Ergebnis)"
return
flat, entries = auswahl[0]
# Schritt 2b: Auswahl-Prüfung gegen die Recherche-Titel (JSON, nicht fatal)
set_p("Prüfe Auswahl…", step=2)
check_path = files["auswahl_check"]
patch = _auswahl_check_schema(_json_datei(check_path))
if patch is None:
check_path.unlink(missing_ok=True)
titel_listen = "\n\n".join(
f"### Recherche {i}\n" + "\n".join(f"- {_titel(t)}" for t in _parse_auswahl(text).values())
for i, text in enumerate(recherchen, 1)
)
status, check = await run_single_slot(
ctx, "Auswahl-Check",
key=f"bausteine-{topic}-auswahlcheck-1",
prompt=_prompt("Bausteine-Auswahl-Check", topic=topic, results=titel_listen, auswahl=flat, out_path=check_path),
role="fast", capabilities="files",
payload=lambda result: _auswahl_check_schema(_json_datei(check_path)),
timeout=_timeout("auswahl_check", len(entries)),
)
if status == CANCELLED:
abgebrochen()
return
if status == FAILED:
_log(topic, "Auswahl-Check fehlgeschlagen — fahre ohne Korrekturen fort")
else:
patch = check
if patch is not None and (patch["streichen"] or patch["nachtraege"]):
idx = _titel_index(entries)
weg = {num for t in patch["streichen"] if (num := _titel_aufloesen(idx, t)) is not None}
if weg:
_log(topic, f"Auswahl-Check streicht Duplikate: {sorted(weg)}")
entries = {n: t for n, t in entries.items() if n not in weg}
if patch["nachtraege"]:
_log(topic, f"Auswahl-Check ergänzt {len(patch['nachtraege'])} Bausteine")
texts = [t for _, t in sorted(entries.items())] + list(patch["nachtraege"])
entries = {i: t for i, t in enumerate(texts, 1)}
# Schritt 4 (nur Projekte): Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt,
# ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung].
if project:
set_p("Ergänze Themenfeld…", step=3)
erg_path = files["ergaenzung"]
ergaenzungen = _ergaenzung_schema(_json_datei(erg_path))
if ergaenzungen is None:
erg_path.unlink(missing_ok=True)
status, ergaenzungen = await run_single_slot(
ctx, "Ergänzung",
key=f"bausteine-{topic}-ergaenzung-1",
prompt=_prompt(
"Bausteine-Ergaenzung",
topic=topic, bausteine="\n".join(f"- {t}" for t in entries.values()),
out_path=erg_path, extra=_extra(instructions),
),
role="quick", capabilities="full",
payload=lambda result: _ergaenzung_schema(_json_datei(erg_path)),
timeout=_timeout("ergaenzung"),
)
if status == CANCELLED:
abgebrochen()
return
if status == FAILED:
_bausteine_errors[topic] = "Ergänzung fehlgeschlagen (kein gültiges Ergebnis)"
return
idx = _titel_index(entries)
neu = [(t, b) for t, b in ergaenzungen if _titel_aufloesen(idx, t) is None]
if neu:
_log(topic, f"Ergänzung: {len(neu)} Baustein(e) aus dem Themenfeld ergänzt")
start = max(entries, default=0) + 1
for off, (t, b) in enumerate(neu):
entries[start + off] = f"{t}{b} [Ergänzung]"
# Titel eindeutig machen und unsortiertes Inventar schreiben
entries = _eindeutige_titel(entries)
atomic_write_text(final_path, "\n".join(f"{i}. {t}" for i, t in entries.items()) + "\n")
except Exception as e:
log.exception("[%s] Bausteine-Generierung fehlgeschlagen", topic)
_bausteine_errors[topic] = str(e)[:2000]
finally:
# Kein Datei-Cleanup: Zwischendateien bleiben für Resume bzw. Nachvollziehbarkeit.
_bausteine_progress.pop(topic, None)
_bausteine_step.pop(topic, None)
_bausteine_cancelled.discard(topic)

668
backend/block_calls.py Normal file
View File

@@ -0,0 +1,668 @@
"""Board 2, verschmolzene Call-Struktur: 3 Bausteine pro Block statt ~20 serieller Segmente.
Die alte Stage-Treppe (Finder-Runden → Facts find/erg/check → Konsolidierung → Lücken →
Nachfass → Levels → Relevanz → Fragen → Kritik → Flashcards → Beispiele → Check) kostete
pro Block 3555 Calls und ~14 min Wandzeit — bei p50 2050 s pro Call zählt NUR die Zahl
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
= 45 Segmente, 69 Calls. Unabhängigkeit bleibt: Generatoren und Prüfer sind getrennte
Agenten, Konsens (≥2 unabhängige Nennungen) und Einstimmigkeits-Faltung wie zuvor.
Output-Kontrakt unverändert (finalize/QA/Guide/Übungssystem lesen dieselben Strukturen):
raw {block: [sub]}, facts {block: {sub_norm: 5-Felder}}, sidecar {block: [{title, level,
relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashcard/example}."""
import asyncio
import hashlib
import logging
import database as db
import embedding
from blocks import (
_SOURCE_TEMPLATE, _FACTS_FIELDS, _agreed_cliques, _cited_evidence, _dedup_subblocks,
_evidence_pack, _facts_lines, _facts_union, _luecken_schnitt, _neg_set, _pairs_of,
_sink_json, _sub_tokens, _subs_hash, _variant_clusters, load_source, material_folder,
source_folder,
)
from config import (ART_SPLIT_SUBS, EMBEDDING_AKTIV, GEN_PANEL, SEED_COVER_COS,
VERIFY_PANEL)
from jsonio import read_json_file as _json_file
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _race, _timeout, run_single_slot
from textkit import _norm_title, clean_title
log = logging.getLogger("creator.block_calls")
_STUFEN = ("beginner", "advanced", "expert")
_RELEVANZ = ("relevant", "peripheral")
def _h8(*parts: str) -> str:
return hashlib.md5("|".join(parts).encode("utf-8")).hexdigest()[:8]
# ── Schemas ─────────────────────────────────────────────────────────────────────────
def _gen_schema(data) -> list[dict] | None:
"""{"subs": [{title, level, relevance, …facts}]} → normalisierte Liste · sonst None.
Feld-Normalisierung wie _facts_schema; ungültiges level/relevance fällt auf ""
(die Stimme entfällt im Vote, der Sub bleibt)."""
if not isinstance(data, dict) or not isinstance(data.get("subs"), list):
return None
out = []
for e in data["subs"]:
if not isinstance(e, dict) or not str(e.get("title", "")).strip():
continue
bf = [{"text": t, "source": str(f.get("source", "")).strip()}
for f in (e.get("cited_facts") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
lv = str(e.get("level", "")).strip().casefold()
rv = str(e.get("relevance", "")).strip().casefold()
out.append({
"title": clean_title(str(e["title"]).strip()),
"level": lv if lv in _STUFEN else "",
"relevance": rv if rv in _RELEVANZ else "",
"key_points": [k for x in (e.get("key_points") or []) if (k := str(x).strip())],
"prerequisites": str(e.get("prerequisites", "")).strip(),
"hurdles": str(e.get("hurdles", "")).strip(),
"cited_facts": bf,
"example_idea": str(e.get("example_idea", "")).strip(),
})
return out or None
def _vid(x, n: int) -> int | None:
"""Prüfer-Nummer → int in 1..n, else None (bools sind keine ids)."""
if isinstance(x, bool):
return None
if isinstance(x, str) and x.isdigit():
x = int(x)
return x if isinstance(x, int) and 1 <= x <= n else None
def _verify_schema(data, n: int) -> dict | None:
"""Prüfer-Output → normalisiertes Verdikt · None wenn kaputt. Alle Felder optional
außer der Grundform (dict) — ein leeres Verdikt {"gruppen": []} heißt „alles ok"."""
if not isinstance(data, dict):
return None
pflicht = ("gruppen", "kataloge", "fremd", "luecken", "uebernehmen", "facts_probleme",
"levels", "relevanz")
if not any(k in data for k in pflicht):
return None
def _ids(lst):
return sorted({i for x in (lst or []) if (i := _vid(x, n)) is not None})
gruppen = []
for g in data.get("gruppen") or []:
if not isinstance(g, dict):
continue
haupt = _vid(g.get("haupt"), n)
ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or []))
if len(ids) >= 2:
gruppen.append({"haupt": haupt if haupt in ids else None, "ids": ids})
kataloge = []
for k in data.get("kataloge") or []:
if not isinstance(k, dict):
continue
ids = _ids(k.get("mitglieder"))
titel = str(k.get("titel") or "").strip()
if len(ids) >= 2 and titel:
kataloge.append({"titel": titel, "ids": ids})
uebernehmen = {}
for k, v in (data.get("uebernehmen") or {}).items() if isinstance(data.get("uebernehmen"), dict) else []:
if (i := _vid(k, n)) is not None:
uebernehmen[i] = str(v).strip().casefold()
probleme = []
for p in data.get("facts_probleme") or []:
if isinstance(p, dict) and (i := _vid(p.get("nr"), n)) is not None:
probleme.append({"nr": i, "discard": bool(p.get("discard")),
"hinweis": str(p.get("hinweis", "")).strip()})
def _enum_map(key, allowed):
out = {}
raw = data.get(key)
for k, v in (raw.items() if isinstance(raw, dict) else []):
if (i := _vid(k, n)) is not None and str(v).strip().casefold() in allowed:
out[i] = str(v).strip().casefold()
return out
return {"gruppen": gruppen, "kataloge": kataloge, "fremd": set(_ids(data.get("fremd"))),
"luecken": [s.strip() for s in data.get("luecken") or [] if isinstance(s, str) and s.strip()],
"uebernehmen": uebernehmen, "facts_probleme": probleme,
"levels": _enum_map("levels", _STUFEN), "relevanz": _enum_map("relevanz", _RELEVANZ)}
def _pattern_liste(lst) -> list[dict]:
out = []
for e in lst or []:
if isinstance(e, dict):
blk, sub, q = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question"))
if blk and sub and q:
out.append({"block": blk, "subblock": sub, "question": q})
return out
def _art_gen_schema(data) -> dict | None:
"""{"pattern": […], "cards": […], "examples": […]} → normalisiert · None wenn kaputt.
pattern ist Pflicht (Leitner hängt an Fragen), cards/examples best-effort."""
if not isinstance(data, dict):
return None
pattern = _pattern_liste(data.get("pattern"))
if not pattern:
return None
cards = []
for e in data.get("cards") or []:
if isinstance(e, dict):
blk, sub, q, a = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question", "answer"))
if blk and sub and q and a:
cards.append({"block": blk, "subblock": sub, "question": q, "answer": a})
examples = []
for e in data.get("examples") or []:
if isinstance(e, dict):
blk, sub, pr, res = (str(e.get(k, "")).strip() for k in ("block", "subblock", "problem", "result"))
steps = [s for x in (e.get("steps") or []) if (s := str(x).strip())]
if blk and sub and pr and steps:
examples.append({"block": blk, "subblock": sub, "problem": pr, "steps": steps, "result": res})
return {"pattern": pattern, "cards": cards, "examples": examples}
def _art_check_schema(data) -> dict | None:
"""{"ok": true} → leeres Verdikt · sonst pattern (bereinigt) + pattern_ergaenzt +
examples_probleme (1-basierte Indizes)."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
if not any(k in data for k in ("pattern", "pattern_ergaenzt", "examples_probleme")):
return None
probleme = set()
for p in data.get("examples_probleme") or []:
i = p.get("index") if isinstance(p, dict) else p
if isinstance(i, str) and i.isdigit():
i = int(i)
if isinstance(i, int) and not isinstance(i, bool) and i >= 1:
probleme.add(i)
return {"pattern": _pattern_liste(data.get("pattern")),
"pattern_ergaenzt": _pattern_liste(data.get("pattern_ergaenzt")),
"examples_probleme": probleme}
# ── Gemeinsames ─────────────────────────────────────────────────────────────────────
def _inline_source(topic: str, sources: list[str] | None, queries: list[str]) -> tuple[str, str]:
"""→ (source-Slot, capabilities). Korpus-Auszüge inline (uni/projekt/link oder
thema-Research-Material); ohne Treffer fail-open auf die alte Selbst-Recherche."""
mat = material_folder(topic)
ev = _evidence_pack(mat, sources, queries) if mat else ""
if ev:
return _prompt("Blocks-Source-Inline", excerpts=ev), "none"
_type = load_source(topic).get("type", "thema")
folder = source_folder(topic)
if _type in _SOURCE_TEMPLATE:
return _prompt(_SOURCE_TEMPLATE[_type], project=folder), ("files" if folder else "full")
return _prompt("Blocks-Source-Thema", topic=topic), "full"
async def _sims_of(titles: list[str]):
"""Ähnlichkeitsmatrix fürs Variant-Clustering; ohne Modell exakte Norm-Gleichheit."""
if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
sims = await asyncio.to_thread(embedding.embed_sims, titles)
if sims is not None:
return sims
norms = [_norm_title(t) for t in titles]
return [[1.0 if norms[i] == norms[j] else 0.0 for j in range(len(titles))]
for i in range(len(titles))]
def _fk_of(e: dict) -> dict:
return {k: e.get(k) for k in _FACTS_FIELDS}
# ── Generate ────────────────────────────────────────────────────────────────────────
async def _generate_block(ctx: GenContext, files: dict, title: str, description: str,
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None,
seeds: list[str] | None = None, melde=None) -> dict | None:
"""Bottom-up-Anreicherung: die Subs stehen FEST — Board 1 hat die Atome geclustert und
als Subs persistiert. EIN Call liefert pro vorgegebenem Sub die Lern-Facts + Level +
Relevanz aus dem Material. Er entdeckt und entfernt KEINE Subs (Discovery/Konsens/Seeds
entfallen; die Vollständigkeit kommt aus Board 1). → {raw, facts, unsicher, votes} | None."""
topic = ctx.topic
work_dir = files["arbeit"]
bnorm = _norm_title(title)
# feste Subs aus Board 1 (Reihenfolge erhalten, dublettenfrei)
subs: list[str] = []
seen: set[str] = set()
for r in await db.list_subblocks(topic, bnorm):
st = str(r.get("sub_title") or "").strip()
sn = _norm_title(st)
if st and sn and sn not in seen and r.get("status") in ("consensus", "candidate"):
seen.add(sn)
subs.append(st)
if not subs:
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
source, caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + subs)
if melde:
melde("Anreichern")
h = _h8(title, description, "enrich")
pfad = work_dir / f"enrich-{h}.json"
sub_liste = "\n".join(f"{i}. {t}" for i, t in enumerate(subs, 1))
prompt = _prompt("Subblock-Anreichern", topic=topic,
block=f"{title}{description}" if description else title,
subs=sub_liste, source=source, extra=_extra(instructions))
if _gen_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Anreichern", key=f"blocks-{topic}-{ns}sb-enrich-{h}",
prompt=prompt, role="quick", capabilities=caps,
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
timeout=_timeout("generate", len(subs)))
if status == FAILED:
_log(topic, f"Anreichern {title} ohne Ergebnis — Facts bleiben leer, Prüfer misst")
if ctx.is_cancelled():
return None
out = _gen_schema(_json_file(pfad)) or []
by_norm = {_norm_title(e["title"]): e for e in out}
facts: dict[str, dict] = {}
votes: dict[str, dict] = {}
for st in subs:
sn = _norm_title(st)
e = by_norm.get(sn)
if e:
fk = _fk_of(e)
votes[sn] = {"level": [e["level"]] if e.get("level") else [],
"relevance": [e["relevance"]] if e.get("relevance") else []}
else: # der Call ließ diesen Sub aus — leere Facts, der Prüfer/QA sieht die Lücke
fk = {k: ([] if k in ("key_points", "cited_facts") else "") for k in _FACTS_FIELDS}
votes[sn] = {"level": [], "relevance": []}
facts[sn] = fk
await db.put_subblock(topic, bnorm, sn, title, st, status="consensus")
return {"raw": {title: subs}, "facts": {title: facts}, "unsicher": [], "votes": votes}
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
def _default_vote(stimmen: list[str], default: str) -> str:
"""Mehrheit über die Stimmen (Gen-Vorschläge + implizite/explizite Prüfer-Stimmen);
Patt oder leer → default."""
counter: dict[str, int] = {}
for s in stimmen:
if s:
counter[s] = counter.get(s, 0) + 1
best = max(counter.values(), default=0)
winners = [s for s, v in counter.items() if v == best]
return winners[0] if len(winners) == 1 and best else default
async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q: dict,
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None, melde=None,
keep_all: bool = False) -> dict | None:
"""VERIFY_PANEL unabhängige Prüfer auditieren den Block in EINEM Call (MECE-Faltung,
Fremd, Lücken, Unsicher-Übernahme, Facts-Korrektheit, Level/Relevanz). Auswertung mit
Schnittmengen-Semantik pro Befundklasse (Faltung/Fremd/Übernahme einstimmig, Discard
2/2, Korrektur ≥1 Stimme); Fix-Tail ist EIN Call für Korrekturen + belegte Lücken.
keep_all=True (Bottom-up): kein Atom wird entfernt/gefaltet/als neu erfunden — nur
Facts-Korrektur + Level/Relevanz bleiben (Board 1 hat schon MECE geclustert).
{raw, facts, sidecar} | None (nur bei Cancel)."""
topic = ctx.topic
work_dir = files["arbeit"]
bnorm = _norm_title(title)
subs = list(gen["raw"].get(title) or [])
bfacts: dict[str, dict] = dict(gen["facts"].get(title) or {})
unsicher: list[dict] = list(gen.get("unsicher") or [])
votes: dict[str, dict] = gen.get("votes") or {}
nummern = subs + [u["title"] for u in unsicher] # 1-basiert: consensus, dann unsicher
n = len(nummern)
def _kp(t: str) -> list:
fk = bfacts.get(_norm_title(t)) or next(
(u for u in unsicher if u["title"] == t), {})
return (fk.get("key_points") or [])[:3]
if melde:
melde("Verify")
verdicts: list[dict] = []
if n:
zeilen = "\n".join(f"{k}. {t}" + "".join(f"\n - {p}" for p in _kp(t))
for k, t in enumerate(nummern, 1))
u_txt = ""
if unsicher:
erste = len(subs) + 1
u_txt = (f"\nUNSICHER — entries {erste}{n} were named by only ONE generator "
"(or are seed candidates). Judge their adoption under `uebernehmen`.\n")
cites = [bf.get("source", "") for fk in bfacts.values() for bf in fk.get("cited_facts", [])]
mat = material_folder(topic)
ev = _cited_evidence(mat, sources, cites, [title] + nummern) if mat else ""
if ev:
source = _prompt("Blocks-Source-Inline", excerpts=ev)
else:
source, _caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + nummern)
sh = _subs_hash({title: nummern})
pfade = {j: work_dir / f"verify-{sh}-j{j}.json" for j in (*range(1, VERIFY_PANEL + 1), "E")}
async def _judge(j):
if _verify_schema(_json_file(pfade[j]), n) is not None:
return # resume
status, _v = await run_single_slot(
ctx, f"{lbl}Verify j{j}", key=f"blocks-{topic}-{ns}sb-verify-{sh}-j{j}",
prompt=_prompt("Subblock-Verify", topic=topic, block=title, subs=zeilen,
unsicher=u_txt, source=source, extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=pfade[j]: _sink_json(result, p, lambda d: _verify_schema(d, n)),
timeout=_timeout("verify", n))
if status == FAILED:
_log(topic, f"Verify {title} j{j} ohne Ergebnis — fail-open")
await asyncio.gather(*[_judge(j) for j in range(1, VERIFY_PANEL + 1)])
if ctx.is_cancelled():
return None
verdicts = [v for j in range(1, VERIFY_PANEL + 1)
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None]
if len(verdicts) == 1 and VERIFY_PANEL >= 2: # Ersatz-Richter statt fail-open
await _judge("E")
if ctx.is_cancelled():
return None
verdicts = [v for j in (*range(1, VERIFY_PANEL + 1), "E")
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None][:2]
einstimmig = len(verdicts) >= 2
if n and not einstimmig:
_log(topic, f"Verify {title}: nur {len(verdicts)}/2 Prüfer — fail-open, unsicher verworfen")
negs = [_neg_set(t) for t in nummern]
gone: set[int] = set()
keep = list(subs)
korrekturen: list[dict] = [] # {titel, hinweis}
luecken: list[str] = []
def _titel(k: int) -> str:
return nummern[k - 1]
async def _fold(k: int, wf: dict | None):
t = _titel(k)
lf = bfacts.pop(_norm_title(t), None) or {}
if wf is not None:
_facts_union(wf, lf)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="variant")
if t in keep:
keep.remove(t)
gone.add(k)
if einstimmig:
v1, v2 = verdicts[0], verdicts[1]
if keep_all:
# Bottom-up: jedes Board-1-Atom bleibt. Destruktive/erzeugende Befunde
# neutralisieren — die Loops unten laufen dann leer. Nur Facts-Korrektur
# (ohne discard) + Level/Relevanz überleben.
for v in (v1, v2):
v["fremd"] = set()
v["gruppen"] = []
v["kataloge"] = []
v["uebernehmen"] = {}
v["luecken"] = []
# discard abschalten (kein Atom entfernen), aber den Korrektur-Hinweis behalten
v["facts_probleme"] = [{**p, "discard": False} for p in v["facts_probleme"]]
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
for k in sorted(v1["fremd"] & v2["fremd"]):
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
# 2. Unsicher-Übernahme (2/2 „ja"): wird consensus samt Generator-Facts
for k in range(len(subs) + 1, n + 1):
if k in gone:
continue
u = unsicher[k - len(subs) - 1]
if v1["uebernehmen"].get(k) == "ja" and v2["uebernehmen"].get(k) == "ja":
sn = _norm_title(u["title"])
bfacts[sn] = _fk_of(u)
keep.append(u["title"])
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
else:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
gone.add(k)
# 3. Gruppen (einstimmige Paare): haupt-Votum, sonst key_points/Länge
haupt_votes: dict[int, int] = {}
for v in (v1, v2):
for g in v["gruppen"]:
if g["haupt"]:
haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["gruppen"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
win = max(g, key=lambda k: (haupt_votes.get(k, 0), len(_kp(_titel(k))), len(_titel(k)), -k))
wf = bfacts.setdefault(_norm_title(_titel(win)), {})
for k in g:
if k != win:
await _fold(k, wf)
# 4. Kataloge: Aufzählungszeilen → EIN neuer benannter Sub (Facts-Union)
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["kataloge"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
titel = next((clean_title(x["titel"]) for x in v1["kataloge"] + v2["kataloge"]
if set(x["ids"]) & set(g) and clean_title(x["titel"])), "")
kn = _norm_title(titel)
if not kn or kn in {_norm_title(s) for s in keep}:
continue
kf: dict = {}
for k in g:
await _fold(k, kf)
bfacts[kn] = kf
keep.append(titel)
await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus")
# 5. Facts-Probleme: discard nur 2/2 (irreversibel), Korrektur ab 1 Stimme
d1 = {p["nr"] for p in v1["facts_probleme"] if p["discard"]}
d2 = {p["nr"] for p in v2["facts_probleme"] if p["discard"]}
for k in sorted(d1 & d2):
if k in gone:
continue
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
for p in v1["facts_probleme"] + v2["facts_probleme"]:
k = p["nr"]
if k in gone or not p["hinweis"]:
continue
t = _titel(k)
if t in keep and all(x["titel"] != t for x in korrekturen):
korrekturen.append({"titel": t, "hinweis": p["hinweis"]})
# 6. Lücken (Schnitt beider Prüfer, Cap)
luecken = _luecken_schnitt(v1["luecken"], v2["luecken"])
else:
# fail-open: consensus bleibt, unsicher wird verworfen (wie heutiges Clarify-Aus)
for u in unsicher:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
# 7. Level/Relevanz: Stimmen = Generatoren + Prüfer (explizite Korrektur schlägt
# die implizite Zustimmung); Patt → advanced/relevant (heutige Defaults)
sidecar_subs = []
for t in keep:
sn = _norm_title(t)
try:
k = nummern.index(t) + 1
except ValueError:
k = 0 # Katalog-/Fix-Neuzugänge haben keine Nummer
stimmen_l = list((votes.get(sn) or {}).get("level") or [])
stimmen_r = list((votes.get(sn) or {}).get("relevance") or [])
for v in verdicts[:2]:
if k and k in v["levels"]:
stimmen_l += [v["levels"][k]] * 2 # explizite Korrektur wiegt doppelt
if k and k in v["relevanz"]:
stimmen_r += [v["relevanz"][k]] * 2
fk = bfacts.get(sn) or {}
sidecar_subs.append({"title": t, "level": _default_vote(stimmen_l, "advanced"),
"relevance": _default_vote(stimmen_r, "relevant"), "facts": fk})
# 8. Fix-Tail (01 Call): Korrekturen + belegte Lücken
if (korrekturen or luecken) and not ctx.is_cancelled():
if melde:
melde("Fix")
neu = await _fix_befunde(ctx, files, title, korrekturen, luecken,
[s["title"] for s in sidecar_subs], instructions, ns, lbl, sources)
for e in neu or []:
sn = _norm_title(e["title"])
vorhanden = next((s for s in sidecar_subs if _norm_title(s["title"]) == sn), None)
if vorhanden is not None: # Korrektur: Facts ersetzen, Einstufung bleibt
vorhanden["facts"] = _fk_of(e)
bfacts[sn] = vorhanden["facts"]
elif not keep_all: # Lücken-Fund: neuer consensus-Sub
# (Bottom-up unterdrückt das: die Subs stehen fest, der Fix-Call darf keine
# neuen einschleusen — sonst wächst die Sub-Zahl über die Board-1-Atome hinaus)
bfacts[sn] = _fk_of(e)
keep.append(e["title"])
sidecar_subs.append({"title": e["title"],
"level": e["level"] or "advanced",
"relevance": e["relevance"] or "relevant",
"facts": bfacts[sn]})
await db.put_subblock(topic, bnorm, sn, title, e["title"], status="consensus")
if len(keep) != len(subs):
_log(topic, f"Verify {title}: {len(subs)} consensus + {len(unsicher)} unsicher → {len(keep)}")
return {"raw": {title: [s["title"] for s in sidecar_subs]},
"facts": {title: bfacts},
"sidecar": {title: sidecar_subs}}
async def _fix_befunde(ctx: GenContext, files: dict, title: str, korrekturen: list[dict],
luecken: list[str], vorhanden: list[str], instructions: str,
ns: str, lbl: str, sources: list[str] | None) -> list[dict]:
"""EIN Call korrigiert beanstandete Facts und füllt gemeldete Lücken. Hartes Beleg-Gate
für Neuzugänge (key_points/cited_facts nicht leer) + Dedup gegen den Bestand — ein
unbelegter Lücken-„Fund" flutet sonst das Fakten-Gate des Guides."""
topic = ctx.topic
work_dir = files["arbeit"]
auftraege = [f"- KORRIGIEREN: „{k['titel']}“ — {k['hinweis']}" for k in korrekturen]
auftraege += [f"- LÜCKE (neuer Subbaustein, nur wenn belegbar): {l}" for l in luecken]
source, caps = await asyncio.to_thread(
_inline_source, topic, sources,
[title] + [k["titel"] for k in korrekturen] + list(luecken))
sh = _h8(title, *sorted(a for a in auftraege))
pfad = work_dir / f"fix-{sh}.json"
if _gen_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Fix", key=f"blocks-{topic}-{ns}sb-fix-{sh}",
prompt=_prompt("Subblock-Fix", topic=topic, block=title, source=source,
auftraege="\n".join(auftraege), extra=_extra(instructions)),
role="quick", capabilities=caps,
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
timeout=_timeout("fix", len(auftraege)))
if status == FAILED:
_log(topic, f"Fix {title} ohne Ergebnis — Befunde bleiben offen")
return []
out = _gen_schema(_json_file(pfad)) or []
korrektur_norms = {_norm_title(k["titel"]) for k in korrekturen}
have_norms = {_norm_title(t) for t in vorhanden}
angenommen = []
for e in out:
sn = _norm_title(e["title"])
if sn in korrektur_norms:
angenommen.append(e)
continue
if sn in have_norms or not (e["key_points"] or e["cited_facts"]):
continue # unbelegt oder Dublette → verfällt
st = _sub_tokens(e["title"])
if any(st <= _sub_tokens(t) or _sub_tokens(t) <= st for t in vorhanden):
continue
angenommen.append(e)
have_norms.add(sn)
return angenommen
# ── Artefakte ───────────────────────────────────────────────────────────────────────
def _subs_text(title: str, sidecar_subs: list[dict]) -> str:
return f"BLOCK: {title}\n" + "\n".join(
f"- {s['title']}\n" + "\n".join(f" {z}" for z in _facts_lines(s.get("facts") or {}).splitlines())
for s in sidecar_subs)
async def _artefakte_block(ctx: GenContext, files: dict, title: str,
sidecar_subs: list[dict], instructions: str = "",
ns: str = "", lbl: str = "", melde=None) -> dict | None:
"""EIN Generator-Call liefert Fragen + Flashcards + Beispiele (Split in 2 parallele
Calls bei > ART_SPLIT_SUBS Subs), EIN Prüfer-Call verifiziert Beispiele, bereinigt
die Fragen und ergänzt fehlende. → {pattern, artefacts} | None (nur Cancel)."""
topic = ctx.topic
work_dir = files["arbeit"]
if not sidecar_subs:
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
if melde:
melde("Artefakte gen")
sh = _subs_hash({title: sidecar_subs})
haelften = ([sidecar_subs] if len(sidecar_subs) <= ART_SPLIT_SUBS
else [sidecar_subs[:len(sidecar_subs) // 2], sidecar_subs[len(sidecar_subs) // 2:]])
async def _gen(gi: int, teil: list[dict]):
pfad = work_dir / f"art-{sh}-t{gi}.json"
if _art_gen_schema(_json_file(pfad)) is not None:
return
status, _v = await run_single_slot(
ctx, f"{lbl}Artefakte {gi}", key=f"blocks-{topic}-{ns}art-gen-{sh}-t{gi}",
prompt=_prompt("Artefakt-Generate", topic=topic, blocks=_subs_text(title, teil),
extra=_extra(instructions)),
role="quick", capabilities="none",
payload=lambda result, p=pfad: _sink_json(result, p, _art_gen_schema),
timeout=_timeout("artefakt", len(teil)))
if status == FAILED:
_log(topic, f"Artefakte {title} Teil {gi} ohne Ergebnis")
await asyncio.gather(*[_gen(gi, teil) for gi, teil in enumerate(haelften, 1)])
if ctx.is_cancelled():
return None
pattern: list[dict] = []
cards: list[dict] = []
examples: list[dict] = []
for gi in range(1, len(haelften) + 1):
o = _art_gen_schema(_json_file(work_dir / f"art-{sh}-t{gi}.json"))
if o:
pattern += o["pattern"]
cards += o["cards"]
examples += o["examples"]
# Prüfer: Beispiele verifizieren, Fragen bereinigen + fehlende ergänzen
sub_titles = [s["title"] for s in sidecar_subs]
fehlend = [t for t in sub_titles
if _norm_title(t) not in {_norm_title(p["subblock"]) for p in pattern}]
if pattern or examples:
if melde:
melde("Artefakte check")
tabelle = "\n".join(f"({p['subblock']}) {p['question']}" for p in pattern) or "(keine)"
beisp = "\n\n".join(
f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e["steps"])
+ (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "")
for k, e in enumerate(examples, 1)) or "(keine)"
fehlend_txt = ("\nSUBBLOCKS STILL MISSING A QUESTION:\n"
+ "\n".join(f"- {t}" for t in fehlend) + "\n") if fehlend else "\n"
pfad = work_dir / f"artcheck-{sh}.json"
if _art_check_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Artefakt-Check", key=f"blocks-{topic}-{ns}art-check-{sh}",
prompt=_prompt("Artefakt-Check", topic=topic, facts=_subs_text(title, sidecar_subs),
table=tabelle, fehlend=fehlend_txt, examples=beisp,
extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=pfad: _sink_json(result, p, _art_check_schema),
timeout=_timeout("artefakt_check", len(sidecar_subs)))
if status == FAILED:
_log(topic, f"Artefakt-Check {title} ohne Ergebnis — Rohfassung übernommen")
check = _art_check_schema(_json_file(pfad))
if check:
if check["examples_probleme"]:
examples = [e for k, e in enumerate(examples, 1)
if k not in check["examples_probleme"]]
_log(topic, f"Artefakt-Check {title}: {len(check['examples_probleme'])} Beispiel(e) verworfen")
if check["pattern"]: # bereinigte Fassung ersetzt die Rohfassung
pattern = check["pattern"]
pattern += check["pattern_ergaenzt"]
pattern_map = {title: [{"subblock": p["subblock"], "question": p["question"]}
for p in pattern]}
# block-Feld auf den Karten-Block normieren (Ein-Block-Call — Agent-Echos abfangen)
for e in cards + examples:
e["block"] = title
return {"pattern": pattern_map,
"artefacts": {"flashcard": [{k: e[k] for k in ("block", "subblock", "question", "answer")} for e in cards],
"example": [{k: e[k] for k in ("block", "subblock", "problem", "steps", "result")} for e in examples]}}

1736
backend/blocks.py Normal file

File diff suppressed because it is too large Load Diff

421
backend/board_artefacts.py Normal file
View File

@@ -0,0 +1,421 @@
"""Board 2 „Artefakte": per finished block, prepare the learning artefacts the guide presents.
A card is spawned by board 1's `done` column per mirrored block and runs through:
generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
(die verschmolzenen Calls liegen in block_calls.py — 45 serielle Segmente statt ~20).
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
chapter order), re-run once per generation run."""
import asyncio
import json
import logging
import re
import database as db
import blocks
from block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _outline_block
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from kanban import Flow, Stage
from pipeline import GenContext, _log
from textkit import _norm_title, _title
log = logging.getLogger("creator.board_artefacts")
BOARD = "artefacts"
DONE = "done_artefact"
def _nset(msg: str, step: int | None = None) -> None:
"""Progress no-op — the kanban board itself is the progress display."""
def _safe(norm: str) -> str:
return re.sub(r"\W+", "-", norm).strip("-")[:24] or "block"
def _sub_key(existing: set[str], sn: str) -> str:
"""Agents echo the short sub title while the sub row is keyed 'kurztitel: beschreibung'
resolve to the stored key: exact, unambiguous prefix, then unambiguous substring
containment either way (agents paraphrase/truncate, measured 23 orphans of ~560 rows).
Ambiguous or unresolvable echoes stay unchanged (visible as QA orphan)."""
if sn in existing:
return sn
hits = [s for s in existing if s.startswith(sn + ":")]
if len(hits) == 1:
return hits[0]
if not hits:
hits = [s for s in sorted(existing) if sn in s or s in sn]
if len(hits) == 1:
return hits[0]
return sn
def _card_set_p(flow: Flow, norm: str):
"""Per-card progress: the inner step messages land in-memory on the flow —
board_snapshot shows them as the card's info line + phase stepper while active.
The step INDEX is resolved to its NAME at write time (indices shift with the
source type, names are stable)."""
info = flow.state.setdefault("card_info", {})
def set_p(msg: str, step: int | None = None) -> None:
name = ""
if step is not None:
steps = flow.state.get("blocks_steps")
if steps is None:
steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
if 0 <= step < len(steps):
name = steps[step]
info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
return set_p
def _pfiles(files: dict, norm: str) -> dict:
"""Per-block file namespace: own work dir + facts/artefakte paths, global rest."""
sub = files["arbeit"] / f"ab-{_norm_title(norm).replace(' ', '_')[:60]}"
sub.mkdir(parents=True, exist_ok=True)
return {**files, "arbeit": sub, "facts": sub / "facts.json", "artefakte": sub / "artefakte.json"}
def _entry_line(p: dict) -> str:
d = p.get("description")
return f"{p['title']}{d}" if d else p["title"]
def make_spawner(topic: str, files: dict):
"""Hook for board 1's `done` column: one artefact card per mirrored block."""
async def spawn(block_card_id: str, payload: dict):
norm = payload.get("mirrored_norm")
if not norm:
return
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "generate", {
"title": payload.get("title", ""),
"description": payload.get("description", ""),
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
})
return spawn
async def seed_artefact_cards(topic: str, files: dict) -> int:
"""Board-2-only (Inventar läuft NICHT mit): für jeden fertigen Block eine ablock-Karte
erzeugen, falls noch keine existiert — sonst liefe ohne den Board-1-Spawn-Hook nichts
durch Board 2. Vorhandene Karten bleiben (sie resümieren in ihrer Spalte). → neue Karten."""
spawn = make_spawner(topic, files)
existing = {c["card_id"] for c in await db.kanban_cards(topic, board=BOARD, kind="ablock")}
n = 0
for c in await db.kanban_cards(topic, board="inventory", kind="block", stage="done_block"):
norm = c["payload"].get("mirrored_norm")
if norm and norm not in existing:
await spawn(c["card_id"], c["payload"])
n += 1
return n
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
errs = [r for r in results if isinstance(r, Exception)]
if errs:
for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
log.error("weitere Karten-Exception im Batch: %r", e)
raise errs[0]
flow.wake.set()
def _fail_or_cancel(ctx: GenContext, what: str):
# A per-card failure belongs on the card (last_error/dead-letter), never in the
# topic banner — the inner block functions may have set it there.
blocks._blocks_errors.pop(ctx.topic, None)
if ctx.is_cancelled():
return None # leave the card where it is
raise RuntimeError(f"{what} ohne Ergebnis")
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
async def _seed_map(topic: str) -> dict[str, list[str]]:
"""Demoted fragments become seed candidates of their SURVIVING parent block.
parent_norm may point at a block that itself got grouped/merged/renamed — follow the
redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
alive: set[str] = set()
redirect: dict[str, str] = {}
rejected: list[dict] = []
for r in await db.kanban_cards(topic, board="inventory", kind="block"):
p = r["payload"]
tn = _norm_title(p.get("title", ""))
if not tn:
continue
if r["stage"] in ("done", "done_block"):
mn = p.get("mirrored_norm") or tn
alive.add(mn)
if tn != mn:
redirect.setdefault(tn, mn)
elif r["stage"] == "grouped" and p.get("merged_into"):
redirect.setdefault(tn, _norm_title(p["merged_into"]))
# umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
# without a seed the umbrella's finders may simply miss them (measured).
rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
elif r["stage"] == "rejected":
if p.get("parent_norm"):
redirect.setdefault(tn, p["parent_norm"])
rejected.append(p)
def _resolve(norm: str) -> str | None:
seen: set[str] = set()
cur = norm
while cur and cur not in seen:
if cur in alive: # alive check BEFORE following (self-edges like „Listen"→„Listen")
return cur
seen.add(cur)
cur = redirect.get(cur, "")
return None
seeds: dict[str, list[str]] = {}
for p in rejected:
pn = p.get("parent_norm")
if pn and (target := _resolve(pn)):
seeds.setdefault(target, []).append(p.get("title", ""))
return seeds
def _melder(flow: Flow, norm: str):
"""Live-Stepper der Karte: Phasenname → card_info (ging bei der Call-Verschmelzung
verloren — Karten liefen ohne Badge/Stepper durch das Board)."""
set_p = _card_set_p(flow, norm)
def melde(schritt: str) -> None:
try:
set_p(f"{schritt}", step=blocks._step_idx(flow.topic, schritt))
except ValueError:
set_p(f"{schritt}")
return melde
async def _proc_generate(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Verschmolzener Erzeuger: 2 unabhängige Generatoren liefern Subs+Facts+Einstufung
in EINEM Call, Konsens im Code (block_calls._generate_block)."""
topic = flow.topic
# fragments demoted to a parent become seed candidates of the parent's subblocks
seeds = await _seed_map(topic)
async def one(c):
p = c["payload"]
norm = c["card_id"]
gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""),
p.get("description", ""), instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ",
sources=p.get("sources"),
seeds=[s for s in seeds.get(norm, []) if s] or None,
melde=_melder(flow, norm))
if gen is None:
return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}")
p["raw"], p["facts"] = gen["raw"], gen["facts"]
p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"]
p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"])
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "verify")
await _gather_cards(ctx, flow, cards, one)
async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards):
"""Verschmolzener Prüfer: MECE + Facts + Einstufung in einem Panel-Call, Fix-Tail
inklusive (block_calls._verify_block) — ersetzt facts-check/konsolidierung/levels/
relevance als eigene Stages."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
title = p.get("title", "")
gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {},
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
sources=p.get("sources"), melde=_melder(flow, norm),
keep_all=True)
if res is None:
return None # nur Cancel — Karte bleibt liegen
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
p.pop("unsicher", None)
p.pop("votes", None)
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "artefakte")
await _gather_cards(ctx, flow, cards, one)
async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter
(block_calls._artefakte_block)."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
title = p.get("title", "")
res = await _artefakte_block(ctx, _pfiles(files, norm), title,
(p.get("sidecar") or {}).get(title) or [],
instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
melde=_melder(flow, norm))
if res is None:
return None # nur Cancel
p["pattern"] = res["pattern"]
p["artefacts"] = res["artefacts"]
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "finalize")
await _gather_cards(ctx, flow, cards, one)
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
def _merge_json(path, block_keys: dict) -> None:
data = _json_file(path)
if not isinstance(data, dict):
data = {}
data.update(block_keys)
atomic_write_json(path, data, indent=1)
async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
topic = flow.topic
for c in cards:
p = c["payload"]
title = p.get("title", "")
sidecar = p.get("sidecar") or {}
pattern = p.get("pattern") or {}
artefacts = p.get("artefacts") or {}
# global sidecar files (the legacy read path of guide/frontend/resume)
_merge_json(files["sub_roh"], {t: subs for t, subs in (p.get("raw") or {}).items()})
_merge_json(files["facts"], p.get("facts") or {})
_merge_json(files["sidecar"], sidecar)
_merge_json(files["question_pattern"], pattern)
art_global = _json_file(files["artefakte"])
if not isinstance(art_global, dict):
art_global = {}
for typ in ARTEFACT_TYPES:
kept = [e for e in art_global.get(typ, [])
if _norm_title(_title(str(e.get("block", "")))) != _norm_title(title)]
art_global[typ] = kept + list(artefacts.get(typ, []))
atomic_write_json(files["artefakte"], art_global, indent=1)
# DB mirrors — per block only (no global deletes)
await blocks._mirror_sidecar_db(topic, sidecar)
# stale question/artefact rows of a PREVIOUS run keyed to gone subs: finalize only
# upserts, so re-runs left orphans (measured: 28).
await db.delete_question_pattern(topic, _norm_title(title))
await db.delete_sub_artefakte(topic, _norm_title(title))
# consensus rows of a PREVIOUS run that this run's sidecar no longer carries would
# linger without facts/questions/artefacts (measured: 25) — drop them per block;
# variant/discarded stay for QA. Then default-level the mirror's own stragglers.
for btitle, subs in sidecar.items():
keep = {_norm_title(str(s.get("title", ""))) for s in subs if isinstance(s, dict)}
await db.delete_stale_consensus(topic, _norm_title(btitle), keep - {""})
await db.default_subblock_levels(topic, _norm_title(title))
sub_keys: dict[str, set[str]] = {}
async def _keys(bnorm: str) -> set[str]:
if bnorm not in sub_keys:
sub_keys[bnorm] = {r["sub_norm"] for r in await db.list_subblocks(topic, bnorm)}
return sub_keys[bnorm]
for btitle, entries in pattern.items():
bnorm = _norm_title(btitle)
for e in entries if isinstance(entries, list) else []:
sub = str(e.get("subblock", "")).strip()
sn = _norm_title(sub)
question = str(e.get("question", "")).strip()
if bnorm and sn and question:
sn = _sub_key(await _keys(bnorm), sn)
await db.upsert_question_pattern(topic, bnorm, sn, btitle, sub, question)
btitles = list(sidecar.keys())
for typ in ARTEFACT_TYPES:
for e in artefacts.get(typ, []):
bt = _match_sub(str(e.get("block", "")), btitles)
bnorm, sn = _norm_title(bt), _norm_title(str(e.get("subblock", "")))
if not bnorm or not sn:
continue
sn = _sub_key(await _keys(bnorm), sn)
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
ensure_ascii=False)
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
_log(topic, f"Artefakte fertig: {title}")
flow.wake.set()
# ── Outline (topic-wide barrier singleton) ─────────────────────────────────────────
OUTLINE_CARD = "outline"
async def ensure_outline_card(topic: str) -> None:
"""(Re-)queue the outline singleton — run once per generation run, after everything."""
await db.kanban_upsert_card(topic, BOARD, OUTLINE_CARD, "outline", "outline",
{"title": "Gliederung"})
async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
done = await db.kanban_cards(topic, board="inventory", stage="done_block")
done.sort(key=lambda c: c["updated_at"])
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
if c["payload"].get("title")}
if entries:
# The outline may run BEFORE finalize has merged the global facts.json — feed the
# prereq hints of _learning_order from the card payloads instead (complete as soon
# as every block passed the facts stage, which the trimmed barrier guarantees).
facts_map: dict = {}
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
facts_map.update(bc["payload"].get("facts") or {})
fp = flow.work_dir / "outline-facts.json"
atomic_write_json(fp, facts_map, indent=1)
plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
if ctx.is_cancelled():
return
if isinstance(plan, dict) and plan.get("chapters"):
chapters = [
{"title": ch.get("title", "Kapitel"),
"blocks": [_title(entries[n]) for n in ch.get("numbers", []) if n in entries]}
for ch in plan["chapters"]
]
await db.set_outline(topic, json.dumps({"chapters": chapters}, ensure_ascii=False))
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) for c in cards])
flow.wake.set()
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
"konsolidierung") # abgeschaltete Stage: verwaiste Karten auf generate zurück
async def migriere_alt_karten(topic: str) -> int:
"""Harter Schnitt: Karten der alten Stage-Treppe beim Flow-Start auf `generate`
zurücksetzen (Payload auf die Spawn-Felder reduziert — Zwischenstände der alten
Struktur sind für die verschmolzenen Calls wertlos). → Anzahl migrierter Karten."""
moves = []
for c in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
if c["stage"] in _ALT_STAGES:
p = c["payload"]
await db.kanban_set_payload(topic, BOARD, c["card_id"], {
"title": p.get("title", ""), "description": p.get("description", ""),
"n_size": p.get("n_size", 0), "sources": p.get("sources")})
moves.append((c["card_id"], "generate"))
if moves:
await db.kanban_advance_many(topic, BOARD, moves)
return len(moves)
def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
instructions: str) -> list[Stage]:
research_done = lambda: flow.research_done # noqa: E731
return [
Stage(BOARD, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
# Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
# global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
barrier=True, drain=True, gate=research_done),
]

2344
backend/board_inventory.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -6,66 +7,242 @@ STORAGE_DIR = PROJECT_ROOT / "storage"
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
DB_PATH = STORAGE_DIR / "creator.db"
PROJECTS_DIR = PROJECT_ROOT / "projects"
UNI_DIR = PROJECT_ROOT / "uni"
def _load_env(path: Path) -> None:
"""Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
env: a --reload master keeps its startup environment forever, so "existing env wins"
silently pinned stale values across .env edits (measured: file said 24, workers
inherited 15 for hours). Trade-off: ad-hoc shell overrides lose against the file."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if key:
os.environ[key] = value
_load_env(PROJECT_ROOT / ".env")
MAX_CONCURRENT_GENERATIONS = 10
# Deckel für gleichzeitige CLI-Agenten-Prozesse (über alle Generierungen hinweg).
# Eigene Spur für interaktive Aufrufe (Chat, Elemente), damit sie nicht hinter
# laufenden Writern in der Warteschlange hängen.
MAX_CONCURRENT_AGENTS = 12
MAX_CONCURRENT_INTERACTIVE = 4
# Readability gate: deterministic checker (small German complexity model,
# scale 17). Sections that are too hard go into the read-exam revision.
# If transformers/torch or the model are missing → gate silently off.
READABILITY_ACTIVE = True
READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
# Anchors on the 17 scale (TextComplexityDE): plain language ~1.2; Wikipedia average
# ~3.22; from MOS > 4 a sentence counts as "truly complex" (the paper's simplification cutoff).
READABILITY_MAX = 3.5 # section too hard when the sentence average is above this
READABILITY_HARD = 4.0 # an individual sentence is "hard" from here on
READABILITY_HARD_SHARE = 0.30 # … OR when this share of sentences is hard
# Timeouts pro Agenten-Schritt: (Basis-Sekunden, Sekunden pro Baustein/Section).
# Gilt für alle Provider gleich — wer zu langsam ist, wird neu gestartet bzw. überholt.
# Kanban clustering: semantic embeddings drive the online title clustering and the
# candidate pairs of the pair check. If transformers/torch are missing or the model
# won't load → embedding silently off (all pairs go to the judge, clusters stay singletons).
EMBEDDING_AKTIV = True
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # CPU, multilingual, ~470 MB
# Stronger (larger) CPU alternative if needed: "BAAI/bge-m3".
# Consolidation = two-stage: (1) the embedding builds COARSE similarity blocks (high recall),
# (2) an LLM judge groups EACH block into the real blocks (merge paraphrases, split
# over-merges). Pure threshold blocking creates a giant component (everything chained) →
# hence "capped blocking": greedily merge by cosine, but cap the block size. This keeps the
# LLM lists short and stable (evidenced: embedding block + LLM judge ≈ 95% precision).
EMBEDDING_BLOCK_FLOOR = 0.5 # minimum cosine for two candidates to share ONE block
EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/stable)
# Subblock dedup: purely deterministic (no LLM). Subblocks are short statements IN THE SAME
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
EMBEDDING_SUB_DUP = 0.90
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.910.95 → negation guard required.
SUB_VARIANT_COS = 0.90
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
# subs are statements: true covers measure 0.270.38 while a wrong hit measured 0.76. The
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
SEED_COVER_COS = 0.80
# Sub duplicate CANDIDATE floor for the judge paths (in-block consolidation band hint,
# cross-block stage, QA detector): the bulk of real paraphrase duplicates measures 0.750.90
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
# judge decides. Candidates only; a merge still needs judge unanimity.
SUB_DUP_KANDIDAT_COS = 0.75
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
# Konfiguration/Start-/Folge-/Stopkonfiguration/Berechnung/Alphabet/δ; KNF: Literale/Klauseln/
# Variablen) into ONE block whose description ENUMERATES the children — so the later subblock step
# re-derives them from the source under the umbrella scope (demotion is re-derivation, not transfer).
# If the flag is off or no embedding model → step is silently skipped (like Dedup).
BLOCKS_GRUPPIERUNG_AKTIV = True
# Lower floor than consolidation (0.5, paraphrase-tuned) for higher sibling recall; the LLM judge is
# the precision gate. Smaller cap since a lower floor pulls in more nodes → keep the judge lists short.
EMBEDDING_SIBLING_FLOOR = 0.35 # heterogeneous facets of one model co-cluster weakly → low floor (recall)
EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent parts
# Reconcile pass: two independently-judged clusters can emit the SAME parent concept under different
# titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is
# ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
GROUP_RECONCILE_FLOOR = 0.75
# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~48, n=285 → ~1222; das alte
# statische „1525" passte implizit nur zu n≈225625).
GROUP_THEMES_PER_SQRT = 1.0
# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
FRAGMENT_MIN_COS = 0.15
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
# dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10).
# Own lane for interactive calls (chat, elements) so they don't hang behind running writers.
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # global, all topics
# Per-topic higher than the process cap: it is SHARED between process and API tier — at 12 a
# single-topic run (the normal case) would never benefit from the cheap API tier.
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "24")) # per topic
# Direct-API text calls (agents._run_text_api): ~0 RAM, only network — own, higher global cap.
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28"))
MAX_CONCURRENT_INTERACTIVE = 8
# RAM guard for opencode spawns: below this free share (MemAvailable/MemTotal in %) new
# processes wait instead of starting (agents._ram_gate). 0 = off.
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20"))
# Grace window of the consensus races (blocks, guide, OnePager): after the first
# valid result the remaining agents may still become done for this many seconds
# (kill only once the minimum is already in).
CONSENSUS_GRACE = 300
# Cap of the clarification and check loops: maximum rounds until everything must be
# decided. In the last round the mapping agent MUST decide every entry;
# check loops leave any remaining objections standing after that.
CONSENSUS_MAX_ROUNDS = 3
# QA gate: after the inventory phase an automatic QA run scores the blocks; below 100 %
# the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst
# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin.
QA_GATE_NOTE = 9.5 # 0 = gate off
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die
# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante).
LUECKEN_RESEARCH_MAX = 2
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
# run's tokens were cache reads from those loops).
EVIDENCE_BUDGET_CHARS = 48_000 # max excerpt characters per judge prompt
EVIDENCE_CTX_LINES = 15 # context lines around a cited source position (facts check)
# ── Pipeline tuning (zentral, tunebar via CREATOR_PARAMS — siehe Override-Hook am Datei-Ende;
# Registry mit Suchraum: backend/train_params.py). QA-/Detektor-Konstanten bleiben bewusst in
# qa.py/guide_qa.py — die Messlatte darf nie Teil des Suchraums sein. ─────────────────────────
SUBBLOCK_MAX = 40 # chunk cap
RESEARCH_BATCH = 1 # eine Seite pro Reader — er kann nichts übersehen (Vollständigkeit)
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package
DEDUP_TITLE_AUTO = 0.95 # near-identical TITLE cosine → merge without judge
DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup candidate floor
FILTER_CHUNK = 35 # blocks per judge in the degrade pass
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur.
# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment.
GEN_PANEL = 2 # unabhängige Generator-Calls pro Block
VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall)
ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer)
ZIELE_MAX = 12 # Lernziele-Cap pro Block (mehr verwässert Coverage-Prüfung und Writer-Fokus)
# Prüfer-Längenband um guide_qa.block_budget: enger als das QA-Band (0.351.5), damit der
# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py.
FIX_LAENGE_BAND = (0.5, 1.2)
WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count
KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
MAX_RESTARTS = 2 # agent restart cap per race slot
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Slot, dann Lauf-Pause (kein fail-open)
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160230 s
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden
# Fix-/Gate-Call (die laufen normal 110135 s). 0 = aus.
HEDGE_NACH_S = 90
JUDGE_CHUNK = 40 # repair: findings per judge call
EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt)
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
# Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = {
"recherche": (1800, 0), # fix 30 min
"auswahl": (600, 10),
"auswahl_check": (300, 2),
"ergaenzung": (900, 0), # Themenfeld-Ergänzung bei Projekten (Web-Recherche)
"guide_auswahl": (300, 5), # pro Baustein im Inventar
"guide_check": (300, 2), # Auswahl-/Gliederungs-Prüfung (nur Titellisten)
"research_mapping": (600, 3), # n = pre-merged entries
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
"plan": (300, 5),
"writer": (600, 120), # pro Section im Chunk
"lese_check": (300, 10), # pro Section im Paket
"onepager_recherche": (900, 0),
"onepager_bauen": (300, 0),
"onepager_verify": (300, 0),
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
# Judge caps tightened 2026-07-04: judge p50 is 672 s; a stalled call burns the whole
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
"subblock_check": (150, 10), # judge decides contested subblocks in the chunk
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
# Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente
"generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
"verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt
"fix": (300, 15), # Korrekturen + Lücken (n = Aufträge)
"artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs)
"artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
"writer": (450, 60), # per section — split keeps sections ≤30 subs
# guide board (per card = one block)
"lernziele": (300, 5), # backward-design objectives per block
"pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
# QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
"qa_judge": (600, 0),
}
# Auswahl-Auftrag je Format: (Mindest-Anteil, Maximal-Anteil, Mindestanzahl, Zweck).
FORMAT_ANTEIL = {
"MiniGuide": (0.05, 0.10, 8, "einen kompakten Anfänger-Guide — der schnelle Einstieg ins Thema"),
"Guide": (0.25, 0.35, 20, "einen ausführlichen Anfänger-Guide — ein solides Fundament im Thema"),
"FullGuide": (0.90, 1.00, 0, "einen Komplett-Guide — das ganze Thema"),
}
# Provider-Stacks: komplett unabhängig, einer kann jederzeit entfernt werden.
# Rollen: "quick" = Massenarbeit (Recherche, Einordnung),
# "fast" = Urteilsaufgaben mit kleinem Output (Auswahl, Final, OnePager, Chat),
# "guide" = große Generierung (Plan, Writer).
DEFAULT_PROVIDER = "claude"
# Provider stacks: completely independent, any one can be removed at any time.
# Roles: "quick" = bulk work (research, classification),
# "fast" = interaction + voting (chat, exam, clarification, elements),
# "judge" = mapping/judge/check agents — cold (low temperature,
# no thinking) for stable verdicts; Claude/local map to "fast",
# "guide" = large generation (proposals, writer).
# Kein Provider-Default im Code (Betreiber-Vorgabe): die .env entscheidet.
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
if not DEFAULT_PROVIDER:
raise RuntimeError("DEFAULT_PROVIDER fehlt in der .env (z. B. DEFAULT_PROVIDER=minimax)")
PROVIDERS = {
"claude": {
"cli": "claude",
"guide": "claude-opus-4-8[1m]",
"fast": "claude-sonnet-4-6",
"judge": "claude-sonnet-4-6", # the CLI has no temperature setting
"quick": "claude-sonnet-4-6",
"env_key": None, # Auth via CLAUDE_CODE_OAUTH_TOKEN oder ~/.claude
"env_key": None, # auth via CLAUDE_CODE_OAUTH_TOKEN or ~/.claude
},
# "minimax-kalt/…" is NOT its own stack, just an opencode provider entry
# (dev-ops/opencode.json) with low temperature; M3 there without thinking.
"minimax": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax/MiniMax-M2.7-highspeed",
"quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
# Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking.
# M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled.
# guide/quick bleiben identisch zur Thinking-Variante.
"minimax-direkt": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax-direkt/MiniMax-M3",
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
"judge": "minimax/MiniMax-M3", # native route — the kalt endpoint stalled 20 % of
# judge calls to the timeout cap (516/2590, 2026-07-04)
"quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
@@ -73,8 +250,62 @@ PROVIDERS = {
"cli": "opencode",
"guide": "ollama/qwen3.6:27b",
"fast": "ollama/qwen3.5:9b",
"judge": "ollama/qwen3.5:9b",
"quick": "ollama/qwen3.5:9b",
"env_key": None,
"check_url": "http://localhost:11434/api/tags", # Ollama erreichbar?
"check_url": "http://localhost:11434/api/tags", # Ollama reachable?
},
}
# Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
# the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
# Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
# to the claude stack regardless of the UI choice ("provider:model" pins a model).
ROLE_ROUTING = {
"quick": os.getenv("ROLE_QUICK", ""),
"judge": os.getenv("ROLE_JUDGE", ""),
"guide": os.getenv("ROLE_GUIDE", ""),
"fast": os.getenv("ROLE_FAST", ""),
}
def resolve_role(run_provider: str, role: str) -> tuple[str, str]:
"""→ (provider, model) for one agent call. Pure routing, no availability check —
the caller (agents.run_agent) falls back to run_provider if the target is unavailable."""
target = ROLE_ROUTING.get(role, "") or run_provider
provider, _, model = target.partition(":")
if provider not in PROVIDERS:
provider, model = run_provider, ""
if not model:
model = PROVIDERS.get(provider, {}).get(role, "")
return provider, model
# ── Trainings-Override: CREATOR_PARAMS (JSON-Dict im ENV) überschreibt gleichnamige
# Tuning-Konstanten oben — pro Prozess-Start (der Trainer startet je Trial einen Subprozess;
# Module binden die Werte beim Import). TIMEOUTS-Einträge via "TIMEOUT_<step>_base"/"_per".
def _apply_param_overrides() -> None:
raw = os.getenv("CREATOR_PARAMS")
if not raw:
return
import json as _json
try:
overrides = _json.loads(raw)
except ValueError:
raise SystemExit(f"CREATOR_PARAMS ist kein gültiges JSON: {raw[:80]}")
g = globals()
for key, val in overrides.items():
if key.startswith("TIMEOUT_"):
rest = key[len("TIMEOUT_"):]
step, _, part = rest.rpartition("_")
if step in TIMEOUTS and part in ("base", "per"):
base, per = TIMEOUTS[step]
TIMEOUTS[step] = (val, per) if part == "base" else (base, val)
continue
raise SystemExit(f"CREATOR_PARAMS: unbekannter Timeout-Schlüssel {key}")
if key not in g or not isinstance(g[key], (int, float)) or isinstance(g[key], bool):
raise SystemExit(f"CREATOR_PARAMS: unbekannter/nicht-numerischer Parameter {key}")
g[key] = type(g[key])(val)
_apply_param_overrides()

120
backend/crawl.py Normal file
View File

@@ -0,0 +1,120 @@
"""Page loader for link sources — renders JS via Playwright (Chromium).
Loads a FIXED list of URLs the user supplied — one page each, NO link following.
HTML pages are rendered in a headless browser (needed for SPAs), then the main text
is pulled from the finished DOM. PDFs are loaded directly as bytes. Deterministic,
bounded; runs via asyncio.to_thread (sync API, no event loop).
"""
import hashlib
import logging
import re
from pathlib import Path
from urllib.parse import urldefrag, urlparse
from urllib.request import Request, urlopen
from fsutil import atomic_write_text
log = logging.getLogger("creator.crawl")
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
_UA = "Mozilla/5.0 (creator-lernbot)"
def _fetch_bytes(url: str) -> bytes | None:
"""Load PDF bytes via urllib (no rendering needed). None on error/too large."""
try:
req = Request(url, headers={"User-Agent": _UA})
with urlopen(req, timeout=PAGE_TIMEOUT) as resp:
data = resp.read(MAX_BYTES + 1)
return None if len(data) > MAX_BYTES else data
except Exception as e:
log.debug("load: PDF fetch failed %s: %s", url, e)
return None
def _name(url: str, ext: str) -> str:
h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8]
slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index"
return f"{h}-{slug}{ext}"
def _is_pdf(url: str) -> bool:
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
def _page_text(page) -> str:
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
try:
from trafilatura import extract # lazy: the backend starts even without the package
text = extract(page.content(), include_comments=False, include_tables=True) or ""
except Exception:
text = ""
if len(text.strip()) >= 200:
return text.strip()
try:
return page.inner_text("body").strip()
except Exception:
return text.strip()
def load_pages(urls: list[str], target: Path, *, cancelled=None) -> int:
"""Load each URL in `urls` (one page each, NO link following), render JS and store
pages/PDFs in `target`.
Duplicate/fragment-only URLs collapse to one. Errors on individual pages are skipped.
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it, so a
restart loads again. Returns the number of saved sources.
"""
# Lazy: this way the backend starts even without Playwright installed; only loading then fails.
from playwright.sync_api import sync_playwright
target.mkdir(parents=True, exist_ok=True)
seen: set[str] = set()
todo: list[str] = []
for u in urls:
nu = urldefrag(u)[0].strip()
if nu and nu not in seen:
seen.add(nu)
todo.append(nu)
saved = 0
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
page = browser.new_page(user_agent=_UA)
try:
for url in todo:
if cancelled and cancelled():
return saved # abort → NO .done marker → restart loads again
# PDFs need no rendering — load directly.
if _is_pdf(url):
data = _fetch_bytes(url)
if data:
p = target / _name(url, ".pdf")
if not p.exists():
p.write_bytes(data)
saved += 1
continue
try:
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
except Exception as e:
log.debug("load: goto incomplete %s: %s", url, e) # still try to read the content
try:
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
except Exception:
pass # an SPA with constant traffic never reaches idle → continue after settle, no 30s hang
text = _page_text(page) # main text, nav/footer removed (fallback: raw body)
if text:
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
saved += 1
finally:
browser.close()
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
log.info("load_pages → %d sources in %s", saved, target)
return saved

File diff suppressed because it is too large Load Diff

View File

@@ -1,258 +0,0 @@
"""Elemente (persönliche Zusammenfassung) und Tutor-Chat zum Guide."""
import json
import logging
import uuid
from agents import run_agent
from config import DEFAULT_PROVIDER
from jsonio import parse_json_text as _parse_json_text, read_json_file as _json_datei
from paths import bausteine_path, guide_content_path
from pipeline import _prompt
log = logging.getLogger("creator.elements")
# --- Tutor-Chat ---
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
transcript = "\n".join(
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
for m in messages
)
return _prompt(
"Chat",
topic=topic, format_name=format_name,
outline_block=outline.strip() or "(keine)",
section_block=section.strip() or "(kein Abschnitt erkannt)",
transcript=transcript,
)
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
returncode, stdout, stderr = await run_agent(
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
reply = stdout.strip()
return reply or "Entschuldigung, ich habe keine Antwort erhalten."
except Exception:
log.warning("[%s] Guide-Chat fehlgeschlagen", topic, exc_info=True)
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
# --- Elemente ---
def _element_fields(data: dict) -> dict | None:
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
if not isinstance(data, dict):
return None
title = str(data.get("title", "")).strip()
if not title:
return None
listen = {}
for key in ("examples", "hints"):
raw = data.get(key, [])
listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
return {
"title": title[:200],
"description": str(data.get("description", "")).strip(),
"examples": listen["examples"],
"hints": listen["hints"],
}
def _topic_context(topic: str, limit: int = 12000) -> str:
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
parts: list[str] = []
bp = bausteine_path(topic)
if bp.exists():
parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("FullGuide", "Guide", "MiniGuide", "OnePager"):
content = _json_datei(guide_content_path(topic, fmt))
if content:
for ch in content.get("chapters", []):
for sec in ch.get("sections", []):
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
break # bester verfügbarer Guide reicht
text = "\n\n".join(parts).strip()
return text[:limit] if text else "(kein Material vorhanden)"
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
try:
prompt = _prompt(
"Element-Create",
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
context=_topic_context(topic),
)
returncode, stdout, _ = await run_agent(
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return fallback
return _element_fields(_parse_json_text(stdout)) or fallback
except Exception:
log.warning("[%s] Element-Erstellung fehlgeschlagen", topic, exc_info=True)
return fallback
def _parse_suggestions(stdout: str) -> list[dict] | None:
"""Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
suggestions = []
for s in data.get("suggestions", []):
if not isinstance(s, dict):
continue
text = str(s.get("text", "")).strip()
target = s.get("target")
content = str(s.get("content", "")).strip()
if text and content and target in ("description", "examples", "hints"):
suggestions.append({"text": text, "target": target, "content": content})
return suggestions
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
try:
element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
context = _topic_context(element["topic"])
# Schritt 1: Recherche — breit Kandidaten sammeln
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
returncode, stdout, _ = await run_agent(
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
candidates = _parse_suggestions(stdout)
if candidates is None:
return None
if not candidates:
return []
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
prompt = _prompt(
"Element-Verify",
topic=element["topic"], element_json=element_json,
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
context=context,
)
returncode, stdout, _ = await run_agent(
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
return _parse_suggestions(stdout)
except Exception:
log.warning("[%s] Element-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
return None
def _element_json(element: dict) -> str:
return json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
def _validate_change(c, element: dict) -> dict | None:
"""Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element."""
if not isinstance(c, dict):
return None
text = str(c.get("text", "")).strip()
action = c.get("action")
target = c.get("target")
index = c.get("index")
content = str(c.get("content", "")).strip()
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
return None
if target not in ("title", "description", "examples", "hints"):
return None
if action in ("anpassen", "hinzufuegen") and not content:
return None
if action == "entfernen" and target not in ("examples", "hints"):
return None
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
if not isinstance(index, int) or not (0 <= index < len(element[target])):
return None
else:
index = None
return {"text": text, "action": action, "target": target, "index": index, "content": content}
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
"""Chat zum Element. Gibt (Antwort, Änderungs-Vorschläge) zurück — ändert nichts direkt."""
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
try:
transcript = "\n".join(
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
for m in messages
)
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
returncode, stdout, _ = await run_agent(
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return fehler, []
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return fehler, []
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
reply = str(data.get("reply", "")).strip() or ("Vorschläge erstellt." if changes else fehler)
return reply, changes
except Exception:
log.warning("[%s] Element-Chat fehlgeschlagen", element.get("topic", "?"), exc_info=True)
return fehler, []
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
try:
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=_element_json(element))
returncode, stdout, _ = await run_agent(
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
except Exception:
log.warning("[%s] Stil-Prüfung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
return None
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
"""Überarbeitet einen einzelnen Vorschlag nach Nutzer-Anweisung. None bei Fehler."""
try:
prompt = _prompt(
"Element-Refine",
topic=element["topic"], element_json=_element_json(element),
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
instruction=instruction,
)
returncode, stdout, _ = await run_agent(
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
return _validate_change(data.get("change"), element)
except Exception:
log.warning("[%s] Vorschlags-Überarbeitung fehlgeschlagen", element.get("topic", "?"), exc_info=True)
return None

123
backend/embedding.py Normal file
View File

@@ -0,0 +1,123 @@
"""Semantic embedding clustering for block consolidation.
Mean-pool embeddings of a multilingual sentence model build GLOBAL candidate
clusters via cosine blocking + union-find (no chunk loss). Safe pairs (similarity
≥ HARD) are merged without an LLM; borderline pairs in the band [BAND_LOW, HARD) are
handed by the caller to an LLM judge for a yes/no decision. If `transformers`/`torch`
are missing or the model won't load → `embed_sims()` returns `None`, and the caller
falls back to the old panel-judge path (silent deactivation, like the readability gate).
CPU is enough; the caller wraps the blocking inference in `asyncio.to_thread`.
`numpy` comes in transitively via torch (deliberately not in requirements.txt, like torch).
"""
import logging
import numpy as np
from config import EMBEDDING_AKTIV, EMBEDDING_MODELL, EMBEDDING_BLOCK_FLOOR, EMBEDDING_BLOCK_CAP
log = logging.getLogger("creator.embedding")
_model_cache = None # (tokenizer, model, torch) — singleton
_load_attempt = False # already tried to load?
EMBEDDING_BATCH = 32 # inference batch size (CPU)
EMBEDDING_MAX_LEN = 128 # title + short description are short → a small truncation cap suffices
def _model():
"""Load the model once. None = clustering off (disabled or load error)."""
global _model_cache, _load_attempt
if _load_attempt:
return _model_cache
_load_attempt = True
if not EMBEDDING_AKTIV:
return None
try:
import torch
from transformers import AutoModel, AutoTokenizer
tok = AutoTokenizer.from_pretrained(EMBEDDING_MODELL)
model = AutoModel.from_pretrained(EMBEDDING_MODELL)
model.eval()
_model_cache = (tok, model, torch)
log.info("embedding model loaded: %s", EMBEDDING_MODELL)
except Exception as e:
log.warning("embedding clustering disabled (model not loadable): %s", e)
_model_cache = None
return _model_cache
def available() -> bool:
"""True if the model could be loaded. Loads on the first call (blocking)."""
return _model() is not None
def embed(texts: list[str]) -> "np.ndarray | None":
"""Texts → (n, d) L2-normalized, mean-pooled embeddings. None = model off."""
if _model() is None:
return None
tok, model, torch = _model_cache
out = []
for i in range(0, len(texts), EMBEDDING_BATCH):
batch = texts[i:i + EMBEDDING_BATCH]
enc = tok(batch, return_tensors="pt", truncation=True, max_length=EMBEDDING_MAX_LEN, padding=True)
with torch.no_grad():
hidden = model(**enc).last_hidden_state # (b, t, d)
mask = enc["attention_mask"].unsqueeze(-1).type_as(hidden)
vec = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-9) # mean-pool without padding
vec = torch.nn.functional.normalize(vec, p=2, dim=1) # L2 → cosine = dot product
out.append(vec.cpu().numpy())
return np.vstack(out).astype(np.float32)
def _find(parent: list[int], x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]] # Pfad-Kompression
x = parent[x]
return x
def _union(parent: list[int], a: int, b: int) -> None:
ra, rb = _find(parent, a), _find(parent, b)
if ra != rb:
parent[max(ra, rb)] = min(ra, rb) # smallest index = root (deterministic)
def embed_sims(texts: list[str]):
"""Texts → (n, n) cosine matrix · None = model not available (fallback)."""
embs = embed(texts)
if embs is None:
return None
return embs @ embs.T # (n, n) cosine, float32 (~2 MB at n=700)
def capped_blocks(sims, floor: float | None = None, cap: int | None = None) -> list[list[int]]:
"""Coarse similarity blocks for the LLM — high recall, but size-capped.
Greedy: all pairs with cosine ≥ `floor` in descending cosine order; two blocks are merged
only if the resulting block stays ≤ `cap`. Prevents the giant component (pure threshold
blocking would otherwise chain almost everything together) and keeps the LLM lists short.
→ list of blocks (index lists), each node in exactly one block.
"""
fl = EMBEDDING_BLOCK_FLOOR if floor is None else floor
cp = EMBEDDING_BLOCK_CAP if cap is None else cap
n = len(sims)
parent = list(range(n))
size = [1] * n
if n >= 2:
iu = np.triu_indices(n, k=1)
s = sims[iu]
kept = np.where(s >= fl)[0]
# highest cosine first → the tightest pairs form blocks first
for k in kept[np.argsort(-s[kept])]:
i, j = int(iu[0][k]), int(iu[1][k])
ri, rj = _find(parent, i), _find(parent, j)
if ri != rj and size[ri] + size[rj] <= cp:
_union(parent, i, j)
r = _find(parent, i)
size[r] = size[ri] + size[rj]
blocks: dict[int, list[int]] = {}
for i in range(n):
blocks.setdefault(_find(parent, i), []).append(i)
return list(blocks.values())

360
backend/fake_agents.py Normal file
View File

@@ -0,0 +1,360 @@
"""Deterministischer Agenten-Ersatz für E2E-Tests: beantwortet run_agent-Aufrufe ohne LLM.
Eine `Welt` beschreibt das Thema (Blöcke → Subs → Facts …); `respond()` routet per
agent_key-Muster und liefert (rc, stdout, stderr) wie ein echter Agent — files-Agenten
schreiben die out_path-Datei aus dem Prompt, none-Agenten antworten als Text (der
Engine-Sink parst/persistiert). Damit laufen ALLE echten Schichten (_race, Quorum,
Retry, Panels, Producer, QA-Gate) in Sekunden.
Störungen sind deterministisch: `Welt.stoerungen` matcht agent_keys per Regex und
liefert n-mal einen Fehler / kaputtes JSON / eine feste Antwort (z. B. Dissens).
Aktivierung: pytest-Fixture `fake_welt` (tests/conftest.py) oder ENV CREATOR_FAKE_AGENTS=1
(echter Server, Sekunden-Smoke im Frontend).
"""
import json
import re
from pathlib import Path
_PATH_RE = re.compile(r"(/\S+\.(?:json|md))")
_NUM_RE = re.compile(r"^\s*(\d+)[.)]\s+(.*\S)", re.MULTILINE)
_SUBLIST_RE = re.compile(r"^- (?:\[(\w+)\] )?(.+\S)\s*$", re.MULTILINE)
_ZIEL_RE = re.compile(r"\(([a-z]\d+)\)")
def _norm(s: str) -> str:
return " ".join((s or "").casefold().split())
class Welt:
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str,
"subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten."""
def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
kataloge: list | None = None, stoerungen: list | None = None):
self.bloecke = bloecke if bloecke is not None else standard_bloecke()
self.gruppen = gruppen or [] # [(haupt_titel, [weitere_titel])] → In-Block-Fold
self.kataloge = kataloge or [] # [(katalog_titel, [mitglieder_titel])]
self.stoerungen = [dict(s, rest=int(s.get("mal", 1))) for s in (stoerungen or [])]
self.calls: list[str] = [] # Auditspur: jeder agent_key in Reihenfolge
# ── Nachschlagen ────────────────────────────────────────────────────────────────
def _alle_subs(self) -> dict[str, str]:
"""sub_norm → sub_titel über alle Blöcke (inkl. Katalog-Titel)."""
out = {}
for b in self.bloecke.values():
for s in b["subs"]:
out[_norm(s)] = s
for kt, _m in self.kataloge:
out[_norm(kt)] = kt
return out
def _bloecke_im_prompt(self, prompt: str) -> list[str]:
return [t for t in self.bloecke if t in prompt]
def _subs_im_prompt(self, prompt: str) -> list[str]:
gefunden = [s for s in self._alle_subs().values() if s in prompt]
return gefunden
# ── Störungen ───────────────────────────────────────────────────────────────────
def _stoerung(self, agent_key: str):
for s in self.stoerungen:
if s["rest"] > 0 and re.search(s["muster"], agent_key):
s["rest"] -= 1
return s
return None
# ── Haupteinstieg ──────────────────────────────────────────────────────────────
def respond(self, agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]:
self.calls.append(agent_key)
if (s := self._stoerung(agent_key)):
if s["modus"] == "fehler":
return 1, "", "fake-stoerung"
if s["modus"] == "garbage":
return self._liefern(prompt, '{"kaputt": ')
if s["modus"] == "antwort":
return self._liefern(prompt, s["antwort"])
text = self._antwort(agent_key, prompt)
if text is None:
return 1, "", f"fake: kein Handler für {agent_key}"
return self._liefern(prompt, text)
@staticmethod
def _liefern(prompt: str, text: str) -> tuple[int, str, str]:
"""files-Agenten schreiben die out_path-Datei aus dem Prompt; none-Agenten
antworten als Text. Wir tun einfach BEIDES — steht ein Pfad im Prompt, wird
er geschrieben (dann liest das payload die Datei), und stdout trägt den Text
(dann parst ihn der Sink). Ein Weg von beiden greift immer."""
if (m := _PATH_RE.search(prompt)):
p = Path(m.group(1))
try:
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(text, encoding="utf-8")
except OSError:
pass
return 0, text, ""
# ── Antwort-Generatoren je Key-Muster ──────────────────────────────────────────
def _antwort(self, key: str, prompt: str) -> str | None: # noqa: C901 — bewusst ein Router
j = json.dumps
# Board 1 / Inventar
if "-research-" in key:
# Bottom-up: Research liefert die ATOME flach; Board 1 gruppiert sie zu den
# Cluster-Bausteinen. Jedes Atom gehört zu genau einem Cluster.
zeilen = []
n = 1
for cl, b in self.bloecke.items():
for atom in b["subs"]:
zeilen.append(f"{n}. {atom} — Atom aus {cl}")
n += 1
return "\n".join(zeilen)
if "-pair-" in key:
n = prompt.count("\nA: ") or 1
return j({"pairs": {str(i): "ja" for i in range(1, n + 1)}})
if "-dedup-" in key:
n = prompt.count("\nA: ") or 1
return j({"pairs": {str(i): "nein" for i in range(1, n + 1)}})
if "-clarify-" in key:
keep = [ln[2:] for ln in prompt.splitlines() if ln.startswith("- ")]
return j({"keep": keep, "rest": []})
if "-naming-" in key: # deckt auch naming_check (gleicher Key)
return j({"best": 1})
if "-sanierung-" in key: # Titel/Beschreibung unverändert zurück (Fake-Welt ist sauber)
t = re.search(r"^Title: (.+)$", prompt, re.M)
d = re.search(r"^Description: (.+)$", prompt, re.M)
beschr = (d.group(1).strip() if d else "")
if beschr == "(leer)":
beschr = "Beschreibung aus dem Material."
return j({"title": t.group(1).strip() if t else "", "description": beschr})
if "-filter-" in key: # auch filter-recheck
return j({"fragments": {}, "drop": []})
if "-gruppierung-completion-" in key or "-gruppierung-attach-" in key:
return j({"additions": []}) # Anschluss-Judge: Fake-Nachzügler bleiben standalone
if "-gruppierung-" in key:
# jedes Atom seinem Cluster zuordnen (nur die FULL ITEM LIST, 1..n eindeutig)
seg = prompt.split("FULL ITEM LIST", 1)[-1]
atom_cluster = {_norm(a): cl for cl, b in self.bloecke.items() for a in b["subs"]}
umb: dict[str, list[int]] = {}
for m in _NUM_RE.finditer(seg):
nr, zeile = int(m.group(1)), _norm(m.group(2))
for an, cl in atom_cluster.items():
if zeile.startswith(an):
umb.setdefault(cl, []).append(nr)
break
umbrellas = [{"title": cl, "description": self.bloecke[cl]["beschreibung"],
"members": ms} for cl, ms in umb.items() if ms]
return j({"umbrellas": umbrellas})
if "-supplement-beleg" in key or "-anker-beleg-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
if "-supplement" in key or "-luecken-nachfass-" in key:
return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
if "-source-relevance-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
# Board 2 / Artefakte (verschmolzene Calls, block_calls.py)
if "-sb-enrich-" in key: # Bottom-up: feste Subs (aus dem Prompt) mit Facts anreichern
seg = prompt.split("FIXED SUBBLOCKS", 1)[-1].split("\n\n", 1)[0]
subs = []
for m in re.finditer(r"^\s*\d+\.\s+(.+)$", seg, re.M):
s = m.group(1).strip()
f = self._fakt("", s)
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
"cited_facts", "example_idea")}})
return j({"subs": subs})
if "-sb-gen-" in key:
subs = []
for t in self._bloecke_im_prompt(prompt):
for s in self.bloecke[t]["subs"]:
f = self._fakt(t, s)
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
"cited_facts", "example_idea")}})
return j({"subs": subs})
if "-sb-verify-" in key:
nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)}
gruppen = []
for haupt, weitere in self.gruppen:
h, w = nummern.get(_norm(haupt)), [nummern[_norm(x)] for x in weitere
if _norm(x) in nummern]
if h and w:
gruppen.append({"haupt": int(h), "weitere": [int(x) for x in w]})
kataloge = []
for kt, mitglieder in self.kataloge:
m = [int(nummern[_norm(x)]) for x in mitglieder if _norm(x) in nummern]
if len(m) >= 2:
kataloge.append({"titel": kt, "mitglieder": m})
unsicher = {}
if (u := prompt.find("UNSICHER")) != -1: # alle Unsicher-Nummern übernehmen
import re as _re
for m in _re.finditer(r"entries (\d+)(\d+)", prompt[u:u + 200]):
unsicher = {str(k): "ja" for k in range(int(m.group(1)), int(m.group(2)) + 1)}
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": [],
"uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}})
if "-sb-fix-" in key:
return j({"subs": []})
if "-art-gen-" in key:
subs = self._subs_im_prompt(prompt)
t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
return j({"pattern": [{"block": t, "subblock": s, "question": f"Was ist {s}?"} for s in subs],
"cards": [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} for s in subs],
"examples": [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} for s in subs]})
if "-art-check-" in key:
return j({"ok": True})
if "-outline-prereqs" in key:
return j({"prereqs": {}})
if "-outline-review" in key:
return j({"moves": {}})
if "-outline-" in key or key.endswith("-outline-judge"):
nums = sorted({int(m.group(1)) for m in _NUM_RE.finditer(prompt)}) or [1]
return j({"chapters": [{"title": "Kapitel 1", "numbers": nums}]})
# Guide-Board
if "-ziele-" in key:
ziele = [{"id": f"z{i}", "text": f"Verstehen von {s}", "sub": s}
for i, s in enumerate(self._subs_im_prompt(prompt), 1)][:8]
return j({"ziele": ziele or [{"id": "z1", "text": "Grundlagen verstehen", "sub": ""}]})
if "-gfix-" in key:
return self._section_aus_prompt(prompt) or "<!-- section: X -->\nRepariert."
if "-pruef-" in key: # verschmolzener Prüfer: Fakten + Coverage + Lesbarkeit
ids = sorted(set(_ZIEL_RE.findall(prompt)))
return j({"claims": [], "ziele": {z: True for z in ids}, "luecken": [],
"ballast": [], "lese_probleme": []})
if "-w-" in key:
return self._writer_md(prompt)
# QA / Repair / Guide-QA (alle Text, _yesno_schema)
if key.startswith(("qa-guide-",)):
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "nein" for k in sorted(nums, key=int)} or {"1": "nein"}})
if key.startswith(("qa-", "repair-")):
# Semantik je Template: Bausteine „ja" = echt; Dubletten/Lücken „nein" = kein Befund
wert = "ja" if ("bausteine" in key or "beleg" in key or "fremd" in key) else "nein"
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: wert for k in sorted(nums, key=int)} or {"1": wert}})
return None
# ── Bausteine der Antworten ─────────────────────────────────────────────────────
@staticmethod
def _fakt(block: str, sub: str) -> dict:
return {"block": block, "subblock": sub,
"key_points": [f"Kernaussage zu {sub}", f"Zweite Aussage zu {sub}"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": f"Beleg für {sub}", "source": "Fake-Quelle"}],
"example_idea": f"Beispiel zu {sub}"}
def _writer_md(self, prompt: str) -> str:
"""Section im Marker-Format; Subs aus der SUBBLOCKS-Liste des Prompts
(`- [label] titel`), Länge je Sub im 1501080-Rahmen."""
block = next(iter(self._bloecke_im_prompt(prompt)), None) or "Abschnitt"
subs = [(lv or "beginner", t) for lv, t in _SUBLIST_RE.findall(prompt)
if _norm(t) in self._alle_subs()]
if not subs:
subs = [("beginner", s) for s in self.bloecke.get(block, {}).get("subs", ["Inhalt"])]
kompakt = "\n".join(f"<!-- sub: {lv} | {t} -->\n- Merksatz zu {t}" for lv, t in subs)
prosa = "\n".join(f"<!-- sub: {lv} | {t} -->\n" + (f"Lehrtext über {t}. " * 12)
for lv, t in subs)
return (f"<!-- kapitel: Kapitel 1 -->\n<!-- section: {block} -->\n"
f"<!-- compact -->\n{kompakt}\n<!-- ausführlich -->\n"
f"Einstieg in {block}.\n{prosa}")
@staticmethod
def _section_aus_prompt(prompt: str) -> str | None:
"""Fix-Agenten geben die Section unverändert zurück (minimal-invasiv)."""
m = re.search(r"(<!--\s*(?:kapitel|section):.*)", prompt, re.DOTALL)
return m.group(1).strip() if m else None
def standard_bloecke() -> dict:
"""3 Themen-Cluster; die subs sind die ATOME, die Board 1 bottom-up zu genau diesem
Cluster gruppiert. Jedes Atom liegt in genau einem Cluster (kein geteiltes Atom mehr —
das globale Clustern in Board 1 macht Cross-Block-Dubletten unmöglich)."""
return {
"Alpha-Konzept": {"beschreibung": "Das erste Grundkonzept",
"subs": ["Definition Alpha", "Alpha Eigenschaften",
"Gemeinsamer Grundbegriff"]},
"Beta-Verfahren": {"beschreibung": "Das zentrale Verfahren",
"subs": ["Beta Ablauf", "Beta Grenzen"]},
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
}
_WELT: Welt | None = None # ENV-Modus (CREATOR_FAKE_AGENTS=1): eine Welt pro Prozess
async def respond(agent_key: str, prompt: str, capabilities: str) -> tuple[int, str, str]:
global _WELT
if _WELT is None:
_WELT = Welt()
return _WELT.respond(agent_key, prompt, capabilities)
def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
"""Alle Patches für einen Fake-E2E-Lauf (run_agent überall, Tempo-Bremsen raus,
Text-Identitäts-Embedding). pytest übergibt monkeypatch.setattr (auto-Rollback);
train_f0 nutzt den Default — der Prozess stirbt nach dem Lauf sowieso."""
import asyncio
import agents
import blocks
import board_inventory as bi
import guide
import guide_board
import kanban
import pipeline
import qa
async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast",
capabilities="none", lane="batch", scope=None, on_line=None, label=""):
return welt.respond(agent_key, prompt, capabilities)
for mod in (agents, pipeline, guide):
setattr_fn(mod, "run_agent", fake_run_agent)
setattr_fn(blocks, "CONSENSUS_GRACE", 0)
setattr_fn(bi, "_QA_GATE_POLL", 0.05)
setattr_fn(kanban, "RETRY_BACKOFF", 0.05)
setattr_fn(guide_board, "READABILITY_ACTIVE", False)
setattr_fn(bi, "_ingest_lock", asyncio.Lock())
class _FakeEmb: # identischer Text → cos 1.0, sonst 0.0 (deterministisch, ohne Modell)
@staticmethod
def available():
return True
@staticmethod
def embed(texts):
import hashlib
import numpy as np
# FESTE Dimension (hash → One-hot): identischer Text = gleiche Spalte = cos 1.0,
# verschiedener Text = andere Spalte = cos 0.0. Anders als eine pro-Aufruf
# variable Breite lassen sich so Vektoren aus verschiedenen embed()-Aufrufen
# concatenieren (Board-1-Cluster-Cache) ohne Dimensions-Mismatch.
D = 4096
arr = np.zeros((len(texts), D))
for r, t in enumerate(texts):
h = int(hashlib.blake2b(t.encode("utf-8"), digest_size=8).hexdigest(), 16) % D
arr[r, h] = 1.0
return arr
@staticmethod
def embed_sims(texts):
arr = _FakeEmb.embed(texts)
return arr @ arr.T
# reine Matrix-/Union-Find-Helfer (modell-unabhängig) ans echte Modul delegieren
import embedding as _real_emb
capped_blocks = staticmethod(_real_emb.capped_blocks)
_find = staticmethod(_real_emb._find)
_union = staticmethod(_real_emb._union)
for mod in (blocks, qa, bi):
setattr_fn(mod, "embedding", _FakeEmb)
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine
# falschen Vorab-Cluster; das eigentliche Clustern macht der TOP-Judge (Card-sort).

View File

@@ -0,0 +1,177 @@
# Frage-Muster für Lern-Prüfung: aak
---
## BAUSTEIN: CliqueAndIndependentSet-Problem
### Subbaustein: Independent Set: Knotenmenge ohne Kanten zwischen je zwei Knoten
**Muster:** Was ist die formale Definition eines Independent Set in einem Graphen G = (V, E)?
### Subbaustein: Komplementgraph (V, E) bildet IS und Clique aufeinander ab
**Muster:** Wie hängt die Existenz einer Clique in G mit der Existenz eines Independent Set in G' (dem Komplementgraphen) zusammen?
### Subbaustein: Existenz von Independent Set der Größe k ist NP-vollständig
**Muster:** Durch welche Polynomialzeitreduktion lässt sich zeigen, dass Independent Set NP-schwer ist?
### Subbaustein: Existenz von Clique der Größe k ist NP-vollständig
**Muster:** Wie wird in Satz 6.26 die NP-Schwere von k-Clique bewiesen?
### Subbaustein: Reduktion k-CLIQUE ⪯ k-INDEPENDENT-SET mittels Komplementgraph
**Muster:** Wie transformiert man eine Instanz (G, k) von CLIQUE in eine Instanz von INDEPENDENT-SET?
### Subbaustein: Clique: Knotenmenge, in der je zwei Knoten durch eine Kante verbunden sind
**Muster:** Wann ist eine Knotenmenge C ⊆ V eine Clique in einem Graphen G = (V, E)?
### Subbaustein: Complementärgraph: Independent Set in G ist Clique in G̅
**Muster:** Welche Beziehung besteht zwischen einem Independent Set in G und einer Clique in G̅?
### Subbaustein: Beide Probleme sind NP-vollständig
**Muster:** Welche fundamentale Konsequenz ergibt sich aus Satz 6.16, wenn ein NP-vollständiges Problem in P liegt?
### Subbaustein: Frage: Existiert Independent Set bzw. Clique der Größe k in G?
**Muster:** Was ist die Eingabe und was die Ausgabe bei den Entscheidungsproblemen k-Clique und k-Independent-Set?
### Subbaustein: Komplementarität: Independent Set in G = Clique im Komplementgraphen
**Muster:** Warum sind Clique und Independent Set gegenseitig in Polynomialzeit aufeinander reduzierbar?
### Subbaustein: CLIQUE: Knotenmenge in der je zwei Knoten durch Kante verbunden sind
**Muster:** Welche Bedingung müssen alle Knotenpaare einer Clique erfüllen?
### Subbaustein: Komplementär: IS in G = CLIQUE in G̅
**Muster:** Was bleibt bei der Bildung des Komplementgraphen G̅ gleich und was ändert sich?
### Subbaustein: Existenz von IS bzw. CLIQUE der Größe k ist NP-vollständig
**Muster:** Welche untere Schranke für die Laufzeit von Algorithmen für Independent Set folgt aus der ETH?
### Subbaustein: Formale Sprachen: CLIQUE = {(G,k) | G enthält Clique ≥ k}
**Muster:** Wie ist die formale Sprache CLIQUE über dem Alphabet Σ = {0, 1} kodiert?
### Subbaustein: Formale Sprachen: INDEPENDENT-SET = {(G,k) | G enthält IS ≥ k}
**Muster:** Welche Struktur hat die formale Sprache INDEPENDENT-SET?
### Subbaustein: Komplementgraph G̅: Kantenmenge E̅ = {(u,v) | u≠v und {u,v} ∉ E}
**Muster:** Wie unterscheiden sich die Kantenmengen von G und seinem Komplementgraphen G̅?
### Subbaustein: K-Clique in G entspricht K-Unabhängige-Menge in G̅
**Muster:** Bleibt die Größe k bei der Reduktion von k-Clique auf k-Independent-Set erhalten?
### Subbaustein: Beide Probleme sind in NP (Verifizierer existiert)
**Muster:** Welche Eigenschaft müssen Zertifikat und Verifizierer für ein Problem in NP erfüllen?
### Subbaustein: NP-vollständig via gegenseitige Reduktion über Komplementgraph
**Muster:** Wie folgt aus Korollar 6.18 die NP-Vollständigkeit von Independent Set?
---
## BAUSTEIN: Reduktion CLIQUE → CLIQUE-NOMEMBER
### Subbaustein: Füge isolierten Knoten v zu G hinzu: G' = G {v}
**Muster:** Wie wird bei der Reduktion von CLIQUE auf CLIQUE-NOMEMBER der neue Graph G' konstruiert?
### Subbaustein: G hat k-Clique ⟺ G' hat (k+1)-Clique mit v
**Muster:** Warum kann der hinzugefügte Knoten v in keiner gültigen k-Clique von G' enthalten sein?
### Subbaustein: Reduktion beweist CLIQUE-NOMEMBER ∈ NP-vollständig
**Muster:** Welche drei Bedingungen müssen erfüllt sein, damit CLIQUE-NOMEMBER als NP-vollständig gilt?
### Subbaustein: CLIQUE: Eingabe Graph G, Frage: existiert K-clique?
**Muster:** Was ist die Eingabe und was ist die Frage beim Entscheidungsproblem CLIQUE?
### Subbaustein: CLIQUE-NOMEMBER: existiert Knoten der nicht in jeder maximalen Clique liegt?
**Muster:** Wie ist das Problem CLIQUE-NOMEMBER gemäß Skript 6.50 formal definiert?
### Subbaustein: Reduktion: isolierten Knoten v zu G hinzufügen → G'
**Muster:** Welche Elemente werden bei der Reduktion von CLIQUE auf CLIQUE-NOMEMBER gegenüber der ursprünglichen Instanz verändert?
### Subbaustein: G' hat Clique der Größe k genau dann wenn G eine hat
**Muster:** Warum bleibt die Cliquengröße k bei der Reduktion unverändert?
### Subbaustein: v ist in G' in keiner k-Clique (isoliert)
**Muster:** Welche Eigenschaft hat der Knoten v in der konstruierten Instanz (G', v, k)?
### Subbaustein: G hat Clique der Größe k ⟺ G' hat Clique der Größe k ohne v
**Muster:** Wie hängt eine k-Clique in G mit einer k-Clique in G' zusammen?
### Subbaustein: Polynomielle Transformation
**Muster:** Warum ist die beschriebene Reduktion in polynomieller Zeit berechenbar?
---
## BAUSTEIN: Independent Set
### Subbaustein: Für alle u, v ∈ S gilt: {u, v} ∉ E
**Muster:** Welche Bedingung muss für je zwei Knoten eines Independent Set gelten?
### Subbaustein: Komplementär zur Clique
**Muster:** Wie hängt ein Independent Set in G mit einer Clique im Komplementgraphen G' zusammen?
### Subbaustein: NP-vollständiges Problem
**Muster:** Welche Komplexitätsklasse enthält Independent Set und wie wurde dies bewiesen?
### Subbaustein: Knotenmenge ohne Kanten zwischen je zwei Knoten der Menge
**Muster:** Was bedeutet es, dass die Knoten eines Independent Set paarweise nicht adjazent sind?
### Subbaustein: Komplementär zum Clique-Problem
**Muster:** In welchem Graphen entspricht ein Independent Set einer Clique?
### Subbaustein: Independent Set S⊆V: keine Kante zwischen je zwei Knoten in S
**Muster:** Wie unterscheidet sich ein Independent Set von einer beliebigen Teilmenge von V?
### Subbaustein: INDEPENDENT-SET = {(G,k) | G enthält unabhängige Menge der Größe ≥k}
**Muster:** Welche Sprache formalisiert das Entscheidungsproblem Independent Set?
---
## BAUSTEIN: Tiefensuche (DFS) für Zykluserkennung
### Subbaustein: Weiß: unbesuchter Knoten, Grau: aktuell in Bearbeitung, Schwarz: fertig
**Muster:** Welche Farbe hat ein Knoten während er von der DFS bearbeitet wird und welche nach Abschluss?
### Subbaustein: DFS-Zykluserkennung in O(V+E) bei adjacency List
**Muster:** Warum beträgt die Laufzeit der DFS-Zykluserkennung bei Adjazenzliste Θ(|V|+|E|)?
### Subbaustein: Schwarz: vollständig bearbeiteter Knoten
**Muster:** Wann wird ein Knoten in der DFS schwarz gefärbt?
### Subbaustein: Rückkante (grau → weiß): signalisiert Zyklus
**Muster:** Zu einem Knoten welcher Farbe muss eine Kante führen, um einen Zyklus anzuzeigen?
### Subbaustein: Tree Edge (weiß): Kante zu unbesuchtem Knoten
**Muster:** Welche Kante wird als Tree Edge bezeichnet?
### Subbaustein: Grau: aktuell in Bearbeitung (in DFS-Weite)
**Muster:** Was bedeutet es, wenn ein Knoten während der DFS grau gefärbt ist?
---
## BAUSTEIN: Turingmaschine für 0^n (Zweierpotenz)
### Subbaustein: Eingabe: n Nullen in unärer Codierung
**Muster:** In welcher Codierung wird die Eingabezahl n der TM für 0^n dargestellt?
### Subbaustein: Akzeptiert nur wenn n = 2^k für ein k ≥ 0
**Muster:** Nach welchem Kriterium entscheidet die TM, ob eine Eingabe akzeptiert wird?
### Subbaustein: Phase 1: Markiere jede zweite 0 mit x (alternierend)
**Muster:** Wie markiert die TM die Nullen im ersten Schritt?
---
## BAUSTEIN: 3-SAT zu 3-Färbung Reduktion
### Subbaustein: Knotenzahl linear in Variablen und Klauseln
**Muster:** Aus welchen Komponenten setzt sich die Knotenmenge V der konstruierten Instanz zusammen?
### Subbaustein: Dreieck erzwingt drei verschiedene Farben für die drei Knoten
**Muster:** Warum benötigen die drei Knoten xi, x̄i und vi eines jeden Dreiecks drei verschiedene Farben?
---
## BAUSTEIN: MC-Knapsack
### Subbaustein: Ziel: Maximierung des Gesamtwerts
**Muster:** Was ist die Zielfunktion beim Maximum-Cut Knapsack Problem?
---
**Gesamt: 48 Frage-Muster**

View File

@@ -0,0 +1,126 @@
# Frage-Muster für Lern-Prüfung: aak
---
## BAUSTEIN: CliqueAndIndependentSet-Problem
### Subbaustein: Clique: Knotenmenge, in der je zwei Knoten durch eine Kante verbunden sind
**Muster:** Wann ist eine Knotenmenge C ⊆ V eine Clique in einem Graphen G = (V, E) und welche Bedingung müssen alle Knotenpaare einer Clique erfüllen?
### Subbaustein: Independent Set: Knotenmenge ohne Kanten zwischen je zwei Knoten
**Muster:** Was ist die formale Definition eines Independent Set in einem Graphen G = (V, E) und welche Bedingung muss für je zwei Knoten eines Independent Set gelten?
### Subbaustein: Komplementgraph: Independent Set in G ist Clique in G̅
**Muster:** Welche Beziehung besteht zwischen einem Independent Set in G und einer Clique in G̅, warum sind die Probleme gegenseitig in Polynomialzeit aufeinander reduzierbar, und was bleibt bei der Bildung des Komplementgraphen gleich bzw. ändert sich?
### Subbaustein: K-CLIQUE ⪯ K-INDEPENDENT-SET mittels Komplementgraph
**Muster:** Wie transformiert man eine Instanz (G, k) von CLIQUE in eine Instanz von INDEPENDENT-SET, und bleibt die Größe k bei der Reduktion erhalten?
### Subbaustein: NP-vollständig via gegenseitige Reduktion über Komplementgraph
**Muster:** Wie folgt aus Korollar 6.18 die NP-Vollständigkeit von Independent Set, und welche untere Schranke für die Laufzeit von Algorithmen für Independent Set folgt aus der ETH?
### Subbaustein: Existenz von IS bzw. CLIQUE der Größe k ist NP-vollständig
**Muster:** Durch welche Polynomialzeitreduktion lässt sich zeigen, dass Independent Set NP-schwer ist, und wie wird in Satz 6.26 die NP-Schwere von k-Clique bewiesen?
### Subbaustein: Beide Probleme sind in NP (Verifizierer existiert)
**Muster:** Welche Eigenschaft müssen Zertifikat und Verifizierer für die Probleme k-Clique und k-Independent-Set erfüllen?
### Subbaustein: Konsequenz: P = NP falls eines in P
**Muster:** Welche fundamentale Konsequenz ergibt sich aus Satz 6.16, wenn ein NP-vollständiges Problem in P liegt?
### Subbaustein: Formale Sprachen: CLIQUE und INDEPENDENT-SET
**Muster:** Wie sind die formalen Sprachen CLIQUE und INDEPENDENT-SET über dem Alphabet Σ = {0, 1} kodiert und welche Struktur haben sie?
### Subbaustein: Eingabe/Ausgabe von k-Clique und k-Independent-Set
**Muster:** Was ist die Eingabe und was die Ausgabe bei den Entscheidungsproblemen k-Clique und k-Independent-Set?
---
## BAUSTEIN: Reduktion CLIQUE → CLIQUE-NOMEMBER
### Subbaustein: Füge isolierten Knoten v zu G hinzu: G' = G {v}
**Muster:** Wie wird bei der Reduktion von CLIQUE auf CLIQUE-NOMEMBER der neue Graph G' konstruiert, und welche Elemente werden gegenüber der ursprünglichen Instanz verändert?
### Subbaustein: v ist in G' in keiner k-Clique (isoliert)
**Muster:** Warum kann der hinzugefügte Knoten v in keiner gültigen k-Clique von G' enthalten sein, und welche Eigenschaft hat der Knoten v in der konstruierten Instanz (G', v, k)?
### Subbaustein: G hat k-Clique ⟺ G' hat (k+1)-Clique mit v
**Muster:** Wie hängt eine k-Clique in G mit einer k-Clique in G' zusammen, und warum bleibt die Cliquengröße k bei der Reduktion unverändert?
### Subbaustein: Polynomielle Transformation
**Muster:** Warum ist die beschriebene Reduktion von CLIQUE auf CLIQUE-NOMEMBER in polynomieller Zeit berechenbar?
### Subbaustein: CLIQUE: Eingabe Graph G, Frage: existiert K-clique?
**Muster:** Was ist die Eingabe und was ist die Frage beim Entscheidungsproblem CLIQUE?
### Subbaustein: CLIQUE-NOMEMBER formal definiert
**Muster:** Wie ist das Problem CLIQUE-NOMEMBER gemäß Skript 6.50 formal definiert?
### Subbaustein: Reduktion beweist CLIQUE-NOMEMBER ∈ NP-vollständig
**Muster:** Welche drei Bedingungen müssen erfüllt sein, damit CLIQUE-NOMEMBER als NP-vollständig gilt?
---
## BAUSTEIN: Independent Set
### Subbaustein: Independent Set S⊆V: keine Kante zwischen je zwei Knoten in S
**Muster:** Welche Bedingung muss für je zwei Knoten eines Independent Set gelten und was bedeutet es, dass die Knoten eines Independent Set paarweise nicht adjazent sind?
### Subbaustein: Komplementär zur Clique
**Muster:** In welchem Graphen entspricht ein Independent Set einer Clique und wie hängt ein Independent Set in G mit einer Clique im Komplementgraphen G' zusammen?
### Subbaustein: NP-vollständiges Problem
**Muster:** Welche Komplexitätsklasse enthält Independent Set und wie wurde dies bewiesen?
### Subbaustein: INDEPENDENT-SET = {(G,k) | G enthält unabhängige Menge der Größe ≥k}
**Muster:** Welche Sprache formalisiert das Entscheidungsproblem Independent Set?
---
## BAUSTEIN: Tiefensuche (DFS) für Zykluserkennung
### Subbaustein: Weiß/Grau/Schwarz: Farbcodierung der DFS
**Muster:** Welche Farbe hat ein Knoten während er von der DFS bearbeitet wird, welche nach Abschluss, und wann wird ein Knoten in der DFS schwarz gefärbt?
### Subbaustein: Tree Edge (weiß): Kante zu unbesuchtem Knoten
**Muster:** Welche Kante wird als Tree Edge bezeichnet?
### Subbaustein: Rückkante (grau → weiß): signalisiert Zyklus
**Muster:** Zu einem Knoten welcher Farbe muss eine Kante führen, um einen Zyklus anzuzeigen?
### Subbaustein: DFS-Zykluserkennung in O(V+E) bei adjacency List
**Muster:** Warum beträgt die Laufzeit der DFS-Zykluserkennung bei Adjazenzliste Θ(|V|+|E|)?
---
## BAUSTEIN: Turingmaschine für 0^n (Zweierpotenz)
### Subbaustein: Eingabe: n Nullen in unärer Codierung
**Muster:** In welcher Codierung wird die Eingabezahl n der TM für 0^n dargestellt?
### Subbaustein: Akzeptiert nur wenn n = 2^k für ein k ≥ 0
**Muster:** Nach welchem Kriterium entscheidet die TM, ob eine Eingabe akzeptiert wird?
### Subbaustein: Phase 1: Markiere jede zweite 0 mit x (alternierend)
**Muster:** Wie markiert die TM die Nullen im ersten Schritt?
---
## BAUSTEIN: 3-SAT zu 3-Färbung Reduktion
### Subbaustein: Knotenzahl linear in Variablen und Klauseln
**Muster:** Aus welchen Komponenten setzt sich die Knotenmenge V der konstruierten Instanz zusammen?
### Subbaustein: Dreieck erzwingt drei verschiedene Farben für die drei Knoten
**Muster:** Warum benötigen die drei Knoten xi, x̄i und vi eines jeden Dreiecks drei verschiedene Farben?
---
## BAUSTEIN: MC-Knapsack
### Subbaustein: Ziel: Maximierung des Gesamtwerts
**Muster:** Was ist die Zielfunktion beim Maximum-Cut Knapsack Problem?
---
**Gesamt: 28 Frage-Muster**

View File

@@ -1,7 +1,7 @@
"""Atomare Datei-Writes: erst .tmp im selben Verzeichnis, dann os.replace.
"""Atomic file writes: first a .tmp in the same directory, then os.replace.
Ein Crash hinterlässt höchstens eine .tmp-Datei — nie eine halb geschriebene
Zieldatei. Die .tmp wird beim nächsten erfolgreichen Write überschrieben.
A crash leaves at most a .tmp file behind — never a half-written target
file. The .tmp is overwritten on the next successful write.
"""
import json

View File

@@ -1,492 +1,419 @@
"""Guide-Generierung: 6 Schritte mit Prüfung nach jeder Phase (OnePager hat einen eigenen Weg).
"""Guide generation as a consensus pipeline.
Prüf-Agenten notieren nur Probleme; das Anpassen übernimmt der jeweilige Erzeuger-Typ.
Schritt-Dateien bleiben liegen → Abbruch erhält Fortschritt, ▶ setzt am offenen Schritt fort.
Outline: select the blocks (deterministic per format) → 3 proposals
(Grace) that order the blocks into chapters by NUMBER → a judge merges the
proposals into one coherent order.
Writing: one writer per block. Reading exam: Check→Fix (one round),
follow-up rounds check only replaced sections; remaining complaints then stand.
Step files are kept → an abort preserves progress, ▶ resumes at the open step.
"""
import asyncio
import json
import logging
import math
from datetime import datetime, timezone
from pathlib import Path
import uuid
from agents import run_agent
from bausteine import _pdfs_konvertieren
from config import DEFAULT_PROVIDER, FORMAT_ANTEIL, TEMPLATES_DIR
from database import list_guides, update_guide
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_datei
from onepager import _generate_onepager
from paths import bausteine_path, guide_content_path, project_dir
from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _claude_error, _extra,
_fail, _gather_error, _log, _prompt, _race, _semaphore, _set_progress,
_set_step, _timeout, clear_guide_cancelled, is_guide_cancelled,
run_single_slot,
)
from textkit import (
_eindeutige_titel, _lade_bausteine, _parse_fragment, _split_chunks,
_titel, _titel_aufloesen, _titel_index, _zuteilung_text,
)
from blocks import _convert_pdfs, source_folder
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from paths import blocks_path, guide_content_path, subblocks_path
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
log = logging.getLogger("creator.guide")
GUIDE_STEPS = ("Auswahl", "Auswahl-Prüfung", "Gliederung", "Gliederungs-Prüfung", "Schreiben", "Lese-Prüfung")
# Writer skalieren mit der Section-Zahl: 1 Writer je ~30 Sections (gedeckelt).
# Kleine Pakete vermeiden Lazy-Output bei langen Listen und begrenzen den Schaden
# eines fehlgeschlagenen Writers.
WRITER_SECTIONS = 30
WRITER_MAX = 20
# Content/Content-Check/Reading-Exam run in packets of ~GUIDE_CHUNK blocks per agent.
# Only the writer (Writing) stays at 1 agent per block (variable lengths, no trimming, no
# length alignment between blocks).
# Check steps as a panel: CHECK_PANEL judges per chunk, section flagged on a majority.
# A single judge is bias/sampling prone; a small panel is more stable.
# Reading exam: only ONE round (Check + Fix). Follow-up rounds added little value
# (1 agent per block checks finely anyway) but cost extra agents.
def _guide_files(content_path: Path) -> dict:
d, stem = content_path.parent, content_path.stem
return {
"auswahl": d / f"{stem}.auswahl.json",
"auswahl_check": d / f"{stem}.auswahl-check.json",
"gliederung": d / f"{stem}.gliederung.json",
"gliederung_check": d / f"{stem}.gliederung-check.json",
# chunk-/lese-check-/fix-Dateien sind dynamisch: {stem}.chunk-i.md usw.
}
# Valid level values: new (learning path) + old (difficulty) backward-compatible.
_LEVELS_OK = ("beginner", "advanced", "expert", "easy", "medium", "hard")
def guide_slot_dateien(content_path: Path) -> list[Path]:
"""Alle Schritt-Dateien eines Guides (für den Frischstart)."""
async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
"""Subblocks per block — DB-first ({title, level, relevance}), fallback sidecar file.
Both missing → {} (guide takes everything). A missing/invalid level defaults to
'advanced' instead of dropping the row: re-run resume left 25 consensus subs
level-less, the writer silently lost them while the guide QA still counted them."""
out: dict[str, list[dict]] = {}
for r in await list_subblocks(topic):
if r["status"] == "consensus" and r["sub_title"]:
facts = parse_facts(r.get("facts"))
level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
out.setdefault(r["block"], []).append(
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
if out:
return out
data = _json_file(subblocks_path(topic))
if not isinstance(data, dict):
return {}
for title, subs in data.items():
if not isinstance(subs, list):
continue
good = [s for s in subs if isinstance(s, dict) and str(s.get("title", "")).strip()
and s.get("level") in _LEVELS_OK]
if good:
out[title] = good
return out
def _level_label(s: dict) -> str:
"""View level of a subblock: peripheral → 'peripheral' (level 4), otherwise the level (13)."""
return "peripheral" if s.get("relevance") == "peripheral" else (s.get("level") or "beginner")
def guide_slot_files(content_path: Path) -> list[Path]:
"""All step files of a guide (for a fresh start)."""
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
def _resolve_auswahl(data, entries: dict[int, str], k_min: int, k_max: int) -> list[int] | None:
"""{"bausteine": [Titel]} → Nummern; None bei Schema-Verstoß/Drift/falschem Umfang."""
if not isinstance(data, dict) or not isinstance(data.get("bausteine"), list):
return None
idx = _titel_index(entries)
nums: list[int] = []
seen: set[int] = set()
total = unknown = 0
for t in data["bausteine"]:
total += 1
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
if num is None:
unknown += 1
elif num not in seen:
seen.add(num)
nums.append(num)
if total == 0 or (total - unknown) / total < 0.85:
return None
if len(nums) < 0.9 * k_min or len(nums) > 1.1 * k_max:
return None
return nums
def _lese_probleme_schema(data):
"""{"ok": true} → [] · {"probleme": [{"section", "problem"}]} → Liste · sonst None."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
p = data.get("probleme")
if not isinstance(p, list) or not p:
return None
out = []
for x in p:
if not isinstance(x, dict) or not isinstance(x.get("section"), str) or not isinstance(x.get("problem"), str):
return None
out.append({"section": x["section"].strip(), "problem": x["problem"].strip()})
return out or None
def _resolve_gliederung(data, entries: dict[int, str], soll_min: int, soll_max: int) -> list[dict] | None:
"""{"kapitel": [{"titel", "bausteine": [Titel]}]} → [{"title", "nums"}].
`soll_min`/`soll_max` = erlaubte Spanne gewählter Bausteine (mit kleiner Toleranz).
"""
if not isinstance(data, dict) or not isinstance(data.get("kapitel"), list):
return None
idx = _titel_index(entries)
chapters: list[dict] = []
seen: set[int] = set()
total = unknown = 0
for ch in data["kapitel"]:
if not isinstance(ch, dict) or not isinstance(ch.get("bausteine"), list):
return None
nums = []
for t in ch["bausteine"]:
total += 1
num = _titel_aufloesen(idx, t) if isinstance(t, str) else None
if num is None:
unknown += 1
elif num not in seen:
nums.append(num)
seen.add(num)
if nums:
chapters.append({"title": str(ch.get("titel", "")).strip() or "Kapitel", "nums": nums})
if not chapters or total == 0:
return None
if (total - unknown) / total < 0.85:
return None
if len(seen) < 0.9 * soll_min or len(seen) > 1.1 * soll_max:
return None
return chapters
async def _generate_sections(
guide_id: str, topic: str, format_name: str, entries: dict[int, str],
facts: str, instructions: str, provider: str,
content_path: Path,
) -> list[dict] | None:
def is_cancelled() -> bool:
return is_guide_cancelled(guide_id)
ctx = GenContext(topic=topic, provider=provider, is_cancelled=is_cancelled, guide_id=guide_id)
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
files = _guide_files(content_path)
bausteine_liste = "\n".join(f"- {t}" for t in entries.values())
n = len(entries)
anteil_min, anteil_max, minimum, zweck = FORMAT_ANTEIL[format_name]
k_min = min(n, max(minimum, math.ceil(anteil_min * n)))
k_max = min(n, max(k_min, math.floor(anteil_max * n)))
auswahl_auftrag = (
f"Wähle MINDESTENS {k_min} und HÖCHSTENS {k_max} der Bausteine und baue daraus {zweck}. "
"Wähle, was diesem Zweck dient — lass weg, was dafür nicht nötig ist."
)
# Schritt 1: Auswahl — vorhandene gültige Datei wird übernommen (Resume)
auswahl = _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max)
if auswahl is None:
await _set_step(guide_id, 0, "Wähle Bausteine…")
files["auswahl"].unlink(missing_ok=True)
status, auswahl = await run_single_slot(
ctx, "Guide-Auswahl",
key=f"{guide_id}-auswahl",
prompt=_prompt(
"Guide-Auswahl",
topic=topic, format_name=format_name, bausteine=bausteine_liste,
auswahl_auftrag=auswahl_auftrag, out_path=files["auswahl"], extra=_extra(instructions),
),
role="guide", capabilities="files",
payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
timeout=_timeout("guide_auswahl", n),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Auswahl fehlgeschlagen")
return None
# Slot-file globs per step (index = GUIDE_STEPS). Stem-anchored, collision-free.
def auswahl_titel() -> str:
return "\n".join(f"- {_titel(entries[num])}" for num in auswahl)
def auswahl_json() -> str:
return json.dumps({"bausteine": [_titel(entries[num]) for num in auswahl]}, ensure_ascii=False)
# Schritt 2: Auswahl-Prüfung — notiert Probleme; Anpassung macht ein Auswahl-Agent
status, fixed = await _check_then_fix(
ctx, name="Auswahl", step=1,
check_key=f"{guide_id}-auswahl-check",
check_prompt=_prompt(
"Guide-Auswahl-Check",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
out_path=files["auswahl_check"], extra=_extra(instructions),
),
check_path=files["auswahl_check"], check_timeout=_timeout("guide_check", len(auswahl)),
fix_key=f"{guide_id}-auswahl-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Auswahl-Fix",
topic=topic, format_name=format_name, auswahl_auftrag=auswahl_auftrag,
bausteine=bausteine_liste, auswahl=auswahl_titel(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["auswahl"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_auswahl(_json_datei(files["auswahl"]), entries, k_min, k_max),
fix_timeout=_timeout("guide_auswahl", n), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["auswahl"], auswahl_json()),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Auswahl-Prüfung fehlgeschlagen")
return None
if fixed is not None:
auswahl = fixed
sel_entries = {num: entries[num] for num in auswahl}
soll = len(sel_entries)
sel_liste = "\n".join(f"- {t}" for t in sel_entries.values())
# Schritt 3: Gliederung der festen Auswahl
plan = _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll)
if plan is None:
await _set_step(guide_id, 2, "Plane Gliederung…")
files["gliederung"].unlink(missing_ok=True)
status, plan = await run_single_slot(
ctx, "Gliederung",
key=f"{guide_id}-gliederung",
prompt=_prompt(
"Guide-Gliederung",
topic=topic, format_name=format_name, bausteine=sel_liste,
out_path=files["gliederung"], extra=_extra(instructions),
),
role="guide", capabilities="files",
payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
timeout=_timeout("plan", soll),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederung fehlgeschlagen")
return None
def gliederung_text() -> str:
return "\n".join(_zuteilung_text([ch], {num: _titel(entries[num]) for num in ch["nums"]}) for ch in plan)
def gliederung_json() -> str:
return json.dumps(
{"kapitel": [{"titel": ch["title"], "bausteine": [_titel(entries[num]) for num in ch["nums"]]} for ch in plan]},
ensure_ascii=False,
)
# Schritt 4: Gliederungs-Prüfung
status, fixed = await _check_then_fix(
ctx, name="Gliederung", step=3,
check_key=f"{guide_id}-gliederung-check",
check_prompt=_prompt(
"Guide-Gliederung-Check",
topic=topic, format_name=format_name, zweck=zweck,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
out_path=files["gliederung_check"], extra=_extra(instructions),
),
check_path=files["gliederung_check"], check_timeout=_timeout("guide_check", soll),
fix_key=f"{guide_id}-gliederung-fix",
build_fix_prompt=lambda probleme: _prompt(
"Guide-Gliederung-Fix",
topic=topic, format_name=format_name,
auswahl=auswahl_titel(), gliederung=gliederung_text(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=files["gliederung"], extra=_extra(instructions),
),
fix_payload=lambda result: _resolve_gliederung(_json_datei(files["gliederung"]), sel_entries, soll, soll),
fix_timeout=_timeout("plan", soll), fix_role="guide",
on_fix_invalid=lambda: atomic_write_text(files["gliederung"], gliederung_json()),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Gliederungs-Prüfung fehlgeschlagen")
return None
if fixed is not None:
plan = fixed
# Schritt 5: Schreiben — vorhandene Chunk-Dateien werden übernommen (Resume)
total_sections = sum(len(c["nums"]) for c in plan)
chunks = _split_chunks(plan, min(WRITER_MAX, max(1, math.ceil(total_sections / WRITER_SECTIONS))))
zuteilungen = [_zuteilung_text(chunk, entries) for chunk in chunks]
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
writer_count = len(zuteilungen)
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, writer_count + 1)]
offen = [i for i, p in enumerate(paths) if not p.exists()]
if offen:
await _set_step(guide_id, 4, f"Schreibe Sections ({writer_count} Writer)…" if writer_count > 1 else "Schreibe Sections…")
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-w{i + 1}",
_prompt(
"Guide-Writer",
topic=topic, format_name=format_name, zuteilung=zuteilungen[i],
facts=facts, spec=spec, out_path=paths[i], extra=_extra(instructions),
),
_timeout("writer", chunk_sizes[i]), provider=provider, role="guide", capabilities="full",
)
for i in offen
], return_exceptions=True)
if is_cancelled():
return None
for i, r in zip(offen, results):
if isinstance(r, BaseException):
_log(topic, f"Writer {i + 1}: {type(r).__name__}: {r}")
elif r[0] != 0:
_log(topic, f"Writer {i + 1}: {_claude_error('Fehler', *r)}")
elif not paths[i].exists():
_log(topic, f"Writer {i + 1}: keine Ausgabedatei erstellt")
if not any(p.exists() for p in paths):
await _fail(guide_id, _gather_error("Writer-Fehler", list(results)))
return None
idx = _titel_index(entries)
by_num: dict[int, dict] = {}
for p in paths:
if not p.exists():
continue
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"])
if num is None:
_log(topic, f"Writer lieferte unbekannte Section '{sec['titel'][:40]}' (ignoriert)")
elif num not in by_num:
by_num[num] = sec
if not by_num:
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
return None
def _fallback_outline(entries: dict[int, str]) -> list[dict]:
"""Deterministic outline when the agents deliver none: one chapter with
all selected blocks in order. Guarantees full coverage."""
return [{"title": "Contents", "nums": list(entries)}]
# Schritt 6: Lese-Prüfung pro Writer-Paket — Fix beauftragt Writer nur mit beanstandeten Sections
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
check_paths = [content_path.parent / f"{content_path.stem}.lese-check-{i}.json" for i in range(1, writer_count + 1)]
offen_checks = [i for i, p in enumerate(check_paths) if _lese_probleme_schema(_json_datei(p)) is None and chunk_nums[i]]
if offen_checks:
await _set_step(guide_id, 5, f"Prüfe Lesbarkeit ({len(offen_checks)} Prüfer)…" if len(offen_checks) > 1 else "Prüfe Lesbarkeit…")
def sections_text(nums: list[int]) -> str:
return "\n\n".join(f"SECTION: {_titel(entries[num])}\n{by_num[num]['md']}" for num in nums)
def _with_remainder(plan: list[dict], entries: dict[int, str]) -> list[dict]:
"""Ensures that EVERY selected block is in the plan — missing ones land in
an "Additional" chapter (against agents/judges that drop blocks)."""
present = {num for ch in plan for num in ch.get("nums", [])}
missing = [num for num in entries if num not in present]
return [*plan, {"title": "Additional", "nums": missing}] if missing else plan
slots = [{
"key": f"{guide_id}-lese-check-{i + 1}",
"prompt": _prompt(
"Guide-Lese-Check",
topic=topic, format_name=format_name, spec=spec,
sections=sections_text(chunk_nums[i]),
out_path=check_paths[i], extra=_extra(instructions),
),
"role": "fast", "capabilities": "files",
"payload": (lambda result, p=check_paths[i]: _lese_probleme_schema(_json_datei(p))),
} for i in offen_checks]
res = await _race(topic, "Lese-Prüfung", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, cancelled=is_cancelled)
if is_cancelled():
return None
if res is None:
await _fail(guide_id, "Lese-Prüfung fehlgeschlagen")
return None
probleme_by_num: dict[int, str] = {}
for p in check_paths:
for item in (_lese_probleme_schema(_json_datei(p)) or []):
num = _titel_aufloesen(idx, item["section"])
if num in by_num and num not in probleme_by_num:
probleme_by_num[num] = item["problem"]
if probleme_by_num:
_log(topic, f"Lese-Prüfung: {len(probleme_by_num)} Section(s) beanstandet")
await _set_step(guide_id, 5, f"Überarbeite {len(probleme_by_num)} Section(s)…")
fix_chunks = [[num for num in nums if num in probleme_by_num] for nums in chunk_nums]
fix_offen = [i for i, nums in enumerate(fix_chunks) if nums]
fix_paths = [content_path.parent / f"{content_path.stem}.fix-{i + 1}.md" for i in range(writer_count)]
def auftraege_text(nums: list[int]) -> str:
return "\n\n".join(
f"SECTION: {_titel(entries[num])}\nPROBLEM: {probleme_by_num[num]}\nAKTUELLER INHALT:\n{by_num[num]['md']}"
for num in nums
)
results = await asyncio.gather(*[
run_agent(
f"{guide_id}-fix-w{i + 1}",
_prompt(
"Guide-Sections-Fix",
topic=topic, format_name=format_name, facts=facts, spec=spec,
auftraege=auftraege_text(fix_chunks[i]),
out_path=fix_paths[i], extra=_extra(instructions),
),
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full",
)
for i in fix_offen
], return_exceptions=True)
if is_cancelled():
return None
for i, r in zip(fix_offen, results):
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
_log(topic, f"Sections-Fix {i + 1} fehlgeschlagen — Original bleibt")
ersetzt = 0
for i in fix_offen:
if not fix_paths[i].exists():
def _facts_grounding(subs_raw: dict[str, list[dict]]) -> str:
"""Verified sub-facts (extract-once from the blocks phase) as a grounding block for the
content agent. Empty if no facts are stored (legacy data → fallback to a source hint)."""
blocks = []
for title, subs in subs_raw.items():
lines = []
for s in subs:
fk = s.get("facts") if isinstance(s.get("facts"), dict) else None
if not fk:
continue
for sec in _parse_fragment(fix_paths[i].read_text(encoding="utf-8")):
num = _titel_aufloesen(idx, sec["titel"])
if num in probleme_by_num and sec["md"].strip():
by_num[num] = sec
ersetzt += 1
_log(topic, f"Lese-Prüfung: {ersetzt} Section(s) überarbeitet")
parts = []
if fk.get("key_points"):
parts.append("Core: " + " · ".join(fk["key_points"]))
for bf in fk.get("cited_facts", []):
parts.append(f"FACT[{bf.get('source', '?')}]: {bf.get('text', '')}")
if fk.get("prerequisites"):
parts.append("Prerequisite: " + fk["prerequisites"])
if fk.get("hurdles"):
parts.append("Hurdle: " + fk["hurdles"])
if fk.get("example_idea"):
parts.append("Example: " + fk["example_idea"])
if parts:
lines.append(f"- {s['title']}: " + " | ".join(parts))
if lines:
blocks.append(f"BLOCK: {title}\n" + "\n".join(lines))
if not blocks:
return ""
return ("VERIFIED FACTS per subblock — binding basis. Quote cited facts (FACT[Source]) "
"VERBATIM, invent nothing extra, do NOT re-research. Use examples as examples, "
"never as fact.\n\n" + "\n\n".join(blocks))
await _set_progress(guide_id, "Setze zusammen…")
chapters: list[dict] = []
for ch in plan:
sections = [
{"num": num, "title": _titel(entries[num]), "md": by_num[num]["md"]}
for num in ch["nums"] if num in by_num
]
if sections:
chapters.append({"title": ch["title"], "sections": sections})
geplant = {num for ch in plan for num in ch["nums"]}
missing = sorted(geplant - set(by_num))
if missing:
_log(topic, f"Sections fehlen in der Writer-Ausgabe: {[_titel(entries[n]) for n in missing]}")
if not chapters:
await _fail(guide_id, "Keine Sections in der Writer-Ausgabe gefunden")
async def _outline_from_db(topic: str, sel_entries: dict[int, str]) -> list[dict] | None:
"""Read the outline from the blocks artifact (DB) and map it onto the selected blocks.
Title-based (robust against number drift): blocks outside the selection are ignored
(format filter), missing ones are added later by _with_remainder. None → no artifact (legacy)."""
raw = await get_outline(topic)
if not raw:
return None
return chapters
try:
data = json.loads(raw)
except (ValueError, TypeError):
return None
chapters = data.get("chapters") if isinstance(data, dict) else None
if not isinstance(chapters, list):
return None
norm_to_num = {_norm_title(_title(t)): num for num, t in sel_entries.items()}
plan, seen = [], set()
for ch in chapters:
if not isinstance(ch, dict):
continue
nums = []
for bt in ch.get("blocks", []):
num = norm_to_num.get(_norm_title(str(bt)))
if num is not None and num not in seen:
seen.add(num)
nums.append(num)
if nums:
plan.append({"title": str(ch.get("title", "")).strip() or "Chapter", "nums": nums})
return plan or None
_LEVEL_RANK = {"beginner": 1, "advanced": 2, "expert": 3, "peripheral": 4,
"easy": 1, "medium": 2, "hard": 3} # old values backward-compatible
def _section_for_level(sec: dict, level: int) -> dict:
"""Reconstruct md/compact of a section from subblocks up to the level (anchor stays)."""
subs = sec.get("subs") or []
if not subs:
return sec # no sub tags (legacy) → unchanged, visible
visible = [s for s in subs if _LEVEL_RANK.get(s.get("level"), 1) <= level]
md = "\n\n".join(t for t in [sec.get("anchor", ""), *(s.get("md", "") for s in visible)] if t).strip()
compact = "\n".join(t for t in [sec.get("anker_compact", ""), *(s.get("compact", "") for s in visible)] if t).strip()
return {**sec, "md": md, "compact": compact, "leer": not visible}
def content_fuer_level(content: dict, level: int) -> dict:
"""Filter guide content to a view level (1=A · 2=F · 3=E · 4=V). Level 4 = full version.
Sections without visible subs are hidden, empty chapters drop out."""
if not isinstance(content, dict) or level >= 4:
return content
chapters = []
for ch in content.get("chapters", []):
secs = [s for s in (_section_for_level(x, level) for x in ch.get("sections", [])) if not s.get("leer")]
if secs:
chapters.append({**ch, "sections": secs})
return {**content, "chapters": chapters}
async def reconcile_guides() -> None:
"""DB↔Dateisystem abgleichen: status=done ohne Content-Datei → error.
"""Reconcile DB↔filesystem: status=done without content file → error.
Läuft beim Server-Start (nach init_db) — fängt Crashes zwischen
Datei-Write und Status-Update ab.
Runs at server start (after init_db) — catches crashes between
file write and status update.
"""
for g in await list_guides():
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
log.warning("[%s] Guide %s: done ohne Content-Datei — auf error gesetzt", g["topic"], g["id"])
now = datetime.now(timezone.utc).isoformat()
await update_guide(g["id"], status="error", error_msg="Inhalt fehlt — neu generieren", updated_at=now)
if g["status"] != "done":
continue
# DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback
if await get_guide_content(g["topic"], g["format"]) is not None:
continue
if guide_content_path(g["topic"], g["format"]).exists():
continue
log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"])
now = datetime.now(timezone.utc).isoformat()
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Starte", updated_at=now)
await update_guide(guide_id, status="generating", progress="Starting", updated_at=now)
content_path = guide_content_path(topic, format_name)
content_path.parent.mkdir(parents=True, exist_ok=True)
project = project_dir(topic) if project_dir(topic).is_dir() else None
project = source_folder(topic) # folder source (project/uni/link) → path, else None
try:
if is_guide_cancelled(guide_id):
return
if project:
await asyncio.to_thread(_pdfs_konvertieren, project)
await asyncio.to_thread(_convert_pdfs, project)
# „Neu erstellen": fertiger Guide → kompletter Frischstart.
# Sonst sind Schritt-Dateien Reste eines Abbruchs/Fehlers → Resume.
import guide_board # lazy — guide_board imports helpers from this module
# A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards
# are leftovers of an abort/error/repair → resume at their stored stage.
if content_path.exists():
for p_alt in guide_slot_dateien(content_path):
p_alt.unlink(missing_ok=True)
counts = await guide_stage_counts(topic, format_name)
if not counts or set(counts) == {"done"}:
await delete_guide_board(topic, format_name)
for p_alt in guide_slot_files(content_path):
p_alt.unlink(missing_ok=True)
if format_name == "OnePager":
chapters = await _generate_onepager(guide_id, topic, instructions, provider, project, content_path)
else:
alle = _lade_bausteine(bausteine_path(topic).read_text(encoding="utf-8"))
if not alle:
await _fail(guide_id, "Keine Bausteine gefunden")
return
entries = _eindeutige_titel(alle)
facts = _prompt("Guide-Fakten-Projekt", project=project) if project else _prompt("Guide-Fakten-Thema")
chapters = await _generate_sections(
guide_id, topic, format_name, entries,
facts, instructions, provider, content_path,
)
if chapters is None or is_guide_cancelled(guide_id):
bs = await list_blocks(topic, status="consensus")
if bs:
alle = {i: (f"{b['title']}{b['description']}" if b["description"] else b["title"])
for i, b in enumerate(bs, 1)}
else: # fallback: blocks.md (legacy topics)
bp = blocks_path(topic)
alle = _load_blocks(bp.read_text(encoding="utf-8")) if bp.exists() else {}
if not alle:
await _fail(guide_id, "No blocks found")
return
entries = _unique_title(alle)
chapters = await guide_board.run_guide_board(
guide_id, topic, format_name, entries, instructions, provider, content_path,
)
if is_guide_cancelled(guide_id):
return
if chapters is None:
await _fail(guide_id, "No finished sections (see board — cards with errors)")
return
content = {"topic": topic, "format": format_name, "chapters": chapters}
atomic_write_json(content_path, {"topic": topic, "format": format_name, "chapters": chapters}, indent=1)
atomic_write_json(content_path, content, indent=1) # bridge (resume/fallback)
await set_guide_content(topic, format_name, json.dumps(content, ensure_ascii=False))
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="done", progress=None, step=None, updated_at=now)
except asyncio.TimeoutError:
await _fail(guide_id, "Timeout bei der Generierung")
await _fail(guide_id, "Timeout during generation")
except FileNotFoundError:
await _fail(guide_id, "Bausteine fehlen")
await _fail(guide_id, "Blocks missing")
except Exception as e:
log.exception("[%s] Guide-Generierung fehlgeschlagen (%s)", topic, guide_id)
log.exception("[%s] Guide generation failed (%s)", topic, guide_id)
await _fail(guide_id, str(e)[:2000])
finally:
clear_guide_cancelled(guide_id)
# --- On-demand: check / fix / rewrite one section (focus, interactive) ---
SECTION_CHECK_TIMEOUT = 300
def _section_spec() -> str:
return (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
def _section_facts(topic: str) -> str:
project = source_folder(topic)
return _prompt("Guide-Facts-Projekt", project=project) if project else _prompt("Guide-Facts-Thema")
def _hint_block(hint: str) -> str:
hint = (hint or "").strip()
return f"NOTE FROM THE USER (pay special attention):\n{hint}" if hint else ""
async def _subs_text(topic: str, block: str) -> str:
"""Relevant subblocks of a block with level — as a checklist for the agents."""
subs_raw = await _load_subblocks(topic)
subs = [s for s in subs_raw.get(_title(block), []) if s.get("relevance") != "peripheral"]
if not subs:
return "(no subblocks recorded — cover 37 concise points)"
return "\n".join(f"- [{s['level']}] {s['title']}" for s in subs)
async def _load_guide_content(topic: str, format_name: str) -> dict | None:
js = await get_guide_content(topic, format_name)
if not js:
return None
try:
return json.loads(js)
except ValueError:
return None
def _find_section(content: dict, block: str) -> dict | None:
for ch in content.get("chapters", []):
for s in ch.get("sections", []):
if s.get("title") == block:
return s
return None
async def block_pruefen(topic: str, format_name: str, block: str, spot: str, snippet: str, hint: str = "", provider: str = DEFAULT_PROVIDER) -> str | None:
"""Check one section (Markdown block) against the guide rules → corrected
block version as Markdown. None = error/section missing."""
content = await _load_guide_content(topic, format_name)
sec = _find_section(content, block) if content else None
if sec is None:
return None
whole = sec.get("compact", "") if str(spot).startswith("compact") else sec.get("md", "")
prompt = _prompt(
"Block-Pruefen", topic=topic, spec=_section_spec(), facts=_section_facts(topic),
subblocks=await _subs_text(topic, block), context=whole, snippet=snippet, hint=_hint_block(hint),
)
rc, stdout, _ = await run_agent(
f"block-pruefen-{uuid.uuid4()}", prompt, SECTION_CHECK_TIMEOUT,
provider=provider, role="judge", capabilities="none", lane="interactive",
)
new = stdout.strip() if rc == 0 else ""
return new or None
async def block_adopt(topic: str, format_name: str, block: str, spot: str, old: str, new: str) -> dict | None:
"""Replace one block (old→new) in the compact/detailed field + persist.
{compact, md, found}; None = section missing."""
content = await _load_guide_content(topic, format_name)
sec = _find_section(content, block) if content else None
if sec is None:
return None
is_compact = str(spot).startswith("compact")
field = "compact" if is_compact else "md"
current = sec.get(field, "") or ""
found = old in current
if found:
sec[field] = current.replace(old, new, 1)
# Also replace in anchor + subs (sources of the filtered E/M/S view), otherwise the
# leveled view keeps showing the old block.
anchor_field = "anker_compact" if is_compact else "anchor"
if old in (sec.get(anchor_field) or ""):
sec[anchor_field] = sec[anchor_field].replace(old, new, 1)
for sub in sec.get("subs", []):
if old in (sub.get(field, "") or ""):
sub[field] = sub[field].replace(old, new, 1)
break
js = json.dumps(content, ensure_ascii=False)
await set_guide_content(topic, format_name, js)
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}
# --- Tutor chat (moved from the removed elements module) ---
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
transcript = "\n".join(
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
for m in messages
)
return _prompt(
"Chat",
topic=topic, format_name=format_name,
outline_block=outline.strip() or "(none)",
section_block=section.strip() or "(no section detected)",
transcript=transcript,
)
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
returncode, stdout, stderr = await run_agent(
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return "Sorry, that didn't work. Please try again."
reply = stdout.strip()
return reply or "Sorry, I didn't get a response."
except Exception:
log.warning("[%s] Guide chat failed", topic, exc_info=True)
return "Sorry, that didn't work. Please try again."

811
backend/guide_board.py Normal file
View File

@@ -0,0 +1,811 @@
"""Board 3 „Guide": one card per block, linear stages with gates between them.
lernziele judge-Rolle Backward Design — objectives BEFORE writing
zuweisung code chapter/order from the outline artefact + facts grounding
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
pruefer judge-Rolle EIN Call: CoVe-Fakten + Coverage + Lesbarkeit (lasen vorher
denselben Text in 3 seriellen Calls) + deterministische Gates
fix guide-Rolle EIN Rewrite unter allen Auflagen; bei falsch/Lücken danach
genau ein Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
needed); stage transitions are persisted in guide_cards, so the board is live and
cancel/resume just picks cards up at their stored stage. Assembly keeps the exact
legacy content format → content_fuer_level / TopicDetail stay untouched.
"""
import asyncio
import json
import logging
import re
import database as db
import readability
from blocks import _sink_json
from config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
from guide_qa import block_budget
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
_timeout, is_guide_cancelled, run_single_slot)
from textkit import _norm_title, _parse_fragment, _title
log = logging.getLogger("creator.guide_board")
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "pruefer", "fix")
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
"pruefer": "Prüfen", "fix": "Fix", "done": "Fertig"}
from config import GATE_FIX_MIN, WRITER_SPLIT_SUBS # zentral tunebar
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
def _safe(norm: str) -> str:
return re.sub(r"\W+", "_", norm)[:50] or "block"
def _ziele_schema(data):
"""{"ziele":[{id,text,sub}]} → list of dicts · None on invalid structure."""
if not isinstance(data, dict) or not isinstance(data.get("ziele"), list) or not data["ziele"]:
return None
out, seen = [], set()
for z in data["ziele"]:
if not isinstance(z, dict):
return None
zid = str(z.get("id", "")).strip()
text = str(z.get("text", "")).strip()
if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
continue
seen.add(zid)
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
return out or None
def _gate_schema(data):
"""{"ok":true} → [] · {"claims":[{text,grund,urteil}]} → list · None invalid.
urteil "falsch" (contradicts the facts/itself) vs "unbelegt" (true but underivable) —
default unbelegt. Entries whose grund starts with "belegt" are dropped: one judge
returned a 65-entry full inventory including SUPPORTED claims."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
claims = data.get("claims")
if not isinstance(claims, list) or not claims:
return None
out = []
for c in claims:
if isinstance(c, dict) and str(c.get("text", "")).strip():
grund = str(c.get("grund", "")).strip()
if grund.casefold().startswith("belegt"):
continue
urteil = str(c.get("urteil", "")).strip().casefold()
out.append({"text": str(c["text"]).strip(), "grund": grund,
"urteil": urteil if urteil == "falsch" else "unbelegt"})
return out
def _pruefer_schema(data, ziel_ids: set[str]):
"""Verschmolzenes Prüfer-Verdikt: Claims (Fakten-Gate-Semantik via _gate_schema) +
Coverage (ziele/luecken/ballast) + Lesbarkeit (lese_probleme). {"ok":true} = leeres
Verdikt. `ziele` muss alle ids abdecken, wenn Ziele existieren — sonst optional."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
if not any(k in data for k in ("claims", "ziele", "luecken", "ballast", "lese_probleme")):
return None
claims = _gate_schema({"claims": data["claims"]}) if data.get("claims") else []
if claims is None:
return None
ziele = {}
for k, v in (data.get("ziele") or {}).items() if isinstance(data.get("ziele"), dict) else []:
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
if ziel_ids and not ziel_ids <= set(ziele):
return None
luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
"lese_probleme": lese}
def _first_section(md: str) -> dict | None:
secs = _parse_fragment(md) if md else []
return secs[0] if secs else None
class _Env:
"""Shared per-run context for the card tasks."""
def __init__(self, ctx, guide_id, topic, format_name, instructions, content_path,
subs_by_title, chapter_map, fallback_facts, spec):
self.ctx = ctx
self.guide_id = guide_id
self.topic = topic
self.format = format_name
self.instructions = instructions
self.content_path = content_path
self.subs_by_title = subs_by_title # block title → [sub dicts]
self.chapter_map = chapter_map # block_norm → (chapter title, ord)
self.fallback_facts = fallback_facts # generic source hint (legacy topics without facts)
self.spec = spec
def slot(self, name: str):
return self.content_path.parent / f"{self.content_path.stem}.{name}"
def _memo(env, attr: str) -> dict:
"""Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
23× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
laufen ihre Stages seriell → kein Race."""
d = env.__dict__.get(attr)
if d is None:
d = env.__dict__[attr] = {}
return d
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
cache = _memo(env, "_ziele_cache")
if block_norm not in cache:
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
return cache[block_norm]
def _ziele_text(ziele: list[dict]) -> str:
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
def _card_facts(env: _Env, block_title: str) -> str:
cache = _memo(env, "_facts_cache")
if block_title not in cache:
from guide import _facts_grounding # lazy: guide imports this module
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
cache[block_title] = grounding or env.fallback_facts
return cache[block_title]
async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
include_unmatched: bool = True) -> str:
"""Verified worked examples of the block as writer input, matched to `subs` via
sub_norm (a split half gets only its own). Rows whose sub does not match (generation
mismatch) go to the full writer / split part 1 so they never vanish silently."""
cache = _memo(env, "_example_rows")
if block_norm not in cache:
cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
rows = cache[block_norm]
if not rows:
return ""
wanted = {_norm_title(s["title"]) for s in subs}
out = []
for r in rows:
matched = r["sub_norm"] in wanted
if not matched and not include_unmatched:
continue
data = json.loads(r["data"]) if isinstance(r["data"], str) else (r["data"] or {})
steps = " ".join(f"{i}) {s}" for i, s in enumerate(data.get("steps") or [], 1))
where = (f"Subbaustein „{r['sub_title']}" if matched
else "Subbaustein unklar — dort einweben, wo es fachlich passt")
out.append(f"- {where}:\n Problem: {data.get('problem', '')}\n"
f" Schritte: {steps}\n Ergebnis: {data.get('result', '')}")
if not out:
return ""
return ("VERIFIED WORKED EXAMPLES (already fact-checked; each belongs to ONE subblock):\n"
+ "\n".join(out) + "\n"
"Weave each example into the ausführlich text of EXACTLY its subblock, right "
"after the concept it applies has been explained — as a short worked-through "
"passage (problem → steps → result recognizable, flowing prose or a compact "
"numbered list). Take all values and results over VERBATIM, never recompute "
"or alter them. NEVER put examples into the compact layer. Subblocks without "
"an example get none.")
def _card_assignment(env: _Env, card: dict) -> str:
from guide import _level_label
lines = [f"- {card['block']}"]
for s in env.subs_by_title.get(card["block"], []):
lines.append(f" [{_level_label(s)}] {s['title']}")
return "\n".join(lines)
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
# board_snapshot shows it as the info line while status == active.
_live_info: dict[tuple[str, str, str], str] = {}
def _live(env: _Env, card: dict, msg: str) -> None:
_live_info[(env.topic, env.format, card["block_norm"])] = msg
async def _set(env: _Env, card: dict, **fields):
card.update(fields)
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
# ── Stages ─────────────────────────────────────────────────────────────────────────
async def _stage_lernziele(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
if not await db.list_lernziele(env.topic, norm):
subs = "\n".join(f"- [{s.get('level', 'beginner')}] {s['title']}"
for s in env.subs_by_title.get(card["block"], [])) or "(keine)"
async def _versuch(suffix: str):
path = env.slot(f"ziele-{_safe(norm)}{suffix}.json")
return await run_single_slot(
env.ctx, f"Lernziele {card['block']}", key=f"{env.guide_id}-ziele-{_safe(norm)}{suffix}",
prompt=_prompt("Guide-Lernziele", topic=env.topic, block=card["block"],
subs=subs, facts=_card_facts(env, card["block"]),
out_path=path, extra=_extra(env.instructions)),
role="judge", capabilities="files",
payload=lambda result, p=path: _ziele_schema(_json_file(p)),
timeout=_timeout("lernziele", len(env.subs_by_title.get(card["block"], []))))
status, ziele = await _versuch("")
if status == CANCELLED:
return False
if status == FAILED:
await _set(env, card, status="error", gate_info="Lernziele ohne Ergebnis")
return False
if not ziele: # ein Ersatz-Versuch — leere Liste heißt: das Coverage-Gate läuft leer
status, ziele = await _versuch("-2")
if status == CANCELLED:
return False
if not isinstance(ziele, list):
ziele = []
if not ziele:
_log(env.topic, f"Lernziele {card['block']}: zweimal leer — Block ohne Coverage-Gate")
for z in ziele:
await db.put_lernziel(env.topic, norm, z["id"], z["text"], _norm_title(z["sub"]))
await _set(env, card, stage="zuweisung", status="open")
return True
async def _stage_zuweisung(env: _Env, card: dict) -> bool:
chapter, ord_ = env.chapter_map.get(card["block_norm"], ("Weitere Inhalte", 10_000))
await _set(env, card, chapter=chapter, ord=ord_, stage="writer")
return True
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
# halves and merged back into ONE canonical section (all gates/assembly read one section).
# WRITER_SPLIT_SUBS: siehe config.py
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
lines = []
if sec_a.get("chapters"):
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
lines.append(f"<!-- section: {sec_a['title']} -->")
lines.append("<!-- compact -->")
if sec_a.get("anker_compact"):
lines.append(sec_a["anker_compact"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("compact"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["compact"])
lines.append("<!-- ausführlich -->")
if sec_a.get("anchor"):
lines.append(sec_a["anchor"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("md"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["md"])
return "\n\n".join(lines)
async def _write_split(env: _Env, card: dict, ziele_text: str):
"""First draft in two halves (parallel), merged into one section.
→ merged text | None (failed) | False (cancelled)."""
from guide import _level_label
norm = card["block_norm"]
subs = env.subs_by_title.get(card["block"], [])
half = (len(subs) + 1) // 2
parts = (subs[:half], subs[half:])
hints = (
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
)
async def _one(i):
assignment = "\n".join([f"- {card['block']}"]
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
path.unlink(missing_ok=True)
def _payload(result, p=path):
t = p.read_text(encoding="utf-8") if p.exists() else ""
sec = _first_section(t)
return t if sec and sec.get("md", "").strip() else None
return await run_single_slot(
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte",
assignment=assignment, ziele=ziele_text,
facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, parts[i],
include_unmatched=(i == 0)),
gaps="\n" + hints[i] + "\n",
budget=block_budget(parts[i]),
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
results = await asyncio.gather(_one(0), _one(1))
if any(s == CANCELLED for s, _ in results):
return False
if any(s == FAILED for s, _ in results):
return None
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
ziele_text = _ziele_text(await _ziele(env, norm))
# oversized first drafts: two halves, merged into one canonical section
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
text = await _write_split(env, card, ziele_text)
if text is False:
return False
if text is None:
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
return False
await _set(env, card, md=text, stage="pruefer", status="open")
return True
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
path.unlink(missing_ok=True)
def _payload(result):
text = path.read_text(encoding="utf-8") if path.exists() else ""
sec = _first_section(text)
return text if sec and sec.get("md", "").strip() else None
status, text = await run_single_slot(
env.ctx, f"Writer {card['block']}", key=f"{env.guide_id}-w-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte",
assignment=_card_assignment(env, card), ziele=ziele_text,
facts=_card_facts(env, card["block"]),
examples=await _card_examples(env, norm, env.subs_by_title.get(card["block"], [])),
gaps="", spec=env.spec,
budget=block_budget(env.subs_by_title.get(card["block"], [])),
out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
if status == CANCELLED:
return False
if status == FAILED:
await _set(env, card, status="error", gate_info="Writer ohne Ergebnis")
return False
await _set(env, card, md=text, stage="pruefer", status="open")
return True
def _n_rel(env: _Env, card: dict) -> int:
return sum(1 for s in env.subs_by_title.get(card["block"], [])
if s.get("relevance") != "peripheral")
def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
"""Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell +
Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit
engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix
und als „nicht wiederholen"-Notiz in den Prüfer-Prompt."""
out: list[str] = []
subs_all = env.subs_by_title.get(card["block"], [])
if not any(s.get("relevance") != "peripheral" for s in subs_all):
return out # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor)
budget = block_budget(subs_all)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
lo, hi = FIX_LAENGE_BAND
if not (lo * budget <= zeichen <= hi * budget):
out.append(
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}{round(hi * budget)}): "
f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
return out
async def _det_readability(sec: dict) -> list[str]:
if not READABILITY_ACTIVE:
return []
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
return [hints[1]] if hints.get(1) else []
def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]:
"""Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken
(nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern).
Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen
sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig)."""
claims = verdict["claims"]
falsch = [c for c in claims if c["urteil"] == "falsch"]
schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN
if claims and not falsch and len(claims) < schwelle:
claims = []
zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f"{c['grund']}" if c['grund'] else "")
for c in claims]
zeilen += [f"- LÜCKE ({l['ziel']}): {l['fehlt']}" for l in verdict["luecken"]]
zeilen += [f"- BALLAST (kürzen): {b}" for b in verdict["ballast"]]
zeilen += [f"- LESBARKEIT: {p}" for p in verdict["lese_probleme"]]
zeilen += [f"- LESBARKEIT: {p}" for p in det]
return zeilen, bool(falsch or verdict["luecken"])
async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[str]) -> dict | None:
"""EIN Judge-Call prüft Fakten + Coverage + Lesbarkeit (die drei lasen vorher denselben
Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
norm = card["block_norm"]
ziele = await _ziele(env, norm)
ziele_text = _ziele_text(ziele)
ids = {z["ziel_id"] for z in ziele}
facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
if ex: # der Fix sieht dieselben Facts — Beispiele überleben den Fix-Pass
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
hinweise = ("\nALREADY NOTED deterministically (do NOT repeat, they go to the fix anyway):\n"
+ "\n".join(f"- {d}" for d in det) + "\n") if det else "\n"
path = env.slot(f"pruefer-{_safe(norm)}-{tag}.json")
status, verdict = await run_single_slot(
env.ctx, f"Prüfer {card['block']}", key=f"{env.guide_id}-pruef-{_safe(norm)}-{tag}",
prompt=_prompt("Guide-Pruefer", topic=env.topic, block=card["block"],
section=sec["md"], facts=facts, ziele=ziele_text, spec=env.spec,
hinweise=hinweise, extra=_extra(env.instructions)),
role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
timeout=_timeout("pruefer", 1))
if status != OK or verdict is None:
return None
for zid, ok in verdict["ziele"].items():
if zid in ids:
await db.set_ziel_covered(env.topic, norm, zid, ok)
return verdict
async def _stage_pruefer(env: _Env, card: dict) -> bool:
"""Verschmolzener Qualitäts-Pass: Fakten-Gate + Coverage + Lese-Check in EINEM Call
(vorher 3 serielle Judges + bis zu 3 Edit-Pässe, die einander überschrieben und deren
letzter ungeprüft blieb). Befunde → Fix-Stage; ohne Befund → done."""
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
return False
det = (await _det_readability(sec)) + _det_hinweise(env, card, sec)
verdict = await _pruefer_call(env, card, sec, f"r{card['writer_rounds']}", det)
if verdict is None:
if is_guide_cancelled(env.guide_id):
return False
# fail-open wie das alte Gate: Karte nie blockieren — deterministische Befunde
# gehen trotzdem in den Fix
_log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks")
verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card))
if not zeilen:
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
return True
_log(env.topic, f"Prüfer {card['block']}: {len(zeilen)} Befund(e){' (kritisch)' if kritisch else ''} → Fix")
await _set(env, card, gate_info=("KRITISCH\n" if kritisch else "") + "\n".join(zeilen),
stage="fix", status="open")
return True
async def _stage_fix(env: _Env, card: dict) -> bool:
"""EIN kompletter Section-Rewrite unter allen Auflagen (ersetzt Fakten-Fix +
Writer-Revision + Lese-Fix). Danach GENAU EIN Re-Prüfer-Pass, wenn der Fix wegen
falsch-Claims/Lücken lief — der alte Lese-Fix blieb ungeprüft. Rest-Befunde bleiben
sichtbar (gate_info), keine weitere Fix-Runde."""
from guide import _level_label
norm = card["block_norm"]
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Fragment unlesbar")
return False
info = card.get("gate_info") or ""
kritisch = info.startswith("KRITISCH\n")
auftraege = info.removeprefix("KRITISCH\n")
subs = env.subs_by_title.get(card["block"], [])
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
fixp.unlink(missing_ok=True)
def _fixload(result):
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
return text if _first_section(text) else None
fstatus, fixed = await run_single_slot(
env.ctx, f"Fix {card['block']}", key=f"{env.guide_id}-gfix-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fix", topic=env.topic, format_name=env.format, block=card["block"],
section=card["md"], facts=_card_facts(env, card["block"]), spec=env.spec,
auftraege=auftraege, sub_list=sub_list, out_path=fixp,
extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload,
timeout=_timeout("writer", 1))
if fstatus == CANCELLED:
return False
angewandt = False
if fstatus == OK and fixed:
new_sec = _first_section(fixed)
# marker invariant: a fix that loses the sub markers kills the level filter → discard
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
_log(env.topic, f"Fix {card['block']} ohne Sub-Marker — verworfen")
else:
card["md"] = fixed
angewandt = True
rest = ""
if not angewandt:
# Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
_log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
if kritisch and angewandt:
sec2 = _first_section(card["md"])
verdict = await _pruefer_call(env, card, sec2, "re", [])
if verdict is None and is_guide_cancelled(env.guide_id):
return False
if verdict is None:
rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
_log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
else:
zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
if zeilen:
rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
_log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben")
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info=rest)
return True
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
async with sem:
try:
await _run_card_inner(env, card)
finally:
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
async def _run_card_inner(env: _Env, card: dict) -> None:
while card["stage"] != "done":
if is_guide_cancelled(env.guide_id):
await _set(env, card, status="open") # no longer being worked
return
fn = _STAGE_FN.get(card["stage"])
if fn is None: # unknown stage → park as error
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
return
if card["status"] != "active":
await _set(env, card, status="active") # live board: this card is being worked
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "")
try:
if not await fn(env, card):
return
except Exception as e:
log.exception("[%s] guide card %s failed", env.topic, card["block"])
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
return
# ── Orchestration ──────────────────────────────────────────────────────────────────
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
"""Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
(der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
zuletzt = None
while True:
try:
counts = await db.guide_stage_counts(topic, format_name)
stand = (counts.get("done", 0), sum(counts.values()))
if stand != zuletzt:
zuletzt = stand
await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
except Exception:
log.exception("[%s] guide progress reporter", topic)
await asyncio.sleep(takt)
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
"""block_norm → (chapter title, global order) from the outline artefact."""
from guide import _outline_from_db, _fallback_outline, _with_remainder
plan = await _outline_from_db(topic, entries) or _fallback_outline(entries)
plan = _with_remainder(plan, entries)
out: dict[str, tuple[str, int]] = {}
i = 0
for ch in plan:
for num in ch.get("nums", []):
if num in entries:
out[_norm_title(_title(entries[num]))] = (ch.get("title") or "Kapitel", i)
i += 1
return out
async def run_guide_board(guide_id: str, topic: str, format_name: str, entries: dict[int, str],
instructions: str, provider: str, content_path) -> list[dict] | None:
"""Seed one card per block (existing cards keep their stage — resume), run all cards,
assemble the chapters in the legacy content format. → chapters | None (cancel/empty)."""
from blocks import source_folder
from guide import _load_subblocks
ctx = GenContext(topic=topic, provider=provider,
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
import uuid
from datetime import datetime, timezone
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
try:
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
subs_raw = await _load_subblocks(topic)
project = source_folder(topic)
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
else _prompt("Guide-Facts-Thema"))
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
subs_raw, await _chapter_map(topic, entries), fallback, spec)
await db.upsert_guide_cards_many(
topic, format_name,
[(_norm_title(_title(line)), _title(line)) for line in entries.values()])
cards = await db.list_guide_cards(topic, format_name)
open_cards = [c for c in cards if c["stage"] != "done"]
if open_cards:
sem = asyncio.Semaphore(CARD_CONCURRENCY)
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
try:
ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
return_exceptions=True)
for c, r in zip(open_cards, ergebnisse):
if isinstance(r, BaseException):
log.error("[%s] guide card task %s: %r", topic, c["block"], r)
finally:
reporter.cancel()
if is_guide_cancelled(guide_id):
return None
# assembly — identical shape to the legacy pipeline
cards = await db.list_guide_cards(topic, format_name)
chapters: list[dict] = []
by_chapter: dict[str, list[dict]] = {}
order: list[str] = []
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
if c["stage"] != "done":
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
continue
sec = _first_section(c["md"])
if sec is None:
continue
ch = c["chapter"] or "Inhalte"
if ch not in by_chapter:
by_chapter[ch] = []
order.append(ch)
by_chapter[ch].append({
"num": c["ord"], "title": c["block"], "md": sec["md"],
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
"checkable": format_name == "Guide" or bool(
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
})
for ch in order:
chapters.append({"title": ch, "sections": by_chapter[ch]})
if chapters:
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
import guide_qa
rep = await guide_qa.guide_qa_report(topic, llm=True)
if rep:
await asyncio.to_thread(guide_qa._write_report, rep)
except Exception:
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
return chapters or None
finally:
# erst NACH der Abschluss-Guide-QA leeren: deren Judge-Events gehören zum
# Lauf — vorher fielen sie ohne run_id aus jeder Run-Aggregation
db.set_current_run(topic, None)
async def done_step(topic: str, format_name: str) -> int:
"""Sidebar dots: highest fully completed stage index. -1 = nothing, len(stages) at done."""
counts = await db.guide_stage_counts(topic, format_name)
if not counts:
return -1
if set(counts) == {"done"}:
return len(GUIDE_STAGES)
lowest = min(GUIDE_STAGES.index(s) for s in counts if s in GUIDE_STAGES)
return lowest - 1 if lowest > 0 else -1
async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
"""Live guide board: columns with counts + cards (title, rounds, covered objectives)."""
cards = await db.list_guide_cards(topic, format_name)
ziele = {}
for z in await db.list_lernziele(topic):
d = ziele.setdefault(z["block_norm"], [0, 0])
d[1] += 1
d[0] += 1 if z["covered"] else 0
columns = []
for stage in (*GUIDE_STAGES, "done"):
in_stage = [c for c in cards if c["stage"] == stage]
views = []
for c in in_stage[:limit]:
zc = ziele.get(c["block_norm"])
info = c["gate_info"][:200] if c["status"] == "error" else ""
if c["status"] == "active":
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
views.append({"title": c["block"], "card_id": c["block_norm"],
"status": c["status"] if c["status"] in ("error", "active") else "open",
"rounds": c["writer_rounds"],
"info": info,
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
columns.append({"key": stage, "label": STAGE_LABELS[stage],
"total": len(in_stage), "cards": views})
import qa as qa_mod # lazy wie in board_inventory
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
return {"columns": columns, "qa_guide": note_guide}
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
import qa as qa_mod
from guide_qa import block_budget, LAENGE_BAND
reports = qa_mod.report_paths(topic, guide=True)
rep = _json_file(reports[-1]) if reports else None
if not rep:
return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
kritisch: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
kritisch.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
kritisch.add(str(e).split(" · ")[0])
for t in rep.get("fachlich_falsch", []) or []:
kritisch.add(_norm_title(str(t)))
stil: dict[str, list[str]] = {}
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
a, b = str(e.get("a", "")), str(e.get("b", ""))
bn = _norm_title(b.split(":")[0])
auszug = b.split(":", 1)[1].strip() if ":" in b else b
stil.setdefault(bn, []).append(
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}\") — "
"hier straffen, der Inhalt bleibt im anderen Block")
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
if not isinstance(e, dict):
continue
bn = _norm_title(e.get("block", ""))
budget = e.get("budget") or block_budget([])
lo, hi = LAENGE_BAND
stil.setdefault(bn, []).append(
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
f"{round(lo * budget)}{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
"alle Sub-Marker und Beispiele behalten")
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
bn = _norm_title(str(e).split(":")[0])
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
betroffen = []
for n in sorted((kritisch | set(stil)) & set(cards)):
if n in kritisch:
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
else:
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
gate_info="\n".join(stil[n]))
betroffen.append(cards[n]["block"])
return betroffen

245
backend/guide_qa.py Normal file
View File

@@ -0,0 +1,245 @@
"""Unabhängiges Guide-Audit über einen FERTIGEN Guide — read-only.
Misst den gebauten Guide (guide_cards) gegen Lernziele und Sub-Satz mit Detektoren,
die bewusst NICHT die Pipeline-Gates wiederverwenden (covered-Flag, Fakten-Gate) —
geteilte blinde Flecken machen das Audit wertlos. Geteilt nur Infra: DB, readability,
Agent-Runner (--llm), Note-Formel aus qa.py.
CLI: python3 guide_qa.py <topic> [--llm] (oder: make qa-guide TOPIC=<topic> [LLM=1])
Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
"""
import asyncio
import logging
import re
import sys
from datetime import datetime, timezone
import database as db
import qa
import readability
from fsutil import atomic_write_json
from textkit import _norm_title, parse_facts
log = logging.getLogger("creator.guide_qa")
JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten
ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal
LLM_SECTION_CHARS = 2500 # Section-Auszug je Judge-Item
# fachliche Fehler wiegen am schwersten; Anker-lose Ziele = Coverage-Behauptung ohne Text.
NOTE_GEWICHTE_GUIDE = {"fachlich_falsch": 3.0, "ziel_ohne_anker": 2.0, "marker_fehlend": 1.5,
"redundanz": 1.0, "laengen_ausreisser": 0.5, "lesbarkeit": 0.5}
_MARKER = re.compile(r"<!--\s*sub:\s*\w+\s*\|\s*(.*?)\s*-->")
def _mnorm(s: str) -> str:
"""Marker-/Sub-Norm ohne Backslashes — escapte Titel (`h\\~2\\~o`) erzeugten
falsch-positive „Marker fehlt"-Befunde, weil Writer und DB verschieden escapen."""
return _norm_title(s.replace("\\", ""))
def _ausfuehrlich(md: str) -> str:
"""Der Lern-Fließtext einer Karte (hinter dem ausführlich-Marker, sonst alles)."""
teile = re.split(r"<!--\s*ausführlich\s*-->", md or "", maxsplit=1)
return teile[1] if len(teile) == 2 else (md or "")
def marker_fehlend(cards: list[dict], subs_rel: dict[str, set]) -> list[str]:
"""Relevante Subs ohne Sub-Marker in der Section — der Level-Filter verliert sie."""
out = []
for c in cards:
marker = {_mnorm(m) for m in _MARKER.findall(c["md"] or "")}
for sn in sorted(subs_rel.get(c["block_norm"], set())):
mn = _mnorm(sn)
if mn not in marker and not any(m.startswith(mn) or mn.startswith(m) for m in marker):
out.append(f"{c['block']} · {sn}")
return out
def ziel_ohne_anker(cards: list[dict], ziele: list[dict]) -> list[str]:
"""Lernziele, deren distinktive Tokens im Section-Text fehlen — eigener Anker-Check,
NICHT das covered-Flag der Pipeline (das hat der Coverage-Judge selbst gesetzt)."""
text_by_norm = {c["block_norm"]: qa._tokens(_ausfuehrlich(c["md"])) for c in cards}
out = []
for z in ziele:
toks = qa._distinctive(z["text"])
st = text_by_norm.get(z["block_norm"])
if st is None or not toks:
continue
if len(toks & st) < min(2, len(toks)):
out.append(f"{z['block_norm']} · ({z['ziel_id']}) {z['text'][:60]}")
return out
# Längenbudget je Sub aus der Inventar-Substanz — ersetzt den festen Rahmen 1501200/Sub:
# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler.
# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem
# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar.
BUDGET_BASIS = 140 # Einstieg/Übergang je Sub
BUDGET_KEY_POINT = 110 # ~1 Satz Erklärung je key_point
BUDGET_FAKT = 45 # zitierter Fakt, in den Text eingewoben
BUDGET_BEISPIEL = 170 # knappes Beispiel
LAENGE_BAND = (0.35, 1.5) # QA-Toleranz um das Blockbudget
def sub_budget(facts: dict) -> int:
"""Zeichenbudget für den ausführlich-Teil EINES Subs (facts = Inventar-JSON des Subs)."""
kp = len(facts.get("key_points") or [])
cf = len(facts.get("cited_facts") or [])
ex = 1 if str(facts.get("example_idea") or "").strip() else 0
return BUDGET_BASIS + BUDGET_KEY_POINT * kp + BUDGET_FAKT * cf + BUDGET_BEISPIEL * ex
def block_budget(subs: list[dict]) -> int:
"""Budget einer Section: Summe über die relevanten Subs ({relevance, facts}-Dicts)."""
return max(BUDGET_BASIS, sum(sub_budget(s.get("facts") or {}) for s in subs
if s.get("relevance") != "peripheral"))
def laengen_ausreisser(cards: list[dict], budget_by_norm: dict[str, int]) -> list[dict]:
out = []
for c in cards:
budget = budget_by_norm.get(c["block_norm"])
if not budget:
continue
zeichen = len(_ausfuehrlich(c["md"]))
if not (LAENGE_BAND[0] * budget <= zeichen <= LAENGE_BAND[1] * budget):
out.append({"block": c["block"], "zeichen": zeichen, "budget": budget})
return out
def redundanz(cards: list[dict]) -> list[dict]:
"""Absatz-Paare topic-weit mit hoher Token-Überlappung — derselbe Stoff doppelt erklärt."""
absaetze = []
for c in cards:
for a in _ausfuehrlich(c["md"]).split("\n\n"):
a = a.strip()
if len(a) >= ABSATZ_MIN_CHARS:
absaetze.append((c["block"], a, qa._tokens(a)))
out = []
for i in range(len(absaetze)):
for j in range(i + 1, len(absaetze)):
if qa._jaccard(absaetze[i][2], absaetze[j][2]) >= JACCARD_ABSATZ:
out.append({"a": f"{absaetze[i][0]}: {absaetze[i][1][:60]}",
"b": f"{absaetze[j][0]}: {absaetze[j][1][:60]}"})
return out
def lesbarkeit(cards: list[dict]) -> list[str]:
"""Deterministisches externes Rating; Modell nicht ladbar → nicht gemessen (zählt nicht)."""
try:
hints = readability.rate_sections({i: _ausfuehrlich(c["md"]) for i, c in enumerate(cards, 1)})
except Exception:
return []
return [f"{cards[i - 1]['block']}: {h}" for i, h in sorted(hints.items()) if h]
async def _fachlich_falsch(topic: str, cards: list[dict]) -> list[str]:
"""LLM-Stichprobe: Section enthält eine fachlich falsche Aussage? Zwei unabhängige
Durchgänge, nur DOPPELT bestätigte zählen — ein Einzel-Judge schwankte zwischen
0 und 5 Befunden am selben Guide und kippte die Note (Gewicht 3.0) auf 0."""
async def _pass(kandidaten: list[dict], tag: str) -> list[str]:
items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten]
v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items,
chunk=5, prefix="qa-guide", label="Guide-QA")
return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"]
verdacht = await _pass(cards, "")
if not verdacht:
return []
# ZWEI unabhängige Bestätiger, beide müssen zustimmen — mit nur einem sprang die
# Note desselben Guides weiter zwischen 2.0 und 6.6 (ein Zufalls-ja kostet 1.5 Punkte)
kandidaten = [c for c in cards if c["block"] in set(verdacht)]
b1 = set(await _pass(kandidaten, "-2"))
if not b1:
return []
b2 = set(await _pass([c for c in kandidaten if c["block"] in b1], "-3"))
return [b for b in verdacht if b in b1 and b in b2]
async def guide_qa_report(topic: str, llm: bool = False) -> dict | None:
cards = [dict(r) for r in await db.list_guide_cards(topic)]
cards = [c for c in cards if (c.get("md") or "").strip()]
if not cards:
print(f"Keine Guide-Karten für '{topic}' — Guide noch nicht gebaut?")
return None
subs_rel: dict[str, set] = {}
subs_by_norm: dict[str, list[dict]] = {}
for r in await db.list_subblocks(topic):
if r["status"] != "consensus":
continue
subs_by_norm.setdefault(r["block_norm"], []).append(
{"relevance": r["relevance"], "facts": parse_facts(r["facts"])})
if r["relevance"] != "peripheral":
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
ziele = [dict(r) for r in await db.list_lernziele(topic)]
mf = marker_fehlend(cards, subs_rel)
za = ziel_ohne_anker(cards, ziele)
la = laengen_ausreisser(cards, {n: block_budget(s) for n, s in subs_by_norm.items()})
rd = redundanz(cards)
lb = lesbarkeit(cards)
falsch = await _fachlich_falsch(topic, cards) if llm else None
n_subs = max(sum(len(s) for s in subs_rel.values()), 1)
n_abs = max(sum(len([a for a in _ausfuehrlich(c["md"]).split("\n\n") if len(a.strip()) >= ABSATZ_MIN_CHARS])
for c in cards), 1)
quoten = {
"marker_fehlend": round(len(mf) / n_subs, 3),
"ziel_ohne_anker": round(len(za) / max(len(ziele), 1), 3),
"laengen_ausreisser": round(len(la) / len(cards), 3),
"redundanz": round(len(rd) / n_abs, 3),
"lesbarkeit": round(len(lb) / len(cards), 3),
**({"fachlich_falsch": round(len(falsch) / len(cards), 3)} if falsch is not None else {}),
}
report = {
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(), "art": "guide",
"bloecke": len(cards), "ziele": len(ziele),
"quoten": quoten, "note_guide": qa.note(quoten, NOTE_GEWICHTE_GUIDE),
"marker_fehlend": mf, "ziel_ohne_anker": za, "laengen_ausreisser": la,
"redundanz": rd[:20], "lesbarkeit": lb,
**({"fachlich_falsch": falsch} if falsch is not None else {}),
"note_gewichte": NOTE_GEWICHTE_GUIDE,
}
return report
def _write_report(report: dict):
tdir = qa.QA_DIR / report["topic"]
tdir.mkdir(parents=True, exist_ok=True)
path = tdir / f"guide-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.json"
atomic_write_json(path, report, indent=1)
return path
def _digest(report: dict, path):
print(f"Guide-QA {report['topic']}{report['bloecke']} Sections, {report['ziele']} Ziele"
f" — Note {report['note_guide']}/10")
for k, v in report["quoten"].items():
print(f" {k:20} {v:6.1%}")
for k in ("marker_fehlend", "ziel_ohne_anker", "lesbarkeit", "fachlich_falsch"):
for x in report.get(k, [])[:5]:
print(f" {k.upper():16} {str(x)[:90]}")
for p in report.get("redundanz", [])[:5]:
print(f" DOPPELT? {p['a'][:55]} <-> {p['b'][:55]}")
print(f"Report: {path}")
async def main(topic: str, llm: bool):
await db.init_db()
try:
report = await guide_qa_report(topic, llm=llm)
if report is None:
sys.exit(1)
_digest(report, _write_report(report))
finally:
await db.close_db()
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print("Nutzung: python3 guide_qa.py <topic> [--llm]")
sys.exit(1)
asyncio.run(main(args[0], "--llm" in sys.argv))

96
backend/invarianten.py Normal file
View File

@@ -0,0 +1,96 @@
"""Invarianten nach einem (Fake-)E2E-Lauf: was IMMER gelten muss, egal welches Szenario.
Nutzt bewusst eigene, schlichte Prüfungen statt Pipeline-Heuristiken (Muster qa.py) —
geteilte blinde Flecken machen den Check wertlos. Rückgabe: Liste von Verstößen,
leer = alles konsistent.
"""
import json
import database as db
from textkit import _norm_title
_LEVELS_OK = {"beginner", "advanced", "expert"}
_RELEVANZ_OK = {"relevant", "peripheral"}
def _json(path):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
async def pruefe_invarianten(topic: str, files: dict | None = None,
mit_artefakten: bool = True) -> list[str]:
fehler: list[str] = []
subs = [dict(r) for r in await db.list_subblocks(topic)]
cons = [r for r in subs if r["status"] == "consensus"]
for r in cons:
wo = f"{r['block']}/{r['sub_title']}"
fk = None
try:
fk = json.loads(r["facts"]) if r["facts"] else None
except ValueError:
fehler.append(f"facts unparsebar: {wo}")
if not (isinstance(fk, dict) and (fk.get("key_points") or fk.get("cited_facts"))):
fehler.append(f"consensus-Sub ohne facts: {wo}")
if r["level"] not in _LEVELS_OK:
fehler.append(f"consensus-Sub ohne gültiges level: {wo}")
if r["relevance"] not in _RELEVANZ_OK:
fehler.append(f"consensus-Sub ohne relevance: {wo}")
if mit_artefakten:
art = [dict(r) for r in await db.get_sub_artefakte(topic)]
fragen = [dict(r) for r in await db.list_question_pattern(topic)]
versorgt = {(r["block_norm"], r["sub_norm"]) for r in art}
versorgt |= {(r["block_norm"], r["sub_norm"]) for r in fragen}
lebend = {(r["block_norm"], r["sub_norm"]) for r in subs if r["status"] in ("consensus", "variant")}
for r in cons:
if r["relevance"] == "relevant" and (r["block_norm"], r["sub_norm"]) not in versorgt:
fehler.append(f"relevanter Sub ohne Frage/Artefakt: {r['block']}/{r['sub_title']}")
for bn, sn in sorted({(r["block_norm"], r["sub_norm"]) for r in art} |
{(r["block_norm"], r["sub_norm"]) for r in fragen}):
if (bn, sn) not in lebend:
fehler.append(f"Waise (Ziel-Sub existiert nicht): {bn}/{sn}")
# keine hängengebliebenen Karten
for c in await db.kanban_cards(topic):
if c["stage"] == "dead":
fehler.append(f"dead-Karte: {c['board']}/{c['card_id']}")
if files is not None:
if not files["final"].exists():
fehler.append("blocks.md fehlt")
sc = _json(files["sidecar"])
if not isinstance(sc, dict):
fehler.append("sidecar-Datei fehlt/unparsebar")
else: # Sidecar und DB-consensus müssen dieselbe Sub-Menge tragen
db_menge = {(r["block_norm"], r["sub_norm"]) for r in cons}
sc_menge = {(_norm_title(bt), _norm_title(str(s.get("title", ""))))
for bt, ss in sc.items() for s in ss if isinstance(s, dict)}
for extra in sorted(sc_menge - db_menge):
fehler.append(f"Sidecar-Sub fehlt in DB: {extra}")
for extra in sorted(db_menge - sc_menge):
fehler.append(f"DB-consensus fehlt im Sidecar: {extra}")
return fehler
async def pruefe_guide_invarianten(topic: str, format_name: str = "Guide") -> list[str]:
"""Jeder relevante consensus-Sub trägt einen Sub-Marker im Guide (Muster
guide_qa.marker_fehlend, ohne LLM)."""
import guide_qa
fehler: list[str] = []
cards = [dict(r) for r in await db.list_guide_cards(topic, format_name)]
if not cards:
return ["keine Guide-Karten"]
for c in cards:
if c["status"] != "ok" or not (c.get("md") or "").strip():
fehler.append(f"Guide-Karte nicht ok: {c['block']} ({c['status']})")
subs_rel: dict[str, set] = {}
for r in await db.list_subblocks(topic):
if r["status"] == "consensus" and r["relevance"] != "peripheral":
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
fehler += [f"Sub-Marker fehlt: {m}" for m in guide_qa.marker_fehlend(cards, subs_rel)]
return fehler

View File

@@ -1,8 +1,8 @@
"""Toleranter JSON-Parser für KI-Output — als Text oder aus Dateien.
"""Tolerant JSON parser for AI output — from text or from files.
Verkraftet Code-Fences, Drumherum-Text und unescapte Anführungszeichen in
Strings (z. B. MiniMax: "Titel „p" geändert"): das letzte `"` vor der
Fehlerstelle wird escapet und erneut geparst.
Copes with code fences, surrounding prose and unescaped quotes inside
strings (e.g. MiniMax: "Title „p" changed"): the last `"` before the
error position is escaped and parsing is retried.
"""
import json
@@ -14,7 +14,7 @@ log = logging.getLogger("creator.jsonio")
def parse_json_text(text: str):
"""Parst JSON aus KI-Output; None bei nicht reparierbarem Input."""
"""Parse JSON from AI output; None for input that can't be repaired."""
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
@@ -36,14 +36,14 @@ def parse_json_text(text: str):
def read_json_file(path: Path):
"""Liest eine JSON-Datei mit derselben Toleranz; None bei fehlend/ungültig."""
"""Read a JSON file with the same tolerance; None if missing/invalid."""
if not path.exists():
return None
try:
data = parse_json_text(path.read_text(encoding="utf-8"))
except Exception as e:
log.debug("JSON-Datei nicht lesbar: %s (%s)", path, e)
log.debug("JSON file not readable: %s (%s)", path, e)
return None
if data is None:
log.debug("JSON-Datei ungültig: %s", path)
log.debug("JSON file invalid: %s", path)
return data

269
backend/kanban.py Normal file
View File

@@ -0,0 +1,269 @@
"""Generic streaming kanban engine (no concrete stages — boards define those).
Each column is a worker that pulls cards from its input `stage` (the queue = kanban_cards rows
WHERE stage = <input>), processes up to KANBAN_BATCH at a time, and advances them. Streaming
columns run continuously; BARRIER columns start only at QUIESCENCE of every stage before them
(no active worker + no queued card). SERIAL columns process one package at a time (their
processor mutates shared cross-card state).
Failure handling: a processor exception (including parse-fails it raises) sends the package's
unadvanced cards into exponential backoff (retries++, not_before); after MAX_CARD_RETRIES the
card goes to stage 'dead' (dead-letter — visible on the board, requeue-able via API). No card
is ever deleted by the engine.
Board definitions live in board_inventory.py / board_artefacts.py; run via run_flow().
"""
import asyncio
import logging
import database as db
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
from pipeline import AgentInfraError
log = logging.getLogger("creator.kanban")
# How many packages ONE worker keeps in flight at once. A worker no longer blocks on a single
# package — it keeps pulling and dispatching until this many run concurrently, so a busy column
# fills the agent slots (the per-topic semaphore is the real cap; over-dispatch just queues cheaply).
WORKER_INFLIGHT = MAX_CONCURRENT_AGENTS_PER_TOPIC
_POLL = 0.3 # seconds between empty-queue polls
# Live registry of running flows (topic → Flow), so routes can attach research agents,
# report `generating`, and cancel.
active_flows: dict[str, "Flow"] = {}
class Flow:
"""Shared runtime state of one topic run: active-task counters per stage + a wakeup event.
`producers` counts running research agents (initial + any added live); research counts as done
only when ALL producers have finished, so the flow stays awake while extras still search."""
def __init__(self, topic: str, work_dir=None):
self.topic = topic
self.work_dir = work_dir
self.active: dict[str, int] = {}
self.producers = 0
self.producer_tag = 0
self.stop = False
self.wake = asyncio.Event()
self.spawn_research = None # set by the board: () → coroutine adding one more research agent
self.state: dict = {} # board-private shared state (embedding caches, one-shot flags …)
self.active_cards: set[str] = set() # "board:card_id" currently inside a processor (live display)
@property
def research_done(self) -> bool:
return self.producers <= 0
def add_producer(self):
"""MUST be called synchronously BEFORE create_task of the producer — otherwise workers
can pass their exit check in the gap and never see the new producer (quiescence race)."""
self.producers += 1
self.wake.set()
def done_producer(self):
self.producers -= 1
self.wake.set()
def next_tag(self) -> int:
self.producer_tag += 1
return self.producer_tag
def enter(self, stage: str):
self.active[stage] = self.active.get(stage, 0) + 1
def leave(self, stage: str):
self.active[stage] = max(0, self.active.get(stage, 0) - 1)
self.wake.set()
def active_in(self, stages) -> bool:
return any(self.active.get(s, 0) > 0 for s in stages)
class Stage:
"""One column: board + stage name + processor. `upstream` (all stages before it, across
boards) is filled by chain_stages(). process(cards) gets the pulled package (list of card
dicts with decoded payload).
barrier: pull only when every upstream stage is quiescent (relational judgements need the
full set). gate: extra callable that must be truthy before the stage pulls (works without
barrier too — e.g. the consensus gate holds cards until research is done so late reader
votes still count). drain: pull the WHOLE queue as one package (global passes like the
fragment filter); implies serial."""
def __init__(self, board: str, stage: str, process, *, barrier: bool = False,
serial: bool = False, gate=None, drain: bool = False):
self.board = board
self.stage = stage
self.process = process
self.barrier = barrier
self.serial = serial or drain
self.gate = gate
self.drain = drain
self.upstream: list[str] = []
def chain_stages(stages: list[Stage]) -> list[Stage]:
"""Fill each stage's upstream = every stage listed before it (list order = flow order).
Producers are upstream of everything implicitly via flow.research_done."""
seen: list[str] = []
for s in stages:
s.upstream = list(seen)
seen.append(s.stage)
return stages
async def quiescent(flow: Flow, stages) -> bool:
"""True iff no worker is active in `stages` AND no card is queued in any of them.
The barrier/exit condition — must include QUEUED cards, not just active workers, or a worker
could exit in a momentary lull while an upstream worker still has work to push down."""
if not stages:
return True
if flow.active_in(stages):
return False
return await db.kanban_count(flow.topic, list(stages)) == 0
async def _sleep_wake(flow: Flow):
try:
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
except asyncio.TimeoutError:
pass
flow.wake.clear()
async def _fail_package(flow: Flow, spec: Stage, cards: list[dict], error: str):
"""Backoff/dead-letter for the cards the processor did NOT advance (their stage is unchanged —
advanced cards must not be punished for a failure after their move)."""
for c in cards:
cur = await db.kanban_get_card(flow.topic, spec.board, c["card_id"])
if cur is None or cur["stage"] != spec.stage:
continue
dead = await db.kanban_fail_card(flow.topic, spec.board, c["card_id"], error,
MAX_CARD_RETRIES, RETRY_BACKOFF)
if dead:
log.warning("kanban %s/%s: card %s → dead (%s)", flow.topic, spec.stage, c["card_id"], error)
async def _worker(flow: Flow, spec: Stage, inflight: int, all_stages: list[str]):
"""Pull cards from spec.stage, run spec.process — keeping up to `inflight` packages running
CONCURRENTLY so a busy column fills the agent slots. A barrier worker only pulls when upstream
is fully quiescent (and its gate, if any, is open). ANY worker exits only when research is
done and the WHOLE flow is quiescent — global instead of per-stage, so a downstream stage
that feeds cards back upstream (gap-check → ingest) never strands work. Double-checked over
one grace sleep (a producer attached in the lull keeps the flow alive).
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of
card-ids (held while a package runs) keeps concurrent pulls from grabbing the same cards."""
topic = flow.topic
claimed: set[str] = set()
tasks: set[asyncio.Task] = set()
batch = 100_000 if spec.drain else KANBAN_BATCH
async def _run(cards):
ids = [c["card_id"] for c in cards]
flow.enter(spec.stage)
flow.active_cards.update(f"{spec.board}:{i}" for i in ids)
try:
await spec.process(cards)
except AgentInfraError as e: # 429/Timeout erschöpft → Lauf pausieren, NICHT dead-letter
log.warning("kanban %s/%s: infra-pause: %s", topic, spec.stage, e)
flow.state["infra_paused"] = True
flow.state["infra_error"] = str(e)
flow.stop = True
flow.wake.set()
except Exception as e: # one bad package must not kill the worker → backoff/dead-letter
log.info("kanban %s/%s: %s: %s", topic, spec.stage, type(e).__name__, e)
try:
await _fail_package(flow, spec, cards, f"{type(e).__name__}: {e}")
except Exception:
log.exception("kanban %s/%s: fail-handling broke", topic, spec.stage)
finally:
flow.leave(spec.stage)
for i in ids:
claimed.discard(i)
flow.active_cards.discard(f"{spec.board}:{i}")
flow.wake.set()
async def _idle_exit() -> bool:
return (flow.research_done and not flow.active_in(all_stages)
and await db.kanban_count(topic, all_stages) == 0)
async def _may_pull() -> bool:
if spec.gate is not None and not spec.gate():
return False
if not spec.barrier:
return True
return await quiescent(flow, spec.upstream)
try:
while not flow.stop:
tasks = {t for t in tasks if not t.done()}
# Fill the pipeline: pull fresh cards and dispatch until `inflight` packages run.
if await _may_pull():
while len(tasks) < inflight:
rows = await db.kanban_pull(topic, spec.board, spec.stage, batch + len(claimed))
fresh = [r for r in rows if r["card_id"] not in claimed][:batch]
if not fresh:
break
for r in fresh:
claimed.add(r["card_id"])
tasks.add(asyncio.create_task(_run(list(fresh))))
if tasks: # busy → wait for a package to finish, then refill
await asyncio.wait(tasks, timeout=_POLL, return_when=asyncio.FIRST_COMPLETED)
continue
# idle: nothing in flight and nothing pulled
if await _idle_exit():
# Real grace sleep (NOT _sleep_wake — the wake event is usually already set
# by the last package and would collapse the window to 0ms). A producer
# attached during the lull flips research_done and keeps us alive.
await asyncio.sleep(_POLL)
if await _idle_exit():
return # nothing left and nothing upstream can produce
continue
await _sleep_wake(flow)
finally:
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def run_flow(flow: Flow, stages: list[Stage], producers=(), set_p=None) -> None:
"""Run producers + one worker per stage until global quiescence. `producers` are coroutines
already counted via flow.add_producer() BEFORE this call (quiescence race). Registers the
flow in active_flows for live attach/cancel."""
active_flows[flow.topic] = flow
names = [s.stage for s in stages]
def _spawn_workers():
return [asyncio.ensure_future(_worker(flow, s, 1 if s.serial else WORKER_INFLIGHT, names))
for s in stages]
workers = [asyncio.ensure_future(p) for p in producers] + _spawn_workers()
progress = asyncio.create_task(_progress(flow, set_p)) if set_p else None
try:
while True:
await asyncio.gather(*workers, return_exceptions=True)
# Restart round: a producer attached exactly as the workers exited (missed even the
# grace sleep) leaves live producers or queued cards behind → run the workers again.
if flow.stop or (flow.research_done and await quiescent(flow, names)):
break
workers = _spawn_workers()
finally:
flow.stop = True
if progress:
progress.cancel()
if active_flows.get(flow.topic) is flow:
active_flows.pop(flow.topic, None)
async def _progress(flow: Flow, set_p):
while not flow.stop:
try:
counts = await db.kanban_stage_counts(flow.topic)
total = sum(n for stages in counts.values() for n in stages.values())
set_p(f"Kanban: {total} Karten im Fluss")
except Exception:
pass
await asyncio.sleep(1.0)

672
backend/learning.py Normal file
View File

@@ -0,0 +1,672 @@
"""Block learning: deep-dive, block chat and exam for individual guide sections.
All calls are interactive (stdout response, lane "interactive") and stateless —
the chat/exam history comes from the frontend; only the exam counter (DB) and
the deep-dive (DB) are persisted.
"""
import logging
import random
import re
import uuid
from datetime import datetime, timezone
from agents import run_agent
from config import DEFAULT_PROVIDER
from database import get_block_hurdles
from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt, _problems_schema
from textkit import _norm_title
log = logging.getLogger("creator.learning")
# Learning levels per block — relative to the cap (floor as % of the max score):
# green=beginner 20% · blue=advanced 40% · purple=expert 60% · gold=master 100%.
# Exam form is always random (5 forms); the cap scales with the amount of material.
LEVELS = (("beginner", 0.2), ("advanced", 0.4), ("expert", 0.6), ("master", 1.0))
POINTS_BASE = 25 # Points per subblock. Master cap = (all subs) × 25.
# Leitner boxes for the flashcard practice deck: roughly doubling intervals cover
# session → day → week → month. Box 1 with interval 0 = a failed card stays due in
# the running session. Absolute UTC times, no day-boundary semantics (timezone-free).
LEITNER_INTERVALS = {1: 0, 2: 1, 3: 3, 4: 7, 5: 21} # days per box
LEITNER_MAX_BOX = 5
PRACTICE_NEW_PER_SESSION = 10 # new cards offered per deck fetch
def leitner_step(box: int | None, correct: bool) -> tuple[int, int]:
"""(new box, interval in days). New card + correct → box 2; wrong → box 1 (due now);
correct → one box up, capped at LEITNER_MAX_BOX."""
if not correct:
new = 1
elif box is None:
new = 2
else:
new = min(box + 1, LEITNER_MAX_BOX)
return new, LEITNER_INTERVALS[new]
def _levels(n_je_level: dict[int, int]) -> list[int]:
return [n_je_level.get(k, 0) for k in (1, 2, 3, 4)]
def thresholds(n_je_level: dict[int, int]) -> list[int]:
"""Cumulative sub-level thresholds [S_1, S_2, S_3, S_4] = (n_1+…+n_k) × 25.
S_k is the score at which sub-level k+1 unlocks; S_4 = cap_final."""
out, acc = [], 0
for n in _levels(n_je_level):
acc += n
out.append(acc * POINTS_BASE)
return out
def cap_final(n_je_level: dict[int, int]) -> int:
"""Max score (master) = all subblocks × 25."""
return thresholds(n_je_level)[-1]
def freie_level(score: int, n_je_level: dict[int, int]) -> int:
"""Highest unlocked sub-level 14. Level k+1 unlocks once score ≥ S_k.
Empty levels (n_k=0) are skipped automatically (S_k == S_{k-1})."""
s = thresholds(n_je_level)
e = 1
for k in range(3): # S_1..S_3 unlock levels 2..4
if score >= s[k]:
e = k + 2
return e
def cap_aktuell(score: int, n_je_level: dict[int, int]) -> int:
"""Reachable cap of the currently unlocked level = unlocked subs × 25."""
return thresholds(n_je_level)[freie_level(score, n_je_level) - 1]
def _threshold(p: float, cap: int) -> int:
return round(p * cap)
def level_from_score(score: int, cap_final_value: int) -> str | None:
"""Highest reached learning level (None below 20%), relative to cap_final."""
reached = None
for key, p in LEVELS:
if score >= _threshold(p, cap_final_value):
reached = key
return reached
def progressive_malus(basis: int, cap_akt: int) -> int:
"""Error penalty by progress within the current level (against cap_aktuell):
≤25%5 · ≤50%10 · ≤75%15 · >75%20."""
pct = (basis / cap_akt) if cap_akt else 0.0
if pct <= 0.25:
return -5
if pct <= 0.5:
return -10
if pct <= 0.75:
return -15
return -20
CHAT_TIMEOUT = 240
EXAM_TIMEOUT = 120 # short JSON turns; caps the serial latency per exam step
THOROUGH_TIMEOUT = 600 # "thorough check": strong model (role guide) takes longer
CRITIC_MAX_ROUNDS = 2 # Generator → Critic → maybe Regenerate, at most this many times
# Question types for active recall — one per question, chosen at random. Creates variety.
QUESTION_TYPES = {
"abruf": "Free Recall: have the learner explain the core idea freely from memory (open comprehension question).",
"punkt": "Cued Recall: ask for ONE specific detail or distinction.",
"warum": "Why-question: ask for the reason/mechanism — why does this work or hold?",
"anwendung": "Application: have the concept applied to ONE short, new example/scenario.",
"pruefen": "For code/tool topics: show a small snippet — predict the output OR find the bug. No code topic → an application question instead.",
}
# Answer tier → base points (new 25-scale). "barely" = 1 is only the signal for the
# progressive malus (the real value comes from progressive_malus). Positive values are
# modulated up on a streak and clamped to [10, 40].
TIERS = {
"unanswerable": 0, # question itself broken → no change
"barely": -1, # < 25% correct → malus
"partial": 0, # 2549% → neutral
"solid": 16, # 5074%
"strong": 24, # 7599% (quiz/gap hit)
"complete": 30, # 100% (only reachable by free explanation)
}
# Order weak→strong (for the follow-up cap).
_TIER_RANK = ("barely", "partial", "solid", "strong", "complete")
def cap_followup(tier: str, asked_again: bool) -> str:
"""With a follow-up (hint received) at most "solid" — no full score by cheating."""
if asked_again and tier in ("strong", "complete"):
return "solid"
return tier
def streak_points(basis_delta: int, streak_basis: int) -> int:
"""Modulate a positive base delta up by streak, clamped to [10, 40]."""
factor = min(1.33, 1 + 0.066 * min(streak_basis, 5))
return max(10, min(40, round(basis_delta * factor)))
def points_delta(tier: str, streak_basis: int, basis: int, cap_akt: int) -> tuple[int, int]:
"""Answer tier → (points delta, new streak). Positive: streak-modulated, streak +1.
Neutral (0): no change, streak stays. Negative: progressive malus, streak reset to 0."""
basis_delta = TIERS.get(tier, 0)
if basis_delta > 0:
return streak_points(basis_delta, streak_basis), streak_basis + 1
if basis_delta == 0:
return 0, streak_basis
return progressive_malus(basis, cap_akt), 0
def compute_score(basis: int, delta: int, floor: int, cap_akt: int, cap_fin: int) -> int:
"""New score · drift-free from the base. Clamps up against `cap_akt` (cap of the
currently unlocked level) and down against `floor`. Frozen ONLY at the absolute
maximum (`basis ≥ cap_fin`) — otherwise it would block at every level threshold."""
if basis >= cap_fin:
return basis
return max(floor, min(cap_akt, basis + delta))
def floor_from_score(basis: int, cap_fin: int, s_thresholds: list[int]) -> int:
"""Lower bound (no fallback): highest reached learning-level threshold (over cap_final)
AND highest reached level-unlock threshold S_k. max of both axes."""
floor = 0
for _, p in LEVELS:
s = _threshold(p, cap_fin)
if basis >= s:
floor = max(floor, s)
for s in s_thresholds:
if basis >= s:
floor = max(floor, s)
return floor
def _transcript(messages: list[dict]) -> str:
return "\n".join(
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
for m in messages
) or "(empty)"
async def block_chat(topic: str, block: str, section: str, compact: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _prompt(
"Block-Chat",
topic=topic, block=block,
section_block=section.strip() or "(no guide version provided)",
compact_block=(compact or "").strip() or "(none)",
transcript=_transcript(messages),
)
returncode, stdout, _ = await run_agent(
"blockchat-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
provider=provider, role="fast", capabilities="none", lane="interactive",
)
if returncode != 0:
return "Sorry, that didn't work. Please try again."
reply = stdout.strip()
return reply or "Sorry, I didn't get a response."
except Exception:
log.warning("[%s] Block chat failed (%s)", topic, block, exc_info=True)
return "Sorry, that didn't work. Please try again."
def _question_schema(data) -> dict | None:
"""{"question": str} · else None."""
if not isinstance(data, dict):
return None
question = str(data.get("question", "")).strip()
return {"question": question} if question else None
def _rating_schema(data) -> dict | None:
"""{"feedback": str, "tier": ∈ TIERS} · else None."""
if not isinstance(data, dict):
return None
feedback = str(data.get("feedback", "")).strip()
tier = data.get("tier")
if not feedback or tier not in TIERS:
return None
return {"feedback": feedback, "tier": tier}
async def _gen_call(name: str, role: str, schema, provider: str, timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> dict | None:
"""Generator agent: fill the template, run it, parse via schema · None on error.
lane="batch" for background (preloading, thorough rating) → its own slot queue."""
returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
provider=provider, role=role, capabilities="none", lane=lane,
)
return schema(_parse_json_text(stdout)) if returncode == 0 else None
async def _critique_call(name: str, provider: str, role: str = "judge", timeout: int = EXAM_TIMEOUT, lane: str = "interactive", **kwargs) -> list[str]:
"""Critic agent (default role judge): empty list = fine. Fail-open: a critic failure
must not block the turn, so it returns an empty list then as well."""
returncode, stdout, _ = await run_agent(
name.lower() + "-" + str(uuid.uuid4()), _prompt(name, **kwargs), timeout,
provider=provider, role=role, capabilities="none", lane=lane,
)
if returncode != 0:
return []
return _problems_schema(_parse_json_text(stdout)) or []
def _critique_block(prev_version: str, problems: list[str]) -> str:
points = "\n".join(f"- {p}" for p in problems)
return (
f"Your previous version was:\n«{prev_version}»\n\n"
f"The examiner objects:\n{points}\n\nFix these points."
)
def _rating_text(rating: dict) -> str:
return f"Tier: {rating['tier']}\nFeedback: {rating['feedback']}"
# Deterministic guard against double questions — the AI critic misses "…, and which…".
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
# Optional preposition between the conjunction and the question word — the second question
# hides behind it: "und IN welcher Datei", "und UNTER welcher Bedingung".
_PREP = r"(?:in|unter|auf|für|mit|bei|zu[rm]?|von|vom|über|durch|aus|nach|an|am|um|gegen|ohne|wobei)\s+"
_DOUBLE_RE = re.compile(
r"[,;]?\s+(?:und|sowie|außerdem|bzw\.?)\s+(?:" + _PREP + r")?" + _QUESTION_WORD + r"\b",
re.IGNORECASE,
)
def _double_question_flaw(question: str) -> str | None:
"""Detects two chained questions. None = ok. Flags ONLY 'und/sowie' + question word."""
if question.count("?") > 1:
return "More than one question mark — ask EXACTLY ONE question."
if _DOUBLE_RE.search(question):
return "Two questions chained with 'und'/'sowie' — ask EXACTLY ONE question, one thing."
return None
async def _question_with_critique(
topic: str, block: str, section_block: str, compact_block: str,
transcript: str, avoid_block: str, type_block: str, fokus_block: str,
tier_block: str, provider: str,
) -> str | None:
"""Generate a question, have the critic check it, regenerate on flaws (max CRITIC_MAX_ROUNDS)."""
kritik_block = "(none)"
question = None
for _ in range(CRITIC_MAX_ROUNDS):
data = await _gen_call(
"Block-Question", "guide", _question_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
type_block=type_block, fokus_block=fokus_block, tier_block=tier_block, kritik_block=kritik_block,
)
if data is None:
return None
question = data["question"]
problems = await _critique_call(
"Block-Question-Critique", provider, role="guide", lane="batch", # strong AI checks the rules
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, transcript=transcript, avoid_block=avoid_block,
type_block=type_block, fokus_block=fokus_block, question=question,
)
hard = _double_question_flaw(question) # forces regeneration even if the AI critic missed it
if hard:
problems = [hard, *(problems or [])]
if not problems:
return question
kritik_block = _critique_block(question, problems)
return question # best-effort after the last round
async def _rating_with_critique(
topic: str, block: str, section_block: str, compact_block: str,
question: str, transcript: str, reason_block: str, provider: str, role: str = "judge",
) -> dict | None:
"""Rate an answer (tier), have the critic check it, redo on misjudgment.
`question` anchors the checked question; the dialog (transcript) provides answer + discussion.
`reason_block` = optional learner dissatisfaction (only for "thorough check").
`role` = "judge" (fast) or "guide" (thorough, strong model with thinking).
"""
timeout = THOROUGH_TIMEOUT if role == "guide" else EXAM_TIMEOUT
# Thorough (role guide) = user is waiting → interactive. Background-thorough (judge) → batch.
lane = "interactive" if role == "guide" else "batch"
kritik_block = "(none)"
rating = None
for _ in range(CRITIC_MAX_ROUNDS):
rating = await _gen_call(
"Block-Rating", role, _rating_schema, provider, timeout, lane=lane,
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, question=question, transcript=transcript,
reason_block=reason_block, kritik_block=kritik_block,
)
if rating is None:
return None
problems = await _critique_call(
"Block-Rating-Critique", provider, role=role, timeout=timeout, lane=lane,
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, question=question, transcript=transcript,
rating_block=_rating_text(rating),
)
if not problems:
return rating
kritik_block = _critique_block(_rating_text(rating), problems)
return rating # best-effort after the last round
def _section_blocks(section: str, compact: str | None) -> tuple[str, str]:
return (
section.strip() or "(no guide version provided)",
(compact or "").strip() or "(none)",
)
def _avoid_block(avoid: list[str] | None) -> str:
entries = [f.strip() for f in (avoid or []) if f and f.strip()]
return "\n".join(f"- {f}" for f in entries) or "(none)"
# Learner tier (derived from the score) → addressee role for the question. This is how the
# difficulty arises: not "make it extra hard", but "ask questions for a beginner/expert".
# Per level: addressee role + cognitive demand (Bloom) + "ask like this" cue. Without explicit levels
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
TIER_ROLE = {
"beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Have the concept applied to ONE minimal case — name the case in a half-sentence (no story, no roles, no backstory), then ask ONE single thing.",
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Ask for ONE distinction, comparison, or typical pitfall — a single question, no scenario prose.",
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have them justify ONE decision or weigh ONE trade-off — one question, no build-up, no second sub-question.",
}
def _tier_block(tier: str | None) -> str:
return TIER_ROLE.get(tier or "", TIER_ROLE["beginner"])
async def exam_question(
topic: str, block: str, section: str, compact: str | None,
messages: list[dict], subblocks: list[str] | None = None,
avoid: list[str] | None = None, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Action 'question': generate a question — random type for a random subblock,
in the addressee role of the tier, then critic (sequential) · None on error."""
try:
section_block, compact_block = _section_blocks(section, compact)
transcript = _transcript(messages) if messages else "(empty)"
type_block = QUESTION_TYPES[random.choice(list(QUESTION_TYPES))]
subs = [s for s in (subblocks or []) if s and s.strip()]
focus = random.choice(subs) if subs else ""
fokus_block = (
f"Focus the question on this subblock: „{focus}\"" if focus
else "(whole block — no specific subblock)"
)
return await _question_with_critique(
topic, block, section_block, compact_block, transcript,
_avoid_block(avoid), type_block, fokus_block, _tier_block(tier), provider,
)
except Exception:
log.warning("[%s] Question failed (%s)", topic, block, exc_info=True)
return None
async def exam_question_variant(
topic: str, block: str, section: str, compact: str | None,
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
the addressee role of the tier. No AI critic (the pattern is build-checked), but the
deterministic double-question guard runs and forces a regenerate on a flaw · None on error."""
try:
section_block, compact_block = _section_blocks(section, compact)
kritik_block = "(none)"
question = None
for _ in range(CRITIC_MAX_ROUNDS):
data = await _gen_call(
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
kritik_block=kritik_block,
)
if data is None:
return None
question = data["question"]
flaw = _double_question_flaw(question)
if not flaw:
return question
kritik_block = _critique_block(question, [flaw])
return question # best-effort after the last round
except Exception:
log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
return None
def _options_schema(opts) -> list[dict] | None:
"""[{text, correct}]×4 → validated list · else None."""
if not isinstance(opts, list) or len(opts) != 4:
return None
out = []
for o in opts:
if not isinstance(o, dict):
return None
text = str(o.get("text", "")).strip()
correct = o.get("correct")
if not text or not isinstance(correct, bool):
return None
out.append({"text": text, "correct": correct})
return out
def _quiz_schema(data) -> dict | None:
"""{"question": str, "options": [{text, correct}]×4} → validated · else None.
Single choice: exactly 1 correct. The difficulty is in the tier, not in the count."""
if not isinstance(data, dict):
return None
question = str(data.get("question", "")).strip()
out = _options_schema(data.get("options"))
if not question or out is None:
return None
if sum(o["correct"] for o in out) != 1:
return None
return {"question": question, "options": out}
def _gapchoice_schema(data) -> dict | None:
"""{"sentence": str (with ___), "options": [{text, correct}]×4} → exactly 1 correct · else None."""
if not isinstance(data, dict):
return None
sentence = str(data.get("sentence", "")).strip()
out = _options_schema(data.get("options"))
if not sentence or "___" not in sentence or out is None or sum(o["correct"] for o in out) != 1:
return None
return {"sentence": sentence, "options": out}
async def hurdles_distractor_block(topic: str, block: str) -> str:
"""Typical misconceptions (facts hurdles) of the block as a distractor source for quiz/gap choice.
Empty if none exist (legacy) → the prompt placeholder disappears without a trace."""
try:
hurdles = await get_block_hurdles(topic, _norm_title(block))
except Exception:
return ""
if not hurdles:
return ""
lines = "\n".join(f"- {h}" for h in hurdles[:8])
return ("TYPICAL MISCONCEPTIONS for this block (use them as distractors when they fit the question):\n"
+ lines + "\n")
async def generate_quiz(
topic: str, block: str, section: str, compact: str | None,
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
distractor_block: str = "",
) -> dict | None:
"""From a pattern, a single-choice question (exactly 1 correct), at the tier's level.
Strong model (role guide) for correct flags. → {question, options} · None on error.
distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
try:
section_block, compact_block = _section_blocks(section, compact)
return await _gen_call(
"Block-Quiz", "guide", _quiz_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
distractor_block=distractor_block,
)
except Exception:
log.warning("[%s] Quiz question failed (%s)", topic, block, exc_info=True)
return None
async def generate_gapchoice(
topic: str, block: str, section: str, compact: str | None,
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
distractor_block: str = "",
) -> dict | None:
"""Gap text with choices: sentence with ___ + 4 terms, exactly 1 correct — at the tier's level.
{sentence, options:[{text,correct}]} · None on error.
distractor_block: optional typical misconceptions (from the facts hurdles) as a distractor source."""
try:
section_block, compact_block = _section_blocks(section, compact)
return await _gen_call(
"Block-Gapchoice", "guide", _gapchoice_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
distractor_block=distractor_block,
)
except Exception:
log.warning("[%s] Gap-text choice failed (%s)", topic, block, exc_info=True)
return None
def _gap_schema(data) -> dict | None:
"""{"sentence": str (with ___), "solution": str, "alternatives": [str]} → validated · else None."""
if not isinstance(data, dict):
return None
sentence = str(data.get("sentence", "")).strip()
solution = str(data.get("solution", "")).strip()
alt = data.get("alternatives", [])
if not sentence or "___" not in sentence or not solution:
return None
alternatives = [str(a).strip() for a in alt if isinstance(a, str) and str(a).strip()] if isinstance(alt, list) else []
return {"sentence": sentence, "solution": solution, "alternatives": alternatives}
async def generate_gaptext(
topic: str, block: str, section: str, compact: str | None,
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""From a pattern, a gap-text task (sentence with ___, solution, synonyms), at the
tier's level. → {sentence, solution, alternatives} · None on error."""
try:
section_block, compact_block = _section_blocks(section, compact)
return await _gen_call(
"Block-Gaptext", "guide", _gap_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
)
except Exception:
log.warning("[%s] Gap-text question failed (%s)", topic, block, exc_info=True)
return None
def _norm_term(t: str) -> str:
return re.sub(r"[^\wäöüß]", "", str(t or "").lower())
def _correct_schema(data) -> dict | None:
if not isinstance(data, dict) or not isinstance(data.get("correct"), bool):
return None
return {"correct": data["correct"]}
async def check_gaptext(
topic: str, block: str, sentence: str, solution: str, alternatives: list[str],
input: str, provider: str = DEFAULT_PROVIDER,
) -> bool:
"""Check a gap-text answer: first a normalized comparison (solution + synonyms),
otherwise 1 AI call for synonym tolerance. Fail-open to CORRECT only on an exact match."""
if not input.strip():
return False
norm = _norm_term(input)
if norm and norm in {_norm_term(solution), *(_norm_term(a) for a in alternatives)}:
return True
data = await _gen_call(
"Block-Gaptext-Exam", "fast", _correct_schema, provider,
topic=topic, block=block, sentence=sentence, solution=solution,
alternatives=", ".join(alternatives) or "(none)", input=input,
)
return bool(data and data["correct"])
async def exam_rating_fast(
topic: str, block: str, section: str, compact: str | None,
question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
) -> dict | None:
"""Action 'answer' (Agent 1, fast): evaluator only, no critic. → {feedback, tier}."""
try:
section_block, compact_block = _section_blocks(section, compact)
transcript = _transcript(messages) if messages else "(empty)"
return await _gen_call(
"Block-Rating", "judge", _rating_schema, provider,
topic=topic, block=block, section_block=section_block, compact_block=compact_block,
question=question.strip() or "(no question provided)", transcript=transcript,
reason_block="(none)", kritik_block="(none)",
)
except Exception:
log.warning("[%s] Fast rating failed (%s)", topic, block, exc_info=True)
return None
async def exam_rating(
topic: str, block: str, section: str, compact: str | None,
question: str, messages: list[dict], provider: str = DEFAULT_PROVIDER,
role: str = "judge", reason: str = "",
) -> dict | None:
"""Action 'answer_check' (Agent 2, thorough): evaluator + critic. → {feedback, tier}.
`role` = "guide" for "thorough check" (strong model). `reason` = optional
learner dissatisfaction with an earlier rating.
"""
try:
section_block, compact_block = _section_blocks(section, compact)
transcript = _transcript(messages) if messages else "(empty)"
reason_block = reason.strip() or "(none)"
return await _rating_with_critique(
topic, block, section_block, compact_block,
question.strip() or "(no question provided)", transcript, reason_block, provider, role,
)
except Exception:
log.warning("[%s] Rating failed (%s)", topic, block, exc_info=True)
return None
async def block_discussion(
topic: str, block: str, section: str, compact: str | None,
question: str, last_rating: str | None, messages: list[dict], provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Action 'discussion': tutor explains/discusses the question or a rating.
No rating, no critic — here the human is the examiner. None on error.
"""
try:
section_block, compact_block = _section_blocks(section, compact)
prompt = _prompt(
"Block-Exam-Discussion",
topic=topic, block=block,
section_block=section_block, compact_block=compact_block,
question=question.strip() or "(no question provided)",
last_rating_block=(last_rating or "").strip() or "(none yet)",
transcript=_transcript(messages) if messages else "(empty)",
)
returncode, stdout, _ = await run_agent(
"examdiscussion-" + str(uuid.uuid4()), prompt, CHAT_TIMEOUT,
provider=provider, role="fast", capabilities="none", lane="interactive",
)
if returncode != 0:
return None
return stdout.strip() or None
except Exception:
log.warning("[%s] Exam discussion failed (%s)", topic, block, exc_info=True)
return None

View File

@@ -1,4 +1,4 @@
"""Zentrales Logging-Setup — einmal in main.py aufrufen, bevor die App entsteht."""
"""Central logging setup — call once in main.py before the app is created."""
import logging
import os

View File

@@ -2,12 +2,15 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
from logsetup import setup_logging
setup_logging()
from config import FRONTEND_DIST, STORAGE_DIR
import agents
import database
from database import init_db, close_db
from guide import reconcile_guides
from routes import router
@@ -15,16 +18,32 @@ from routes import router
@asynccontextmanager
async def lifespan(app: FastAPI):
(STORAGE_DIR / "themen").mkdir(parents=True, exist_ok=True)
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
await init_db()
agents.on_event = database.add_event # pipeline history sink (agents.py stays DB-free)
await reconcile_guides()
yield
await close_db()
class CachedStatic(StaticFiles):
"""StaticFiles with Cache-Control: hashed assets forever (immutable),
index.html never cached (it always points at the current asset hashes)."""
async def get_response(self, path, scope):
resp = await super().get_response(path, scope)
if path.startswith("assets/"):
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
resp.headers["Cache-Control"] = "no-cache"
return resp
app = FastAPI(title="Creator", lifespan=lifespan)
# gzip for the JS/CSS bundle + large JSON responses (~1.39 MB JS → ~400 KB).
app.add_middleware(GZipMiddleware, minimum_size=500)
app.include_router(router)
if FRONTEND_DIST.exists():
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend")
app.mount("/", CachedStatic(directory=FRONTEND_DIST, html=True), name="frontend")

View File

@@ -2,48 +2,107 @@ from pydantic import BaseModel, Field
from typing import Literal
FormatType = Literal[
"OnePager",
"MiniGuide",
"Guide",
"FullGuide",
"Rest",
]
ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
from config import DEFAULT_PROVIDER
ProviderType = Literal["claude", "minimax", "lokal"]
SourceType = Literal["thema", "projekt", "uni", "link"]
class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType
instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class TopicCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
class BausteineCreateRequest(BaseModel):
class QaRunRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
llm: bool = True # wie das Gate: Echtheits-/Dubletten-Stichprobe inklusive
ebene: str | None = None # None = alle Ebenen; "inventory"/"artefacts" = ohne Guide-QA-Overhead
class RepairRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
ebene: str | None = None # None = alle Fixes, "inventory" oder "artefacts" filtert je Ebene
class BlocksCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
source_type: SourceType = "thema"
source_location: str = Field(default="", max_length=20000) # link mode: one URL per line
research: bool = True # False = Continue: drain the existing kanban queue, no new search
qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
# „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×.
# Aus = nach der QA stoppen (Kontrollpunkt). Default an.
auto_inventory: bool = True
auto_artefacts: bool = True
auto_guide: bool = True
only_artefacts: bool = False # True = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate)
class BausteineStep(BaseModel):
label: str
state: Literal["done", "active", "pending"]
class GuideFormatRequest(BaseModel):
topic: str = Field(min_length=1)
format: str = Field(min_length=1)
class BausteineStatusResponse(BaseModel):
class PracticeAnswerRequest(BaseModel):
topic: str = Field(min_length=1)
block_norm: str = Field(min_length=1, max_length=300)
sub_norm: str = Field(max_length=300)
correct: bool
class BlocksStatusResponse(BaseModel):
ready: bool
generating: bool
progress: str | None = None
error: str | None = None
partial: bool = False
steps: list[BausteineStep] = []
class ProjectResponse(BaseModel):
class FolderResponse(BaseModel):
name: str
location: str # path relative to the repo root (e.g. "projects/foo")
class BlocksSourceUpdate(BaseModel):
topic: str = Field(min_length=1, max_length=100)
type: SourceType = "thema"
location: str = Field(default="", max_length=20000) # link mode: one URL per line
spec: str = Field(default="", max_length=2000)
class BlocksSourceResponse(BaseModel):
type: SourceType
location: str
spec: str
class SubblockInfo(BaseModel):
title: str
level: Literal["beginner", "advanced", "expert", "easy", "medium", "hard"]
relevance: Literal["relevant", "peripheral"] | None = None
class BlockOverview(BaseModel):
num: int
title: str
description: str = ""
subblocks: list[SubblockInfo] = []
class ProviderInfo(BaseModel):
@@ -72,87 +131,111 @@ class GuideChatRequest(BaseModel):
section: str = Field(default="", max_length=20000)
outline: str = Field(default="", max_length=8000)
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class GuideChatResponse(BaseModel):
reply: str
class ElementResponse(BaseModel):
id: str
topic: str
title: str
description: str = ""
examples: list[str] = []
hints: list[str] = []
created_at: str
updated_at: str
# --- Block learning ---
class ElementCreateRequest(BaseModel):
class BlockChatRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
hint: str = Field(default="", max_length=500)
provider: ProviderType = "claude"
class ElementUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=200)
description: str | None = None
examples: list[str] | None = None
hints: list[str] | None = None
class ElementCheckRequest(BaseModel):
provider: ProviderType = "claude"
class ElementSuggestion(BaseModel):
text: str
target: Literal["description", "examples", "hints"]
content: str
class ElementCheckResponse(BaseModel):
suggestions: list[ElementSuggestion]
class ElementStyleChange(BaseModel):
text: str
action: Literal["entfernen", "anpassen", "hinzufuegen"]
target: Literal["title", "description", "examples", "hints"]
index: int | None = None
content: str = ""
class ElementStyleResponse(BaseModel):
changes: list[ElementStyleChange]
class ElementChatRequest(BaseModel):
block: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000) # detailed version
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class ElementChatResponse(BaseModel):
class BlockChatResponse(BaseModel):
reply: str
changes: list[ElementStyleChange] = []
class ElementRefineRequest(BaseModel):
suggestion: ElementStyleChange
instruction: str = Field(min_length=1, max_length=2000)
provider: ProviderType = "claude"
class BlockExamRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
block: str = Field(min_length=1, max_length=200)
section: str = Field(default="", max_length=20000) # detailed version
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
action: Literal[
"question", "discussion", "answer", "answer_check",
"quiz_question", "quiz_answer", "gap_question", "gap_answer",
] = "question"
question: str = Field(default="", max_length=2000) # currently checked question (for discussion/answer); base anchor
selection: list[int] = [] # quiz/gap-text choice: option indices picked by the learner
correct: list[int] = [] # quiz/gap-text choice: correct indices (the client keeps them from generation)
solution: str = Field(default="", max_length=500) # gap text free: expected term
alternatives: list[str] = [] # gap text free: accepted synonyms
input: str = Field(default="", max_length=500) # gap text free: typed term
schwer: bool = False # variant: easy (+1/1) vs hard (+3/1)
last_rating: str = Field(default="", max_length=2000) # feedback of the last rating (context for discussion)
avoid: list[str] = [] # already-asked + earmarked questions — don't repeat them in substance
asked_again: bool = False # asked_again was used for this question → gain capped at +1
reason: str = Field(default="", max_length=2000) # "thorough check": why dissatisfied with the rating
pattern: str = Field(default="", max_length=2000) # drawn question pattern (seed); empty → live generation (fallback)
# Base + cap are kept server-side (anchor / subs×25) — the client cap is only a hint.
cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
messages: list[ChatMessage] = [] # dialog so far; empty = first question
provider: ProviderType = DEFAULT_PROVIDER
thorough: bool = False # "thorough check": rating with a strong model (role guide)
class ElementRefineResponse(BaseModel):
change: ElementStyleChange
class QuizOption(BaseModel):
text: str
correct: bool
class ProgressUpdate(BaseModel):
chapter: str = Field(min_length=1, max_length=100)
done: bool
class BlockExamResponse(BaseModel):
question: str | None = None
reply: str | None = None
feedback: str | None = None
points: int | None = None # points delta of this answer (2 … +3); fast = expected
rating: Literal["gut", "neutral", "schlecht"] | None = None # from the sign, for coloring
options: list[QuizOption] | None = None # quiz: 4 options + correct flags
sentence: str | None = None # gap text: sentence with a gap (___)
solution: str | None = None # gap text: expected term
alternatives: list[str] | None = None # gap text: accepted synonyms
good_answers: int
streak: int = 0 # current run of correct answers (per block)
cap: int = 10 # cap_final = all subs × 25 — the frontend derives the learning level
class ProgressResponse(BaseModel):
chapters: list[str]
class BlockLearnState(BaseModel):
good_answers: int
streak: int = 0
cap: int = 0 # cap_final = all subblocks × 25
cap_aktuell: int = 0 # reachable cap of the currently unlocked level
freie_level: int = 1 # 1=A · 2=F · 3=E · 4=V
class BlockLearnStateResponse(BaseModel):
blocks: dict[str, BlockLearnState]
# --- Block content: check + apply one section on demand (focus, right-click) ---
class BlockPruefenRequest(BaseModel):
block: str = Field(min_length=1, max_length=200)
spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
hint: str = Field(default="", max_length=2000) # optional addition (✏️)
provider: ProviderType = DEFAULT_PROVIDER
class BlockPruefenResponse(BaseModel):
revised: str # corrected block as markdown
class BlockUebernehmenRequest(BaseModel):
block: str = Field(min_length=1, max_length=200)
spot: str = "ausführlich"
alt: str = Field(min_length=1, max_length=20000)
revised: str = Field(default="", max_length=20000)
provider: ProviderType = DEFAULT_PROVIDER
class BlockUebernehmenResponse(BaseModel):
compact: str
md: str
found: bool

View File

@@ -1,157 +0,0 @@
"""OnePager-Pipeline: Recherche → Recherche-Prüfung → Bauen → Prüfung (7 Karten im 3×3-Raster)."""
from pathlib import Path
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_datei
from pipeline import (
CANCELLED, FAILED, GenContext, _check_then_fix, _extra, _fail,
_prompt, _set_step, _timeout, is_guide_cancelled, run_single_slot,
)
async def _generate_onepager(
guide_id: str, topic: str, instructions: str, provider: str,
project: Path | None, content_path: Path,
) -> list[dict] | None:
ctx = GenContext(topic=topic, provider=provider, is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
# 3×3-Raster: 7 Karten mit festen Schlüsseln (Reihenfolge = Lesereihenfolge mobil)
KARTEN_KEYS = ("info", "eigenschaften", "beispiel", "zusammenhaenge", "voraussetzungen", "modern", "veraltet")
def karten_schema(data):
"""{"karten": {key: {titel, md}}} → Liste · sonst None."""
if not isinstance(data, dict):
return None
karten = data.get("karten")
if not isinstance(karten, dict):
return None
out = []
for key in KARTEN_KEYS:
k = karten.get(key)
if not isinstance(k, dict) or not isinstance(k.get("titel"), str) or not isinstance(k.get("md"), str):
return None
titel, md = k["titel"].strip(), k["md"].strip()
if not titel or len(md) < 5: # abgebrochene/leere Karten sind ungültig
return None
out.append({"key": key, "titel": titel, "md": md})
return out
d, stem = content_path.parent, content_path.stem
recherche_path = d / f"{stem}.recherche.md"
recherche_check_path = d / f"{stem}.recherche-check.json"
karten_path = d / f"{stem}.karten.json"
check_path = d / f"{stem}.onepager-check.json"
# Projekte bekommen eigene Recherche-Dimensionen — Produkt-Fragen
# (Version, Lizenz, Alternativen) laufen dort ins Leere.
if project:
source = _prompt("OnePager-Quelle-Projekt", project=project)
recherche_template = "OnePager-Recherche-Projekt"
recherche_check_template = "OnePager-Recherche-Check-Projekt"
else:
source = _prompt("OnePager-Quelle-Thema", topic=topic)
recherche_template = "OnePager-Recherche"
recherche_check_template = "OnePager-Recherche-Check"
def recherche_payload(result=None):
if not recherche_path.exists():
return None
text = recherche_path.read_text(encoding="utf-8").strip()
return text or None
# Schritt 1: Recherche — vorhandene Datei wird übernommen (Resume)
recherche = recherche_payload()
if recherche is None:
await _set_step(guide_id, 0, "Recherchiere…")
status, recherche = await run_single_slot(
ctx, "OnePager-Recherche",
key=f"{guide_id}-recherche",
prompt=_prompt(recherche_template, topic=topic, source=source, out_path=recherche_path, extra=_extra(instructions)),
role="quick", capabilities="files" if project else "full",
payload=recherche_payload, timeout=_timeout("onepager_recherche"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Recherche fehlgeschlagen")
return None
# Schritt 2: Recherche-Prüfung — notiert Probleme; Anpassung macht ein Recherche-Agent
status, fixed = await _check_then_fix(
ctx, name="Recherche", step=1,
check_key=f"{guide_id}-recherche-check",
check_prompt=_prompt(recherche_check_template, topic=topic, recherche=recherche, out_path=recherche_check_path),
check_path=recherche_check_path, check_timeout=_timeout("onepager_verify"),
fix_key=f"{guide_id}-recherche-fix",
build_fix_prompt=lambda probleme: _prompt(
"OnePager-Recherche-Fix",
topic=topic, source=source, recherche=recherche,
probleme="\n".join(f"- {p}" for p in probleme),
out_path=recherche_path, extra=_extra(instructions),
),
fix_payload=recherche_payload, fix_timeout=_timeout("onepager_recherche"),
fix_role="quick", fix_caps="files" if project else "full",
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "Recherche-Prüfung fehlgeschlagen")
return None
if fixed is not None:
recherche = fixed
# Schritt 3: Bauen — Karten nur aus der Faktenbasis (Resume: gültige Datei wird übernommen)
karten = karten_schema(_json_datei(karten_path))
if karten is None:
await _set_step(guide_id, 2, "Baue OnePager…")
karten_path.unlink(missing_ok=True)
status, karten = await run_single_slot(
ctx, "OnePager-Bauen",
key=f"{guide_id}-bauen",
prompt=_prompt("OnePager-Bauen", topic=topic, recherche=recherche, out_path=karten_path, extra=_extra(instructions)),
role="fast", capabilities="files",
payload=lambda result: karten_schema(_json_datei(karten_path)),
timeout=_timeout("onepager_bauen"),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Bau fehlgeschlagen")
return None
def karten_block() -> str:
return "\n\n".join(f"### {k['titel']} [{k['key']}]\n{k['md']}" for k in karten)
# Schritt 4: Prüfung — notiert Probleme; Anpassung macht ein Bauen-Agent
status, fixed = await _check_then_fix(
ctx, name="OnePager", step=3,
check_key=f"{guide_id}-verify",
check_prompt=_prompt("OnePager-Verifikation", topic=topic, recherche=recherche, karten=karten_block(), out_path=check_path),
check_path=check_path, check_timeout=_timeout("onepager_verify"),
fix_key=f"{guide_id}-karten-fix",
build_fix_prompt=lambda probleme: _prompt(
"OnePager-Fix",
topic=topic, recherche=recherche, karten=karten_block(),
probleme="\n".join(f"- {p}" for p in probleme),
out_path=karten_path, extra=_extra(instructions),
),
fix_payload=lambda result: karten_schema(_json_datei(karten_path)),
fix_timeout=_timeout("onepager_bauen"),
on_fix_invalid=lambda: atomic_write_json(
karten_path, {"karten": {k["key"]: {"titel": k["titel"], "md": k["md"]} for k in karten}},
),
)
if status == CANCELLED:
return None
if status == FAILED:
await _fail(guide_id, "OnePager-Prüfung fehlgeschlagen")
return None
if fixed is not None:
karten = fixed
sections = [
{"num": i, "title": k["titel"], "md": k["md"], "key": k["key"]}
for i, k in enumerate(karten, 1)
]
return [{"title": topic, "sections": sections}]

View File

@@ -1,8 +1,8 @@
from pathlib import Path
from config import STORAGE_DIR, PROJECTS_DIR
from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT
THEMEN_DIR = STORAGE_DIR / "themen"
TOPICS_DIR = STORAGE_DIR / "topics"
def _safe(name: str) -> str:
@@ -10,26 +10,58 @@ def _safe(name: str) -> str:
def topic_dir(topic: str) -> Path:
return THEMEN_DIR / _safe(topic)
return TOPICS_DIR / _safe(topic)
def arbeit_dir(topic: str) -> Path:
return topic_dir(topic) / "arbeit"
def bausteine_path(topic: str) -> Path:
return topic_dir(topic) / "bausteine.md"
def blocks_path(topic: str) -> Path:
return topic_dir(topic) / "blocks.md"
def subblocks_path(topic: str) -> Path:
"""Sidecar: the subblocks with level per block (shared by all guides)."""
return topic_dir(topic) / "subblocks.json"
def question_pattern_path(topic: str) -> Path:
"""Sidecar: predefined question patterns per block (subblock × type → example question)."""
return topic_dir(topic) / "question_pattern.json"
def source_path(topic: str) -> Path:
"""Persisted source choice per topic: {type, location, spec}."""
return topic_dir(topic) / "source.json"
def source_crawl_dir(topic: str) -> Path:
"""Target folder for crawled link sources (pages + PDF .txt)."""
return topic_dir(topic) / "source"
def safe_folder(location: str) -> Path | None:
"""Folder path relative to the repo root, sandboxed. None if empty/escaping (../, absolute outside)."""
if not location or not location.strip():
return None
p = (PROJECT_ROOT / location.strip()).resolve()
try:
p.relative_to(PROJECT_ROOT)
except ValueError:
return None
return p
def guide_content_path(topic: str, format_name: str) -> Path:
return topic_dir(topic) / "guides" / f"{format_name}.json"
def bausteine_topics() -> list[str]:
"""Themen, für die ein Themen-Ordner existiert."""
if not THEMEN_DIR.is_dir():
def blocks_topics() -> list[str]:
"""Topics for which a topic folder exists."""
if not TOPICS_DIR.is_dir():
return []
return [d.name for d in THEMEN_DIR.iterdir() if d.is_dir()]
return [d.name for d in TOPICS_DIR.iterdir() if d.is_dir()]
def project_dir(name: str) -> Path:

View File

@@ -1,21 +1,19 @@
"""Pipeline-Grundbausteine: Agent-Races, Single-Slot, Check→Fix, Prompts, Guide-Status.
"""Pipeline building blocks: agent races (with grace), single-slot, schemas, prompts, guide status.
Hält den mutablen Pipeline-Zustand (Generierungs-Semaphore, Cancel-Set).
Zugriff auf das Cancel-Set NUR über die Funktionen hier — kopierte Referenzen
in anderen Modulen würden bei einem Re-Assign auseinanderlaufen.
Holds the mutable pipeline state (generation semaphore, cancel set).
Access the cancel set ONLY through the functions herecopied references
in other modules would diverge on a re-assign.
"""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from agents import run_agent, kill_process
from agents import run_agent, kill_process, cancel_scope, clear_scope
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
from database import update_guide
from jsonio import read_json_file as _json_datei
log = logging.getLogger("creator.pipeline")
@@ -25,9 +23,10 @@ _cancelled: set[str] = set()
async def cancel_guide(guide_id: str) -> bool:
_cancelled.add(guide_id)
kill_process(guide_id)
cancel_scope(f"{guide_id}-") # waiting agents bail before spawn
kill_process(guide_id) # kill running subprocesses
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="error", progress=None, error_msg="Abgebrochen — Fortschritt bleibt erhalten", updated_at=now)
await update_guide(guide_id, status="error", progress=None, error_msg="Cancelled — progress is preserved", updated_at=now)
return True
@@ -37,6 +36,7 @@ def is_guide_cancelled(guide_id: str) -> bool:
def clear_guide_cancelled(guide_id: str) -> None:
_cancelled.discard(guide_id)
clear_scope(f"{guide_id}-") # clear scope → restart not blocked
async def _set_progress(guide_id: str, progress: str) -> None:
@@ -60,7 +60,7 @@ def _prompt(name: str, **kwargs) -> str:
def _extra(instructions: str) -> str:
return f"\n\nZUSÄTZLICHE ANWEISUNGEN VOM NUTZER:\n{instructions}\n" if instructions else ""
return f"\n\nADDITIONAL INSTRUCTIONS FROM THE USER:\n{instructions}\n" if instructions else ""
def _log(topic: str, msg: str) -> None:
@@ -73,8 +73,17 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
return f"{label}: {stderr[:1000]}"
tail = (stdout or "").strip()[-500:]
if tail:
return f"{label} (exit {returncode}, stderr leer): …{tail}"
return f"{label} (exit {returncode}, ohne Ausgabe)"
return f"{label} (exit {returncode}, stderr empty): …{tail}"
return f"{label} (exit {returncode}, no output)"
_INFRA_MARKERS = ("HTTP 429", "HTTP 5", "rate_limit", "Timeout after",
"ConnectError", "ConnectTimeout", "ReadError", "RemoteProtocolError")
def _is_infra(err: str) -> bool:
"""Transport-/Infra-Fehler (retry + pause) statt inhaltlichem Fehlschlag."""
return any(m in (err or "") for m in _INFRA_MARKERS)
def _gather_error(label: str, results: list) -> str:
@@ -84,7 +93,7 @@ def _gather_error(label: str, results: list) -> str:
returncode, stdout, stderr = r
if returncode != 0:
return _claude_error(label, returncode, stdout, stderr)
return f"{label}: kein verwertbares Ergebnis"
return f"{label}: no usable result"
def _timeout(step: str, n: int = 0) -> int:
@@ -92,41 +101,116 @@ def _timeout(step: str, n: int = 0) -> int:
return base + per * n
def _probleme_schema(data):
"""{"ok": true} → [] · {"probleme": [str]} → Liste · sonst None."""
def _problems_schema(data):
"""{"ok": true} → [] · {"problems": [str]} → list · else None."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
p = data.get("probleme")
p = data.get("problems")
if not isinstance(p, list) or not p:
return None
out = [str(x).strip() for x in p if str(x).strip()]
return out or None
_MAX_RESTARTS = 2
def _str_list(val) -> list[str] | None:
"""List of non-empty strings → stripped list (empty allowed) · else None."""
if not isinstance(val, list) or not all(isinstance(x, str) for x in val):
return None
out = [x.strip() for x in val]
return None if any(not x for x in out) else out
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None) -> list | None:
"""Startet alle Slots parallel und sammelt `quorum` gültige Ergebnisse.
Slot-Spec: {key, prompt, role, capabilities, payload}. `payload(result)`
prüft die Gültigkeit und liefert das Slot-Ergebnis oder None.
Fehler/Timeout/ungültig → Slot-Neustart (max. _MAX_RESTARTS). Sobald das
Quorum steht, werden die übrigen Agenten gekillt. None = Quorum verfehlt.
`cancelled()` → True bricht ab (keine Restarts, Rückgabe None).
def _runde_schema(data, final: bool = False):
"""{"keep": [str], "rest": [str]} → (include, rest) · else None.
final=True: last clarification round — a non-empty rest is invalid.
"""
if not isinstance(data, dict):
return None
include = _str_list(data.get("keep"))
rest = _str_list(data.get("rest"))
if include is None or rest is None or (final and rest):
return None
return include, rest
_YESNO = ("ja", "nein")
def _enum_map_schema(key: str, allowed):
"""Factory for `{"<key>": {"1": value, …}}` → `{id: value}` parsers; value ∈ `allowed`
(casefolded). If `ids` are given, at least these must be covered (extras allowed). None
on any invalid id/value or wrong shape. The caller filters the result to `ids`."""
def parse(data, ids: set[int] | None = None):
if not isinstance(data, dict) or not isinstance(data.get(key), dict) or not data[key]:
return None
out: dict[int, str] = {}
for k, v in data[key].items():
try:
num = int(k)
except (ValueError, TypeError):
return None
value = str(v).strip().casefold()
if value not in allowed:
return None
out[num] = value
if ids is not None and not ids <= set(out):
return None
return out
return parse
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
from config import (MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S, # noqa: E402 — zentral tunebar
INFRA_MAX_RETRIES as _INFRA_MAX_RETRIES, INFRA_BACKOFF_BASE as _INFRA_BACKOFF)
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None:
"""Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
checks validity and returns the slot result or None.
Error/timeout/invalid → slot restart (max. _MAX_RESTARTS). As soon as the
quorum stands, the remaining agents are killed. None = quorum missed.
`cancelled()` → True aborts (no restarts, returns None).
With `grace`, `quorum` becomes the minimum: the first valid result starts
a timer of `grace` seconds. After it expires, running agents are only
killed if the minimum stands — otherwise the race, including restarts,
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
"""
attempts = {i: 0 for i in range(len(slots))}
infra_attempts = {i: 0 for i in range(len(slots))}
infra_erschoepft: set[int] = set()
tasks: dict[asyncio.Task, int] = {}
keys: dict[asyncio.Task, str] = {}
born: dict[asyncio.Task, float] = {}
hedged: set[int] = set() # slot got its one twin — no hedge cascades
fertig: set[int] = set() # slot delivered a valid result (late twins are ignored)
# Hedge-Schwelle relativ zum Call-Timeout (HEDGE_NACH_S = Untergrenze): pauschale 90 s
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110135 s laufen.
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
loop = asyncio.get_running_loop()
deadline: float | None = None
def spawn(i: int) -> None:
def spawn(i: int, suffix: str = "") -> None:
slot = slots[i]
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
key = slot["key"] + suffix
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
key, slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, on_line=slot.get("on_line"), label=lbl,
))
tasks[task] = i
keys[task] = key
born[task] = loop.time()
for i in range(len(slots)):
spawn(i)
@@ -136,40 +220,95 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks:
if cancelled and cancelled():
return None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED)
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
return results
# Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin
# (key -h) — first valid result wins. Stalled provider calls burned the full
# timeout cap before the restart even began (measured: 160230 s per stall).
if hedge_s:
now = loop.time()
for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig
and now - born[t] >= hedge_s]:
i = tasks[t]
hedged.add(i)
spawn(i, suffix="-h")
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet")
# Wake up for the earliest relevant deadline (grace or next hedge).
waits = []
if deadline is not None and len(results) >= quorum:
waits.append(deadline - loop.time())
if hedge_s:
naechste = [born[t] + hedge_s - loop.time() for t in tasks
if tasks[t] not in hedged | fertig]
if naechste:
waits.append(max(0.0, min(naechste)))
wait_timeout = max(0.0, min(waits)) if waits else None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
if not done:
continue
for task in done:
i = tasks.pop(task)
keys.pop(task, None)
born.pop(task, None)
if i in fertig:
continue # späte Zwillinge eines bereits gewerteten Slots
payload, err = None, None
try:
result = task.result()
if result[0] != 0:
err = _claude_error("Fehler", *result)
err = _claude_error("Error", *result)
else:
payload = slots[i]["payload"](result)
if payload is None:
err = "Ergebnis ungültig/nicht parsebar"
err = "result invalid/not parseable"
except asyncio.TimeoutError:
err = f"Timeout nach {timeout}s"
err = f"Timeout after {timeout}s"
except Exception as e:
err = f"{type(e).__name__}: {e}"
if payload is not None:
results.append(payload)
if on_update:
on_update(len(results))
if len(results) >= quorum:
fertig.add(i)
for t2 in [t2 for t2, i2 in tasks.items() if i2 == i]: # Zwilling killen
kill_process(keys.get(t2, slots[i]["key"]))
t2.cancel()
if grace is not None and deadline is None:
deadline = loop.time() + grace
_log(topic, f"{label}: first result — grace {grace}s running")
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
return results
continue
_log(topic, f"{label} {i + 1} (Versuch {attempts[i] + 1}): {err}")
infra = _is_infra(err)
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}{' infra' if infra else ''}): {err}")
attempts[i] += 1
if attempts[i] <= _MAX_RESTARTS and not (cancelled and cancelled()):
# If the minimum already stands, restarts are pointless — the restart
# would be killed at the grace end anyway. A still-running twin IS the retry.
enough = grace is not None and len(results) >= quorum
zwilling = any(i2 == i for i2 in tasks.values())
if enough or zwilling or (cancelled and cancelled()):
continue
if infra:
# 429/Timeout/Netz: eigener Zähler + wachsende Pause, dann Eskalation
infra_attempts[i] += 1
if infra_attempts[i] > _INFRA_MAX_RETRIES:
infra_erschoepft.add(i) # kein Respawn → raise am Quorum-Miss
continue
await asyncio.sleep(_INFRA_BACKOFF * 2 ** (infra_attempts[i] - 1))
spawn(i)
_log(topic, f"{label}: Quorum {quorum} nicht erreicht ({len(results)} gültig)")
elif attempts[i] <= _MAX_RESTARTS:
spawn(i)
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
return results
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
if infra_erschoepft:
raise AgentInfraError(
f"{label}: {len(infra_erschoepft)} Slot(s) nach {_INFRA_MAX_RETRIES} "
"Infra-Retries erschöpft (429/Timeout)")
return None
finally:
for task, i in tasks.items():
kill_process(slots[i]["key"])
kill_process(keys.get(task, slots[i]["key"]))
task.cancel()
if tasks:
await asyncio.gather(*tasks.keys(), return_exceptions=True)
@@ -177,26 +316,31 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
@dataclass
class GenContext:
"""Durchgereichte Pipeline-Parameter — erspart lange Argument-Signaturen."""
"""Pipeline parameters passed through — saves long argument signatures."""
topic: str
provider: str
is_cancelled: Callable[[], bool]
guide_id: str | None = None
# Ergebnis-Status von run_single_slot/_check_then_fix
# Result status of run_single_slot
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
class AgentInfraError(Exception):
"""Slot nach Infra-Retries erschöpft (429/Timeout). KEIN inhaltliches Urteil —
der Aufrufer pausiert den Lauf, nie fail-open."""
async def run_single_slot(
ctx: GenContext, label: str, *,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
) -> tuple[str, object]:
"""Ein Agent, ein gültiges Ergebnis (Race mit Quorum 1).
"""One agent, one valid result (race with quorum 1).
→ (OK, wert) | (CANCELLED, None) | (FAILED, None)
→ (OK, value) | (CANCELLED, None) | (FAILED, None)
"""
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}]
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload, "on_line": on_line}]
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
if ctx.is_cancelled():
return CANCELLED, None
@@ -205,47 +349,18 @@ async def run_single_slot(
return OK, res[0]
async def _check_then_fix(
ctx: GenContext, *, name: str, step: int,
check_key: str, check_prompt: str, check_path: Path, check_timeout: int,
fix_key: str, build_fix_prompt, fix_payload, fix_timeout: int,
fix_role: str = "fast", fix_caps: str = "files",
on_fix_invalid=None,
) -> tuple[str, object]:
"""Check→Fix-Muster: Prüf-Agent notiert Probleme (JSON), Fix-Agent behebt sie.
async def _gather_progress(coros, total, report, start=0):
"""Runs `coros` concurrently and reports live progress: `await report(done, total)`
after each completion (and once initially). Results in order, return_exceptions=True."""
done = start
Resume: existierende Check-Datei überspringt den ganzen Schritt.
Check ist fatal (FAILED), Fix nicht — Original bleibt; on_fix_invalid kann
das kanonische Original zurückschreiben, falls der Fix-Agent die
Artefakt-Datei zerschrieben hat.
Lese-Check (Multi-Slot, Section-genau) und Bausteine-Auswahl-Check
(Patch-Semantik) passen bewusst NICHT in dieses Muster.
→ (OK, neues_artefakt | None=unverändert) | (CANCELLED, None) | (FAILED, None)
"""
if check_path.exists():
return OK, None
await _set_step(ctx.guide_id, step, f"Prüfe {name}")
status, probleme = await run_single_slot(
ctx, f"{name}-Prüfung", key=check_key, prompt=check_prompt,
role="fast", capabilities="files",
payload=lambda result: _probleme_schema(_json_datei(check_path)),
timeout=check_timeout,
)
if status != OK:
return status, None
if not probleme:
return OK, None
_log(ctx.topic, f"{name}-Prüfung: {len(probleme)} Problem(e) notiert")
await _set_step(ctx.guide_id, step, f"Passe {name} an…")
status, fixed = await run_single_slot(
ctx, f"{name}-Fix", key=fix_key, prompt=build_fix_prompt(probleme),
role=fix_role, capabilities=fix_caps, payload=fix_payload, timeout=fix_timeout,
)
if status == CANCELLED:
return CANCELLED, None
if status == FAILED:
_log(ctx.topic, f"{name}-Fix ungültig — Original bleibt")
if on_fix_invalid:
on_fix_invalid()
return OK, None
return OK, fixed
async def wrap(c):
nonlocal done
try:
return await c
finally:
done += 1
await report(done, total)
await report(done, total)
return await asyncio.gather(*[wrap(c) for c in coros], return_exceptions=True)

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

648
backend/qa.py Normal file
View File

@@ -0,0 +1,648 @@
"""Independent quality audit over a FINISHED generation run — read-only.
Measures the MECE goal ("no duplicates, no gaps") with detectors that deliberately
do NOT reuse the pipeline's heuristics (_canonical_key/_relation_conflict/_evidence_pack)
— shared blind spots would make the audit worthless. Shared infra only: DB access,
embedding.py, the agent runner (--llm sampling), atomic_write_json.
CLI: python3 qa.py <topic> [--llm] (or: make qa TOPIC=<topic> [LLM=1])
Report: storage/qa/<topic>/<run_id|timestamp>.json + console digest + diff to the
previous report of the same topic.
"""
import asyncio
import hashlib
import logging
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import database as db
import embedding
from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from paths import arbeit_dir
from textkit import _norm_title
log = logging.getLogger("creator.qa")
QA_DIR = STORAGE_DIR / "qa"
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious
EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65)
SECTION_CHARS = 4000 # own paragraph splitter — independent of _text_sections
COVER_MIN_TOKENS = 2 # distinctive block tokens a section must share to count as covered
FREMD_MIN_TOKENS = 1 # distinctive title tokens that must appear in the corpus
CONCEPT_COVER_RATIO = 0.6 # fraction of a named result's stems a block must share to count as covered
LLM_SAMPLE = 12 # pairs/sections per judge call with --llm
# Note 0-10, deterministisch aus den Quoten (transparent, diffbar — keine LLM-"Gefühlsnote").
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff). konzept_luecken (benannte Kernresultate
# ohne Baustein) ist heuristisch → mittleres Gewicht. Dubletten-VERDACHT enthält bewusst Rauschen,
# bildet aber das Nutzerproblem (Dopplungen) ab → leichtestes Gewicht, aber nicht null.
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "konzept_luecken": 1.5,
"dubletten_verdacht": 1.0, "hygiene": 0.5}
# subs/artefacts only exist after board 2 — at gate time these quotas would always be 0
# and water down the inventory score, hence a separate score.
# sub_dubletten counts only with --llm (confirmed pairs); the bare candidate list is
# suspicion (sub_dubletten_verdacht, weightless — like dubletten_verdacht).
NOTE_GEWICHTE_ARTEFAKTE = {"subs_ohne_beleg": 2.0, "verwaiste": 1.0, "sub_dubletten": 1.0}
_WORD = re.compile(r"\w{3,}")
_PAREN = re.compile(r"^\s*(.*?)\s*\(([^()]{2,60})\)\s*$")
_STOP = {"der", "die", "das", "und", "oder", "für", "mit", "von", "des", "den", "dem",
"ein", "eine", "the", "and", "for", "with", "als", "auf", "bei", "aus",
"problem", "algorithmus", "algorithm", "definition", "satz", "lemma"}
def _tokens(s: str) -> set[str]:
return {t for t in _WORD.findall((s or "").casefold()) if t not in _STOP}
def _distinctive(s: str) -> set[str]:
"""Tokens that can anchor a title in a corpus (stopword-free, ≥3 chars)."""
return _tokens(s)
def _ascii(t: str) -> str:
return "".join(c for c in t if c.isascii())
def _jaccard(a: set[str], b: set[str]) -> float:
return len(a & b) / len(a | b) if a | b else 0.0
def _sections(text: str, goal: int | None = None) -> list[str]:
"""Own paragraph-boundary splitter (NOT blocks._text_sections — independence)."""
goal = goal or SECTION_CHARS
out, buf = [], ""
for para in re.split(r"\n\s*\n", text.strip()):
para = para.strip()
if not para:
continue
if buf and len(buf) + len(para) > goal:
out.append(buf)
buf = para
else:
buf = f"{buf}\n\n{para}" if buf else para
if buf.strip():
out.append(buf)
return out
def _corpus_texts(topic: str) -> dict[str, str]:
from blocks import source_folder # lazy: blocks pulls heavy deps
folder = source_folder(topic)
if not folder or not folder.is_dir():
return {}
out = {}
for f in sorted(folder.glob("*.txt")):
try:
text = f.read_text(encoding="utf-8")
except OSError:
continue
# QUELLE:-Header (Metadata, kein Lerninhalt) strippen — sonst bildet er bei großen
# Seiten einen isolierten, block-losen Abschnitt = False-Positive-Lücke.
lines = text.splitlines()
if lines and lines[0].startswith("QUELLE:"):
text = "\n".join(lines[1:]).lstrip("\n")
out[f.name] = text
return out
# ── Detectors ───────────────────────────────────────────────────────────────────────
def dubletten(blocks: list[dict], emb_on: bool = True) -> list[dict]:
"""Suspicious pairs via signal UNION: token jaccard, casefolded-title embedding
cosine, paren acronym == other title. Every signal is independent of the pipeline."""
titles = [b["title"] for b in blocks]
toks = [_tokens(t) for t in titles]
sims = None
if emb_on and titles and embedding.available():
arr = embedding.embed([t.casefold() for t in titles])
if arr is not None:
sims = arr @ arr.T
ops = [bool(re.search(r"[≤⪯≥⊆⊊→⇒⟹⇔←]", t)) for t in titles]
out = []
for i in range(len(titles)):
for j in range(i + 1, len(titles)):
# relation vs. its operand ("Subset Sum" ⊂ "3-SAT ≤ Subset Sum"): by design
# separate entities — token containment there is expected, not suspicious
if ops[i] != ops[j] and (toks[i] <= toks[j] or toks[j] <= toks[i]):
continue
signals = {}
jac = _jaccard(toks[i], toks[j])
if jac >= JACCARD_FLOOR:
signals["jaccard"] = round(jac, 2)
if sims is not None and float(sims[i][j]) >= EMB_FLOOR:
signals["emb_cos"] = round(float(sims[i][j]), 2)
for a, b in ((i, j), (j, i)):
m = _PAREN.match(titles[a])
if m and _norm_title(titles[b]) in (_norm_title(m.group(1)), _norm_title(m.group(2))):
signals["akronym"] = True
if signals:
out.append({"a": titles[i], "b": titles[j], "signale": signals})
return out
def sub_dubletten(sub_rows: list[dict], emb_on: bool = True) -> list[dict]:
"""Suspicious SUB pairs, in-block AND cross-block: casefolded title cosine ≥
SUB_DUP_KANDIDAT_COS. The pipeline's own merge paths act from 0.90 upward — the
measured bulk of real paraphrase duplicates sits in the band below, so everything
above the floor is a candidate. The verdict falls with --llm; without it this is
a suspicion list only (weightless)."""
cons = [r for r in sub_rows if r["status"] == "consensus"]
if len(cons) < 2 or not emb_on or not embedding.available():
return []
arr = embedding.embed([r["sub_title"].casefold() for r in cons])
if arr is None:
return []
sims = arr @ arr.T
out = []
for i in range(len(cons)):
for j in range(i + 1, len(cons)):
v = float(sims[i][j])
if v >= SUB_DUP_KANDIDAT_COS:
out.append({"a": f"[{cons[i]['block']}] {cons[i]['sub_title']}",
"b": f"[{cons[j]['block']}] {cons[j]['sub_title']}",
"cos": round(v, 2),
"cross": cons[i]["block_norm"] != cons[j]["block_norm"]})
return sorted(out, key=lambda p: -p["cos"])
def luecken(blocks: list[dict], subs_by_norm: dict[str, list[str]], corpus: dict[str, str]) -> list[dict]:
"""Corpus sections no block (title+description+subs tokens) sufficiently anchors.
Description tokens matter at the QA GATE: board 2 has not run yet, so titles alone
under-cover and inflate the quota."""
anchors: list[set[str]] = []
for b in blocks:
t = _distinctive(b["title"]) | _distinctive(b.get("description") or "")
for s in subs_by_norm.get(_norm_title(b["title"]), []):
t |= _distinctive(s)
anchors.append(t)
out = []
for fname, text in corpus.items():
for k, sec in enumerate(_sections(text), 1):
sec_toks = _tokens(sec)
covered = any(len(a & sec_toks) >= COVER_MIN_TOKENS for a in anchors)
if not covered:
preview = " ".join(sec.split())[:120]
out.append({"datei": fname, "abschnitt": k, "vorschau": preview})
return out
# A NAMED result carries a concept name, not just a number: "Satz 7.13 (Christofides)",
# "Satz 6.24: Cook-Levin". These are the core statements the token-based `luecken` misses — 108
# over-granular blocks cover every SECTION, yet the named core result may have no block at all.
# Generic + domain-safe: the NUMBER is mandatory (a numbered labeled unit is a formal statement in
# a structured document, any field), so prose like "ein Satz von Goethe" (no number) never matches.
_NAMED_RESULT_RE = re.compile(
r'\b(?:Satz|Lemma|Korollar|Theorem|Proposition|Folgerung|Definition|Algorithmus)\s+\d+(?:\.\d+)*\s*'
r'(?:\(\s*([^()\n]{3,60}?)\s*\)|:\s*([^\n.;·]{3,60}?)\s*(?:[.\n;·]|$))', re.M)
def _stem(t: str) -> str:
"""Declension-tolerant token stem: drop trailing digits, keep the 5-char prefix
('Eulerschen'/'Eulerscher''euler', 'Kreise'/'Kreis''kreis')."""
return t.rstrip("0123456789")[:5]
def _named_results(corpus: dict[str, str]) -> dict[str, set[str]]:
"""Named/attributed corpus results → {concept name: distinctive stems}. A bare 'Satz 7.18'
(number, no name) yields nothing to match. Same catalogue vocabulary as the title strip.
Umbruch-Schutz (generisch): endet ein Doppelpunkt-Fang am Zeilenumbruch und läuft die
nächste Zeile klein weiter, ist es ein UMBROCHENER SATZ, kein Name — solche Fragmente
(„Satz 6.16: Wenn ein … in P⏎liegt …") kann kein Block je ankern (Phantom-Lücke)."""
out: dict[str, set[str]] = {}
for text in corpus.values():
for m in _NAMED_RESULT_RE.finditer(text):
name = (m.group(1) or m.group(2) or "").strip()
if m.group(2) and m.end() > 0 and text[m.end() - 1] == "\n":
rest = text[m.end():].lstrip(" \t")
if rest[:1].islower():
continue # Satzfragment: Fortsetzung klein auf der nächsten Zeile
toks = _distinctive(name)
if len(name) >= 3 and toks:
out.setdefault(name, set()).update(_stem(t) for t in toks)
return out
def konzept_luecken(blocks: list[dict], named: dict[str, set[str]]) -> list[str]:
"""Named corpus results that NO block covers — concept gaps the token-based `luecken` cannot see.
Covered = a block whose title+description share ≥ CONCEPT_COVER_RATIO of the result's distinctive
stems (declension-tolerant). Errs toward 'covered' so the heuristic never invents a false gap."""
anchors = [{_stem(t) for t in _distinctive(b["title"]) | _distinctive(b.get("description") or "")}
for b in blocks]
out = []
for name, stems in named.items():
if not any(len(stems & a) >= CONCEPT_COVER_RATIO * len(stems) for a in anchors):
out.append(name)
return sorted(out)
def fremd(blocks: list[dict], corpus: dict[str, str]) -> list[str]:
"""Blocks whose distinctive title tokens never appear in the corpus (scope creep).
Token/stem match, NOT raw substring — 'bergang''Übergang' had whitewashed the
garbage title 'αÜbergang'. The ASCII form only bridges symbol variants (Δ/∆)."""
ctoks = set(_WORD.findall("\n".join(corpus.values()).casefold()))
def _hit(t: str) -> bool:
forms = {t} | ({a} if len(a := _ascii(t)) >= 3 else set())
# digit-suffix fallback: '∆TSP1' → 'tsp1' misses the corpus token 'tsp' ('∆' is no \w)
forms |= {f2 for f in list(forms) if len(f2 := f.rstrip("0123456789")) >= 3}
return any(ct == f or ct.startswith(f) for f in forms for ct in ctoks)
out = []
for b in blocks:
dist = _distinctive(b["title"])
if dist and sum(1 for t in dist if _hit(t)) < FREMD_MIN_TOKENS:
out.append(b["title"])
return out
def beleg(blocks: list[dict], sub_rows: list[dict]) -> dict:
ohne_quelle = [b["title"] for b in blocks if not b.get("sources")]
ohne_mention = [f"{r['block']} · {r['sub_title']}" for r in sub_rows
if r["status"] != "variant" and not r["mentions"]]
return {"bloecke_ohne_quelle": ohne_quelle, "subs_ohne_beleg": ohne_mention}
def hygiene(blocks: list[dict]) -> list[dict]:
out = []
for b in blocks:
t = b["title"]
probleme = []
if "**" in t or "`" in t:
probleme.append("markdown")
if re.search(r"\(\d+\)\s*$", t):
probleme.append("kollisions-suffix")
if not (b.get("description") or "").strip():
probleme.append("leere-beschreibung")
if probleme:
out.append({"titel": t, "probleme": probleme})
return out
def _zaehlbare_luecken(lk: list[dict], llm: bool) -> list[dict]:
"""With --llm only non-refuted gaps count ('?' = unjudged stays, conservative) — refuted
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).
Persistierte Freisprüche (2:1 im Repair) zählen nie."""
offen = [x for x in lk if not x.get("freispruch")]
return [x for x in offen if x.get("llm") != "nein"] if llm else offen
def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float:
"""10 = alle gewichteten Quoten 0. Gewicht = Punktabzug bei 100 % Quote (keine Normierung,
sonst staucht die Gewichtssumme die Skala nach oben). Ungemessene Quoten zählen nicht —
unechte_bloecke existiert nur mit --llm; dubletten_verdacht ist Verdachtsliste, kein Urteil."""
da = {k: w for k, w in gewichte.items() if k in quoten}
schaden = sum(w * min(float(quoten[k]), 1.0) for k, w in da.items())
return round(max(0.0, 10.0 * (1 - schaden)), 1)
def artefakte(sub_rows: list[dict], art_rows: list[dict], fragen: list[dict]) -> dict:
"""Coverage + orphans of the learning artefacts. Nenner = consensus-Subs (verworfene
zählen nicht als abzudeckendes Material). Waise = Ziel weder lebend (consensus/variant)
noch eindeutig als Kurztitel-Präfix von „kurztitel: beschreibung" auflösbar."""
if not art_rows and not fragen:
return {"status": "nicht generiert"}
cons = {(r["block_norm"], r["sub_norm"]) for r in sub_rows if r["status"] == "consensus"}
lebt = {(r["block_norm"], r["sub_norm"]) for r in sub_rows if r["status"] != "discarded"}
def _ziel(bn: str, sn: str):
if (bn, sn) in lebt:
return (bn, sn)
treffer = [k for k in lebt if k[0] == bn and k[1].startswith(sn + ":")]
if len(treffer) == 1:
return treffer[0]
# mehrere Treffer = meist ein consensus-Sub plus seine gefalteten Varianten
haupt = [k for k in treffer if k in cons]
return haupt[0] if len(haupt) == 1 else None
deck: dict[str, set] = {}
verwaist = []
for typ, bn, sn in ([(r["type"], r["block_norm"], r["sub_norm"]) for r in art_rows]
+ [("frage", r["block_norm"], r["sub_norm"]) for r in fragen]):
z = _ziel(bn, sn)
if z is None:
verwaist.append(f"{typ}: {bn} · {sn}")
else:
deck.setdefault(typ, set()).add(z)
n = max(len(cons), 1)
return {"status": "ok",
"frage_abdeckung": round(len(deck.get("frage", set()) & cons) / n, 3),
"flashcard_abdeckung": round(len(deck.get("flashcard", set()) & cons) / n, 3),
"beispiel_abdeckung": round(len(deck.get("example", set()) & cons) / n, 3),
"verwaiste": sorted(verwaist)}
# ── LLM sampling (optional, own prompts under templates/QA/) ────────────────────────
def _qa_prompt(name: str, **kwargs) -> str:
"""Own template dir (templates/QA/) — deliberately separate from the pipeline prompts."""
from config import TEMPLATES_DIR
return (TEMPLATES_DIR / "QA" / f"{name}.md").read_text(encoding="utf-8").format(**kwargs)
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA",
extra: str = "") -> dict[int, str]:
"""Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in
agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen
Handkopien in repair/qa/guide_qa. `extra` füllt den {extra}-Slot des Templates
(z. B. Inventar-Kontext für das Lücken-Urteil im Repair)."""
from agents import run_agent
from pipeline import _timeout, _yesno_schema
from jsonio import parse_json_text
async def _chunk(lo: int) -> dict[int, str]:
teil = items[lo:lo + chunk]
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
try:
rc, out, _err = await run_agent(
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
except Exception:
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
return {}
if rc != 0:
log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil",
topic, label, key, lo, rc, len(teil))
return {}
return _yesno_schema(parse_json_text(out)) or {}
offsets = range(0, len(items), chunk)
results = await asyncio.gather(*[_chunk(lo) for lo in offsets])
return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()}
# ── Report ──────────────────────────────────────────────────────────────────────────
def report_paths(topic: str, guide: bool = False) -> list[Path]:
"""QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren
lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py).
freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor."""
tdir = QA_DIR / topic
if not tdir.is_dir():
return []
return sorted((p for p in tdir.glob("*.json")
if p.name.startswith("guide-") == guide and p.name != "freispruch.json"),
key=lambda p: p.stat().st_mtime)
_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {}
def latest_report(topic: str, guide: bool = False) -> dict | None:
"""Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das
im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat
bleiben (billig), der Read passiert nur bei geänderter mtime."""
reports = report_paths(topic, guide)
if not reports:
return None
p = reports[-1]
mtime = p.stat().st_mtime
key = (topic, guide)
cached = _latest_cache.get(key)
if cached is None or cached[0] != mtime:
_latest_cache[key] = (mtime, _json_file(p) or {})
return _latest_cache[key][1]
def freispruch_pfad(topic: str) -> Path:
return QA_DIR / topic / "freispruch.json"
def _paar_key(a: str, b: str) -> str:
return "||".join(sorted((_norm_title(a), _norm_title(b))))
def luecken_key(item: dict) -> str:
"""Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash.
Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys
und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil)."""
v = " ".join(str(item.get("vorschau", "")).casefold().split())
return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}"
def offene_luecken(report: dict) -> tuple[list[dict], list[str]]:
"""Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein,
konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair."""
lk = [x for x in report.get("luecken", [])
if not x.get("freispruch") and x.get("llm") != "nein"]
return lk, list(report.get("konzept_luecken", []))
def lade_freispruch(topic: str) -> dict[str, list[str]]:
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst
pendelte sie dauerhaft unter 10 ohne Fix-Pfad (gemessen: kanban-smoke 9.4, aak 9.2).
Die Detektoren bleiben unverändert; ein Freispruch ist ein persistiertes Urteil,
kein Detektor-Tuning. Freigesprochene bleiben im Report sichtbar."""
d = _json_file(freispruch_pfad(topic))
return d if isinstance(d, dict) else {}
async def qa_report(topic: str, llm: bool = False) -> dict | None:
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
if not cards:
print(f"Keine fertigen Blöcke für '{topic}' — Tippfehler im Namen oder Lauf nicht durch?")
return None
blocks = [{"title": c["payload"].get("title", ""), "description": c["payload"].get("description", ""),
"sources": c["payload"].get("sources") or []} for c in cards]
sub_rows = [dict(r) for bn in {_norm_title(b["title"]) for b in blocks}
for r in await db.list_subblocks(topic, bn)]
subs_by_norm: dict[str, list[str]] = {}
for r in sub_rows:
if r["status"] != "variant":
subs_by_norm.setdefault(r["block_norm"], []).append(r["sub_title"])
corpus = _corpus_texts(topic)
d = dubletten(blocks)
sd = sub_dubletten(sub_rows)
lk = luecken(blocks, subs_by_norm, corpus) if corpus else []
fr = fremd(blocks, corpus) if corpus else []
named = _named_results(corpus) if corpus else {}
kl = konzept_luecken(blocks, named) if corpus else []
bl = beleg(blocks, sub_rows)
hy = hygiene(blocks)
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
frei = lade_freispruch(topic)
frei_fremd = set(frei.get("fremd") or [])
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
# Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr
# in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10.
frei_dub = set(frei.get("dubletten") or [])
for p in d:
if _paar_key(p["a"], p["b"]) in frei_dub:
p["freispruch"] = True
d_offen = [p for p in d if not p.get("freispruch")]
frei_lk = set(frei.get("luecken") or [])
for x in lk:
if luecken_key(x) in frei_lk:
x["freispruch"] = True
lk_offen = [x for x in lk if not x.get("freispruch")]
frei_kl = set(frei.get("konzept_luecken") or [])
kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl]
kl = [nm for nm in kl if _norm_title(nm) not in frei_kl]
if llm and d_offen:
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?")
if llm and lk_offen:
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in sd])
for k, p in enumerate(sd, 1):
p["llm"] = v.get(k, "?")
frei_sub = set(frei.get("sub_dubletten") or [])
for p in sd:
if p.get("llm") == "ja" and _paar_key(p["a"], p["b"]) in frei_sub:
p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei
unecht: list[str] | None = None
if llm and blocks:
v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks",
[f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in blocks],
chunk=80)
verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"]
# Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE
# Blöcke (gemessen aak: Note pendelte 9.3↔10.0 bei identischem Bestand) — nur
# doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung
unecht = []
if verdacht:
v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks",
[f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
frei_unecht = set(frei.get("unecht") or [])
unecht = [t for t in unecht if _norm_title(t) not in frei_unecht]
art_rows = [dict(r) for r in await db.get_sub_artefakte(topic)]
fragen = [dict(r) for r in await db.list_question_pattern(topic)]
art = artefakte(sub_rows, art_rows, fragen)
quoten_art: dict[str, float] = {}
if sub_rows:
quoten_art["subs_ohne_beleg"] = round(len(bl["subs_ohne_beleg"]) / len(sub_rows), 3)
if art.get("status") == "ok":
quoten_art["verwaiste"] = round(len(art["verwaiste"]) / max(len(art_rows) + len(fragen), 1), 3)
n_cons = sum(1 for r in sub_rows if r["status"] == "consensus")
if n_cons:
quoten_art["sub_dubletten_verdacht"] = round(len(sd) / n_cons, 3)
if llm: # confirmed pairs only — the bare candidate list is suspicion, not damage;
# freigesprochene (2:1 „behalten") zählen nicht mehr
quoten_art["sub_dubletten"] = round(
sum(1 for p in sd if p.get("llm") == "ja" and not p.get("freispruch")) / n_cons, 3)
summary = _json_file(arbeit_dir(topic) / "lauf-summary.json") or {}
report = {
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
"run_id": summary.get("run_id", ""), "bloecke": len(blocks),
"quoten": {
"dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3),
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
"fremd": round(len(fr) / max(len(blocks), 1), 3),
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
"hygiene": round(len(hy) / max(len(blocks), 1), 3),
**({"unechte_bloecke": round(len(unecht) / max(len(blocks), 1), 3)} if unecht is not None else {}),
},
"quoten_artefakte": quoten_art,
**({"unecht": unecht} if unecht is not None else {}),
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
**({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
"fremd": fr, "beleg": bl, "hygiene": hy,
"artefakte": art,
"lauf": summary,
}
report["note"] = note(report["quoten"])
# None statt 10.0, solange Board 2 nichts geliefert hat — nichts gemessen ist keine Bestnote
report["note_artefakte"] = note(quoten_art, NOTE_GEWICHTE_ARTEFAKTE) if quoten_art else None
report["note_gewichte"] = {"inventar": NOTE_GEWICHTE, "artefakte": NOTE_GEWICHTE_ARTEFAKTE}
return report
def _diff(prev: dict | None, cur: dict) -> dict:
if not prev:
return {}
# ältere Reports führten die Artefakt-Quoten noch unter "quoten"
alt = {**prev.get("quoten", {}), **prev.get("quoten_artefakte", {})}
neu = {**cur["quoten"], **cur.get("quoten_artefakte", {})}
return {k: round(v - alt.get(k, 0), 3) for k, v in neu.items()}
def _write_report(report: dict) -> Path:
tdir = QA_DIR / report["topic"]
tdir.mkdir(parents=True, exist_ok=True)
older = report_paths(report["topic"])
prev = _json_file(older[-1]) if older else None
report["diff_zum_vorlauf"] = _diff(prev, report)
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
path = tdir / f"{name}.json"
atomic_write_json(path, report, indent=1)
return path
async def write_report(report: dict) -> Path:
"""_write_report + kompaktes kind='qa'-Event. Die Report-JSONs liegen nur auf der
Lauf-Maschine (storage/qa/) — ein DB-Pull reichte nicht, um Note/Quoten eines Runs
zu rekonstruieren (Analyse 20260704-1452-b223). Nur die Kennzahlen, kein Volltext;
run_id stempelt add_event aus der Registry (gesetzt im Lauf, leer bei manueller QA)."""
path = await asyncio.to_thread(_write_report, report)
try: # Event ist Komfort — ein DB-Fehler darf den Report nicht kosten (fail-open)
await db.add_event(report["topic"], "qa", key=path.stem, meta={
"note": report["note"], "note_artefakte": report.get("note_artefakte"),
"quoten": report["quoten"], "quoten_artefakte": report.get("quoten_artefakte", {})})
except Exception:
pass
return path
def _digest(report: dict, path: Path):
na = report.get("note_artefakte")
print(f"QA {report['topic']}{report['bloecke']} Blöcke (run {report['run_id'] or ''})"
f" — Inventar {report['note']}/10 · Artefakte {f'{na}/10' if na is not None else ''}")
for k, v in {**report["quoten"], **report.get("quoten_artefakte", {})}.items():
delta = report.get("diff_zum_vorlauf", {}).get(k)
d = f" ({'+' if delta > 0 else ''}{delta})" if delta else ""
print(f" {k:20} {v:6.1%}{d}")
for p in report["dubletten"][:8]:
print(f" DUBLETTE? {p['a']} <-> {p['b']} {p['signale']}{' LLM:' + p['llm'] if 'llm' in p else ''}")
for p in report.get("sub_dubletten", [])[:8]:
print(f" SUB-DUP? {p['a']} <-> {p['b']} cos={p['cos']}{' LLM:' + p['llm'] if 'llm' in p else ''}")
for t in report["fremd"][:8]:
print(f" FREMD? {t}")
for t in report.get("unecht", [])[:8]:
print(f" UNECHT {t}")
art = report["artefakte"]
if art.get("status") == "ok":
print(f" Artefakte: Frage {art['frage_abdeckung']:.0%} · Flashcard {art['flashcard_abdeckung']:.0%}"
f" · Beispiel {art['beispiel_abdeckung']:.0%} · verwaist {len(art['verwaiste'])}")
else:
print(" Artefakte: nicht generiert (Board 2 nicht gelaufen)")
print(f"Report: {path}")
async def main(topic: str, llm: bool):
await db.init_db()
try:
report = await qa_report(topic, llm=llm)
if report is None:
sys.exit(1)
_digest(report, await write_report(report))
finally:
await db.close_db()
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print("Nutzung: python3 qa.py <topic> [--llm]")
sys.exit(1)
asyncio.run(main(args[0], "--llm" in sys.argv))

115
backend/readability.py Normal file
View File

@@ -0,0 +1,115 @@
"""Deterministic readability gate for guide sections.
A small German complexity model (DistilBERT, GermEval 2022, scale 17) rates the
readability of the prose. guide.py feeds sections that are too hard into the existing
read-exam/revision loop — no prompt, no guessing.
Optional: if `transformers`/`torch` are missing or the model won't load, the gate is
silently disabled (the backend keeps running unchanged). CPU is enough; the caller
wraps the scoring in `asyncio.to_thread` (blocking model inference).
"""
import logging
import re
from config import (
READABILITY_ACTIVE, READABILITY_HARD, READABILITY_HARD_SHARE, READABILITY_MAX, READABILITY_MODEL,
)
log = logging.getLogger("creator.readability")
_model_cache = None # (tokenizer, model, torch) — singleton
_load_attempt = False # already tried to load?
# Strip markup → plain prose (code does not count toward readability).
_CODE_FENCE = re.compile(r"```.*?```", re.DOTALL)
_COMMENT = re.compile(r"<!--.*?-->", re.DOTALL)
_INLINE_CODE = re.compile(r"`[^`]*`")
_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_MD_MARK = re.compile(r"^[ \t]*([#>]+|[-*+]\s)|[*_~|]", re.MULTILINE)
_WS = re.compile(r"\s+")
_SENTENCE = re.compile(r"(?<=[.!?])\s+")
def _model():
"""Load the model once. None = gate off (disabled or load error)."""
global _model_cache, _load_attempt
if _load_attempt:
return _model_cache
_load_attempt = True
if not READABILITY_ACTIVE:
return None
try:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
tok = AutoTokenizer.from_pretrained(READABILITY_MODEL)
model = AutoModelForSequenceClassification.from_pretrained(READABILITY_MODEL)
model.eval()
_model_cache = (tok, model, torch)
log.info("readability model loaded: %s (num_labels=%d)", READABILITY_MODEL, model.config.num_labels)
except Exception as e:
log.warning("readability gate disabled (model not loadable): %s", e)
_model_cache = None
return _model_cache
def _prose(md: str) -> str:
"""Strip markdown/code → plain prose for scoring."""
t = _CODE_FENCE.sub(" ", md)
t = _COMMENT.sub(" ", t)
t = _INLINE_CODE.sub(" ", t)
t = _LINK.sub(r"\1", t)
t = _MD_MARK.sub(" ", t)
return _WS.sub(" ", t).strip()
def _sentences(text: str) -> list[str]:
"""Split prose into sentences; discard very short fragments."""
return [s.strip() for s in _SENTENCE.split(text) if len(s.strip()) >= 15]
def _scores(sentences: list[str]) -> list[float]:
"""Complexity per sentence (17). Regression (num_labels=1) or expectation over classes."""
tok, model, torch = _model_cache
values: list[float] = []
n = model.config.num_labels
for i in range(0, len(sentences), 16):
batch = sentences[i:i + 16]
enc = tok(batch, return_tensors="pt", truncation=True, max_length=256, padding=True)
with torch.no_grad():
logits = model(**enc).logits
if n == 1:
vals = logits.reshape(-1).tolist()
else:
probs = torch.softmax(logits, dim=-1)
levels = torch.arange(1, n + 1, dtype=probs.dtype)
vals = (probs * levels).sum(-1).reshape(-1).tolist()
values.extend(vals)
return values
def rate_sections(md_by_num: dict[int, str]) -> dict[int, str]:
"""{num: section_md} → {num: hint} only for sections that are too hard.
Empty dict if the gate is off. Blocking (CPU) — call inside to_thread.
"""
if _model() is None:
return {}
out: dict[int, str] = {}
for num, md in md_by_num.items():
sentences = _sentences(_prose(md or ""))
if len(sentences) < 2: # almost only code / too short → skip
continue
values = _scores(sentences)
if not values:
continue
mean = sum(values) / len(values)
hard = sum(1 for w in values if w > READABILITY_HARD) / len(values)
# Too hard = high mean OR too many hard individual sentences (outlier nests).
if mean > READABILITY_MAX or hard >= READABILITY_HARD_SHARE:
# German revision hint fed to the (German-writing) writer agent — kept German on purpose.
out[num] = (
f"Zu schwer lesbar (Ø {mean:.1f}/7, {hard * 100:.0f}% harte Sätze): "
"kürzere Sätze, einfachere Wörter, weniger Schachtelsätze, mehr Examples."
)
return out

View File

@@ -1,88 +0,0 @@
"""Lernschulden-Regeln: Progression und Deckel für offene Guides — die EINZIGE Quelle.
Regeln (nur Neu-Erstellungen; Themen, Bausteine, OnePager unbegrenzt):
- JE Format (MiniGuide/Guide/FullGuide) höchstens 3 erstellte, nicht absolvierte Guides
- Progression pro Thema: Guide erst nach absolviertem MiniGuide, FullGuide erst nach absolviertem Guide
Alle Funktionen arbeiten auf einmal geladenen Daten (lade_lernstand) — keine
Query-Schleifen mehr pro Guide.
"""
import json
from database import list_guides, list_progress_all
from guide import guide_slot_dateien
from paths import bausteine_path, guide_content_path
MAX_OFFENE_GUIDES = 3
VORSTUFE = {"Guide": "MiniGuide", "FullGuide": "Guide"}
FORMATE = ("MiniGuide", "Guide", "FullGuide")
async def lade_lernstand() -> tuple[list[dict], dict[str, set[str]]]:
"""Guides + kompletter Kapitel-Fortschritt in zwei Queries."""
return await list_guides(), await list_progress_all()
def _kapitel_titel(topic: str, fmt: str) -> set[str] | None:
path = guide_content_path(topic, fmt)
if not path.exists():
return None
try:
chapters = json.loads(path.read_text(encoding="utf-8")).get("chapters", [])
except ValueError:
return None
return {c.get("title") for c in chapters}
def _neueste_done(guides: list[dict], fmt: str) -> dict[str, dict]:
"""Pro Thema der neueste fertige Guide dieses Formats."""
neueste: dict[str, dict] = {}
for g in guides:
if g["format"] == fmt and g["status"] == "done":
if g["topic"] not in neueste or g["created_at"] > neueste[g["topic"]]["created_at"]:
neueste[g["topic"]] = g
return neueste
def _guide_absolviert(g: dict, progress: dict[str, set[str]]) -> bool:
titles = _kapitel_titel(g["topic"], g["format"])
return bool(titles) and titles <= progress.get(g["id"], set())
def ist_absolviert(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> bool:
"""Alle Kapitel des neuesten fertigen Guides (Thema+Format) abgehakt?"""
g = _neueste_done(guides, fmt).get(topic)
return g is not None and _guide_absolviert(g, progress)
def formate_stats(guides: list[dict], progress: dict[str, set[str]]) -> dict:
"""Pro Format erstellt/absolviert — pro Thema zählt nur der neueste fertige Guide."""
formate = {}
for fmt in FORMATE:
neueste = _neueste_done(guides, fmt)
absolviert = sum(1 for g in neueste.values() if _guide_absolviert(g, progress))
formate[fmt] = {"erstellt": len(neueste), "absolviert": absolviert}
return formate
def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]]) -> str | None:
"""Grund, warum ein Neu-Start für Thema+Format gesperrt ist — None = erlaubt.
Exakt die Regeln aus POST /guides: Bausteine nötig, kein Duplikat-Start,
Lernschulden nur für echte Neu-Erstellungen (Resume/Regenerieren frei).
"""
if fmt != "OnePager" and not bausteine_path(topic).exists():
return "Erst Bausteine erstellen"
for g in guides:
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
return "Generierung läuft bereits"
content = guide_content_path(topic, fmt)
if fmt != "OnePager" and not content.exists() and not guide_slot_dateien(content):
vorstufe = VORSTUFE.get(fmt)
if vorstufe and not ist_absolviert(topic, vorstufe, guides, progress):
return f"Erst den {vorstufe} dieses Themas absolvieren"
stat = formate_stats(guides, progress).get(fmt, {"erstellt": 0, "absolviert": 0})
offen = stat["erstellt"] - stat["absolviert"]
if offen >= MAX_OFFENE_GUIDES:
return f"Erst {fmt}s absolvieren — maximal {MAX_OFFENE_GUIDES} offene erlaubt ({offen} offen)"
return None

567
backend/repair.py Normal file
View File

@@ -0,0 +1,567 @@
"""Befund-Repair: arbeitet den jüngsten QA-Report gezielt ab — ohne Flow, ohne Board-Rebuild.
Blindes Re-Filtern reproduziert die blinden Flecken der Pipeline (sie hat die Befunde ja
durchgelassen). Hier fließen die QA-BEFUNDE als Input in gezielte Aktionen: Hygiene
deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
import logging
import re
import database as db
import qa
from blocks import _blocks_files, _evidence_pack, material_folder, source_folder
from config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from textkit import _norm_title, _title, clean_title, parse_facts
log = logging.getLogger("creator.repair")
async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
"""ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch
oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche
noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die
Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0."""
reports = qa.report_paths(topic)
report = _json_file(reports[-1]) if reports else None
if not report:
return {"fehler": "kein QA-Report — erst QA laufen lassen"}
files = _blocks_files(topic)
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards}
inv = ebene in (None, "inventory")
art = ebene in (None, "artefacts")
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else []
hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else []
merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], [])
sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else []
beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else []
aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
neu = await qa.qa_report(topic, llm=True)
if neu:
await qa.write_report(neu)
na = neu.get("note_artefakte") if neu else None
freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken
lk_rest, kl_rest = qa.offene_luecken(neu or {})
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
"aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt)
+ len(beleg_fix) + aufgeraeumt + len(freigesprochen)),
"braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde
"note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note
"note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str],
extra: str = "") -> dict[int, str]:
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair",
extra=extra)
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
d = qa.lade_freispruch(topic)
alt = set(d.get(kategorie) or [])
d[kategorie] = sorted(alt | set(keys))
qa.freispruch_pfad(topic).parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qa.freispruch_pfad(topic), d, indent=1)
async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
lines: list[str], befund: str, kategorie: str = "",
ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]:
"""Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
die Note dauerhaft unter 10 ohne Fix-Pfad (gemessen: aak-fremd 9.2, kanban-smoke-
Dublette 9.4 — „keine behebbaren Befunde" trotz Befund).
Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
v = await _judge(template, topic, key, slot, lines, extra=extra)
strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
frei: list[str] = []
if strittig:
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra)
gegen = "nein" if befund == "ja" else "ja"
frei_keys: list[str] = []
for pos, i in enumerate(strittig, 1):
if v3.get(pos) == befund:
v[i] = befund # 2:1 für den QA-Befund → handeln
elif v3.get(pos) == gegen and kategorie and ids:
frei_keys.append(ids[i - 1])
frei.append(lines[i - 1].splitlines()[0][:80])
if frei_keys:
_speichere_freispruch(topic, kategorie, frei_keys)
log.info("[%s] Repair %s: %d Befund(e) per 2:1 freigesprochen", topic, kategorie, len(frei_keys))
return v, frei
async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen
generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und
hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund."""
from agents import run_agent
from pipeline import _prompt, _timeout
from jsonio import parse_json_text
from board_inventory import _sanierung_schema
items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])]
folder = source_folder(topic) or material_folder(topic)
if not items or not folder:
return []
out: list[str] = []
for h in items:
norm = _norm_title(h.get("titel", ""))
card = by_norm.get(norm)
if not card or (card["payload"].get("description") or "").strip():
continue
titel = card["payload"].get("title", "")
ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000)
if not ev:
continue
try:
rc, txt, _err = await run_agent(
f"repair-{topic}-sanierung-{norm[:24]}",
_prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic,
label="Repair Beschreibung")
except Exception:
log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic)
continue
verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None
if not verdict or not verdict[1].strip():
continue
p = dict(card["payload"])
p["description"] = verdict[1].strip()
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.set_block_status(topic, norm, "consensus", description=p["description"])
card["payload"] = p
out.append(f"beschrieben: {titel}")
return out
_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$")
async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln):
Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel
inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte +
Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der
Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem)."""
items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])]
out: list[str] = []
for h in items:
alt = h.get("titel", "")
basis = _SUFFIX_RE.sub("", alt).strip()
alt_norm, basis_norm = _norm_title(alt), _norm_title(basis)
card = by_norm.get(alt_norm)
if not card or not basis or basis_norm == alt_norm:
continue
if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename
merged, _frei = await _merge_dubletten(
topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files)
out += [f"suffix-merge: {z}" for z in merged]
continue
await db.rename_block_norm(topic, alt_norm, basis_norm, basis)
b2 = await db.kanban_get_card(topic, "artefacts", alt_norm)
if b2:
p2 = dict(b2["payload"])
p2["title"] = basis
await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2)
await db.kanban_delete_card(topic, "artefacts", alt_norm)
p = dict(card["payload"])
p["title"] = basis
if p.get("mirrored_norm"):
p["mirrored_norm"] = basis_norm
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
card["payload"] = p
_rename_in_files(files, alt_norm, basis)
by_norm.pop(alt_norm, None)
by_norm[basis_norm] = card
out.append(f"suffix: {alt}{basis}")
return out
async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem
Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert,
bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer
klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die
Judges Abdeckung gegen den Bestand prüfen können."""
lk, kl = qa.offene_luecken(report)
if not lk and not kl:
return []
extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n"
+ "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values()))
frei: list[str] = []
if lk:
_v, f = await _mit_stichentscheid(
"QA-Luecken", topic, "luecken", "sections",
[f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk],
"ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra)
frei += f
if kl:
_v, f = await _mit_stichentscheid(
"QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl),
"ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra)
frei += f
return frei
async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]:
"""subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen.
Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das
nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am
gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist
der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit.
Ohne Quellordner kein Urteil möglich → Befund bleibt."""
eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or []
folder = source_folder(topic) or material_folder(topic)
if not eintraege or not folder:
return []
rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic)
if r["status"] != "variant"}
items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]]
if not items:
return []
lines = []
for e, r in items:
ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK)
lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}")
v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines)
strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"]
v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks",
[lines[i - 1] for i in strittig])) if strittig else {}
out: list[str] = []
async def _verwerfen(e: str, r: dict):
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded")
_entferne_sub_in_files(files, r["block_norm"], r["sub_norm"])
out.append(f"entfernt: {e[:60]}")
for i, (e, r) in enumerate(items, 1):
if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg
await _verwerfen(e, r)
elif v1.get(i) == "ja": # Dissens → Stichentscheid
pos = strittig.index(i) + 1
if v3.get(pos) == "ja":
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1)
out.append(f"belegt: {e[:60]}")
elif v3.get(pos) == "nein":
await _verwerfen(e, r)
return out
async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen)."""
fixed = []
for h in report.get("hygiene", []):
alt = h.get("titel", "")
neu = clean_title(alt)
if neu == alt or _norm_title(neu) != _norm_title(alt):
continue
norm = _norm_title(alt)
card = by_norm.get(norm)
if not card:
continue
p = dict(card["payload"])
p["title"] = neu
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.set_block_status(topic, norm, "consensus", title=neu)
_rename_in_files(files, norm, neu)
fixed.append(f"{alt}{neu}")
return fixed
async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
"""Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
if not a or not b or a["card_id"] == b["card_id"]:
return None
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
len(c["payload"].get("title") or "")), reverse=True)
wp, lp = dict(win["payload"]), dict(lose["payload"])
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
lp.update(reason="merged", merged_into=wp.get("title", ""))
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
await _purge_block(topic, lp.get("title", ""), files)
by_norm.pop(_norm_title(lp.get("title", "")), None)
return f"{lp.get('title')}{wp.get('title')}"
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
"""QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein"
Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
alle = [p for p in report.get("dubletten", []) if not p.get("freispruch")
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
ja = [p for p in alle if p.get("llm") == "ja"]
offen = [p for p in alle if p.get("llm") != "ja"]
merged: list[str] = []
frei: list[str] = []
if ja:
v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja",
kategorie="dubletten",
ids=[qa._paar_key(p["a"], p["b"]) for p in ja])
frei += f
for i, p in enumerate(ja, 1):
if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)):
merged.append(zeile)
if offen:
ids = [qa._paar_key(p["a"], p["b"]) for p in offen]
v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in offen])
frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]
for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"]
if strittig:
v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs",
[f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig])
for pos, i in enumerate(strittig, 1):
if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)):
merged.append(zeile)
elif v3.get(pos) == "nein":
frei_keys.append(ids[i - 1])
frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80])
if frei_keys:
_speichere_freispruch(topic, "dubletten", frei_keys)
log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys))
return merged, frei
_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
"""Gewinner = mehr key_points im facts-Feld, dann längerer Titel (Muster Konsolidierung)."""
def score(r):
kp = len(parse_facts(r.get("facts")).get("key_points") or [])
return (kp, len(r.get("sub_title") or ""))
return (a, b) if score(a) >= score(b) else (b, a)
async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None:
"""Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder
löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern
von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows."""
await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
for r in await db.list_question_pattern(topic, lose["block_norm"]):
if r["sub_norm"] != lose["sub_norm"]:
continue
if win["sub_norm"] not in w_fragen:
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
win["block"], win["sub_title"], r["question"])
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
if r["sub_norm"] == win["sub_norm"]}
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
if r["sub_norm"] != lose["sub_norm"]:
continue
if r["type"] not in w_typen:
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
r["data"], win["block"], win["sub_title"])
await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
_entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> tuple[list[str], list[str]]:
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus."""
rows = {(r["block_norm"], r["sub_norm"]): r for r in await db.list_subblocks(topic)
if r["status"] == "consensus"}
def _row(eintrag: str):
m = _SUB_PAAR.match(eintrag or "")
return rows.get((_norm_title(m.group(1)), _norm_title(m.group(2)))) if m else None
paare = [(a, b) for p in report.get("sub_dubletten", []) if p.get("llm") == "ja"
and (a := _row(p.get("a"))) and (b := _row(p.get("b")))
and (a["block_norm"], a["sub_norm"]) != (b["block_norm"], b["sub_norm"])]
if not paare:
return [], []
v, frei = await _mit_stichentscheid(
"QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
[f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}" for a, b in paare],
"ja", kategorie="sub_dubletten",
ids=[qa._paar_key(f"[{a['block']}] {a['sub_title']}", f"[{b['block']}] {b['sub_title']}")
for a, b in paare])
merged: list[str] = []
gone: set[tuple] = set()
for i, (a, b) in enumerate(paare, 1):
win, lose = _sub_gewinner(a, b)
wk, lk = (win["block_norm"], win["sub_norm"]), (lose["block_norm"], lose["sub_norm"])
if v.get(i) != "ja" or wk in gone or lk in gone:
continue
await falte_sub(topic, files, win, lose)
gone.add(lk)
merged.append(f"{lose['sub_title'][:40]}{win['sub_title'][:40]}")
return merged, frei
def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
"""Verlierer-Sub aus den Sidecar-JSONs nehmen (Legacy-Lesepfad von Guide/Frontend);
die DB trägt die umgehängten Fragen/Artefakte."""
for key, feld in (("sidecar", "title"), ("sub_roh", None), ("question_pattern", "subblock")):
d = _json_file(files[key])
if not isinstance(d, dict):
continue
changed = False
for bt, eintraege in d.items():
if _norm_title(bt) != bnorm or not isinstance(eintraege, list):
continue
neu = [e for e in eintraege
if _norm_title(e if feld is None else str((e or {}).get(feld, ""))) != sub_norm]
if len(neu) != len(eintraege):
d[bt] = neu
changed = True
if changed:
atomic_write_json(files[key], d, indent=1)
art = _json_file(files["artefakte"])
if isinstance(art, dict):
neu = {t: [e for e in (es if isinstance(es, list) else [])
if not (_norm_title(_title(str(e.get("block", "")))) == bnorm
and _norm_title(str(e.get("subblock", ""))) == sub_norm)]
for t, es in art.items()}
if neu != art:
atomic_write_json(files["artefakte"], neu, indent=1)
async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
out = []
frei_alle: list[str] = []
fremd = [t for t in report.get("fremd", []) if _norm_title(t) in by_norm]
if fremd:
folder = source_folder(topic)
lines = []
for t in fremd:
srcs = by_norm[_norm_title(t)]["payload"].get("sources") or None
ev = _evidence_pack(folder, srcs, [t], budget=EVIDENCE_PER_BLOCK) if folder else ""
lines.append(f"{t}\n{ev or '(keine Treffer im Material)'}")
v, frei = await _mit_stichentscheid("QA-Repair-Beleg", topic, "fremd", "blocks", lines,
"nein", kategorie="fremd",
ids=[_norm_title(t) for t in fremd])
frei_alle += frei
for i, t in enumerate(fremd, 1):
if v.get(i) == "nein":
await _reject(topic, t, by_norm, files, "qa-fremd")
out.append(t)
unecht = [t for t in report.get("unecht", []) if _norm_title(t) in by_norm]
if unecht:
lines = [f"{t}{by_norm[_norm_title(t)]['payload'].get('description') or '(ohne Beschreibung)'}"
for t in unecht]
v, frei = await _mit_stichentscheid("QA-Bausteine", topic, "unecht", "blocks", lines,
"nein", kategorie="unecht",
ids=[_norm_title(t) for t in unecht])
frei_alle += frei
for i, t in enumerate(unecht, 1):
if v.get(i) == "nein":
await _reject(topic, t, by_norm, files, "qa-unecht")
out.append(t)
return out, frei_alle
async def _raeume_waisen(topic: str) -> int:
"""Artefakte/Fragen mit totem Ziel löschen (Sub verworfen oder weg) — inert, der
Übungs-Join spielt sie nie aus, aber sie drücken die Artefakt-Note. Mehrdeutige
Präfix-Treffer bleiben (könnten lebend sein — Löschen wäre riskanter als behalten)."""
lebt = {(r["block_norm"], r["sub_norm"]) for r in await db.list_subblocks(topic)
if r["status"] != "discarded"}
def tot(bn: str, sn: str) -> bool:
if (bn, sn) in lebt:
return False
return not any(b == bn and s.startswith(sn + ":") for b, s in lebt)
n = 0
for r in await db.get_sub_artefakte(topic):
if tot(r["block_norm"], r["sub_norm"]):
await db.delete_artefakt_row(topic, r["block_norm"], r["sub_norm"], r["type"])
n += 1
for r in await db.list_question_pattern(topic):
if tot(r["block_norm"], r["sub_norm"]):
await db.delete_frage_row(topic, r["block_norm"], r["sub_norm"])
n += 1
return n
async def _reject(topic: str, title: str, by_norm: dict, files: dict, grund: str) -> None:
norm = _norm_title(title)
card = by_norm.pop(norm, None)
if not card:
return
p = dict(card["payload"])
p["reason"] = grund
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.kanban_advance(topic, "inventory", card["card_id"], "rejected")
await _purge_block(topic, title, files)
async def _purge_block(topic: str, title: str, files: dict) -> None:
"""Abgeleitete Daten eines Blocks gezielt entfernen (DB-Spiegel, Board-2-Karte, Sidecars)."""
norm = _norm_title(title)
await db.set_block_status(topic, norm, "discarded")
await db.delete_subblocks(topic, norm)
await db.delete_question_pattern(topic, norm)
await db.delete_sub_artefakte(topic, norm)
await db.kanban_delete_card(topic, "artefacts", norm)
for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
d = _json_file(files[key])
if isinstance(d, dict):
hits = [k for k in d if _norm_title(k) == norm]
if hits:
for k in hits:
d.pop(k)
atomic_write_json(files[key], d, indent=1)
art = _json_file(files["artefakte"])
if isinstance(art, dict):
neu = {t: [e for e in (es if isinstance(es, list) else [])
if _norm_title(_title(str(e.get("block", "")))) != norm]
for t, es in art.items()}
if neu != art:
atomic_write_json(files["artefakte"], neu, indent=1)
def _rename_in_files(files: dict, norm: str, neu: str) -> None:
"""Titel-Keys der Sidecar-JSONs + artefakte-`block`-Felder auf den bereinigten Titel."""
for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
d = _json_file(files[key])
if isinstance(d, dict):
hits = [k for k in d if _norm_title(k) == norm and k != neu]
if hits:
for k in hits:
d[neu] = d.pop(k)
atomic_write_json(files[key], d, indent=1)
art = _json_file(files["artefakte"])
if isinstance(art, dict):
changed = False
for es in art.values():
for e in es if isinstance(es, list) else []:
if _norm_title(_title(str(e.get("block", "")))) == norm and e.get("block") != neu:
e["block"] = neu
changed = True
if changed:
atomic_write_json(files["artefakte"], art, indent=1)

View File

@@ -1,3 +1,10 @@
fastapi
uvicorn[standard]
aiosqlite
httpx
playwright
trafilatura
pymupdf4llm
transformers
# torch NICHT hier listen — sonst zieht pip die CUDA-Variante (~2,5 GB).
# Es wird separat als CPU-Build installiert (Dockerfile + Makefile-Target `install`).

View File

@@ -1,35 +1,49 @@
import asyncio
import json
import logging
import shutil
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from fastapi.responses import Response
from agents import provider_available
from config import PROJECTS_DIR, PROVIDERS
from agents import active_agents, agent_ebene, provider_available
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
create_guide, delete_guide, get_guide, list_guides,
create_guide, delete_guide, get_guide, list_guides, update_guide,
create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element,
list_block_progress, get_block_progress, set_open_question,
set_block_score_and_streak,
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
list_runs, latest_board_runs, get_db,
)
from bausteine import generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from guide import generate_guide, guide_slot_dateien
from textkit import _norm_title
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
from board_inventory import add_research_agent, board_snapshot, requeue_dead
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide
from regeln import FORMATE, formate_stats, guide_lock, ist_absolviert, lade_lernstand
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BausteineCreateRequest, BausteineStatusResponse,
BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideFormatRequest,
GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse,
BlockExamRequest, BlockExamResponse, BlockLearnStateResponse,
BlockPruefenRequest, BlockPruefenResponse, BlockUebernehmenRequest, BlockUebernehmenResponse,
PracticeAnswerRequest,
)
from paths import bausteine_topics, guide_content_path, project_dir, topic_dir
from paths import blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
from fsutil import atomic_write_json
router = APIRouter(prefix="/api")
@@ -44,27 +58,43 @@ async def get_topics():
db_topics = await db_list_topics()
guides = await list_guides()
derived = {g["topic"] for g in guides}
derived.update(bausteine_topics())
derived.update(job["topic"] for job in active_bausteine())
# DB ist führend (Reihenfolge: neueste zuerst); Abgeleitetes ohne DB-Eintrag hinten anhängen
derived.update(blocks_topics())
derived.update(job["topic"] for job in active_blocks())
# DB is authoritative (order: newest first); append derived entries without a DB row at the end
return db_topics + sorted(derived - set(db_topics))
@router.get("/stats")
async def get_stats():
"""Tracker: Themen-Anzahl + pro Format erstellt/absolviert."""
guides, progress = await lade_lernstand()
themen = set(await db_list_topics()) | {g["topic"] for g in guides} | set(bausteine_topics())
"""Tracker: number of topics + per format created/completed."""
guides, levels = await load_learnstate()
topics = set(await db_list_topics()) | {g["topic"] for g in guides} | set(blocks_topics())
if PROJECTS_DIR.is_dir():
themen |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
return {"themen": len(themen), "formate": formate_stats(guides, progress)}
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
@router.get("/topics/fortschritt")
async def topic_fortschritt(topic: str):
"""Absolviert-Status pro Format — fürs Freischalten der nächsten Ausbaustufe."""
guides, progress = await lade_lernstand()
return {fmt: ist_absolviert(topic, fmt, guides, progress) for fmt in FORMATE}
@router.get("/health")
async def health():
await (await get_db()).execute("SELECT 1")
return {"ok": True}
@router.get("/runs")
async def get_runs(topic: str, limit: int = 10):
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
`latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die
Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen."""
return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)}
@router.get("/topics/progress")
async def topic_progress(topic: str):
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
guides, levels = await load_learnstate()
status = {fmt: ist_completed(topic, fmt, guides, levels) for fmt in FORMATE}
status["completed"] = topic_completed(topic, guides, levels)
return status
@router.post("/topics")
@@ -75,77 +105,573 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics")
async def remove_topic(topic: str):
guides = [g for g in await list_guides() if g["topic"] == topic]
status = await blocks_status(topic)
if status["generating"] or any(g["status"] == "generating" for g in guides):
raise HTTPException(409, "Generierung läuft — erst abbrechen")
await delete_topic(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
await delete_block_data(topic)
await delete_topic_pipeline(topic)
await delete_source(topic) # topic config (DB) — removed together with the topic
await delete_guide_content(topic)
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
# sonst taucht das gelöschte Topic sofort wieder auf
for g in guides:
await delete_guide(g["id"])
await delete_guide_board(topic)
await kanban_reset(topic)
import qa
# rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
# parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
def _wipe():
shutil.rmtree(topic_dir(topic), ignore_errors=True)
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
await asyncio.to_thread(_wipe)
return {"ok": True}
def _safe_project_name(name: str) -> str:
if not name or "/" in name or "\\" in name or ".." in name or "\x00" in name:
raise HTTPException(400, "Ungültiger Projektname")
return name
@router.get("/projects", response_model=list[ProjectResponse])
async def list_projects():
if not PROJECTS_DIR.is_dir():
@router.get("/folders", response_model=list[FolderResponse])
async def list_folders(kind: str):
"""Folders for the source selection: kind=projekt → projects/, kind=uni → uni/."""
base = {"projekt": (PROJECTS_DIR, "projects"), "uni": (UNI_DIR, "uni")}.get(kind)
if base is None:
raise HTTPException(400, "kind must be 'projekt' or 'uni'")
root, prefix = base
if not root.is_dir():
return []
return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.is_dir()]
return [{"name": e.name, "location": f"{prefix}/{e.name}"} for e in sorted(root.iterdir()) if e.is_dir()]
@router.delete("/projects/{name}")
async def remove_project(name: str):
_safe_project_name(name)
pdir = project_dir(name)
if not pdir.is_dir():
raise HTTPException(404, "Projekt nicht gefunden")
shutil.rmtree(pdir)
return {"ok": True}
# --- Blocks ---
@router.get("/blocks/status", response_model=BlocksStatusResponse)
async def get_blocks_status(topic: str):
return await blocks_status(topic)
# --- Bausteine ---
@router.get("/bausteine/status", response_model=BausteineStatusResponse)
async def get_bausteine_status(topic: str):
return bausteine_status(topic)
@router.get("/blocks/active")
async def get_active_blocks():
return active_blocks()
@router.get("/bausteine/active")
async def get_active_bausteine():
return active_bausteine()
@router.post("/bausteine")
async def create_bausteine(req: BausteineCreateRequest):
@router.post("/blocks")
async def create_blocks(req: BlocksCreateRequest):
topic = req.topic.strip()
if bausteine_status(topic)["generating"]:
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "already_generating"}
await create_topic(topic)
asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider))
qp = source_path(topic)
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
if not qp.exists():
type, location = req.source_type, req.source_location.strip()
_validate_source(type, location)
qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
research=req.research, qa_force=req.qa_force,
auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
auto_guide=req.auto_guide, only_artefacts=req.only_artefacts))
return {"ok": True}
@router.post("/bausteine/cancel")
async def cancel_bausteine_route(topic: str):
if not cancel_bausteine(topic):
raise HTTPException(404, "Keine laufende Generierung")
@router.get("/blocks/board")
async def get_blocks_board(topic: str):
"""Live kanban board: columns with counts + newest cards, dead-letter, agents."""
snap = await board_snapshot(topic)
status = await blocks_status(topic)
snap["generating"] = status["generating"]
snap["progress"] = status["progress"]
snap["error"] = status["error"]
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"),
"runtime": a["runtime"], "ebene": agent_ebene(a["key"])}
for a in active_agents(f"blocks-{topic}-")]
return snap
_qa_laeuft: set[str] = set()
@router.post("/blocks/qa")
async def run_qa_route(req: QaRunRequest):
"""Manual QA run (like the gate: incl. LLM samples); the badge reads the written report."""
if req.topic in _qa_laeuft:
return {"status": "läuft bereits"}
_qa_laeuft.add(req.topic)
try:
import qa
report = await qa.qa_report(req.topic, llm=req.llm)
if report is None:
raise HTTPException(status_code=404, detail="keine fertigen Bausteine")
await qa.write_report(report)
note_guide = None
# ebene-scoped: ein Inventar-/Artefakt-QA-Klick misst NICHT den (teuren) Guide mit.
guide_scope = req.ebene in (None, "guide")
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
import guide_qa
from database import list_guide_cards
if guide_scope and any(c["stage"] == "done" and (c.get("md") or "").strip()
for c in await list_guide_cards(req.topic)):
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
if grep:
await asyncio.to_thread(guide_qa._write_report, grep)
note_guide = grep["note_guide"]
except Exception:
logging.getLogger("creator.routes").exception("[%s] Guide-QA im QA-Button fehlgeschlagen", req.topic)
return {"note": report["note"], "note_artefakte": report["note_artefakte"],
"note_guide": note_guide}
finally:
_qa_laeuft.discard(req.topic)
_repair_laeuft: set[str] = set()
@router.post("/blocks/repair")
async def run_repair_route(req: RepairRequest):
"""Fix the latest QA findings in place: hygiene, confirmed duplicates, foreign/unreal blocks."""
topic = req.topic.strip()
if (await blocks_status(topic))["generating"]:
return {"status": "generating"}
if topic in _repair_laeuft:
return {"status": "läuft bereits"}
_repair_laeuft.add(topic)
try:
import repair
res = await repair.repair_befunde(topic, req.ebene)
if "fehler" in res:
raise HTTPException(status_code=404, detail=res["fehler"])
return res
finally:
_repair_laeuft.discard(topic)
@router.post("/blocks/research")
async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
"""Attach one more research agent — to the live flow, or attach-or-start."""
if add_research_agent(topic):
return {"ok": True, "attached": True}
if (await blocks_status(topic))["generating"]:
return {"ok": False, "status": "starting"} # flow is booting, try again shortly
asyncio.create_task(generate_blocks(topic, "", provider, research=True))
return {"ok": True, "attached": False}
@router.post("/blocks/requeue-dead")
async def requeue_blocks_dead(topic: str):
return {"ok": True, "requeued": await requeue_dead(topic)}
@router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str):
if not cancel_blocks(topic):
raise HTTPException(404, "No running generation")
return {"ok": True}
@router.delete("/bausteine")
async def remove_bausteine(topic: str):
reset_bausteine(topic)
@router.delete("/blocks")
async def remove_blocks(topic: str):
reset_blocks(topic) # Files: crawl + triage + inventory…questions gone; source.json stays
await delete_topic_pipeline(topic) # DB: blocks area gone; topic config (source) stays
await kanban_reset(topic) # kanban cards + cluster membership gone
return {"ok": True}
@router.delete("/blocks/artefacts")
async def remove_artefacts(topic: str):
"""Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt.
Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar."""
topic = topic.strip()
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "generating"}
from board_inventory import reset_board_from_stage
moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic))
return {"ok": True, "moved": moved}
@router.delete("/blocks/progress")
async def reset_block_progress(topic: str, block: str):
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
await delete_block_progress(topic, block)
return {"ok": True}
def _validate_source(type: str, location: str) -> None:
"""Check source input (shared by create + edit)."""
if type in ("projekt", "uni"):
folder = safe_folder(location)
if folder is None or not folder.is_dir():
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
elif type == "link":
urls = [ln.strip() for ln in location.splitlines() if ln.strip()]
if not urls:
raise HTTPException(400, "Enter at least one link.")
if not all(u.lower().startswith(("http://", "https://")) for u in urls):
raise HTTPException(400, "Each link must start with http:// or https:// (one per line).")
@router.get("/blocks/source", response_model=BlocksSourceResponse)
async def get_blocks_source(topic: str):
return load_source(topic)
@router.put("/blocks/source", response_model=BlocksSourceResponse)
async def update_blocks_source(req: BlocksSourceUpdate):
"""Only save — NO regeneration. Overwrite the source/spec choice."""
topic, type, location = req.topic.strip(), req.type, req.location.strip()
_validate_source(type, location)
qp = source_path(topic)
qp.parent.mkdir(parents=True, exist_ok=True)
data = {"type": type, "location": location, "spec": req.spec.strip()}
atomic_write_json(qp, data)
return data
@router.get("/blocks/completeness")
async def blocks_completeness(topic: str):
"""Beleg der Themen-Zerlegung: Bestand, Filter-Bilanz, Lernziele, Artefakte, Laufzeit."""
import glob as _glob
from pathlib import Path as _Path
from paths import arbeit_dir
from database import (kanban_stage_counts, list_blocks, list_subblocks, list_lernziele,
count_question_pattern_blocks, count_sub_artefakte, event_span)
counts = await kanban_stage_counts(topic)
inv = counts.get("inventory", {})
blocks = await list_blocks(topic, status="consensus")
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
consensus_blocks = {b["title_norm"] for b in blocks}
subs = sum(1 for s in await list_subblocks(topic)
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0
for p in _glob.glob(str(arbeit_dir(topic) / "inventar-filter*.json")):
try:
d = json.loads(_Path(p).read_text(encoding="utf-8"))
degradiert += d.get("degradiert", 0)
ueberstimmt += len(d.get("ueberstimmt", []))
except Exception:
continue
status = await blocks_status(topic)
return {
"bloecke": len(blocks), "subs": subs,
"verworfen": inv.get("rejected", 0), "zusammengelegt": inv.get("grouped", 0),
"degradiert_geprueft": degradiert, "panel_gerettet": ueberstimmt,
"ziele_total": len(ziele), "ziele_covered": sum(1 for z in ziele if z["covered"]),
"frage_bloecke": await count_question_pattern_blocks(topic),
"lernkarten": await count_sub_artefakte(topic),
"dead": dead, "lauf_minuten": await event_span(topic),
"vollstaendig": bool(status.get("ready")) and dead == 0,
}
@router.get("/blocks/overview", response_model=list[BlockOverview])
async def get_blocks_uebersicht(topic: str):
return await load_overview(topic)
@router.get("/blocks/question-pattern")
async def get_question_pattern(topic: str, block: str):
"""Unlocked question patterns of a block (up to the current level; empty = live)."""
state = await get_block_progress(topic, block)
fe = freie_level(state["good_answers"], await subs_per_level(topic, block))
return {"pattern": await load_question_pattern_free(topic, block, fe)}
# --- Practice deck: Leitner flashcard pool per topic ---
async def build_practice_deck(topic: str) -> dict:
"""ONE stack per topic (spacing beats per-block mini-stacks): due cards first
(oldest due_at), then up to PRACTICE_NEW_PER_SESSION new ones. Level gate via the
block's exam score (freie_level) — locked cards are counted for transparency."""
cards = await get_sub_artefakte(topic, "flashcard")
levels = await sub_levels_norm(topic)
n_je = await subs_per_level_norm(topic)
progress = {_norm_title(p["block"]): p["good_answers"] for p in await list_block_progress(topic)}
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(topic)}
now = datetime.now(timezone.utc).isoformat()
due, new, future, gesperrt = [], [], [], 0
for r in cards:
bn, sn = r["block_norm"], r["sub_norm"]
n_block = n_je.get(bn)
if n_block is not None: # legacy blocks without level data pass unfiltered
if levels.get((bn, sn), 1) > freie_level(progress.get(bn, 0), n_block):
gesperrt += 1
continue
try:
data = json.loads(r["data"])
except (ValueError, TypeError):
continue
card = {"block": r["block"], "block_norm": bn, "sub_norm": sn,
"subblock": r["sub_title"], "question": data.get("question", ""),
"answer": data.get("answer", "")}
p = pp.get((bn, sn))
if p is None:
card.update(box=None, status="new")
new.append(card)
elif p["due_at"] <= now:
card.update(box=p["box"], status="due", due_at=p["due_at"])
due.append(card)
else:
future.append(p["due_at"])
due.sort(key=lambda c: c["due_at"])
new_total = len(new)
new = new[:PRACTICE_NEW_PER_SESSION]
return {"cards": due + new,
"counts": {"due": len(due), "new": len(new), "new_total": new_total,
"gesperrt": gesperrt},
"next_due_at": min(future) if future else None}
@router.get("/practice/deck")
async def practice_deck(topic: str):
return await build_practice_deck(topic)
@router.post("/practice/answer")
async def practice_answer(req: PracticeAnswerRequest):
"""Book a Leitner step. Deliberately NO existence check against sub_artefakte:
an answer during regeneration books instead of failing (worst case an orphan row)."""
pp = {(p["block_norm"], p["sub_norm"]): p for p in await get_practice_progress(req.topic)}
prev = pp.get((req.block_norm, req.sub_norm))
box, days = leitner_step(prev["box"] if prev else None, req.correct)
due_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
await upsert_practice_progress(req.topic, req.block_norm, req.sub_norm, box, due_at)
return {"box": box, "due_at": due_at}
# --- Block learning: chat, exam ---
@router.get("/blocks/learnstate", response_model=BlockLearnStateResponse)
async def block_learnstate(topic: str):
"""Exam state per block (raw title as key). cap_final = all subs × 25;
cap_aktuell + freie_level from the score — for ALL blocks (even unexamined)."""
progress = {p["block"]: p for p in await list_block_progress(topic)}
levels = await subs_per_level_raw(topic)
def _state(score: int, streak: int, n_je_level: dict[int, int]) -> dict:
return {
"good_answers": score, "streak": streak,
"cap": cap_final(n_je_level),
"cap_aktuell": cap_aktuell(score, n_je_level),
"freie_level": freie_level(score, n_je_level),
}
blocks = {
b: _state(progress[b]["good_answers"] if b in progress else 0,
progress[b]["streak"] if b in progress else 0, n)
for b, n in levels.items()
}
# Legacy blocks with an exam but without subs → empty levels (cap 0).
for b, p in progress.items():
if b not in blocks:
blocks[b] = _state(p["good_answers"], p["streak"], {})
return {"blocks": blocks}
@router.post("/blocks/chat", response_model=BlockChatResponse)
async def block_chat_route(req: BlockChatRequest):
reply = await block_chat(
req.topic, req.block, req.section, req.section_compact,
[m.model_dump() for m in req.messages], provider=req.provider,
)
return {"reply": reply}
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# overwrite the absolute score with a stale base (race).
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
@asynccontextmanager
async def _check_lock(topic: str, block: str):
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
key = (topic, block)
entry = _check_locks.get(key)
if entry is None:
entry = _check_locks[key] = (asyncio.Lock(), [0])
lock, ref = entry
ref[0] += 1
try:
async with lock:
yield
finally:
ref[0] -= 1
if ref[0] == 0 and _check_locks.get(key) is entry:
del _check_locks[key]
def _basis(state: dict, question: str) -> tuple[int, bool]:
"""Score base BEFORE the question. Same open question → re-rating on the same base
(idempotent); otherwise a new question on the current state. → (basis, re_rating)."""
re_rating = state["offene_question"] == question and state["offene_basis"] is not None
return (state["offene_basis"] if re_rating else state["good_answers"]), re_rating
def _color(points: int) -> str:
"""Points delta → rough bubble coloring."""
return "gut" if points > 0 else ("neutral" if points == 0 else "schlecht")
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
from the base (delayed unlock at the level threshold).
Re-rating of the same question uses the open streak anchor → idempotent."""
async with _check_lock(req.topic, req.block):
state = await get_block_progress(req.topic, req.block)
basis, re_rating = _basis(state, question)
streak_basis = state["offene_streak"] if re_rating else state["streak"]
if not re_rating:
await set_open_question(req.topic, req.block, question, basis, state["streak"])
s = thresholds(n_je_level)
cf = s[-1]
ca = cap_aktuell(basis, n_je_level)
floor = floor_from_score(basis, cf, s)
d, new_streak = points_delta(tier, streak_basis, basis, ca)
score = compute_score(basis, d, floor, ca, cf)
points = score - basis
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
@router.post("/blocks/exam", response_model=BlockExamResponse)
async def block_exam_route(req: BlockExamRequest):
state = await get_block_progress(req.topic, req.block)
good = state["good_answers"]
n_je_level = await subs_per_level(req.topic, req.block)
cap = cap_final(n_je_level)
tier = level_from_score(good, cap) or "beginner" # addressee role of the question
fe = freie_level(good, n_je_level) # only check unlocked subs
compact = req.section_compact
msgs = [m.model_dump() for m in req.messages]
if req.action == "question":
if req.pattern.strip():
# From a drawn pattern, phrase a concrete question at the tier (no dedup needed).
question = await exam_question_variant(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
else:
# Fallback (no pattern sidecar): live generation, focus only on unlocked subs.
subs = await subblocks_frei(req.topic, req.block, fe)
question = await exam_question(req.topic, req.block, req.section, compact, msgs, subblocks=subs, avoid=req.avoid, tier=tier, provider=req.provider)
if question is None:
raise HTTPException(502, "Question failed — please try again")
return {"question": question, "good_answers": good, "cap": cap}
if req.action == "discussion":
if not req.question.strip():
raise HTTPException(400, "Discussion needs an active question")
reply = await block_discussion(
req.topic, req.block, req.section, compact,
req.question, req.last_rating or None, msgs, provider=req.provider,
)
if reply is None:
raise HTTPException(502, "Discussion failed — please try again")
return {"reply": reply, "good_answers": good, "cap": cap}
# --- Quiz: easy (1 of 4) +1/1 · hard (x of 4) +3/1 — deterministic ---
if req.action == "quiz_question":
if not req.pattern.strip():
raise HTTPException(400, "Quiz needs a pattern")
distractors = await hurdles_distractor_block(req.topic, req.block)
quiz = await generate_quiz(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
if quiz is None:
raise HTTPException(502, "Quiz question failed — please try again")
return {"question": quiz["question"], "options": quiz["options"],
"good_answers": good, "cap": cap}
if req.action == "quiz_answer":
if not req.question.strip():
raise HTTPException(400, "Quiz answer needs a question")
hit = set(req.selection) == set(req.correct) # exactly the correct set
res = await _book_score(req, req.question, "strong" if hit else "barely", n_je_level)
res["feedback"] = "Correct — all correct ones hit." if hit else "Not quite — the marked ones were correct."
return res
# --- Gap text: easy (term from 4) +1/1 · hard (free typing) +3/1 ---
if req.action == "gap_question":
if not req.pattern.strip():
raise HTTPException(400, "Gap text needs a pattern")
if req.schwer:
lt = await generate_gaptext(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider)
if lt is None:
raise HTTPException(502, "Gap text failed — please try again")
return {"sentence": lt["sentence"], "solution": lt["solution"], "alternatives": lt["alternatives"],
"good_answers": good, "cap": cap}
distractors = await hurdles_distractor_block(req.topic, req.block)
lw = await generate_gapchoice(req.topic, req.block, req.section, compact, req.pattern, tier=tier, provider=req.provider, distractor_block=distractors)
if lw is None:
raise HTTPException(502, "Gap text failed — please try again")
return {"sentence": lw["sentence"], "options": lw["options"],
"good_answers": good, "cap": cap}
if req.action == "gap_answer":
if not req.question.strip():
raise HTTPException(400, "Gap-text answer needs a sentence")
if req.schwer: # free typed → synonym-tolerant AI check
ok = await check_gaptext(req.topic, req.block, req.question, req.solution, req.alternatives, req.input, provider=req.provider)
feedback = "Correct!" if ok else f"Not quite — expected „{req.solution}“."
else: # term chosen from 4 → deterministic
ok = set(req.selection) == set(req.correct)
feedback = "Correct!" if ok else "Not quite — the marked term was correct."
res = await _book_score(req, req.question, "strong" if ok else "barely", n_je_level)
res["feedback"] = feedback
return res
# action "answer" (Agent 1 fast) / "answer_check" (Agent 2 thorough).
if not any(m.get("role") == "user" for m in msgs):
raise HTTPException(400, "Answer needs a user answer")
if not req.question.strip():
raise HTTPException(400, "Answer needs an active question")
if req.action == "answer":
# Agent 1: preview only — tier + expected points, persist NOTHING, no anchor.
data = await exam_rating_fast(
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
)
if data is None:
raise HTTPException(502, "Rating failed — please try again")
basis, re_rating = _basis(state, req.question)
streak_basis = state["offene_streak"] if re_rating else state["streak"]
s = thresholds(n_je_level)
ca = cap_aktuell(basis, n_je_level)
floor = floor_from_score(basis, s[-1], s)
tier = cap_followup(data["tier"], req.asked_again)
d, _ = points_delta(tier, streak_basis, basis, ca)
score = compute_score(basis, d, floor, ca, s[-1])
points = score - basis
return {"feedback": data["feedback"], "points": points, "rating": _color(points),
"good_answers": good, "cap": cap}
# action "answer_check" (Agent 2 thorough): binding, persisted. ONLY here does the score change.
# The LLM runs WITHOUT a lock; booking is done briefly via _book_score (anchor + score), as with quiz/gap.
# This way the long AI rating doesn't block a following (deterministic) answer of the same block.
data = await exam_rating(
req.topic, req.block, req.section, compact, req.question, msgs, provider=req.provider,
role="guide" if req.thorough else "judge", reason=req.reason,
)
if data is None:
raise HTTPException(502, "Rating failed — please try again")
tier = cap_followup(data["tier"], req.asked_again)
res = await _book_score(req, req.question, tier, n_je_level) # short lock: drift-free base via anchor
res["feedback"] = data["feedback"]
return res
# --- Guides ---
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
guides, progress = await lade_lernstand()
grund = guide_lock(req.topic.strip(), req.format, guides, progress)
if grund:
raise HTTPException(400 if grund == "Erst Bausteine erstellen" else 409, grund)
guides, levels = await load_learnstate()
from database import list_blocks
from paths import blocks_path
topic = req.topic.strip()
# DB is authoritative (generate_guide builds from it); blocks.md is only a legacy fallback
# and is absent when the inventory paused at the QA gate — don't lock the guide on the file.
has_blocks = bool(await list_blocks(topic, status="consensus")) or blocks_path(topic).exists()
reason = guide_lock(topic, req.format, guides, levels, has_blocks)
if reason:
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
await create_topic(req.topic.strip())
now = datetime.now(timezone.utc).isoformat()
guide = {
@@ -168,39 +694,72 @@ async def list_all():
return await list_guides()
@router.get("/guides/locks")
async def guide_locks(topic: str):
"""Sperr-Gründe pro Format für den ▶-Button — None = erstellbar."""
guides, progress = await lade_lernstand()
return {fmt: guide_lock(topic, fmt, guides, progress) for fmt in ("OnePager", *FORMATE)}
@router.get("/guides/board")
async def get_guide_board(topic: str, format: str = "Guide"):
"""Live guide board: columns with counts + cards (rounds, covered objectives), agents."""
import guide_board
snap = await guide_board.board_snapshot(topic, format)
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == format), None)
snap["generating"] = bool(guide and guide["status"] in ("queued", "generating"))
snap["guide_id"] = guide["id"] if guide else None
snap["progress"] = guide.get("progress") if guide else None
snap["error"] = guide.get("error_msg") if guide else None
prefix = f"{guide['id']}-" if guide else "-"
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
for a in active_agents(prefix)]
return snap
@router.get("/guides/{guide_id}", response_model=GuideResponse)
async def get_one(guide_id: str):
guide = await get_guide(guide_id)
@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideFormatRequest):
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
return guide
raise HTTPException(404, "Kein Guide für dieses Topic/Format")
if guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating", "betroffen": []}
betroffen = await guide_board.repair_karten(topic, req.format)
if betroffen:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
asyncio.create_task(generate_guide(guide["id"], topic, req.format,
guide.get("instructions") or "", DEFAULT_PROVIDER))
return {"ok": True, "betroffen": betroffen}
@router.get("/guides/{guide_id}/content")
async def guide_content(guide_id: str):
async def guide_content(guide_id: str, level: int = 4):
"""Guide content. `level` (1=A · 2=F · 3=E · 4=V) filters to subblocks up to this
level; 4 = full version (raw, unchanged)."""
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
raise HTTPException(404, "Guide not found")
if guide["status"] != "done":
raise HTTPException(404, "Inhalt nicht verfügbar")
path = guide_content_path(guide["topic"], guide["format"])
if not path.exists():
raise HTTPException(404, "Datei nicht gefunden")
return FileResponse(path, media_type="application/json")
raise HTTPException(404, "Content not available")
stored = await get_guide_content(guide["topic"], guide["format"]) # DB-first
if stored is None:
path = guide_content_path(guide["topic"], guide["format"]) # fallback: file (legacy topics)
if not path.exists():
raise HTTPException(404, "File not found")
stored = path.read_text(encoding="utf-8")
if level >= 4:
return Response(content=stored, media_type="application/json") # full version, raw
try:
return content_fuer_level(json.loads(stored), level)
except ValueError:
return Response(content=stored, media_type="application/json")
@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse)
async def guide_chat(guide_id: str, req: GuideChatRequest):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
raise HTTPException(404, "Guide not found")
reply = await chat_with_guide(
guide["topic"], guide["format"], req.section, req.outline,
[m.model_dump() for m in req.messages],
@@ -209,122 +768,76 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
return {"reply": reply}
# --- Elemente (persönliche Zusammenfassung) ---
@router.get("/elements", response_model=list[ElementResponse])
async def get_elements(topic: str):
return await list_elements(topic)
async def _guide_tf(guide_id: str) -> tuple[str, str]:
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide not found")
return guide["topic"], guide["format"]
@router.post("/elements", response_model=ElementResponse)
async def post_element(req: ElementCreateRequest):
fields = await generate_element(req.topic, req.hint, provider=req.provider)
now = datetime.now(timezone.utc).isoformat()
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
await create_element(element)
return element
@router.post("/guides/{guide_id}/block/pruefen", response_model=BlockPruefenResponse)
async def block_pruefen_route(guide_id: str, req: BlockPruefenRequest):
topic, fmt = await _guide_tf(guide_id)
new = await block_pruefen(topic, fmt, req.block, req.spot, req.snippet, req.hint, provider=req.provider)
if new is None:
raise HTTPException(502, "Check failed — please try again")
return {"revised": new}
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
async def element_chat(element_id: str, req: ElementChatRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
return {"reply": reply, "changes": changes}
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
async def element_refine(element_id: str, req: ElementRefineRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
if change is None:
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
return {"change": change}
@router.put("/elements/{element_id}", response_model=ElementResponse)
async def put_element(element_id: str, req: ElementUpdateRequest):
if await get_element(element_id) is None:
raise HTTPException(404, "Element nicht gefunden")
fields = req.model_dump(exclude_unset=True, exclude_none=True)
if fields:
now = datetime.now(timezone.utc).isoformat()
await update_element(element_id, **fields, updated_at=now)
return await get_element(element_id)
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
async def element_style(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
changes = await style_element(element, provider=req.provider)
if changes is None:
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"changes": changes}
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
async def element_check(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
suggestions = await check_element(element, provider=req.provider)
if suggestions is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"suggestions": suggestions}
@router.delete("/elements/{element_id}")
async def remove_element(element_id: str):
if not await delete_element(element_id):
raise HTTPException(404, "Element nicht gefunden")
return {"ok": True}
@router.post("/guides/{guide_id}/block/uebernehmen", response_model=BlockUebernehmenResponse)
async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
topic, fmt = await _guide_tf(guide_id)
res = await block_adopt(topic, fmt, req.block, req.spot, req.alt, req.revised)
if res is None:
raise HTTPException(404, "Section not found")
return res
@router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id)
if not cancelled:
raise HTTPException(404, "Kein aktiver Prozess gefunden")
raise HTTPException(404, "No active process found")
return {"ok": True}
@router.post("/guides/board/remove")
async def remove_guide_format(req: GuideFormatRequest):
"""Board-Remove: discard ALL runs of topic+format — old error rows pile up, and the
per-guide delete keeps the board cards until the LAST row is gone (measured: 8 rows)."""
doomed = [g for g in await list_guides() if g["topic"] == req.topic and g["format"] == req.format]
if any(g["status"] in ("queued", "generating") for g in doomed):
return {"ok": True, "status": "generating"}
for g in doomed:
await delete_guide(g["id"])
await delete_guide_content(req.topic, req.format)
await delete_guide_board(req.topic, req.format)
content = guide_content_path(req.topic, req.format)
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
import qa # Guide-QA-Report mitlöschen — sonst zeigt board_snapshot die alte Note weiter
for p in qa.report_paths(req.topic, guide=True):
p.unlink(missing_ok=True)
return {"ok": True, "removed": len(doomed)}
@router.delete("/guides/{guide_id}")
async def remove(guide_id: str, slots: bool = False):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
await delete_progress(guide_id)
raise HTTPException(404, "Guide not found")
await delete_guide(guide_id)
# Content-/Schritt-Dateien teilen sich alle Läufe eines Thema+Formatserst löschen,
# wenn kein Eintrag sie mehr braucht. Teilfortschritt (Schritt-Dateien ohne fertigen
# Content) bleibt fürs Resume erhalten, außer es wird explizit verlangt (slots=1).
# Content/step files are shared by all runs of a topic+format — only delete them
# once no entry needs them anymore. Partial progress (step files without finished
# content) is kept for resume, unless explicitly requested (slots=1).
rest = [g for g in await list_guides() if g["topic"] == guide["topic"] and g["format"] == guide["format"]]
if not rest:
await delete_guide_content(guide["topic"], guide["format"])
await delete_guide_board(guide["topic"], guide["format"]) # board cards + lernziele
content = guide_content_path(guide["topic"], guide["format"])
if slots or content.exists():
for p in guide_slot_dateien(content):
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
return {"ok": True}
@router.get("/guides/{guide_id}/progress", response_model=ProgressResponse)
async def get_progress(guide_id: str):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
return {"chapters": await list_progress(guide_id)}
@router.post("/guides/{guide_id}/progress", response_model=ProgressResponse)
async def update_progress(guide_id: str, req: ProgressUpdate):
guide = await get_guide(guide_id)
if guide is None:
raise HTTPException(404, "Guide nicht gefunden")
await set_progress(guide_id, req.chapter, req.done)
return {"chapters": await list_progress(guide_id)}

156
backend/rules.py Normal file
View File

@@ -0,0 +1,156 @@
"""Learning-debt rules: progression and cap for open guides — the ONLY source.
Rules (new creations only; topics + blocks unlimited):
- Format "Guide": at most 3 created, not-yet-completed guides
- No more progression/prerequisite (only a single guide format).
- Completed: ALL blocks (section titles) of the latest finished guide have
a passed exam. The rest is read-only (no progress, no exam).
All functions work on data loaded once (load_learnstate) — no more
query loops per guide.
"""
import json
from database import list_block_scores_all, subs_per_level_all, list_guides
from guide import guide_slot_files
from learning import cap_final, LEVELS, _threshold
from paths import guide_content_path
from textkit import _norm_title
MAX_OFFENE_GUIDES = 3
# Only ONE format "Guide" left (all relevant blocks, exam 0cap). No progression,
# no prerequisite → the guide is always unlockable. "Rest"/FullGuide are separate.
PRESTAGE: dict[str, str] = {}
FREISCHALT_LEVEL: dict[str, str] = {}
FORMATE = ("Guide",)
# 4 learning levels (floor in % of the cap) — keys from learning.LEVELS.
_LEVEL_WORT = {
"beginner": "to beginner (20%)",
"advanced": "to advanced (40%)",
"expert": "to expert (60%)",
"master": "to mastery (100%)",
}
async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]:
"""Guides + blocks per level.
levels: {"beginner"/"advanced"/"expert"/"master": {topic → normalized title}}.
The level per block is derived from score + cap (4×relevant subs).
"""
scores = await list_block_scores_all()
subs_by_level = await subs_per_level_all()
levels: dict[str, dict[str, set[str]]] = {key: {} for key, _ in LEVELS}
for topic, block, score in scores:
cf = cap_final(subs_by_level.get((topic, _norm_title(block)), {}))
for key, p in LEVELS:
if cf and score >= _threshold(p, cf):
levels[key].setdefault(topic, set()).add(_norm_title(block))
return await list_guides(), levels
_content_cache: dict[str, tuple[float, dict | None]] = {}
def _content_json(topic: str, fmt: str) -> dict | None:
"""Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress
lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop."""
path = guide_content_path(topic, fmt)
try:
mtime = path.stat().st_mtime
except OSError:
_content_cache.pop(str(path), None)
return None
cached = _content_cache.get(str(path))
if cached is None or cached[0] != mtime:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except ValueError:
data = None
_content_cache[str(path)] = (mtime, data)
return _content_cache[str(path)][1]
def _section_title(topic: str, fmt: str) -> set[str] | None:
"""Normalized block titles (sections) from the guide content."""
content = _content_json(topic, fmt)
if content is None:
return None
return {
_norm_title(s.get("title", ""))
for ch in content.get("chapters", [])
for s in ch.get("sections", [])
}
def _latest_done(guides: list[dict], fmt: str) -> dict[str, dict]:
"""Per topic, the latest finished guide of this format."""
latest: dict[str, dict] = {}
for g in guides:
if g["format"] == fmt and g["status"] == "done":
if g["topic"] not in latest or g["created_at"] > latest[g["topic"]]["created_at"]:
latest[g["topic"]] = g
return latest
def _guide_all(g: dict, levelset: dict[str, set[str]]) -> bool:
"""Are ALL blocks of the guide at the required level?"""
sections = _section_title(g["topic"], g["format"])
return bool(sections) and sections <= levelset.get(g["topic"], set())
def is_level(topic: str, fmt: str, guides: list[dict], levelset: dict[str, set[str]]) -> bool:
"""Latest finished guide (topic+format): all blocks at the level of levelset?"""
g = _latest_done(guides, fmt).get(topic)
return g is not None and _guide_all(g, levelset)
def ist_completed(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> bool:
"""All blocks of the latest finished guide at least beginner (≥20%)?"""
return is_level(topic, fmt, guides, levels["beginner"])
def topic_completed(topic: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> bool:
"""Topic done: latest finished guide, all blocks at master (100%)?"""
return is_level(topic, "Guide", guides, levels["master"])
def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> dict:
"""Per format created/completed — per topic only the latest finished guide counts."""
formats = {}
for fmt in FORMATE:
latest = _latest_done(guides, fmt)
completed = sum(1 for g in latest.values() if _guide_all(g, levels["beginner"]))
formats[fmt] = {"created": len(latest), "completed": completed}
return formats
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]],
has_blocks: bool) -> str | None:
"""Reason why a fresh start for topic+format is locked — None = allowed.
Exactly the rules from POST /guides: blocks required, no duplicate start,
learning debt only for genuine new creations (resume/regenerate are free).
`has_blocks` = DB consensus blocks OR the legacy blocks.md exist — checked by the
caller, because generate_guide builds from the DB (a paused inventory writes no file).
"""
if not has_blocks:
return "Create blocks first"
for g in guides:
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
return "Generation already running"
content = guide_content_path(topic, fmt)
if not content.exists() and not guide_slot_files(content):
prereq = PRESTAGE.get(fmt)
if prereq:
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
if not is_level(topic, prereq, guides, levels[level]):
return f"First take the {prereq} of this topic {_LEVEL_WORT[level]}"
stat = formats_stats(guides, levels).get(fmt, {"created": 0, "completed": 0})
open_count = stat["created"] - stat["completed"]
if open_count >= MAX_OFFENE_GUIDES:
return f"Complete {fmt}s first — at most {MAX_OFFENE_GUIDES} open allowed ({open_count} open)"
return None

31
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,31 @@
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import database # noqa: E402
@pytest.fixture
async def testdb(tmp_path, monkeypatch):
"""Fresh sqlite file per test; resets the module-global connection."""
monkeypatch.setattr(database, "DB_PATH", tmp_path / "test.db")
database._db = None
await database.init_db()
yield database
await database.close_db()
@pytest.fixture
async def fake_welt(testdb, tmp_path, monkeypatch):
"""E2E ohne LLM: run_agent überall durch die Fake-Welt ersetzt, Tempo-Bremsen raus.
Alle echten Schichten (_race, Quorum, Panels, Producer, QA-Gate) laufen mit."""
import qa
from fake_agents import Welt, aktivieren
welt = Welt()
aktivieren(welt, setattr_fn=monkeypatch.setattr)
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa") # Reports nie in echte Nutzdaten
return welt

View File

@@ -0,0 +1,284 @@
"""Direkter Text-API-Pfad (MiniMax) + RAM-Gate für opencode-Spawns."""
import asyncio
import httpx
import pytest
import agents
TOPIC = "t"
# ── Routing: wann läuft ein Call über die API statt über opencode? ───────────────────
def test_use_text_api_routing(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.delenv("CREATOR_TEXT_API", raising=False)
api = agents._use_text_api
assert api("minimax", "minimax/MiniMax-M3", "none", None) is True
assert api("minimax", "minimax-kalt/MiniMax-M2.7-highspeed", "none", None) is True
# Tools, Streaming, andere Provider/Modelle → Prozess-Pfad
assert api("minimax", "minimax/MiniMax-M3", "files", None) is False
assert api("minimax", "minimax/MiniMax-M3", "none", lambda s: None) is False
assert api("claude", "claude-sonnet-4-6", "none", None) is False
assert api("lokal", "ollama/qwen3.5:9b", "none", None) is False
# Kill-Switch und fehlender Key → Fallback
monkeypatch.setenv("CREATOR_TEXT_API", "0")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
monkeypatch.delenv("CREATOR_TEXT_API")
monkeypatch.delenv("MINIMAX_API_KEY")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
async def test_run_agent_dispatches_to_api(monkeypatch):
"""none+minimax → API-Runner; opencode wird nicht angefasst (auch kein which-Check)."""
called = {}
async def fake_api(agent_key, prompt, timeout, model, label=""):
called["api"] = (agent_key, model)
return 0, "out", "", {"input": 1, "output": 1, "reasoning": 0, "cache_read": 0, "cache_write": 0}
async def fail_oc(*a, **kw):
raise AssertionError("opencode-Pfad darf nicht laufen")
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_run_opencode", fail_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: None) # API-Pfad braucht kein Binary
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, out, err = await agents.run_agent("blocks-t-a", "p", 5, provider="minimax", role="judge")
assert (rc, out) == (0, "out") and called["api"][1] == "minimax/MiniMax-M3"
async def test_run_agent_kill_switch_uses_opencode(monkeypatch):
called = {}
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
called["oc"] = agent_key
return 0, "out", ""
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setenv("CREATOR_TEXT_API", "0")
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-b", "p", 5, provider="minimax")
assert rc == 0 and called["oc"] == "blocks-t-b"
async def test_run_agent_api_tokens_in_event_meta(monkeypatch):
"""API-Tokens landen im Event-Meta; die OpenCode-Session-DB wird NICHT konsultiert."""
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_api(agent_key, prompt, timeout, model, label=""):
return 0, "out", "", {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_session_tokens", lambda k: pytest.fail("Session-DB-Lookup im API-Pfad"))
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
assert rc == 0
assert recorded and recorded[0]["meta"]["tokens"] == {
"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
# ── API-Runner: Request-Bau, Antwort-Extraktion, Fehlerfälle ─────────────────────────
class _FakeResp:
def __init__(self, status_code=200, data=None, text=""):
self.status_code = status_code
self._data = data or {}
self.text = text
def json(self):
return self._data
def _fake_client(monkeypatch, seen, resp=None, exc=None):
class _Client:
def __init__(self, **kw):
seen["client_kw"] = kw
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None, headers=None):
seen.update(url=url, body=json, headers=headers)
if exc is not None:
raise exc
return resp
monkeypatch.setattr(agents.httpx, "AsyncClient", _Client)
async def test_api_request_and_response(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "geheim")
seen = {}
resp = _FakeResp(data={
"content": [{"type": "thinking", "thinking": "hm"},
{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
"usage": {"input_tokens": 5, "output_tokens": 2,
"cache_read_input_tokens": 100, "cache_creation_input_tokens": 1},
"stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, tokens = await agents._run_text_api("k", "PROMPT", 5, "minimax-kalt/MiniMax-M3")
assert (rc, out, err) == (0, "AB", "") # Thinking-Block übersprungen
assert tokens == {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 1}
assert seen["url"] == agents._API_URL
assert seen["headers"]["x-api-key"] == "geheim"
assert seen["headers"]["anthropic-version"] == agents._API_VERSION
b = seen["body"]
assert b["model"] == "MiniMax-M3" and b["max_tokens"] == agents._API_MAX_TOKENS
assert b["messages"] == [{"role": "user", "content": "PROMPT"}]
assert b["temperature"] == 0.2 and b["thinking"] == {"type": "disabled"} # kalt-Route
async def test_api_model_options_per_route(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "x"}], "usage": {}})
_fake_client(monkeypatch, seen, resp=resp)
await agents._run_text_api("k", "p", 5, "minimax-kalt/MiniMax-M2.7-highspeed")
assert seen["body"]["temperature"] == 0.3 and "thinking" not in seen["body"]
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3") # nativ: Endpunkt-Defaults
assert "temperature" not in seen["body"] and "thinking" not in seen["body"]
async def test_api_errors(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
_fake_client(monkeypatch, seen, resp=_FakeResp(status_code=500, text="kaputt"))
rc, out, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and out == "" and "HTTP 500" in err and tokens is None
_fake_client(monkeypatch, seen, exc=httpx.ConnectError("down"))
rc, _, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "ConnectError" in err
_fake_client(monkeypatch, seen, exc=httpx.ReadTimeout("langsam"))
with pytest.raises(asyncio.TimeoutError):
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
# leere Antwort (nur Thinking) → rc 1, Tokens bleiben sichtbar
resp = _FakeResp(data={"content": [{"type": "thinking", "thinking": ""}],
"usage": {"input_tokens": 3}, "stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, _, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "empty response" in err and tokens["input"] == 3
async def test_api_truncation_flagged(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "halb"}],
"usage": {}, "stop_reason": "max_tokens"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 0 and out == "halb" and "max_tokens" in err
# ── RAM-Gate ─────────────────────────────────────────────────────────────────────────
@pytest.fixture
def ram_gate(monkeypatch):
monkeypatch.setattr(agents, "RAM_MIN_FREE_PCT", 20)
monkeypatch.setattr(agents, "_RAM_POLL_S", 0.01)
monkeypatch.setattr(agents, "_cli_recent_starts", [])
return monkeypatch
_EST = agents._RAM_PER_PROC_KB # default batch-agent estimate
async def test_ram_gate_admits_with_free_ram(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
assert await agents._ram_gate("k", _EST) is True
assert len(agents._cli_recent_starts) == 1 # Commit registriert
async def test_ram_gate_waits_when_low(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
# RAM wird frei → Gate lässt nach ≥1 Poll durch
vals = iter([(800_000, 8_000_000)])
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
assert await agents._ram_gate("k", _EST) is True
async def test_ram_gate_floor_and_fail_open(ram_gate):
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
ram_gate.setattr(agents, "_cli_running", 1) # unter Floor
assert await agents._ram_gate("k", _EST) is True
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
assert await agents._ram_gate("k", _EST) is True
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
assert await agents._ram_gate("k", _EST) is True
async def test_ram_gate_commit_accounting(ram_gate):
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt.
Ein `full`-Spawn wiegt mehr (MCP-Server) und drückt die verfügbare RAM stärker."""
import time as _time
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
agents._cli_recent_starts.extend([(_time.monotonic(), _EST), (_time.monotonic(), _EST)])
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
async def test_ram_gate_full_weighs_more(ram_gate):
"""Ein frischer Vorgänger belegt Commit für das Fenster. Ein leichter (350 MB) lässt den
nächsten noch durch; ein `full` (1250 MB, MCP-Server) reißt die Schwelle → nächster wartet."""
import time as _time
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (2_000_000, 8_000_000)) # 25 % frei
agents._cli_recent_starts[:] = [(_time.monotonic(), _EST)] # leichter Vorgänger
assert await agents._ram_gate("nach-leicht", _EST) is True
agents._cli_recent_starts[:] = [(_time.monotonic(), agents._RAM_PER_PROC_FULL_KB)] # full-Vorgänger
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("nach-full", _EST), 0.1)
async def test_ram_gate_cancelled_scope(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
agents.cancel_scope("blocks-cxl-")
try:
assert await agents._ram_gate("blocks-cxl-x", _EST) is False
finally:
agents.clear_scope("blocks-cxl-")
def test_meminfo_reads_proc():
mem = agents._meminfo()
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
def test_agent_ebene_klassifiziert_board():
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
T = "shopware"
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
assert agents.agent_ebene(key) == "inventory", key
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
f"blocks-{T}-outline-judge"):
assert agents.agent_ebene(key) == "artefacts", key

View File

@@ -0,0 +1,52 @@
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
import auto_loop
async def test_fertig_ohne_repair():
async def rep():
raise AssertionError("darf bei 100 % nie reparieren")
r = await auto_loop.auto_repair_loop("x", 10.0, rep)
assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"}
async def test_verbessert_bis_voll():
noten = iter([9.0, 10.0])
async def rep():
return next(noten), True
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
assert r["grund"] == "fertig" and r["runden"] == 2
async def test_stillstand():
async def rep():
return 8.0, False # nichts getan, Note steht
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 1
assert r["note"] == 8.0
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
async def rep():
return next(laeufe)
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 3
async def test_limit():
stand = {"n": 1.0}
async def rep():
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
return stand["n"], True
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
assert r["grund"] == "limit" and r["runden"] == 10

View File

@@ -0,0 +1,533 @@
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
import json
import pytest
import block_calls as bc
import blocks as blx
import board_artefacts as ba
from pipeline import FAILED, OK, GenContext
from textkit import _norm_title
TOPIC = "t"
def _ctx():
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
return {"title": title, "level": level, "relevance": relevance,
"key_points": [f"kp {title}"] if kp is None else kp,
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
# ── Schemas ─────────────────────────────────────────────────────────────────────────
def test_gen_schema_normalisiert():
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
out = bc._gen_schema({"subs": [
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
{"text": ""}, "quatsch"]},
{"title": "B", "level": "profi", "relevance": "mittel"},
{"title": " "},
]})
assert [e["title"] for e in out] == ["A", "B"]
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
assert out[0]["key_points"] == ["k"]
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
assert out[1]["level"] == "" and out[1]["relevance"] == ""
def test_gen_schema_kaputt_ist_none():
assert bc._gen_schema(None) is None
assert bc._gen_schema({"subs": "x"}) is None
assert bc._gen_schema({"subs": []}) is None
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
assert bc._verify_schema({}, 3) is None
assert bc._verify_schema({"irgendwas": 1}, 3) is None
v = bc._verify_schema({"gruppen": []}, 3)
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
def test_verify_schema_grenzen_und_normalisierung():
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
v = bc._verify_schema({
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
"fremd": [True, 1, "2", 9],
"luecken": [" x ", "", 7],
"uebernehmen": {"3": " JA ", "9": "ja"},
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
"levels": {"1": "expert", "2": "quatsch"},
"relevanz": {"1": "peripheral"},
}, 3)
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
assert v["fremd"] == {1, 2}
assert v["luecken"] == ["x"]
assert v["uebernehmen"] == {3: "ja"}
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
def test_art_gen_schema_pattern_ist_pflicht():
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
cards/examples sind best-effort und werden einzeln validiert."""
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
assert bc._art_gen_schema("x") is None
out = bc._art_gen_schema({
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
{"block": "B", "subblock": "", "question": "F?"}],
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
{"block": "B", "subblock": "S", "question": "F?"}],
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
})
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
"steps": ["s1"], "result": ""}]
def test_art_check_schema_varianten():
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
1-basiert, bools/0 zählen nicht."""
ok = bc._art_check_schema({"ok": True})
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
assert bc._art_check_schema({"foo": 1}) is None
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
assert v["examples_probleme"] == {1, 2, 3}
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
# ── Generate ────────────────────────────────────────────────────────────────────────
@pytest.fixture
def env(testdb, tmp_path, monkeypatch):
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
monkeypatch.setattr(bc, "material_folder", lambda t: None)
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
return testdb, _ctx(), {"arbeit": tmp_path}
def _mk_enrich_slot(output):
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
payload (wie der Engine-Sink); output=None → Ausfall."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if output is None:
return FAILED, None
return OK, payload((0, json.dumps(output), ""))
fake.calls = calls
return fake
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
_sub("Sub A", kp=["k1"]),
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
assert gen["unsicher"] == []
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
db, ctx, files = env
called = []
async def spy(*a, **k):
called.append(1)
return OK, None
monkeypatch.setattr(bc, "run_single_slot", spy)
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
assert called == []
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
Facts, die der Prüfer/QA als Lücke sieht."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A"])
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
monkeypatch.setattr(bc, "run_single_slot", fake)
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
n = len(fake.calls)
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
assert len(fake.calls) == n # aus Datei resumed
assert gen2["raw"] == gen1["raw"]
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
def _gen_von(title, subs, unsicher=None, votes=None):
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
return {"raw": {title: list(subs)},
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
"hurdles": "", "cited_facts": [], "example_idea": ""}
for s in subs}},
"unsicher": unsicher or [], "votes": votes or {}}
def _judge_slot(antworten, fix=None):
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
fehlender Eintrag = FAILED."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-sb-fix-" in key:
if fix is None:
return FAILED, None
return OK, payload((0, json.dumps(fix), ""))
j = key.rsplit("-j", 1)[-1]
antwort = antworten.get(j)
if antwort is None:
return FAILED, None
return OK, payload((0, json.dumps(antwort), ""))
fake.calls = calls
return fake
async def _seed_rows(db, bnorm, titles, status="consensus"):
for t in titles:
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
db, ctx, files = env
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
await _seed_rows(db, "alpha", subs)
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
{"haupt": 3, "weitere": [2]}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title(subs[0])] == "variant"
assert rows[_norm_title(subs[1])] == "consensus"
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
Level/Relevanz greifen weiter."""
db, ctx, files = env
subs = ["Sub A", "Sub B", "Sub C"]
await _seed_rows(db, "alpha", subs)
# Fix-Call liefert die Korrektur für Sub A UND einen belegten NEUEN Fund — der darf
# bei keep_all NICHT als Sub eingeschleust werden (Sub-Zahl bleibt bei den Board-1-Atomen).
fix = {"subs": [_sub("Sub A", kp=["korrigiert"]),
_sub("Extra Fund", kp=["belegt"], cf=[{"text": "x", "source": "s"}])]}
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
{"nr": 2, "discard": True}],
"levels": {"2": "expert"}}
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
assert res["raw"] == {"Alpha": subs} # exakt die drei Atome — nichts entfernt, nichts neu
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
assert "extra fund" not in rows # der Fix-Neuzugang wurde unterdrückt
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
löst auch keinen Fix aus."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
{"nr": 2, "discard": True}]},
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub B"]}
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
beanstandeten Subs; die Einstufung bleibt."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
"2": {"gruppen": []}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert any("-sb-fix-" in c["key"] for c in fake.calls)
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
_sub("Unbelegte Behauptung", kp=[])]}
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
assert _norm_title("Unbelegte Behauptung") not in rows
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B")]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A"]} # fremd-Einzelstimme wirkt NICHT
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
advanced/relevant (heutige Defaults)."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
votes = {"sub a": {"level": ["beginner"], "relevance": []},
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
n = len(fake.calls)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert len(fake.calls) == n
# ── Artefakte ───────────────────────────────────────────────────────────────────────
def _sidecar(titles):
return [{"title": t, "level": "beginner", "relevance": "relevant",
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
def _art_slot(gen_out, check_out):
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
check_out fürs Prüfer-Verdikt."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-art-gen-" in key:
out = gen_out(prompt) if callable(gen_out) else gen_out
return OK, payload((0, json.dumps(out), ""))
if "-art-check-" in key:
if check_out is None:
return FAILED, None
return OK, payload((0, json.dumps(check_out), ""))
raise AssertionError(f"unerwarteter Call {key}")
fake.calls = calls
return fake
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
"steps": ["s1"], "result": "R"}]}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
"question": "F?", "answer": "A"}]
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [],
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
check = {"examples_probleme": [{"index": 1}],
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
fake = _art_slot(gen_out, check)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
{"subblock": "Sub B", "question": "F B?"}]
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
Hälfte; die Ergebnisse werden zusammengeführt."""
db, ctx, files = env
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
def gen_out(prompt):
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
"cards": [], "examples": []}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [], "examples": []}
fake = _art_slot(gen_out, None)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
async def test_artefakte_leerer_block(env):
db, ctx, files = env
res = await bc._artefakte_block(ctx, files, "Alpha", [])
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
async def test_migriere_alt_karten(testdb):
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
Neu-Struktur-Karten bleiben unangetastet."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
{"title": "Alpha", "description": "d", "n_size": 3,
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
"facts": {"Alpha": {}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
{"title": "Beta", "sidecar": {}})
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
{"title": "Delta", "raw": {"Delta": ["neu"]}})
n = await ba.migriere_alt_karten(TOPIC)
assert n == 2
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["stage"] == "generate"
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
"""E2E über die ECHTE Engine mit Fake-Agenten: kompletter Generierungspfad in Sekunden.
Anders als test_board_inventory (dort sind die Block-Funktionen gefakt) läuft hier alles
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
"""
import asyncio
import pytest
import board_inventory as bi
from pipeline import GenContext
from invarianten import pruefe_invarianten, pruefe_guide_invarianten
TOPIC = "t"
def _files(tmp_path):
work = tmp_path / "arbeit"
work.mkdir(exist_ok=True)
return {"arbeit": work, "final": tmp_path / "blocks.md",
"sub_roh": tmp_path / "sub_roh.json", "sidecar": tmp_path / "subblocks.json",
"facts": tmp_path / "facts.json", "question_pattern": tmp_path / "question_pattern.json",
"artefakte": tmp_path / "artefakte.json", "outline": tmp_path / "outline.json",
"outline_slots": [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)],
"research": [work / f"research-{i}.md" for i in (1, 2, 3, 4, 5)]}
async def _lauf(tmp_path, research=True, qa_force=False, timeout=120):
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
files = _files(tmp_path)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=research, qa_force=qa_force), timeout=timeout)
return ok, files
async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
"""Research → Inventar → QA-Gate → Artefakte → Finalize, alle Schichten echt."""
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
titel = {c["payload"]["title"] for c in done}
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert [r["status"] for r in gemeinsam] == ["consensus"]
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
fehler = await pruefe_invarianten(TOPIC, files)
assert fehler == []
async def test_e2e_guide(fake_welt, testdb, tmp_path):
"""Auf den Vollpfad folgt der Guide-Bau — Gate/Coverage/Lese-Stages laufen echt."""
import guide_board
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
done = await db.kanban_cards(TOPIC, board="inventory", stage="done_block")
entries = {i: f"{c['payload']['title']}{c['payload'].get('description', '')}"
for i, c in enumerate(done, 1)}
chapters = await asyncio.wait_for(
guide_board.run_guide_board("g-e2e", TOPIC, "Guide", entries, "", "claude",
tmp_path / "guides" / "Guide.json"), timeout=120)
assert chapters is not None
assert await pruefe_guide_invarianten(TOPIC) == []
async def test_e2e_only_artefacts(fake_welt, testdb, tmp_path):
"""Artefakt-only (inventory=False): nach removeArtefacts läuft NUR Board 2 auf den
fertigen Blocks — kein Board 1, keine Research-Agenten. Karten aus done_block geseedet."""
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
subs_vorher = {r["sub_norm"] for r in await db.list_subblocks(TOPIC)}
assert subs_vorher
# Artefakte entfernen (Karten zurück auf generate, Board-1-Subs bleiben)
await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
fake_welt.calls.clear()
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
ok2 = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=False, inventory=False), timeout=60)
assert ok2
assert not any("-research-" in k for k in fake_welt.calls) # Board 1 lief NICHT
assert any("-sb-enrich-" in k or "-sb-verify-" in k for k in fake_welt.calls) # Board 2 lief
assert {r["sub_norm"] for r in await db.list_subblocks(TOPIC)} == subs_vorher # vollständig
async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
"""Zweiter Lauf (Continue, research=False) hinterlässt keine Waisen/Reste."""
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
vorher = {(r["block_norm"], r["sub_norm"], r["status"])
for r in await db.list_subblocks(TOPIC)}
ok2, _f = await _lauf(tmp_path, research=False)
assert ok2
nachher = {(r["block_norm"], r["sub_norm"], r["status"])
for r in await db.list_subblocks(TOPIC)}
assert nachher == vorher
assert await pruefe_invarianten(TOPIC, files) == []
@pytest.mark.parametrize("stoerung", [
{"muster": r"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
])
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
"""Einzel-Ausfälle dürfen weder den Flow stoppen noch Invarianten reißen."""
fake_welt.stoerungen.append(dict(stoerung, rest=stoerung["mal"]))
ok, files = await _lauf(tmp_path)
assert ok
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_gate_vollinventur_ohne_fix(fake_welt, testdb, tmp_path):
"""Gate-Judge liefert eine Voll-Inventur (belegte Claims mit „Belegt…"-Grund) —
der Schema-Filter wirft sie raus, es läuft KEIN Fakten-Fix."""
import json
antwort = json.dumps({"claims": [
{"text": "Aussage 1", "grund": "Belegt durch Quelle", "urteil": "unbelegt"},
{"text": "Aussage 2", "grund": "Belegt durch Fakten", "urteil": "unbelegt"},
{"text": "Aussage 3", "grund": "Belegt: steht im Skript", "urteil": "unbelegt"}]})
fake_welt.stoerungen.append({"muster": r"-gate-", "modus": "antwort",
"antwort": antwort, "mal": 99, "rest": 99})
import guide_board
ok, _files = await _lauf(tmp_path)
assert ok
db = testdb
done = await db.kanban_cards(TOPIC, board="inventory", stage="done_block")
entries = {i: c["payload"]["title"] for i, c in enumerate(done, 1)}
chapters = await asyncio.wait_for(
guide_board.run_guide_board("g-vi", TOPIC, "Guide", entries, "", "claude",
tmp_path / "guides" / "Guide.json"), timeout=120)
assert chapters is not None
assert not any("-gatefix-" in k for k in fake_welt.calls)
async def test_e2e_echtheits_flattern_gestoppt(fake_welt, testdb, tmp_path):
"""QA-Pass 1 flaggt alle Blöcke als unecht (Judge-Flattern) — der Bestätiger-Pass
widerspricht, die Gate-Note bleibt sauber, der Flow läuft durch."""
import json
fake_welt.stoerungen.append({"muster": r"^qa-t-bausteine-0$", "modus": "antwort",
"antwort": json.dumps({"relevant": {"1": "nein", "2": "nein", "3": "nein"}}),
"mal": 1, "rest": 1})
ok, files = await _lauf(tmp_path)
assert ok # Gate hat nicht pausiert — der Zufalls-Verdacht wurde nicht bestätigt
assert any("bausteine-b2" in k for k in fake_welt.calls)
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
"""uni-Modus mit Mini-Korpus: der Kanon-Titel ohne Korpus-Anker wird deterministisch
rejected (leeres Evidence-Pack), die belegten Blöcke laufen durch; QA misst gegen
den echten Korpus."""
import blocks as blx
fake_welt.bloecke["Kanon-Klassiker"] = {
"beschreibung": "Beruehmtes Lehrbuchproblem", "subs": ["Klassiker Detail"]}
korpus = tmp_path / "korpus"
korpus.mkdir()
zeilen = []
for t, b in fake_welt.bloecke.items():
if t == "Kanon-Klassiker":
continue # kommt bewusst NICHT im Material vor
zeilen.append(f"Kapitel {t}: {b['beschreibung']}. " +
" ".join(f"Wir behandeln {s}." for s in b["subs"]))
(korpus / "skript.txt").write_text("\n\n".join(zeilen), encoding="utf-8")
monkeypatch.setattr(bi, "source_folder", lambda t: korpus)
monkeypatch.setattr(blx, "source_folder", lambda t: korpus)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
files = _files(tmp_path)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "uni", "location": str(korpus)},
korpus, "", research=True, qa_force=True), timeout=120)
assert ok
db = testdb
alle = [dict(c) for c in await db.kanban_cards(TOPIC, board="inventory")]
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
for c in alle)
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
done = {c["payload"].get("title") for c in alle
if c["kind"] == "block" and c["stage"] == "done_block"}
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done

View File

@@ -0,0 +1,397 @@
"""Event-Tracking (events-Tabelle) + Agenten-Labels."""
import asyncio
import agents
from pipeline import GenContext
TOPIC = "t"
async def _events(db, kind=None):
conn = await db.get_db()
q = "SELECT topic, kind, key, label, status, dur_ms, wait_ms FROM events WHERE topic = ?"
args = [TOPIC]
if kind:
q += " AND kind = ?"
args.append(kind)
cur = await conn.execute(q, args)
return [dict(zip(("topic", "kind", "key", "label", "status", "dur_ms", "wait_ms"), r))
for r in await cur.fetchall()]
async def test_advance_many_writes_stage_events(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
await db.kanban_advance_many(TOPIC, "inventory", [("a", "s2"), ("b", "s2")])
evs = await _events(db, "stage")
assert {(e["key"], e["status"]) for e in evs} == {("inventory:a", "s2"), ("inventory:b", "s2")}
async def test_fail_card_events_retry_then_dead(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is False
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is True
evs = await _events(db, "fail")
assert [e["status"] for e in evs] == ["retry1", "dead"]
async def test_guide_stage_event(testdb):
db = testdb
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.set_guide_card(TOPIC, "Guide", "alpha", stage="writer")
evs = await _events(db, "stage")
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and evs[-1]["status"] == "writer"
async def test_run_agent_emits_event_and_survives_broken_sink(testdb, monkeypatch):
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_cli(agent_key, prompt, timeout, model, capabilities, label=""):
return 0, "out", ""
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_claude_cli", fake_cli)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("claude", "test-model"))
rc, out, err = await agents.run_agent("blocks-t-x", "p", 5, provider="claude",
role="judge", scope=TOPIC, label="Alpha · Judge")
assert rc == 0
assert recorded and recorded[0]["kind"] == "agent"
assert recorded[0]["label"] == "Alpha · Judge" and recorded[0]["status"] == "ok"
assert isinstance(recorded[0]["wait_ms"], int) and isinstance(recorded[0]["dur_ms"], int)
# broken sink never breaks the call; interactive/scope-less calls don't log
async def broken(**kw):
raise RuntimeError("sink down")
monkeypatch.setattr(agents, "on_event", broken)
rc, _, _ = await agents.run_agent("blocks-t-y", "p", 5, provider="claude", scope=TOPIC)
assert rc == 0
monkeypatch.setattr(agents, "on_event", sink)
recorded.clear()
await agents.run_agent("chat-1", "p", 5, provider="claude", lane="interactive")
assert recorded == []
async def test_active_agents_carry_labels():
async def run(key, label):
return await agents._communicate(key, ["sleep", "0.4"], None, 5, label=label)
t1 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 1"))
t2 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 2")) # key collision → ~2
await asyncio.sleep(0.15)
agents_now = agents.active_agents("blocks-t-")
assert sorted(a["label"] for a in agents_now) == ["Alpha · Facts 1", "Alpha · Facts 2"]
assert {a["key"] for a in agents_now} == {"blocks-t-x", "blocks-t-x~2"}
await asyncio.gather(t1, t2)
assert agents.active_agents("blocks-t-") == []
async def test_pull_prefers_bigger_blocks(testdb):
"""LPT: Karten mit größerem subs_n werden zuerst gezogen; ohne Feld bleibt FIFO."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "klein", "ablock", "facts", {"subs_n": 5})
await db.kanban_upsert_card(TOPIC, "artefacts", "gross", "ablock", "facts", {"subs_n": 40})
await db.kanban_upsert_card(TOPIC, "artefacts", "mittel", "ablock", "facts", {"subs_n": 15})
pulled = await db.kanban_pull(TOPIC, "artefacts", "facts", 10)
assert [c["card_id"] for c in pulled] == ["gross", "mittel", "klein"]
# ohne subs_n: FIFO nach updated_at
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
assert [c["card_id"] for c in pulled] == ["a", "b"]
# n_size (Board 1) ist der Fallback-Schätzer; subs_n behält Vorrang
await db.kanban_upsert_card(TOPIC, "inventory", "n-klein", "block", "s2", {"n_size": 2})
await db.kanban_upsert_card(TOPIC, "inventory", "n-gross", "block", "s2", {"n_size": 9})
await db.kanban_upsert_card(TOPIC, "inventory", "n-ohne", "block", "s2")
await db.kanban_upsert_card(TOPIC, "inventory", "n-subs", "block", "s2", {"subs_n": 3, "n_size": 1})
pulled = await db.kanban_pull(TOPIC, "inventory", "s2", 10)
assert [c["card_id"] for c in pulled] == ["n-gross", "n-subs", "n-klein", "n-ohne"]
async def test_learnstate_smoke(testdb):
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
from rules import load_learnstate
guides, levels = await load_learnstate()
assert isinstance(levels, dict)
async def test_guide_error_event(testdb):
db = testdb
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.set_guide_card(TOPIC, "Guide", "alpha", status="error", gate_info="Writer ohne Ergebnis")
evs = await _events(db, "fail")
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and "Writer" in evs[-1]["status"]
def test_timeout_calibration_smoke():
from pipeline import _timeout
assert _timeout("subblock_check", 10) == 150 + 100
assert _timeout("writer", 10) == 450 + 600
def test_env_file_wins(tmp_path, monkeypatch):
"""Regression: geerbte (veraltete) Env-Werte dürfen die .env nicht mehr überstimmen."""
import config
monkeypatch.setenv("X_CREATOR_TESTKEY", "alt")
p = tmp_path / ".env"
p.write_text("X_CREATOR_TESTKEY=neu\n", encoding="utf-8")
config._load_env(p)
import os
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
async def test_completeness_route(testdb, tmp_path, monkeypatch):
import routes, paths
db = testdb
monkeypatch.setattr(paths, "arbeit_dir", lambda t: tmp_path)
await db.upsert_block(TOPIC, "alpha", "Alpha", "d", "[]")
await db.set_block_status(TOPIC, "alpha", "consensus")
await db.upsert_subblock(TOPIC, "alpha", "s1", "Alpha", "Sub Eins")
await db.set_subblock_fields(TOPIC, "alpha", "s1", status="consensus")
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "Sub Eins", "Frage?")
await db.put_sub_artifact(TOPIC, "alpha", "s1", "flashcard", "Alpha", "Sub Eins", "{}")
await db.put_lernziel(TOPIC, "alpha", "z1", "Ziel")
await db.set_ziel_covered(TOPIC, "alpha", "z1", True)
(tmp_path / "inventar-filter-x.json").write_text(
'{"degradiert": 3, "ueberstimmt": ["A"], "floor_veto": []}', encoding="utf-8")
res = await routes.blocks_completeness(TOPIC)
assert res["bloecke"] == 1 and res["subs"] == 1
assert res["frage_bloecke"] == 1 and res["lernkarten"] == 1
assert res["ziele_total"] == 1 and res["ziele_covered"] == 1
assert res["degradiert_geprueft"] == 3 and res["panel_gerettet"] == 1
assert res["dead"] == 0
async def test_blocks_ready_from_db(testdb, monkeypatch):
"""Regression: gesynctes Topic ohne blocks.md muss trotzdem ready sein (DB zählt)."""
import blocks as blx
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
st = await blx.blocks_status(TOPIC)
assert st["ready"] is True and st["partial"] is False
async def test_remove_guide_format_clears_everything(testdb, monkeypatch):
"""Board-Remove räumt ALLE Läufe eines Formats + Karten (8 error-Zeilen stapelten sich)."""
import routes
from models import GuideFormatRequest
db = testdb
for i in range(3):
await db.create_guide({"id": f"g{i}", "topic": TOPIC, "format": "Guide",
"instructions": "", "status": "error", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
res = await routes.remove_guide_format(GuideFormatRequest(topic=TOPIC, format="Guide"))
assert res["removed"] == 3
assert await db.list_guides() == [] or all(g["topic"] != TOPIC for g in await db.list_guides())
assert await db.list_guide_cards(TOPIC, "Guide") == []
# ── Token-Logging pro Agent (OpenCode-Session → Event-Meta) ──────────────────────────
def test_session_tokens_reads_newest(tmp_path, monkeypatch):
"""--title = Agent-Key: neueste Session gewinnt (Retry); fehlende DB/Zeile → None."""
import sqlite3
dbf = tmp_path / "oc.db"
con = sqlite3.connect(dbf)
con.execute("CREATE TABLE session (title TEXT, time_created INT, tokens_input INT,"
" tokens_output INT, tokens_reasoning INT, tokens_cache_read INT, tokens_cache_write INT)")
con.execute("INSERT INTO session VALUES ('k', 1, 1, 1, 0, 10, 0)")
con.execute("INSERT INTO session VALUES ('k', 2, 7, 3, 0, 99, 5)")
con.commit()
con.close()
monkeypatch.setattr(agents, "_OPENCODE_DB", dbf)
assert agents._session_tokens("k") == {"input": 7, "output": 3, "reasoning": 0,
"cache_read": 99, "cache_write": 5}
assert agents._session_tokens("fehlt") is None
monkeypatch.setattr(agents, "_OPENCODE_DB", tmp_path / "nope.db")
assert agents._session_tokens("k") is None
async def test_opencode_cmd_sets_title(monkeypatch):
"""Der Agent-Key wird Session-Titel — der Join-Schlüssel fürs Token-Logging."""
seen = {}
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None, est_kb=None):
seen["cmd"] = cmd
return 0, "", ""
monkeypatch.setattr(agents, "_communicate", fake_comm)
await agents._run_opencode("blocks-t-z", "p", 5, "minimax", "m", "none")
i = seen["cmd"].index("--title")
assert seen["cmd"][i + 1] == "blocks-t-z"
async def test_run_agent_logs_opencode_tokens(testdb, monkeypatch):
"""OpenCode-Lauf: Token-Zähler der Session landen im Event-Meta."""
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
return 0, "out", ""
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "test-model"))
monkeypatch.setattr(agents, "_session_tokens",
lambda k: {"input": 5, "output": 2, "reasoning": 0,
"cache_read": 100, "cache_write": 0})
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
assert rc == 0
assert recorded and recorded[0]["meta"]["tokens"]["cache_read"] == 100
# ── run_id-Registry + Lauf-Summary ───────────────────────────────────────────────────
async def test_run_id_stamped_on_events(testdb):
"""Registry gesetzt → Agent- und Stage-Events tragen die run_id; geleert → leer."""
db = testdb
db.set_current_run(TOPIC, "20260703-1200-abcd")
await db.add_event(TOPIC, "agent", key="k1", status="ok",
meta={"tokens": {"input": 10, "output": 2, "cache_read": 50, "cache_write": 1}})
await db.kanban_upsert_card(TOPIC, "inventory", "c1", "block", "ingest", {})
await db.kanban_advance(TOPIC, "inventory", "c1", "cluster")
db.set_current_run(TOPIC, None)
await db.add_event(TOPIC, "agent", key="k2", status="ok")
conn = await db.get_db()
rows = await (await conn.execute("SELECT key, run_id FROM events WHERE topic=? ORDER BY id", (TOPIC,))).fetchall()
by_key = {k: r for k, r in rows}
assert by_key["k1"] == "20260703-1200-abcd"
assert by_key["inventory:c1"] == "20260703-1200-abcd"
assert by_key["k2"] == ""
async def test_events_run_summary_aggregates(testdb):
db = testdb
db.set_current_run(TOPIC, "r1")
await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000,
meta={"tokens": {"input": 10, "output": 2, "cache_read": 50, "cache_write": 1}})
await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000,
meta={"tokens": {"input": 5, "output": 0, "cache_read": 30, "cache_write": 0}})
db.set_current_run(TOPIC, None)
s = await db.events_run_summary(TOPIC, "r1")
assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
assert s["agents"]["verlorene_min"] == 2
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
async def test_events_run_summary_per_board(testdb):
"""meta.board scoped die Aggregation: board="artefacts" summiert nur Artefakt-Agenten."""
db = testdb
db.set_current_run(TOPIC, "r2")
await db.add_event(TOPIC, "agent", key="inv", status="ok", dur_ms=1000,
meta={"board": "inventory", "tokens": {"input": 10, "output": 2}})
await db.add_event(TOPIC, "agent", key="art", status="ok", dur_ms=2000,
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
db.set_current_run(TOPIC, None)
art = await db.events_run_summary(TOPIC, "r2", board="artefacts")
assert art["agents"]["gesamt"] == 1
assert art["tokens"] == {"input": 5, "output": 7, "cache_read": 0, "cache_write": 0}
inv = await db.events_run_summary(TOPIC, "r2", board="inventory")
assert inv["agents"]["gesamt"] == 1 and inv["tokens"]["input"] == 10
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
async def test_latest_board_runs_je_ebene(testdb):
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
db = testdb
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
await db.add_event(TOPIC, "agent", key="i1", status="ok",
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
await db.add_event(TOPIC, "agent", key="a2", status="ok",
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
await db.add_event(TOPIC, "agent", key="g1", status="ok",
meta={"tokens": {"input": 3, "output": 4}})
latest = await db.latest_board_runs(TOPIC)
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
assert latest["inventory"]["tokens"]["input"] == 10
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
assert latest["artefacts"]["tokens"]["output"] == 7
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
assert latest["guide"]["tokens"]["input"] == 3
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
db.set_current_run(TOPIC, None)
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
import routes, qa
db = testdb
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
await db.create_topic(TOPIC)
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
"status": "done", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
res = await routes.remove_topic(TOPIC)
assert res["ok"]
assert all(g["topic"] != TOPIC for g in await db.list_guides())
assert await db.list_guide_cards(TOPIC, "Guide") == []
assert await db.kanban_cards(TOPIC, "inventory") == []
assert TOPIC not in await routes.get_topics()
async def test_topic_delete_409_bei_laufendem_guide(testdb, tmp_path, monkeypatch):
"""Läuft eine Generierung, wird nicht gelöscht (409) — ein laufender Flow schrieb
sonst nach dem Löschen munter neue Rows/Dateien."""
import pytest
from fastapi import HTTPException
import routes
db = testdb
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
"status": "generating", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
with pytest.raises(HTTPException) as e:
await routes.remove_topic(TOPIC)
assert e.value.status_code == 409
assert any(g["topic"] == TOPIC for g in await db.list_guides())
async def test_runs_endpoint_liefert_bilanz(testdb):
"""GET /api/runs: pro run_id Zeitspanne + Agent-/Token-Bilanz + Fails, jüngster zuerst,
aktiv-Flag aus dem Run-Registry."""
import routes
db = testdb
db.set_current_run(TOPIC, "r1")
await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000,
meta={"tokens": {"input": 10, "output": 20, "cache_read": 0, "cache_write": 0}})
await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000)
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
res = await routes.get_runs(TOPIC)
runs = res["runs"]
assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
assert runs[0]["tokens"]["output"] == 20
assert runs[0]["fails"][0]["error"] == "kaputt" and runs[0]["fails"][0]["status"] == "dead"
assert runs[0]["start"] <= runs[0]["ende"]
db.set_current_run(TOPIC, "r2")
await db.add_event(TOPIC, "agent", key="c", status="ok")
db.set_current_run(TOPIC, None)
runs = (await routes.get_runs(TOPIC))["runs"]
assert [r["run_id"] for r in runs] == ["r2", "r1"]
assert runs[0]["aktiv"] is False # Registry geräumt → Lauf beendet
async def test_health(testdb):
import routes
assert (await routes.health())["ok"] is True

View File

@@ -0,0 +1,80 @@
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
import blocks
import board_artefacts as ba
from kanban import Flow
from pipeline import GenContext
TOPIC = "finalize"
def _ctx():
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
def test_luecken_schnitt_cap():
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
def test_neg_set_lemmatisiert():
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
assert a == b == frozenset({"kein"})
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
async def test_finalize_purges_stale_rows(testdb, tmp_path):
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
db = testdb
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
flow = Flow(TOPIC, work_dir=tmp_path)
await ba._proc_finalize(_ctx(), flow, files, [card])
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
db = testdb
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
"sidecar": {}, "pattern": {}, "artefacts": {}}}
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
assert row["level"] == "advanced" and row["relevance"] == "relevant"
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
db = testdb
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
"pattern": {}, "artefacts": {}}}
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows == {"neu": "consensus", "alte-variante": "variant"}

View File

@@ -0,0 +1,532 @@
"""Guide board: schema parsers + card reset semantics (no LLM)."""
import guide_board as gb
TOPIC, FMT = "t", "Guide"
def test_ziele_schema():
ok = gb._ziele_schema({"ziele": [{"id": "z1", "text": "Erklären, warum X", "sub": "S"},
{"id": "z2", "text": "Nennen von Y"}]})
assert [z["id"] for z in ok] == ["z1", "z2"]
assert gb._ziele_schema({"ziele": []}) is None
assert gb._ziele_schema({"ziele": [{"id": "z1", "text": "a"}, {"id": "z1", "text": "b"}]}) \
== [{"id": "z1", "text": "a", "sub": ""}] # duplicate ids fold
assert gb._ziele_schema("quatsch") is None
def test_gate_schema():
assert gb._gate_schema({"ok": True}) == []
claims = gb._gate_schema({"claims": [{"text": "Falsch", "grund": "fehlt"}]})
assert claims == [{"text": "Falsch", "grund": "fehlt", "urteil": "unbelegt"}]
assert gb._gate_schema({}) is None
# urteil "falsch" wird durchgereicht, alles andere defaultet auf unbelegt
claims = gb._gate_schema({"claims": [{"text": "A", "grund": "widerspricht", "urteil": "FALSCH"},
{"text": "B", "grund": "x", "urteil": "quatsch"}]})
assert [c["urteil"] for c in claims] == ["falsch", "unbelegt"]
# Voll-Inventur-Rauschen: als belegt begründete Einträge fliegen raus
claims = gb._gate_schema({"claims": [{"text": "A", "grund": "Belegt durch Quelle X"},
{"text": "B", "grund": "nicht ableitbar"}]})
assert [c["text"] for c in claims] == ["B"]
def test_pruefer_schema():
"""Verschmolzenes Verdikt: ok-Kurzform, Claims-Normalisierung, Ziel-Vollständigkeit."""
assert gb._pruefer_schema({"ok": True}, {"z1"}) == {
"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
res = gb._pruefer_schema({"claims": [{"text": "A", "grund": "x", "urteil": "FALSCH"}],
"ziele": {"z1": True, "z2": "false"},
"luecken": [{"ziel": "z2", "fehlt": "Beweis"}],
"ballast": ["Abschweifung"],
"lese_probleme": [{"problem": "zu lang"}]}, {"z1", "z2"})
assert res["claims"] == [{"text": "A", "grund": "x", "urteil": "falsch"}]
assert res["ziele"] == {"z1": True, "z2": False}
assert res["luecken"][0]["fehlt"] == "Beweis" and res["lese_probleme"] == ["zu lang"]
assert gb._pruefer_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 fehlt
assert gb._pruefer_schema({"lese_probleme": []}, set()) is not None # leeres Verdikt ok
assert gb._pruefer_schema("quatsch", set()) is None
def test_auftraege_schwelle_und_kritisch():
"""12 nur-unbelegt-Claims verfallen (GATE_FIX_MIN); falsch/Lücken sind kritisch."""
leer = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "", "urteil": "unbelegt"}] * 2}, [])
assert z == [] and k is False
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "w", "urteil": "falsch"}]}, [])
assert len(z) == 1 and k is True
z, k = gb._auftraege({**leer, "luecken": [{"ziel": "z1", "fehlt": "X"}]}, ["Länge 2000"])
assert len(z) == 2 and k is True
async def test_done_step(testdb):
db = testdb
assert await gb.done_step(TOPIC, FMT) == -1
await db.upsert_guide_card(TOPIC, FMT, "a", "A")
assert await gb.done_step(TOPIC, FMT) == -1 # alles in lernziele
await db.set_guide_card(TOPIC, FMT, "a", stage="pruefer")
assert await gb.done_step(TOPIC, FMT) == 2 # bis writer fertig
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch):
"""Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim
ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage."""
import asyncio
from types import SimpleNamespace
import guide_board as gb
db = testdb
await db.upsert_guide_card("t", "Guide", "alpha", "Alpha")
env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide")
card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"}
seen = {}
async def fake_stage(env2, card2):
seen.update(dict(gb._live_info))
card2["stage"] = "done"
return True
monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage})
await gb._run_card(env, card, asyncio.Semaphore(1))
assert card["stage"] == "done"
assert ("t", "Guide", "alpha") in seen # live info stand während der Stage
assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt
def test_merge_split_sections_one_section_all_markers():
import guide_board as gb
from textkit import _parse_fragment
a = _parse_fragment("""<!-- section: Front Matter -->
<!-- compact -->
Kurzer Einstieg kompakt.
<!-- sub: beginner | YAML-Basics -->
YAML kompakt.
<!-- ausführlich -->
Einstieg ausführlich.
<!-- sub: beginner | YAML-Basics -->
YAML ausführlich.""")[0]
b = _parse_fragment("""<!-- section: Front Matter (Teil 2) -->
<!-- compact -->
<!-- sub: advanced | TOML-Sektionen -->
TOML kompakt.
<!-- ausführlich -->
Unerwünschter zweiter Einstieg.
<!-- sub: advanced | TOML-Sektionen -->
TOML ausführlich.""")[0]
merged = gb._merge_split_sections(a, b)
secs = _parse_fragment(merged)
assert len(secs) == 1
sec = secs[0]
assert sec["title"] == "Front Matter"
assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"]
assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
async def test_card_examples_filters_and_formats(testdb):
"""_card_examples: Norm-Matching auf die übergebenen Subs; unmatchte Beispiele nur
beim Voll-Writer/Teil 1 (include_unmatched) — nie stillschweigend weg."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a", "b"], "result": "R1"}),
"Gross", "Sub Eins")
await db.put_sub_artifact("t", "gross", "verwaist", "example",
_json.dumps({"problem": "P2", "steps": ["x"], "result": "R2"}),
"Gross", "Verwaister Sub")
env = SimpleNamespace(topic="t")
subs = [{"title": "Sub Eins", "level": "beginner"}]
full = await gb._card_examples(env, "gross", subs)
assert "Sub Eins" in full and "P1" in full and "1) a 2) b" in full and "R1" in full
assert "Subbaustein unklar" in full and "P2" in full # orphan attached with hint
half = await gb._card_examples(env, "gross", subs, include_unmatched=False)
assert "P1" in half and "P2" not in half # split half: only its own subs
assert await gb._card_examples(env, "leer", subs) == ""
def test_writer_template_has_examples_placeholder():
"""Smoke: alle Platzhalter versorgt — ein fehlender Kwarg stürbe als KeyError."""
from pipeline import _prompt
text = _prompt("Guide-Writer-Board", topic="t", format_name="Guide", chapter="K1",
assignment="- B", ziele="- z", facts="F", examples="", gaps="",
budget=2000, spec="", out_path="/tmp/x.md", extra="")
assert "VERIFIED FACTS" in text and "2000 characters" in text
async def test_pruefer_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
"""Prüfer-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
gerechnete Beispielwerte als „nicht belegt" raus."""
import json as _json
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
await db.put_sub_artifact("t", "gross", "sub eins", "example",
_json.dumps({"problem": "P1", "steps": ["a"], "result": "R1"}),
"Gross", "Sub Eins")
captured = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
captured["prompt"] = prompt
return "ok", payload((0, '{"ok": true}', ""))
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": [{"title": "Sub Eins", "level": "beginner"}]},
spec="", slot=lambda name: tmp_path / name)
md = ("<!-- section: Gross -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20)
card = {"block_norm": "gross", "block": "Gross", "stage": "pruefer", "status": "open",
"writer_rounds": 0, "gate_info": "", "md": md}
ok = await gb._stage_pruefer(env, card)
assert ok is True
assert "VERIFIED WORKED EXAMPLES" in captured["prompt"] and "P1" in captured["prompt"]
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
calls = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append(label)
part = "2" if key.endswith("-b") else "1"
p = tmp_path / f"out-{key[-1]}.md"
p.write_text(f"<!-- section: Gross -->\n<!-- ausführlich -->\n"
+ ("Einstieg.\n" if part == "1" else "")
+ f"<!-- sub: beginner | Sub {part} -->\nText {part}.", encoding="utf-8")
# payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(p.read_text(encoding="utf-8"))
return "ok", payload(None)
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)]
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": subs}, spec="",
slot=lambda name: tmp_path / name)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "")
card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open",
"writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"}
ok = await gb._stage_writer(env, card)
assert ok is True
assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c]
from textkit import _parse_fragment
secs = _parse_fragment(card["md"])
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
assert card["stage"] == "pruefer"
async def test_lernziele_retry_bei_leerer_liste(testdb, tmp_path, monkeypatch):
"""Leere Ziele-Liste → genau EIN Ersatz-Versuch (Key-Suffix -2); dessen Ziele landen in der DB."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-r", TOPIC, FMT, "", tmp_path / "Guide.json",
{"Alpha": [{"title": "S1", "level": "beginner"}]}, {}, "(quelle)", "spec")
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0}
calls = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
calls.append(key)
if len(calls) == 1:
return gb.OK, []
return gb.OK, [{"id": "z1", "text": "Ziel", "sub": "S1"}]
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_lernziele(env, card)
assert len(calls) == 2 and calls[1].endswith("-2")
assert [z["ziel_id"] for z in await db.list_lernziele(TOPIC, "alpha")] == ["z1"]
async def test_lernziele_zweimal_leer_laeuft_weiter(testdb, tmp_path, monkeypatch):
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-r2", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0}
async def leer(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
return gb.OK, []
monkeypatch.setattr(gb, "run_single_slot", leer)
assert await gb._stage_lernziele(env, card)
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "zuweisung"
assert not await db.list_lernziele(TOPIC, "alpha")
async def test_pruefer_text_sink_ohne_befund_done(testdb, tmp_path, monkeypatch):
"""Prüfer antwortet als Text (Engine-Sink, capabilities none); ohne Befund → done."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-l", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = ("<!-- kapitel: K -->\n<!-- section: Alpha -->\n<!-- compact -->\n- x\n"
"<!-- ausführlich -->\n" + "Text im Längen-Rahmen. " * 20) # ~460 Z. — kein Längen-Trigger
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
seen["caps"] = capabilities
return gb.OK, payload((0, '{"ok": true}', ""))
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
assert seen["caps"] == "none"
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
async def test_writer_prompt_traegt_budget(testdb, tmp_path, monkeypatch):
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-w", TOPIC, FMT, "", tmp_path / "Guide.json",
{"Alpha": [{"title": "S1", "level": "beginner"},
{"title": "S2", "level": "beginner"}]}, {}, "(q)", "spec")
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "chapter": "K", "gate_info": ""}
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
seen["prompt"] = prompt
return gb.FAILED, None
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
await gb._stage_writer(env, card)
assert str(gb.block_budget(env.subs_by_title["Alpha"])) in seen["prompt"] # 2× Basis-Budget
async def test_pruefer_claims_schwelle(testdb, tmp_path, monkeypatch):
"""12 nur-unbelegt-Claims → kein Fix (Karte direkt done); die Schwelle lebt in _auftraege."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-g", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
antwort = '{"claims": [{"text": "c1", "grund": ""}, {"text": "c2", "grund": ""}]}'
return gb.OK, payload((0, antwort, ""))
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
async def test_load_subblocks_defaultet_levellose(testdb):
"""Consensus-Row ohne level fällt NICHT mehr raus — Default 'advanced'.
(Re-Run-Resume hinterließ 25 solcher Rows; der Writer verlor sie stumm.)"""
from guide import _load_subblocks
db = testdb
await db.put_subblock("t", "block", "mit level", "Block", "Mit Level",
level="beginner", status="consensus")
await db.put_subblock("t", "block", "ohne level", "Block", "Ohne Level", status="consensus")
subs = await _load_subblocks("t")
by_title = {s["title"]: s["level"] for s in subs["Block"]}
assert by_title == {"Mit Level": "beginner", "Ohne Level": "advanced"}
async def test_falsch_claim_erzwingt_fix_und_repruefer(testdb, tmp_path, monkeypatch):
"""Ein einzelner FALSCH-Claim läuft in den Fix, auch unter GATE_FIX_MIN; nach
angewandtem Fix läuft GENAU EIN Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-gf", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
keys = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
keys.append(key)
if "-pruef-" in key and key.endswith("-re"):
return gb.OK, payload((0, '{"ok": true}', ""))
if "-pruef-" in key:
return gb.OK, payload((0, '{"claims": [{"text": "c1", "grund": "widerspricht", "urteil": "falsch"}]}', ""))
if "-gfix-" in key: # Fix liefert eine valide Section über die Slot-Datei
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
return gb.OK, payload(None)
raise AssertionError(key)
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "fix" and karte["gate_info"].startswith("KRITISCH")
card.update(stage="fix", gate_info=karte["gate_info"])
assert await gb._stage_fix(env, card)
assert any("-gfix-" in k for k in keys)
assert any(k.endswith("-re") for k in keys) # Re-Prüfer lief
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
async def test_laengen_trigger_startet_fix(testdb, tmp_path, monkeypatch):
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem mit hartem
Zeichenziel landet als Auftrag in der Fix-Stage (unkritisch → kein Re-Prüfer)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-lz", TOPIC, FMT, "", tmp_path / "Guide.json",
{"Alpha": [{"title": "S1", "level": "beginner", "relevance": "relevant"}]},
{}, "(q)", "spec")
md = ("<!-- section: Alpha -->\n<!-- compact -->\n- x\n<!-- ausführlich -->\n"
+ "Viel zu langer Sockeltext. " * 80) # ~2160 Z./Sub > 1200×0.9
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
if "-gfix-" in key:
seen["auftraege"] = prompt
return gb.FAILED, None
if key.endswith("-re"):
raise AssertionError("unkritischer Befund darf keinen Re-Prüfer starten")
return gb.OK, payload((0, '{"ok": true}', "")) # Prüfer: keine LLM-Befunde
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "fix"
card.update(stage="fix", gate_info=(await db.list_guide_cards(TOPIC, FMT))[0]["gate_info"])
assert await gb._stage_fix(env, card)
budget = gb.block_budget(env.subs_by_title["Alpha"])
assert "Länge" in seen["auftraege"] and f"etwa {budget} Zeichen GESAMT" in seen["auftraege"]
async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, monkeypatch):
"""Guide-Repair: Karten mit QA-Befunden (alle Befundklassen-Formate) → pruefer,
md bleibt; Karten ohne Befund unangetastet. Kein Report → leere Liste."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
assert await gb.repair_karten(TOPIC, FMT) == [] # kein Report
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000000.json").write_text(_json.dumps({
"marker_fehlend": ["Alpha · sub eins"],
"fachlich_falsch": ["Beta"],
"ziel_ohne_anker": [], "laengen_ausreisser": [], "redundanz": [], "lesbarkeit": [],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer"
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
assert cards["gamma"]["stage"] == "done"
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
"lesbarkeit": ["Alpha: Sätze kürzen"],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-ff", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
assert "-gfix-" in key
return gb.FAILED, None
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fix(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done"
assert "offene Befunde" in karte["gate_info"] and "Claim c1" in karte["gate_info"]
async def test_repruefer_ausfall_wird_vermerkt(testdb, tmp_path, monkeypatch):
"""Fällt der Re-Prüfer aus (kein Cancel), wird das im gate_info vermerkt statt die
Karte stumm als geprüft durchzuwinken."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-ra", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
if "-gfix-" in key:
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
return gb.OK, payload(None)
return gb.FAILED, None # Re-Prüfer fällt aus
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fix(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
assert "Re-Prüfer ohne Ergebnis" in karte["gate_info"]
async def test_progress_reporter_ueberlebt_db_fehler(testdb, monkeypatch):
"""Ein DB-Fehler beendet den Reporter nicht (der Fortschritt fror sonst still ein);
unveränderter Stand wird nicht erneut geschrieben."""
import asyncio
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
calls = {"n": 0}
writes = []
orig = db.guide_stage_counts
async def flaky(topic, fmt):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("kaputt")
return await orig(topic, fmt)
async def fake_update(guide_id, **kw):
writes.append(kw["progress"])
monkeypatch.setattr(gb.db, "guide_stage_counts", flaky)
monkeypatch.setattr(gb.db, "update_guide", fake_update)
task = asyncio.create_task(gb._progress_reporter("g-pr", TOPIC, FMT, takt=0.01))
await asyncio.sleep(0.08)
task.cancel()
assert calls["n"] >= 3 # lief nach dem Fehler weiter
assert writes.count(writes[0]) == 1 # gleicher Stand nur einmal geschrieben

View File

@@ -0,0 +1,120 @@
"""Guide-QA: Fehler-Injektion auf Mini-Guide-Karten — deterministisch, ohne LLM."""
import guide_qa as gq
import qa
def _card(block, md):
return {"block": block, "block_norm": block.casefold(), "md": md}
AUSF = ("<!-- section: Alpha -->\n<!-- compact -->\n- m\n<!-- ausführlich -->\n"
"Einstieg in den Block.\n"
"<!-- sub: beginner | Kantenzug Definition -->\n"
"Ein Kantenzug verbindet Knoten über Kanten im Graphen.\n")
def test_ausfuehrlich_extrahiert_lerntext():
assert gq._ausfuehrlich(AUSF).startswith("\nEinstieg")
assert gq._ausfuehrlich("nur text") == "nur text"
def test_marker_fehlend():
cards = [_card("Alpha", AUSF)]
rel = {"alpha": {"kantenzug definition", "fehlender aspekt"}}
out = gq.marker_fehlend(cards, rel)
assert out == ["Alpha · fehlender aspekt"]
def test_ziel_ohne_anker():
cards = [_card("Alpha", AUSF)]
ziele = [{"block_norm": "alpha", "ziel_id": "z1", "text": "Kantenzug im Graphen erklären"},
{"block_norm": "alpha", "ziel_id": "z2", "text": "Adjazenzmatrix aufstellen können"}]
out = gq.ziel_ohne_anker(cards, ziele)
assert len(out) == 1 and "z2" in out[0]
def test_laengen_ausreisser():
"""Budget-Band statt Festrahmen: zu dünn und zu dick fallen auf, ohne Budget kein Urteil."""
duenn = _card("Alpha", "<!-- ausführlich -->\nkurz")
ok = _card("Beta", "<!-- ausführlich -->\n" + "x" * 500)
dick = _card("Gamma", "<!-- ausführlich -->\n" + "x" * 2000)
ohne = _card("Delta", "<!-- ausführlich -->\nkurz")
budgets = {"alpha": 500, "beta": 500, "gamma": 500} # delta: kein Inventar → übersprungen
out = gq.laengen_ausreisser([duenn, ok, dick, ohne], budgets)
assert [x["block"] for x in out] == ["Alpha", "Gamma"]
assert out[1]["zeichen"] >= 2000 and out[1]["budget"] == 500
def test_budget_aus_substanz():
"""Dichte Subs bekommen mehr Budget; periphere zählen nicht."""
dicht = {"relevance": "relevant", "facts": {"key_points": ["a", "b"], "cited_facts": [{}], "example_idea": "x"}}
duenn = {"relevance": "relevant", "facts": {}}
peripher = {"relevance": "peripheral", "facts": {"key_points": ["a"] * 9}}
assert gq.sub_budget(dicht["facts"]) > gq.sub_budget(duenn["facts"])
assert gq.block_budget([dicht, duenn, peripher]) == gq.block_budget([dicht, duenn])
def test_redundanz_findet_absatz_doppel():
a = "Der Kantenzug verbindet Knoten über mehrere Kanten und darf Knoten wiederholen. " * 3
b = "Der Kantenzug verbindet Knoten über mehrere Kanten und darf Knoten wiederholen, genau. " * 3
c = "Völlig anderes Thema: Matrizen, Determinanten und lineare Abbildungen im Vektorraum. " * 3
cards = [_card("Alpha", f"<!-- ausführlich -->\n{a}\n\n{c}"),
_card("Beta", f"<!-- ausführlich -->\n{b}")]
out = gq.redundanz(cards)
assert len(out) == 1 and out[0]["a"].startswith("Alpha")
def test_lesbarkeit_fail_open(monkeypatch):
def kaputt(md_by_num):
raise RuntimeError("Modell fehlt")
monkeypatch.setattr(gq.readability, "rate_sections", kaputt)
assert gq.lesbarkeit([_card("Alpha", AUSF)]) == []
def test_note_guide_kalibrierung():
"""Gewicht = Punktabzug bei 100 %: 10 % fachlich falsch × 3.0 → 7.0; ungemessen zählt nicht."""
assert qa.note({"fachlich_falsch": 0.1}, gq.NOTE_GEWICHTE_GUIDE) == 7.0
ohne = {"marker_fehlend": 0.0, "ziel_ohne_anker": 0.0}
assert qa.note(ohne, gq.NOTE_GEWICHTE_GUIDE) == 10.0
def test_marker_escaping_tolerant():
"""Escapte Titel (h\\~2\\~o) sind kein „Marker fehlt" — Writer und DB escapen verschieden."""
md = "<!-- ausführlich -->\n<!-- sub: beginner | ^ und ~ müssen escaped werden (h\\\\~2\\\\~o) -->\nText."
cards = [{"block": "Hoch", "block_norm": "hoch", "md": md}]
rel = {"hoch": {gq._norm_title("^ und ~ müssen escaped werden (h~2~o)")}}
assert gq.marker_fehlend(cards, rel) == []
async def test_fachlich_falsch_braucht_beide_bestaetiger(monkeypatch):
"""Befund zählt nur, wenn BEIDE Bestätiger zustimmen — Einzel-/Zweifach-Urteile
ließen die Note desselben Guides zwischen 2.0 und 6.6 springen."""
import agents
calls = {"n": 0}
async def fake_agent(key, prompt, timeout, **kw):
calls["n"] += 1
if "-fakten-3-" in key: # Bestätiger 2: nur #1 bleibt
return 0, '{"relevant": {"1": "ja"}}', ""
if "-fakten-2-" in key: # Bestätiger 1: #1 und #2
return 0, '{"relevant": {"1": "ja", "2": "ja"}}', ""
return 0, '{"relevant": {"1": "ja", "2": "ja", "3": "nein"}}', "" # Pass 1
monkeypatch.setattr(agents, "run_agent", fake_agent)
cards = [_card("Alpha", AUSF), _card("Beta", AUSF), _card("Gamma", AUSF)]
out = await gq._fachlich_falsch("t", cards)
assert out == ["Alpha"] and calls["n"] == 3
async def test_fachlich_falsch_ohne_verdacht_kein_zweiter_pass(monkeypatch):
import agents
calls = {"n": 0}
async def fake_agent(key, prompt, timeout, **kw):
calls["n"] += 1
return 0, '{"relevant": {"1": "nein", "2": "nein"}}', ""
monkeypatch.setattr(agents, "run_agent", fake_agent)
out = await gq._fachlich_falsch("t", [_card("Alpha", AUSF), _card("Beta", AUSF)])
assert out == [] and calls["n"] == 1

View File

@@ -0,0 +1,48 @@
"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand."""
import blocks
import guide
import guide_qa
import guide_board
def _patch(monkeypatch, noten, calls):
async def fake_gen(*a, **k):
calls["gen"] += 1
async def fake_rep(topic, fmt):
calls["rep"] += 1
return []
async def fake_qa(topic, llm=False):
return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]}
monkeypatch.setattr(guide, "generate_guide", fake_gen)
monkeypatch.setattr(guide_board, "repair_karten", fake_rep)
monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa)
async def test_guide_fertig_ohne_repair(testdb, monkeypatch):
blocks._blocks_errors.pop("G1", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [10.0], calls)
await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None)
assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut
assert "G1" not in blocks._blocks_errors
async def test_guide_repariert_bis_100(testdb, monkeypatch):
blocks._blocks_errors.pop("G2", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0
await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None)
assert calls["rep"] == 1
assert "G2" not in blocks._blocks_errors
async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch):
blocks._blocks_errors.pop("G3", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie
await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None)
assert "G3" in blocks._blocks_errors
assert "Guide" in blocks._blocks_errors["G3"]

View File

@@ -0,0 +1,128 @@
"""Engine tests with fake processors (no LLM): flow, barrier, retry/dead-letter, producer race."""
import asyncio
import pytest
import kanban
from kanban import Flow, Stage, chain_stages, run_flow
TOPIC = "t"
BOARD = "inventory"
def _advance_proc(db, to_stage):
async def proc(cards):
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], to_stage) for c in cards])
return proc
async def _seed(db, n, stage="s1"):
for i in range(n):
await db.kanban_upsert_card(TOPIC, BOARD, f"card-{i}", "title", stage, {"title": f"T{i}"})
async def test_cards_flow_through_stages(testdb):
db = testdb
await _seed(db, 7)
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", _advance_proc(db, "s2")),
Stage(BOARD, "s2", _advance_proc(db, "done")),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 7
assert await db.kanban_count(TOPIC, ["s1", "s2"], board=BOARD) == 0
async def test_barrier_waits_for_upstream(testdb):
db = testdb
await _seed(db, 6)
upstream_left: list[int] = []
async def slow_s1(cards):
await asyncio.sleep(0.05) # keep upstream busy so an eager barrier would see queued cards
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "gate") for c in cards])
async def barrier_proc(cards):
upstream_left.append(await db.kanban_count(TOPIC, ["s1"], board=BOARD))
await db.kanban_advance_many(TOPIC, BOARD, [(c["card_id"], "done") for c in cards])
flow = Flow(TOPIC)
stages = chain_stages([
Stage(BOARD, "s1", slow_s1),
Stage(BOARD, "gate", barrier_proc, barrier=True),
])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 6
assert upstream_left and all(n == 0 for n in upstream_left) # barrier never ran with s1 queued
async def test_retry_backoff_then_dead(testdb, monkeypatch):
db = testdb
monkeypatch.setattr(kanban, "RETRY_BACKOFF", 0.02)
await _seed(db, 1)
attempts = []
async def failing(cards):
attempts.append(cards[0]["retries"])
raise RuntimeError("kaputt")
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", failing)])
await asyncio.wait_for(run_flow(flow, stages), timeout=10)
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "dead"
assert card["retries"] == kanban.MAX_CARD_RETRIES
assert "kaputt" in card["last_error"]
assert attempts == [0, 1, 2] # backoff between attempts, then dead-letter
async def test_requeue_dead(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
for _ in range(kanban.MAX_CARD_RETRIES):
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", kanban.MAX_CARD_RETRIES, 0.0)
assert (await db.kanban_get_card(TOPIC, BOARD, "card-0"))["stage"] == "dead"
assert await db.kanban_requeue_dead(TOPIC, BOARD, "s1") == 1
card = await db.kanban_get_card(TOPIC, BOARD, "card-0")
assert card["stage"] == "s1" and card["retries"] == 0
async def test_producer_attach_in_idle_lull(testdb):
"""Fix-6 regression: a producer attached while workers sit in the exit grace poll
must keep the flow alive and its cards must still be processed."""
db = testdb
flow = Flow(TOPIC)
stages = chain_stages([Stage(BOARD, "s1", _advance_proc(db, "done"))])
async def producer_a():
await db.kanban_upsert_card(TOPIC, BOARD, "card-a", "title", "s1")
flow.wake.set()
flow.done_producer()
async def attacher():
while await db.kanban_count(TOPIC, "done", board=BOARD) == 0: # wait for card-a done
await asyncio.sleep(0.01)
flow.add_producer() # synchronous BEFORE the work — the grace poll must see it
async def producer_b():
await db.kanban_upsert_card(TOPIC, BOARD, "card-b", "title", "s1")
flow.wake.set()
flow.done_producer()
await producer_b()
flow.add_producer() # producer_a, counted before run_flow (sync add)
asyncio.get_event_loop().create_task(attacher())
await asyncio.wait_for(run_flow(flow, stages, producers=[producer_a()]), timeout=10)
assert await db.kanban_count(TOPIC, "done", board=BOARD) == 2
async def test_backoff_delays_pull(testdb, monkeypatch):
db = testdb
await db.kanban_upsert_card(TOPIC, BOARD, "card-0", "title", "s1")
await db.kanban_fail_card(TOPIC, BOARD, "card-0", "x", 5, 0.2)
assert await db.kanban_pull(TOPIC, BOARD, "s1", 10) == [] # in backoff → not pullable
assert await db.kanban_count(TOPIC, "s1", board=BOARD) == 1 # but still counts as queued
await asyncio.sleep(0.25)
assert len(await db.kanban_pull(TOPIC, BOARD, "s1", 10)) == 1

View File

@@ -0,0 +1,115 @@
"""PDF→Text-Konvertierung: pymupdf4llm primär, pdftotext-Fallback, mtime-Cache."""
import os
import time
import fitz # PyMuPDF
import pytest
import blocks as blx
def _mini_pdf(path, text="Approximationsalgorithmen sind wichtig."):
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 72), text, fontsize=12)
doc.save(str(path))
doc.close()
def test_convert_writes_markdown_txt(tmp_path):
_mini_pdf(tmp_path / "skript.pdf")
blx._convert_pdfs(tmp_path)
out = (tmp_path / "skript.txt").read_text(encoding="utf-8")
assert "Approximationsalgorithmen" in out
def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf")
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
marker = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60
os.utime(marker, (now, now))
blx._convert_pdfs(tmp_path)
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
_mini_pdf(tmp_path / "a.pdf")
stale = tmp_path / "a.txt"
stale.write_text("ALT", encoding="utf-8")
now = time.time() + 60
os.utime(stale, (now, now))
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
blx._convert_pdfs(tmp_path)
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
stale.write_text("BLEIBT", encoding="utf-8")
os.utime(stale, (now, now))
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
assert stale.read_text(encoding="utf-8") == "BLEIBT"
def test_entzerre_pdf_woerter():
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
# Regel A inkl. Kette
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
belegt = "N P ist zentral. " + "NP NP NP."
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
# kein Match mitten im Wort
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "fallback")
blx._convert_pdfs(tmp_path)
assert (tmp_path / "b.txt").read_text(encoding="utf-8") == "fallback"
def test_ocr_languages_from_tessdata(tmp_path, monkeypatch):
import pymupdf
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: str(tmp_path))
assert blx._ocr_languages() is None # keine Sprachdaten → OCR aus
(tmp_path / "eng.traineddata").touch()
assert blx._ocr_languages() == "eng"
(tmp_path / "deu.traineddata").touch()
assert blx._ocr_languages() == "deu+eng"
monkeypatch.setattr(pymupdf, "get_tessdata", lambda: (_ for _ in ()).throw(RuntimeError()))
assert blx._ocr_languages() is None
def test_fidelity_guard_prefers_faithful_plaintext():
plain = "Definition. P = {L ⊆ Σ | A ∈ L} und ≤ sowie häufig über. " * 20
# Markdown verlor die Formeln (Symbole weg) → plain gewinnt
md_lossy = "Definition. und sowie h¨aufig ¨uber. " * 20
text, tool = blx._pick_conversion(md_lossy, plain)
assert tool == "pdftotext"
# Markdown treu (Symbole + Länge da) → md gewinnt
md_ok = "# Def\n" + plain
text, tool = blx._pick_conversion(md_ok, plain)
assert tool == "pymupdf4llm"
# nur eine Quelle verfügbar
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
assert blx._pick_conversion(None, None) is None
def test_entzerre_hyphen_space():
"""Regel C: GROSSLAUF + Leerzeichen vor Bindestrich-Kompositum („NP -vollständig",
Makro-Spacing) wird gemerged; echter Gedankenstrich („X - y") bleibt."""
assert blx._entzerre_pdf_woerter("die NP -vollständigkeit gilt") == "die NP-vollständigkeit gilt"
assert blx._entzerre_pdf_woerter("Begriffe der NP -schwere und NP -vollständigkeit") == \
"Begriffe der NP-schwere und NP-vollständigkeit"
assert blx._entzerre_pdf_woerter("Term X - y bleibt") == "Term X - y bleibt"
assert blx._entzerre_pdf_woerter("Liste:\n-punkt eins") == "Liste:\n-punkt eins"

View File

@@ -0,0 +1,146 @@
"""Flashcard-Übungspool: Leitner-Schritte, Deck-Bau (Level-Gate, fällig/neu), Persistenz."""
import json
from datetime import datetime, timedelta, timezone
from learning import LEITNER_MAX_BOX, PRACTICE_NEW_PER_SESSION, leitner_step
TOPIC = "t"
def _iso(days: float = 0) -> str:
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
async def _card(db, bn, sn, sub_title="Sub", q="Q?", block="Block"):
await db.put_sub_artifact(TOPIC, bn, sn, "flashcard",
json.dumps({"question": q, "answer": "A"}), block, sub_title)
# ── Leitner rein funktional ──────────────────────────────────────────────────────────
def test_leitner_step_transitions():
assert leitner_step(None, True) == (2, 1) # neue Karte gewusst → Box 2, morgen
assert leitner_step(None, False) == (1, 0) # neue Karte falsch → Box 1, sofort
assert leitner_step(2, True) == (3, 3)
assert leitner_step(LEITNER_MAX_BOX, True) == (LEITNER_MAX_BOX, 21) # Cap
assert leitner_step(4, False) == (1, 0) # falsch → zurück auf Anfang
# ── Persistenz ───────────────────────────────────────────────────────────────────────
async def test_progress_upsert_roundtrip(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.upsert_practice_progress(TOPIC, "b", "s", 3, _iso(3))
rows = await db.get_practice_progress(TOPIC)
assert len(rows) == 1 and rows[0]["box"] == 3
async def test_progress_survives_artefakte_wipe(testdb):
db = testdb
await _card(db, "b", "s")
await db.upsert_practice_progress(TOPIC, "b", "s", 4, _iso(7))
await db.delete_sub_artefakte(TOPIC) # Regenerations-Wipe
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 4
async def test_delete_topic_pipeline_clears_progress(testdb):
db = testdb
await db.upsert_practice_progress(TOPIC, "b", "s", 2, _iso(1))
await db.delete_topic_pipeline(TOPIC)
assert await db.get_practice_progress(TOPIC) == []
async def test_sub_levels_norm_and_counts(testdb):
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "b", "s2", "Block", "S2", level="expert")
await db.put_subblock(TOPIC, "b", "s3", "Block", "S3", level="beginner", relevance="peripheral")
await db.put_subblock(TOPIC, "b", "s4", "Block", "S4", level="beginner", status="variant")
levels = await db.sub_levels_norm(TOPIC)
assert levels[("b", "s1")] == 1 and levels[("b", "s2")] == 3 and levels[("b", "s3")] == 4
assert ("b", "s4") not in levels # non-consensus ausgeschlossen
counts = await db.subs_per_level_norm(TOPIC)
assert counts["b"] == {1: 1, 2: 0, 3: 1, 4: 1}
# ── Deck-Bau ─────────────────────────────────────────────────────────────────────────
async def test_deck_level_gate_and_unlock(testdb):
from routes import build_practice_deck
db = testdb
# block_norm muss _norm_title(Roh-Titel) sein — so entsteht er auch in der Pipeline
await db.put_subblock(TOPIC, "block", "s1", "Block", "S1", level="beginner")
await db.put_subblock(TOPIC, "block", "s2", "Block", "S2", level="expert")
await _card(db, "block", "s1", "S1")
await _card(db, "block", "s2", "S2")
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s1"] # expert gesperrt
assert deck["counts"]["gesperrt"] == 1
# Score über S1+S2-Schwelle (2 Subs × 25 = 50) → expert (Level 3) frei
await db.set_block_score_and_streak(TOPIC, "Block", 50, 0)
deck = await build_practice_deck(TOPIC)
assert {c["sub_norm"] for c in deck["cards"]} == {"s1", "s2"}
async def test_deck_due_before_new_oldest_first(testdb):
from routes import build_practice_deck
db = testdb
for sn in ("s1", "s2", "s3"):
await db.put_subblock(TOPIC, "b", sn, "Block", sn.upper(), level="beginner")
await _card(db, "b", sn, sn.upper())
await db.upsert_practice_progress(TOPIC, "b", "s2", 2, _iso(-1))
await db.upsert_practice_progress(TOPIC, "b", "s3", 2, _iso(-5))
deck = await build_practice_deck(TOPIC)
assert [c["sub_norm"] for c in deck["cards"]] == ["s3", "s2", "s1"] # älteste fällige zuerst
assert [c["status"] for c in deck["cards"]] == ["due", "due", "new"]
assert deck["counts"] == {"due": 2, "new": 1, "new_total": 1, "gesperrt": 0}
async def test_deck_caps_new_and_reports_total(testdb):
from routes import build_practice_deck
db = testdb
for i in range(PRACTICE_NEW_PER_SESSION + 5):
sn = f"s{i:02d}"
await db.put_subblock(TOPIC, "b", sn, "Block", sn, level="beginner")
await _card(db, "b", sn, sn)
deck = await build_practice_deck(TOPIC)
assert deck["counts"]["new"] == PRACTICE_NEW_PER_SESSION
assert deck["counts"]["new_total"] == PRACTICE_NEW_PER_SESSION + 5
async def test_deck_future_due_sets_next_due_at(testdb):
from routes import build_practice_deck
db = testdb
await db.put_subblock(TOPIC, "b", "s1", "Block", "S1", level="beginner")
await _card(db, "b", "s1", "S1")
await db.upsert_practice_progress(TOPIC, "b", "s1", 3, _iso(3))
deck = await build_practice_deck(TOPIC)
assert deck["cards"] == [] and deck["counts"]["due"] == 0
assert deck["next_due_at"] is not None
async def test_deck_orphan_progress_and_legacy_block(testdb):
from routes import build_practice_deck
db = testdb
# Orphan: Progress ohne Karte → unschädlich, taucht nicht auf
await db.upsert_practice_progress(TOPIC, "weg", "s0", 2, _iso(-1))
# Legacy: Karte ohne subblocks-Zeilen → ungefiltert durchlassen
await _card(db, "leg", "sx", "SX")
deck = await build_practice_deck(TOPIC)
assert [c["block_norm"] for c in deck["cards"]] == ["leg"]
async def test_answer_books_without_card(testdb):
"""Antwort während Regeneration: bucht immer, kein Fehlerpfad."""
from models import PracticeAnswerRequest
from routes import practice_answer
db = testdb
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=True))
assert res["box"] == 2
res = await practice_answer(PracticeAnswerRequest(
topic=TOPIC, block_norm="b", sub_norm="s", correct=False))
assert res["box"] == 1
assert (await db.get_practice_progress(TOPIC))[0]["box"] == 1

347
backend/tests/test_qa.py Normal file
View File

@@ -0,0 +1,347 @@
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
import json
import qa
CORPUS = {"Skript.txt": (
"Kapitel 1: Vertex Cover — Definition, Approximation und Beweis der Guete.\n\n"
"Kapitel 2: Matching in Graphen — perfektes Matching und Augmentationswege.")}
BLOCKS = [
{"title": "Vertex Cover", "description": "Knotenüberdeckung", "sources": ["Skript.txt"]},
{"title": "Matching", "description": "Paarung in Graphen", "sources": ["Skript.txt"]},
]
SUBS = {"vertex cover": ["Approximation der Guete"], "matching": ["Matching in Graphen", "Augmentationswege"]}
def test_baseline_clean(monkeypatch):
"""Sauberes Soll-Inventar → alle Detektoren still (jeder Absatz ein Abschnitt)."""
monkeypatch.setattr(qa, "SECTION_CHARS", 20)
assert qa.dubletten(BLOCKS, emb_on=False) == []
assert qa.luecken(BLOCKS, SUBS, CORPUS) == []
assert qa.fremd(BLOCKS, CORPUS) == []
assert qa.hygiene(BLOCKS) == []
def test_injected_duplicate_found():
b = BLOCKS + [{"title": "Vertex-Cover-Problem", "description": "", "sources": []}]
pairs = qa.dubletten(b, emb_on=False)
assert any({p["a"], p["b"]} == {"Vertex Cover", "Vertex-Cover-Problem"} for p in pairs)
def test_acronym_signal():
b = BLOCKS + [{"title": "VC (Vertex Cover)", "description": "", "sources": []}]
pairs = qa.dubletten(b, emb_on=False)
hit = next(p for p in pairs if "VC (Vertex Cover)" in (p["a"], p["b"]) and "Vertex Cover" in (p["a"], p["b"]))
assert hit["signale"].get("akronym") is True
def test_relation_operand_not_suspicious():
"""Relation vs. Operand ist per Design getrennt — kein Verdachtspaar."""
b = BLOCKS + [{"title": "3-SAT ≤ Vertex Cover", "description": "", "sources": []}]
pairs = qa.dubletten(b, emb_on=False)
assert not any("" in p["a"] + p["b"] for p in pairs)
def test_removed_block_creates_gap(monkeypatch):
monkeypatch.setattr(qa, "SECTION_CHARS", 20)
only_vc = [BLOCKS[0]]
gaps = qa.luecken(only_vc, {"vertex cover": SUBS["vertex cover"]}, CORPUS)
assert len(gaps) == 1 and "Matching" in gaps[0]["vorschau"]
def test_foreign_block_flagged():
b = BLOCKS + [{"title": "Quantencomputer Grundlagen", "description": "", "sources": []}]
assert qa.fremd(b, CORPUS) == ["Quantencomputer Grundlagen"]
def test_beleg_flags_unbacked_sub():
rows = [{"block": "Matching", "sub_title": "Erfunden", "mentions": 0, "status": "consensus"},
{"block": "Matching", "sub_title": "Belegt", "mentions": 3, "status": "consensus"}]
r = qa.beleg([{"title": "Matching", "sources": []}], rows)
assert r["subs_ohne_beleg"] == ["Matching · Erfunden"]
assert r["bloecke_ohne_quelle"] == ["Matching"]
def test_hygiene_flags():
b = [{"title": "**Fett**", "description": "", "sources": []},
{"title": "Block (2)", "description": "ok", "sources": []}]
h = {x["titel"]: x["probleme"] for x in qa.hygiene(b)}
assert "markdown" in h["**Fett**"] and "leere-beschreibung" in h["**Fett**"]
assert h["Block (2)"] == ["kollisions-suffix"]
def test_sections_split_on_paragraphs():
secs = qa._sections("a\n\nb\n\nc", goal=3)
assert len(secs) >= 2 and "".join(secs).replace("\n", "") == "abc"
def test_note_deterministic_and_monotonic():
"""Saubere Quoten → 10; jede zusätzliche Quote drückt die Note."""
sauber = {k: 0 for k in qa.NOTE_GEWICHTE}
assert qa.note(sauber) == 10.0
schlechter = dict(sauber, luecken=0.05)
noch_schlechter = dict(schlechter, fremd=0.05)
assert 10.0 > qa.note(schlechter) > qa.note(noch_schlechter) >= 0.0
assert qa.note({k: 1 for k in qa.NOTE_GEWICHTE}) == 0.0
def test_note_kalibrierung():
"""Gewicht = Punktabzug bei 100 %: 5 % Fremd × 2.5 → 1.25 → 8.8 gerundet."""
assert qa.note({"fremd": 0.05}) == 8.8
assert qa.note({"fremd": 1.0}) == 0.0 # komplett fremdes Inventar = 0, nicht 7.7
def test_note_dubletten_verdacht_zaehlt():
"""dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0),
bleibt aber das leichteste Gewicht. 36 % Verdacht → 3.6 → 6.4; die Liste kann >1 sein → geklemmt."""
assert qa.note({"dubletten_verdacht": 0.0}) == 10.0
assert qa.note({"dubletten_verdacht": 0.36}) == 6.4
assert qa.note({"dubletten_verdacht": 2.0}) == 0.0 # >1 wird auf 1.0 geklemmt
# sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil)
assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0
def test_note_konzept_luecken_zaehlt():
"""Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 %3.0 → 7.0."""
assert qa.note({"konzept_luecken": 0.0}) == 10.0
assert qa.note({"konzept_luecken": 0.2}) == 7.0
def test_named_results_only_numbered(monkeypatch):
"""Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne
Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch)."""
corpus = {"skript.txt":
"Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n"
"Satz 6.24: Satz von Cook und Levin.\n"
"Nach Satz 7.18 folgt daraus die Schranke.\n"
"Ein Satz von Goethe steht hier."}
named = qa._named_results(corpus)
assert "Christofides" in named
assert any("Cook" in n and "Levin" in n for n in named)
assert not any("Goethe" in n for n in named) # keine Nummer → kein False Positive
assert all(n.strip() for n in named)
def test_konzept_luecken_flags_missing():
"""Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht."""
corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."}
named = qa._named_results(corpus)
blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}]
gaps = qa.konzept_luecken(blocks, named)
assert "Christofides" not in gaps
assert any("P=NP" in g or "Kriterium" in g for g in gaps)
def test_konzept_luecken_declension_tolerant():
"""Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm."""
named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."})
blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}]
assert qa.konzept_luecken(blocks, named) == []
def test_note_artefakte_getrennt():
"""Subs/Artefakte haben eigene Gewichte — zur Gate-Zeit existieren sie noch nicht
und dürfen die Inventar-Note weder schönen noch drücken."""
assert "subs_ohne_beleg" not in qa.NOTE_GEWICHTE
assert qa.note({"subs_ohne_beleg": 0.0, "verwaiste": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0
assert qa.note({"subs_ohne_beleg": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 0.0
def test_artefakte_coverage_and_orphans():
subs = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung", "status": "consensus"},
{"block_norm": "b", "sub_norm": "s2", "status": "consensus"},
{"block_norm": "b", "sub_norm": "alt", "status": "variant"},
{"block_norm": "b", "sub_norm": "weg", "status": "discarded"}]
arts = [{"block_norm": "b", "sub_norm": "s1", "type": "flashcard"}, # Präfix-Treffer
{"block_norm": "b", "sub_norm": "alt", "type": "flashcard"}, # variant → lebt, keine Waise
{"block_norm": "b", "sub_norm": "weg", "type": "flashcard"}, # verworfen → Waise
{"block_norm": "b", "sub_norm": "tot", "type": "flashcard"}] # fehlt → Waise
fragen = [{"block_norm": "b", "sub_norm": "s1: lange beschreibung"},
{"block_norm": "b", "sub_norm": "s2"}]
r = qa.artefakte(subs, arts, fragen)
assert r["frage_abdeckung"] == 1.0
assert r["flashcard_abdeckung"] == 0.5 # nur s1 der beiden consensus-Subs
assert r["verwaiste"] == ["flashcard: b · tot", "flashcard: b · weg"]
def test_artefakte_prefix_family_resolves_to_consensus():
"""Kurz-Key trifft consensus-Sub PLUS gefaltete Varianten mit gleichem Präfix —
das ist keine Waise, das Ziel ist der consensus-Sub."""
subs = [{"block_norm": "b", "sub_norm": "auto: echte fassung", "status": "consensus"},
{"block_norm": "b", "sub_norm": "auto: variante eins", "status": "variant"},
{"block_norm": "b", "sub_norm": "auto: variante zwei", "status": "variant"}]
arts = [{"block_norm": "b", "sub_norm": "auto", "type": "example"}]
r = qa.artefakte(subs, arts, [])
assert r["verwaiste"] == []
assert r["beispiel_abdeckung"] == 1.0
def test_artefakte_not_generated():
assert qa.artefakte([{"block_norm": "b", "sub_norm": "s", "status": "consensus"}], [], []) == {"status": "nicht generiert"}
def test_fremd_glued_prefix_not_whitewashed():
"""'αÜbergang' darf nicht über den Substring 'bergang''Übergang' als belegt gelten;
Symbol-Varianten (Δ/∆) bleiben über die ASCII-Form gedeckt."""
corpus = {"S.txt": "Der Übergang ist wichtig.\n\nDer ∆TSP1 Algorithmus folgt."}
b = [{"title": "αÜbergang", "description": "", "sources": []},
{"title": "ΔTSP1-Algorithmus", "description": "", "sources": []}]
assert qa.fremd(b, corpus) == ["αÜbergang"]
def test_note_ignores_unmeasured_quotes():
"""unechte_bloecke zählt nur, wenn gemessen (--llm) — sonst weder Schaden noch Schönung."""
ohne = {k: 0.02 for k in qa.NOTE_GEWICHTE if k != "unechte_bloecke"}
mit_null = dict(ohne, unechte_bloecke=0.0)
mit_schaden = dict(ohne, unechte_bloecke=0.5)
assert qa.note(mit_null) == qa.note(ohne)
assert qa.note(mit_schaden) < qa.note(ohne)
class _FakeEmb:
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
@staticmethod
def available():
return True
@staticmethod
def embed(texts):
import numpy as np
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
arr = np.zeros((len(texts), max(len(uniq), 1)))
for r, t in enumerate(texts):
arr[r, uniq[t]] = 1.0
return arr
def test_sub_dubletten_detector(monkeypatch):
"""Kandidaten in-block UND cross-block; nur consensus-Subs zählen."""
monkeypatch.setattr(qa, "embedding", _FakeEmb)
rows = [{"block": "Alpha", "block_norm": "alpha", "sub_title": "Gleiche Aussage", "status": "consensus"},
{"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "consensus"},
{"block": "Beta", "block_norm": "beta", "sub_title": "Andere Aussage", "status": "consensus"},
{"block": "Beta", "block_norm": "beta", "sub_title": "Gleiche Aussage", "status": "variant"}]
pairs = qa.sub_dubletten(rows)
assert len(pairs) == 1
assert pairs[0]["cross"] is True and pairs[0]["cos"] == 1.0
assert qa.sub_dubletten(rows, emb_on=False) == []
def test_note_sub_dubletten():
"""Bestätigte Sub-Dubletten drücken die Artefakt-Note; der bloße Verdacht nicht."""
assert qa.note({"sub_dubletten": 0.1}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 9.0
assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0
def test_zaehlbare_luecken():
"""Mit LLM zählen widerlegte Lücken nicht; unbeurteilte ('?'/ohne Key) konservativ schon."""
lk = [{"llm": "ja"}, {"llm": "nein"}, {"llm": "?"}, {}]
assert len(qa._zaehlbare_luecken(lk, llm=True)) == 3
assert len(qa._zaehlbare_luecken(lk, llm=False)) == 4
def test_description_anchors_cover(monkeypatch):
"""Am Gate existieren keine Subs — Beschreibungs-Tokens müssen Abschnitte decken."""
monkeypatch.setattr(qa, "SECTION_CHARS", 20)
corpus = {"S.txt": "Kapitel 9: Augmentationswege und perfektes Matching."}
block = [{"title": "Paarungen", "description": "perfektes Matching mit Augmentationswege", "sources": []}]
assert qa.luecken(block, {}, corpus) == []
ohne = [{"title": "Paarungen", "description": "", "sources": []}]
assert len(qa.luecken(ohne, {}, corpus)) == 1
def test_fremd_digit_suffix_tolerant():
"""'ΔTSP1' matcht Korpus-'∆TSP' (tokenisiert zu 'tsp') via Ziffern-Suffix-Fallback."""
corpus = {"S.txt": "Der ∆TSP Algorithmus verdoppelt Kanten im Graphen."}
b = [{"title": "ΔTSP1-Algorithmus", "description": "", "sources": []},
{"title": "Quantencomputer", "description": "", "sources": []}]
assert qa.fremd(b, corpus) == ["Quantencomputer"]
async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
"""Echtheits-Urteil zählt nur nach Bestätiger-Pass: der Einzel-Judge flaggte pro Lauf
andere Blöcke und pendelte die Note (aak: 9.3↔10.0 bei identischem Bestand)."""
db = testdb
for cid, titel in (("b1", "Wackelkandidat"), ("b2", "Zufallstreffer"), ("b3", "Solide")):
await db.kanban_upsert_card("t", "inventory", cid, "block", "done_block",
{"title": titel, "description": "d"})
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
async def fake_wave(template, topic, key, slot, items, **kw):
if template != "QA-Bausteine":
return {}
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten
assert len(items) == 2
return {1: "nein", 2: "ja"} # nur der erste wird bestätigt
return {1: "nein", 2: "nein", 3: "ja"} # Pass 1 flaggt zwei
monkeypatch.setattr(qa, "judge_wave", fake_wave)
report = await qa.qa_report("t", llm=True)
assert report["unecht"] == ["Wackelkandidat"]
async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch):
"""Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten
als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht."""
db = testdb
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0,
"quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}}
path = await qa.write_report(report)
assert path.stem == "20260704-1452-b223"
conn = await db.get_db()
row = await (await conn.execute(
"SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone()
assert row and row[0] == "20260704-1452-b223"
meta = json.loads(row[1])
assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0
assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0}
assert row[2] == "" # manuelle QA ohne Lauf → leeres run_id ist korrekt
async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
"""DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
import routes
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
qdir = tmp_path / "qa" / "t"
qdir.mkdir(parents=True)
(qdir / "alt.json").write_text("{}", encoding="utf-8")
await routes.remove_topic("t")
assert not qdir.exists()
def test_luecken_key_stabil_gegen_whitespace():
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
assert qa.luecken_key(a) == qa.luecken_key(b)
assert qa.luecken_key(a) != qa.luecken_key(c)
assert qa.luecken_key(a).startswith("f.txt||")
def test_offene_luecken_filtert_freispruch_und_nein():
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
{"datei": "f", "vorschau": "b", "llm": "nein"},
{"datei": "f", "vorschau": "c", "freispruch": True}],
"konzept_luecken": ["Satz von Foo"]}
lk, kl = qa.offene_luecken(rep)
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]
def test_named_results_ueberspringt_umbrochene_saetze():
"""Doppelpunkt-Fang, der am Zeilenumbruch endet und klein weiterläuft, ist ein
umbrochener SATZ, kein Konzeptname — solche Phantom-Lücken kann kein Block ankern.
Echte Namen (auch \\n-terminiert mit großer Folgezeile) bleiben."""
text = ("Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P\n"
"liegt, dann sind alle NP Entscheidungsprobleme in P.\n\n"
"Satz 7.13 (Christofides). Beweis folgt.\n"
"Satz 6.24: Cook-Levin.\n"
"Satz 9.1: Vier-Farben-Satz\nDer Beweis nutzt Computer.\n")
assert sorted(qa._named_results({"f": text})) == ["Christofides", "Cook-Levin", "Vier-Farben-Satz"]

103
backend/tests/test_race.py Normal file
View File

@@ -0,0 +1,103 @@
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
abzuwarten (gemessen: 4 Panel-Stalls à 160230 s pro Lauf auf dem kritischen Pfad)."""
import asyncio
import pipeline
def _slot(payload=lambda r: r[1]):
return {"key": "k1", "prompt": "p", "role": "judge", "capabilities": "none", "payload": payload}
async def test_hedge_zwilling_rettet_stall(monkeypatch):
"""Original stallt → nach HEDGE_NACH_S startet der Zwilling (key -h), sein Ergebnis
gewinnt, das hängende Original wird gekillt."""
calls, killed = [], []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
return (0, "zwilling", "")
await asyncio.sleep(30) # Stall — würde sonst den ganzen Cap verbrennen
return (0, "original", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"]
assert "k1" in killed # das hängende Original läuft nicht weiter
async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch):
"""Kommt das Original doch noch vor dem Zwilling an, wird der Zwilling gekillt
und sein spätes Ergebnis nicht gewertet."""
killed = []
async def fake_agent(key, prompt, timeout, **kw):
await asyncio.sleep(0.3 if key.endswith("-h") else 0.15)
return (0, key, "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["k1"]
assert "k1-h" in killed
async def test_hedge_aus_bei_null(monkeypatch):
"""HEDGE_NACH_S=0 → kein Zwilling, Verhalten wie zuvor."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.1)
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
"""Die Schwelle ist max(HEDGE_NACH_S, timeout/2): ein gesunder Call, der länger als
die Untergrenze, aber kürzer als das halbe Timeout läuft, bekommt KEINEN Zwilling
(pauschale 90 s hedgten jeden normalen Fix-Call)."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.15) # > Untergrenze 0.05, < timeout/2 = 0.5
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 1.0, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
zusätzlichen Restart — der Zwilling ist der Retry."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
await asyncio.sleep(0.2)
return (0, "zwilling", "")
await asyncio.sleep(0.1)
return (1, "", "kaputt") # Fehler NACH dem Hedge-Start
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"] # kein dritter Spawn

View File

@@ -0,0 +1,432 @@
"""Befund-Repair: gezielte Aktionen aus dem QA-Report (repair.py) — ohne Flow, gegen Test-DB."""
import json
import pytest
import repair
import qa as qa_mod
TOPIC = "reparatur"
def _report(**over):
r = {"topic": TOPIC, "note": 9.0, "quoten": {}, "hygiene": [], "dubletten": [],
"fremd": [], "unecht": [], "luecken": [], "artefakte": {"verwaiste": []}}
r.update(over)
return r
@pytest.fixture
async def env(testdb, tmp_path, monkeypatch):
"""Zwei fertige Blöcke auf beiden Boards + Subs/Artefakte + Sidecar-Dateien."""
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
files = {"sidecar": tmp_path / "sidecar.json", "facts": tmp_path / "facts.json",
"question_pattern": tmp_path / "qp.json", "sub_roh": tmp_path / "roh.json",
"artefakte": tmp_path / "artefakte.json"}
monkeypatch.setattr(repair, "_blocks_files", lambda t: files)
# frisches Abschluss-QA im Repair stumm schalten (eigener Test deckt qa_report ab)
async def _no_qa(topic, llm=False):
return None
monkeypatch.setattr(qa_mod, "qa_report", _no_qa)
async def _seed(title, desc, subs=1):
norm = repair._norm_title(title)
cid = "b-" + norm.replace(" ", "")[:10]
await db.kanban_upsert_card(TOPIC, "inventory", cid, "block", "done_block",
{"title": title, "description": desc, "sources": [f"{title}.txt"],
"readers": ["r1"], "mirrored_norm": norm})
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact", {"title": title})
await db.upsert_block(TOPIC, norm, title, desc, [f"{title}.txt"])
await db.set_block_status(TOPIC, norm, "consensus")
for i in range(subs):
await db.put_subblock(TOPIC, norm, f"sub{i}", title, f"Sub {i}")
await db.put_sub_artifact(TOPIC, norm, "sub0", "flashcard", "{}", title, "Sub 0")
return cid
for p in files.values():
p.write_text("{}", encoding="utf-8")
(tmp_path / "qa" / TOPIC).mkdir(parents=True)
def write_report(r):
(tmp_path / "qa" / TOPIC / "r.json").write_text(json.dumps(r), encoding="utf-8")
return db, _seed, files, write_report
async def test_merge_confirmed_duplicate(env, monkeypatch):
db, seed, files, write_report = env
cid_a = await seed("Alpha", "kurz")
cid_b = await seed("Alpha Problem", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Problem", "llm": "ja"},
{"a": "Alpha", "b": "Beta", "llm": "nein"}]))
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(prompt)
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Problem"]
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge
verlierer = await db.kanban_get_card(TOPIC, "inventory", cid_a)
assert verlierer["stage"] == "grouped" and verlierer["payload"]["merged_into"] == "Alpha Problem"
gewinner = await db.kanban_get_card(TOPIC, "inventory", cid_b)
assert "Alpha.txt" in gewinner["payload"]["sources"] # Union
assert await db.kanban_get_card(TOPIC, "artefacts", "alpha") is None
assert not [r for r in await db.list_subblocks(TOPIC, "alpha")]
async def test_fremd_removed_only_on_nein(env, monkeypatch):
db, seed, files, write_report = env
cid_f = await seed("Fremdling", "gehört nicht rein")
cid_e = await seed("Echter", "belegt")
write_report(_report(fremd=["Fremdling", "Echter"]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid über den strittigen „Echter": behalten
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
monkeypatch.setattr(repair, "source_folder", lambda t: None)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == ["Fremdling"]
weg = await db.kanban_get_card(TOPIC, "inventory", cid_f)
assert weg["stage"] == "rejected" and weg["payload"]["reason"] == "qa-fremd"
bleibt = await db.kanban_get_card(TOPIC, "inventory", cid_e)
assert bleibt["stage"] == "done_block"
async def test_judge_failure_keeps_everything(env, monkeypatch):
db, seed, files, write_report = env
cid = await seed("Wackelig", "unsicher")
write_report(_report(unecht=["Wackelig"]))
async def broken_agent(key, prompt, timeout, **kw):
raise RuntimeError("boom")
import agents; monkeypatch.setattr(agents, "run_agent", broken_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["stage"] == "done_block" # fail-open
async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch):
db, seed, files, write_report = env
cid = await seed("**Fetter Titel**", "beschreibung")
files["sidecar"].write_text(json.dumps({"**Fetter Titel**": ["s"]}), encoding="utf-8")
write_report(_report(hygiene=[{"titel": "**Fetter Titel**", "probleme": ["markdown"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Hygiene braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Fetter Titel"
assert json.loads(files["sidecar"].read_text()) == {"Fetter Titel": ["s"]}
rows = await db.list_blocks(TOPIC)
assert any(r["title"] == "Fetter Titel" and r["status"] == "consensus" for r in rows)
async def test_no_report_is_clean_error(env):
db, seed, files, write_report = env
res = await repair.repair_befunde("gibtsnicht")
assert "fehler" in res
async def test_abschluss_qa_misst_mit_llm(env, monkeypatch):
"""Repair-Abschlussreport misst mit LLM — der llm=False-Report blendete
sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln."""
db, seed, files, write_report = env
write_report(_report())
import qa as qa_mod
seen = {}
async def spy(topic, llm=False):
seen["llm"] = llm
return None
monkeypatch.setattr(qa_mod, "qa_report", spy)
await repair.repair_befunde(TOPIC)
assert seen["llm"] is True
async def test_sub_dubletten_merge(env, monkeypatch):
"""Bestätigtes Sub-Paar + Zweitmeinung ja → Verlierer variant, Frage/Artefakt
wandern zum Gewinner (bzw. fallen weg, wenn er den Typ schon hat)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "gewinner sub", "Alpha", "Gewinner Sub",
facts='{"key_points": ["a", "b"]}', status="consensus")
await db.put_subblock(TOPIC, norm, "verlierer sub", "Alpha", "Verlierer Sub",
facts='{"key_points": ["x"]}', status="consensus")
await db.put_sub_artifact(TOPIC, norm, "verlierer sub", "example", "{}", "Alpha", "Verlierer Sub")
await db.upsert_question_pattern(TOPIC, norm, "verlierer sub", "Alpha", "Verlierer Sub", "Frage V?")
write_report(_report(sub_dubletten=[
{"a": "[Alpha] Gewinner Sub", "b": "[Alpha] Verlierer Sub", "llm": "ja"},
{"a": "[Alpha] Gibtsnicht", "b": "[Alpha] Verlierer Sub", "llm": "ja"}])) # tote Zeile → skip
async def fake_agent(key, prompt, timeout, **kw):
assert "Gibtsnicht" not in prompt
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["verlierer sub"] == "variant" and rows["gewinner sub"] == "consensus"
arts = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert ("gewinner sub", "example") in arts and ("verlierer sub", "example") not in arts
fragen = {r["sub_norm"]: r["question"] for r in await db.list_question_pattern(TOPIC)}
assert fragen.get("gewinner sub") == "Frage V?" and "verlierer sub" not in fragen
async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == []
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch):
"""Dissens QA (Befund) vs. Zweitmeinung (behalten) → Stichentscheid-Judge (Key -st)
entscheidet mit 2:1 für den Befund → Merge. Vorher pendelte die Note dauerhaft
unter 10 ohne Fix-Pfad („keine behebbaren Befunde" trotz Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A",
facts='{"key_points": ["a"]}', status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid bestätigt den QA-Befund
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Sub B → Sub A"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub b"] == "variant" and rows["sub a"] == "consensus"
async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch):
"""2:1 „behalten" (Zweitmeinung + j3 einig gegen den QA-Befund) → Freispruch wird
persistiert und der Report des nächsten qa_report zählt das Paar nicht mehr —
vorher pendelte die Note dauerhaft unter 10 ohne Fix-Pfad."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
frei = qa_mod.lade_freispruch(TOPIC)
key = qa_mod._paar_key("[Alpha] Sub A", "[Alpha] Sub B")
assert key in set(frei.get("sub_dubletten") or [])
# QA-Seite: bestätigtes, aber freigesprochenes Paar zählt nicht in die Quote
sd = [{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]
frei_sub = set(frei["sub_dubletten"])
zaehlt = sum(1 for p in sd if p.get("llm") == "ja"
and qa_mod._paar_key(p["a"], p["b"]) not in frei_sub)
assert zaehlt == 0
async def test_fremd_stichentscheid_behalten(env, monkeypatch):
"""Dissens bei fremd, Stichentscheid sagt ebenfalls behalten (ja) → Block bleibt
(fail-open bei 1:2 gegen den Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
write_report(_report(fremd=["Alpha"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
async def test_waisen_cleanup(env, monkeypatch):
"""Artefakte/Fragen auf verworfene oder fehlende Subs fliegen; lebende und
mehrdeutig-präfixige bleiben."""
db, seed, files, write_report = env
await seed("Alpha", "beschreibung") # legt sub0 (consensus) + flashcard auf sub0 an
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "weg", "Alpha", "Weg", status="discarded")
await db.put_subblock(TOPIC, norm, "doppel: eins", "Alpha", "Doppel eins")
await db.put_subblock(TOPIC, norm, "doppel: zwei", "Alpha", "Doppel zwei")
await db.put_sub_artifact(TOPIC, norm, "weg", "flashcard", "{}", "Alpha", "Weg") # tot
await db.put_sub_artifact(TOPIC, norm, "fehlt", "example", "{}", "Alpha", "Fehlt") # tot
await db.put_sub_artifact(TOPIC, norm, "doppel", "example", "{}", "Alpha", "Doppel") # mehrdeutig → bleibt
await db.upsert_question_pattern(TOPIC, norm, "fehlt", "Alpha", "Fehlt", "Frage?") # tot
write_report(_report())
async def no_agent(*a, **kw):
raise AssertionError("Aufräumen braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["aufgeraeumt"] == 3
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
assert not [r for r in await db.list_question_pattern(TOPIC)]
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
await seed("Alpha Kreis", "beschr")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
Merge mit 2:1 gegen das QA-Urteil."""
db, seed, files, write_report = env
await seed("Alpha", "kurz")
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
if "-beleg-st-" in key:
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "artefacts")
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
assert rows["ohne beleg"]["status"] == "discarded"
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
Auszügen; Titel bleibt unverändert."""
db, seed, files, write_report = env
cid = await seed("Leerling", "")
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
assert "-sanierung-" in key
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "beschrieben: Leerling" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["description"] == "Aus dem Material."
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
from invarianten import pruefe_invarianten
db, seed, files, write_report = env
cid = await seed("Gamma (2)", "beschr")
norm_alt = repair._norm_title("Gamma (2)")
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
assert not await db.list_subblocks(TOPIC, norm_alt)
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
assert not [b for b in befunde if "Waise" in b]
async def test_pruefe_luecken_freispruch(env, monkeypatch):
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert len(res["freigesprochen"]) == 2
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
lk[0]["freispruch"] = True
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []

View File

@@ -0,0 +1,41 @@
"""Role routing: resolve_role maps (run_provider, role) → (provider, model) across stacks."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import config
from config import PROVIDERS, resolve_role
def test_default_quick_routes_to_minimax(monkeypatch):
monkeypatch.setitem(config.ROLE_ROUTING, "quick", "minimax")
assert resolve_role("claude", "quick") == ("minimax", PROVIDERS["minimax"]["quick"])
def test_default_judge_routes_to_claude(monkeypatch):
monkeypatch.setitem(config.ROLE_ROUTING, "judge", "claude")
assert resolve_role("minimax", "judge") == ("claude", PROVIDERS["claude"]["judge"])
def test_empty_routing_keeps_run_provider(monkeypatch):
monkeypatch.setitem(config.ROLE_ROUTING, "fast", "")
assert resolve_role("claude", "fast") == ("claude", PROVIDERS["claude"]["fast"])
assert resolve_role("minimax", "fast") == ("minimax", PROVIDERS["minimax"]["fast"])
def test_explicit_model_syntax(monkeypatch):
monkeypatch.setitem(config.ROLE_ROUTING, "guide", "claude:claude-opus-4-8")
assert resolve_role("minimax", "guide") == ("claude", "claude-opus-4-8")
def test_unknown_target_falls_back_to_run_provider(monkeypatch):
monkeypatch.setitem(config.ROLE_ROUTING, "quick", "gibtsnicht")
assert resolve_role("claude", "quick") == ("claude", PROVIDERS["claude"]["quick"])
def test_unknown_role_yields_empty_model():
provider, model = resolve_role("claude", "nope")
assert provider == "claude"
assert model == ""

View File

@@ -0,0 +1,55 @@
"""Link source = curated URL list (one page per line, no crawl following)."""
from pathlib import Path
import pytest
from fastapi import HTTPException
import blocks
from pipeline import GenContext
from routes import _validate_source
# --- _validate_source: per-line validation ---------------------------------
def test_validate_source_link_multiline_ok():
_validate_source("link", "https://a.com/x\nhttp://b.com/y") # no raise
def test_validate_source_link_rejects_bad_line():
with pytest.raises(HTTPException):
_validate_source("link", "https://a.com/x\nftp://bad")
def test_validate_source_link_rejects_empty():
with pytest.raises(HTTPException):
_validate_source("link", " \n ")
# --- _prepare_source: load every line, all pages as content, no triage -----
async def test_prepare_source_link_loads_all_urls_as_content(testdb, tmp_path, monkeypatch):
topic = "T"
folder = tmp_path / "source"
folder.mkdir()
captured = {}
def fake_load(urls, target, *, cancelled=None):
captured["urls"] = list(urls)
for i, u in enumerate(urls):
(Path(target) / f"p{i}.txt").write_text(f"QUELLE: {u}\n\nInhalt {i}", encoding="utf-8")
return len(urls)
monkeypatch.setattr(blocks, "load_pages", fake_load)
monkeypatch.setattr(blocks, "_convert_pdfs", lambda f: None)
monkeypatch.setattr(blocks, "_crawl_done", lambda t: False)
monkeypatch.setattr(blocks, "_step_idx", lambda t, n: 0) # step list needs source.json — irrelevant here
ctx = GenContext(topic=topic, provider="claude", is_cancelled=lambda: False)
q = {"type": "link", "location": " https://a.com/x \n\nhttps://b.com/y\n", "spec": ""}
ok = await blocks._prepare_source(ctx, lambda *a, **k: None, {"arbeit": tmp_path}, q, folder, "")
assert ok is True
assert captured["urls"] == ["https://a.com/x", "https://b.com/y"] # split + strip + blanks dropped
content = await testdb.list_content(topic)
assert len(content) == 2 # every page kept, no relevance triage

View File

@@ -0,0 +1,192 @@
"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
import json
import numpy as np
import blocks as blx
from pipeline import GenContext
TOPIC = "t"
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
def _sims(pairs, n):
m = np.eye(n)
for i, j, v in pairs:
m[i][j] = m[j][i] = v
return m
def test_variant_clusters_folds_paraphrases():
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
"Tabs werden expandiert"]
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
by_rep = {c["rep"]: c for c in cl}
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
assert by_rep[2]["mentions"] == 1
def test_variant_clusters_negation_guard():
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
assert len(cl) == 2 # antonyms never merge, no matter the cosine
# ── Outline-Review ───────────────────────────────────────────────────────────────────
def test_outline_review_schema():
valid = {1, 2, 3, 4, 5, 6}
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
assert ok == {3: 2}
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
entries = {i: f"Block {i} — d" for i in range(1, 7)}
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
for p in slots[:2]:
p.write_text(json.dumps(plan_a), encoding="utf-8")
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
"facts": tmp_path / "facts.json"}
review_out = {"val": {"moves": {"6": 2}}}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
out = None
if key.endswith("outline-prereqs"):
out = {"prereqs": {}}
elif key.endswith("outline-judge"):
out = plan_a
elif key.endswith("outline-review"):
out = review_out["val"]
if out is not None: # neue Semantik: Antwort als TEXT, der Sink persistiert
return "ok", payload((0, json.dumps(out), ""))
return "ok", payload(None)
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
assert plan["chapters"][0]["numbers"] == [1, 2]
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
# broken review (mass move) → schema rejects, plan unchanged
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
(tmp_path / "outline-review.json").unlink()
(tmp_path / "outline.json").unlink()
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
def _corpus(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "Skript.txt").write_text(
"Kapitel 1\nAlpha Grundlagen: der Kernbegriff.\nMehr Text dazu.\n\n"
"Kapitel 2\nGamma Randnotiz ohne Bezug.\n", encoding="utf-8")
(d / "Aufgaben.txt").write_text("Übung 1\nAlpha Vertiefung der Konzepte.\n", encoding="utf-8")
return d
def test_evidence_pack_selects_matching_sections(tmp_path):
d = _corpus(tmp_path)
pack = blx._evidence_pack(d, None, ["Alpha Grundlagen"])
assert "── Skript.txt" in pack and "Kernbegriff" in pack
pack2 = blx._evidence_pack(d, ["Aufgaben.txt"], ["Alpha"]) # genannte Quellen engen ein
assert "Skript.txt" not in pack2 and "Aufgaben.txt" in pack2
assert blx._evidence_pack(None, None, ["x"]) == "" # kein Korpus → Selbst-Recherche bleibt
def test_evidence_pack_budget_and_guarantee(tmp_path):
d = tmp_path / "korpus"
d.mkdir()
(d / "A.txt").write_text("Alpha wichtig. " * 50, encoding="utf-8")
(d / "B.txt").write_text("Beta anderes Thema. " * 50, encoding="utf-8")
pack = blx._evidence_pack(d, None, ["Alpha"], budget=10)
assert "Alpha" in pack # Abdeckungs-Garantie schlägt das Budget
assert "Beta" not in pack # Top-up respektiert das Budget
def test_cite_ref_parses_positions(tmp_path):
d = _corpus(tmp_path)
files = blx._corpus_files(d, None)
f, lo, hi = blx._cite_ref("Skript.txt, Übung 6.47, Z.2-3", files)
assert f.name == "Skript.txt" and (lo, hi) == (2, 3)
f2, lo2, hi2 = blx._cite_ref("Aufgaben.txt Zeile 2", files)
assert f2.name == "Aufgaben.txt" and lo2 == hi2 == 2
assert blx._cite_ref("Skript.txt, Übung 6.47", files) is None # keine Zeilenangabe
assert blx._cite_ref("Z.5 irgendwo", files) is None # keine Datei
# englische Zitierformen (Quellen sind nicht immer deutsch)
f3, lo3, hi3 = blx._cite_ref("Skript.txt, line 2", files)
assert f3.name == "Skript.txt" and lo3 == hi3 == 2
f4, lo4, hi4 = blx._cite_ref("Aufgaben.txt, lines 1-2", files)
assert f4.name == "Aufgaben.txt" and (lo4, hi4) == (1, 2)
def test_cited_evidence_lines_and_fallback(tmp_path):
d = _corpus(tmp_path)
ev = blx._cited_evidence(d, None, ["Skript.txt, Z.2"], ["Alpha"])
assert "── Skript.txt · Z." in ev and "Kernbegriff" in ev
ev2 = blx._cited_evidence(d, None, ["ohne Position"], ["Alpha Grundlagen"])
assert "Kernbegriff" in ev2 # Keyword-Fallback
def test_sink_json_writes_only_valid(tmp_path):
p = tmp_path / "level-final-c1.json"
from pipeline import _enum_map_schema
levels = _enum_map_schema("levels", ("beginner", "advanced", "expert"))
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: levels(d, {1}))
assert ok == {1: "beginner"}
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
assert bad is None and not (tmp_path / "x.json").exists()
def test_material_folder_fallbacks(monkeypatch, tmp_path):
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
assert blx.material_folder("t") == tmp_path / "quelle"
monkeypatch.setattr(blx, "source_folder", lambda t: None)
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
assert blx.material_folder("t") is None # kein Material-Ordner
md = tmp_path / "arbeit" / "material"
md.mkdir(parents=True)
assert blx.material_folder("t") is None # leer zählt nicht
(md / "research-1.txt").write_text("x", encoding="utf-8")
assert blx.material_folder("t") == md
def test_sub_key_resolves_short_titles():
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""
import board_artefacts as ba
existing = {"autolink mit url: erzeugt link", "bilder: bindet bilder ein",
"doppel: eins", "doppel: zwei", "exakt"}
assert ba._sub_key(existing, "exakt") == "exakt"
assert ba._sub_key(existing, "autolink mit url") == "autolink mit url: erzeugt link"
assert ba._sub_key(existing, "doppel") == "doppel" # mehrdeutig → unverändert
assert ba._sub_key(existing, "fehlt") == "fehlt" # kein Treffer → unverändert
# Fuzzy: Paraphrase/Kürzung ohne Doppelpunkt-Präfix löst eindeutig auf
lang = {"der backslash selbst muss mit escaped werden, um literal zu erscheinen"}
assert ba._sub_key(lang, "der backslash selbst muss mit escaped werden") == next(iter(lang))
assert ba._sub_key(lang | {"der backslash am zeilenende"}, "der backslash") == "der backslash" # mehrdeutig
def test_subs_hash_invalidiert_bei_neuem_zuschnitt():
"""Gleicher Sub-Satz → gleicher Hash (Resume greift); geänderter → neuer Hash.
raw-Form (Strings) und sidecar-Form (dicts) hashen identisch."""
a = {"Block": ["s1", "s2"]}
assert blx._subs_hash(a) == blx._subs_hash({"Block": ["s1", "s2"]})
assert blx._subs_hash(a) != blx._subs_hash({"Block": ["s1", "s3"]})
assert blx._subs_hash(a) == blx._subs_hash({"Block": [{"title": "s1"}, {"title": "s2"}]})

View File

@@ -0,0 +1,57 @@
"""Titel-Hygiene: klammer-bewusster Split + Dash-Normalisierung (M1.1).
Reale aak-Schadensfälle: ein en-Dash mit einseitigem Space INNERHALB eines Titels
wurde zum Separator → Split mitten im Titel („Aε-Algorithmus (Güte 1+ε, Laufzeit O(n3").
"""
from textkit import _norm_dash, _title, _split_top, _unclosed, _parse_selection
def test_norm_dash_top_level_only():
# einseitiger Space am Dash → Separator (Modelle liefern das so)
assert _norm_dash("Titel Beschreibung") == "Titel — Beschreibung"
assert _norm_dash("Titel— Beschreibung") == "Titel — Beschreibung"
# Dash INNERHALB offener Klammer bleibt unangetastet (kein Separator)
assert _norm_dash("f (a b) — Rest") == "f (a b) — Rest"
# ASCII-Hyphen und geklebte/rangebasierte Dashes sind nie Separatoren
assert _norm_dash("3-SAT") == "3-SAT"
assert _norm_dash("1215") == "1215"
def test_split_top_bracket_aware():
assert _split_top("a (b — c) — d", "") == "a (b — c)"
assert _split_top("a — b", "") == "a"
assert _split_top("kein Separator hier", "") == "kein Separator hier"
def test_unclosed_bracket_detection():
assert _unclosed("a (b") is True
assert _unclosed("a [b {c") is True
assert _unclosed("a (b) c") is False
assert _unclosed("a) b") is False # überzähliger Schließer ist kein Abschnitt
def test_title_keeps_bracketed_dash():
"""Der Separator innerhalb der Klammer zerschneidet den Titel nicht mehr."""
t = _title("Aε-Algorithmus (Güte 1+ε Laufzeit O(n³)) — der Approx-Algorithmus")
assert t == "Aε-Algorithmus (Güte 1+ε Laufzeit O(n³))"
def test_title_plain_split_unchanged():
assert _title("Vertex Cover — minimale Knotenüberdeckung") == "Vertex Cover"
assert _title("Ohne Separator bleibt ganz") == "Ohne Separator bleibt ganz"
def test_title_unclosed_bracket_keeps_entry():
"""Malformed: offene Klammer nie geschlossen → keinen abgeschnittenen Titel ausgeben,
stattdessen den ganzen Eintrag behalten (Repair = Split rückgängig)."""
e = "N P via Turingmaschine (Definition 6.19 — Rest"
assert _title(e) == e
def test_parse_selection_then_title_regression():
"""End-to-end der Parse-Kette (wie in der Pipeline): _norm_dash + _title lassen den
Klammer-Inhalt intakt statt mitten im Titel zu splitten (der reale aak-Bug)."""
line = "1. Aε-Algorithmus (Güte 1+ε Laufzeit O(n³)) — der Approximations-Algorithmus"
entries = _parse_selection(line)
assert _title(entries[1]) == "Aε-Algorithmus (Güte 1+ε Laufzeit O(n³))"

162
backend/tests/test_train.py Normal file
View File

@@ -0,0 +1,162 @@
"""Training-Harness: Registry↔config, ENV-Override, ACO-Trainer (Stub-Runner), Soll-Abgleich."""
import json
import subprocess
import sys
from pathlib import Path
import config
import train_params
from train import AmeisenTrainer, score
from train_lauf import soll_abgleich
BACKEND = Path(__file__).resolve().parent.parent
def test_registry_spiegelt_config():
"""Jeder Registry-Parameter existiert in config mit identischem Default, flow-sicheren
Rändern und einer Fidelity-Zuordnung — sonst optimiert der Trainer Phantome."""
for name, p in train_params.PARAMS.items():
assert getattr(config, name, None) == p["default"], name
assert p["min"] <= p["default"] <= p["max"], name
assert p["step"] > 0, name
assert p["fidelity"] in ("board2", "voll"), name
def test_creator_params_override_wirkt_im_subprozess():
out = subprocess.run(
[sys.executable, "-c", "import config; print(config.GATE_FIX_MIN, config.TIMEOUTS['subblock_check'][0])"],
capture_output=True, text=True, cwd=BACKEND,
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GATE_FIX_MIN": 6, "TIMEOUT_subblock_check_base": 77}'})
assert out.stdout.split() == ["6", "77"], out.stderr
def test_creator_params_unbekannter_name_bricht_ab():
out = subprocess.run([sys.executable, "-c", "import config"],
capture_output=True, text=True, cwd=BACKEND,
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GIBT_ES_NICHT": 1}'})
assert out.returncode != 0 and "GIBT_ES_NICHT" in out.stderr
def _metrics(note=8.0, dauer=10.0, tokens=1_000_000, **quoten):
return {"note": note, "quoten": quoten, "quoten_artefakte": {},
"dauer_min": dauer, "tokens": {"input": tokens, "output": 0}, "agents": {}}
def _stub(score_fn):
"""Runner-Paar (F1/F2 + F0) für Tests: score_fn(params) → Metriken."""
calls = []
async def runner(params, fidelity, suffix=""):
calls.append((dict(params), fidelity))
return score_fn(params)
async def f0(params):
return {"ok": True, "invarianten_fehler": [], "calls": 100}
runner.calls = calls
return runner, f0
def _trainer(tmp_path, runner, f0, **kw):
args = dict(max_trials=999, max_stunden=1, ameisen=3, seed=7, f2_intervall=1000)
args.update(kw)
return AmeisenTrainer(tmp_path / "s", runner=runner, runner_f0=f0, **args)
async def test_aco_konvergiert_auf_optimum(tmp_path):
"""Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen;
die Pheromon-Spur konzentriert sich dort."""
def bewertung(params):
return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=120)
best = await t.run()
assert best.get("GEN_PANEL") == 3
taus = t.pheromon["GEN_PANEL"]
assert max(taus, key=lambda k: taus[k]) == "3"
async def test_uebernahme_braucht_bestaetigung(tmp_path):
"""Einmaliger Glückstreffer ohne bestätigten Zweitlauf wird nicht Bester."""
zustand = {"mal": 0}
def bewertung(params):
if params.get("GEN_PANEL") == 3:
zustand["mal"] += 1
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
return _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=40)
best = await t.run()
assert best.get("GEN_PANEL") != 3
async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
"""Kandidaten mit Invarianten-Fehlern erreichen nie einen bezahlten Lauf."""
runner, _f0 = _stub(lambda p: _metrics())
async def f0_kaputt(params):
if params: # nur Nicht-Baseline
return {"ok": True, "invarianten_fehler": ["kaputt"], "calls": 100}
return {"ok": True, "invarianten_fehler": [], "calls": 100}
t = _trainer(tmp_path, runner, f0_kaputt, max_trials=20)
await t.run()
bezahlt_mit_params = [c for c, _f in runner.calls if c]
assert bezahlt_mit_params == [] # nur Baselines liefen
async def test_resume_laedt_pheromon_und_cache(tmp_path):
def bewertung(params):
return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=60)
await t.run()
best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"])
runner2, f02 = _stub(bewertung)
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
assert t2.best_params == best
assert t2.pheromon["GEN_PANEL"] == tau
async def test_budget_stoppt(tmp_path):
runner, f0 = _stub(lambda p: _metrics())
t = _trainer(tmp_path, runner, f0, max_trials=4)
await t.run()
assert len(runner.calls) <= 4
def test_score_richtungen():
basis = _metrics()
assert score(_metrics(note=9.0), basis) > score(basis, basis)
assert score(_metrics(dauer=20.0, tokens=2_000_000), basis) < score(basis, basis)
assert score(_metrics(fremd=0.2, luecken=0.1), basis) < score(basis, basis)
mit_soll = dict(_metrics(), soll={"f1": 1.0})
ohne_soll = dict(_metrics(), soll={"f1": 0.5})
assert score(mit_soll, basis) > score(ohne_soll, basis)
def test_soll_abgleich():
soll = {"bloecke": [{"titel": "Symmetrische Verschlüsselung"},
{"titel": "Asymmetrische Verschlüsselung",
"alternativen": ["Public-Key-Kryptographie"]},
{"titel": "Digitale Signaturen"}]}
r = soll_abgleich(["Symmetrische Verschlüsselung", "Public-Key-Kryptographie", "Quantencomputer"], soll)
assert r["fehlend"] == ["Digitale Signaturen"]
assert r["extra"] == ["Quantencomputer"]
assert 0 < r["f1"] < 1
async def test_copy_topic_dupliziert_karten_und_bloecke(testdb):
db = testdb
await db.kanban_upsert_card("q", "inventory", "b1", "block", "done_block", {"title": "Alpha"})
await db.upsert_block("q", "alpha", "Alpha", "Beschreibung", ["s1"], "r1")
await db.copy_topic("q", "z")
karten = await db.kanban_cards("z")
assert [c["card_id"] for c in karten] == ["b1"]
bloecke = await db.list_blocks("z")
assert [b["title"] for b in bloecke] == ["Alpha"]

View File

@@ -1,19 +1,29 @@
"""Reine Text-Helfer: Titel-Normalisierung, Listen-Parser, Chunk-Aufteilung.
"""Pure text helpers: title normalization, list parsers, chunk splitting.
Kein Zustand, keine IO — überall gefahrlos importierbar.
No state, no IO — safe to import anywhere.
"""
import json
import re
import unicodedata
_CATEGORIES = ("KERN", "WICHTIG", "REST") # nur noch für den Altformat-Reader
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
def _norm_titel(s: str) -> str:
"""Normalisiert einen Titel für den Schlüssel-Vergleich.
def parse_facts(raw) -> dict:
"""subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}."""
try:
d = json.loads(raw) if raw else {}
except (ValueError, TypeError):
return {}
return d if isinstance(d, dict) else {}
NFKC + casefold fangen Unicode-Varianten; Anführungszeichen, Markdown-
Emphasis und Dash-Varianten kommen aus KI-Output in allen Spielarten.
def _norm_title(s: str) -> str:
"""Normalize a title for key comparison.
NFKC + casefold catch Unicode variants; quotes, markdown emphasis
and dash variants come out of AI output in every shape.
"""
s = unicodedata.normalize("NFKC", s)
s = re.sub(r"[`'\"<>„“”‚’«»*_]", "", s)
@@ -22,53 +32,120 @@ def _norm_titel(s: str) -> str:
return s.casefold()
def _titel(entry: str) -> str:
return entry.split("")[0].strip() or entry
# Bracket-aware separator handling: a ' — ' (or a dash that _norm_dash would normalize) INSIDE
# ()[]{} is part of the title, not a title/description separator. Without this a spaced dash within
# a math title truncates it: 'Aε-Algorithmus (Güte 1+ε, Laufzeit O(n³)) — …' → 'Aε-Algorithmus (Güte…'.
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
_CLOSERS = {v: k for k, v in _BRACKETS.items()}
def _eindeutige_titel(entries: dict[int, str]) -> dict[int, str]:
"""Macht Titel eindeutig (Suffix " (2)", " (3)" …), damit sie als Schlüssel taugen."""
def _unclosed(s: str) -> bool:
"""True if s has a dangling opening bracket ()[]{} — the symptom of a mid-bracket cut. Extra
closers (more ')' than '(') do NOT count; only an unclosed opener signals a truncated title."""
depth = 0
for c in s:
if c in _BRACKETS:
depth += 1
elif c in _CLOSERS and depth > 0:
depth -= 1
return depth > 0
def _split_top(s: str, sep: str) -> str:
"""First bracket-depth-0 segment of s split on sep; whole s if sep never occurs at depth 0."""
depth = 0
for i, c in enumerate(s):
if c in _BRACKETS:
depth += 1
elif c in _CLOSERS and depth > 0:
depth -= 1
elif depth == 0 and s.startswith(sep, i):
return s[:i]
return s
def _title(entry: str) -> str:
"""Title = text before the first TOP-LEVEL ''. A separator inside ()[]{} does not split
(keeps math titles intact); if the extracted title has an unclosed bracket (malformed source),
keep the whole entry rather than emit a truncated title."""
title = _split_top(entry, "")
if _unclosed(title):
return entry.strip() or entry
return title.strip() or entry
def clean_title(s: str) -> str:
"""Strip markdown noise from a DISPLAY title (norm keys use _norm_title).
Only clearly-markdown characters go: `**` pairs and backticks. Single `*`,
underscores and pipes stay — they are legitimate in math titles
(„2|prec, pi∈{1,2}|Cmax", „x_i", „P*")."""
s = (s or "").replace("**", "").replace("`", "")
return re.sub(r"\s+", " ", s).strip()
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
seen: dict[str, int] = {}
out: dict[int, str] = {}
for num, text in entries.items():
titel = _titel(text)
key = _norm_titel(titel)
title = _title(text)
key = _norm_title(title)
seen[key] = seen.get(key, 0) + 1
if seen[key] > 1:
rest = text.split("", 1)
text = f"{titel} ({seen[key]})" + (f"{rest[1]}" if len(rest) == 2 else "")
# zweiter Durchlauf nicht nötig: Suffixe kollidieren praktisch nicht
text = f"{title} ({seen[key]})" + (f"{rest[1]}" if len(rest) == 2 else "")
# a second pass isn't needed: suffixes practically never collide
out[num] = text
return out
def _titel_index(entries: dict[int, str]) -> dict[str, int]:
return {_norm_titel(_titel(text)): num for num, text in entries.items()}
def _titel_aufloesen(idx: dict[str, int], t: str) -> int | None:
"""Titel → Nummer; toleriert mitgeschleppte Beschreibungen ("Titel — …")."""
def _title_index(entries: dict[int, str]) -> dict[str, int]:
return {_norm_title(_title(text)): num for num, text in entries.items()}
def _resolve_title(idx: dict[str, int], t: str) -> int | None:
"""Title → number; tolerates trailing descriptions ("Title — …")."""
if not isinstance(t, str):
return None
return idx.get(_norm_titel(t)) or idx.get(_norm_titel(_titel(t)))
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
def _parse_auswahl(text: str) -> dict[int, str]:
"""Parst eine Baustein-Liste: `N. Titel — Kurzbeschreibung` pro Zeile."""
_DASH_SEP_RE = re.compile(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*")
def _norm_dash(s: str) -> str:
"""Dash variants (en/em/figure/bar) with whitespace on AT LEAST ONE side → uniform separator ''.
Some models (especially non-western ones) use an en-dash "" instead of the em-dash; without
normalization the ` — ` split fails entirely and the whole entry becomes the title. A one-sided
space ("Titel —Beschreibung" / "Titel— Beschreibung") also breaks the split and leaks the source
filename into the description — so a dash with a space on either side is repaired too. The ASCII
hyphen "-" is deliberately NOT in the class (would split "n - 1"/"3-SAT"); requiring ≥1 surrounding
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "1215" untouched.
A qualifying dash INSIDE an open bracket ()[]{} is left untouched — it is part of the title, not a
separator (else 'Algorithmus (Güte 1+ε O(n³)) — …' would split mid-parenthetical)."""
def _repl(m):
return m.group(0) if _unclosed(s[:m.start()]) else ""
return _DASH_SEP_RE.sub(_repl, s)
def _parse_selection(text: str) -> dict[int, str]:
"""Parse a block list: `N. Title — short description` per line."""
entries: dict[int, str] = {}
last = None
for line in text.splitlines():
m = re.match(r"\s*(\d+)[.)]\s+(.*\S)", line)
if m:
last = int(m.group(1))
entries[last] = m.group(2)
entries[last] = _norm_dash(m.group(2))
elif last is not None and line.strip():
entries[last] += " " + line.strip()
entries[last] += " " + _norm_dash(line.strip())
return entries
def _parse_kategorien(text: str) -> dict[str, list[str]]:
"""Altformat-Reader: finale Baustein-Datei mit ## KERN/WICHTIG/REST-Abschnitten."""
def _parse_categories(text: str) -> dict[str, list[str]]:
"""Legacy-format reader: final block file with ## KERN/WICHTIG/REST sections."""
cats: dict[str, list[str]] = {}
current = None
for line in text.splitlines():
@@ -84,45 +161,128 @@ def _parse_kategorien(text: str) -> dict[str, list[str]]:
return cats
def _lade_bausteine(text: str) -> dict[int, str]:
"""Lädt die finale Baustein-Datei — sortierte Liste (neu) oder Kategorien (Altformat)."""
def _load_blocks(text: str) -> dict[int, str]:
"""Load the final block file — sorted list (new) or categories (legacy format)."""
if re.search(r"^#+\s*KERN\b", text, re.IGNORECASE | re.MULTILINE):
cats = _parse_kategorien(text)
cats = _parse_categories(text)
texts = [t for cat in _CATEGORIES for t in cats.get(cat, [])]
return {i: t for i, t in enumerate(texts, 1)}
return _parse_auswahl(text)
return _parse_selection(text)
_FRAGMENT_KAPITEL_RE = re.compile(r"<!--\s*kapitel\s*:\s*(.*?)\s*-->", re.IGNORECASE)
_FRAGMENT_SECTION_RE = re.compile(r"<!--\s*section\s*:\s*(.*?)\s*-->", re.IGNORECASE)
_FRAGMENT_SUB_RE = re.compile(r"<!--\s*sub\s*:\s*(.*?)\s*-->", re.IGNORECASE)
_FRAGMENT_BAUSTEIN_RE = re.compile(r"<!--\s*block\s*:\s*(.*?)\s*-->", re.IGNORECASE)
# Two reading layers per section: compact (key sentences) + detailed (explanation).
_FRAGMENT_KOMPAKT_RE = re.compile(r"<!--\s*compact\s*-->", re.IGNORECASE)
_FRAGMENT_AUSF_RE = re.compile(r"<!--\s*ausf(?:ü|ue)hrlich\s*-->", re.IGNORECASE)
# Learning-path levels + peripheral; old difficulty values accepted for backward compatibility.
_STUFEN = ("beginner", "advanced", "expert", "peripheral", "easy", "medium", "hard")
def _parse_fragment(text: str) -> list[dict]:
"""Parst eine Writer-Datei → [{"kapitel", "titel", "md"}] in Datei-Reihenfolge."""
"""Parse a writer file → [{kapitel, title, md, compact, anker, anker_compact, subs}].
Two reading layers per section via `<!-- compact -->` / `<!-- ausführlich -->`. Within
both, `<!-- sub: level | title -->` markers mark a block per subblock; the same
sub title in both layers is merged → `sec["subs"] = [{level, title, md, compact}]`.
Text BEFORE the first sub marker is the anchor (framing) → `anker`/`anker_compact`.
`md`/`compact` stay the FULL version (anchor + all subs) — backward compatible.
"""
sections: list[dict] = []
kapitel = None
current = None
cur_sub = None
cur_layer = "md" # default: anything without a layer marker is the detailed version
for line in text.splitlines():
s = line.strip()
m = _FRAGMENT_KAPITEL_RE.match(s)
if m:
kapitel = m.group(1)
current = None
cur_sub = None
continue
m = _FRAGMENT_SECTION_RE.match(s)
if m:
current = {"kapitel": kapitel, "titel": m.group(1), "md": []}
current = {"chapters": kapitel, "title": m.group(1), "md": [], "compact": [],
"anker_md": [], "anker_compact": [], "_submap": {}, "_suborder": []}
cur_sub = None
cur_layer = "md"
sections.append(current)
continue
if current is not None and _FRAGMENT_KOMPAKT_RE.match(s):
cur_layer = "compact"
cur_sub = None
continue
if current is not None and _FRAGMENT_AUSF_RE.match(s):
cur_layer = "md"
cur_sub = None
continue
m = _FRAGMENT_SUB_RE.match(s)
if m and current is not None:
parts = m.group(1).split("|", 1)
level = parts[0].strip().casefold()
title = parts[1].strip() if len(parts) == 2 else ""
key = title.casefold() or f"_pos{len(current['_suborder'])}"
cur_sub = current["_submap"].get(key)
if cur_sub is None:
cur_sub = {"level": level if level in _STUFEN else "beginner", "title": title, "md": [], "compact": []}
current["_submap"][key] = cur_sub
current["_suborder"].append(key)
elif level in _STUFEN:
cur_sub["level"] = level
continue
if current is not None:
current["md"].append(line)
current[cur_layer].append(line)
if cur_sub is not None:
cur_sub[cur_layer].append(line)
else:
current["anker_" + cur_layer].append(line)
out: list[dict] = []
for sec in sections:
sec["md"] = "\n".join(sec["md"]).strip()
return sections
subs = []
for key in sec["_suborder"]:
sub = sec["_submap"][key]
sub["md"] = "\n".join(sub["md"]).strip()
sub["compact"] = "\n".join(sub["compact"]).strip()
if sub["md"] or sub["compact"]:
subs.append(sub)
out.append({
"chapters": sec["chapters"], "title": sec["title"],
"md": "\n".join(sec["md"]).strip(),
"compact": "\n".join(sec["compact"]).strip(),
"anchor": "\n".join(sec["anker_md"]).strip(),
"anker_compact": "\n".join(sec["anker_compact"]).strip(),
"subs": subs,
})
return out
def _parse_subblocks(text: str) -> dict[str, list[str]]:
"""Parse a subblock file → {block title: [subblock, …]} in order.
Format: `<!-- block: Title -->` followed by list lines `- Subblock`.
"""
out: dict[str, list[str]] = {}
current = None
for line in text.splitlines():
s = line.strip()
m = _FRAGMENT_BAUSTEIN_RE.match(s)
if m:
current = m.group(1).strip()
out.setdefault(current, [])
continue
if current is None:
continue
m = re.match(r"[-*]\s+(.*\S)", s)
if m:
out[current].append(m.group(1).strip())
return {k: v for k, v in out.items() if v}
def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
"""Teilt Kapitel in bis zu n zusammenhängende Chunks, balanciert nach Section-Anzahl."""
"""Split chapters into up to n contiguous chunks, balanced by section count."""
n = max(1, min(n, len(chapters)))
chunks: list[list[dict]] = []
current: list[dict] = []
@@ -143,9 +303,3 @@ def _split_chunks(chapters: list[dict], n: int) -> list[list[dict]]:
return chunks
def _zuteilung_text(chunk: list[dict], entries: dict[int, str]) -> str:
lines = []
for ch in chunk:
lines.append(f"KAPITEL: {ch['title']}")
lines.extend(f"- {entries[num]}" for num in ch["nums"])
return "\n".join(lines)

342
backend/train.py Normal file
View File

@@ -0,0 +1,342 @@
"""make train: Ameisen-Optimierung (ACO) der Pipeline-Parameter — anytime, multi-fidelity.
Prinzip: Pheromon-Gewichte je (Parameter, Stufe) steuern, welche Kandidaten („Ameisen")
als Nächstes getestet werden. Gute Kandidaten verstärken ihre Stufen, Verdunstung hält
die Suche offen — je länger der Trainer läuft, desto gezielter werden die Tests.
Jederzeit stoppbar; der Stand (beste_params.json/report.md) ist immer aktuell.
Fidelity-Kaskade pro Kandidat:
F0 Fake-E2E (train_f0.py, Sekunden, 0 Tokens): Invarianten + Struktur-Proxy — Filter.
F1 Frozen-Inventar (train_lauf.py --board2, ~58 min): misst Board-2/Guide-Parameter.
F2 Volllauf inkl. Soll-Abgleich: alle N Runden für den Besten + Inventar-Parameter.
CLI: python3 train.py [--stunden 8] [--trials 60] [--ameisen 3] [--seed 0]
[--sitzung NAME] [--f2-intervall 5]
python3 train.py --init (baut das Frozen-Inventar-Vorlage-Topic, einmalig)
Ergebnis: storage/train/<sitzung>/{trials.jsonl, pheromon.json, report.md, beste_params.json}
"""
import argparse
import asyncio
import hashlib
import json
import os
import random
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from config import STORAGE_DIR
from train_params import PARAMS
VORLAGE_TOPIC = "train-vorlage"
BENCHMARK = "benchmarks/pruefstand"
# Score-Gewichte: Qualität + Auswahl dominieren (Entwicklungsphase), Kosten ziehen ab.
W_NOTE, W_AUSWAHL, W_ZEIT, W_TOKEN = 4.0, 4.0, 1.0, 1.0
RHO = 0.2 # Pheromon-Verdunstung je Runde
SPARSITY = 0.5 # Wahrscheinlichkeit, dass eine Ameise einen Parameter auf Default lässt
F0_CALL_FAKTOR = 1.5 # Struktur-Proxy: mehr als 1.5× Baseline-Calls → Kandidat verworfen
# Trainings-Fixa (Speed, kein Suchraum): Abschluss-QA ohne Judges, kurze Nachzügler-Gnade
TRAIN_FIXA = {"ABSCHLUSS_QA_LLM": 0, "CONSENSUS_GRACE": 60}
def stufen(name: str) -> list[float]:
p = PARAMS[name]
out, w = [], p["min"]
while w <= p["max"] + 1e-9:
out.append(round(w, 4))
w += p["step"]
return out
def score(m: dict, basis: dict) -> float:
"""Skalarer Vergleichswert. note 010; auswahl aus Soll-Abgleich (F2) oder MECE-Quoten;
Zeit/Tokens normiert auf die Baseline derselben Fidelity."""
if m.get("soll"):
auswahl = 10.0 * m["soll"]["f1"]
else:
q = m.get("quoten") or {}
qa_ = m.get("quoten_artefakte") or {}
auswahl = 10.0 * max(0.0, 1.0 - min(1.0, (
q.get("dubletten_verdacht", 0) + q.get("luecken", 0) + q.get("fremd", 0)
+ qa_.get("sub_dubletten_verdacht", 0) + qa_.get("verwaiste", 0))))
zeit = (m.get("dauer_min") or 0) / max(basis.get("dauer_min") or 1, 0.1)
tok = _tokens(m) / max(_tokens(basis), 1)
return round(W_NOTE * (m.get("note") or 0) + W_AUSWAHL * auswahl
- W_ZEIT * 10 * zeit - W_TOKEN * 10 * tok, 2)
def _tokens(m: dict) -> int:
t = m.get("tokens") or {}
return int(t.get("input") or 0) + int(t.get("output") or 0)
class AmeisenTrainer:
def __init__(self, sitzung: Path, *, max_trials: int, max_stunden: float, ameisen: int = 3,
seed: int = 0, f2_intervall: int = 5, runner=None, runner_f0=None):
self.dir = sitzung
self.dir.mkdir(parents=True, exist_ok=True)
self.rng = random.Random(seed)
self.ameisen = ameisen
self.f2_intervall = max(f2_intervall, 1)
self.max_trials = max_trials
self.deadline = time.monotonic() + max_stunden * 3600
self.gezahlt = 0
self.runner = runner or self._subprozess # (params, fidelity) -> metrics|None
self.runner_f0 = runner_f0 or self._subprozess_f0 # (params) -> {"ok","calls",…}|None
self.log: list[str] = []
self.basis: dict[str, dict] = {} # Fidelity → Baseline-Metriken
self.f0_basis: int | None = None
self.best_params: dict = {}
self.best_score: float | None = None
self.rauschen = 0.5
# Pheromon + Trial-Cache (Resume)
self.pheromon: dict[str, dict[str, float]] = {
n: {str(s): 1.0 for s in stufen(n)} for n in PARAMS}
ph = self.dir / "pheromon.json"
if ph.exists():
gespeichert = json.loads(ph.read_text(encoding="utf-8"))
for n, taus in gespeichert.get("pheromon", {}).items():
if n in self.pheromon:
self.pheromon[n].update({k: float(v) for k, v in taus.items()})
self.best_params = gespeichert.get("best_params", {})
self.best_score = gespeichert.get("best_score")
self.cache_pfad = self.dir / "trials.jsonl"
self.cache: dict[str, dict] = {}
if self.cache_pfad.exists():
for line in self.cache_pfad.read_text(encoding="utf-8").splitlines():
e = json.loads(line)
self.cache[e["key"]] = e["metrics"]
# ── Kandidaten ──────────────────────────────────────────────────────────────────
def kandidat(self, fidelity: str) -> dict:
"""Eine Ameise: je Parameter der Fidelity mit SPARSITY auf Default, sonst
Pheromon-gewichtete Stufe. Sparsame Kandidaten → saubere Attribution."""
params = {}
for name, p in PARAMS.items():
if fidelity == "board2" and p["fidelity"] != "board2":
continue
if self.rng.random() < SPARSITY:
continue
st = stufen(name)
taus = [self.pheromon[name][str(s)] for s in st]
wert = self.rng.choices(st, weights=taus)[0]
if wert != p["default"]:
params[name] = wert
return params
# ── Trial-Ausführung ────────────────────────────────────────────────────────────
def _key(self, params: dict, fidelity: str, tag: str = "") -> str:
raw = json.dumps({"p": params, "f": fidelity, "tag": tag}, sort_keys=True)
return hashlib.md5(raw.encode()).hexdigest()[:12]
async def trial(self, params: dict, fidelity: str, tag: str = "") -> dict | None:
key = self._key(params, fidelity, tag)
if key in self.cache:
return self.cache[key]
if self.gezahlt >= self.max_trials or time.monotonic() > self.deadline:
return None
self.gezahlt += 1
metrics = await self.runner(params, fidelity, "0")
if metrics is not None:
with open(self.cache_pfad, "a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, "params": params, "fidelity": fidelity,
"tag": tag, "metrics": metrics}, ensure_ascii=False) + "\n")
self.cache[key] = metrics
return metrics
async def _subprozess(self, params: dict, fidelity: str, topic_suffix: str = "0") -> dict | None:
out = self.dir / f"metrics-{self._key(params, fidelity)}{topic_suffix}.json"
topic = f"train-t{topic_suffix}"
args = ([topic, VORLAGE_TOPIC, str(out), "--board2"] if fidelity == "board2"
else [topic, BENCHMARK, str(out)])
env = {**os.environ, "CREATOR_PARAMS": json.dumps({**TRAIN_FIXA, **params})}
proc = await asyncio.create_subprocess_exec(sys.executable, "train_lauf.py", *args, env=env)
rc = await proc.wait()
if rc != 0 or not out.exists():
self._log(f"Trial fehlgeschlagen (rc={rc}, {fidelity}, params={params})")
return None
return json.loads(out.read_text(encoding="utf-8"))
async def _subprozess_f0(self, params: dict) -> dict | None:
out = self.dir / f"f0-{self._key(params, 'f0')}.json"
env = {**os.environ, "CREATOR_PARAMS": json.dumps(params)}
proc = await asyncio.create_subprocess_exec(sys.executable, "train_f0.py", str(out), env=env)
rc = await proc.wait()
return json.loads(out.read_text(encoding="utf-8")) if rc == 0 and out.exists() else None
def _log(self, msg: str) -> None:
line = f"{datetime.now(timezone.utc).isoformat()[11:19]} {msg}"
print(line, flush=True)
self.log.append(line)
# ── Pheromon ────────────────────────────────────────────────────────────────────
def verstaerke(self, params: dict, delta: float) -> None:
for name, wert in params.items():
taus = self.pheromon[name]
key = str(wert)
if key in taus:
taus[key] += delta
def verdunste(self) -> None:
for taus in self.pheromon.values():
for k in taus:
taus[k] = max(0.1, (1 - RHO) * taus[k] + RHO * 1.0) # Drift zurück zu uniform
# ── Hauptschleife ───────────────────────────────────────────────────────────────
async def run(self) -> dict:
# Baseline F1 ×2 → Score-Basis + Rausch-Schwelle; F0-Basis für den Struktur-Proxy
f0 = await self.runner_f0({})
self.f0_basis = (f0 or {}).get("calls")
b1 = await self.trial({}, "board2", tag="baseline-1")
b2 = await self.trial({}, "board2", tag="baseline-2")
if not b1 or not b2:
self._log("Baseline unvollständig — Abbruch.")
return self.best_params
self.basis["board2"] = b1
s1, s2 = score(b1, b1), score(b2, b1)
self.rauschen = max(abs(s1 - s2), 0.5)
if self.best_score is None:
self.best_score = max(s1, s2)
self._log(f"Baseline {s1}/{s2}, Rauschen {self.rauschen}, F0-Basis {self.f0_basis} Calls")
runde, stagnation, gezahlt_vorher = 0, 0, self.gezahlt
while (self.gezahlt < self.max_trials and time.monotonic() < self.deadline
and stagnation < 20): # konvergiert: nur noch Cache-Treffer → fertig
if runde > 0:
stagnation = stagnation + 1 if self.gezahlt == gezahlt_vorher else 0
gezahlt_vorher = self.gezahlt
runde += 1
fidelity = "voll" if runde % self.f2_intervall == 0 else "board2"
if fidelity == "voll" and "voll" not in self.basis:
base = await self.trial({}, "voll", tag="baseline-voll")
if base is None:
break
self.basis["voll"] = base
kandidaten = []
for _ in range(self.ameisen * 3): # ziehen bis K einzigartige nicht-leere da sind
k = self.kandidat(fidelity)
if k and k not in kandidaten and k != self.best_params:
kandidaten.append(k)
if len(kandidaten) >= self.ameisen:
break
if not kandidaten:
continue
# F0-Filter: Invarianten + Struktur-Proxy, parallel, kostenlos
f0s = await asyncio.gather(*[self.runner_f0(k) for k in kandidaten])
ueberlebende = []
for k, f in zip(kandidaten, f0s):
if f is None or not f.get("ok") or f.get("invarianten_fehler"):
self._log(f"F0 verwirft {k} (Invarianten)")
elif self.f0_basis and f.get("calls", 0) > self.f0_basis * F0_CALL_FAKTOR:
self._log(f"F0 verwirft {k} (Calls {f['calls']} > {self.f0_basis}×{F0_CALL_FAKTOR})")
else:
ueberlebende.append(k)
if not ueberlebende:
self.verdunste()
continue
# F1/F2 parallel (eigene Topic-Namen)
ergebnisse = await asyncio.gather(*[
self._bewertet(k, fidelity, str(i + 1)) for i, k in enumerate(ueberlebende)])
bewertet = [(k, m, score(m, self.basis[fidelity]))
for k, m in ergebnisse if m is not None]
if not bewertet:
continue
bewertet.sort(key=lambda x: -x[2])
self.verdunste()
top_k, _top_m, top_s = bewertet[0]
self._log(f"Runde {runde} ({fidelity}): top {top_s} {top_k} "
f"(best {self.best_score})")
if top_s > (self.best_score or 0):
self.verstaerke(top_k, delta=1.0)
if top_s > (self.best_score or 0) + self.rauschen:
m2 = await self.trial(top_k, fidelity, tag="bestaetigung")
if m2 is not None and score(m2, self.basis[fidelity]) > self.best_score + self.rauschen:
self.best_params = top_k
self.best_score = min(top_s, score(m2, self.basis[fidelity]))
self._log(f"NEUER BESTER {self.best_params}{self.best_score}")
else:
self._log(f"{top_k}: nicht bestätigt")
self.verstaerke(self.best_params, delta=0.5) # Elite hält die Spur warm
self._speichern()
self._speichern()
return self.best_params
async def _bewertet(self, params: dict, fidelity: str, suffix: str):
if self.gezahlt >= self.max_trials or time.monotonic() > self.deadline:
return params, None
key = self._key(params, fidelity)
if key in self.cache:
return params, self.cache[key]
self.gezahlt += 1
m = await self.runner(params, fidelity, suffix)
if m is not None:
with open(self.cache_pfad, "a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, "params": params, "fidelity": fidelity,
"tag": "", "metrics": m}, ensure_ascii=False) + "\n")
self.cache[key] = m
return params, m
def _speichern(self) -> None:
from fsutil import atomic_write_json, atomic_write_text
atomic_write_json(self.dir / "pheromon.json",
{"pheromon": self.pheromon, "best_params": self.best_params,
"best_score": self.best_score}, indent=1)
atomic_write_json(self.dir / "beste_params.json", self.best_params, indent=1)
staerkste = sorted(((n, max(t.items(), key=lambda x: x[1]))
for n, t in self.pheromon.items()),
key=lambda x: -x[1][1])[:10]
report = ["# Trainings-Report (Ameisen)", "",
f"Bezahlte Läufe: {self.gezahlt}/{self.max_trials}",
f"Bester Score: {self.best_score} (Rauschband {self.rauschen})",
f"Beste Parameter: `{json.dumps(self.best_params, ensure_ascii=False)}`",
"", "Stärkste Pheromon-Spuren:",
*[f"- {n}={s} (τ={t:.1f})" for n, (s, t) in staerkste],
"", "Nutzung: `CREATOR_PARAMS=$(cat beste_params.json)` —",
"Übernahme nach config.py bleibt eine manuelle Entscheidung.", "", "## Log", ""]
report += [f"- {l}" for l in self.log[-200:]]
atomic_write_text(self.dir / "report.md", "\n".join(report))
async def init_vorlage() -> None:
"""Einmalig: Prüfstand-Volllauf mit Defaults, Ergebnis bleibt als Frozen-Inventar-Vorlage
liegen (Topic train-vorlage). Nach Korpus-/Prompt-Änderungen neu ausführen."""
import agents
import database
from blocks import generate_blocks
from fsutil import atomic_write_json as awj
from paths import source_path
await database.init_db()
agents.on_event = database.add_event
await database.create_topic(VORLAGE_TOPIC)
qp = source_path(VORLAGE_TOPIC)
qp.parent.mkdir(parents=True, exist_ok=True)
awj(qp, {"type": "uni", "location": BENCHMARK, "spec": ""})
await generate_blocks(VORLAGE_TOPIC, provider="minimax", research=True, qa_force=True)
await database.close_db()
print(f"Vorlage {VORLAGE_TOPIC} steht — Training kann starten (make train).")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--init", action="store_true", help="Frozen-Inventar-Vorlage bauen")
ap.add_argument("--trials", type=int, default=60)
ap.add_argument("--stunden", type=float, default=8.0)
ap.add_argument("--ameisen", type=int, default=3)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--f2-intervall", type=int, default=5)
ap.add_argument("--sitzung", default="aco") # fester Default: Resume über Sitzungen hinweg
args = ap.parse_args()
if args.init:
asyncio.run(init_vorlage())
return
trainer = AmeisenTrainer(STORAGE_DIR / "train" / args.sitzung,
max_trials=args.trials, max_stunden=args.stunden,
ameisen=args.ameisen, seed=args.seed,
f2_intervall=args.f2_intervall)
asyncio.run(trainer.run())
if __name__ == "__main__":
main()

60
backend/train_f0.py Normal file
View File

@@ -0,0 +1,60 @@
"""Fidelity 0 des Trainers: Fake-E2E-Lauf im Subprozess — Sekunden, null Tokens.
Misst mit den CREATOR_PARAMS des Kandidaten: (a) halten die Invarianten? (b) wie viele
Agenten-Calls erzeugt die Struktur (Proxy für Tokens/Laufzeit)? Unsinnige Kandidaten
fallen hier raus, bevor ein echter Lauf Geld kostet.
CLI: python3 train_f0.py <ausgabe.json> (CREATOR_PARAMS im ENV)
"""
import asyncio
import json
import sys
import tempfile
import time
from pathlib import Path
# WICHTIG: config (mit CREATOR_PARAMS) lädt vor allen Pipeline-Modulen
import database
from config import DEFAULT_PROVIDER
from fake_agents import Welt, aktivieren
from fsutil import atomic_write_json
async def f0(out: str) -> None:
tmp = Path(tempfile.mkdtemp(prefix="train-f0-"))
database.DB_PATH = tmp / "f0.db"
database._db = None
await database.init_db()
welt = Welt()
aktivieren(welt)
import board_inventory as bi
import qa
qa.QA_DIR = tmp / "qa"
from pipeline import GenContext
work = tmp / "arbeit"
work.mkdir()
files = {"arbeit": work, "final": tmp / "blocks.md",
"sub_roh": tmp / "sub_roh.json", "sidecar": tmp / "subblocks.json",
"facts": tmp / "facts.json", "question_pattern": tmp / "question_pattern.json",
"artefakte": tmp / "artefakte.json", "outline": tmp / "outline.json",
"outline_slots": [tmp / f"outline-{i}.json" for i in (1, 2, 3)],
"research": [work / f"research-{i}.md" for i in (1, 2, 3, 4, 5)]}
ctx = GenContext(topic="f0", provider=DEFAULT_PROVIDER, is_cancelled=lambda: False)
start = time.monotonic()
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=True, qa_force=True), timeout=180)
from invarianten import pruefe_invarianten
fehler = await pruefe_invarianten("f0", files)
atomic_write_json(Path(out), {
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),
"dauer_s": round(time.monotonic() - start, 1)}, indent=1)
await database.close_db()
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Nutzung: python3 train_f0.py <ausgabe.json>")
asyncio.run(f0(sys.argv[1]))

127
backend/train_lauf.py Normal file
View File

@@ -0,0 +1,127 @@
"""EIN Trainings-Trial: frischer Prozess (CREATOR_PARAMS wirkt beim Import), ein Mini-Lauf,
deterministische Metriken als JSON — danach ist das Trial-Topic weg.
Fidelity-Modi:
voll python3 train_lauf.py <topic> <benchmark-location> <ausgabe.json>
— kompletter Lauf (Research + Board 1 + Board 2) + Soll-Abgleich gegen
<benchmark-location>/soll.json (falls vorhanden)
board2 python3 train_lauf.py <topic> <vorlage-topic> <ausgabe.json> --board2
— Frozen-Inventar: Vorlage kopieren, Board 2 komplett neu (research=False)
"""
import asyncio
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
import agents
import database
import qa
from blocks import generate_blocks
from fsutil import atomic_write_json
from paths import blocks_path, source_path, topic_dir
from textkit import _norm_title
def soll_abgleich(ist_titel: list[str], soll: dict) -> dict:
"""Ground-Truth-Vergleich: welche Soll-Blöcke fehlen, was ist überzählig.
Match über Norm-Gleichheit gegen Titel+Alternativen, Fallback beidseitiges Containment."""
ist = {_norm_title(t): t for t in ist_titel}
treffer, fehlend, belegt = [], [], set()
for block in soll.get("bloecke", []):
formen = {_norm_title(block["titel"])} | {_norm_title(a) for a in block.get("alternativen", [])}
gefunden = next((n for n in ist if n in formen), None)
if gefunden is None:
gefunden = next((n for n in ist if any(f and (f in n or n in f) for f in formen)), None)
if gefunden:
treffer.append(block["titel"])
belegt.add(gefunden)
else:
fehlend.append(block["titel"])
extra = [t for n, t in ist.items() if n not in belegt]
n_soll = max(len(soll.get("bloecke", [])), 1)
praezision = len(treffer) / max(len(ist), 1)
recall = len(treffer) / n_soll
f1 = 2 * praezision * recall / max(praezision + recall, 1e-9)
return {"treffer": treffer, "fehlend": fehlend, "extra": extra, "f1": round(f1, 3)}
async def trial(topic: str, quelle: str, out: str, board2: bool) -> None:
await database.init_db()
agents.on_event = database.add_event # sonst keine Dauer-/Token-Events (main.py-lifespan-Pendant)
try:
await _aufraeumen(topic) # Reste eines abgebrochenen Trials
await database.create_topic(topic)
start = datetime.now(timezone.utc)
if board2:
await _frozen_inventar(topic, vorlage=quelle)
await generate_blocks(topic, provider="minimax", research=False, qa_force=True)
else:
qp = source_path(topic)
qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": "uni", "location": quelle, "spec": ""})
# qa_force=True: das Gate misst nichts und pausiert nie
await generate_blocks(topic, provider="minimax", research=True, qa_force=True)
dauer_min = round((datetime.now(timezone.utc) - start).total_seconds() / 60, 1)
report = await qa.qa_report(topic, llm=False) or {}
lauf = report.get("lauf") or {}
metrics = {
"topic": topic,
"fidelity": "board2" if board2 else "voll",
"note": report.get("note"),
"note_artefakte": report.get("note_artefakte"),
"quoten": report.get("quoten") or {},
"quoten_artefakte": report.get("quoten_artefakte") or {},
"bloecke": report.get("bloecke"),
"dauer_min": lauf.get("dauer_min") or dauer_min,
"tokens": (lauf.get("tokens") or {}),
"agents": (lauf.get("agents") or {}),
}
if not board2:
soll_pfad = Path(__file__).resolve().parent.parent / quelle / "soll.json"
if soll_pfad.exists():
import json
done = await database.kanban_cards(topic, board="inventory", stage="done_block")
titel = [c["payload"].get("title", "") for c in done if c["kind"] == "block"]
metrics["soll"] = soll_abgleich(titel, json.loads(soll_pfad.read_text(encoding="utf-8")))
atomic_write_json(Path(out), metrics, indent=1)
finally:
await _aufraeumen(topic)
await database.close_db()
async def _frozen_inventar(topic: str, vorlage: str) -> None:
"""Board-1-Stand der Vorlage übernehmen und Board 2 auf Start zurücksetzen —
reset_board_from_stage räumt DB-Spiegel, globale Dateien und Resume-Slots."""
import board_inventory
from blocks import _blocks_files
await database.copy_topic(vorlage, topic)
tdir = topic_dir(topic)
tdir.mkdir(parents=True, exist_ok=True)
for src, dst in ((source_path(vorlage), source_path(topic)),
(blocks_path(vorlage), blocks_path(topic))):
if src.exists():
shutil.copy(src, dst)
files = _blocks_files(topic)
files["arbeit"].mkdir(parents=True, exist_ok=True)
await board_inventory.reset_board_from_stage(topic, "artefacts", "generate", files)
async def _aufraeumen(topic: str) -> None:
"""Topic restlos entfernen (DELETE-/topics-Sequenz aus routes.py)."""
await database.delete_topic(topic)
await database.delete_block_data(topic)
await database.delete_topic_pipeline(topic)
await database.kanban_reset(topic)
await database.delete_source(topic)
await database.delete_guide_content(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True)
if __name__ == "__main__":
args = [a for a in sys.argv[1:] if a != "--board2"]
if len(args) != 3:
raise SystemExit("Nutzung: python3 train_lauf.py <topic> <quelle> <ausgabe.json> [--board2]")
asyncio.run(trial(args[0], args[1], args[2], board2="--board2" in sys.argv))

47
backend/train_params.py Normal file
View File

@@ -0,0 +1,47 @@
"""Suchraum fürs Training (make train): welche config-Parameter der Trainer bewegen darf.
Je Parameter: default (muss config spiegeln — Test prüft das), min/max (harte Ränder,
flow-sicher), step (Schrittweite der Koordinaten-Suche), kategorie (welches Ziel er
primär bewegt: qualitaet/auswahl/laufzeit/tokens). QA-/Detektor-Konstanten stehen
bewusst NICHT hier — die Messlatte darf nie Teil des Suchraums sein.
"""
PARAMS: dict[str, dict] = {
# Recherche / Inventar
"RESEARCH_THEMA_AGENTS": {"default": 5, "min": 2, "max": 8, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
"RESEARCH_READERS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
"RESEARCH_SECTION_CHARS": {"default": 12000, "min": 6000, "max": 24000, "step": 3000, "kategorie": "tokens", "fidelity": "voll"},
"DEDUP_PAIR_FLOOR": {"default": 0.6, "min": 0.45, "max": 0.8, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"DEDUP_TITLE_AUTO": {"default": 0.95, "min": 0.9, "max": 0.99, "step": 0.01, "kategorie": "auswahl", "fidelity": "voll"},
"DEDUP_GLOBAL_FLOOR": {"default": 0.65, "min": 0.5, "max": 0.8, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"DEDUP_PAIRS_CHUNK": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "voll"},
"FILTER_CHUNK": {"default": 35, "min": 15, "max": 60, "step": 10, "kategorie": "laufzeit", "fidelity": "voll"},
"FILTER_RECHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
"CONSOLIDATION_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "voll"},
# Board 2, verschmolzene Calls (block_calls.py)
"GEN_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"VERIFY_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"ART_SPLIT_SUBS": {"default": 20, "min": 10, "max": 40, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
# Embedding-Schwellen (Auswahl-Kern)
"SUB_VARIANT_COS": {"default": 0.90, "min": 0.85, "max": 0.96, "step": 0.01, "kategorie": "auswahl", "fidelity": "board2"},
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"},
# Guide
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"},
# Engine / Kosten
"CONSENSUS_GRACE": {"default": 300, "min": 0, "max": 600, "step": 60, "kategorie": "laufzeit", "fidelity": "board2"},
"MAX_RESTARTS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "laufzeit", "fidelity": "board2"},
"HEDGE_NACH_S": {"default": 90, "min": 30, "max": 240, "step": 30, "kategorie": "laufzeit", "fidelity": "board2"},
"EVIDENCE_BUDGET_CHARS": {"default": 48000, "min": 16000, "max": 64000, "step": 8000, "kategorie": "tokens", "fidelity": "board2"},
}
def schritte(name: str) -> tuple[float, float]:
"""(wert_runter, wert_hoch) je einen step von default, an die Ränder geklemmt."""
p = PARAMS[name]
lo = max(p["min"], round(p["default"] - p["step"], 4))
hi = min(p["max"], round(p["default"] + p["step"], 4))
return lo, hi

View File

@@ -0,0 +1,23 @@
Kapitel 1: Die Blende
Die Blende ist eine verstellbare Öffnung im Objektiv, die die einfallende Lichtmenge steuert. Ihre Größe wird als Blendenzahl angegeben, dem Verhältnis von Brennweite zu Öffnungsdurchmesser: f/2.8 bezeichnet eine große, f/16 eine kleine Öffnung. Eine ganze Blendenstufe halbiert oder verdoppelt die Lichtmenge; die Reihe ganzer Stufen lautet f/1.4, f/2, f/2.8, f/4, f/5.6, f/8, f/11, f/16, f/22. Die Blende steuert zugleich die Schärfentiefe: Eine offene Blende (kleine Blendenzahl) erzeugt geringe Schärfentiefe und Freistellung, eine geschlossene Blende große Schärfentiefe. Jenseits von etwa f/16 sinkt die Detailschärfe durch Beugung.
Kapitel 2: Die Belichtungszeit
Die Belichtungszeit ist die Dauer, während der der Sensor Licht sammelt. Sie wird in Sekundenbruchteilen angegeben; jede Halbierung oder Verdopplung entspricht einer Lichtwertstufe. Kurze Zeiten frieren Bewegung ein: 1/1000 Sekunde genügt für Sport, 1/250 Sekunde für gehende Personen. Lange Zeiten erzeugen Bewegungsunschärfe, etwa fließendes Wasser ab 1/4 Sekunde. Als Faustregel für verwacklungsfreies Fotografieren aus der Hand gilt: Belichtungszeit höchstens eins durch Brennweite (Kleinbild-äquivalent), also 1/50 Sekunde bei 50 Millimetern. Bildstabilisatoren verlängern diese Grenze um drei bis fünf Stufen.
Kapitel 3: Der ISO-Wert
Der ISO-Wert beschreibt die Signalverstärkung des Sensors. Die Basisempfindlichkeit liegt bei den meisten Kameras bei ISO 100; jede Verdopplung entspricht einer Lichtwertstufe. Höhere ISO-Werte ermöglichen kürzere Belichtungszeiten bei wenig Licht, verstärken aber das Bildrauschen und verringern den Dynamikumfang. Modernes Rauschverhalten erlaubt bei Vollformatsensoren meist saubere Bilder bis ISO 3200 bis 6400. ISO-invariante Sensoren erlauben es, die Aufhellung ins RAW-Processing zu verschieben, ohne zusätzliches Rauschen einzuhandeln.
Kapitel 4: Das Belichtungsdreieck
Blende, Belichtungszeit und ISO-Wert bilden das Belichtungsdreieck: Alle drei Größen bestimmen gemeinsam die Bildhelligkeit, und eine Stufe bei einer Größe lässt sich durch eine Stufe einer anderen ausgleichen. Ein Beispiel: f/8 bei 1/125 Sekunde und ISO 100 belichtet identisch wie f/5.6 bei 1/250 Sekunde und ISO 100 oder f/8 bei 1/250 Sekunde und ISO 200. Die Wahl innerhalb dieser äquivalenten Kombinationen ist eine gestalterische Entscheidung über Schärfentiefe, Bewegungsdarstellung und Rauschen.
Kapitel 5: Der Weißabgleich
Der Weißabgleich gleicht die Farbtemperatur der Lichtquelle aus, gemessen in Kelvin: Kerzenlicht liegt bei etwa 1800 Kelvin, Glühlampen bei 2700 Kelvin, Tageslicht bei 5500 Kelvin, bedeckter Himmel bei 6500 bis 7500 Kelvin. Ein zu niedrig eingestellter Weißabgleich macht das Bild blau, ein zu hoher macht es orange. Wer in RAW fotografiert, kann den Weißabgleich verlustfrei nachträglich setzen; bei JPEG ist die Korrektur begrenzt.
Kapitel 6: Autofokus-Betriebsarten
Der Einzel-Autofokus (AF-S) stellt einmal scharf und verriegelt die Entfernung — geeignet für statische Motive. Der kontinuierliche Autofokus (AF-C) führt die Schärfe laufend nach und ist die Wahl für bewegte Motive; die Trefferquote hängt von der Motivverfolgung ab. Beim Fokus-und-Verschwenken-Verfahren wird mit dem mittleren Feld scharfgestellt und dann der Bildausschnitt verändert; bei offener Blende und naher Distanz führt das Verschwenken zu Fokusfehlern, weil sich die Fokusebene dreht.

View File

@@ -0,0 +1,13 @@
Übungsblatt Fotografie-Grundlagen
Aufgabe 1: Sie fotografieren mit f/8, 1/125 Sekunde, ISO 100. Das Bild ist eine Stufe zu dunkel. Nennen Sie drei Korrekturen, die je genau eine Lichtwertstufe aufhellen: Blende auf f/5.6 öffnen, Belichtungszeit auf 1/60 Sekunde verlängern oder ISO auf 200 verdoppeln.
Aufgabe 2: Ordnen Sie die Blendenzahlen f/4, f/11, f/2 nach Öffnungsgröße, beginnend mit der größten Öffnung. Reihenfolge: f/2, f/4, f/11.
Aufgabe 3: Sie fotografieren mit einem 200-Millimeter-Objektiv ohne Stabilisator aus der Hand. Welche längste Belichtungszeit empfiehlt die Faustregel? 1/200 Sekunde.
Aufgabe 4: Ein Porträt vor unruhigem Hintergrund soll freigestellt werden. Welche Blendenwahl unterstützt das, und welcher Nebeneffekt ist zu beachten? Offene Blende wie f/2, dabei geringe Schärfentiefe — die Fokusebene muss exakt auf den Augen liegen.
Aufgabe 5: Das Bild einer Kunstlicht-Szene wirkt stark orange. In welche Richtung war der Weißabgleich falsch eingestellt, und wie lautet die passende Farbtemperatur für Glühlampenlicht? Der Weißabgleich stand zu hoch; passend sind etwa 2700 Kelvin.
Aufgabe 6: Warum führt Fokus-und-Verschwenken bei f/1.8 und einem Meter Abstand zu unscharfen Augen? Beim Verschwenken dreht sich die Fokusebene aus dem Motiv heraus; die geringe Schärfentiefe verzeiht die Abweichung nicht.

View File

@@ -0,0 +1,27 @@
Skript: Grundlagen der Verschlüsselung
Kapitel 1: Symmetrische Verschlüsselung
Symmetrische Verschlüsselung verwendet denselben Schlüssel zum Ver- und Entschlüsseln. Sender und Empfänger müssen den Schlüssel vorab über einen sicheren Kanal austauschen. Der Schlüsselraum muss groß genug sein, dass vollständiges Durchprobieren aussichtslos bleibt: 128 Bit gelten als sicher gegen Brute-Force. Symmetrische Verfahren sind schnell und eignen sich für große Datenmengen. Blockchiffren verarbeiten die Nachricht in festen Blöcken, etwa 128 Bit bei AES; Stromchiffren verschlüsseln Bit für Bit mit einem Schlüsselstrom. Das Kerckhoffs-Prinzip verlangt, dass die Sicherheit allein am Schlüssel hängt, nie an der Geheimhaltung des Verfahrens.
Kapitel 2: Betriebsmodi von Blockchiffren
Ein Betriebsmodus legt fest, wie eine Blockchiffre Nachrichten länger als einen Block verarbeitet. Die wichtigsten Modi sind ein kleiner Katalog: ECB verschlüsselt jeden Block unabhängig, CBC verkettet jeden Block mit dem Vorgänger-Geheimtext, CTR macht aus der Blockchiffre eine Stromchiffre über einen Zähler, und GCM ergänzt CTR um einen Authentizitäts-Tag. ECB gilt als unsicher, weil gleiche Klartextblöcke gleiche Geheimtextblöcke ergeben und Muster sichtbar bleiben. CBC braucht einen zufälligen Initialisierungsvektor je Nachricht. CTR und GCM sind parallelisierbar; GCM ist der Standard für authentifizierte Verschlüsselung.
Kapitel 3: Asymmetrische Verschlüsselung
Asymmetrische Verschlüsselung, im Englischen public key cryptography, arbeitet mit einem Schlüsselpaar: Der öffentliche Schlüssel verschlüsselt, der private entschlüsselt. Der öffentliche Schlüssel darf jeder kennen; der private verlässt den Besitzer nie. Damit entfällt der sichere Kanal für den Schlüsselaustausch. Die Sicherheit beruht auf mathematisch schweren Problemen: RSA auf der Faktorisierung großer Zahlen, Elliptische-Kurven-Verfahren auf dem diskreten Logarithmus. Asymmetrische Verfahren sind um Größenordnungen langsamer als symmetrische. In der Praxis verschlüsselt man deshalb hybrid: asymmetrisch nur den Sitzungsschlüssel, die Daten symmetrisch.
Anders formuliert löst die Public-Key-Kryptographie das Verteilungsproblem: Zwei Parteien ohne gemeinsames Geheimnis können vertraulich kommunizieren, weil das Schlüsselpaar die Rollen trennt — verschlüsseln kann jeder, entschlüsseln nur der Inhaber des privaten Schlüssels.
Kapitel 4: Kryptographische Hashfunktionen
Eine kryptographische Hashfunktion bildet beliebig lange Eingaben auf einen Wert fester Länge ab, den Digest. Drei Eigenschaften machen sie kryptographisch: Einwegfunktion (aus dem Digest ist die Eingabe praktisch nicht rekonstruierbar), schwache Kollisionsresistenz (zu gegebener Eingabe ist keine zweite mit gleichem Digest findbar) und starke Kollisionsresistenz (es ist praktisch unmöglich, irgendein kollidierendes Paar zu finden). Nach dem Geburtstagsparadoxon (Satz 3.2) sinkt der Kollisionsaufwand auf die Wurzel des Werteraums: Bei einem 256-Bit-Digest liegt er bei 2 hoch 128 Versuchen. Kleine Eingabeänderungen kippen im Mittel die Hälfte der Digest-Bits; das heißt Lawineneffekt. Hashfunktionen speichern Passwörter, prüfen Datenintegrität und bilden die Basis von Signaturen.
Kapitel 5: Digitale Signaturen
Eine digitale Signatur weist Urheberschaft und Unverändertheit einer Nachricht nach. Der Absender hasht die Nachricht und verschlüsselt den Digest mit seinem privaten Schlüssel; jeder kann die Signatur mit dem öffentlichen Schlüssel prüfen. Stimmen berechneter und entschlüsselter Digest überein, ist die Nachricht unverändert und stammt vom Schlüsselinhaber. Signaturen liefern damit drei Garantien: Integrität, Authentizität und Nichtabstreitbarkeit. Signiert wird immer der Hash, nie die Nachricht selbst — aus Effizienz und weil manche Verfahren nur kurze Eingaben verarbeiten. Gängige Verfahren sind RSA-PSS und ECDSA.
Kapitel 6: Zertifikate und PKI
Ein Zertifikat bindet einen öffentlichen Schlüssel an eine Identität. Es enthält Inhaber, Schlüssel, Gültigkeitszeitraum und die Signatur einer Zertifizierungsstelle (CA). Die Public-Key-Infrastruktur (PKI) ordnet CAs hierarchisch: Eine Wurzel-CA signiert Zwischen-CAs, diese signieren Endzertifikate — die Vertrauenskette. Browser prüfen die Kette bis zu einer vorinstallierten Wurzel. Widerrufene Zertifikate landen in Sperrlisten (CRL) oder werden per OCSP live abgefragt. Am Rande: Das X.690-Format kodiert Zertifikatsfelder in ASN.1-Strukturen — ein Implementierungsdetail, das für das Verständnis der Vertrauenskette nicht nötig ist.

View File

@@ -0,0 +1,16 @@
{
"bloecke": [
{"titel": "Symmetrische Verschlüsselung", "alternativen": ["Symmetrische Kryptographie", "Symmetrische Verfahren"]},
{"titel": "Betriebsmodi von Blockchiffren", "alternativen": ["Betriebsmodi", "Blockchiffren-Modi", "Betriebsarten von Blockchiffren"]},
{"titel": "Asymmetrische Verschlüsselung", "alternativen": ["Public-Key-Kryptographie", "Asymmetrische Kryptographie", "Public Key Cryptography"]},
{"titel": "Kryptographische Hashfunktionen", "alternativen": ["Hashfunktionen", "Hash-Funktionen"]},
{"titel": "Digitale Signaturen", "alternativen": ["Signaturen", "Digitale Signatur"]},
{"titel": "Zertifikate und PKI", "alternativen": ["Zertifikate", "Public-Key-Infrastruktur", "PKI", "Zertifikate und Public-Key-Infrastruktur"]}
],
"fallen": {
"dublette": "Asymmetrische Verschlüsselung und Public-Key-Kryptographie (Kapitel 3, zwei Formulierungen + DE/EN) dürfen nur EINEN Block ergeben.",
"katalog": "ECB/CBC/CTR/GCM gehören als Katalog unter Betriebsmodi — keine vier Einzelblöcke.",
"peripheral": "X.690/ASN.1 ist als Implementierungsdetail markiert — höchstens peripheral, nie eigener Kern-Block.",
"referenz_titel": "„Geburtstagsparadoxon (Satz 3.2)" darf nicht als Referenz-Titel überleben (Naming-Regel)."
}
}

View File

@@ -0,0 +1,23 @@
Kapitel 1: Grundbegriffe des Sortierens
Ein Sortierverfahren ordnet eine Folge von n Elementen nach einem Ordnungskriterium, meist aufsteigend nach einem Schlüssel. Ein Verfahren heißt stabil, wenn Elemente mit gleichem Schlüssel ihre ursprüngliche Reihenfolge behalten. Ein Verfahren arbeitet in-place, wenn es neben der Eingabefolge nur konstant viel zusätzlichen Speicher benötigt. Die Laufzeit wird in Vergleichen und Vertauschungen gemessen; die untere Schranke für vergleichsbasierte Verfahren liegt bei n log n Vergleichen im schlechtesten Fall.
Kapitel 2: Bubblesort
Bubblesort durchläuft die Folge wiederholt von links nach rechts und vertauscht benachbarte Elemente, wenn sie in falscher Reihenfolge stehen. Nach dem ersten Durchlauf steht das größte Element sicher am rechten Ende; nach k Durchläufen stehen die k größten Elemente an ihren endgültigen Positionen. Das Verfahren endet, wenn ein Durchlauf ohne Vertauschung bleibt. Bubblesort ist stabil und arbeitet in-place. Die Laufzeit beträgt im schlechtesten und mittleren Fall Theta(n Quadrat) Vergleiche; im besten Fall (bereits sortierte Folge) genügt ein Durchlauf mit n minus 1 Vergleichen, sofern die Abbruchbedingung implementiert ist.
Kapitel 3: Insertionsort
Insertionsort baut den sortierten Bereich am linken Rand schrittweise auf: Das jeweils nächste Element wird von rechts nach links durch Vergleiche an seine Einfügeposition geschoben. Insertionsort ist stabil, arbeitet in-place und benötigt im schlechtesten Fall n mal (n minus 1) durch 2 Vergleiche. Auf fast sortierten Folgen ist Insertionsort ausgesprochen schnell: Die Laufzeit ist linear in der Zahl der Fehlstellungen (Inversionen). Deshalb wird Insertionsort in der Praxis als Basisfall in hybriden Verfahren eingesetzt, etwa für Teilfolgen unter etwa 16 Elementen.
Kapitel 4: Mergesort
Mergesort teilt die Folge in zwei Hälften, sortiert beide rekursiv und mischt die sortierten Hälften in linearer Zeit zusammen (Merge-Schritt). Der Merge-Schritt vergleicht die jeweils vordersten Elemente beider Hälften und übernimmt das kleinere. Mergesort ist stabil, benötigt aber ein Hilfsarray der Größe n und arbeitet damit nicht in-place. Die Laufzeit beträgt in allen Fällen Theta(n log n). Mergesort ist das Standardverfahren für externes Sortieren, weil es sequentiell auf Datenströmen arbeiten kann.
Kapitel 5: Quicksort
Quicksort wählt ein Pivot-Element, partitioniert die Folge in Elemente kleiner und größer als das Pivot und sortiert beide Teile rekursiv. Die Partitionierung nach Lomuto verwendet das letzte Element als Pivot und einen Lauffinger; die Partitionierung nach Hoare arbeitet mit zwei gegenläufigen Zeigern und weniger Vertauschungen. Quicksort ist nicht stabil. Die mittlere Laufzeit beträgt Theta(n log n) mit kleiner Konstante; der schlechteste Fall Theta(n Quadrat) tritt bei ungünstiger Pivot-Wahl auf, etwa beim ersten Element auf sortierter Eingabe. Randomisierte Pivot-Wahl oder Median-aus-drei machen den schlechten Fall unwahrscheinlich.
Kapitel 6: Heapsort
Heapsort baut aus der Folge einen Max-Heap: einen binären Baum in Array-Darstellung, bei dem jeder Knoten mindestens so groß ist wie seine Kinder. Der Aufbau gelingt in linearer Zeit durch absinken lassen (sift-down) von der Mitte an rückwärts. Danach wird wiederholt die Wurzel (das Maximum) mit dem letzten Heap-Element getauscht, der Heap um eins verkürzt und die neue Wurzel abgesenkt. Heapsort arbeitet in-place und garantiert Theta(n log n) im schlechtesten Fall, ist aber nicht stabil und hat schlechtere Cache-Lokalität als Quicksort.

View File

@@ -0,0 +1,13 @@
Übungsblatt Sortierverfahren
Aufgabe 1: Sortieren Sie die Folge 5, 2, 8, 1, 9 mit Bubblesort. Notieren Sie nach jedem Durchlauf den Zustand der Folge und die Zahl der Vertauschungen. Nach Durchlauf 1: 2, 5, 1, 8, 9 (drei Vertauschungen). Nach Durchlauf 2: 2, 1, 5, 8, 9 (eine Vertauschung). Nach Durchlauf 3: 1, 2, 5, 8, 9 (eine Vertauschung). Durchlauf 4 bleibt ohne Vertauschung, das Verfahren endet.
Aufgabe 2: Zeigen Sie, dass Insertionsort auf einer Folge mit k Inversionen höchstens n minus 1 plus k Vergleiche benötigt. Hinweis: Jeder Vergleich, der zu einer Verschiebung führt, beseitigt genau eine Inversion.
Aufgabe 3: Führen Sie den Merge-Schritt für die sortierten Hälften 1, 4, 7 und 2, 3, 9 durch. Ergebnisfolge: 1, 2, 3, 4, 7, 9 mit fünf Vergleichen.
Aufgabe 4: Geben Sie für Quicksort mit Lomuto-Partitionierung und letztem Element als Pivot eine Eingabe der Länge 5 an, die den schlechtesten Fall erzeugt. Die bereits sortierte Folge 1, 2, 3, 4, 5 erzeugt Partitionen der Größen 4, 3, 2, 1 und damit quadratische Laufzeit.
Aufgabe 5: Bauen Sie aus der Folge 3, 7, 1, 9, 4 einen Max-Heap in Array-Darstellung. Ergebnis nach dem Heap-Aufbau: 9, 7, 1, 3, 4. Begründen Sie, warum der Aufbau von der Mitte an rückwärts in linearer Zeit gelingt.
Aufgabe 6: Welche der Verfahren Bubblesort, Insertionsort, Mergesort, Quicksort, Heapsort sind stabil? Stabil sind Bubblesort, Insertionsort und Mergesort; Quicksort und Heapsort sind nicht stabil.

113
dev-ops/opencode-slim.json Normal file
View File

@@ -0,0 +1,113 @@
// Auto-Ableitung von opencode.json OHNE mcp-Server: Batch-Agenten (files/readonly/text)
// brauchen keine Web-MCPs — jeder opencode-Prozess startet sonst ~3 MCP-Prozesse (~300 MB).
// Bei Änderungen an opencode.json hier nachziehen (nur der mcp-Block fehlt).
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"minimax": {
"options": {
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3"
}
}
},
"minimax-kalt": {
"npm": "@ai-sdk/anthropic",
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
"options": {
"baseURL": "https://api.minimax.io/anthropic/v1",
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3 (kalt)",
"options": {
"temperature": 0.2,
"thinking": {
"type": "disabled"
}
}
},
"MiniMax-M2.7-highspeed": {
"name": "MiniMax M2.7 highspeed (kalt)",
"options": {
"temperature": 0.3
}
}
}
},
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama (lokal)",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"qwen3.6:27b": {
"name": "Qwen3.6 27B"
},
"qwen3.5:9b": {
"name": "Qwen3.5 9B"
}
}
}
},
"agent": {
"full": {
"description": "Alle Tools: Dateien, Bash, Websuche",
"permission": {
"edit": "allow",
"bash": "allow",
"webfetch": "allow"
}
},
"files": {
"description": "Dateien lesen/schreiben + Bash, keine Websuche",
"permission": {
"edit": "allow",
"bash": "allow",
"webfetch": "deny"
},
"tools": {
"minimax-search*": false,
"searxng*": false
}
},
"readonly": {
"description": "Nur Dateien lesen",
"permission": {
"edit": "deny",
"bash": "deny",
"webfetch": "deny"
},
"tools": {
"write": false,
"edit": false,
"bash": false,
"minimax-search*": false,
"searxng*": false
}
},
"text": {
"description": "Reine Textantwort, keine Tools",
"permission": {
"edit": "deny",
"bash": "deny",
"webfetch": "deny"
},
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": false,
"glob": false,
"grep": false,
"minimax-search*": false,
"searxng*": false
}
}
}
}

View File

@@ -11,19 +11,26 @@
}
}
},
"minimax-direkt": {
"minimax-kalt": {
"npm": "@ai-sdk/anthropic",
"name": "MiniMax (ohne Thinking)",
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
"options": {
"baseURL": "https://api.minimax.io/anthropic/v1",
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3 (ohne Thinking)",
"name": "MiniMax M3 (kalt)",
"options": {
"temperature": 0.2,
"thinking": { "type": "disabled" }
}
},
"MiniMax-M2.7-highspeed": {
"name": "MiniMax M2.7 highspeed (kalt)",
"options": {
"temperature": 0.3
}
}
}
},

View File

@@ -4,14 +4,20 @@ services:
context: .
container_name: creator
restart: unless-stopped
environment:
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
# komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc.
# still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil)
env_file: .env
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
networks:
- web
volumes:
- ./storage:/app/storage
- ./projects:/app/projects
- ./uni:/app/uni
- ./.claude-data:/home/app/.claude
labels:
- "traefik.enable=true"

View File

@@ -10,6 +10,7 @@
"dependencies": {
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"vue": "^3.5.32"
@@ -1187,6 +1188,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/commander": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1468,6 +1478,22 @@
"node": ">=6"
}
},
"node_modules/katex": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",

View File

@@ -11,6 +11,7 @@
"dependencies": {
"dompurify": "^3.4.7",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
"marked": "^18.0.4",
"marked-highlight": "^2.2.4",
"vue": "^3.5.32"

View File

@@ -1,45 +1,55 @@
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt, fetchGuideLocks } from './api.js'
import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
import BlocksOverview from './components/BlocksOverview.vue'
import GenerationView from './components/GenerationView.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
import PracticePanel from './components/PracticePanel.vue'
const guides = ref([])
const projects = ref([])
const backendTopics = ref([])
const selectedTopic = ref(null)
const previewGuide = ref(null)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false)
const focusOpen = ref(false) // block fullscreen active → lift sidebar as overlay above the focus overlay
const darkMode = ref(
localStorage.getItem('darkMode') === null
? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true',
)
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [] }
const bausteine = ref({ ...EMPTY_BAUSTEINE })
const activeBausteine = ref([])
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
const blocks = ref({ ...EMPTY_BLOCKS })
const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | generation | general | practice | detail — exclusive main-area view
const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const _storedLevel = localStorage.getItem('level')
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
const stats = ref(null)
const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
const elementsOpen = ref(false) // rechte Sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
const elementOpenTick = ref(0)
const progress = ref({})
const uiError = ref(null) // surface rejected actions (409/400)
// Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) {
try { return await fn() } catch (e) { console.error(label, e) }
}
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
async function withUiError(fn) {
uiError.value = null
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
}
async function loadStats() {
try {
stats.value = await fetchStats()
} catch (e) {
console.error('Fehler beim Laden der Statistik:', e)
}
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
}
function setProvider(id) {
@@ -47,17 +57,22 @@ function setProvider(id) {
localStorage.setItem('provider', id)
}
async function loadFolders() {
await guard('Failed to load folders:', async () => {
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
folders.value = { projekt, uni }
})
}
async function loadProviders() {
try {
await guard('Failed to load providers:', async () => {
providers.value = await fetchProviders()
const current = providers.value.find((p) => p.id === provider.value)
if (current && !current.available) {
const fallback = providers.value.find((p) => p.available)
if (fallback) setProvider(fallback.id)
}
} catch (e) {
console.error('Fehler beim Laden der Provider:', e)
}
})
}
function applyTheme() {
@@ -86,12 +101,7 @@ function onSidebarLeave() {
if (!sidebarPinned.value) sidebarSticky.value = false
}
const projectNames = computed(() => projects.value.map((p) => p.name))
const topics = computed(() => {
const isProject = new Set(projectNames.value)
return backendTopics.value.filter((t) => !isProject.has(t))
})
const topics = computed(() => backendTopics.value)
const doneByFormat = computed(() => {
const map = {}
@@ -124,12 +134,12 @@ async function loadTopics() {
try {
backendTopics.value = await fetchTopics()
} catch (e) {
console.error('Fehler beim Laden der Themen:', e)
console.error('Failed to load topics:', e)
}
}
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
// Dismissed errors stay dismissed — even across reloads (localStorage).
// Errors that aren't dismissed stay visible until the user closes them.
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
function persistDismissed() {
@@ -144,7 +154,7 @@ function handleDismissError(guideId) {
async function loadGuides() {
try {
guides.value = await fetchGuides()
// IDs prunen, deren Guide nicht mehr als Fehler existiert
// Prune IDs whose guide no longer exists as an error
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
@@ -152,107 +162,158 @@ async function loadGuides() {
}
loadStats()
} catch (e) {
console.error('Fehler beim Laden:', e)
console.error('Failed to load:', e)
}
}
async function loadBausteine() {
async function loadBlocks() {
try {
activeBausteine.value = await fetchActiveBausteine()
activeBlocks.value = await fetchActiveBlocks()
if (selectedTopic.value) {
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value)
blocks.value = await fetchBlocksStatus(selectedTopic.value)
progress.value = await fetchTopicProgress(selectedTopic.value)
} else {
bausteine.value = { ...EMPTY_BAUSTEINE }
fortschritt.value = {}
locks.value = {}
blocks.value = { ...EMPTY_BLOCKS }
progress.value = {}
}
if (activeBausteine.value.length && !polling.running()) startPolling()
if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) {
console.error('Fehler beim Laden der Bausteine:', e)
console.error('Failed to load blocks:', e)
}
}
async function loadProjects() {
try {
projects.value = await fetchProjects()
} catch (e) {
console.error('Fehler beim Laden der Projekte:', e)
}
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
const autoInventory = ref(true)
const autoArtefacts = ref(true)
const autoGuide = ref(true)
function ladeAuto(topic) {
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
autoInventory.value = g('inventory')
autoArtefacts.value = g('artefacts')
autoGuide.value = g('guide')
}
const FORMAT_ORDER = ['OnePager', 'MiniGuide', 'Guide', 'FullGuide']
function autoPreview() {
const map = doneByFormat.value
for (const f of FORMAT_ORDER) {
if (map[f]) {
previewGuide.value = map[f]
return
}
}
previewGuide.value = null
function handleSetAuto({ ebene, value }) {
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
if (!r) return
r.value = value
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
}
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
elementsOpen.value = false
elementsView.value = false
elementOpenId.value = null
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
ladeAuto(topic)
localStorage.setItem('lastTopic', topic)
loadBausteine()
nextTick(autoPreview)
loadBlocks()
}
// Beim Reload dort landen, wo man vorher war (Thema + Format)
// On reload, land where you were before (topic + format)
watch(previewGuide, (g) => {
if (g) localStorage.setItem('lastFormat', g.format)
})
async function createTopic(topic) {
await apiCreateTopic(topic)
await loadTopics()
selectedTopic.value = topic
previewGuide.value = null
loadBausteine()
}
async function handleCancelBausteine() {
async function handleCancelBlocks() {
if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value)
await loadBausteine()
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleResetBausteine() {
async function handleResetBlocks() {
if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value)
await loadBausteine()
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleBausteineClick({ instructions }) {
async function handleAddResearch() {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
await apiAddResearch(selectedTopic.value, provider.value)
} catch (e) {
uiError.value = e.message
return
}
await loadBausteine()
await loadBlocks()
startPolling()
}
async function handleFormatClick({ format, instructions }) {
async function handleRequeueDead() {
if (!selectedTopic.value) return
// Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren
const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
})
if (!ok) return
await loadBlocks()
startPolling()
}
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
startPolling()
}
async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
uiError.value = null
try {
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
} catch (e) {
uiError.value = e.message
return
}
await loadTopics()
selectTopic(topic)
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
startPolling()
}
async function handleUpdateSource({ topic, type, ort, spec }) {
uiError.value = null
try {
await apiUpdateQuelle(topic, { type, ort, spec })
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks() // the step display may follow the new source (e.g. link → "load source")
}
function setView(modus) {
viewMode.value = modus
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
}
function setLevel(k) {
levelView.value = k
localStorage.setItem('level', String(k))
}
function handleOpenBlocksView() {
if (!selectedTopic.value) return
mainView.value = 'blocks'
previewGuide.value = null
}
async function handleFormatClick({ format, instructions = '' }) {
if (!selectedTopic.value) return
// No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some(
(g) => g.topic === selectedTopic.value && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running) return
if (running) { handleOpenGuideBoard(format); return }
uiError.value = null
try {
await apiCreate(selectedTopic.value, format, instructions, provider.value)
@@ -260,38 +321,65 @@ async function handleFormatClick({ format, instructions }) {
uiError.value = e.message
return
}
handleOpenGuideBoard(format) // Start → direkt aufs Live-Board
await loadGuides()
startPolling()
}
async function handleDeleteProject(name) {
await apiDeleteProject(name)
if (selectedTopic.value === name) {
selectedTopic.value = null
previewGuide.value = null
function handleOpenGuideBoard(format = 'Guide') {
if (!selectedTopic.value) return
guideBoardFormat.value = format
mainView.value = 'generation'
previewGuide.value = null
}
function handleOpenGeneration() {
if (!selectedTopic.value) return
mainView.value = 'generation'
previewGuide.value = null
}
async function handleRemoveGuideFormat(format) {
uiError.value = null
try {
await apiRemoveGuideFormat(selectedTopic.value, format)
} catch (e) {
uiError.value = e.message
return
}
await loadProjects()
await loadGuides()
}
function handleGuideBoardPreview() {
const g = doneByFormat.value[guideBoardFormat.value]
if (g) handlePreview(g)
}
function handlePreview(guide) {
previewGuide.value = guide
elementsView.value = false
mainView.value = 'detail'
}
function handleOpenElements() {
function handleGeneralExam() {
if (!selectedTopic.value) return
elementsView.value = true
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
mainView.value = 'general'
previewGuide.value = null
}
function handleOpenElementDetail(el) {
elementOpenId.value = el.id
elementOpenTick.value++
elementsOpen.value = true
function handlePractice() {
if (!selectedTopic.value) return
mainView.value = 'practice'
previewGuide.value = null
}
async function handleDeleteGuide(guideId, slots = false) {
await deleteGuide(guideId, slots)
uiError.value = null
try {
await deleteGuide(guideId, slots)
} catch (e) {
uiError.value = e.message
return
}
if (previewGuide.value?.id === guideId) {
previewGuide.value = null
}
@@ -299,23 +387,18 @@ async function handleDeleteGuide(guideId, slots = false) {
}
const polling = usePolling(
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
() => hasActiveGuides.value || activeBausteine.value.length > 0,
() => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
() => hasActiveGuides.value || activeBlocks.value.length > 0,
)
const startPolling = polling.start
async function handleCancel(guideId) {
await apiCancel(guideId)
await loadGuides()
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
}
async function handleDeleteTopic(topic) {
const topicGuides = guides.value.filter((g) => g.topic === topic)
for (const g of topicGuides) {
await deleteGuide(g.id)
}
await apiDeleteBausteine(topic)
await apiDeleteTopic(topic)
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
if (!await withUiError(() => apiDeleteTopic(topic))) return
await loadTopics()
if (selectedTopic.value === topic) {
selectedTopic.value = null
@@ -325,14 +408,10 @@ async function handleDeleteTopic(topic) {
}
onMounted(async () => {
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
const savedTopic = localStorage.getItem('lastTopic')
const savedFormat = localStorage.getItem('lastFormat')
if (savedTopic && [...topics.value, ...projectNames.value].includes(savedTopic)) {
selectTopic(savedTopic)
await nextTick()
const g = doneByFormat.value[savedFormat]
if (g) previewGuide.value = g
if (savedTopic && topics.value.includes(savedTopic)) {
selectTopic(savedTopic) // lands on the blocks overview
} else if (!selectedTopic.value && topics.value.length) {
selectTopic(topics.value[0])
}
@@ -340,78 +419,110 @@ onMounted(async () => {
</script>
<template>
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky }">
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': focusOpen && sidebarSticky }">
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
<div v-if="!sidebarPinned && sidebarSticky" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
<TopicSidebar
:topics="topics"
:projects="projectNames"
:selectedTopic="selectedTopic"
:stats="stats"
:fortschritt="fortschritt"
:locks="locks"
:fortschritt="progress"
:uiError="uiError"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
:allGuides="guides"
:dismissedErrors="dismissedErrors"
:bausteine="bausteine"
:activeBausteine="activeBausteine"
:blocks="blocks"
:activeBausteine="activeBlocks"
:pinned="sidebarPinned"
:dark="darkMode"
:provider="provider"
:providers="providers"
:folders="folders"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@setProvider="setProvider"
@toggleDark="toggleDark"
@setAnsicht="setView"
@setStufe="setLevel"
@generalExam="handleGeneralExam"
@practice="handlePractice"
@select="selectTopic"
@create="createTopic"
@formatClick="handleFormatClick"
@bausteineClick="handleBausteineClick"
@cancelBausteine="handleCancelBausteine"
@resetBausteine="handleResetBausteine"
@createThema="handleCreateTopic"
@updateSource="handleUpdateSource"
@openBausteineView="handleOpenBlocksView"
@openGuideBoard="handleOpenGuideBoard"
@bausteineClick="handleBlocksClick"
@deleteTopic="handleDeleteTopic"
@deleteProject="handleDeleteProject"
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError"
@dismissUiError="uiError = null"
@preview="handlePreview"
@openElements="handleOpenElements"
@openGeneration="handleOpenGeneration"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
<ElementsOverview
v-if="selectedTopic && elementsView"
<BlocksOverview
v-if="selectedTopic && mainView === 'blocks'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
@close="mainView = 'detail'"
@openGeneration="handleOpenGeneration"
/>
<GenerationView
v-else-if="selectedTopic && mainView === 'generation'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
:error="blocks.error"
:guideFormat="guideBoardFormat"
:autoInventory="autoInventory"
:autoArtefacts="autoArtefacts"
:autoGuide="autoGuide"
@close="mainView = 'blocks'"
@restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
@addResearch="handleAddResearch"
@requeueDead="handleRequeueDead"
@removeAll="handleResetBlocks"
@cancel="handleCancelBlocks"
@cancelGuide="handleCancel"
@startGuide="handleFormatClick"
@preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat"
@setAuto="handleSetAuto"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"
:topic="selectedTopic"
:provider="provider"
@progressChanged="loadStats(); loadBlocks()"
@fokus-active="focusOpen = $event"
/>
<PracticePanel
v-else-if="selectedTopic && mainView === 'practice'"
:key="selectedTopic"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
/>
<TopicDetail
v-else-if="selectedTopic"
:previewGuide="previewGuide"
:dark="darkMode"
:provider="provider"
:elementsOpen="elementsOpen"
@progressChanged="loadStats(); loadBausteine()"
@openElements="elementsOpen = true"
:themaAbgeschlossen="!!progress.completed"
:ansichtModus="viewMode"
:stufeAnsicht="levelView"
@progressChanged="loadStats(); loadBlocks()"
@setAnsicht="setView"
@open-sidebar="sidebarSticky = true"
@fokus-active="focusOpen = $event"
/>
<div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p>
<p>Create or select a topic in the sidebar.</p>
</div>
<div
v-if="elementsOpen && selectedTopic"
class="elements-backdrop"
@click="elementsOpen = false"
></div>
<ElementsSidebar
v-if="elementsOpen && selectedTopic"
:topic="selectedTopic"
:provider="provider"
:openId="elementOpenId"
:openTick="elementOpenTick"
@close="elementsOpen = false"
@changed="elementsVersion++"
/>
</div>
</template>
@@ -435,6 +546,11 @@ onMounted(async () => {
--success-soft: #d1fae5;
--success-soft-hover: #a7f3d0;
--success-border: #34d399;
/* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
--level-beginner: #22c55e;
--level-advanced: #3b82f6;
--level-expert: #8b5cf6;
--level-master: #d4af37;
--warning: #92400e;
--warning-soft: #fef3c7;
--warning-border: #fbbf24;
@@ -510,8 +626,8 @@ textarea::placeholder {
cursor: pointer;
}
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar.
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */
/* Invisible surface behind the open floating sidebar.
A tap/click next to it closes it — without it there's no way out on touch. */
.sidebar-backdrop {
position: fixed;
inset: 0;
@@ -536,6 +652,21 @@ textarea::placeholder {
transform: translateX(0);
}
/* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
Applies for pinned AND floating. */
.layout.sidebar-over-fokus > .sidebar {
position: fixed;
left: 0;
top: 0;
height: 100dvh;
transform: translateX(0);
z-index: 50;
box-shadow: 0 0 16px var(--shadow);
}
.layout.sidebar-over-fokus .sidebar-backdrop {
z-index: 49;
}
.empty-main {
flex: 1;
display: flex;
@@ -545,19 +676,4 @@ textarea::placeholder {
font-size: 1rem;
}
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt.
Tipp daneben schließt sie. */
.elements-backdrop {
display: none;
}
@media (max-width: 768px) {
.elements-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 29;
background: var(--shadow);
}
}
</style>

View File

@@ -1,204 +1,159 @@
const BASE = '/api'
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
async function jsonOrThrow(res) {
function qs(query) {
if (!query) return ''
const p = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) p.set(k, v)
}
const s = p.toString()
return s ? `?${s}` : ''
}
// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu
// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln.
async function req(path, { method = 'GET', body, query } = {}) {
const opts = { method }
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' }
opts.body = JSON.stringify(body)
}
const res = await fetch(`${BASE}${path}${qs(query)}`, opts)
if (!res.ok) {
let detail = `Fehler (HTTP ${res.status})`
try {
const data = await res.json()
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
} catch { /* kein JSON-Body */ }
throw new Error(detail)
const err = new Error(detail)
err.status = res.status
throw err
}
return res.json()
const text = await res.text() // DELETE/manche POSTs liefern keinen Body
return text ? JSON.parse(text) : null
}
export async function fetchGuides() {
const res = await fetch(`${BASE}/guides`)
return res.json()
}
export const fetchGuides = () => req('/guides')
export async function fetchGuideLocks(topic) {
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
export async function createGuide(topic, format, instructions = '', provider = 'claude') {
const res = await fetch(`${BASE}/guides`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, instructions, provider }),
})
return jsonOrThrow(res)
}
export const fetchActiveBlocks = () => req('/blocks/active')
export async function fetchActiveBausteine() {
const res = await fetch(`${BASE}/bausteine/active`)
return res.json()
}
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
export async function fetchBausteineStatus(topic) {
const res = await fetch(`${BASE}/bausteine/status?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}, onlyArtefacts = false) =>
req('/blocks', { method: 'POST', body: {
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
only_artefacts: onlyArtefacts,
} })
export async function createBausteine(topic, instructions = '', provider = 'claude') {
const res = await fetch(`${BASE}/bausteine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider }),
})
return jsonOrThrow(res)
}
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
export async function cancelBausteine(topic) {
await fetch(`${BASE}/bausteine/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
export const runQa = (topic, llm = true, ebene = null) => req('/blocks/qa', { method: 'POST', body: { topic, llm, ebene } })
export async function deleteBausteine(topic) {
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
}
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
export async function fetchTopicFortschritt(topic) {
const res = await fetch(`${BASE}/topics/fortschritt?topic=${encodeURIComponent(topic)}`)
return res.json()
}
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
export async function fetchStats() {
const res = await fetch(`${BASE}/stats`)
return res.json()
}
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
export const addBlocksResearch = (topic, provider = 'claude') =>
req('/blocks/research', { method: 'POST', query: { topic, provider } })
export async function fetchProviders() {
const res = await fetch(`${BASE}/providers`)
return res.json()
}
export const removeGuideFormat = (topic, format) =>
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
export async function fetchProjects() {
const res = await fetch(`${BASE}/projects`)
return res.json()
}
export const requeueBlocksDead = (topic) =>
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
export async function deleteProject(name) {
await fetch(`${BASE}/projects/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
// Live-Board der Guide-Erzeugung.
export const fetchGuideBoard = (topic, format = 'Guide') =>
req('/guides/board', { query: { topic, format } })
export async function cancelGuide(id) {
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
}
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export const repairGuideBoard = (topic, format) =>
req('/guides/board/repair', { method: 'POST', body: { topic, format } })
export async function deleteGuide(id, slots = false) {
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
}
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
export async function fetchGuideContent(id) {
const res = await fetch(`${BASE}/guides/${id}/content`)
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
return res.json()
}
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
export async function fetchTopics() {
const res = await fetch(`${BASE}/topics`)
return res.json()
}
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
export async function createTopic(name) {
await fetch(`${BASE}/topics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
}
// --- Block-Learning: Chat, Exam ---
export async function deleteTopic(name) {
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
export async function fetchProgress(id) {
const res = await fetch(`${BASE}/guides/${id}/progress`)
return res.json()
}
export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
export async function setProgress(id, chapter, done) {
const res = await fetch(`${BASE}/guides/${id}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chapter, done }),
})
return res.json()
}
export const examBlock = ({
topic, block, section, section_compact = '', provider,
action = 'question', question = '', last_rating = '', avoid = [],
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
}) => req('/blocks/exam', {
method: 'POST',
body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer },
})
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
const res = await fetch(`${BASE}/guides/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, outline, messages, provider }),
})
return res.json()
}
export const fetchQuestionPattern = (topic, block) =>
req('/blocks/question-pattern', { query: { topic, block } })
export async function fetchElements(topic) {
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
export async function createElement(topic, hint = '', provider = 'claude') {
const res = await fetch(`${BASE}/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, hint, provider }),
})
return res.json()
}
export const fetchStats = () => req('/stats')
export async function chatElement(id, messages, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, provider }),
})
return res.json()
}
export const fetchProviders = () => req('/providers')
export async function deleteElement(id) {
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
}
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
export async function updateElement(id, fields) {
const res = await fetch(`${BASE}/elements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
return res.json()
}
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
export async function styleElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/style`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/refine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestion, instruction, provider }),
})
if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`)
return res.json()
}
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
export async function checkElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
export const deleteGuide = (id, slots = false) =>
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
// Leitner-Schritt buchen (correct = „Gewusst").
export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
// Reset a block's learning progress to zero (score/streak/flags/open question).
export const resetBlockProgress = (topic, block) =>
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
export const fetchTopics = () => req('/topics')
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })

View File

@@ -7,6 +7,24 @@
margin: 0 0 0.5em;
}
/* KaTeX: lange Block-Formeln scrollen statt das Layout zu sprengen.
Sichtbarer dünner Scrollbalken (statt unsichtbarem Overlay) → man erkennt,
dass die Formel weitergeht und nicht abgeschnitten ist. */
.markdown .katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 0.2em 0 0.45em;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.markdown .katex-display::-webkit-scrollbar {
height: 6px;
}
.markdown .katex-display::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.markdown p:last-child {
margin-bottom: 0;
}
@@ -71,3 +89,48 @@
border: 1px solid var(--border-strong);
padding: 2px 6px;
}
/* Stufen-Segmente (Vollansicht): dezenter Rand + A/F/E-Badge. Global, weil TopicDetail
das Markup per v-html injiziert (scoped-Styles greifen dort nicht). */
.sub-stufe {
position: relative;
border-left: 3px solid color-mix(in srgb, var(--stufe-farbe) 55%, transparent);
background: color-mix(in srgb, var(--stufe-farbe) 8%, transparent);
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-stufe-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--stufe-farbe);
border: 1px solid color-mix(in srgb, var(--stufe-farbe) 60%, transparent);
border-radius: 4px;
padding: 0 4px;
opacity: 0.55;
}
/* Neu freigeschaltete Subbausteine (Auto-Stufe): kräftigerer Rand + Stufen-Badge */
.sub-neu {
position: relative;
border-left: 3px solid var(--neu-farbe);
background: color-mix(in srgb, var(--neu-farbe) 12%, transparent);
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-neu-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--neu-farbe);
border: 1px solid var(--neu-farbe);
border-radius: 4px;
padding: 0 4px;
opacity: 0.8;
}

View File

@@ -0,0 +1,7 @@
/* Geteilte, komponentenübergreifende Stile (global, NICHT scoped). */
/* Live-Puls für „läuft"-Indikatoren — vorher als bk-/gb-/gen-/gen-side-pulse 4× kopiert. */
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}

View File

@@ -0,0 +1,430 @@
<script setup>
import BlockPanel from './BlockPanel.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer, SUB_RANK, VIEW_KURZ, VIEW_FARBE } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
import { clearPruef } from '../pruefungCache.js'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
block: { type: Object, required: true }, // { title, md, num }
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null },
cap: { type: Number, default: 6 },
guideId: { type: String, default: '' },
tab: { type: String, default: 'exam' },
ansicht: { type: String, default: 'compact' }, // compact | erklärend — left guide column
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
stufe: { type: Number, default: 4 }, // höchste sichtbare Stufe (1=A … 4=V) — wie die Standardansicht
})
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht', 'openSidebar', 'sectionUpdated'])
// --- Reset per block: questions session (slot) resp. progress (score) ---
const { isArmed, armOrRun } = useConfirm()
const resetN = ref(0) // increment → panel remount (fresh slot, patterns reset)
const examKey = computed(() => `${props.topic}::${props.block.title}`)
// New question session: discard slot → remount loads patterns + pool fresh (fixes explain-only).
function resetQuestions() {
clearPruef(examKey.value)
resetN.value++
}
// Progress to 0: delete DB row, zero badge/learn state, fresh session.
async function resetProgress() {
try {
await resetBlockProgress(props.topic, props.block.title)
emit('statusChanged', { block: props.block.title, good_answers: 0, streak: 0, cap: props.cap })
clearPruef(examKey.value)
resetN.value++
} catch (e) { /* stay quiet — reset failed */ }
}
const guideEl = ref(null) // left guide column (scroll target for ALT+↑/↓)
const rightEl = ref(null) // right column (exam panel with input field)
// ALT is the modifier of the focus view. Plain arrows stay browser-default.
// ALT+←/→ pages blocks, ALT+↑/↓ scrolls the guide. A bare ALT tap
// (press+release without another key) toggles focus on the input field.
let altAlone = false
function onKeyDown(e) {
if (e.key === 'Alt') { altAlone = true; e.preventDefault(); return } // suppresses the Firefox menu bar
if (e.ctrlKey || e.metaKey || !e.altKey) return // plain/other → normal
altAlone = false // ALT+anything = no lone tap
if (e.key === 'ArrowLeft') { e.preventDefault(); if (!e.repeat && props.hasPrev) emit('prev') }
else if (e.key === 'ArrowRight') { e.preventDefault(); if (!e.repeat && props.hasNext) emit('next') }
else if (e.key === 'ArrowUp') { e.preventDefault(); guideEl.value?.scrollBy(0, -120) }
else if (e.key === 'ArrowDown') { e.preventDefault(); guideEl.value?.scrollBy(0, 120) }
}
function onKeyUp(e) {
if (e.key === 'Alt') { e.preventDefault(); if (altAlone) { altAlone = false; toggleInput() } }
}
function resetAlt() { altAlone = false } // window blur (ALT+Tab) → no false tap
function toggleInput() {
// Answer field of the current form: explain = textarea, free gap-text = input.
const el = rightEl.value?.querySelector('textarea, input')
if (!el) return
document.activeElement === el ? el.blur() : el.focus()
}
onMounted(() => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', resetAlt)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', resetAlt)
})
const pct = (n) => (100 * n / (props.fortschritt.total || 1)) + '%'
// Accent color by the current block's level (green/blue/purple/gold) or neutral.
const levelColor = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)?.farbe || 'var(--border)'
})
const level = computed(() => {
const s = props.status || {}
return stufeFuer(s.good_answers || 0, s.cap || props.cap)
})
// --- Check block (right-click on a section) ---
const displayedText = computed(() =>
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
)
// Stufen-Segmente: mit subs wird je Sub separat gerendert (Rand+Badge in Stufen-Farbe),
// ohne subs (Legacy) bleibt der flache Pfad. Globaler Block-Index läuft über alle
// Segmente durch — Check/Vorschläge arbeiten unverändert über Index + raw.
const segments = computed(() => {
const compact = props.ansicht === 'compact'
const subs = props.block.subs
let i = 0
const seg = (level, text) => ({ level, blocks: renderBlocks(text).map((b) => ({ ...b, i: i++ })) })
if (!subs || !subs.length) return [seg('', displayedText.value)]
const out = []
const anchor = compact ? (props.block.anker_compact || '') : (props.block.anchor || '')
if (anchor.trim()) out.push(seg('', anchor))
for (const sub of subs) {
const rank = SUB_RANK[sub.level] || 1
if (rank > props.stufe) continue // Stufen-Filter (wie die Standardansicht)
const body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
if (body && body.trim()) out.push(seg(sub.level, body)) // jede Stufe A/F/E/V markiert (Filter bleibt)
}
return out
})
const blocks = computed(() => segments.value.flatMap((s) => s.blocks))
// Field that edits are applied to (must match the displayed text).
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
const menu = reactive({ show: false, x: 0, y: 0, index: null })
function blockMenu(i, e) { menu.show = true; menu.x = e.clientX; menu.y = e.clientY; menu.index = i }
function closeMenu() { menu.show = false }
// Suggestion per block index: { new, running, error, editOpen, hint }
const suggestions = reactive({})
async function checkBlock(i, extra = '') {
const raw = blocks.value[i]?.raw
if (!raw || !props.guideId) return
closeMenu()
suggestions[i] = { revised: '', running: true, error: '', editOpen: false, hint: '' }
try {
const res = await pruefeBlock(props.guideId, { block: props.block.title, spot: spot.value, snippet: raw, hint: extra, provider: props.provider })
suggestions[i] = { revised: res.revised, running: false, error: '', editOpen: false, hint: '' }
} catch (e) {
suggestions[i] = { revised: '', running: false, error: e.message || 'Exam failed', editOpen: false, hint: '' }
}
}
async function applyBlock(i) {
const v = suggestions[i]; const raw = blocks.value[i]?.raw
if (!v || v.running || !raw) return
v.running = true; v.error = ''
try {
const res = await uebernehmeBlock(props.guideId, { block: props.block.title, spot: spot.value, alt: raw, revised: v.revised, provider: props.provider })
if (res.found) emit('sectionUpdated', { title: props.block.title, compact: res.compact, md: res.md })
else { v.running = false; v.error = 'Spot not found — may have already changed.' }
} catch (e) { v.running = false; v.error = e.message || 'Apply failed' }
}
function discardBlock(i) { delete suggestions[i] }
function editBlock(i) { const v = suggestions[i]; if (v) v.editOpen = !v.editOpen }
function sendBlockEdit(i) {
const z = (suggestions[i]?.hint || '').trim()
if (z) checkBlock(i, z)
}
// Block/content switch → reset suggestions + menu (block indices change).
watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}`, () => {
for (const k of Object.keys(suggestions)) delete suggestions[k]
closeMenu()
})
</script>
<template>
<div class="fokus-overlay" :style="{ '--stand': levelColor }">
<div class="fokus-bar">
<div class="fokus-bar-inner">
<button class="fokus-btn" title="Open navigation" @click="$emit('openSidebar')"></button>
<button class="fokus-btn" :disabled="!hasPrev" title="Previous block" @click="$emit('prev')"></button>
<button class="fokus-btn" :disabled="!hasNext" title="Next block" @click="$emit('next')"></button>
<span class="fokus-title">{{ block.title }}</span>
<span v-if="level" class="stand-badge" :style="{ color: level.farbe, borderColor: level.farbe }">{{ level.kurz }} {{ level.label }}</span>
<span class="fokus-spacer"></span>
<button class="fokus-btn" title="Reset questions (new question session)" @click="resetQuestions"></button>
<button
class="fokus-btn"
:class="{ armed: isArmed('reset-fortschritt') }"
:title="isArmed('reset-fortschritt') ? 'Click again: set progress to 0' : 'Reset progress (score to 0)'"
@click="armOrRun('reset-fortschritt', resetProgress)"
></button>
<button class="fokus-btn" title="Exit full view" @click="$emit('close')"></button>
</div>
</div>
<div class="fokus-xp" :title="`${fortschritt.beginner}/${fortschritt.total} from Beginner · ${fortschritt.advanced} Advanced · ${fortschritt.expert} Expert · ${fortschritt.master} Master`">
<div class="xp-seg" :style="{ width: pct(fortschritt.master), background: 'var(--level-master)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.expert - fortschritt.master), background: 'var(--level-expert)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.advanced - fortschritt.expert), background: 'var(--level-advanced)' }"></div>
<div class="xp-seg" :style="{ width: pct(fortschritt.beginner - fortschritt.advanced), background: 'var(--level-beginner)' }"></div>
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown">
<template v-for="(seg, si) in segments" :key="si">
<div :class="{ 'sub-stufe': !!seg.level }"
:style="seg.level ? { '--stufe-farbe': VIEW_FARBE[SUB_RANK[seg.level] || 1] } : null">
<template v-for="b in seg.blocks" :key="b.i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(b.i, $event)"></div>
<div v-if="suggestions[b.i]" class="block-vorschlag">
<div v-if="suggestions[b.i].running" class="bv-status">Check Section</div>
<template v-else>
<p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Übernehmen" @click="applyBlock(b.i)"></button>
<button class="bv-btn" title="Verwerfen" @click="discardBlock(b.i)"></button>
<button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)"></button>
</div>
<div v-if="suggestions[b.i].editOpen" class="bv-edit">
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Zusatzinfo → erneut prüfen" @keyup.enter="sendBlockEdit(b.i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)"></button>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>
<button class="bm-item" @click="checkBlock(menu.index)">Check</button>
</div>
</div>
<div ref="rightEl" class="fokus-col right">
<BlockPanel
mode="full"
:key="block.title + '|' + tab + '|' + resetN"
:initial-tab="tab"
:topic="topic"
:block="block.title"
:section="block.md"
:section-compact="block.compact || ''"
:provider="provider"
:status="status"
:cap="cap"
:ansicht="ansicht"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="$emit('statusChanged', $event)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.fokus-overlay {
position: fixed;
inset: 0;
z-index: 40;
/* Semi-transparent + blur: the guide list behind shows blurred through the gray surfaces. */
background: color-mix(in srgb, var(--bg-preview) 60%, transparent);
backdrop-filter: blur(10px);
display: grid;
grid-template-rows: auto auto 1fr; /* header / XP bar / body */
}
.fokus-bar {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
/* Header content aligns with the body (same max-width + inner padding). */
.fokus-bar-inner {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
padding-inline: 1.25rem;
}
.fokus-spacer { flex: 1; }
.stand-badge {
margin-left: 0.5rem;
padding: 0.12rem 0.6rem;
font-size: 0.72rem; font-weight: 600;
border-radius: 999px; border: 1px solid; white-space: nowrap;
}
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
.fokus-xp::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to right,
transparent 0,
transparent calc(10% - 2px),
var(--panel) calc(10% - 2px),
var(--panel) 10%
);
}
.xp-seg { height: 100%; transition: width 0.3s ease; }
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
.fokus-btn {
display: inline-flex; align-items: center; justify-content: center;
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 1rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.fokus-btn:hover { border-color: var(--accent); }
.fokus-btn:disabled { opacity: 0.4; cursor: default; }
.fokus-btn.armed { border-color: var(--danger, #dc2626); color: var(--danger, #dc2626); background: color-mix(in srgb, var(--danger, #dc2626) 12%, var(--panel)); }
/* Two raised cards on a gray "desk" (--bg-preview from the overlay). */
.fokus-body {
min-height: 0;
display: flex;
gap: 1.25rem;
padding: 1.25rem;
max-width: 1600px;
width: 100%;
margin-inline: auto;
}
.fokus-col {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
/* Card tilts by block level: colored border + tinted background (green/purple/gold). */
background: color-mix(in srgb, var(--stand) 7%, var(--panel));
border: 1px solid var(--stand);
border-top: 3px solid var(--stand);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
/* Left: wide reading card, text centered at reading width. */
.fokus-col.left { flex: 1; padding: 2rem 2.5rem; }
.fokus-col.left > * { max-width: 74ch; margin-inline: auto; }
/* Right: work card; the panel inside is borderless (the card is the frame). */
.fokus-col.right { flex: none; width: clamp(440px, 34%, 600px); padding: 1.5rem; }
.fokus-col.right :deep(.bp) { margin-top: 0; }
.fokus-col.right :deep(.bp-panel) { border: none; background: transparent; padding: 0; }
.fokus-h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
/* Desktop: the right card as a flex column — exam/chat history fills the
full height and scrolls internally, input + buttons stay at the bottom. */
@media (min-width: 901px) {
.fokus-col.right { display: flex; flex-direction: column; overflow: hidden; }
.fokus-col.right :deep(.bp) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-panel) { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; }
.fokus-col.right :deep(.bp-panel > div) { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.fokus-col.right :deep(.bp-messages) { flex: 1; min-height: 0; max-height: none; }
}
@media (max-width: 900px) {
.fokus-body { flex-direction: column; overflow-y: auto; }
.fokus-col { overflow-y: visible; }
.fokus-col.right { width: auto; }
}
/* Check block: right-click menu + suggestion below the section */
/* Keep the spacing on the wrapper — the inner p is now :last-child (margin 0). */
.md-block { border-radius: 6px; transition: background 0.15s; margin-bottom: 0.8em; }
.md-block:last-child { margin-bottom: 0; }
.md-block > :first-child { margin-top: 0; }
.md-block > :last-child { margin-bottom: 0; }
.md-block:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.menu-overlay { position: fixed; inset: 0; z-index: 60; }
.block-menu {
position: fixed;
min-width: 8rem;
padding: 0.25rem;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.bm-item {
display: block;
width: 100%;
padding: 0.4rem 0.7rem;
text-align: left;
font-size: 0.9rem;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.bm-item:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.block-vorschlag {
margin: 0.5rem 0 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--accent);
border-left: 3px solid var(--accent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 6%, var(--panel));
}
.bv-status { font-size: 0.9rem; color: var(--text-soft, #888); }
.bv-fehler { color: var(--danger, #dc2626); font-size: 0.88rem; margin: 0 0 0.5rem; }
.bv-new > :first-child { margin-top: 0; }
.bv-new > :last-child { margin-bottom: 0; }
.bv-aktionen { display: flex; gap: 0.4rem; margin-top: 0.6rem; }
.bv-btn {
min-width: 2rem; height: 2rem; padding: 0 0.5rem;
font-size: 0.95rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bv-btn:hover { border-color: var(--accent); }
.bv-btn.aktiv { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, var(--panel)); }
.bv-btn.ja { border-color: var(--success-border); color: var(--success); }
.bv-edit { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.bv-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,949 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBlock, examBlock, fetchQuestionPattern } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { stufeFuer, malusRegel } from '../levels.js'
import { useChat, istUnten } from '../composables/useChat.js'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({
topic: { type: String, required: true },
block: { type: String, required: true },
section: { type: String, default: '' }, // detailed version
sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
// 'trigger' (list): only tab bar, click opens the fullscreen focus.
// 'full' (focus): tabs + panel content as before.
mode: { type: String, default: 'trigger' },
initialTab: { type: String, default: null },
ansicht: { type: String, default: 'compact' }, // compact | erklärend — controls the left guide column
})
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
// Learning levels relative to the cap (levels.js): green/blue/purple/gold @ 20/40/60/100 %.
// Exam is ALWAYS random from 5 forms (with pattern); cap = 4×relevant subblocks.
// Four question forms, random. No more easy/hard — the difficulty is set by the backend
// via the learner level (addressee role). gapchoice = term choice · gaptext = free.
const MODES = ['quiz', 'gapchoice', 'gaptext', 'erklaeren']
const st = computed(() => props.status || { good_answers: 0, streak: 0, cap: props.cap })
const score = computed(() => st.value.good_answers || 0)
const cap = computed(() => st.value.cap || props.cap)
const scoreDisplay = computed(() => Math.min(score.value, cap.value)) // legacy data may exceed cap
const level = computed(() => stufeFuer(score.value, cap.value)) // object {key,label,kurz,farbe} | null
const atCap = computed(() => score.value >= cap.value)
// With question pattern random from 5 forms; without pattern (old block) only Explain.
const currentBand = computed(() => patternMode.value ? 'zufall' : 'erklaeren')
const inRandom = computed(() => currentBand.value === 'zufall')
const activeMode = computed(() => currentBand.value) // 'zufall' | 'erklaeren'
const activeForm = computed(() => inRandom.value ? 'quiz' : 'erklaeren') // placeholder; drawn object overrides
// Displayed form: follows the active form, freezes while a drawn question is visible.
const displayForm = ref(null)
const shownForm = computed(() => displayForm.value || activeForm.value)
const displayBand = ref(null)
const sectionChange = computed(() => displayBand.value && displayBand.value !== currentBand.value)
// Difficulty of the currently shown widget (from the drawn object).
const currentHard = computed(() => {
if (shownForm.value === 'quiz' && quizCurrent.value) return quizCurrent.value.schwer
if (shownForm.value === 'gaptext' && clozeCurrent.value) return clozeCurrent.value.schwer
return false
})
const modeRule = computed(() => {
const m = malusRegel(score.value, cap.value) // '0' | '1' | '2' | '3' by progress
if (shownForm.value === 'quiz') return currentHard.value ? `x of 4 · +3/${m}` : `1 of 4 · +1/${m}`
if (shownForm.value === 'gaptext') return currentHard.value ? `free text · +3/${m}` : `term from 4 · +1/${m}`
return `+1/+2/+3 · error ${m}`
})
const bandTarget = computed(() => cap.value)
const FORM_NAME = { quiz: 'Quiz', gaptext: 'Cloze', erklaeren: 'Explain' }
// --- Toggle area ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'exam'
// Click on a tab: in the list open the focus, in the focus toggle the tab.
function tabClick(tab) {
if (props.mode === 'trigger') { emit('openFokus', tab); return }
activeTab.value = activeTab.value === tab ? null : tab
}
// --- Block chat (ephemeral) ---
const chat = useChat((msgs) => chatBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
messages: msgs, provider: props.provider,
}))
// --- Exam: guided dialog ---
// Phases: 'idle' (request question) | 'question_offen' (answer/ask) | 'bewertet' (discuss/re-evaluate/continue)
// Durable state lives in a SHARED slot per block (module store): survives
// remounting and a late-arriving question is immediately reactive and visible.
const examKey = `${props.topic}::${props.block}`
const slot = usePruefSlot(examKey)
const examMessages = slot.messages // {role, kind: 'question'|'nachfrage'|'answer'|'feedback'|'discussion'|'fehler', content, rating?}
const examPhase = slot.phase
const currentQuestion = slot.aktuelleFrage // anchors evaluation/discussion
const lastFeedback = slot.letztesFeedback // context for the discussion about an evaluation
const patternSource = slot.musterQuelle // immutable full list of question patterns (empty = fallback)
const patternPool = slot.musterPool // working copy, without replacement; empty → reset
const patternLoaded = slot.musterGeladen
const poolForm = slot.poolForm // form the pool is filled for
const quizCurrent = slot.quizAktuell // running quiz question (widget state)
const clozeCurrent = slot.lueckAktuell // running cloze task (widget state)
// Transient (per instance): input draft, loading spinner.
const examInput = ref('')
const examLoading = ref(false)
const asked_again = ref(false) // the running question used asked_again → gain max +1
const thoroughMsg = ref(null) // feedback bubble whose thorough field is open
const thoroughText = ref('') // optional reason for "check thoroughly"
const examMessagesEl = ref(null)
const examInputEl = ref(null)
const examStick = ref(true) // only auto-scroll when the user is (almost) at the bottom
let examRun = 0
function onExamScroll() {
if (examMessagesEl.value) examStick.value = istUnten(examMessagesEl.value)
}
function applyExam(res) {
// res.streak only set on answer_check (binding, persisted); otherwise leave streak unchanged.
emit('statusChanged', { block: props.block, ...st.value, good_answers: res.good_answers, streak: res.streak ?? st.value.streak, cap: res.cap ?? cap.value })
}
async function examScroll() {
await nextTick()
if (examMessagesEl.value && examStick.value) examMessagesEl.value.scrollTop = examMessagesEl.value.scrollHeight
}
// Only real conversation turns go to the backend; feedback stays a pure UI artifact.
function examDialog() {
return examMessages.value
.filter((m) => m.kind !== 'feedback' && m.kind !== 'fehler')
.map((m) => ({ role: m.role, content: m.content }))
}
async function examSend(payload, onOk) {
const run = ++examRun
examStick.value = true // own action = to the end; scrolling up while waiting sets it back to false
examLoading.value = true
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), ...payload,
})
if (run !== examRun) return
onOk(res)
applyExam(res)
examScroll()
nextTick(() => examInputEl.value?.focus())
} catch {
if (run === examRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
} finally {
if (run === examRun) examLoading.value = false
}
}
// Show question (fresh or queued). Base/streak is tracked by the server per question.
function showQuestion(text) {
currentQuestion.value = text
lastFeedback.value = ''
asked_again.value = false
thoroughMsg.value = null
examMessages.value.push({ role: 'assistant', kind: 'question', content: text })
examPhase.value = 'question_offen'
}
// Dedup: detect already-asked questions (the generator repeats itself on small blocks).
function normQuestion(t) { return (t || '').toLowerCase().replace(/\s+/g, ' ').trim() }
function askedQuestions() {
return new Set(examMessages.value.filter((m) => m.kind === 'question').map((m) => normQuestion(m.content)))
}
// --- Questions pool: TARGET pre-formulated question OBJECTS of the active form (in the slot) ---
// Each object: {form, question, options?|sentence?|solution?|alternatives?}. When the
// active form changes, the pool is cleared and refilled for the new form.
const POOL_TARGET = 5
const pool = slot.pool
const inflight = slot.inflight
const examError = ref('') // error hint for quiz/cloze (no bubble history)
let poolMiss = 0 // fallback only: consecutive duplicates/errors — cap against an infinite loop
// Pattern mode active once loaded and patterns present; otherwise fallback (only Explain).
const patternMode = computed(() => patternLoaded.value && patternSource.value.length > 0)
function objText(o) { return (o && (o.question || o.sentence)) || '' }
// Render cloze sentence: split at "___", parts inline (with $…$ math), gap as a span.
// This keeps the gap and markdown does not touch the underscores.
function clozeSentenceHtml(sentence) {
sentence = String(sentence || '')
const m = sentence.match(/_{3,}/) // first gap (three or more underscores)
if (!m) return renderMarkdownInline(sentence)
// Is the gap INSIDE a $…$ formula (odd number of $ before it)? Then do not split
// (that tears the formula) — instead replace it with a KaTeX line, render the sentence whole.
const inFormula = ((sentence.slice(0, m.index).match(/\$/g) || []).length % 2) === 1
if (inFormula) return renderMarkdownInline(sentence.replace(/_{3,}/, '\\rule{2.5em}{0.4pt}'))
// Gap in text: split + gap span.
return sentence.split(/_{3,}/).map((t) => renderMarkdownInline(t)).join('<span class="bp-luecke">______</span>')
}
function isKnown(o) {
const n = normQuestion(objText(o))
return !n || askedQuestions().has(n) || pool.value.some((f) => normQuestion(objText(f)) === n)
}
// Already-asked + queued questions — fallback mode only (Explain without pattern).
function avoidList() {
const asked = examMessages.value.filter((m) => m.kind === 'question').map((m) => m.content)
return [...asked, ...pool.value.map(objText)]
}
// Load pattern sidecar once per block (slot-cached). Empty → fallback.
async function loadPatterns() {
if (patternLoaded.value) return
try {
const res = await fetchQuestionPattern(props.topic, props.block)
patternSource.value = (res.pattern || []).map((m) => (m.question || '').trim()).filter(Boolean)
patternLoaded.value = true // ONLY on success — otherwise a failed attempt freezes the session to Explain-only
} catch {
patternSource.value = [] // patternLoaded stays false → next turn retries
}
}
function mischen(arr) { // Fisher-Yates
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// Draw a pattern without replacement; pool empty → reset (reshuffle). null = fallback.
function takePattern() {
if (!patternSource.value.length) return null
if (!patternPool.value.length) patternPool.value = mischen(patternSource.value)
return patternPool.value.shift()
}
// Pool key = form + difficulty; in the random band a mixed pool ('zufall').
const poolKey = computed(() => inRandom.value ? 'zufall' : 'erklaeren')
// Mode of the next question: in the random band (score 1930) random from all 5 modes.
function nextMode() {
return inRandom.value ? MODES[Math.floor(Math.random() * MODES.length)] : activeMode.value
}
// Generate a question object of a given mode (from a drawn seed; Explain has a fallback).
function buildSingleQuestion(mode = nextMode()) {
const form = mode === 'quiz' ? 'quiz' : mode.startsWith('gap') ? 'gaptext' : 'erklaeren'
const istLueckFrei = mode === 'gaptext' // free text vs. gapchoice = term from 4
const pattern = takePattern()
const base = {
topic: props.topic, block: props.block, section: props.section,
section_compact: props.sectionCompact, provider: props.provider,
}
if (form === 'quiz' && pattern) {
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
.then((r) => ({ form, schwer: false, question: r.question, options: r.options }))
}
if (form === 'gaptext' && pattern) {
return examBlock({ ...base, action: 'gap_question', pattern, schwer: istLueckFrei })
.then((r) => istLueckFrei
? ({ form, schwer: true, question: r.sentence, sentence: r.sentence, solution: r.solution, alternatives: r.alternatives })
: ({ form, schwer: false, question: r.sentence, sentence: r.sentence, options: r.options }))
}
if (pattern) { // erklaeren from pattern
return examBlock({ ...base, action: 'question', pattern }).then((r) => ({ form: 'erklaeren', question: r.question }))
}
// Fallback (no pattern sidecar): Explain live with an avoid list.
return examBlock({ ...base, action: 'question', messages: examDialog(), avoid: avoidList() })
.then((r) => ({ form: 'erklaeren', question: r.question }))
}
// Clear the pool when form or difficulty changed (band boundary or ALT+E).
function checkPoolForm() {
if (poolForm.value !== poolKey.value) {
pool.value = []
poolForm.value = poolKey.value
poolMiss = 0
}
}
// Fill the pool in PARALLEL: each draws a DISTINCT pattern → no collision.
function fillPool() {
checkPoolForm()
const keyStart = poolKey.value
const missCap = patternMode.value ? Infinity : 3
while (pool.value.length + inflight.value < POOL_TARGET && poolMiss < missCap) {
inflight.value++
buildSingleQuestion()
.then((obj) => {
if (obj && objText(obj) && poolKey.value === keyStart && (patternMode.value || !isKnown(obj))) { pool.value.push(obj); poolMiss = 0 }
else poolMiss++
})
.catch(() => { poolMiss++ })
.finally(() => { inflight.value--; fillPool() })
}
}
// Draw a question from the pool — random, never the fastest.
// First load (pool empty + nothing in progress): generate 5, await ALL, then draw.
async function drawQuestion() {
const firstLoad = !pool.value.length && !inflight.value
fillPool() // start/keep up to 5
examLoading.value = true
try {
const missCap = patternMode.value ? Infinity : 3
if (firstLoad) {
// only at the start: wait until all 5 are done (speed must not decide the form)
while ((inflight.value > 0 || pool.value.length < POOL_TARGET) && poolMiss < missCap) {
if (!inflight.value) fillPool()
await new Promise((r) => setTimeout(r, 80))
}
} else if (!pool.value.length) {
// pool drained (fast user): wait for the first finished one
while (!pool.value.length && (inflight.value > 0 || poolMiss < missCap)) {
if (!inflight.value) fillPool()
await new Promise((r) => setTimeout(r, 80))
}
}
if (!pool.value.length) return null
const i = Math.floor(Math.random() * pool.value.length)
return pool.value.splice(i, 1)[0]
} finally { examLoading.value = false }
}
// Show a drawn object in the matching form (Explain bubble or quiz/cloze widget).
function showQuestionObj(obj) {
examError.value = ''
displayForm.value = obj.form // bind display to the drawn question (freeze the form)
displayBand.value = currentBand.value // band at draw time (before the answer)
if (obj.form === 'quiz') {
quizCurrent.value = { question: obj.question, options: obj.options, schwer: obj.schwer, gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
} else if (obj.form === 'gaptext') {
clozeCurrent.value = { sentence: obj.sentence, schwer: obj.schwer, options: obj.options || null, solution: obj.solution || '', alternatives: obj.alternatives || [], input: '', gewaehlt: [], done: false, points: null, rating: null, feedback: '' }
if (obj.schwer) nextTick(() => document.getElementById('bp-gap-input')?.focus())
} else {
showQuestion(obj.question)
nextTick(() => examInputEl.value?.focus())
}
}
// First / next question: load patterns, roll the form, fetch exactly that form.
async function showNextQuestion() {
if (examLoading.value) return
poolMiss = 0
examError.value = ''
await loadPatterns()
const obj = await drawQuestion() // first load: await all 5; then draw randomly
if (obj) {
showQuestionObj(obj)
examScroll()
} else if (activeForm.value === 'erklaeren') {
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'No question — please try again.' })
} else {
examError.value = 'No question — please try again.'
}
fillPool()
}
const requestQuestion = showNextQuestion
// Milestone exceeded: release the freeze (result was visible) without drawing a new
// question → idle state of the new band ("request question").
function toNextSection() {
displayForm.value = null
displayBand.value = null
quizCurrent.value = null
clozeCurrent.value = null
examMessages.value = [] // discard the old Explain history (new section)
examPhase.value = 'idle'
examError.value = ''
}
// --- Quiz: easy = single select (exactly 1) · hard = multi select. Deterministic. ---
function quizToggle(i) {
const q = quizCurrent.value
if (!q || q.done) return
if (!q.schwer) { q.gewaehlt = q.gewaehlt.includes(i) ? [] : [i]; return }
const idx = q.gewaehlt.indexOf(i)
if (idx >= 0) q.gewaehlt.splice(idx, 1)
else q.gewaehlt.push(i)
}
async function quizAnswer() {
const q = quizCurrent.value
if (!q || q.done || examLoading.value) return
examLoading.value = true
examError.value = ''
try {
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
selection: q.gewaehlt, correct, schwer: q.schwer,
})
q.done = true; q.points = res.points; q.rating = res.rating; q.feedback = res.feedback
applyExam(res)
} catch {
examError.value = 'Didn\'t work — please try again.'
} finally {
examLoading.value = false
}
}
// --- Cloze: easy = term from 4 (single select) · hard = free text ---
function clozeToggle(i) {
const l = clozeCurrent.value
if (!l || l.done || l.schwer) return
l.gewaehlt = l.gewaehlt.includes(i) ? [] : [i]
}
async function clozeAnswer() {
const l = clozeCurrent.value
if (!l || l.done || examLoading.value) return
if (l.schwer ? !l.input.trim() : !l.gewaehlt.length) return
examLoading.value = true
examError.value = ''
try {
const specific = l.schwer
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
})
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
applyExam(res)
} catch {
examError.value = 'Didn\'t work — please try again.'
} finally {
examLoading.value = false
}
}
function askFollowUp() {
const text = examInput.value.trim()
if (!text || examLoading.value) return
asked_again.value = true // help used → gain for this question max +1
examMessages.value.push({ role: 'user', kind: 'nachfrage', content: text })
examInput.value = ''
examSend(
{ action: 'discussion', question: currentQuestion.value, last_rating: lastFeedback.value },
(res) => examMessages.value.push({ role: 'assistant', kind: 'discussion', content: res.reply }),
)
}
let lastFeedbackMsg = null // last shown evaluation bubble
let evalRun = 0 // only the most recent quick evaluation may display
function ratingPayload() {
return { question: currentQuestion.value, cap: props.cap, asked_again: asked_again.value }
}
// Agent 1 (fast): show immediate level + points, then Agent 2 (precise) in the background.
async function quickEvaluate() {
const mine = ++evalRun
examStick.value = true
examLoading.value = true
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
})
if (mine !== evalRun) return
// Agent 1 does NOT change the score — only show feedback + expected points.
lastFeedback.value = res.feedback || ''
examMessages.value.push({ role: 'assistant', kind: 'feedback', content: res.feedback || '', rating: res.rating, points: res.points, checked: false })
lastFeedbackMsg = examMessages.value[examMessages.value.length - 1]
examPhase.value = 'bewertet'
examScroll()
nextTick(() => examInputEl.value?.focus())
preciseEvaluate() // Agent 2 evaluates bindingly (sets the score)
} catch {
if (mine === evalRun) examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Didn\'t work — please try again.' })
} finally {
if (mine === evalRun) examLoading.value = false
}
}
// Agent 2 (precise): evaluator + critic. Corrects bubble + score (server truth, always apply).
// thorough=true: strong model, optionally with the learner's reason.
async function preciseEvaluate(thorough = false, reason = '') {
const target = lastFeedbackMsg
if (thorough) examLoading.value = true
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
})
applyExam(res)
if (target) {
target.content = res.feedback || target.content
target.rating = res.rating
target.points = res.points
target.checked = true
}
} catch {
if (target) target.checked = true
} finally {
if (thorough) examLoading.value = false
}
}
function submitAnswer() {
const text = examInput.value.trim()
if (!text || examLoading.value) return
examMessages.value.push({ role: 'user', kind: 'answer', content: text })
examInput.value = ''
quickEvaluate()
}
// Was there already a discussion? Then the history may be evaluated directly.
const hasDiscussion = computed(() => examMessages.value.some((m) => m.kind === 'nachfrage' || m.kind === 'discussion'))
// "Evaluate history": evaluate the dialog so far as the answer (field is ignored).
function evaluateHistory() {
if (examLoading.value) return
quickEvaluate()
}
// Right-click an evaluation → open the thorough field; submitting re-evaluates (with reason).
function openThorough(msg) {
thoroughText.value = ''
thoroughMsg.value = msg
nextTick(() => document.getElementById('bp-thorough-input')?.focus())
}
function submitThorough() {
if (examLoading.value) return
lastFeedbackMsg = thoroughMsg.value || lastFeedbackMsg
const text = thoroughText.value.trim()
thoroughMsg.value = null
preciseEvaluate(true, text)
}
function pointsLabel(p) {
if (p == null) return ''
return p > 0 ? `+${p}` : p < 0 ? String(p) : '±0'
}
// Cancel like in chat: increment the run counter → the running result (examSend
// and quickEvaluate) is discarded, buttons free immediately. Agent finishes server-side.
function examCancel() {
if (!examLoading.value) return
examRun++
evalRun++
examLoading.value = false
examMessages.value.push({ role: 'assistant', kind: 'fehler', content: 'Cancelled.' })
}
// ESC cancels — window listener, because after a button click the focus leaves the panel.
function onWindowKey(e) {
if (e.key === 'Escape' && examLoading.value) { e.preventDefault(); examCancel() }
}
onMounted(() => {
if (props.mode !== 'full') return // trigger mode (list): only tab bar, no exam/text logic
window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onExamKey)
loadPatterns() // GET (no agent) — sets the active form early correctly (quiz/cloze/explain)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
window.removeEventListener('keydown', onExamKey)
})
// Quiz / cloze choice is a choice widget with keys 14; otherwise text field/bubble.
const choiceWidget = computed(() => {
if (shownForm.value === 'quiz') return quizCurrent.value
if (shownForm.value === 'gaptext' && clozeCurrent.value && !clozeCurrent.value.schwer) return clozeCurrent.value
return null
})
// After an answer: on milestone crossing go to the new section, otherwise next question.
function continueAction() {
if (sectionChange.value) toNextSection()
else showNextQuestion()
}
// Primary action per form: no question → request · open → answer · done → continue.
function primaryAction() {
if (examLoading.value) return
const f = shownForm.value
if (f === 'quiz') { if (!quizCurrent.value) requestQuestion(); else if (!quizCurrent.value.done) quizAnswer(); else continueAction(); return }
if (f === 'gaptext') { if (!clozeCurrent.value) requestQuestion(); else if (!clozeCurrent.value.done) clozeAnswer(); else continueAction(); return }
if (examPhase.value === 'idle') requestQuestion()
else if (examPhase.value === 'question_offen') { if (examInput.value.trim()) submitAnswer() }
else continueAction()
}
// 14 (bare): toggle option in the choice widget. Alt alone: jump to the input field.
// Alt+2 primary (answer/next) · Alt+Q view · Alt+1/3 Explain.
function onExamKey(e) {
if (e.repeat) return
if (props.mode !== 'full' || activeTab.value !== 'exam') return
// Alt alone toggles the focus (BlockFocus lone-tap) → do NOT focus here,
// otherwise keydown focus (here) and keyup toggle (focus) fight → "only while held".
if (e.key === 'Alt') return
if (!e.altKey) {
// Bare 14: toggle the choice widget — but not while a text field has focus.
if (['1', '2', '3', '4'].includes(e.key) && choiceWidget.value && !choiceWidget.value.done) {
const ae = document.activeElement
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return
e.preventDefault()
const i = Number(e.key) - 1
if (i < choiceWidget.value.options.length) (shownForm.value === 'quiz' ? quizToggle : clozeToggle)(i)
}
return
}
if (e.code === 'KeyQ') {
e.preventDefault()
emit('setAnsicht', props.ansicht === 'compact' ? 'erklärend' : 'compact')
return
}
if (e.key === '2') { e.preventDefault(); primaryAction(); return }
// Explain special keys
if (e.key === '1') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && examInput.value.trim()) askFollowUp(); return }
if (e.key === '3') { e.preventDefault(); if (shownForm.value === 'erklaeren' && examPhase.value === 'question_offen' && hasDiscussion.value) evaluateHistory() }
}
</script>
<template>
<div class="bp">
<div class="bp-toggles">
<!-- View switcher (fullscreen only): controls the left guide column, left of the tabs -->
<div v-if="mode === 'full'" class="bp-ansicht">
<button :class="{ active: ansicht === 'compact' }" title="Key points" @click="emit('setAnsicht', 'compact')">Compact</button>
<button :class="{ active: ansicht === 'erklärend' }" title="Detailed explanation" @click="emit('setAnsicht', 'erklärend')">Explanatory</button>
</div>
<button :class="{ active: activeTab === 'chat' }" @click="tabClick('chat')">
Chat
</button>
<button :class="{ active: activeTab === 'exam' }" @click="tabClick('exam')">
Exam
<span v-if="level" class="bp-chip" :style="{ borderColor: level.farbe, color: level.farbe }" :title="`${level.label} (${cap})`">{{ level.kurz }} {{ scoreDisplay }}/{{ cap }}</span>
<span v-else-if="score" class="bp-chip">{{ scoreDisplay }}/{{ cap }}</span>
</button>
</div>
<div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Block chat -->
<div v-if="activeTab === 'chat'">
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
placeholder="Frage zum Baustein…" />
</div>
<!-- Exam: guided dialog -->
<div v-else>
<p class="bp-hint">
<template v-if="atCap">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} <strong>Max</strong>.</template>
<template v-else-if="inRandom && !displayForm">Random · {{ scoreDisplay }}/{{ cap }}</template>
<template v-else-if="inRandom">{{ FORM_NAME[shownForm] }} · {{ scoreDisplay }}/{{ cap }} · {{ modeRule }}</template>
<template v-else>{{ FORM_NAME[shownForm] }} · {{ score }}/{{ bandTarget }} · {{ modeRule }}</template>
</p>
<!-- Quiz: question + multiple choice (widget stays even at the cap practice without points) -->
<template v-if="shownForm === 'quiz'">
<div v-if="!quizCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-quiz">
<p class="bp-quiz-question markdown" v-html="renderMarkdownInline(quizCurrent.question)"></p>
<div class="bp-quiz-grid">
<button
v-for="(o, i) in quizCurrent.options" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: quizCurrent.gewaehlt.includes(i), correct: quizCurrent.done && o.correct, falsch: quizCurrent.done && !o.correct && quizCurrent.gewaehlt.includes(i) }"
:disabled="quizCurrent.done"
:title="`${i + 1}`"
@click="quizToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="examError" class="bp-error">{{ examError }}</p>
<div class="bp-actions">
<button v-if="!quizCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading" @click="quizAnswer">Answer</button>
<template v-else>
<span class="bp-tier" :class="quizCurrent.rating">{{ pointsLabel(quizCurrent.points) }}</span>
<span class="bp-form-feedback">{{ quizCurrent.feedback }}</span>
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
</template>
</div>
</div>
</template>
<!-- Cloze: sentence with gap + input -->
<template v-else-if="shownForm === 'gaptext'">
<div v-if="!clozeCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-gap">
<p class="bp-gap-sentence markdown" v-html="clozeSentenceHtml(clozeCurrent.sentence)"></p>
<!-- hard: free text -->
<input
v-if="clozeCurrent.schwer"
id="bp-gap-input"
v-model="clozeCurrent.input"
:disabled="clozeCurrent.done"
placeholder="Begriff für die Lücke…"
@keyup.enter="clozeAnswer"
/>
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
<!-- easy: choose term from 4 -->
<div v-if="!clozeCurrent.schwer" class="bp-quiz-grid">
<button
v-for="(o, i) in clozeCurrent.options" :key="i"
type="button"
class="bp-quiz-opt"
:class="{ gewaehlt: clozeCurrent.gewaehlt.includes(i), correct: clozeCurrent.done && o.correct, falsch: clozeCurrent.done && !o.correct && clozeCurrent.gewaehlt.includes(i) }"
:disabled="clozeCurrent.done"
:title="`${i + 1}`"
@click="clozeToggle(i)"
>
<span class="markdown" v-html="renderMarkdownInline(o.text)"></span>
</button>
</div>
<p v-if="examError" class="bp-error">{{ examError }}</p>
<div class="bp-actions">
<button v-if="!clozeCurrent.done" class="bp-action primary" title="Alt+2" :disabled="examLoading || (clozeCurrent.schwer ? !clozeCurrent.input.trim() : !clozeCurrent.gewaehlt.length)" @click="clozeAnswer">Answer</button>
<template v-else>
<span class="bp-tier" :class="clozeCurrent.rating">{{ pointsLabel(clozeCurrent.points) }}</span>
<span class="bp-form-feedback">{{ clozeCurrent.feedback }}</span>
<button class="bp-action primary" @click="continueAction">{{ sectionChange ? 'To next section' : 'Next' }}</button>
</template>
</div>
</div>
</template>
<!-- Explain: guided dialog (existing) -->
<template v-else>
<div v-if="examMessages.length" ref="examMessagesEl" class="bp-messages" @scroll="onExamScroll">
<template v-for="(m, i) in examMessages" :key="i">
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked</span>
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="submitThorough" />
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
<button class="bp-action" @click="thoroughMsg = null">×</button>
</div>
</div>
<div v-else-if="m.kind === 'fehler'" class="bp-error">{{ m.content }}</div>
<div v-else-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="examLoading" class="bp-msg assistant bp-typing"></div>
</div>
<div v-if="examPhase === 'idle'" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<template v-else>
<div v-if="examPhase === 'question_offen'" class="bp-input">
<textarea
ref="examInputEl"
v-model="examInput"
rows="2"
placeholder="Antwort — oder nachfragen…"
></textarea>
</div>
<div class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<template v-else-if="examPhase === 'question_offen'">
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
<button v-if="hasDiscussion" class="bp-action" title="Alt+3" @click="evaluateHistory"><span class="bp-kbd">3</span>Evaluate history</button>
</template>
<template v-else>
<button class="bp-action primary" title="Alt+2" @click="continueAction"><span class="bp-kbd">2</span>{{ sectionChange ? 'To next section' : 'Next question' }}</button>
</template>
</div>
</template>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.bp { margin-top: 0.75rem; }
.bp-toggles { display: flex; gap: 0.4rem; }
.bp-toggles button {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* View switcher (Compact/Explanatory) — segmented, left of the tabs */
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
.bp-ansicht button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; }
.bp-ansicht button + button { border-left: none; }
.bp-ansicht button:hover { color: var(--text); }
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, var(--level-expert) 16%, var(--panel)); border-color: var(--level-expert); color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
.bp-panel {
margin-top: 0.6rem;
padding: 0.75rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
}
.bp-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.bp-hint-key { font-size: 0.72rem; opacity: 0.7; white-space: nowrap; }
.bp-error { font-size: 0.85rem; color: var(--danger); margin: 0.5rem 0 0; }
.bp-action {
margin-top: 0.5rem;
padding: 0.3rem 0.8rem;
font-size: 0.8rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.bp-action:hover { border-color: var(--accent); }
.bp-action:disabled { opacity: 0.5; cursor: default; }
.bp-action.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-action.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.bp-action.cancel { background: var(--danger); border-color: var(--danger); color: var(--on-accent); }
.bp-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.bp-actions .bp-action { margin-top: 0; }
.bp-kbd {
display: inline-flex; align-items: center; justify-content: center;
min-width: 1rem; height: 1rem; padding: 0 0.2rem; margin-right: 0.35rem;
font-size: 0.65rem; font-weight: 600; border-radius: 3px;
background: var(--panel); border: 1px solid var(--border); color: var(--text-muted);
}
.bp-action.primary .bp-kbd { background: color-mix(in srgb, var(--on-accent) 20%, transparent); border-color: transparent; color: var(--on-accent); }
.bp-messages { display: flex; flex-direction: column; gap: 0.4rem; max-height: 320px; overflow-y: auto; }
.bp-msg {
max-width: 88%;
padding: 0.4rem 0.65rem;
border-radius: 10px;
font-size: 0.88rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.bp-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.bp-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.bp-typing { color: var(--text-faint); font-style: italic; }
/* Evaluation of the last answer — separated above the next question */
.bp-feedback {
align-self: flex-start;
max-width: 88%;
padding: 0.3rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
line-height: 1.4;
border: 1px solid var(--border);
}
.bp-pruefend { font-style: italic; opacity: 0.7; font-size: 0.92em; }
.bp-feedback { cursor: pointer; }
.bp-feedback.gut { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-feedback.neutral { background: var(--warning-soft); border-color: var(--warning-border); color: var(--warning); }
.bp-feedback.schlecht { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); color: var(--danger); }
.bp-tier { font-weight: 700; text-transform: uppercase; font-size: 0.62rem; letter-spacing: 0.03em; margin-right: 0.4rem; opacity: 0.85; }
.bp-tier.gut { color: var(--success); }
.bp-tier.neutral { color: var(--warning); }
.bp-tier.schlecht { color: var(--danger); }
/* Quiz: question + multiple choice */
.bp-quiz, .bp-gap { margin-top: 0.6rem; }
.bp-quiz-question, .bp-gap-sentence { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.5rem; line-height: 1.5; }
.bp-luecke { font-weight: 700; letter-spacing: 1px; color: var(--accent); padding: 0 0.15rem; }
.bp-quiz-opt .markdown, .bp-quiz-question.markdown, .bp-gap-sentence.markdown { display: inline; }
.bp-quiz-opt .markdown { min-width: 0; overflow-wrap: anywhere; }
.bp-quiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
.bp-quiz-opt {
display: flex; gap: 0.45rem; align-items: flex-start; text-align: left;
min-width: 0; overflow-wrap: anywhere;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--panel); color: var(--text); cursor: pointer; font-size: 0.85rem;
}
.bp-quiz-opt:disabled { cursor: default; }
.bp-quiz-opt.gewaehlt { border-color: var(--accent); background: var(--accent-soft, rgba(99, 102, 241, 0.12)); }
.bp-quiz-opt.correct { background: var(--success-soft); border-color: var(--success-border); }
.bp-quiz-opt.falsch { background: var(--danger-soft, #fee2e2); border-color: var(--danger-border, #f87171); }
.bp-form-feedback { font-size: 0.82rem; color: var(--text-muted); flex: 1; }
/* Cloze: sentence + input */
.bp-gap input {
width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; font: inherit; font-size: 0.88rem;
border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text);
}
.bp-gap input:disabled { opacity: 0.7; }
.bp-gap-solution { font-size: 0.82rem; color: var(--success); margin: 0.35rem 0 0; }
/* Thorough-check field (via right-click) inside the evaluation bubble */
.bp-thorough { display: flex; gap: 0.3rem; margin-top: 0.4rem; align-items: center; }
.bp-thorough input {
flex: 1; min-width: 0; padding: 0.3rem 0.5rem; font: inherit; font-size: 0.8rem;
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel); color: var(--text);
}
.bp-thorough .bp-action { margin-top: 0; }
.bp-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.bp-input textarea {
flex: 1;
resize: none;
padding: 0.45rem 0.6rem;
font: inherit;
font-size: 0.88rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
}
.bp-input button {
padding: 0.45rem 0.7rem;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.bp-input button:disabled { opacity: 0.5; cursor: default; }
.bp-input button.cancel { background: var(--danger); }
</style>

View File

@@ -0,0 +1,339 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
const props = defineProps({
topic: { type: String, required: true },
generating: { type: Boolean, default: false },
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'openGeneration'])
const items = ref([])
const loading = ref(true)
const error = ref(null)
const comp = ref(null) // Vollständigkeits-Beleg (nur wenn ready)
const compOpen = ref(false)
async function loadCompleteness() {
if (!props.ready) { comp.value = null; return }
try {
comp.value = await fetchBlocksCompleteness(props.topic)
} catch { comp.value = null }
}
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
const { start: startPoll } = usePolling(load, () => props.generating, 5000)
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [
{ key: 'beginner', label: 'Beginner' },
{ key: 'advanced', label: 'Advanced' },
{ key: 'expert', label: 'Expert' },
]
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
async function load() {
if (!items.value.length) loading.value = true // Spinner nur beim Erstladen, Live-Reload flackert nicht
error.value = null
try {
items.value = await fetchBlocksOverview(props.topic)
} catch (e) {
items.value = []
error.value = 'Overview not available — create blocks first.'
} finally {
loading.value = false
}
}
function relevant(b) {
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
}
function groups(b) {
return LEVELS
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
.filter((g) => g.subs.length)
}
const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.length || 0), 0))
</script>
<template>
<div class="bk-view">
<header class="bk-head">
<h1>{{ topic }}</h1>
<span class="bk-sub">Bausteine-Übersicht</span>
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
<span class="bk-spacer"></span>
<button class="bk-close" title="Schließen" @click="emit('close')"></button>
</header>
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
<span class="bk-progress-dot"></span>
Generierung läuft{{ progress ? ' · ' + progress : '' }} Board öffnen
</button>
<button v-else-if="!ready && !partial && !items.length && !loading" class="bk-banner idle" @click="emit('openGeneration')">
Noch keine Bausteine zur Generierung
</button>
<section v-if="comp" class="bk-panel" :class="{ ok: comp.vollstaendig }">
<button class="bk-panel-row" @click="compOpen = !compOpen">
<span class="bk-panel-status">{{ comp.vollstaendig ? '✓ Zerlegung vollständig' : '○ Zerlegung unvollständig' }}</span>
<span class="bk-panel-stat">{{ comp.bloecke }} Blöcke</span>
<span class="bk-panel-stat">{{ comp.subs }} Subbausteine</span>
<span v-if="comp.ziele_total" class="bk-panel-stat">Lernziele {{ comp.ziele_covered }}/{{ comp.ziele_total }}</span>
<span class="bk-panel-stat">{{ comp.frage_bloecke }}/{{ comp.bloecke }} mit Prüfungsfragen</span>
<span class="bk-panel-stat">{{ comp.lernkarten }} Lernartefakte</span>
<span v-if="comp.dead" class="bk-panel-stat warn">{{ comp.dead }} dead</span>
<span class="bk-panel-toggle">{{ compOpen ? '▴' : '▾' }}</span>
</button>
<div v-if="compOpen" class="bk-panel-detail">
<span>{{ comp.verworfen }} Kandidaten geprüft verworfen</span>
<span>{{ comp.zusammengelegt }} zusammengelegt (Dubletten/Umbrellas)</span>
<span>{{ comp.degradiert_geprueft }} Fragmente degradiert (Panel-geprüft)</span>
<span v-if="comp.panel_gerettet">{{ comp.panel_gerettet }} vom Panel gerettet</span>
<span v-if="comp.lauf_minuten">Lauf: {{ comp.lauf_minuten }} min</span>
</div>
</section>
<div v-if="loading" class="bk-empty-state">Lädt</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
<div v-else class="bk-grid">
<article
v-for="b in items"
:key="b.num"
class="bk-card"
:class="{ 'bk-irrelevant': !relevant(b) }"
:title="relevant(b) ? null : 'Not relevant — comes later in the &quot;Rest&quot;'"
>
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.title }}</h3>
<p v-if="b.description" class="bk-desc">{{ b.description }}</p>
<div v-if="b.subblocks && b.subblocks.length" class="bk-levels">
<div v-for="g in groups(b)" :key="g.key" class="bk-level" :class="'st-' + g.key">
<span class="bk-level-label">{{ g.label }}</span>
<ul>
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthema — kommt später im „Rest">Rand</span>
</li>
</ul>
</div>
</div>
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
</article>
</div>
</div>
</template>
<style scoped>
.bk-view {
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
overflow-y: auto; /* EIN Seitenfluss: Board scrollt mit, nur der Kopf bleibt stehen */
background: var(--bg-preview);
}
.bk-head {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.bk-head h1 { font-size: 1.5rem; }
.bk-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.bk-count { color: var(--text-muted); font-size: 0.82rem; }
.bk-spacer { flex: 1; }
.bk-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.bk-close:hover { border-color: var(--accent); }
.bk-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse-soft 1.2s ease-in-out infinite;
}
.bk-banner {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.85rem 2rem 0;
padding: 0.55rem 0.9rem;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--panel);
color: var(--accent);
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
.bk-banner:hover { background: var(--panel-soft); }
.bk-panel {
margin: 0.85rem 2rem 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel);
}
.bk-panel.ok { border-color: var(--level-beginner); }
.bk-panel-row {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1.1rem;
padding: 0.55rem 0.9rem;
border: none;
background: none;
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.bk-panel-status { font-weight: 700; }
.bk-panel.ok .bk-panel-status { color: var(--level-beginner); }
.bk-panel-stat { color: var(--text-muted); }
.bk-panel-stat.warn { color: var(--danger); font-weight: 600; }
.bk-panel-toggle { margin-left: auto; color: var(--text-faint); }
.bk-panel-detail {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.1rem;
padding: 0 0.9rem 0.6rem;
font-size: 0.78rem;
color: var(--text-faint);
border-top: 1px dashed var(--border);
padding-top: 0.5rem;
}
.bk-empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.bk-grid {
flex: 1;
padding: 1.5rem 2rem 4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
align-content: start;
}
.bk-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent-border);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.bk-card.bk-irrelevant { opacity: 0.5; }
.bk-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1rem;
margin-bottom: 0.4rem;
}
.bk-num {
flex: 0 0 auto;
min-width: 26px;
height: 26px;
padding: 0 6px;
border-radius: 7px;
background: var(--accent);
color: var(--on-accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
font-weight: 700;
}
.bk-desc {
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.45;
margin-bottom: 0.7rem;
}
.bk-levels { display: flex; flex-direction: column; gap: 0.55rem; }
.bk-level {
border-left: 3px solid var(--st);
padding-left: 0.6rem;
}
.bk-level.st-beginner { --st: var(--level-beginner); }
.bk-level.st-advanced { --st: var(--level-advanced); }
.bk-level.st-expert { --st: var(--level-expert); }
.bk-level-label {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--st);
}
.bk-level ul {
list-style: disc;
margin: 0.25rem 0 0;
padding-left: 1.15rem;
}
.bk-level li {
font-size: 0.85rem;
color: var(--text);
line-height: 1.35;
margin-bottom: 2px;
}
.bk-level li::marker { color: var(--text-faint); }
.bk-level li.rand { color: var(--text-faint); }
.rand-tag {
margin-left: 0.4rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-faint);
border: 1px solid var(--border-strong);
border-radius: 4px;
padding: 0 4px;
}
.bk-no-subs { color: var(--text-faint); font-size: 0.8rem; font-style: italic; }
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
// Präsentationaler Chat (Nachrichtenliste + Typing + Eingabe). Die Mechanik (send/cancel/
// scroll/focus) lebt in useChat; hier wird nur das `chat`-Objekt gerendert. Ersetzt die
// zeichengleichen Chat-Blöcke in BlockPanel und TopicDetail.
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
chat: { type: Object, required: true }, // Rückgabe von useChat()
hint: { type: String, default: '' }, // Platzhaltertext bei leerer Historie
placeholder: { type: String, default: '' },
rows: { type: Number, default: 2 },
autoGrow: { type: Boolean, default: false },
maxHeight: { type: String, default: '320px' },
})
function onInput() {
if (props.autoGrow) props.chat.autoGrow()
}
</script>
<template>
<div :ref="chat.messagesEl" class="ct-messages" :style="{ maxHeight }" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="ct-hint">{{ hint }}</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="ct-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="ct-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="ct-msg assistant ct-typing">Denkt nach</div>
</div>
<div class="ct-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
:rows="rows"
:placeholder="placeholder"
@input="onInput"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button
:disabled="!chat.input.value.trim() && !chat.loading.value"
:class="{ cancel: chat.loading.value }"
:title="chat.loading.value ? 'Abbrechen' : 'Senden'"
@click="chat.send"
>{{ chat.loading.value ? '✕' : '➤' }}</button>
</div>
</template>
<style scoped>
/* flex:1 füllt ein Panel mit fester Höhe (TopicDetail); maxHeight begrenzt sonst (BlockPanel-Tab). */
.ct-messages { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; overflow-y: auto; padding: 0.2rem; }
.ct-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.ct-msg {
max-width: 85%;
padding: 0.4rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
word-break: break-word;
}
.ct-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.ct-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.ct-typing { color: var(--text-faint); font-style: italic; }
.ct-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.ct-input textarea {
flex: 1;
resize: none;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--input-bg, var(--panel));
color: var(--text);
font-family: inherit;
font-size: 0.85rem;
}
.ct-input textarea:focus { outline: none; border-color: var(--accent); }
.ct-input button {
flex: 0 0 auto;
width: 38px;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font-size: 1rem;
}
.ct-input button:disabled { opacity: 0.5; cursor: default; }
.ct-input button.cancel { background: var(--danger); }
</style>

View File

@@ -1,170 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
version: { type: Number, default: 0 }, // Erhöhung = Elemente neu laden
})
const emit = defineEmits(['open'])
const elements = ref([])
watch([() => props.topic, () => props.version], load, { immediate: true })
async function load() {
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
}
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
</script>
<template>
<div class="elements-overview">
<div class="overview-scroll">
<div class="overview-content">
<header class="overview-head">
<h1>{{ topic }}</h1>
<span class="overview-format">Elemente</span>
</header>
<p v-if="!elements.length" class="overview-empty">
Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken.
</p>
<div class="element-grid">
<article
v-for="el in elements"
:key="el.id"
class="element-card"
@click="emit('open', el)"
>
<h3>{{ plain(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block">
<h4>Hinweise</h4>
<ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.elements-overview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.overview-scroll {
flex: 1;
overflow-y: auto;
}
.overview-content {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 2rem 4rem;
}
.overview-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.overview-head h1 {
margin: 0;
font-size: 2.2rem;
color: var(--text);
}
.overview-format {
font-size: 1rem;
font-weight: 600;
color: var(--text-faint);
}
.overview-empty {
color: var(--text-muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
align-items: start;
}
.element-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
}
.element-card:hover {
box-shadow: 0 4px 16px var(--shadow);
transform: translateY(-1px);
}
.element-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--text);
}
.el-example {
margin-top: 0.5rem;
}
.el-hints-block {
margin-top: 0.7rem;
}
.el-hints-block h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-hints-block ul {
margin: 0;
padding-left: 1.1rem;
}
.el-hints-block li {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.2rem;
}
/* Markdown: Basis global (assets/markdown.css), hier nur Grundschrift der Karten */
.markdown {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup>
import { ref, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
// Reine Flip-Karte: der Übungspool (PracticePanel) steuert Stapel und Leitner —
// die Karte zeigt nur Frage/Antwort und meldet die Bewertung nach oben.
const props = defineProps({ card: { type: Object, required: true } }) // { question, answer }
const emit = defineEmits(['answer']) // answer(correct: boolean)
const flipped = ref(false)
watch(() => props.card, () => { flipped.value = false })
</script>
<template>
<div class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div v-if="!flipped" class="fc-seite">
<span class="fc-label">Frage</span>
<div class="fc-text" v-html="renderMarkdownInline(card.question)"></div>
<span class="fc-hint">Klicken zum Umdrehen</span>
</div>
<div v-else class="fc-seite">
<span class="fc-label">Antwort</span>
<div class="fc-text" v-html="renderMarkdownInline(card.answer)"></div>
</div>
</div>
<div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="emit('answer', false)">Nochmal</button>
<button class="fc-btn gewusst" @click="emit('answer', true)">Gewusst</button>
</div>
</div>
</template>
<style scoped>
.fc-body { margin-top: 0.5rem; }
.fc-card {
position: relative; min-height: 120px; cursor: pointer;
border: 1px solid var(--border); border-radius: 10px;
padding: 1rem 1.1rem; background: var(--panel-soft);
display: flex; align-items: center; justify-content: center; text-align: center;
transition: border-color 0.15s;
}
.fc-card.flipped { border-color: var(--accent); }
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
.fc-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-faint); font-weight: 700;
}
.fc-text { font-size: 0.98rem; line-height: 1.45; }
.fc-hint { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
.fc-aktionen { display: flex; gap: 8px; margin-top: 0.5rem; }
.fc-btn {
flex: 1; padding: 0.5rem; border-radius: 8px; cursor: pointer;
font-size: 0.82rem; font-weight: 600; border: 1px solid var(--border-strong);
background: var(--panel);
}
.fc-btn.gewusst { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.fc-btn.nochmal { color: var(--text-muted); }
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
// Topic-wide mixed exam: picks a block (prioritized: not yet at max), then random
// within it, and reuses the normal BlockPanel. Each block has its own exam slot
// (pruefungCache), so the question pools stay separate. Booking runs per block via the
// normal exam route. Total cap = Σ cap_final of all checkable blocks.
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fetchGuides, fetchGuideContent, fetchBlockLearnState } from '../api.js'
import BlockPanel from './BlockPanel.vue'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['progressChanged', 'fokus-active'])
const sections = ref([]) // [{title, md, compact, num}] — checkable sections of the full version
const learnstate = ref({}) // title -> {good_answers, streak, cap, cap_aktuell, freie_level}
const active = ref(null) // current block title
const loadError = ref(null)
const checkable = computed(() => sections.value.filter((s) => (learnstate.value[s.title]?.cap || 0) > 0))
// Total progress: Σ min(score, cap_final) / Σ cap_final over all checkable blocks.
const total = computed(() => {
let score = 0, cap = 0
for (const s of checkable.value) {
const l = learnstate.value[s.title] || {}
score += Math.min(l.good_answers || 0, l.cap || 0)
cap += l.cap || 0
}
return { score, cap }
})
const activeSection = computed(() => sections.value.find((s) => s.title === active.value) || null)
const activeStatus = computed(() => learnstate.value[active.value] || null)
// Priority: blocks not yet at cap_final; among those random.
function pickBlock() {
const unfinished = checkable.value.filter((s) => {
const l = learnstate.value[s.title] || {}
return (l.good_answers || 0) < (l.cap || 0)
})
const pool = unfinished.length ? unfinished : checkable.value
active.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].title : null
}
async function loadLearnState() {
try {
learnstate.value = (await fetchBlockLearnState(props.topic)).blocks || {}
} catch { /* offline → keep old state */ }
}
onMounted(async () => {
emit('fokus-active', true)
try {
const guides = await fetchGuides()
const g = guides
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
if (!g) { loadError.value = 'No finished guide — create a guide first.'; return }
const content = await fetchGuideContent(g.id, 4) // full version (all subs)
sections.value = (content.chapters || [])
.flatMap((ch) => ch.sections || [])
.filter((s) => s.checkable !== false)
.map((s) => ({ title: s.title, md: s.md, compact: s.compact, num: s.num }))
await loadLearnState()
pickBlock()
} catch {
loadError.value = 'Could not load the exam.'
}
})
onUnmounted(() => emit('fokus-active', false))
async function onStatus() {
await loadLearnState() // cap_aktuell/freie_level may have grown after an answer
emit('progressChanged')
}
</script>
<template>
<div class="ge-panel">
<div class="ge-head">
<h2>General Exam</h2>
<span v-if="!loadError" class="ge-score">{{ total.score }} / {{ total.cap }}</span>
<button v-if="active" class="ge-next" title="Check another block" @click="pickBlock"> Another block</button>
</div>
<p v-if="loadError" class="ge-msg">{{ loadError }}</p>
<p v-else-if="!checkable.length" class="ge-msg">No checkable blocks yet.</p>
<p v-else-if="!active" class="ge-msg">Everything maxed out nothing left to check. 🎉</p>
<div v-else class="ge-body">
<div class="ge-block">Block: <strong>{{ active }}</strong></div>
<BlockPanel
:key="active"
mode="full"
:topic="topic"
:block="active"
:section="activeSection?.md || ''"
:section-compact="activeSection?.compact || ''"
:provider="provider"
:status="activeStatus"
:cap="activeStatus?.cap || 0"
@statusChanged="onStatus"
/>
</div>
</div>
</template>
<style scoped>
.ge-panel { padding: 16px 20px; max-width: 900px; margin: 0 auto; }
.ge-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.ge-head h2 { margin: 0; font-size: 1.1rem; }
.ge-score { font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 600; }
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
.ge-next:hover { color: var(--accent-hover); }
.ge-msg { color: var(--text-muted); }
.ge-block { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
</style>

View File

@@ -0,0 +1,363 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
generating: { type: Boolean, default: false },
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
error: { type: String, default: null },
guideFormat: { type: String, default: 'Guide' },
autoInventory: { type: Boolean, default: true },
autoArtefacts: { type: Boolean, default: true },
autoGuide: { type: Boolean, default: true },
})
const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null)
const pollError = ref(null)
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
pollError.value = null
} catch (e) {
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else pollBoard() // Endstand nachladen
}, { immediate: true })
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const dead = computed(() => board.value?.dead || [])
const qa = computed(() => board.value?.qa || null)
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
function boardProgress(cols) {
const total = cols.reduce((n, c) => n + c.total, 0)
if (!total) return { value: 0, done: 0, total: 0 }
const last = Math.max(1, cols.length - 1)
let acc = 0
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
return { value: acc / total, done, total }
}
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
// Score 010 → „100 %"
const pct = (note) => Math.round(note * 10) + ' %'
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const res = await fetchRuns(props.topic, 1)
run.value = res.runs[0] || null
latest.value = res.latest || null
} catch { run.value = null; latest.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene,
// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch.
function boardZeit(b) {
if (!b?.start) return null
const start = Date.parse(b.start)
const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start)
return fmtRuntime((ende - start) / 1000)
}
function boardTokens(b) {
const t = b?.tokens
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
}
const invStat = computed(() => latest.value?.inventory)
const artStat = computed(() => latest.value?.artefacts)
watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
function later(fn) { fn(); setTimeout(pollBoard, 600) }
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
const qaBusy = ref(false)
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
async function runQaClick(ebene = null) {
if (qaBusy.value) return
qaBusy.value = ebene || true // markiert die geklickte Ebene (Button-Label)
try { await runQa(props.topic, true, ebene) }
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
}
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
const repairInfo = ref({}) // { inventory, artefacts }
function repairText(r) {
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
return n === 0 ? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt`
}
// Auto an → „Befunde beheben"-Loop bis 100 % / Stillstand / 10×; sonst genau ein Durchlauf.
async function repairClick(ebene) {
if (repairBusy.value) return
const auto = ebene === 'inventory' ? props.autoInventory : props.autoArtefacts
const max = auto ? 10 : 1
repairBusy.value = ebene
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
let prev = null
try {
for (let i = 0; i < max; i++) {
const r = await runRepair(props.topic, ebene)
const note = ebene === 'artefacts' ? r.note_artefakte : r.note
const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
if (note == null || note >= 10) break // 100 % erreicht
if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht
prev = note
}
} catch (e) {
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
} finally {
repairBusy.value = ''; pollBoard()
}
}
function removeArtefactsClick() {
later(() => removeArtefacts(props.topic).then(pollBoard))
}
</script>
<template>
<div class="gen-view">
<header class="gen-head">
<h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span>
<button class="gen-close" title="Schließen" @click="emit('close')"></button>
</header>
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
<section class="gen-section">
<div class="gen-steps-top">
<span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')"> {{ run.fails.length }} Fehler</span>
<div v-if="generating" class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
</div>
<button
v-if="dead.length && generating"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button>
</div>
<!-- Ebene Inventar -->
<div class="gen-board-label">
Inventar
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
<span v-if="boardZeit(invStat)" class="gen-run">⏱ {{ boardZeit(invStat) }}<template v-if="boardTokens(invStat)"> · {{ boardTokens(invStat) }} Tokens</template></span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('inventory')">{{ qaBusy === 'inventory' ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button v-if="ready || partial || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard :columns="inventoryCols" :agents="(board?.agents || []).filter(a => a.ebene !== 'artefacts')" :generating="generating" />
<!-- Ebene Artefakte -->
<div class="gen-board-label">
Artefakte
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
<span v-if="boardZeit(artStat)" class="gen-run">⏱ {{ boardZeit(artStat) }}<template v-if="boardTokens(artStat)"> · {{ boardTokens(artStat) }} Tokens</template></span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('artefacts')">{{ qaBusy === 'artefacts' ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen — nur Artefakte, ohne Board 1" @click="emit('continueAll', { qaForce: true, onlyArtefacts: true })">Generieren</button>
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
title="Nur die Artefakte leeren — Inventar bleibt"
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard :columns="artefactCols" :agents="(board?.agents || []).filter(a => a.ebene === 'artefacts')" :generating="generating" />
</section>
<section class="gen-section">
<GuideBoardSection
:topic="topic"
:format="guideFormat"
:refresh="guideRefresh"
:auto="autoGuide"
@cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)"
@preview="emit('preview')"
@removeFormat="(f) => emit('removeFormat', f)"
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
/>
</section>
</div>
</template>
<style scoped>
.gen-view {
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--bg-preview);
}
.gen-head {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.gen-head h1 { font-size: 1.5rem; }
.gen-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.gen-spacer { flex: 1; }
.gen-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.gen-close:hover { border-color: var(--accent); }
.gen-section {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.7rem 0 0.3rem;
}
.gen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.gen-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse-soft 1.2s ease-in-out infinite;
}
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gen-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: none;
letter-spacing: 0;
}
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
</style>

View File

@@ -0,0 +1,245 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
format: { type: String, default: 'Guide' },
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
auto: { type: Boolean, default: true },
})
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
const board = ref(null)
const pollError = ref(null)
async function poll() {
try {
board.value = await fetchGuideBoard(props.topic, props.format)
pollError.value = null
} catch (e) {
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
watch(() => props.refresh, () => poll())
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
const generating = computed(() => !!board.value?.generating)
const columns = computed(() => board.value?.columns || [])
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
// QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note.
const qaNote = computed(() => (total.value ? board.value?.qa_guide : null))
// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf
// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen).
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
watch(() => generating.value, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
const qaBusy = ref(false)
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; poll() }
}
function removeClick() {
emit('removeFormat', props.format)
board.value = null // optimistisch: Karten + QA-Badge sofort weg, poll bestätigt
setTimeout(poll, 800)
}
const repairBusy = ref(false)
const repairInfo = ref('')
// Wartet, bis die Guide-Regenerierung der offenen Karten fertig ist (für den Auto-Loop).
function warteBisFertig(timeoutMs = 300000) {
return new Promise((resolve) => {
const t0 = Date.now()
const iv = setInterval(async () => {
await poll()
if (!board.value?.generating || Date.now() - t0 > timeoutMs) { clearInterval(iv); resolve() }
}, 2000)
})
}
// Auto an → Loop: Befunde beheben → regenerieren → QA, bis 100 % / Stillstand / 10×.
// Auto aus → genau ein Durchlauf (nur anstoßen).
async function repairClick() {
if (repairBusy.value) return
const max = props.auto ? 10 : 1
repairBusy.value = true
repairInfo.value = ''
let prev = null
try {
for (let i = 0; i < max; i++) {
const r = await repairGuideBoard(props.topic, props.format)
if (!(r.betroffen || []).length) {
repairInfo.value = i === 0 ? 'keine Befunde im letzten QA-Report' : `fertig nach ${i} Runde(n)`
break
}
repairInfo.value = `Runde ${i + 1}: ${r.betroffen.length} Abschnitt(e) → Prüfen…`
startPoll()
if (max === 1) break // ohne Auto: nur anstoßen, nicht auf Fertigstellung warten
await warteBisFertig() // Regenerierung abwarten
await runQa(props.topic) // neue Guide-QA messen
await poll()
const note = board.value?.qa_guide
if (note == null || note >= 10) { repairInfo.value = `100 % nach ${i + 1} Runde(n)`; break }
if (prev != null && note <= prev) { repairInfo.value = `Stillstand bei ${Math.round(note * 10)} %`; break }
prev = note
}
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
poll()
}
}
</script>
<template>
<section class="gb-board">
<div class="gen-board-label">
Guide · {{ format }}
<span v-if="qaNote != null" class="qa-note" :class="qaNote >= 10 ? 'ok' : 'bad'">QA {{ Math.round(qaNote * 10) }} %</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
</label>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gen-progress"><span class="gen-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
</div>
<div v-if="generating" class="gen-bar">
<button class="gen-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</div>
<div v-else class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="total && qaNote != null && qaNote < 10" class="gen-act" :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gen-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gen-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', removeClick)">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
</template>
<style scoped>
.gb-board { padding: 0.85rem 0 0; }
.gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.2rem 0 0.3rem;
}
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: none;
letter-spacing: 0;
}
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.gen-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: pulse-soft 1.2s ease-in-out infinite;
}
.gb-error { color: var(--danger); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
.gen-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
</style>

View File

@@ -0,0 +1,246 @@
<script setup>
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
// Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
import { fmtRuntime } from '../format.js'
const props = defineProps({
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false },
})
</script>
<template>
<div class="kb">
<div v-if="agents.length" class="kb-agents">
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
<span v-for="(a, i) in agents" :key="a.key || i" class="kb-agent" :title="a.key">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
</div>
<div class="kb-cols">
<div
v-for="c in columns"
:key="(c.board || '') + c.key"
class="kb-col"
:class="{ active: c.total > 0, collapsed: !c.total }"
>
<div class="kb-col-head" :title="c.label">
<span class="kb-col-label">{{ c.label }}</span>
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</div>
<ul v-if="c.cards && c.cards.length" class="kb-cards">
<li
v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="k.status"
:title="k.info || k.title"
>
<div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
<span class="kb-card-title">{{ k.title }}</span>
<span v-if="k.rounds" class="kb-badge" title="Writer-Runden">R{{ k.rounds }}</span>
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
</div>
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
<div v-if="k.step_n && k.status === 'active'" class="kb-steps" :title="k.steps ? k.steps.join(' → ') : ''">
<span
v-for="s in k.step_n" :key="s" class="kb-step"
:class="{ done: s < k.step_i, act: s === k.step_i }"
:title="k.steps ? k.steps[s - 1] : ''"
></span>
</div>
</li>
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.kb { display: flex; flex-direction: column; gap: 0.5rem; }
.kb-agents {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.7rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.kb-agents-label { font-weight: 700; color: var(--accent); }
.kb-agent {
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 0.35rem;
background: var(--panel);
white-space: nowrap;
}
.kb-cols {
display: flex;
gap: 0.45rem;
overflow-x: auto; /* Fallback (schmale Screens) — am Desktop passt alles dank Kollaps */
align-items: stretch;
padding-bottom: 0.3rem;
}
.kb-col {
flex: 1 1 150px;
min-width: 140px;
max-width: 230px;
align-self: flex-start;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
opacity: 0.6;
}
.kb-col.active { opacity: 1; border-color: var(--border-strong); }
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
/* Leere Spalten kollabieren zu schmalen Säulen (Jira-Muster) — der Kopf bleibt
klickbar (Reset ab Spalte), das Label läuft vertikal. */
.kb-col.collapsed {
flex: 0 0 auto;
min-width: 0;
width: 32px;
align-self: stretch;
opacity: 0.5;
}
.kb-col.collapsed:hover { opacity: 0.85; }
.kb-col.collapsed .kb-col-head {
flex-direction: column-reverse;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
height: 100%;
min-height: 130px;
border-bottom: none;
border-radius: 8px;
padding: 0.4rem 0;
}
.kb-col.collapsed .kb-col-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.6rem;
overflow: hidden;
text-overflow: ellipsis;
max-height: 150px;
}
.kb-col-head {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.3rem;
border: none;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
border-radius: 8px 8px 0 0;
padding: 0.3rem 0.5rem;
cursor: pointer;
color: var(--text-muted);
}
.kb-col-head:disabled { cursor: default; }
.kb-col-head:hover:not(:disabled) { color: var(--accent); }
.kb-col-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.kb-col-count {
min-width: 1.3rem;
text-align: center;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.7rem;
font-weight: 700;
padding: 0 0.25rem;
}
.kb-col-count.zero { background: var(--border-strong); color: var(--text-faint); }
.kb-cards {
list-style: none;
margin: 0;
padding: 0.3rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 240px;
overflow-y: auto;
}
.kb-card {
font-size: 0.74rem;
line-height: 1.25;
padding: 0.2rem 0.3rem;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
}
.kb-card.error { border-color: var(--danger); }
.kb-card.active { border-color: var(--accent-border); }
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
.kb-card.klickbar { cursor: pointer; }
.kb-card.klickbar:hover { border-color: var(--accent); }
.kb-card-info {
margin: 0.15rem 0 0 1rem;
font-size: 0.66rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Phase stepper: one segment per fine step of the card's stage */
.kb-steps {
display: flex;
gap: 3px;
margin: 0.25rem 0 0.05rem 1rem;
}
.kb-step {
flex: 1;
max-width: 26px;
height: 3px;
border-radius: 2px;
background: var(--border, #444);
}
.kb-step.done { background: var(--accent, #7aa2f7); opacity: 0.55; }
.kb-step.act {
background: var(--accent, #7aa2f7);
animation: kbStepPulse 1.2s ease-in-out infinite;
}
@keyframes kbStepPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.kb-card-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb-dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-faint);
}
.kb-dot.error { background: var(--danger); }
.kb-dot.pulse { background: var(--accent); animation: kb-pulse 1.2s ease-in-out infinite; }
@keyframes kb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.kb-badge {
flex: 0 0 auto;
font-size: 0.6rem;
font-weight: 700;
border: 1px solid var(--warning-border);
color: var(--warning);
border-radius: 4px;
padding: 0 3px;
}
.kb-badge.ziele { border-color: var(--success-border); color: var(--success); }
.kb-more { font-size: 0.68rem; color: var(--text-faint); padding: 0.1rem 0.3rem; }
</style>

View File

@@ -0,0 +1,114 @@
<script setup>
// Übungspool: EIN Leitner-Stapel pro Thema (fällige Karten zuerst, dann neue).
// Der Server wählt und ordnet die Karten (Entscheidungs-Entlastung); „Nochmal"
// schiebt die Karte ans Rundenende, gebucht wird jede Antwort sofort.
import { ref, computed, onMounted } from 'vue'
import { fetchPracticeDeck, answerPracticeCard } from '../api.js'
import FlashcardWidget from './FlashcardWidget.vue'
const props = defineProps({
topic: { type: String, required: true },
})
const deck = ref([]) // Karten in Übungsreihenfolge
const counts = ref(null) // {due, new, new_total, gesperrt}
const nextDueAt = ref(null)
const loadError = ref(null)
const loading = ref(true)
const erledigt = ref(0)
const current = computed(() => deck.value[0] || null)
const offen = computed(() => deck.value.length)
async function loadDeck() {
loading.value = true
loadError.value = null
erledigt.value = 0
try {
const d = await fetchPracticeDeck(props.topic)
deck.value = d.cards || []
counts.value = d.counts || null
nextDueAt.value = d.next_due_at
} catch (e) {
loadError.value = e.message || 'Übungsstapel nicht ladbar.'
} finally {
loading.value = false
}
}
onMounted(loadDeck)
async function onAnswer(correct) {
const card = current.value
if (!card) return
try {
await answerPracticeCard({
topic: props.topic, block_norm: card.block_norm,
sub_norm: card.sub_norm, correct,
})
} catch { /* Buchung offline fehlgeschlagen — Durchgang läuft lokal weiter */ }
deck.value.shift()
if (correct) {
erledigt.value += 1
} else {
deck.value.push(card) // Rundenende: Box 1 ist sofort wieder fällig
}
}
function naechsteFaelligkeit() {
if (!nextDueAt.value) return null
const d = new Date(nextDueAt.value)
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
</script>
<template>
<div class="pr-panel">
<div class="pr-head">
<h2>Üben</h2>
<span v-if="counts" class="pr-counts">
{{ counts.due }} fällig · {{ counts.new }} neu<template v-if="counts.new_total > counts.new"> (von {{ counts.new_total }})</template>
</span>
<span v-if="offen" class="pr-rest">{{ offen }} übrig</span>
</div>
<p v-if="loadError" class="pr-msg">{{ loadError }}</p>
<p v-else-if="loading" class="pr-msg">Lade Stapel</p>
<div v-else-if="current" class="pr-body">
<div class="pr-kontext">{{ current.block }} · {{ current.subblock }}</div>
<FlashcardWidget :card="current" @answer="onAnswer" />
</div>
<div v-else class="pr-done">
<p class="pr-done-title">Alles erledigt </p>
<p v-if="erledigt" class="pr-msg">{{ erledigt }} Karten in dieser Runde.</p>
<p v-if="nextDueAt" class="pr-msg">Nächste Karten fällig: {{ naechsteFaelligkeit() }}</p>
<button
v-if="counts && counts.new_total > counts.new"
class="pr-mehr" @click="loadDeck"
>Weitere neue Karten üben</button>
<p v-if="counts && counts.gesperrt" class="pr-msg pr-faint">
{{ counts.gesperrt }} Karten schalten sich über Block-Prüfungen frei.
</p>
</div>
</div>
</template>
<style scoped>
.pr-panel { padding: 16px 20px; max-width: 720px; margin: 0 auto; }
.pr-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.pr-head h2 { margin: 0; font-size: 1.1rem; }
.pr-counts { color: var(--text-muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.pr-rest { margin-left: auto; color: var(--text-faint); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.pr-msg { color: var(--text-muted); }
.pr-faint { color: var(--text-faint); font-size: 0.85rem; }
.pr-kontext { margin-bottom: 6px; color: var(--text-muted); font-size: 0.88rem; }
.pr-done { text-align: center; padding: 2.5rem 0; }
.pr-done-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.6rem; }
.pr-mehr {
margin-top: 0.6rem; padding: 7px 14px; border: 1px solid var(--border-strong);
border-radius: 8px; background: var(--bg); cursor: pointer; font-weight: 600;
}
.pr-mehr:hover { color: var(--accent-hover); }
</style>

View File

@@ -0,0 +1,26 @@
<script setup>
// Schmaler Fortschrittsbalken (01) mit Beschriftung und optionalem Hinweis.
defineProps({
value: { type: Number, default: 0 }, // 0..1
label: { type: String, default: '' },
hint: { type: String, default: '' }, // z. B. „Umfang wächst noch"
})
</script>
<template>
<div class="pb">
<div class="pb-track">
<div class="pb-fill" :style="{ width: Math.round(Math.min(1, Math.max(0, value)) * 100) + '%' }"></div>
</div>
<span v-if="label" class="pb-label">{{ label }}</span>
<span v-if="hint" class="pb-hint">{{ hint }}</span>
</div>
</template>
<style scoped>
.pb { display: flex; align-items: center; gap: 0.5rem; }
.pb-track { flex: 1; height: 6px; border-radius: 999px; background: var(--panel-soft); overflow: hidden; }
.pb-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s ease; }
.pb-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
.pb-hint { font-size: 0.74rem; color: var(--text-faint); white-space: nowrap; }
</style>

View File

@@ -0,0 +1,73 @@
<script setup>
// Quellenauswahl (Typ-Buttons + Link-Feld/Ordnerwahl), geteilt zwischen dem Anlegen-Panel
// und dem Bearbeiten-Panel der Sidebar. v-model trägt { type, ort }.
const props = defineProps({
modelValue: { type: Object, required: true }, // { type, ort }
folders: { type: Object, default: () => ({}) }, // { projekt: [...], uni: [...] }
})
const emit = defineEmits(['update:modelValue', 'submit'])
const TYPES = [
{ key: 'thema', label: 'Thema' },
{ key: 'link', label: 'Link' },
{ key: 'projekt', label: 'Projekt' },
{ key: 'uni', label: 'Uni' },
]
function setType(key) {
emit('update:modelValue', { type: key, ort: '' }) // Typwechsel verwirft den alten Ort
}
function setOrt(ort) {
emit('update:modelValue', { ...props.modelValue, ort })
}
</script>
<template>
<div class="dlg-sources">
<button v-for="t in TYPES" :key="t.key" :class="{ active: modelValue.type === t.key }"
@click="setType(t.key)">{{ t.label }}</button>
</div>
<textarea
v-if="modelValue.type === 'link'"
class="dlg-input dlg-textarea" :value="modelValue.ort"
rows="4"
placeholder="https://… — ein Link pro Zeile"
@input="setOrt($event.target.value)"
/>
<select
v-else-if="modelValue.type === 'projekt' || modelValue.type === 'uni'"
class="dlg-input" :value="modelValue.ort"
@change="setOrt($event.target.value)"
>
<option value="" disabled>Ordner wählen</option>
<option v-for="fo in (folders[modelValue.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
</template>
<style scoped>
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-input {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus { outline: none; border-color: var(--accent); }
select.dlg-input { cursor: pointer; }
.dlg-textarea { resize: vertical; min-height: 5rem; }
</style>

View File

@@ -1,87 +1,209 @@
<script setup>
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, BADGE_VIEW_RANK } from '../levels.js'
import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({
previewGuide: { type: Object, default: null },
dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' },
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['progressChanged', 'openElements'])
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
// Rotierende Kapitel-Akzentfarben (passend zum OnePager-Cheat-Sheet, ohne Rot)
// Rotating chapter accent colors (no red)
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
// --- Inhalt laden ---
// --- Load content ---
const content = ref(null)
const loadError = ref(null)
const doneChapters = ref(new Set())
const scrollEl = ref(null)
const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
// --- Lazy render + markdown cache: parse only visible sections, each only once.
// Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
const mdCache = new Map() // `${mode}:${num}` → html
const visible = reactive({}) // num → true (stays true once ever visible)
let mdObserver = null
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
function viewLevelFor(title) {
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
// Auto = an die erreichte Badge-Stufe gekoppelt (gleiche Schwelle wie das Level-Abzeichen):
// Advanced-Badge → advanced-Subs sichtbar. Vorher hing es an `freie_level` (Sub-Zahl × 25),
// das bei vielen beginner-Subs SPÄTER umschaltet als der Badge (0.4 × cap) — dann zeigte der
// Badge „Advanced", die advanced-Inhalte blieben aber verborgen.
const st = levelOf(title)
return st ? (BADGE_VIEW_RANK[st.key] || 1) : 1
}
function htmlFor(s) {
const lvl = viewLevelFor(s.title)
const key = `${props.ansichtModus}:${s.num}:${lvl}:${props.stufeAnsicht}`
let h = mdCache.get(key)
if (h !== undefined) return h
const compact = props.ansichtModus === 'compact'
if (!s.subs || !s.subs.length) { // Legacy-Abschnitt ohne Marker → ungefiltert
h = renderMarkdown(compact ? (s.compact || s.md) : s.md)
} else {
const anchor = compact ? (s.anker_compact || '') : (s.anchor || '')
const parts = [renderMarkdown(anchor)]
for (const sub of s.subs) {
const rank = SUB_RANK[sub.level] || 1
if (rank > lvl) continue
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
if (props.stufeAnsicht === 'auto' && rank > 1 && rank === lvl) {
// gerade neu freigeschaltete Stufe: kräftigere Färbung (Rand), ohne Badge
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
} else {
// jede Stufe A/F/E/V leicht in ihrer Farbe kennzeichnen (Rand + Färbung), ohne Badge
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
}
}
h = parts.join('')
}
mdCache.set(key, h)
return h
}
// Sections render only when (almost) in the viewport — observer on the scroll container.
function setupLazy() {
mdObserver?.disconnect()
if (!scrollEl.value) return
mdObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue
visible[Number(e.target.dataset.num)] = true
mdObserver.unobserve(e.target)
}
}, { root: scrollEl.value, rootMargin: '800px 0px' })
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
}
watch(content, () => nextTick(setupLazy))
onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
watch(() => props.stufeAnsicht, () => mdCache.clear())
async function loadContent() {
content.value = null
loadError.value = null
doneChapters.value = new Set()
learnstate.value = {}
mdCache.clear()
for (const k in visible) delete visible[k]
const g = props.previewGuide
if (!g || g.status !== 'done') return
try {
content.value = await fetchGuideContent(g.id)
content.value = await fetchGuideContent(g.id, 4) // Stufen-Filterung passiert lokal (auto/Override)
} catch (e) {
console.error('Fehler beim Laden des Guides:', e)
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
console.error('Error loading guide:', e)
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
return
}
try {
const res = await fetchProgress(g.id)
doneChapters.value = new Set(res.chapters || [])
} catch { /* offline → leer */ }
nextTick(scrollToFirstOpen)
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
} catch { /* offline → empty */ }
// On open, scroll to the first not-yet-mastered checkable block.
await nextTick()
const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
if (target) scrollEl.value?.querySelector(`[data-num="${target.num}"]`)?.scrollIntoView({ block: 'start' })
}
// Zum ersten noch offenen Kapitel springen — aber nur, wenn schon etwas erledigt ist.
function scrollToFirstOpen() {
if (!doneChapters.value.size || !content.value) return
const chapters = Array.from(scrollEl.value?.querySelectorAll('section.chapter') || [])
const firstOpen = chapters.find((el) => !el.classList.contains('ch-complete'))
if (firstOpen && firstOpen !== chapters[0]) firstOpen.scrollIntoView({ block: 'start' })
// --- Block learning: exam state per block title (learnstate declared above) ---
function levelOf(title) {
const l = learnstate.value[title]
return l ? stufeFuer(l.good_answers || 0, l.cap || 10) : null
}
function onBlockStatus(block, status) {
const prevKey = levelOf(block)?.key || null
learnstate.value = { ...learnstate.value, [block]: status }
const newKey = stufeFuer(status.good_answers || 0, status.cap || 10)?.key || null
if (newKey !== prevKey) emit('progressChanged') // level change → reload locks/stats
}
// --- Kapitel-Fortschritt ---
async function toggleChapter(title) {
const newState = !doneChapters.value.has(title)
const optimistic = new Set(doneChapters.value)
if (newState) optimistic.add(title)
else optimistic.delete(title)
doneChapters.value = optimistic
try {
const res = await setProgress(props.previewGuide.id, title, newState)
doneChapters.value = new Set(res.chapters || [])
emit('progressChanged')
} catch {
const rollback = new Set(doneChapters.value)
if (newState) rollback.delete(title)
else rollback.add(title)
doneChapters.value = rollback
// Replace the section in content after check/fix/rewrite + clear the markdown cache.
function onSectionUpdated({ title, compact, md }) {
if (!content.value) return
for (const ch of content.value.chapters) {
for (const s of ch.sections) {
if (s.title !== title) continue
s.md = md
s.compact = compact
for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k)
}
}
}
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
// --- Fullscreen focus: one block large, guide left + exam right ---
const focusIndex = ref(null) // index in the flat block list; null = closed
const focusTab = ref('exam')
const blocks = computed(() =>
!content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
)
const focusBlock = computed(() => (focusIndex.value === null ? null : blocks.value[focusIndex.value]))
// Report focus state to App (for the sidebar above the overlay); a topic change closes the focus.
watch(focusIndex, (v) => emit('fokusActive', v !== null))
watch(() => props.previewGuide?.id, () => { focusIndex.value = null })
function openFocus(block, tab) {
if (!isCheckable(block)) return // read-only sections (Rest/edge) don't open an exam focus
const i = blocks.value.findIndex((s) => s.title === block.title)
if (i === -1) return
focusIndex.value = i
focusTab.value = tab || 'exam'
}
// Only jump to checkable sections in focus (FullGuide has read-only sections in between).
function focusPrev() {
for (let i = focusIndex.value - 1; i >= 0; i--) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
}
function focusNext() {
for (let i = focusIndex.value + 1; i < blocks.value.length; i++) if (isCheckable(blocks.value[i])) { focusIndex.value = i; return }
}
// Experience bar: cumulative level state over all blocks (gold ⊆ purple ⊆ blue ⊆ green).
const progress = computed(() => {
const z = { total: blocks.value.length, beginner: 0, advanced: 0, expert: 0, master: 0 }
for (const s of blocks.value) {
const l = learnstate.value[s.title]
if (!l) continue
const sc = l.good_answers || 0, cp = l.cap || 10
if (sc >= schwelle(0.2, cp)) z.beginner++
if (sc >= schwelle(0.4, cp)) z.advanced++
if (sc >= schwelle(0.6, cp)) z.expert++
if (sc >= schwelle(1.0, cp)) z.master++
}
return z
})
// cap per block = 4×relevant subblocks (provided by the backend in learnstate[title].cap).
function capOf(title) {
return learnstate.value[title]?.cap || 10
}
// Section checkable? Rest never. Otherwise checkable, unless the field is explicitly false (FullGuide edge).
// Missing field (old guides without `checkable`) → checkable, so existing guides keep working.
function isCheckable(s) {
return props.previewGuide?.format !== 'Rest' && s.checkable !== false
}
// --- Chat (mechanics in useChat; context extraction stays here) ---
const chat = useChat((msgs) => {
const { section, outline } = extractContext()
return chatGuide(props.previewGuide.id, {
section, outline, messages: msgs, provider: props.provider,
})
})
const { messages, input, loading, messagesEl, inputEl, send } = chat
const autoGrow = () => chat.autoGrow()
const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
const chatOpen = ref(false)
const panelEl = ref(null)
@@ -95,19 +217,13 @@ function closeChat() {
chat.reset()
}
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
watch(() => props.elementsOpen, (open) => {
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
})
function onDocMouseDown(e) {
if (!chatOpen.value) return
if (panelEl.value && panelEl.value.contains(e.target)) return
closeChat()
}
// Enter öffnet den Chat (wenn zu, nicht in Eingabefeld); ESC schließt ihn
// Enter opens the chat (when closed, not in an input field); ESC closes it
function onDocKeyDown(e) {
if (e.key === 'Escape' && chatOpen.value) {
e.preventDefault()
@@ -137,7 +253,7 @@ function extractContext() {
.join('\n')
.slice(0, 7000)
// Aktuelle Section = letzte Karte, deren Oberkante oben im Viewport oder darüber liegt
// Current section = last card whose top edge is at or above the top of the viewport
let section = ''
const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || [])
let current = null
@@ -155,16 +271,22 @@ function extractContext() {
<template>
<div class="detail">
<div v-if="previewGuide && content" ref="scrollEl" class="guide-scroll">
<div class="guide-content" :class="{ onepager: isOnePager }">
<div class="guide-content">
<header class="guide-head">
<h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="All blocks mastered"> Topic completed</span>
<span class="gh-spacer"></span>
<div class="ansicht-toggle" title="Toggle verbosity">
<button :class="{ active: ansichtModus === 'compact' }" @click="$emit('setAnsicht', 'compact')">Compact</button>
<button :class="{ active: ansichtModus === 'erklärend' }" @click="$emit('setAnsicht', 'erklärend')">Explanatory</button>
</div>
</header>
<section
v-for="(ch, ci) in content.chapters"
:key="ch.title"
class="chapter"
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
>
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
@@ -172,63 +294,73 @@ function extractContext() {
<article
v-for="s in ch.sections"
:key="s.num"
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']"
:style="isOnePager && s.key ? { gridArea: s.key } : null"
:data-num="s.num"
class="section-card"
:style="levelOf(s.title) ? { borderLeftColor: levelOf(s.title).farbe } : {}"
>
<h3>{{ s.title }}</h3>
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
<h3 :class="{ 'block-klick': isCheckable(s) }" @click="isCheckable(s) && openFocus(s, 'exam')">
{{ s.title }}
<template v-if="isCheckable(s) && levelOf(s.title)">
<span class="block-done" :style="{ color: levelOf(s.title).farbe, borderColor: levelOf(s.title).farbe }" :title="`${levelOf(s.title).label} (${capOf(s.title)})`">{{ levelOf(s.title).kurz }} {{ levelOf(s.title).label }}</span>
</template>
</h3>
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div>
<BlockPanel
v-if="isCheckable(s)"
mode="trigger"
:block="s.title"
:status="learnstate[s.title]"
:cap="capOf(s.title)"
:topic="previewGuide.topic"
@open-fokus="(tab) => openFocus(s, tab)"
/>
</article>
</div>
<button
v-if="!isOnePager"
class="ch-toggle"
:class="{ 'is-done': doneChapters.has(ch.title) }"
@click="toggleChapter(ch.title)"
>{{ doneChapters.has(ch.title) ? '✓ Erledigt rückgängig' : 'Kapitel als erledigt markieren' }}</button>
</section>
</div>
</div>
<div v-else-if="previewGuide" class="empty-preview">
<p>{{ loadError || 'Lade Inhalt…' }}</p>
<p>{{ loadError || 'Loading content…' }}</p>
</div>
<div class="empty-preview" v-else>
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
<p>Click a guide format to generate or open a preview.</p>
</div>
<button v-if="previewGuide && !chatOpen" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen && !elementsOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
<BlockFocus
v-if="focusBlock"
:block="focusBlock"
:topic="previewGuide.topic"
:guide-id="previewGuide.id"
:provider="provider"
:fortschritt="progress"
:status="learnstate[focusBlock.title]"
:cap="capOf(focusBlock.title)"
:stufe="viewLevelFor(focusBlock.title)"
:tab="focusTab"
:ansicht="ansichtModus"
:has-prev="focusIndex > 0"
:has-next="focusIndex < blocks.length - 1"
@prev="focusPrev"
@next="focusNext"
@close="focusIndex = null"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="(st) => onBlockStatus(st.block, st)"
@open-sidebar="$emit('openSidebar')"
@section-updated="onSectionUpdated"
/>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" title="Questions about the guide" @click="openChat">💬</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
<header class="chat-header">
<span>Fragen zum Guide</span>
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
<span>Questions about the guide</span>
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
</header>
<div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
<template v-for="(m, i) in messages" :key="i">
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="chat-msg user">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Denkt</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
rows="3"
placeholder="Frage stellen…"
@input="autoGrow"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Abbrechen' : 'Senden'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div>
<ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
placeholder="Frage stellen" :rows="3" auto-grow max-height="none" />
</div>
</div>
</template>
@@ -236,8 +368,8 @@ function extractContext() {
<style scoped>
.detail {
flex: 1;
/* Flex-Item darf schmaler werden als seine Code-Blöckesonst sprengt
deren Mindestbreite auf Mobile das Layout */
/* Flex item may shrink below its code blocksotherwise their
min width breaks the layout on mobile */
min-width: 0;
height: 100dvh;
position: relative;
@@ -246,7 +378,7 @@ function extractContext() {
.guide-scroll {
height: 100%;
overflow-y: auto;
/* Kein horizontales Pannen der ganzen Seite — Code-Blöcke scrollen intern */
/* No horizontal panning of the whole page — code blocks scroll internally */
overflow-x: hidden;
background: var(--bg-preview);
}
@@ -255,7 +387,7 @@ function extractContext() {
max-width: 880px;
margin: 0 auto;
padding: 2rem 2.5rem 5rem;
/* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */
/* Reading zoom only for the content — sidebar/chat stay unchanged */
zoom: 1;
}
@@ -270,18 +402,57 @@ function extractContext() {
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1.5rem;
/* Sticky: the verbosity toggle stays reachable while scrolling. */
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-preview);
padding: 0.6rem 0 0.5rem;
h1 {
font-size: 1.7rem;
}
}
.gh-spacer { flex: 1; }
.ansicht-toggle {
align-self: center;
display: inline-flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.ansicht-toggle button {
border: none;
background: var(--panel);
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.7rem;
cursor: pointer;
}
.ansicht-toggle button.active {
background: var(--accent);
color: var(--on-accent);
}
.guide-format {
color: var(--text-faint);
font-size: 0.9rem;
font-weight: 600;
}
.thema-done {
font-size: 0.8rem;
font-weight: 600;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border: 1px solid var(--level-master);
color: #8a6d12;
}
.chapter {
margin-bottom: 2.5rem;
}
@@ -310,10 +481,6 @@ function extractContext() {
font-weight: 700;
}
.chapter.ch-complete .sections {
opacity: 0.4;
}
.section-card {
background: var(--panel);
border: 1px solid var(--border);
@@ -322,8 +489,53 @@ function extractContext() {
margin-bottom: 0.75rem;
}
/* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */
.guide-content:not(.onepager) .section-card {
.block-done {
float: right;
margin-left: 0.5rem;
padding: 0.12rem 0.6rem;
font-size: 0.68em;
font-weight: 600;
line-height: 1.5;
border-radius: 999px;
background: var(--success-soft);
border: 1px solid var(--success-border);
color: var(--success);
white-space: nowrap;
}
/* Completed blocks: card visibly flips to green */
.guide-content .section-card.completed {
border-color: var(--success-border);
border-top: 3px solid var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--panel));
}
/* Understood blocks (10/10): purple */
.block-done.understood {
background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
border-color: var(--level-expert);
color: #6d28d9;
}
.guide-content .section-card.understood {
border-color: var(--level-expert);
border-top: 3px solid var(--level-expert);
background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
}
/* Mastered blocks (master path 25/25): gold */
.block-done.mastered {
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border-color: var(--level-master);
color: #8a6d12;
}
.guide-content .section-card.mastered {
border-color: var(--level-master);
border-top: 3px solid var(--level-master);
background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
}
/* Guides: cards carry the chapter accent color */
.guide-content .section-card {
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel));
}
@@ -336,136 +548,13 @@ function extractContext() {
}
}
/* OnePager: festes 3×3-Raster über volle Breite und Höhe.
Kein Lese-Zoom (bricht 100%-Höhen) — stattdessen sind die Schriften unten 1.2× skaliert. */
.guide-content.onepager {
max-width: none;
height: 100%;
zoom: 1;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
}
/* Title click opens the full view. */
.section-card h3.block-klick { cursor: pointer; width: fit-content; }
.section-card h3.block-klick:hover { color: var(--accent); }
.guide-content.onepager .guide-head,
.guide-content.onepager .chapter-title {
display: none; /* Thema steht in der Info-Karte — Platz fürs Raster */
}
.guide-content.onepager .chapter {
flex: 1;
min-height: 0;
margin-bottom: 0;
}
.guide-content.onepager .sections {
height: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-template-areas:
"info beispiel voraussetzungen"
"eigenschaften beispiel modern"
"eigenschaften zusammenhaenge veraltet";
gap: 0.6rem;
}
.guide-content.onepager .section-card {
margin-bottom: 0;
padding: 0.7rem 0.9rem;
min-height: 0;
overflow-y: auto;
h3 {
font-size: 1.06rem;
margin-bottom: 0.3rem;
}
.section-body {
font-size: 0.98rem;
}
}
/* Farbkodiertes Cheat-Sheet: feste Akzentfarbe + Icon pro Karte */
.op-info { --op-accent: #3b82f6; }
.op-eigenschaften { --op-accent: #8b5cf6; --op-icon: "☰"; }
.op-beispiel { --op-accent: #64748b; --op-icon: ""; }
.op-zusammenhaenge { --op-accent: #14b8a6; --op-icon: "⇄"; }
.op-voraussetzungen { --op-accent: #f59e0b; --op-icon: "✓"; }
.op-modern { --op-accent: #22c55e; --op-icon: "✦"; }
.op-veraltet { --op-accent: #ef4444; --op-icon: "⚠"; }
.guide-content.onepager .section-card.op-card {
border-top: 3px solid var(--op-accent);
background: color-mix(in srgb, var(--op-accent) 5%, var(--panel));
h3 {
color: var(--op-accent);
font-size: 0.95rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
h3::before {
content: var(--op-icon, "");
margin-right: 7px;
}
}
/* Info-Karte: Thema als große Headline statt Uppercase-Label */
.guide-content.onepager .op-card.op-info h3 {
font-size: 1.5rem;
text-transform: none;
letter-spacing: 0;
color: var(--text);
}
/* Mobil: eine Spalte in Quellreihenfolge (info → … → veraltet) */
@media (max-width: 900px) {
.guide-content.onepager {
height: auto;
}
.guide-content.onepager .sections {
height: auto;
display: flex;
flex-direction: column;
}
.guide-content.onepager .section-card {
overflow-y: visible;
}
}
.ch-toggle {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.8rem 1rem;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text-muted);
font: 600 0.9rem/1.2 inherit;
font-family: inherit;
text-align: center;
cursor: pointer;
transition: all 0.12s;
&:hover {
border-color: var(--accent);
color: var(--accent);
background: transparent;
}
&.is-done {
border-style: solid;
border-color: var(--success-border);
background: var(--success-soft);
color: var(--success);
}
}
/* Placeholder for not-yet-rendered sections (lazy render). Stable height,
so the IntersectionObserver detects the following cards in a staggered way. */
.section-body.skeleton { min-height: 160px; }
.empty-preview {
display: flex;
@@ -475,16 +564,16 @@ function extractContext() {
color: var(--text-muted);
}
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */
/* --- Markdown: base global (assets/markdown.css), here only reading-view overrides --- */
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */
/* Wide reading view: code scrolls horizontally instead of wrapping */
.markdown :deep(pre) {
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */
/* "Example" headings in cards as a subtle uppercase label */
.section-card .markdown :deep(h3) {
font-size: 0.74em;
text-transform: uppercase;
@@ -493,8 +582,8 @@ function extractContext() {
margin: 0.9em 0 0.35em;
}
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen
Code-Blöcke dürfen die volle Kartenbreite nutzen */
/* Readability: ~17px body text, line height 1.6, text column max ~70 chars
code blocks may use the full card width */
.section-body {
font-size: 1.0625rem;
line-height: 1.6;
@@ -506,12 +595,6 @@ function extractContext() {
max-width: 70ch;
}
.onepager .section-card .markdown :deep(p),
.onepager .section-card .markdown :deep(ul),
.onepager .section-card .markdown :deep(ol) {
max-width: none; /* OnePager-Zellen sind selbst schmal genug */
}
/* --- Chat --- */
.chat-fab {
position: fixed;
@@ -533,27 +616,6 @@ function extractContext() {
background: var(--accent-hover);
}
.elements-fab {
right: 5.25rem;
}
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
.chat-fab.shifted {
right: calc(1.5rem + 320px);
}
.chat-panel.shifted {
right: calc(1.5rem + 320px);
}
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */
@media (max-width: 768px) {
.chat-fab.shifted,
.chat-panel.shifted {
display: none;
}
}
.chat-panel {
position: fixed;
right: 1.5rem;
@@ -592,99 +654,7 @@ function extractContext() {
padding: 0 4px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
.chat-hint {
color: var(--text-faint);
font-size: 0.82rem;
text-align: center;
margin-top: 1rem;
}
.chat-msg {
max-width: 85%;
padding: 7px 11px;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-msg.markdown {
white-space: normal;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
align-items: stretch;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
min-height: 72px;
max-height: 200px;
overflow-y: auto;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.4;
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
flex-shrink: 0;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-input button.cancel {
background: var(--danger);
}
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
</style>

View File

@@ -1,90 +1,98 @@
<script setup>
import { ref, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js'
import { fetchSource } from '../api.js'
import SourceForm from './SourceForm.vue'
const props = defineProps({
topics: { type: Array, required: true },
projects: { type: Array, default: () => [] },
selectedTopic: { type: String, default: null },
stats: { type: Object, default: null },
fortschritt: { type: Object, default: () => ({}) },
locks: { type: Object, default: () => ({}) },
uiError: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] },
dismissedErrors: { type: Object, default: () => new Set() },
bausteine: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
blocks: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
activeBausteine: { type: Array, default: () => [] },
pinned: { type: Boolean, default: true },
dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' },
providers: { type: Array, default: () => [] },
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'practice', 'setProvider'])
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
const isOpen = (id) => openPanel.value === id
function togglePanel(id) {
openPanel.value = openPanel.value === id ? null : id
}
function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id)
return p ? p.available : true
}
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', 'minimax-direkt': 'MiniMax direkt', lokal: 'Lokal' }
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Local' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
// Tracker at the top of the navigation: total topics, created/completed per format
const trackerItems = computed(() => {
if (!props.stats) return []
const f = props.stats.formate || {}
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}`
const f = props.stats.formats || {}
const fmt = (k) => `${f[k]?.completed ?? 0}/${f[k]?.erstellt ?? 0}`
return [
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' },
{ label: 'MiniGuides', value: fmt('MiniGuide'), title: 'absolviert/erstellt' },
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
{ label: 'FullGuides', value: fmt('FullGuide'), title: 'absolviert/erstellt' },
{ label: 'Topics', value: String(props.stats.topics ?? 0), title: 'Topics incl. projects' },
{ label: 'Guides', value: fmt('Guide'), title: 'completed/created' },
]
})
const formats = [
{ key: 'OnePager', label: 'OnePager' },
{ key: 'MiniGuide', label: 'MiniGuide' },
{ key: 'Guide', label: 'Guide' },
{ key: 'FullGuide', label: 'FullGuide' },
]
const bausteineState = computed(() => {
if (props.bausteine.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none'
// Level views of the SINGLE guide (filtered by subblock depth).
const LEVEL_VIEWS = [
{ k: 'auto', label: '⟳', title: 'Auto — Stufe folgt deinem Prüfungs-Score je Block' },
{ k: 1, label: 'A', title: 'Beginner' },
{ k: 2, label: 'F', title: 'Beginner + Advanced' },
{ k: 3, label: 'E', title: 'up to Expert' },
{ k: 4, label: 'V', title: 'Complete (incl. edge)' },
]
const blocksState = computed(() => {
if (props.blocks.generating) return 'generating'
return props.blocks.ready ? 'done' : 'none'
})
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
// Re-run from a substep now happens in the blocks overview; the sidebar no longer has phase pills.
// Only OTHER topics — the selected topic shows its progress inline on the row
const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine
const blockLines = props.activeBausteine
.filter((b) => b.topic !== props.selectedTopic)
.map((b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`)
.map((b) => `${b.topic} Blocks: ${b.progress || 'Waiting…'}`)
const guideLines = props.allGuides
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`)
return [...bausteinLines, ...guideLines]
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Waiting…'}`)
return [...blockLines, ...guideLines]
})
const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBausteine() {
armOrRun('bausteine', () => emit('cancelBausteine'))
}
function confirmResetBausteine() {
armOrRun('bausteine', () => emit('resetBausteine'))
}
function handleBausteinePlay() {
if (bausteineState.value === 'generating') return
emit('bausteineClick', { instructions: '' })
// Name click = open the block overview (generate/resume/remove all live there now).
function onBlocksName() {
emit('openBausteineView')
}
function guideStatus(format) {
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger
// Guide den Lauf und ▶ würde Duplikate starten.
// Running generation takes precedence — otherwise an older finished
// guide masks the run and ▶ would start duplicates.
const latest = props.latestByFormat[format]
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
if (props.doneByFormat[format]) return 'done'
@@ -92,104 +100,105 @@ function guideStatus(format) {
return latest.status
}
// Schritt-Kugeln der Guide-Pipelines
const GUIDE_STEPS = ['Auswahl', 'Auswahl-Prüfung', 'Gliederung', 'Gliederungs-Prüfung', 'Schreiben', 'Lese-Prüfung']
const ONEPAGER_STEPS = ['Recherche', 'Recherche-Prüfung', 'Bauen', 'Prüfung']
// Kugeln werden wie bei den Bausteinen immer angezeigt:
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
function guideSteps(format) {
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
const st = guideStatus(format)
if (st === 'generating' || st === 'queued') {
const step = props.latestByFormat[format]?.step ?? -1
return labels.map((label, i) => ({
label,
state: i < step ? 'done' : i === step ? 'active' : 'pending',
}))
}
if (props.doneByFormat[format]) {
return labels.map((label) => ({ label, state: 'done' }))
}
if (abgebrochen(format)) {
const step = props.latestByFormat[format]?.step ?? 0
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
}
return labels.map((label) => ({ label, state: 'pending' }))
}
function errorMsg(format) {
const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand
return latest.error_msg || 'Fehler bei der Generierung'
if (aborted(format)) return '' // no red error — the Paused badge shows the state
return latest.error_msg || 'Generation failed'
}
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt
function abgebrochen(format) {
// Aborted run = partial progress present: ▶ resumes, ✕ deletes the progress
function aborted(format) {
const latest = props.latestByFormat[format]
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Abgebrochen')
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
}
// Name click: finished guide → preview, otherwise the generation view (start/cancel live there).
function handleFormatClick(format) {
const guide = props.doneByFormat[format]
if (guide) {
emit('preview', guide)
}
if (guide) emit('preview', guide)
else emit('openGuideBoard', format)
}
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
function playLock(format) {
return props.locks?.[format] ?? null
}
function handlePlay(format) {
if (playLock(format)) return
emit('formatClick', { format, instructions: '' })
}
// Flash-Message-Verhalten: × blendet nur aus, nichts wird gelöscht
// Flash-message behavior: × only hides, nothing is deleted
function dismissError(format) {
const latest = props.latestByFormat[format]
if (latest?.status === 'error') emit('dismissError', latest.id)
}
function handleDelete(format) {
if (!props.latestByFormat[format]) return
armOrRun('fmt-' + format, () => {
// Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab)
const running = props.allGuides.filter(
(g) => g.topic === props.selectedTopic && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running.length) {
for (const g of running) emit('cancelGuide', g.id)
} else if (abgebrochen(format)) {
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset)
emit('deleteGuide', props.latestByFormat[format].id, true)
} else {
emit('deleteGuide', props.latestByFormat[format].id)
}
})
// Create area: inline expandable (name + more info + source type).
const dlg = ref(false)
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
const canCreate = computed(() => {
if (!form.value.name.trim()) return false
if (['link', 'projekt', 'uni'].includes(form.value.sourceType)) return !!form.value.sourceOrt.trim()
return true
})
function toggleCreate() {
if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }
dlg.value = !dlg.value
}
const newTopic = ref('')
function submit() {
const t = newTopic.value.trim()
if (!t) return
emit('create', t)
newTopic.value = ''
function createTopic() {
if (!canCreate.value) return
emit('createThema', {
topic: form.value.name.trim(),
instructions: form.value.instructions.trim(),
sourceType: form.value.sourceType,
sourceOrt: form.value.sourceOrt.trim(),
})
dlg.value = false
}
function confirmDeleteTopic(topic) {
armOrRun('topic-' + topic, () => emit('deleteTopic', topic))
}
function confirmDeleteProject(name) {
armOrRun('project-' + name, () => emit('deleteProject', name))
// --- Topic edit: name click only selects; the chevron toggles the sources form (default closed) ---
const editTopic = ref(null)
const editForm = ref({ type: 'thema', ort: '', spec: '' })
const editLoading = ref(false)
const canSave = computed(() => {
if (['link', 'projekt', 'uni'].includes(editForm.value.type)) return !!editForm.value.ort.trim()
return true
})
async function toggleTopicPanel(t) {
emit('select', t)
if (openPanel.value === 'topic-' + t) { openPanel.value = null; return }
openPanel.value = 'topic-' + t
editTopic.value = t
editLoading.value = true
try {
const q = await fetchSource(t)
editForm.value = { type: q.type || 'thema', ort: q.location || '', spec: q.spec || '' }
} catch {
editForm.value = { type: 'thema', ort: '', spec: '' }
} finally {
editLoading.value = false
}
}
// v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
const createSource = computed({
get: () => ({ type: form.value.sourceType, ort: form.value.sourceOrt }),
set: (v) => { form.value.sourceType = v.type; form.value.sourceOrt = v.ort },
})
const editSource = computed({
get: () => ({ type: editForm.value.type, ort: editForm.value.ort }),
set: (v) => { editForm.value.type = v.type; editForm.value.ort = v.ort },
})
function saveSource() {
if (!canSave.value || !editTopic.value) return
emit('updateSource', {
topic: editTopic.value,
type: editForm.value.type,
ort: editForm.value.ort.trim(),
spec: editForm.value.spec.trim(),
})
openPanel.value = null
}
</script>
@@ -204,20 +213,47 @@ function confirmDeleteProject(name) {
<div class="new-topic">
<button
class="pin-btn"
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
:title="pinned ? 'Hide sidebar' : 'Pin sidebar'"
@click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button>
<button
class="theme-btn"
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
:title="dark ? 'Light mode' : 'Dark mode'"
@click="emit('toggleDark')"
>{{ dark ? '☀' : '🌙' }}</button>
<input
v-model="newTopic"
placeholder="Neues Thema…"
@keyup.enter="submit"
/>
<button @click="submit" :disabled="!newTopic.trim()">+</button>
<div v-if="selectedTopic" class="ansicht-toggle">
<button :class="{ active: ansichtModus === 'compact' }" title="Short text" @click="emit('setAnsicht', 'compact')">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="2" y="5" width="12" height="1.6" rx="0.8" />
<rect x="2" y="9" width="7" height="1.6" rx="0.8" />
</svg>
</button>
<button :class="{ active: ansichtModus === 'erklärend' }" title="Long text" @click="emit('setAnsicht', 'erklärend')">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="2" y="3" width="12" height="1.4" rx="0.7" />
<rect x="2" y="6.3" width="12" height="1.4" rx="0.7" />
<rect x="2" y="9.6" width="12" height="1.4" rx="0.7" />
<rect x="2" y="12.9" width="8" height="1.4" rx="0.7" />
</svg>
</button>
</div>
<div v-if="selectedTopic" class="level-toggle" title="Depth of guide view">
<button v-for="s in LEVEL_VIEWS" :key="s.k"
:class="{ active: stufeAnsicht === s.k }" :title="s.title"
@click="emit('setStufe', s.k)">{{ s.label }}</button>
</div>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Create topic" @click="toggleCreate">+</button>
</div>
<!-- Create: inline expandable (no modal) -->
<div v-if="dlg" class="thema-panel">
<input class="dlg-input" v-model="form.name" placeholder="Themenname…" @keyup.enter="createTopic" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
<div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
</div>
</div>
<div class="provider-toggle" v-if="providers.length">
<button
@@ -225,7 +261,7 @@ function confirmDeleteProject(name) {
:key="p.id"
:class="{ active: p.id === provider }"
:disabled="!p.available"
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
:title="p.available ? '' : 'Not configured (CLI/key missing)'"
@click="emit('setProvider', p.id)"
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
</div>
@@ -237,108 +273,62 @@ function confirmDeleteProject(name) {
<div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
</div>
<div
class="format-row bausteine-row ord-bausteine"
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial }"
>
<div class="format-name bausteine-name">
<span class="format-label">Bausteine</span>
<span
v-if="bausteine.partial && bausteineState !== 'generating'"
class="resume-badge"
title="Abgebrochen — ▶ setzt fort"
>Pausiert</span>
<span class="step-dots">
<div class="ord-blocks">
<div
class="format-row blocks-row"
:class="{ 'is-active': blocksState === 'generating' || blocks.partial }"
>
<button class="format-name blocks-name" @click="onBlocksName">
<span class="format-label">Blocks</span>
<span
v-for="s in (bausteine.steps || [])"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
></span>
</span>
<span
v-if="bausteineState === 'generating'"
class="format-x"
:class="{ armed: pendingConfirm === 'bausteine' }"
title="Aktuellen Schritt abbrechen (Fortschritt bleibt)"
@click.stop="confirmCancelBausteine"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
<span
v-else-if="bausteine.partial"
class="format-x"
:class="{ armed: pendingConfirm === 'bausteine' }"
title="Fortschritt löschen"
@click.stop="confirmResetBausteine"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
v-if="blocks.partial && blocksState !== 'generating'"
class="resume-badge"
title="Aborted — can be resumed"
>Paused</span>
</button>
</div>
<div class="format-actions">
<template v-if="bausteineState !== 'generating'">
<button
class="action-btn play"
:title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay"
></button>
</template>
<div v-if="blocksState === 'generating'" class="format-progress">
{{ blocks.progress || 'Waiting' }}
</div>
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
<span class="format-error-text">{{ blocks.error }}</span>
</div>
<div class="format-row">
<button class="format-name" @click="emit('openGeneration')">
<span class="format-label gen-label">Generierung</span>
<span v-if="blocksState === 'generating'" class="gen-live-dot" title="Läuft"></span>
</button>
</div>
</div>
<div v-if="bausteineState === 'generating'" class="format-progress ord-bausteine">
{{ bausteine.progress || 'Wartend' }}
</div>
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error ord-bausteine">
<span class="format-error-text">{{ bausteine.error }}</span>
</div>
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key) }]">
<!-- Formats come after the blocks row via CSS order (order 2) -->
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span>
<span
v-if="abgebrochen(f.key)"
v-if="aborted(f.key)"
class="resume-badge"
title="Abgebrochen — ▶ setzt fort"
>Pausiert</span>
<span class="step-dots" v-if="guideSteps(f.key).length">
<span
v-for="s in guideSteps(f.key)"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label"
></span>
</span>
<span
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
class="format-x"
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
@click.stop="handleDelete(f.key)"
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen'
: abgebrochen(f.key) ? 'Fortschritt löschen' : 'Löschen'"
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : '×' }}</span>
title="Aborted — can be resumed"
>Paused</span>
</button>
<div class="format-actions">
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button
class="action-btn play"
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')"
:disabled="!!playLock(f.key)"
@click="handlePlay(f.key)"
></button>
</template>
</div>
</div>
<div
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
class="format-progress"
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</div>
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
<div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div>
</div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span>
<div class="format-row ord-exam">
<button class="format-name elements-btn" @click="emit('generalExam')">
<span class="format-label">General Exam</span>
</button>
</div>
<div class="format-row ord-practice">
<button class="format-name elements-btn" @click="emit('practice')">
<span class="format-label">Üben</span>
</button>
</div>
</div>
@@ -347,34 +337,28 @@ function confirmDeleteProject(name) {
<li
v-for="t in topics"
:key="t"
:class="{ active: t === selectedTopic }"
@click="emit('select', t)"
:class="{ active: t === selectedTopic, 'li-open': isOpen('topic-' + t) }"
>
<span>{{ t }}</span>
<button
class="delete-topic"
:class="{ armed: pendingConfirm === 'topic-' + t }"
@click.stop="confirmDeleteTopic(t)"
title="Thema und alle Guides löschen"
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : '×' }}</button>
<div class="topic-row">
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)"></button>
</div>
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
<p v-if="editLoading" class="dlg-hint">Lädt</p>
<template v-else>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
<div class="dlg-actions">
<button
class="dlg-delete"
:class="{ armed: pendingConfirm === 'topic-' + t }"
@click="confirmDeleteTopic(t)"
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
</div>
</template>
</div>
</li>
<template v-if="projects.length">
<li class="projects-divider">Projekte</li>
<li
v-for="p in projects"
:key="'project-' + p"
:class="{ active: p === selectedTopic, 'project-item': true }"
@click="emit('select', p)"
>
<span>{{ p }}</span>
<button
class="delete-topic"
:class="{ armed: pendingConfirm === 'project-' + p }"
@click.stop="confirmDeleteProject(p)"
title="Projekt entfernen (löscht ./projects-Ordner)"
>{{ pendingConfirm === 'project-' + p ? 'Sicher?' : '×' }}</button>
</li>
</template>
</ul>
</aside>
</template>
@@ -455,6 +439,37 @@ function confirmDeleteProject(name) {
border-color: var(--accent-border);
}
/* View switcher: two icon buttons (short / long text) */
.ansicht-toggle { display: inline-flex; }
.new-topic .ansicht-toggle button {
padding: 4px 7px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
display: inline-flex;
align-items: center;
}
.new-topic .ansicht-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .ansicht-toggle button:last-child { border-radius: 0 6px 6px 0; border-left: none; }
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ansicht-toggle svg { fill: currentColor; display: block; }
.level-toggle { display: inline-flex; margin-left: 4px; }
.new-topic .level-toggle button {
padding: 4px 6px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-size: 11px;
font-weight: 600;
min-width: 20px;
}
.new-topic .level-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .level-toggle button:last-child { border-radius: 0 6px 6px 0; }
.new-topic .level-toggle button:not(:first-child) { border-left: none; }
.new-topic .level-toggle button:hover { color: var(--accent-hover); }
.new-topic .level-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.stats-bar {
display: flex;
padding: 0.5rem 0.75rem;
@@ -463,12 +478,12 @@ function confirmDeleteProject(name) {
}
.stat {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 4px 2px;
justify-content: center;
padding: 7px 2px 4px;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 6px;
@@ -481,11 +496,18 @@ function confirmDeleteProject(name) {
color: var(--text);
}
/* Mini title sits on the top edge of the badge (legend look) */
.stat-label {
font-size: 0.58rem;
position: absolute;
top: -0.42rem;
left: 50%;
transform: translateX(-50%);
padding: 0 3px;
font-size: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-faint);
background: var(--panel);
white-space: nowrap;
}
@@ -536,120 +558,78 @@ function confirmDeleteProject(name) {
}
.topic-list li {
padding: 0.6rem 1rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text);
transition: background 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
}
.topic-list li:hover {
.topic-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0.6rem 1rem;
cursor: pointer;
transition: background 0.15s;
}
.topic-row:hover {
background: var(--accent-soft);
}
.topic-list li.active {
.topic-list li.active .topic-row {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 600;
}
.delete-topic {
display: none;
background: none;
border: none;
color: var(--danger);
font-size: 1.1rem;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.topic-list li:hover .delete-topic {
display: block;
}
.projects-divider {
cursor: default;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
font-weight: 700;
padding: 0.6rem 1rem 0.3rem;
margin-top: 0.4rem;
border-top: 1px solid var(--border);
}
.projects-divider:hover {
background: none;
}
.topic-list li.project-item span::before {
content: '📁 ';
.topic-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Format section */
.bausteine-name {
.blocks-name {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
cursor: pointer;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-strong);
flex-shrink: 0;
}
.step-dot.done {
background: var(--success-border);
}
.step-dot.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.format-section {
flex-shrink: 0;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
/* flex + order: OnePager (order 1) vor Bausteine (order 2) vor den restlichen Formaten (order 3) */
/* flex + order: blocks (order 2) before the formats (order 3) */
display: flex;
flex-direction: column;
}
.ord-bausteine {
.gen-label { color: var(--text-muted); font-size: 0.86em; }
.gen-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: pulse-soft 1.2s ease-in-out infinite;
}
.ord-blocks {
order: 2;
}
.ord-elemente {
.ord-exam {
order: 4;
}
.ord-practice {
order: 5;
}
.elements-btn {
cursor: pointer;
color: var(--text);
@@ -668,14 +648,14 @@ function confirmDeleteProject(name) {
animation: pulse 1.5s ease-in-out infinite;
}
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */
/* Rejected action (409/400) — above all format rows */
.ui-error {
order: 0;
padding: 0.4rem 0.75rem;
background: var(--warning-soft);
}
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
/* Progress text directly below the running format row */
.format-progress {
padding: 0 0.75rem 5px calc(0.75rem + 8px);
font-size: 0.72rem;
@@ -710,13 +690,14 @@ function confirmDeleteProject(name) {
.format-name {
flex: 1;
min-width: 0;
background: none;
border: none;
text-align: left;
font-size: 0.85rem;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
cursor: pointer;
color: var(--text-faint);
display: flex;
align-items: center;
@@ -724,28 +705,28 @@ function confirmDeleteProject(name) {
gap: 8px;
}
.format-x {
display: none;
color: var(--danger);
font-size: 1.1rem;
line-height: 1;
/* Accordion: arrow toggle + expanding action panel */
.panel-toggle {
flex: 0 0 auto;
background: none;
border: none;
cursor: pointer;
padding: 0 2px;
color: var(--text-faint);
font-size: 0.8rem;
line-height: 1;
padding: 4px 8px;
transition: transform 0.15s, color 0.15s;
}
.panel-toggle.open { transform: rotate(180deg); color: var(--text); }
.panel-toggle:hover { color: var(--accent); }
.format-name:hover .format-x {
display: inline;
}
.format-row.row-open,
.topic-list li.li-open .topic-row { background: var(--panel-soft); }
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.bausteine-row.is-active .format-x {
display: inline;
}
.format-x.armed,
.panel-btn.danger:hover { border-color: var(--danger); }
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
.format-error-x.armed,
.delete-topic.armed {
display: inline-block;
@@ -808,48 +789,74 @@ function confirmDeleteProject(name) {
opacity: 1;
}
.format-actions {
display: flex;
gap: 2px;
margin-left: 6px;
}
.action-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s;
}
.action-btn.play {
color: var(--success);
}
.action-btn.play:hover {
background: var(--success-soft);
border-color: var(--success-border);
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.action-btn:disabled:hover {
background: none;
border-color: transparent;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* Create — inline expandable (no modal) */
.new-topic-toggle {
margin-left: auto; /* right-aligned instead of full width */
padding: 6px 12px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font: inherit;
font-size: 1.1rem;
line-height: 1;
text-align: center;
}
.new-topic-toggle:hover { background: var(--accent-hover); }
.new-topic-toggle.active { background: var(--accent-hover); }
.thema-panel {
margin: 0.4rem 0;
padding: 0.5rem;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
}
.dlg-input, .dlg-textarea {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
.dlg-textarea { resize: vertical; min-height: 2rem; }
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
.dlg-actions button {
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
}
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-create:disabled { opacity: 0.45; cursor: default; }
/* Topic edit panel: subtly inserted into the list — no box, just a left accent */
.edit-panel {
margin: 0;
padding: 0.5rem 1rem 0.7rem 1.25rem;
background: var(--accent-soft);
border: none;
border-left: 3px solid var(--accent-border);
border-radius: 0;
}
.dlg-delete { margin-right: auto; color: var(--danger); }
.dlg-delete.armed { background: var(--danger); border-color: var(--danger); color: #fff; font-weight: 700; }
select.dlg-input { cursor: pointer; }
</style>

Some files were not shown because too many files have changed in this diff Show More