Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2178c6faf4 | ||
|
|
f9d77a113b | ||
|
|
9a6ab0937b | ||
|
|
224ba1ed4f | ||
|
|
27364d0789 | ||
|
|
f2bc6bcedb | ||
|
|
41f96330fd | ||
|
|
f0c1bdacfa | ||
|
|
f2feb1dbd0 | ||
|
|
1441c01f2c | ||
|
|
9c55b05373 | ||
|
|
72a82ed9dd | ||
|
|
73ac3e532f | ||
|
|
cc27e53b9e | ||
|
|
f4c5116abb | ||
|
|
ed5cf146c3 | ||
|
|
250ea0b764 | ||
|
|
07e14fb82e | ||
|
|
fb3e967fdb | ||
|
|
6a765b7d89 | ||
|
|
9d5940e21f | ||
|
|
54cb6f10e7 | ||
|
|
9b6bfa45c6 | ||
|
|
db58d52567 | ||
|
|
219993ffd5 | ||
|
|
d25824229e | ||
|
|
4105146c59 | ||
|
|
2d9ca00b47 | ||
|
|
c05421a8c1 | ||
|
|
92c69c1561 | ||
|
|
8488737303 | ||
|
|
8d8f6c8e51 | ||
|
|
2f5d5b9ca1 | ||
|
|
c4caf31ed0 | ||
|
|
91b0d00aa1 | ||
|
|
9754cbcfae | ||
|
|
abcadd145d | ||
|
|
285317927d | ||
|
|
41c9f29a37 | ||
|
|
afa8b36105 |
No files matched your search
+20
-5
@@ -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.
|
||||
@@ -6,8 +9,20 @@ CLAUDE_CODE_OAUTH_TOKEN=
|
||||
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
|
||||
MINIMAX_API_KEY=
|
||||
|
||||
# Agent-Parallelität (optional). Zwei verschachtelte Limits, Default je 10 = bisheriges Verhalten.
|
||||
# Global gilt über ALLE Themen, der Thema-Wert je Thema. Lokal das globale Limit hochsetzen,
|
||||
# um mehrere Themen parallel mit je 10 Agenten zu fahren (z.B. global 50, Thema 10).
|
||||
# MAX_CONCURRENT_AGENTS=10
|
||||
# MAX_CONCURRENT_AGENTS_PER_TOPIC=10
|
||||
# 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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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 \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse 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
|
||||
|
||||
@@ -12,11 +12,12 @@ auth:
|
||||
@echo "Verzeichnisse angelegt und auf uid 1000 chowned."
|
||||
|
||||
install:
|
||||
pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright transformers trafilatura
|
||||
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
|
||||
@@ -46,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
|
||||
@@ -61,13 +63,18 @@ ollama:
|
||||
ollama pull qwen3.5:9b
|
||||
@echo "Ollama bereit — Provider 'Lokal' ist aktiv (Modelle anpassen: backend/config.py + dev-ops/opencode.json)."
|
||||
|
||||
# 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/themen
|
||||
@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 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/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`).
|
||||
# Mit --delete — exakter Spiegel: lokale Projekte ohne Server-Pendant werden gelöscht.
|
||||
@@ -83,15 +90,52 @@ sync-all: sync sync-projects
|
||||
# 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, themen/, projects/) mit dem lokalen."
|
||||
@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/themen/ root@178.104.67.87:/var/www/creator/storage/themen/
|
||||
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
|
||||
@@ -1,4 +1,127 @@
|
||||
# Creator — Features
|
||||
# Creator
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## 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 0–10 |
|
||||
| `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 (~5–8 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. 2–4 GB Swap anlegen.
|
||||
|
||||
---
|
||||
|
||||
# Features (Endnutzer-Sicht)
|
||||
|
||||
## Quellen
|
||||
- Freies Thema: die KI recherchiert den Stoff selbst im Web.
|
||||
|
||||
+329
-38
@@ -2,6 +2,10 @@
|
||||
|
||||
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
|
||||
@@ -11,30 +15,47 @@ 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
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE)
|
||||
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)
|
||||
|
||||
|
||||
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, runtime}] sorted longest-first."""
|
||||
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
|
||||
now = time.time()
|
||||
out = [{"key": k, "runtime": round(now - t, 1)}
|
||||
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 and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
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.
|
||||
@@ -88,44 +109,118 @@ class _PrioritySemaphore:
|
||||
self._value += 1
|
||||
|
||||
|
||||
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
|
||||
_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] = {}
|
||||
|
||||
# Earlier kanban columns get the scarce global slot first (smaller = higher priority).
|
||||
_STAGE_PRIORITY = ("research", "verify", "naming", "small", "dep")
|
||||
# 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) # downstream agents (subblocks/facts/…) after the inventory columns
|
||||
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _batch_gate(scope: str | None, priority: int):
|
||||
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."""
|
||||
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 _batch_sem.acquire(priority)
|
||||
await global_sem.acquire(priority)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_batch_sem.release()
|
||||
global_sem.release()
|
||||
if topic_sem is not None:
|
||||
topic_sem.release()
|
||||
|
||||
# Serialize OpenCode starts: processes starting simultaneously collide on the
|
||||
# internal session DB ("database is locked", exit after <1s). The short
|
||||
# stagger spreads out the starts; afterwards the processes run in parallel normally.
|
||||
# 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 = 1.0
|
||||
_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 = {
|
||||
@@ -144,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:
|
||||
@@ -162,6 +268,22 @@ 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())."""
|
||||
@@ -186,6 +308,11 @@ def kill_process(agent_key_prefix: str) -> None:
|
||||
_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(
|
||||
agent_key: str,
|
||||
prompt: str,
|
||||
@@ -196,23 +323,76 @@ async def run_agent(
|
||||
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"Unknown provider: {provider}"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
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})"
|
||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
||||
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"
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities, on_line=on_line)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
|
||||
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, stagger: bool = False, on_line=None) -> 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()
|
||||
|
||||
async def spawn():
|
||||
@@ -222,21 +402,33 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
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:
|
||||
async with _opencode_start_lock:
|
||||
process = await spawn()
|
||||
await asyncio.sleep(_OPENCODE_START_DELAY)
|
||||
else:
|
||||
process = await spawn()
|
||||
_active_processes[agent_key] = process
|
||||
_active_started[agent_key] = time.time()
|
||||
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:
|
||||
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")
|
||||
@@ -268,24 +460,53 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
)
|
||||
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
finally:
|
||||
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(agent_key) is process:
|
||||
del _active_processes[agent_key]
|
||||
_active_started.pop(agent_key, None)
|
||||
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, on_line=None) -> 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 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:
|
||||
@@ -296,20 +517,90 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
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, stagger=True, on_line=on_line)
|
||||
# `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")
|
||||
|
||||
|
||||
|
||||
@@ -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 (0–10), `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 (0–10).
|
||||
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"}
|
||||
@@ -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 35–55 Calls und ~14 min Wandzeit — bei p50 20–50 s pro Call zählt NUR die Zahl
|
||||
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
|
||||
= 4–5 Segmente, 6–9 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 (0–1 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]}}
|
||||
+911
-2298
File diff suppressed because it is too large.
Load diff
@@ -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 — 4–5 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),
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
+209
-55
@@ -9,6 +9,28 @@ 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
|
||||
|
||||
# Readability gate: deterministic checker (small German complexity model,
|
||||
@@ -22,12 +44,9 @@ READABILITY_MAX = 3.5 # section too hard when the sentence average is a
|
||||
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
|
||||
|
||||
# Block consolidation: semantic embedding clustering instead of an LLM list merge.
|
||||
# A small multilingual sentence embedding (mean-pool) builds the candidate clusters
|
||||
# GLOBALLY (no chunk loss) via cosine + union-find. Title variants of the same concept
|
||||
# ("Vertex Cover" / "Vertex Cover Definition") merge; the consensus then counts the
|
||||
# real readers per cluster (≥2 = consensus). If transformers/torch are missing or the model
|
||||
# won't load → embedding silently off, `_consolidate` falls back to the old panel-judge path.
|
||||
# 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".
|
||||
@@ -42,81 +61,159 @@ EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/s
|
||||
# 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
|
||||
# Cosine: two judge-subblocks state the SAME point → one cluster in the clarify majority vote.
|
||||
# Lower than _DUP because it must merge paraphrases (not just typo-variants). Empirically 0.80 keeps
|
||||
# distinct aspects (∈NP vs NP-hard) apart while clustering re-wordings of the same fact.
|
||||
EMBEDDING_SUB_SAME = 0.80
|
||||
# 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.91–0.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.27–0.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.75–0.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 → ~4–8, n=285 → ~12–22; das alte
|
||||
# statische „15–25" passte implizit nur zu n≈225–625).
|
||||
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", "10")) # global, all topics
|
||||
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "10")) # per topic
|
||||
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
|
||||
|
||||
# Inventory engine: streaming kanban dataflow (kanban.py) is the default; set "0" for the legacy ER pipeline.
|
||||
KANBAN_INVENTORY = os.getenv("KANBAN_INVENTORY", "1") != "0"
|
||||
# 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
|
||||
|
||||
# Research race: longer grace window. Research drives the whole block count;
|
||||
# with slow providers (e.g. MiniMax) ALL 5 agents should become done, not just
|
||||
# the quorum of 3. The per-agent timeout (TIMEOUTS["research"]=1800s) caps real hangs.
|
||||
RESEARCH_GRACE = 900
|
||||
|
||||
# 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
|
||||
|
||||
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
|
||||
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
|
||||
# Just add special rules here.
|
||||
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
|
||||
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
|
||||
"clubs", "events", "podcasts", "resources", "-u-",
|
||||
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
|
||||
]
|
||||
CRAWL_MIN_CHARS = 400 # too little text → out
|
||||
# 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
|
||||
|
||||
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
|
||||
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
|
||||
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
|
||||
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
|
||||
# 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.35–1.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 à 160–230 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 110–135 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 = {
|
||||
"research": (1800, 0), # fixed 30 min
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
|
||||
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (600, 90), # identify content per block in the chunk (web search)
|
||||
"content_check": (300, 10), # content exam per block in the package
|
||||
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
|
||||
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (300, 10), # judge decides contested levels in the chunk
|
||||
# Judge caps tightened 2026-07-04: judge p50 is 6–72 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
|
||||
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
||||
"writer": (600, 120), # per section in the chunk
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
}
|
||||
|
||||
# Purpose per format — flows into the outline judge (what the guide should achieve).
|
||||
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
|
||||
FORMAT_PURPOSE = {
|
||||
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
|
||||
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
|
||||
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
|
||||
# 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),
|
||||
}
|
||||
|
||||
# Provider stacks: completely independent, any one can be removed at any time.
|
||||
@@ -125,7 +222,10 @@ FORMAT_PURPOSE = {
|
||||
# "judge" = mapping/judge/check agents — cold (low temperature,
|
||||
# no thinking) for stable verdicts; Claude/local map to "fast",
|
||||
# "guide" = large generation (proposals, writer).
|
||||
DEFAULT_PROVIDER = "claude"
|
||||
# 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",
|
||||
@@ -141,7 +241,8 @@ PROVIDERS = {
|
||||
"cli": "opencode",
|
||||
"guide": "minimax/MiniMax-M3",
|
||||
"fast": "minimax-kalt/MiniMax-M2.7-highspeed",
|
||||
"judge": "minimax-kalt/MiniMax-M3",
|
||||
"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",
|
||||
},
|
||||
@@ -155,3 +256,56 @@ PROVIDERS = {
|
||||
"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()
|
||||
+23
-51
@@ -1,9 +1,9 @@
|
||||
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
|
||||
"""Page loader for link sources — renders JS via Playwright (Chromium).
|
||||
|
||||
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
|
||||
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
|
||||
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
|
||||
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
|
||||
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
|
||||
@@ -17,8 +17,6 @@ from fsutil import atomic_write_text
|
||||
|
||||
log = logging.getLogger("creator.crawl")
|
||||
|
||||
MAX_DEPTH = 3
|
||||
MAX_PAGES = 500
|
||||
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
|
||||
@@ -33,7 +31,7 @@ def _fetch_bytes(url: str) -> bytes | None:
|
||||
data = resp.read(MAX_BYTES + 1)
|
||||
return None if len(data) > MAX_BYTES else data
|
||||
except Exception as e:
|
||||
log.debug("crawl: PDF fetch failed %s: %s", url, e)
|
||||
log.debug("load: PDF fetch failed %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -47,21 +45,6 @@ def _is_pdf(url: str) -> bool:
|
||||
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
|
||||
|
||||
|
||||
def _scope_prefix(start_url: str) -> str:
|
||||
"""First non-empty path segment of the start URL as the crawl scope, e.g.
|
||||
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
|
||||
seg = [s for s in urlparse(start_url).path.split("/") if s]
|
||||
return f"/{seg[0]}" if seg else ""
|
||||
|
||||
|
||||
def _in_scope(url: str, prefix: str) -> bool:
|
||||
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
|
||||
if not prefix:
|
||||
return True
|
||||
p = urlparse(url).path
|
||||
return p == prefix or p.startswith(prefix + "/")
|
||||
|
||||
|
||||
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)."""
|
||||
@@ -78,34 +61,34 @@ def _page_text(page) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
|
||||
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
|
||||
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`.
|
||||
|
||||
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
|
||||
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
|
||||
so a restart crawls again. Returns the number of saved sources.
|
||||
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 crawling then fails.
|
||||
# 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)
|
||||
domain = urlparse(start_url).netloc
|
||||
prefix = _scope_prefix(start_url) # only follow links under this path segment
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
|
||||
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:
|
||||
while queue and saved < max_pages:
|
||||
for url in todo:
|
||||
if cancelled and cancelled():
|
||||
return saved # abort → NO .done marker → restart crawls again
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
return saved # abort → NO .done marker → restart loads again
|
||||
|
||||
# PDFs need no rendering — load directly.
|
||||
if _is_pdf(url):
|
||||
@@ -120,7 +103,7 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
|
||||
except Exception as e:
|
||||
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
|
||||
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:
|
||||
@@ -129,20 +112,9 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
|
||||
if text:
|
||||
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
|
||||
saved += 1
|
||||
if depth < max_depth:
|
||||
try:
|
||||
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
|
||||
except Exception:
|
||||
hrefs = []
|
||||
for href in hrefs:
|
||||
nxt = urldefrag(href)[0]
|
||||
if (nxt.startswith(("http://", "https://"))
|
||||
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
|
||||
and nxt not in seen):
|
||||
queue.append((nxt, depth + 1))
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
|
||||
log.info("crawl %s → %d sources in %s", start_url, saved, target)
|
||||
log.info("load_pages → %d sources in %s", saved, target)
|
||||
return saved
|
||||
+962
-512
File diff suppressed because it is too large.
Load diff
@@ -1,261 +0,0 @@
|
||||
"""Elements (personal summary) and tutor chat for the 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 _read_json_file
|
||||
from paths import blocks_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"{'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."
|
||||
|
||||
|
||||
# --- Elements ---
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validate AI element JSON and normalize it onto the DB fields."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
lists = {}
|
||||
for key in ("examples", "hints"):
|
||||
raw = data.get(key, [])
|
||||
lists[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": lists["examples"],
|
||||
"hints": lists["hints"],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Blocks + guide content of the topic as context text (truncated)."""
|
||||
parts: list[str] = []
|
||||
bp = blocks_path(topic)
|
||||
if bp.exists():
|
||||
parts.append(bp.read_text(encoding="utf-8"))
|
||||
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
|
||||
content = _read_json_file(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 # the best available guide is enough
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(no material available)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
||||
"""Create element fields via AI. Fallback: only the title from the keyword."""
|
||||
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
|
||||
try:
|
||||
context = _topic_context(topic)
|
||||
if extra_context.strip():
|
||||
context = (extra_context.strip() + "\n\n" + context)[:12000]
|
||||
prompt = _prompt(
|
||||
"Element-Create",
|
||||
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
|
||||
context=context,
|
||||
)
|
||||
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 creation failed", topic, exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
"""Validate suggestion JSON from AI output. None on invalid 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:
|
||||
"""Two-step check for missing info: research → verify. None on error."""
|
||||
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"])
|
||||
|
||||
# Step 1: research — collect candidates broadly
|
||||
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 []
|
||||
|
||||
# Step 2: verify — only let important, non-redundant items through
|
||||
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 check failed", 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:
|
||||
"""Validate a change suggestion from AI output against the 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 ("remove", "adjust", "add"):
|
||||
return None
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
return None
|
||||
if action in ("adjust", "add") and not content:
|
||||
return None
|
||||
if action == "remove" and target not in ("examples", "hints"):
|
||||
return None
|
||||
# Index only for adjust/remove on list fields; must exist
|
||||
if target in ("examples", "hints") and action in ("adjust", "remove"):
|
||||
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 about the element. Returns (reply, change suggestions) — changes nothing directly."""
|
||||
error = "Sorry, that didn't work. Please try again."
|
||||
try:
|
||||
transcript = "\n".join(
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {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 error, []
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return error, []
|
||||
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
|
||||
return reply, changes
|
||||
except Exception:
|
||||
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
|
||||
return error, []
|
||||
|
||||
|
||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Check an element against the style rules and suggest changes. None on error."""
|
||||
try:
|
||||
prompt = _prompt("Element-Style", 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] Style check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
||||
"""Revise a single suggestion per user instruction. None on error."""
|
||||
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] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
@@ -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 150–1080-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).
|
||||
@@ -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**
|
||||
+72
-685
@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,42 +18,27 @@ import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from blocks import _convert_pdfs, source_folder
|
||||
from config import (
|
||||
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
|
||||
READABILITY_ACTIVE, TEMPLATES_DIR,
|
||||
)
|
||||
import readability
|
||||
from database import list_guides, update_guide, list_blocks, list_subblocks, set_guide_content, get_guide_content, get_outline
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text
|
||||
from paths import blocks_path, guide_content_path, project_dir, subblocks_path
|
||||
from pipeline import (
|
||||
CANCELLED, FAILED, GenContext, _claude_error, _extra,
|
||||
_fail, _gather_error, _gather_progress, _log, _prompt, _race,
|
||||
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
|
||||
is_guide_cancelled, run_single_slot,
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks,
|
||||
_title, _resolve_title, _title_index,
|
||||
)
|
||||
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 = ("Outline", "Content", "Content-Check", "Writing", "Reading-Exam")
|
||||
|
||||
# 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).
|
||||
GUIDE_CHUNK = 10
|
||||
|
||||
# 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.
|
||||
CHECK_PANEL = 3
|
||||
|
||||
# Reading exam: only ONE round (Check + Fix). Follow-up rounds added little value
|
||||
# (1 agent per block checks finely anyway) but cost extra agents.
|
||||
READING_ROUNDS = 1
|
||||
|
||||
|
||||
# Valid level values: new (learning path) + old (difficulty) backward-compatible.
|
||||
@@ -63,16 +47,16 @@ _LEVELS_OK = ("beginner", "advanced", "expert", "easy", "medium", "hard")
|
||||
|
||||
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)."""
|
||||
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"] and r["level"] in _LEVELS_OK:
|
||||
try:
|
||||
facts = json.loads(r["facts"]) if r.get("facts") else {}
|
||||
except (ValueError, TypeError):
|
||||
facts = {}
|
||||
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": r["level"], "relevance": r["relevance"], "facts": facts})
|
||||
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
|
||||
if out:
|
||||
return out
|
||||
data = _json_file(subblocks_path(topic))
|
||||
@@ -93,26 +77,8 @@ def _level_label(s: dict) -> str:
|
||||
return "peripheral" if s.get("relevance") == "peripheral" else (s.get("level") or "beginner")
|
||||
|
||||
|
||||
def _assignment_subs(chunk: list[dict], entries: dict[int, str], subs_by_title: dict[str, list[dict]]) -> str:
|
||||
"""Lists the blocks per chapter, with their subblocks and level labels beneath."""
|
||||
lines: list[str] = []
|
||||
for ch in chunk:
|
||||
lines.append(f"CHAPTER: {ch['title']}")
|
||||
for num in ch["nums"]:
|
||||
lines.append(f"- {entries[num]}")
|
||||
for s in subs_by_title.get(_title(entries[num]), []):
|
||||
lines.append(f" [{_level_label(s)}] {s['title']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _guide_files(content_path: Path) -> dict:
|
||||
d, stem = content_path.parent, content_path.stem
|
||||
return {
|
||||
"outline_slots": [d / f"{stem}.outline-{i}.json" for i in (1, 2, 3)],
|
||||
"outline": d / f"{stem}.outline.json", # judge output
|
||||
# chunk/reading-check/fix files are dynamic:
|
||||
# {stem}.chunk-i.md, {stem}.lese-check-r{n}-{i}.json, {stem}.fix-r{n}-{i}.md
|
||||
}
|
||||
|
||||
|
||||
def guide_slot_files(content_path: Path) -> list[Path]:
|
||||
@@ -120,132 +86,23 @@ def guide_slot_files(content_path: Path) -> list[Path]:
|
||||
return [p for p in content_path.parent.glob(f"{content_path.stem}.*") if p != content_path]
|
||||
|
||||
|
||||
def _done_path(content_path: Path) -> Path:
|
||||
return content_path.parent / f"{content_path.stem}.done"
|
||||
|
||||
|
||||
def guide_done_step(content_path: Path) -> int:
|
||||
"""Highest FULLY completed step index (marker per topic+format). -1 = none.
|
||||
If the content file exists, all steps are done."""
|
||||
if content_path.exists():
|
||||
return len(GUIDE_STEPS) - 1
|
||||
try:
|
||||
return int(_done_path(content_path).read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return -1
|
||||
|
||||
|
||||
def _set_done(content_path: Path, step: int) -> None:
|
||||
"""Set marker to `step` — monotone (only increase), except on the re-run reset (force)."""
|
||||
if step > guide_done_step(content_path):
|
||||
atomic_write_text(_done_path(content_path), str(step))
|
||||
|
||||
|
||||
def _reset_done(content_path: Path, step: int) -> None:
|
||||
"""Set marker hard to `step` (for re-run from step; step may decrease)."""
|
||||
if step < 0:
|
||||
_done_path(content_path).unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write_text(_done_path(content_path), str(step))
|
||||
|
||||
|
||||
# Slot-file globs per step (index = GUIDE_STEPS). Stem-anchored, collision-free.
|
||||
_STEP_GLOBS = (
|
||||
("outline*",), # 0 Outline (incl. selection filter)
|
||||
("content-chunk-*", "content-nach-*"), # 1 Content (incl. follow-up round)
|
||||
("content-check-*", "content-fix-*"), # 2 Content-Check
|
||||
("chunk-*",), # 3 Writing (chunk-* also matches chunk-nach-*)
|
||||
("lese-check-*", "fix-r*"), # 4 Reading-Exam
|
||||
)
|
||||
|
||||
|
||||
def _reset_guide_from_step(content_path: Path, step: int) -> None:
|
||||
"""Re-run from step: delete content + all slot files of steps ≥ step.
|
||||
Earlier steps stay → the resume rebuilds from `step` (everything below is reused)."""
|
||||
content_path.unlink(missing_ok=True) # no longer "done" → no fresh-start wipe
|
||||
d, stem = content_path.parent, content_path.stem
|
||||
for globs in _STEP_GLOBS[step:]:
|
||||
for pat in globs:
|
||||
for p in d.glob(f"{stem}.{pat}"):
|
||||
p.unlink(missing_ok=True)
|
||||
_reset_done(content_path, step - 1) # steps < step count as done
|
||||
|
||||
|
||||
def _read_problems_schema(data):
|
||||
"""{"ok": true} → [] · {"problems": [{"section", "problem"}]} → list · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if data.get("ok") is True:
|
||||
return []
|
||||
p = data.get("problems")
|
||||
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 _panel_problems(judge_paths: list[Path], valid: set[int], idx: dict[str, int]) -> dict[int, str]:
|
||||
"""Panel aggregation: several judge outputs of a chunk → flagged {num: problem}.
|
||||
|
||||
One vote per judge that names a section. Flagged when more than half of the
|
||||
DELIVERED (validly parsed) judges name it (3→≥2, 2→≥2, 1→≥1). Robust against a
|
||||
single failure: missing files do not count. Problem text from the first naming judge.
|
||||
"""
|
||||
outputs = [p for p in (_read_problems_schema(_json_file(j)) for j in judge_paths) if p is not None]
|
||||
if not outputs:
|
||||
return {}
|
||||
votes: dict[int, int] = {}
|
||||
problem: dict[int, str] = {}
|
||||
for out in outputs:
|
||||
seen: set[int] = set()
|
||||
for item in out:
|
||||
num = _resolve_title(idx, item["section"])
|
||||
if num is None or num not in valid or num in seen:
|
||||
continue
|
||||
seen.add(num)
|
||||
votes[num] = votes.get(num, 0) + 1
|
||||
problem.setdefault(num, item["problem"])
|
||||
threshold = len(outputs) / 2
|
||||
return {num: problem[num] for num, v in votes.items() if v > threshold}
|
||||
|
||||
|
||||
def _resolve_outline(data, entries: dict[int, str], target_min: int, target_max: int) -> list[dict] | None:
|
||||
"""{"chapters": [{"title", "numbers": [1, 3, 7]}]} → [{"title", "nums"}].
|
||||
|
||||
Numbers are the IDs from `entries` (1-based, as presented to the agent).
|
||||
`target_min`/`target_max` = allowed range of selected blocks (with a small tolerance).
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("chapters"), list):
|
||||
return None
|
||||
valid = set(entries)
|
||||
chapters: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
total = unknown = 0
|
||||
for ch in data["chapters"]:
|
||||
if not isinstance(ch, dict) or not isinstance(ch.get("numbers"), list):
|
||||
return None
|
||||
nums = []
|
||||
for t in ch["numbers"]:
|
||||
total += 1
|
||||
num = t if isinstance(t, int) and not isinstance(t, bool) else None
|
||||
if num is None or num not in valid:
|
||||
unknown += 1
|
||||
elif num not in seen:
|
||||
nums.append(num)
|
||||
seen.add(num)
|
||||
if nums:
|
||||
chapters.append({"title": str(ch.get("title", "")).strip() or "Chapter", "nums": nums})
|
||||
if not chapters or total == 0:
|
||||
return None
|
||||
if (total - unknown) / total < 0.85:
|
||||
return None
|
||||
if len(seen) < 0.9 * target_min or len(seen) > 1.1 * target_max:
|
||||
return None
|
||||
return chapters
|
||||
|
||||
|
||||
def _fallback_outline(entries: dict[int, str]) -> list[dict]:
|
||||
@@ -324,515 +181,6 @@ async def _outline_from_db(topic: str, sel_entries: dict[int, str]) -> list[dict
|
||||
return plan or None
|
||||
|
||||
|
||||
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)
|
||||
zweck = FORMAT_PURPOSE[format_name]
|
||||
|
||||
# Subblocks per block (DB-first) — loaded early: drives selection + sub-filter per format.
|
||||
# Missing → {} (fallback: guide takes everything).
|
||||
subs_raw = await _load_subblocks(topic)
|
||||
# Extract-once grounding: stored, verified facts replace the generic source hint.
|
||||
# The content agent phrases from them instead of reading the source again.
|
||||
if (facts_block := _facts_grounding(subs_raw)):
|
||||
facts = facts_block
|
||||
|
||||
def _has_relevance(num, kind):
|
||||
return any(isinstance(s, dict) and s.get("relevance") == kind for s in subs_raw.get(_title(entries[num]), []))
|
||||
|
||||
# Selection: ONE full document with ALL blocks (incl. peripheral). The views E/M/S/F
|
||||
# filter later per subblock level. (FullGuide/Rest remain as legacy branches.)
|
||||
if format_name == "Rest":
|
||||
selection = [num for num in entries if not _has_relevance(num, "relevant")]
|
||||
else: # Guide / FullGuide → all blocks
|
||||
selection = list(entries)
|
||||
if not selection:
|
||||
await _fail(guide_id, "No matching blocks for this format")
|
||||
return None
|
||||
|
||||
sel_entries = {num: entries[num] for num in selection}
|
||||
target = len(sel_entries)
|
||||
# Numbered list (ID = block number from entries) — agents/judge order by number.
|
||||
sel_list = "\n".join(f"{num}. {t}" for num, t in sel_entries.items())
|
||||
|
||||
# Step 0: outline. Prefers the blocks artifact (DB) — the guide only presents,
|
||||
# no longer structures itself. Missing (legacy) → previous agents/judge logic as fallback.
|
||||
# 0 valid → code fallback, 1 → direct, ≥2 → judge (with proposal as fallback).
|
||||
plan = await _outline_from_db(topic, sel_entries)
|
||||
if plan is not None:
|
||||
_log(topic, f"Outline from blocks artifact ({len(plan)} chapters)")
|
||||
if plan is None:
|
||||
plan = _resolve_outline(_json_file(files["outline"]), sel_entries, target, target)
|
||||
if plan is None:
|
||||
await _set_step(guide_id, 0, "Outline proposals (3 agents)…")
|
||||
files["outline"].unlink(missing_ok=True)
|
||||
proposals: list[list[dict]] = []
|
||||
pending = []
|
||||
for i, path in enumerate(files["outline_slots"], 1):
|
||||
res = _resolve_outline(_json_file(path), sel_entries, target, target)
|
||||
if res is not None:
|
||||
proposals.append(res)
|
||||
else:
|
||||
pending.append((i, path))
|
||||
if len(proposals) < 3 and pending:
|
||||
slots = [
|
||||
{
|
||||
"key": f"{guide_id}-outline-{i}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Outline",
|
||||
topic=topic, format_name=format_name, blocks=sel_list,
|
||||
out_path=path, extra=_extra(instructions),
|
||||
),
|
||||
"role": "guide", "capabilities": "files",
|
||||
"payload": (lambda result, p=path: _resolve_outline(_json_file(p), sel_entries, target, target)),
|
||||
}
|
||||
for i, path in pending
|
||||
]
|
||||
# Quorum 1: take whatever comes — no minimum requirement, no abort.
|
||||
new = await _race(
|
||||
topic, "Outline", slots, 1, _timeout("plan", target),
|
||||
provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE,
|
||||
)
|
||||
if is_cancelled():
|
||||
return None
|
||||
proposals += new or []
|
||||
|
||||
if not proposals:
|
||||
_log(topic, "Outline: no valid proposal — deterministic fallback")
|
||||
plan = _fallback_outline(sel_entries)
|
||||
elif len(proposals) == 1:
|
||||
plan = proposals[0] # one proposal → no judge needed
|
||||
else:
|
||||
await _set_step(guide_id, 0, "Merging outlines…")
|
||||
proposals_text = "\n\n".join(
|
||||
f"### Proposal {i}\n"
|
||||
+ "\n".join(f"CHAPTER: {ch['title']}\n Numbers: {', '.join(str(num) for num in ch['nums'])}" for ch in v)
|
||||
for i, v in enumerate(proposals, 1)
|
||||
)
|
||||
status, plan = await run_single_slot(
|
||||
ctx, "Outline-Judge",
|
||||
key=f"{guide_id}-outline-judge",
|
||||
prompt=_prompt(
|
||||
"Guide-Outline-Judge",
|
||||
topic=topic, format_name=format_name, purpose=zweck, n=len(proposals),
|
||||
blocks=sel_list, outlines=proposals_text,
|
||||
out_path=files["outline"], extra=_extra(instructions),
|
||||
),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _resolve_outline(_json_file(files["outline"]), sel_entries, target, target),
|
||||
timeout=_timeout("plan_judge", target),
|
||||
)
|
||||
if status == CANCELLED:
|
||||
return None
|
||||
if status == FAILED or plan is None:
|
||||
_log(topic, "Outline judge produced no result — best proposal kept")
|
||||
plan = proposals[0]
|
||||
|
||||
# Guarantee: every selected block is in the plan (against dropping agents/judges).
|
||||
plan = _with_remainder(plan, sel_entries)
|
||||
_set_done(content_path, 0) # outline ready
|
||||
|
||||
# Coarse chunks (~GUIDE_CHUNK blocks per agent) for content, content-check and reading-exam.
|
||||
# The writer builds per block beneath (its own fine chunks, see below) → variable lengths.
|
||||
total_sections = sum(len(c["nums"]) for c in plan)
|
||||
chunks = _split_chunks(plan, max(1, math.ceil(total_sections / GUIDE_CHUNK)))
|
||||
# Subblocks per block: Guide/FullGuide take ALL (incl. peripheral → level 4 in the view);
|
||||
# only the legacy Rest branch filters to peripheral. So the one document carries all levels.
|
||||
if format_name == "Rest":
|
||||
subs_by_title = {t: [s for s in subs if s.get("relevance") == "peripheral"] for t, subs in subs_raw.items()}
|
||||
else: # Guide / FullGuide
|
||||
subs_by_title = {t: list(subs) for t, subs in subs_raw.items()}
|
||||
subs_by_title = {t: subs for t, subs in subs_by_title.items() if subs}
|
||||
assignments = [_assignment_subs(chunk, entries, subs_by_title) for chunk in chunks]
|
||||
chunk_sizes = [sum(len(c["nums"]) for c in chunk) for chunk in chunks]
|
||||
writer_count = len(chunks)
|
||||
idx = _title_index(entries)
|
||||
|
||||
# Step 2: identify content per block — one agent per chunk (marker output, resume).
|
||||
content_paths = [content_path.parent / f"{content_path.stem}.content-chunk-{i}.md" for i in range(1, writer_count + 1)]
|
||||
pending = [i for i, p in enumerate(content_paths) if not p.exists()]
|
||||
if pending:
|
||||
async def report(d, t): await _set_step(guide_id, 1, f"Gathering content {d}/{t}…")
|
||||
results = await _gather_progress([
|
||||
run_agent(
|
||||
f"{guide_id}-content-{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Content",
|
||||
topic=topic, assignment=assignments[i], facts=facts,
|
||||
out_path=content_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("content", chunk_sizes[i]), provider=provider, role="guide", capabilities="full", scope=topic,
|
||||
)
|
||||
for i in pending
|
||||
], writer_count, report, start=writer_count - len(pending))
|
||||
if is_cancelled():
|
||||
return None
|
||||
if not any(p.exists() for p in content_paths):
|
||||
await _fail(guide_id, _gather_error("Content error", list(results)))
|
||||
return None
|
||||
|
||||
content_by_num: dict[int, str] = {}
|
||||
for p in content_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _resolve_title(idx, sec["title"])
|
||||
if num is not None and num not in content_by_num and sec["md"].strip():
|
||||
content_by_num[num] = sec["md"]
|
||||
if not content_by_num:
|
||||
await _fail(guide_id, "No content identified")
|
||||
return None
|
||||
|
||||
# Follow-up round: pull missing blocks (chunk failure or lazy output) deliberately — one round.
|
||||
planned_nums = [num for ch in plan for num in ch["nums"]]
|
||||
missing = [num for num in planned_nums if num not in content_by_num]
|
||||
if missing:
|
||||
_log(topic, f"Content: {len(missing)} block(s) missing — follow-up round…")
|
||||
followup_chunks = [[{"title": "Additional", "nums": missing[k:k + GUIDE_CHUNK]}] for k in range(0, len(missing), GUIDE_CHUNK)]
|
||||
followup_paths = [content_path.parent / f"{content_path.stem}.content-nach-{k}.md" for k in range(1, len(followup_chunks) + 1)]
|
||||
followup_pending = [k for k, p in enumerate(followup_paths) if not p.exists()]
|
||||
if followup_pending:
|
||||
async def report_n(d, t): await _set_step(guide_id, 1, f"Gathering missing content {d}/{t}…")
|
||||
await _gather_progress([
|
||||
run_agent(
|
||||
f"{guide_id}-content-nach-{k + 1}",
|
||||
_prompt(
|
||||
"Guide-Content",
|
||||
topic=topic, assignment=_assignment_subs(followup_chunks[k], entries, subs_by_title),
|
||||
facts=facts, out_path=followup_paths[k], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("content", len(followup_chunks[k][0]["nums"])), provider=provider, role="guide", capabilities="full", scope=topic,
|
||||
)
|
||||
for k in followup_pending
|
||||
], len(followup_chunks), report_n, start=len(followup_chunks) - len(followup_pending))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in followup_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _resolve_title(idx, sec["title"])
|
||||
if num is not None and num not in content_by_num and sec["md"].strip():
|
||||
content_by_num[num] = sec["md"]
|
||||
|
||||
if all(p.exists() for p in content_paths):
|
||||
_set_done(content_path, 1) # content complete
|
||||
|
||||
content_chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in content_by_num] for chunk in chunks]
|
||||
|
||||
# Step 3: check content — CHECK_PANEL judges per chunk, majority flags.
|
||||
# + revise flagged ones once. Resume: only restart missing judge files.
|
||||
check_judge_paths = [
|
||||
[content_path.parent / f"{content_path.stem}.content-check-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||||
for i in range(1, writer_count + 1)
|
||||
]
|
||||
pending_slots = [
|
||||
(i, j) for i in range(writer_count) if content_chunk_nums[i]
|
||||
for j in range(CHECK_PANEL) if _read_problems_schema(_json_file(check_judge_paths[i][j])) is None
|
||||
]
|
||||
if pending_slots:
|
||||
await _set_step(guide_id, 2, "Checking content…")
|
||||
sections_per_chunk = {
|
||||
i: "\n\n".join(f"SECTION: {_title(entries[num])}\n{content_by_num[num]}" for num in content_chunk_nums[i])
|
||||
for i, _ in pending_slots
|
||||
}
|
||||
slots = [{
|
||||
"key": f"{guide_id}-content-check-{i + 1}-j{j + 1}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Content-Check",
|
||||
topic=topic, format_name=format_name, sections=sections_per_chunk[i],
|
||||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||||
),
|
||||
"role": "judge", "capabilities": "files",
|
||||
"payload": (lambda result, p=check_judge_paths[i][j]: _read_problems_schema(_json_file(p))),
|
||||
} for i, j in pending_slots]
|
||||
n_checks = len(slots)
|
||||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 2, f"Checking content {n}/{n_checks}…"))
|
||||
await _race(topic, "Content-Exam", slots, len(slots), _timeout("content_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled():
|
||||
return None
|
||||
|
||||
problems_by_num: dict[int, str] = {}
|
||||
for i in range(writer_count):
|
||||
if content_chunk_nums[i]:
|
||||
problems_by_num.update(_panel_problems(check_judge_paths[i], set(content_chunk_nums[i]), idx))
|
||||
|
||||
if problems_by_num:
|
||||
_log(topic, f"Content exam: {len(problems_by_num)} block(s) flagged")
|
||||
await _set_step(guide_id, 2, f"Revising {len(problems_by_num)} content(s)…")
|
||||
fix_chunks = [[num for num in nums if num in problems_by_num] for nums in content_chunk_nums]
|
||||
fix_paths = [content_path.parent / f"{content_path.stem}.content-fix-{i + 1}.md" for i in range(writer_count)]
|
||||
fix_pending = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||||
results = await asyncio.gather(*[
|
||||
run_agent(
|
||||
f"{guide_id}-content-fix-{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Content-Fix",
|
||||
topic=topic, facts=facts,
|
||||
tasks="\n\n".join(
|
||||
f"SECTION: {_title(entries[num])}\nPROBLEM: {problems_by_num[num]}\nCURRENT:\n{content_by_num[num]}"
|
||||
for num in fix_chunks[i]
|
||||
),
|
||||
out_path=fix_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("content", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", scope=topic,
|
||||
)
|
||||
for i in fix_pending
|
||||
], return_exceptions=True)
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in fix_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _resolve_title(idx, sec["title"])
|
||||
if num in problems_by_num and sec["md"].strip():
|
||||
content_by_num[num] = sec["md"]
|
||||
|
||||
_set_done(content_path, 2) # content check done
|
||||
|
||||
# Step 4: writing — the writer phrases out the checked content (resume).
|
||||
# FINE chunks: exactly 1 block per writer → variable lengths, no budget rationing.
|
||||
def content_text(chunk) -> str:
|
||||
nums = [num for ch in chunk for num in ch["nums"] if num in content_by_num]
|
||||
return "\n\n".join(f"<!-- section: {_title(entries[num])} -->\n{content_by_num[num]}" for num in nums)
|
||||
|
||||
w_chunks = [[{"title": ch["title"], "nums": [num]}] for ch in plan for num in ch["nums"]]
|
||||
w_assignments = [_assignment_subs(c, entries, subs_by_title) for c in w_chunks]
|
||||
paths = [content_path.parent / f"{content_path.stem}.chunk-{i}.md" for i in range(1, len(w_chunks) + 1)]
|
||||
pending = [i for i, p in enumerate(paths) if not p.exists()]
|
||||
if pending:
|
||||
async def report(d, t): await _set_step(guide_id, 3, f"Writing sections {d}/{t}…")
|
||||
results = await _gather_progress([
|
||||
run_agent(
|
||||
f"{guide_id}-w{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Writer",
|
||||
topic=topic, format_name=format_name, assignment=w_assignments[i],
|
||||
contents=content_text(w_chunks[i]),
|
||||
spec=spec, out_path=paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files", scope=topic,
|
||||
)
|
||||
for i in pending
|
||||
], len(w_chunks), report, start=len(w_chunks) - len(pending))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for i, r in zip(pending, 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('Error', *r)}")
|
||||
elif not paths[i].exists():
|
||||
_log(topic, f"Writer {i + 1}: no output file created")
|
||||
if not any(p.exists() for p in paths):
|
||||
await _fail(guide_id, _gather_error("Writer error", list(results)))
|
||||
return None
|
||||
|
||||
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 = _resolve_title(idx, sec["title"])
|
||||
if num is None:
|
||||
_log(topic, f"Writer produced unknown section '{sec['title'][:40]}' (ignored)")
|
||||
elif num not in by_num:
|
||||
by_num[num] = sec
|
||||
if not by_num:
|
||||
await _fail(guide_id, "No sections found in writer output")
|
||||
return None
|
||||
|
||||
# Follow-up round: write missing sections (writer failure) deliberately — one round.
|
||||
missing_after = [num for num in planned_nums if num not in by_num]
|
||||
if missing_after:
|
||||
_log(topic, f"Writing: {len(missing_after)} section(s) missing — follow-up round…")
|
||||
nw_chunks = [[{"title": "Additional", "nums": [num]}] for num in missing_after]
|
||||
nw_paths = [content_path.parent / f"{content_path.stem}.chunk-nach-{k}.md" for k in range(1, len(nw_chunks) + 1)]
|
||||
nw_pending = [k for k, p in enumerate(nw_paths) if not p.exists()]
|
||||
if nw_pending:
|
||||
async def report_nw(d, t): await _set_step(guide_id, 3, f"Writing missing sections {d}/{t}…")
|
||||
await _gather_progress([
|
||||
run_agent(
|
||||
f"{guide_id}-w-nach-{k + 1}",
|
||||
_prompt(
|
||||
"Guide-Writer",
|
||||
topic=topic, format_name=format_name, assignment=_assignment_subs(nw_chunks[k], entries, subs_by_title),
|
||||
contents=content_text(nw_chunks[k]), spec=spec, out_path=nw_paths[k], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", 1), provider=provider, role="guide", capabilities="files", scope=topic,
|
||||
)
|
||||
for k in nw_pending
|
||||
], len(nw_chunks), report_nw, start=len(nw_chunks) - len(nw_pending))
|
||||
if is_cancelled():
|
||||
return None
|
||||
for p in nw_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _resolve_title(idx, sec["title"])
|
||||
if num is not None and num not in by_num and sec["md"].strip():
|
||||
by_num[num] = sec
|
||||
|
||||
if all(p.exists() for p in paths):
|
||||
_set_done(content_path, 3) # writing complete
|
||||
|
||||
# Step 3: reading-exam loop — check per writer packet, fix only for
|
||||
# flagged sections; follow-up rounds check ONLY the replaced sections.
|
||||
# After the round cap, open complaints stand.
|
||||
chunk_nums = [[num for ch in chunk for num in ch["nums"] if num in by_num] for chunk in chunks]
|
||||
|
||||
def sections_text(nums: list[int]) -> str:
|
||||
return "\n\n".join(f"SECTION: {_title(entries[num])}\n{by_num[num]['md']}" for num in nums)
|
||||
|
||||
def _sub_list(num: int) -> str:
|
||||
subs = subs_by_title.get(_title(entries[num]), [])
|
||||
return "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
|
||||
|
||||
def tasks_text(nums: list[int], problems: dict[int, str]) -> str:
|
||||
return "\n\n".join(
|
||||
f"SECTION: {_title(entries[num])}\n"
|
||||
f"SUBBLOCKS (set one `<!-- sub: LABEL | title -->` marker each, label/order as here):\n{_sub_list(num)}\n"
|
||||
f"PROBLEM: {problems[num]}\nCURRENT CONTENT:\n{by_num[num]['md']}"
|
||||
for num in nums
|
||||
)
|
||||
|
||||
scope = chunk_nums
|
||||
for round_no in range(1, READING_ROUNDS + 1):
|
||||
# CHECK_PANEL judges per packet; majority flags. Aggregation robust against a single failure.
|
||||
check_judge_paths = [
|
||||
[content_path.parent / f"{content_path.stem}.lese-check-r{round_no}-{i}-j{j}.json" for j in range(1, CHECK_PANEL + 1)]
|
||||
for i in range(1, writer_count + 1)
|
||||
]
|
||||
pending_slots = [
|
||||
(i, j) for i in range(writer_count) if scope[i]
|
||||
for j in range(CHECK_PANEL) if _read_problems_schema(_json_file(check_judge_paths[i][j])) is None
|
||||
]
|
||||
if pending_slots:
|
||||
await _set_step(guide_id, 4, "Checking readability…")
|
||||
sections_per_chunk = {i: sections_text(scope[i]) for i, _ in pending_slots}
|
||||
slots = [{
|
||||
"key": f"{guide_id}-lese-check-r{round_no}-{i + 1}-j{j + 1}",
|
||||
"prompt": _prompt(
|
||||
"Guide-Lese-Check",
|
||||
topic=topic, format_name=format_name, spec=spec,
|
||||
sections=sections_per_chunk[i],
|
||||
out_path=check_judge_paths[i][j], extra=_extra(instructions),
|
||||
),
|
||||
"role": "judge", "capabilities": "files",
|
||||
"payload": (lambda result, p=check_judge_paths[i][j]: _read_problems_schema(_json_file(p))),
|
||||
} for i, j in pending_slots]
|
||||
n_checks = len(slots)
|
||||
upd = lambda n: asyncio.create_task(_set_step(guide_id, 4, f"Checking readability {n}/{n_checks}…"))
|
||||
res = await _race(topic, f"Reading-Exam r{round_no}", slots, len(slots), _timeout("lese_check", max(chunk_sizes)), provider, on_update=upd, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled():
|
||||
return None
|
||||
if res is None:
|
||||
_log(topic, f"Reading exam round {round_no}: no full quorum — aggregated available judges")
|
||||
|
||||
problems_by_num: dict[int, str] = {}
|
||||
for i in range(writer_count):
|
||||
if scope[i]:
|
||||
problems_by_num.update(_panel_problems(check_judge_paths[i], set(scope[i]), idx))
|
||||
|
||||
# Deterministic readability gate: queue too-hard sections into the same
|
||||
# revision (LLM complaint takes precedence). Gate off → no-op.
|
||||
if READABILITY_ACTIVE:
|
||||
md_by_num = {num: by_num[num]["md"] for nums in scope for num in nums if num in by_num}
|
||||
hints = await asyncio.to_thread(readability.rate_sections, md_by_num)
|
||||
if hints:
|
||||
_log(topic, f"Readability: {len(hints)} section(s) too hard")
|
||||
for num, hint in hints.items():
|
||||
problems_by_num.setdefault(num, hint)
|
||||
|
||||
if not problems_by_num:
|
||||
break
|
||||
|
||||
_log(topic, f"Reading exam round {round_no}: {len(problems_by_num)} section(s) flagged")
|
||||
await _set_step(guide_id, 4, f"Revising {len(problems_by_num)} section(s) (round {round_no})…")
|
||||
fix_chunks = [[num for num in nums if num in problems_by_num] for nums in chunk_nums]
|
||||
fix_paths = [content_path.parent / f"{content_path.stem}.fix-r{round_no}-{i + 1}.md" for i in range(writer_count)]
|
||||
fix_pending = [i for i, nums in enumerate(fix_chunks) if nums and not fix_paths[i].exists()]
|
||||
results = await asyncio.gather(*[
|
||||
run_agent(
|
||||
f"{guide_id}-fix-r{round_no}-w{i + 1}",
|
||||
_prompt(
|
||||
"Guide-Sections-Fix",
|
||||
topic=topic, format_name=format_name, facts=facts, spec=spec,
|
||||
tasks=tasks_text(fix_chunks[i], problems_by_num),
|
||||
out_path=fix_paths[i], extra=_extra(instructions),
|
||||
),
|
||||
_timeout("writer", len(fix_chunks[i])), provider=provider, role="guide", capabilities="full", scope=topic,
|
||||
)
|
||||
for i in fix_pending
|
||||
], return_exceptions=True)
|
||||
if is_cancelled():
|
||||
return None
|
||||
for i, r in zip(fix_pending, results):
|
||||
if isinstance(r, BaseException) or (not isinstance(r, BaseException) and r[0] != 0):
|
||||
_log(topic, f"Sections fix {i + 1} (round {round_no}) failed — original kept")
|
||||
replaced: set[int] = set()
|
||||
for p in fix_paths:
|
||||
if not p.exists():
|
||||
continue
|
||||
for sec in _parse_fragment(p.read_text(encoding="utf-8")):
|
||||
num = _resolve_title(idx, sec["title"])
|
||||
if num not in problems_by_num or not sec["md"].strip():
|
||||
continue
|
||||
# Marker invariant: if the fix loses the sub markers although the original had
|
||||
# some, it is discarded — otherwise the level filter (E/M/S/F) dies silently.
|
||||
if by_num[num].get("subs") and not sec.get("subs"):
|
||||
_log(topic, f"Reading fix for '{sec['title']}' without sub markers — discarded, tagged original kept")
|
||||
continue
|
||||
by_num[num] = sec
|
||||
replaced.add(num)
|
||||
_log(topic, f"Reading exam round {round_no}: {len(replaced)} section(s) revised")
|
||||
if not replaced:
|
||||
break
|
||||
if round_no == READING_ROUNDS:
|
||||
_log(topic, f"Reading exam: 1 round — revision stays unchecked")
|
||||
break
|
||||
scope = [[num for num in nums if num in replaced] for nums in chunk_nums]
|
||||
_set_done(content_path, 4) # reading exam done
|
||||
|
||||
# Checkable = format has an exam AND the block has ≥1 relevant subblock.
|
||||
# Guide is always checkable (even without relevance data, fallback = everything).
|
||||
def _checkable(num):
|
||||
if format_name == "Guide":
|
||||
return True
|
||||
if format_name == "FullGuide":
|
||||
return any(isinstance(s, dict) and s.get("relevance") == "relevant"
|
||||
for s in subs_raw.get(_title(entries[num]), []))
|
||||
return False # Rest etc. → pure reading sections
|
||||
|
||||
await _set_progress(guide_id, "Assembling…")
|
||||
chapters: list[dict] = []
|
||||
for ch in plan:
|
||||
sections = [
|
||||
{"num": num, "title": _title(entries[num]), "md": by_num[num]["md"],
|
||||
"compact": by_num[num].get("compact", ""),
|
||||
"anchor": by_num[num].get("anchor", ""), "anker_compact": by_num[num].get("anker_compact", ""),
|
||||
"subs": by_num[num].get("subs", []), "checkable": _checkable(num)}
|
||||
for num in ch["nums"] if num in by_num
|
||||
]
|
||||
if sections:
|
||||
chapters.append({"title": ch["title"], "sections": sections})
|
||||
planned = {num for ch in plan for num in ch["nums"]}
|
||||
missing = sorted(planned - set(by_num))
|
||||
if missing:
|
||||
_log(topic, f"Sections missing from writer output: {[_title(entries[n]) for n in missing]}")
|
||||
if not chapters:
|
||||
await _fail(guide_id, "No sections found in writer output")
|
||||
return None
|
||||
return chapters
|
||||
|
||||
|
||||
_LEVEL_RANK = {"beginner": 1, "advanced": 2, "expert": 3, "peripheral": 4,
|
||||
@@ -870,13 +218,19 @@ async def reconcile_guides() -> None:
|
||||
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 without content file — 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)
|
||||
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, ab_step: int | None = None) -> None:
|
||||
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="Starting…", updated_at=now)
|
||||
@@ -892,14 +246,15 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
if project:
|
||||
await asyncio.to_thread(_convert_pdfs, project)
|
||||
|
||||
# Re-run from step: delete content + slots from `ab_step`, rest stays → resume rebuilds from there.
|
||||
# Otherwise "recreate": a finished guide → complete fresh start.
|
||||
# Otherwise step files are leftovers of an abort/error → resume.
|
||||
if ab_step is not None:
|
||||
_reset_guide_from_step(content_path, ab_step)
|
||||
elif content_path.exists():
|
||||
for p_alt in guide_slot_files(content_path):
|
||||
p_alt.unlink(missing_ok=True)
|
||||
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():
|
||||
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)
|
||||
|
||||
bs = await list_blocks(topic, status="consensus")
|
||||
if bs:
|
||||
@@ -912,12 +267,13 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
|
||||
await _fail(guide_id, "No blocks found")
|
||||
return
|
||||
entries = _unique_title(alle)
|
||||
facts = _prompt("Guide-Facts-Projekt", project=project) if project else _prompt("Guide-Facts-Thema")
|
||||
chapters = await _generate_sections(
|
||||
guide_id, topic, format_name, entries,
|
||||
facts, instructions, provider, content_path,
|
||||
chapters = await guide_board.run_guide_board(
|
||||
guide_id, topic, format_name, entries, instructions, provider, content_path,
|
||||
)
|
||||
if chapters is None or is_guide_cancelled(guide_id):
|
||||
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}
|
||||
|
||||
@@ -1030,3 +386,34 @@ async def block_adopt(topic: str, format_name: str, block: str, spot: str, old:
|
||||
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."
|
||||
@@ -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
|
||||
2–3× 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
|
||||
|
||||
|
||||
@@ -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 150–1200/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))
|
||||
@@ -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
|
||||
+166
-645
@@ -1,73 +1,63 @@
|
||||
"""Streaming kanban dataflow for the inventory phase.
|
||||
"""Generic streaming kanban engine (no concrete stages — boards define those).
|
||||
|
||||
Each column is a worker that pulls cards from its input `stage` (the queue), processes up to
|
||||
KANBAN_BATCH at a time, and advances them to the next stage. Cards: titles → chains → blocks.
|
||||
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).
|
||||
|
||||
Streaming columns run continuously; barrier columns start only at QUIESCENCE of everything before
|
||||
them (no active worker + empty queues). Verify columns push failures back (rework). The Chain column
|
||||
re-clusters live: a chain that gains a member is marked dirty and flows back to chain_verify.
|
||||
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.
|
||||
|
||||
Reused from blocks.py (imported lazily-safe — kanban is only imported after blocks is loaded):
|
||||
embedding clustering, `_pairs_schema`/`_cliques`, `_canonical`, research prompt + file payload.
|
||||
Board definitions live in board_inventory.py / board_artefacts.py; run via run_flow().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
import database as db
|
||||
import embedding
|
||||
import blocks
|
||||
from config import RESEARCH_GRACE, MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
from pipeline import GenContext, run_single_slot, _prompt, _timeout, _log, OK
|
||||
from textkit import _norm_title, _title, _parse_selection
|
||||
from jsonio import read_json_file as _json_file
|
||||
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
|
||||
from pipeline import AgentInfraError
|
||||
|
||||
log = logging.getLogger("creator.kanban")
|
||||
|
||||
KANBAN_BATCH = 5 # cards a worker pulls per package (micro-batching)
|
||||
# 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
|
||||
# Stages whose processor mutates shared cross-card state and MUST run one package at a time.
|
||||
# Online chain-clustering reads the whole universe + membership; parallel packages would race.
|
||||
_SERIAL_STAGES = {"chain"}
|
||||
_ID_COL = {"kanban_titles": "title_norm", "kanban_chains": "chain_id", "kanban_blocks": "block_id"}
|
||||
CHAIN_CAP = 12 # max members per chain — caps the O(n²) pair-verification blow-up
|
||||
_POLL = 0.3 # seconds between empty-queue polls
|
||||
|
||||
# Stage order. A card's `stage` = the column it waits in (its worker's input).
|
||||
TITLE_STAGES = ["merge", "chain", "chained"] # 'chained' = consumed into a chain
|
||||
CHAIN_STAGES = ["chain_verify", "naming", "naming_verify", "chain_filter", "filter_verify", "block_assemble"]
|
||||
BLOCK_STAGES = ["small_blocks", "small_verify", "dependency", "dependency_verify", "main"]
|
||||
DONE_CHAIN = "done_chain"
|
||||
DONE_BLOCK = "done_block"
|
||||
REJECTED = "rejected" # block dropped by filter_verify (off-topic / noise) — terminal, never mirrored
|
||||
|
||||
# Predecessor stages for each barrier (must ALL be quiescent before the barrier worker runs).
|
||||
_BEFORE_CHAIN_FILTER = ["merge", "chain", "chain_verify", "naming", "naming_verify"]
|
||||
_BEFORE_BLOCK = _BEFORE_CHAIN_FILTER + ["chain_filter", "filter_verify"]
|
||||
_BEFORE_MAIN = ["small_blocks", "small_verify", "dependency", "dependency_verify"]
|
||||
# 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: active-task counters per stage + a wakeup event. `producers` counts the
|
||||
running research agents (initial + any added live via the generate button); 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):
|
||||
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 = 1 # the initial research agent
|
||||
self.research_tag = 0
|
||||
self.producers = 0
|
||||
self.producer_tag = 0
|
||||
self.stop = False
|
||||
self.wake = asyncio.Event()
|
||||
self.spawn_research = None # set by run_kanban: () → coroutine that adds one more research agent
|
||||
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()
|
||||
|
||||
@@ -76,8 +66,8 @@ class _Flow:
|
||||
self.wake.set()
|
||||
|
||||
def next_tag(self) -> int:
|
||||
self.research_tag += 1
|
||||
return self.research_tag
|
||||
self.producer_tag += 1
|
||||
return self.producer_tag
|
||||
|
||||
def enter(self, stage: str):
|
||||
self.active[stage] = self.active.get(stage, 0) + 1
|
||||
@@ -89,192 +79,148 @@ class _Flow:
|
||||
def active_in(self, stages) -> bool:
|
||||
return any(self.active.get(s, 0) > 0 for s in stages)
|
||||
|
||||
async def queued_in(self, table: str, stages) -> bool:
|
||||
return await db.kanban_count(self.topic, table, list(stages)) > 0
|
||||
|
||||
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] = []
|
||||
|
||||
|
||||
# ── Research producer ────────────────────────────────────────────────────────────
|
||||
async def _ingest_titles(topic: str, text: str) -> int:
|
||||
"""Parse a reader file into kanban_titles (stage 'merge'). Exact dupes drop on the PK. → new count."""
|
||||
n, seen = 0, set()
|
||||
for record in _parse_selection(text).values():
|
||||
title = _title(record)
|
||||
norm = _norm_title(title)
|
||||
if not norm or norm in seen:
|
||||
continue
|
||||
seen.add(norm)
|
||||
parts = [t.strip() for t in record.split(" — ")]
|
||||
source = parts[2] if len(parts) >= 3 else ""
|
||||
desc = parts[1] if len(parts) >= 2 else ""
|
||||
if await db.kanban_add_title(topic, norm, title, source, desc):
|
||||
n += 1
|
||||
return n
|
||||
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
|
||||
|
||||
|
||||
RESEARCH_RUNTIME = 900 # one research agent, one round, ~15 min hard cap — the tail ingests live while it writes
|
||||
_POLL_RESEARCH = 3 # seconds between live reads of a running research file
|
||||
|
||||
# Live registry of running flows, so the "+ research" button can attach another agent to a live run.
|
||||
_active_flows: dict[str, "_Flow"] = {}
|
||||
|
||||
|
||||
def _extract_text(raw_line: str) -> str:
|
||||
"""Best-effort: pull assistant/tool text out of ONE opencode `--format json` event line.
|
||||
Recursively collects every `text`/`content` string — robust to the exact event schema."""
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
except Exception:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
def _walk(o):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
if k in ("text", "content") and isinstance(v, str):
|
||||
parts.append(v)
|
||||
else:
|
||||
_walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
_walk(v)
|
||||
_walk(obj)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
async def _research_once(ctx: GenContext, files: dict, q: dict, folder, instructions: str, tag: str, flow: "_Flow"):
|
||||
"""ONE agent searches the topic; its titles go into the merge queue LIVE. Two sources feed the
|
||||
ingest: the JSON event stream (on_line → text buffer) AND the file the agent writes — whichever
|
||||
the agent uses, cards stream in immediately (not only after it finishes)."""
|
||||
work_dir = files["arbeit"]
|
||||
caps = "files" if folder else "full"
|
||||
p = work_dir / f"research-{tag}.md"
|
||||
p.unlink(missing_ok=True)
|
||||
stop = asyncio.Event()
|
||||
buf: list[str] = [] # assistant text streamed live from the JSON events
|
||||
|
||||
def _on_line(raw: str): # sync, called per stdout line by the agent runner
|
||||
if (t := _extract_text(raw)):
|
||||
buf.append(t)
|
||||
|
||||
async def _drain() -> bool: # ingest from BOTH event buffer and file (idempotent, dupes drop on PK)
|
||||
text = "".join(buf)
|
||||
if (ft := blocks._file_payload(p)):
|
||||
text += "\n" + ft
|
||||
return bool(text) and await _ingest_titles(ctx.topic, text)
|
||||
|
||||
async def _tail(): # live-ingest loop while the agent runs
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=_POLL_RESEARCH)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if await _drain():
|
||||
flow.wake.set() # new cards → wake the workers
|
||||
|
||||
tail = asyncio.create_task(_tail())
|
||||
try:
|
||||
await run_single_slot(
|
||||
ctx, f"research-{tag}", key=f"blocks-{ctx.topic}-research-{tag}",
|
||||
prompt=blocks._build_research_prompt(ctx.topic, p, instructions, q["type"], folder),
|
||||
role="quick", capabilities=caps,
|
||||
payload=(lambda result, p=p: blocks._file_payload(p)),
|
||||
timeout=RESEARCH_RUNTIME, on_line=_on_line,
|
||||
)
|
||||
finally:
|
||||
stop.set()
|
||||
await tail
|
||||
if await _drain(): # final catch-up
|
||||
flow.wake.set()
|
||||
_log(ctx.topic, f"Research {tag}: titles → merge queue")
|
||||
|
||||
|
||||
async def _research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
|
||||
"""The initial research producer (already counted in flow.producers=1)."""
|
||||
try:
|
||||
await _research_once(ctx, files, q, folder, instructions, "1", flow)
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
async def _extra_research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
|
||||
"""One more research agent, added live via the generate button. Keeps the flow awake until done."""
|
||||
flow.add_producer()
|
||||
try:
|
||||
await _research_once(ctx, files, q, folder, instructions, f"x{flow.next_tag()}", flow)
|
||||
finally:
|
||||
flow.done_producer()
|
||||
|
||||
|
||||
def add_research_agent(topic: str) -> bool:
|
||||
"""Attach one more research agent to a running flow. → True if a run was live to attach to."""
|
||||
flow = _active_flows.get(topic)
|
||||
if flow is None or flow.stop or flow.spawn_research is None:
|
||||
return False
|
||||
asyncio.create_task(flow.spawn_research())
|
||||
return True
|
||||
|
||||
|
||||
# ── Generic worker loop ────────────────────────────────────────────────────────────
|
||||
async def _quiescent(flow: _Flow, stages) -> bool:
|
||||
"""True iff no worker is active in `stages` AND no card is queued in any of them (all tables).
|
||||
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
|
||||
for tb in ("kanban_titles", "kanban_chains", "kanban_blocks"):
|
||||
if await db.kanban_count(flow.topic, tb, list(stages)):
|
||||
return False
|
||||
return True
|
||||
return await db.kanban_count(flow.topic, list(stages)) == 0
|
||||
|
||||
|
||||
async def _worker(flow: _Flow, table: str, in_stage: str, process, upstream, *, barrier=False, inflight=WORKER_INFLIGHT):
|
||||
"""Pull cards from `in_stage`, run `process` — keeping up to `inflight` packages running CONCURRENTLY
|
||||
so a busy column fills the agent slots instead of doing one package at a time. `upstream` = all stages
|
||||
before this one. A barrier worker only pulls when `upstream` is fully quiescent. ANY worker exits only
|
||||
when research is done, its own queue is empty, AND `upstream` is quiescent (nothing can still arrive).
|
||||
async def _sleep_wake(flow: Flow):
|
||||
try:
|
||||
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
flow.wake.clear()
|
||||
|
||||
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of card-ids (held
|
||||
while a package runs) is enough to keep concurrent pulls from grabbing the same cards."""
|
||||
|
||||
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
|
||||
idc = _ID_COL[table]
|
||||
claimed: set[str] = set()
|
||||
tasks: set[asyncio.Task] = set()
|
||||
batch = 100_000 if spec.drain else KANBAN_BATCH
|
||||
|
||||
async def _run(cards):
|
||||
ids = [c[idc] for c in cards]
|
||||
flow.enter(in_stage)
|
||||
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 process(cards)
|
||||
except Exception as e: # one bad package must not kill the worker
|
||||
_log(topic, f"worker {in_stage}: {type(e).__name__}: {e}")
|
||||
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(in_stage)
|
||||
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 not barrier or await _quiescent(flow, upstream):
|
||||
if await _may_pull():
|
||||
while len(tasks) < inflight:
|
||||
rows = await db.kanban_pull(topic, table, in_stage, KANBAN_BATCH + len(claimed))
|
||||
fresh = [r for r in rows if r[idc] not in claimed][:KANBAN_BATCH]
|
||||
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[idc])
|
||||
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
|
||||
up_quiet = await _quiescent(flow, upstream)
|
||||
if (flow.research_done and up_quiet and not flow.active_in([in_stage])
|
||||
and await db.kanban_count(topic, table, in_stage) == 0):
|
||||
return # nothing left and nothing upstream can produce
|
||||
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:
|
||||
@@ -283,466 +229,41 @@ async def _worker(flow: _Flow, table: str, in_stage: str, process, upstream, *,
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
async def _sleep_wake(flow: _Flow):
|
||||
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:
|
||||
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
flow.wake.clear()
|
||||
|
||||
|
||||
# ── Column processors ──────────────────────────────────────────────────────────────
|
||||
async def _proc_merge(flow: _Flow, cards):
|
||||
"""Exact dedup happened at ingest (PK). Merge just advances titles to the chain column."""
|
||||
for c in cards:
|
||||
await db.kanban_advance(flow.topic, "kanban_titles", c["title_norm"], "chain")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_chain(flow: _Flow, cards):
|
||||
"""Embedding blocking: for each new title, find the most similar existing title (cosine ≥ floor).
|
||||
Join its chain (or open a new one), mark the chain dirty → chain_verify. Live-growing clusters."""
|
||||
topic = flow.topic
|
||||
by_norm = await db.kanban_titles_by_norm(topic)
|
||||
# Universe = titles already chained + the new batch (for nearest-neighbour search).
|
||||
universe = [nm for nm, r in by_norm.items() if r["stage"] in ("chain", "chained")]
|
||||
if len(universe) < 1:
|
||||
return
|
||||
texts = [f"{by_norm[nm]['title']} — {by_norm[nm]['content']}" if by_norm[nm]["content"] else by_norm[nm]["title"]
|
||||
for nm in universe]
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
|
||||
embedding and await asyncio.to_thread(embedding.available)) else None
|
||||
idx = {nm: i for i, nm in enumerate(universe)}
|
||||
# existing membership
|
||||
member_chain = {}
|
||||
for nm in universe:
|
||||
cid = await _chain_of(topic, nm)
|
||||
if cid:
|
||||
member_chain[nm] = cid
|
||||
touched = set()
|
||||
for c in cards:
|
||||
nm = c["title_norm"]
|
||||
target = None
|
||||
if sims is not None and nm in idx:
|
||||
best, bestcos = None, blocks.DEDUP_PAIR_FLOOR
|
||||
for other in universe:
|
||||
if other == nm or other not in member_chain and other not in idx:
|
||||
continue
|
||||
cos = float(sims[idx[nm]][idx[other]]) if other in idx else -1
|
||||
if cos >= bestcos and other != nm:
|
||||
best, bestcos = other, cos
|
||||
if best is not None:
|
||||
target = member_chain.get(best)
|
||||
cid = target or f"c-{uuid.uuid4().hex[:12]}"
|
||||
members = set(await db.kanban_chain_members(topic, cid))
|
||||
if target and len(members) >= CHAIN_CAP: # neighbour's chain is full → start a fresh chain
|
||||
cid = f"c-{uuid.uuid4().hex[:12]}"
|
||||
members = set()
|
||||
members.add(nm)
|
||||
await db.kanban_set_chain_members(topic, cid, sorted(members))
|
||||
await db.kanban_upsert_chain(topic, cid, "chain_verify", dirty=1)
|
||||
member_chain[nm] = cid
|
||||
await db.kanban_advance(topic, "kanban_titles", nm, "chained")
|
||||
touched.add(cid)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _chain_of(topic: str, title_norm: str) -> str | None:
|
||||
return await db.kanban_member_chain(topic, title_norm)
|
||||
|
||||
|
||||
async def _members_dicts(topic: str, members: list[str]) -> list[dict]:
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
return [by[m] for m in members if m in by]
|
||||
|
||||
|
||||
async def _proc_chain_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Pairwise-verify the batch's chains IN PARALLEL (one agent per chain). Failures split off."""
|
||||
await asyncio.gather(*[_verify_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _verify_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
if len(dicts) <= 1:
|
||||
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
|
||||
return
|
||||
# Only the embedding-NEAR candidate pairs (cosine ≥ floor) — NOT all O(n²) pairs. A 12-member
|
||||
# chain shrinks from 66 pairs to a handful. Transitivity (connected components) does the rest.
|
||||
nm = [d["title_norm"] for d in dicts]
|
||||
texts = [f"{d['title']} — {d['content']}" if d["content"] else d["title"] for d in dicts]
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
|
||||
embedding and await asyncio.to_thread(embedding.available)) else None
|
||||
if sims is not None:
|
||||
pairs = [(nm[i], nm[j]) for i in range(len(nm)) for j in range(i + 1, len(nm))
|
||||
if float(sims[i][j]) >= blocks.DEDUP_PAIR_FLOOR]
|
||||
else:
|
||||
pairs = [(a, b) for x, a in enumerate(members) for b in members[x + 1:]]
|
||||
keep_edges = await _verify_pairs(ctx, flow.work_dir, topic, cid, dicts, pairs)
|
||||
groups = _components(members, keep_edges) # transitive groups over confirmed near-pairs
|
||||
groups.sort(key=len, reverse=True)
|
||||
main = groups[0] if groups else members
|
||||
await db.kanban_set_chain_members(topic, cid, sorted(main))
|
||||
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
|
||||
for g in groups[1:]: # the rest split into fresh chains
|
||||
ncid = f"c-{uuid.uuid4().hex[:12]}"
|
||||
await db.kanban_set_chain_members(topic, ncid, sorted(g))
|
||||
await db.kanban_upsert_chain(topic, ncid, "naming", dirty=0)
|
||||
|
||||
|
||||
def _components(members: list[str], edges) -> list[list[str]]:
|
||||
"""Connected components (union-find) over confirmed pairs. Members without an edge stay alone."""
|
||||
parent = {m: m for m in members}
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
for a, b in edges:
|
||||
if a in parent and b in parent:
|
||||
parent[find(a)] = find(b)
|
||||
comp: dict[str, list[str]] = {}
|
||||
for m in members:
|
||||
comp.setdefault(find(m), []).append(m)
|
||||
return list(comp.values())
|
||||
|
||||
|
||||
async def _verify_pairs(ctx, work_dir, topic, cid, dicts, pairs):
|
||||
"""Judge the candidate pairs in DEDUP_PAIRS_CHUNK packages, all packages IN PARALLEL → confirmed edges."""
|
||||
by = {d["title_norm"]: d for d in dicts}
|
||||
chunks = [pairs[k:k + blocks.DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), blocks.DEDUP_PAIRS_CHUNK)]
|
||||
|
||||
async def _chunk(ci, chunk):
|
||||
path = work_dir / f"verify-{cid}-{ci}.json"
|
||||
lines = "\n\n".join(
|
||||
f"{j + 1}.\nA: {by[a]['title']} — {by[a]['content']}\nB: {by[b]['title']} — {by[b]['content']}"
|
||||
for j, (a, b) in enumerate(chunk))
|
||||
await run_single_slot(
|
||||
ctx, f"Chain verify {cid}", key=f"blocks-{topic}-verify-{cid}-{ci}",
|
||||
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: blocks._pairs_schema(_json_file(p)),
|
||||
timeout=_timeout("selection_mapping", len(chunk)))
|
||||
verdict = blocks._pairs_schema(_json_file(path)) or {}
|
||||
return [(a, b) for j, (a, b) in enumerate(chunk) if verdict.get(j + 1)]
|
||||
|
||||
results = await asyncio.gather(*[_chunk(ci, ch) for ci, ch in enumerate(chunks)], return_exceptions=True)
|
||||
return [e for r in results if isinstance(r, list) for e in r]
|
||||
|
||||
|
||||
async def _proc_naming(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Pick the best member title per chain — batch runs IN PARALLEL (one agent per chain)."""
|
||||
await asyncio.gather(*[_name_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _name_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
if len(dicts) <= 1:
|
||||
await db.kanban_upsert_chain(topic, cid, "naming_verify",
|
||||
main_title_norm=(members[0] if members else None), dirty=0)
|
||||
return
|
||||
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming")
|
||||
await db.kanban_upsert_chain(topic, cid, "naming_verify", main_title_norm=winner, dirty=0)
|
||||
|
||||
|
||||
async def _proc_naming_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Second judge checks each title — batch runs IN PARALLEL."""
|
||||
await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _namecheck_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
dicts = await _members_dicts(topic, members)
|
||||
winner = c.get("main_title_norm") or (members[0] if members else None)
|
||||
if len(dicts) > 1:
|
||||
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming-Check",
|
||||
current=(members.index(winner) + 1 if winner in members else 1))
|
||||
await db.kanban_upsert_chain(topic, cid, "chain_filter", main_title_norm=winner, dirty=0)
|
||||
|
||||
|
||||
async def _choose_title(ctx, work_dir, topic, cid, members, dicts, template, current=None):
|
||||
by = {d["title_norm"]: d for d in dicts}
|
||||
path = work_dir / f"naming-{cid}.json"
|
||||
lines = "\n".join(f"{k + 1}. {by[m]['title']} — {by[m]['content']}" for k, m in enumerate(members) if m in by)
|
||||
kw = dict(topic=topic, members=lines, out_path=path)
|
||||
if current is not None:
|
||||
kw["current"] = current
|
||||
await run_single_slot(
|
||||
ctx, f"Naming {cid}", key=f"blocks-{topic}-naming-{cid}",
|
||||
prompt=_prompt(template, **kw), role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: blocks._naming_schema(_json_file(p), len(members)),
|
||||
timeout=_timeout("selection_mapping", len(members)))
|
||||
best = blocks._naming_schema(_json_file(path), len(members))
|
||||
if best is None:
|
||||
rep = blocks._canonical(dicts, list(range(len(dicts))), set())
|
||||
w = _norm_title(rep["title"])
|
||||
return w if w in members else members[0]
|
||||
return members[best - 1]
|
||||
|
||||
|
||||
async def _proc_chain_filter(flow: _Flow, cards):
|
||||
"""BARRIER. Reduce each chain to its winner → upsert a block (chain_id stable). → filter_verify."""
|
||||
topic = flow.topic
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
for c in cards:
|
||||
cid = c["chain_id"]
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
winner = c.get("main_title_norm") if c.get("main_title_norm") in members else (members[0] if members else None)
|
||||
if not winner or winner not in by:
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
continue
|
||||
w = by[winner]
|
||||
await db.kanban_upsert_block(topic, f"b-{cid}", cid, w["title"], w["source"], w["content"], stage="filter_verify_b")
|
||||
await db.kanban_upsert_chain(topic, cid, "filter_verify", dirty=0)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_filter_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""An agent confirms each reduced block is a valid, on-topic, self-contained concept. Off-topic /
|
||||
noise / empty blocks are dropped (→ REJECTED, chain done). IN PARALLEL (one agent per block)."""
|
||||
await asyncio.gather(*[_filtercheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _filtercheck_one(ctx: GenContext, flow: _Flow, c):
|
||||
topic = flow.topic
|
||||
cid = c["chain_id"]
|
||||
bid = f"b-{cid}"
|
||||
by = await db.kanban_titles_by_norm(topic)
|
||||
members = await db.kanban_chain_members(topic, cid)
|
||||
winner = c.get("main_title_norm") if c.get("main_title_norm") in by else (members[0] if members else None)
|
||||
|
||||
async def _drop():
|
||||
await db.kanban_advance(topic, "kanban_blocks", bid, REJECTED)
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
|
||||
async def _pass():
|
||||
await db.kanban_advance(topic, "kanban_blocks", bid, "block_assemble_b")
|
||||
await db.kanban_upsert_chain(topic, cid, "block_assemble", dirty=0)
|
||||
|
||||
if not winner or winner not in by: # nothing to verify → drop the empty chain
|
||||
await _drop()
|
||||
return
|
||||
w = by[winner]
|
||||
path = flow.work_dir / f"filtercheck-{cid}.json"
|
||||
await run_single_slot(
|
||||
ctx, f"Filter verify {cid}", key=f"blocks-{topic}-verify-filter-{cid}",
|
||||
prompt=_prompt("Blocks-Filter-Check", topic=topic, title=w["title"], content=w["content"], out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _keep_schema(_json_file(p)),
|
||||
timeout=_timeout("selection_mapping", 1))
|
||||
keep = _keep_schema(_json_file(path))
|
||||
await (_drop() if keep is False else _pass()) # None (parse fail) → keep, conservative
|
||||
|
||||
|
||||
async def _proc_block(flow: _Flow, cards):
|
||||
"""BARRIER. Assemble the final block row → small_blocks. (chain card consumed → done.)"""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
cid = c["chain_id"]
|
||||
await db.kanban_advance(topic, "kanban_blocks", f"b-{cid}", "small_blocks")
|
||||
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
def _small_schema(data, count):
|
||||
"""{"small": {"1": true, ...}} → {block_index: bool} · else None."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("small"), dict):
|
||||
return None
|
||||
out = {}
|
||||
for k, v in data["small"].items():
|
||||
try:
|
||||
n = int(k)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if 1 <= n <= count:
|
||||
out[n] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
return out or None
|
||||
|
||||
|
||||
def _keep_schema(data):
|
||||
"""{"keep": true/false} → bool · None when absent/unparseable (caller keeps on None, conservative)."""
|
||||
if not isinstance(data, dict) or "keep" not in data:
|
||||
return None
|
||||
return str(data["keep"]).strip().casefold() in ("true", "ja", "yes", "1")
|
||||
|
||||
|
||||
def _dep_schema(data, count):
|
||||
"""{"parent": N} → 0..count (0 = standalone) · else None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
n = int(data.get("parent"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return n if 0 <= n <= count else None
|
||||
|
||||
|
||||
async def _proc_small(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Judge marks fragment-like blocks (batch). → small_verify."""
|
||||
topic = flow.topic
|
||||
path = flow.work_dir / f"small-{cards[0]['block_id']}.json"
|
||||
lines = "\n".join(f"{i + 1}. {c['title']} — {c['content']}" for i, c in enumerate(cards))
|
||||
await run_single_slot(
|
||||
ctx, "Small blocks", key=f"blocks-{topic}-small-{cards[0]['block_id']}",
|
||||
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
|
||||
timeout=_timeout("selection_mapping", len(cards)))
|
||||
verdict = _small_schema(_json_file(path), len(cards)) or {}
|
||||
for i, c in enumerate(cards):
|
||||
is_small = 1 if verdict.get(i + 1) else 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="small_verify", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_small_verify(ctx: GenContext, flow: _Flow, cards):
|
||||
"""Second judge re-checks the small flag (consensus): a block stays `small` only if it was marked
|
||||
small AND this judge also calls it a fragment. Disagreement → keep as a main block (conservative).
|
||||
→ dependency."""
|
||||
topic = flow.topic
|
||||
path = flow.work_dir / f"smallcheck-{cards[0]['block_id']}.json"
|
||||
lines = "\n".join(f"{i + 1}. {c['title']} — {c['content']}" for i, c in enumerate(cards))
|
||||
await run_single_slot(
|
||||
ctx, "Small verify", key=f"blocks-{topic}-verify-small-{cards[0]['block_id']}",
|
||||
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
|
||||
timeout=_timeout("selection_mapping", len(cards)))
|
||||
verdict = _small_schema(_json_file(path), len(cards)) or {}
|
||||
for i, c in enumerate(cards):
|
||||
is_small = 1 if (c["is_small"] and verdict.get(i + 1)) else 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="dependency", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_dependency(ctx: GenContext, flow: _Flow, cards):
|
||||
"""For each SMALL block, a judge picks its parent from the full list — batch runs IN PARALLEL."""
|
||||
topic = flow.topic
|
||||
parents = [b for b in await db.kanban_all_blocks(topic) if not b["is_small"] and b["stage"] != REJECTED]
|
||||
plist = "\n".join(f"{i + 1}. {b['title']}" for i, b in enumerate(parents))
|
||||
await asyncio.gather(*[_dep_one(ctx, flow, c, parents, plist) for c in cards], return_exceptions=True)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _dep_one(ctx: GenContext, flow: _Flow, c, parents, plist):
|
||||
topic = flow.topic
|
||||
if not c["is_small"] or not parents:
|
||||
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], "dependency_verify")
|
||||
return
|
||||
path = flow.work_dir / f"dep-{c['block_id']}.json"
|
||||
await run_single_slot(
|
||||
ctx, "Dependency", key=f"blocks-{topic}-dep-{c['block_id']}",
|
||||
prompt=_prompt("Blocks-Dependency", topic=topic,
|
||||
small=f"{c['title']} — {c['content']}", parents=plist, out_path=path),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result, p=path: _dep_schema(_json_file(p), len(parents)),
|
||||
timeout=_timeout("selection_mapping", len(parents)))
|
||||
pick = _dep_schema(_json_file(path), len(parents))
|
||||
parent_id = parents[pick - 1]["block_id"] if pick else None
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="dependency_verify", is_small=c["is_small"], parent_block_id=parent_id)
|
||||
|
||||
|
||||
async def _proc_dependency_verify(flow: _Flow, cards):
|
||||
"""A small block without a parent is demarked → becomes a main block. → main."""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
is_small = c["is_small"]
|
||||
if is_small and not c.get("parent_block_id"):
|
||||
is_small = 0
|
||||
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
|
||||
stage="main", is_small=is_small, parent_block_id=c.get("parent_block_id"))
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
async def _proc_main(flow: _Flow, cards):
|
||||
"""BARRIER. Finalize non-small blocks → mirror into the legacy `blocks` table as consensus."""
|
||||
topic = flow.topic
|
||||
for c in cards:
|
||||
if not c["is_small"]:
|
||||
norm = _norm_title(c["title"])
|
||||
await db.upsert_block(topic, norm, c["title"], c["content"], [c["source"]] if c["source"] else [])
|
||||
await db.set_block_status(topic, norm, "consensus")
|
||||
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], DONE_BLOCK)
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
async def run_kanban(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
|
||||
research: bool = True) -> bool:
|
||||
"""Run the streaming inventory. Returns True when the whole flow reaches quiescence at 'main'.
|
||||
|
||||
research=False ("Continue"): process the EXISTING queue without searching new titles. No initial
|
||||
research producer, producers=0 → research_done is true at once; workers drain the queue and exit.
|
||||
The +Research button can still attach an agent later via flow.spawn_research."""
|
||||
topic = ctx.topic
|
||||
flow = _Flow(topic, files["arbeit"])
|
||||
flow.spawn_research = lambda: _extra_research(ctx, files, q, folder, instructions, flow)
|
||||
if not research:
|
||||
flow.producers = 0 # continue the existing queue, search no new titles
|
||||
_active_flows[topic] = flow
|
||||
set_p("Kanban inventory…")
|
||||
|
||||
ORDER = ["merge", "chain", "chain_verify", "naming", "naming_verify", "chain_filter",
|
||||
"filter_verify", "block_assemble", "small_blocks", "small_verify",
|
||||
"dependency", "dependency_verify", "main"]
|
||||
up = {s: ORDER[:i] for i, s in enumerate(ORDER)} # upstream = all stages before this one
|
||||
barriers = {"chain_filter", "block_assemble", "main"}
|
||||
specs = [
|
||||
("kanban_titles", "merge", lambda cs: _proc_merge(flow, cs)),
|
||||
("kanban_titles", "chain", lambda cs: _proc_chain(flow, cs)),
|
||||
("kanban_chains", "chain_verify", lambda cs: _proc_chain_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "naming", lambda cs: _proc_naming(ctx, flow, cs)),
|
||||
("kanban_chains", "naming_verify", lambda cs: _proc_naming_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "chain_filter", lambda cs: _proc_chain_filter(flow, cs)),
|
||||
("kanban_chains", "filter_verify", lambda cs: _proc_filter_verify(ctx, flow, cs)),
|
||||
("kanban_chains", "block_assemble", lambda cs: _proc_block(flow, cs)),
|
||||
("kanban_blocks", "small_blocks", lambda cs: _proc_small(ctx, flow, cs)),
|
||||
("kanban_blocks", "small_verify", lambda cs: _proc_small_verify(ctx, flow, cs)),
|
||||
("kanban_blocks", "dependency", lambda cs: _proc_dependency(ctx, flow, cs)),
|
||||
("kanban_blocks", "dependency_verify", lambda cs: _proc_dependency_verify(flow, cs)),
|
||||
("kanban_blocks", "main", lambda cs: _proc_main(flow, cs)),
|
||||
]
|
||||
workers = [_research(ctx, files, q, folder, instructions, flow)] if research else []
|
||||
for table, stage, proc in specs:
|
||||
workers.append(_worker(flow, table, stage, proc, up[stage], barrier=(stage in barriers),
|
||||
inflight=(1 if stage in _SERIAL_STAGES else WORKER_INFLIGHT)))
|
||||
|
||||
progress = asyncio.create_task(_progress(flow, set_p))
|
||||
try:
|
||||
await asyncio.gather(*workers, return_exceptions=True)
|
||||
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
|
||||
progress.cancel()
|
||||
_active_flows.pop(topic, None)
|
||||
if ctx.is_cancelled():
|
||||
return False
|
||||
n = await db.kanban_count(topic, "kanban_blocks", DONE_BLOCK)
|
||||
_log(topic, f"Kanban: done — {n} blocks finalized")
|
||||
return 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):
|
||||
async def _progress(flow: Flow, set_p):
|
||||
while not flow.stop:
|
||||
try:
|
||||
counts = await db.kanban_stage_counts(flow.topic)
|
||||
total = sum(counts.values())
|
||||
set_p(f"Kanban: {total} cards in flow")
|
||||
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)
|
||||
+49
-34
@@ -13,8 +13,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from database import create_element, list_elements, get_block_hurdles
|
||||
from elements import generate_element
|
||||
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
|
||||
@@ -29,6 +28,25 @@ 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)]
|
||||
@@ -251,7 +269,13 @@ def _rating_text(rating: dict) -> str:
|
||||
|
||||
# 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)"
|
||||
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)
|
||||
# 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:
|
||||
@@ -350,9 +374,9 @@ def _avoid_block(avoid: list[str] | None) -> str:
|
||||
# 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. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
|
||||
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
|
||||
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
|
||||
"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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -391,18 +415,27 @@ async def exam_question_variant(
|
||||
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 critic (the pattern is build-checked).
|
||||
The style guard stays as a cheap protection against double questions · None on error."""
|
||||
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)
|
||||
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),
|
||||
)
|
||||
if data is None:
|
||||
return None
|
||||
return data["question"]
|
||||
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
|
||||
@@ -637,21 +670,3 @@ async def block_discussion(
|
||||
return None
|
||||
|
||||
|
||||
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||
"""Background task after completion: register the block as an element.
|
||||
|
||||
Dedup via normalized title — if an element for the block already exists,
|
||||
nothing happens. Must never raise an exception to the outside.
|
||||
"""
|
||||
try:
|
||||
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
|
||||
if _norm_title(block) in existing:
|
||||
return
|
||||
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
|
||||
if _norm_title(fields["title"]) in existing:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
|
||||
log.info("[%s] Block registered as element: %s", topic, fields["title"])
|
||||
except Exception:
|
||||
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
@@ -18,6 +20,7 @@ from routes import router
|
||||
async def lifespan(app: FastAPI):
|
||||
(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()
|
||||
|
||||
+38
-109
@@ -7,6 +7,8 @@ FormatType = Literal[
|
||||
"Rest",
|
||||
]
|
||||
|
||||
from config import DEFAULT_PROVIDER
|
||||
|
||||
ProviderType = Literal["claude", "minimax", "lokal"]
|
||||
|
||||
SourceType = Literal["thema", "projekt", "uni", "link"]
|
||||
@@ -16,40 +18,52 @@ class GuideCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
format: FormatType
|
||||
instructions: str = Field(default="", max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
ab_step: int | None = Field(default=None, ge=0, le=4) # re-run from guide step (0 outline … 4 read-exam); None = full/resume
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class TopicCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
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=2000)
|
||||
ab_phase: int | None = Field(default=None, ge=1, le=9) # re-run from a coarse phase (position in _phasen(topic), 1-based; up to 9: …outline/questions/artifacts); None = resume/continue without deleting
|
||||
ab_step: int | None = Field(default=None, ge=0) # re-run from a fine sub-step (0-based index into _blocks_steps); takes precedence over ab_phase
|
||||
to_step: int | None = Field(default=None, ge=0) # stop AFTER this fine sub-step (0-based index into _blocks_steps); None = run to the end
|
||||
research: bool = True # False = "Continue": process the existing queue, search no new titles
|
||||
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 BlocksResetStepRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
ab_step: int = Field(ge=0) # ONLY reset from this sub-step (no regeneration)
|
||||
class GuideFormatRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
format: str = Field(min_length=1)
|
||||
|
||||
|
||||
class BlocksStep(BaseModel):
|
||||
label: str
|
||||
state: Literal["done", "active", "pending"]
|
||||
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 BlocksFineStep(BaseModel):
|
||||
label: str
|
||||
phase: str = ""
|
||||
state: Literal["done", "active", "pending"]
|
||||
|
||||
|
||||
class BlocksStatusResponse(BaseModel):
|
||||
@@ -58,12 +72,6 @@ class BlocksStatusResponse(BaseModel):
|
||||
progress: str | None = None
|
||||
error: str | None = None
|
||||
partial: bool = False
|
||||
steps: list[BlocksStep] = []
|
||||
feine_steps: list[BlocksFineStep] = []
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
@@ -74,7 +82,7 @@ class FolderResponse(BaseModel):
|
||||
class BlocksSourceUpdate(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
type: SourceType = "thema"
|
||||
location: str = Field(default="", max_length=2000)
|
||||
location: str = Field(default="", max_length=20000) # link mode: one URL per line
|
||||
spec: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
@@ -123,92 +131,13 @@ 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
|
||||
|
||||
|
||||
class ElementCreateRequest(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["remove", "adjust", "add"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
|
||||
|
||||
class ElementStyleResponse(BaseModel):
|
||||
changes: list[ElementStyleChange]
|
||||
|
||||
|
||||
class ElementChatRequest(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementChatResponse(BaseModel):
|
||||
reply: str
|
||||
changes: list[ElementStyleChange] = []
|
||||
|
||||
|
||||
class ElementRefineRequest(BaseModel):
|
||||
suggestion: ElementStyleChange
|
||||
instruction: str = Field(min_length=1, max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementRefineResponse(BaseModel):
|
||||
change: ElementStyleChange
|
||||
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
chapter: str = Field(min_length=1, max_length=100)
|
||||
done: bool
|
||||
|
||||
|
||||
class ProgressResponse(BaseModel):
|
||||
chapters: list[str]
|
||||
|
||||
|
||||
# --- Block learning ---
|
||||
|
||||
class BlockChatRequest(BaseModel):
|
||||
@@ -217,7 +146,7 @@ class BlockChatRequest(BaseModel):
|
||||
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 BlockChatResponse(BaseModel):
|
||||
@@ -248,7 +177,7 @@ class BlockExamRequest(BaseModel):
|
||||
# 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 = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
thorough: bool = False # "thorough check": rating with a strong model (role guide)
|
||||
|
||||
|
||||
@@ -291,7 +220,7 @@ class BlockPruefenRequest(BaseModel):
|
||||
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 = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class BlockPruefenResponse(BaseModel):
|
||||
@@ -303,7 +232,7 @@ class BlockUebernehmenRequest(BaseModel):
|
||||
spot: str = "ausführlich"
|
||||
alt: str = Field(min_length=1, max_length=20000)
|
||||
revised: str = Field(default="", max_length=20000)
|
||||
provider: ProviderType = "claude"
|
||||
provider: ProviderType = DEFAULT_PROVIDER
|
||||
|
||||
|
||||
class BlockUebernehmenResponse(BaseModel):
|
||||
|
||||
+80
-38
@@ -9,14 +9,11 @@ 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, 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_file
|
||||
from textkit import _STUFEN
|
||||
|
||||
log = logging.getLogger("creator.pipeline")
|
||||
|
||||
@@ -80,6 +77,15 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
|
||||
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:
|
||||
for r in results:
|
||||
if isinstance(r, BaseException):
|
||||
@@ -132,7 +138,6 @@ def _runde_schema(data, final: bool = False):
|
||||
return include, rest
|
||||
|
||||
|
||||
_RELEVANCE = ("relevant", "peripheral")
|
||||
_YESNO = ("ja", "nein")
|
||||
|
||||
|
||||
@@ -159,15 +164,14 @@ def _enum_map_schema(key: str, allowed):
|
||||
return parse
|
||||
|
||||
|
||||
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
|
||||
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
|
||||
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
|
||||
|
||||
|
||||
_MAX_RESTARTS = 2
|
||||
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, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
|
||||
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)`
|
||||
@@ -180,28 +184,33 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
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.
|
||||
|
||||
`min_runtime` (wall-clock from start): the race does not return before it
|
||||
elapses while agents are still running — gives them time to search thoroughly.
|
||||
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
|
||||
(or None if nothing), killing the rest. Both default off; only Research sets them.
|
||||
"""
|
||||
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 110–135 s laufen.
|
||||
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
min_deadline = start + min_runtime if min_runtime else None
|
||||
max_deadline = start + max_runtime if max_runtime else None
|
||||
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"),
|
||||
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)
|
||||
@@ -211,27 +220,38 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
while tasks:
|
||||
if cancelled and cancelled():
|
||||
return None
|
||||
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
|
||||
if max_deadline is not None and loop.time() >= max_deadline:
|
||||
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
|
||||
return results or None
|
||||
min_ok = min_deadline is None or loop.time() >= min_deadline
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
|
||||
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
|
||||
return results
|
||||
# Wake up for the earliest relevant deadline (grace, min, or max).
|
||||
# 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: 160–230 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 min_deadline is not None:
|
||||
waits.append(min_deadline - loop.time())
|
||||
if max_deadline is not None:
|
||||
waits.append(max_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()
|
||||
@@ -248,30 +268,47 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
if payload is not None:
|
||||
results.append(payload)
|
||||
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 on_update:
|
||||
on_update(len(results))
|
||||
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
|
||||
and (min_deadline is None or loop.time() >= min_deadline)):
|
||||
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
|
||||
return results
|
||||
continue
|
||||
|
||||
_log(topic, f"{label} {i + 1} (attempt {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 the minimum already stands, restarts are pointless — the restart
|
||||
# would be killed at the grace end anyway.
|
||||
# would be killed at the grace end anyway. A still-running twin IS the retry.
|
||||
enough = grace is not None and len(results) >= quorum
|
||||
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
|
||||
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)
|
||||
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)
|
||||
@@ -290,6 +327,11 @@ class GenContext:
|
||||
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, on_line=None,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
+648
@@ -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))
|
||||
@@ -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)
|
||||
@@ -1,8 +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`).
|
||||
+339
-223
@@ -1,44 +1,46 @@
|
||||
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 Response
|
||||
|
||||
from agents import provider_available
|
||||
from config import PROJECTS_DIR, UNI_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, set_block_completed,
|
||||
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,
|
||||
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 blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, reset_blocks_ab_step, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from learning import block_chat, block_discussion, create_block_element, 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
|
||||
from guide import generate_guide, guide_slot_files, guide_done_step, block_pruefen, block_adopt, content_fuer_level
|
||||
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 rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BlocksCreateRequest, BlocksResetStepRequest, BlocksStatusResponse,
|
||||
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 blocks_topics, guide_content_path, project_dir, topic_dir, source_path, safe_folder
|
||||
from fsutil import atomic_write_json
|
||||
@@ -65,19 +67,33 @@ async def get_topics():
|
||||
@router.get("/stats")
|
||||
async def get_stats():
|
||||
"""Tracker: number of topics + per format created/completed."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
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():
|
||||
topics |= {e.name for e in PROJECTS_DIR.iterdir() if e.is_dir()}
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, progress, levels)}
|
||||
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
|
||||
|
||||
|
||||
@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, progress, levels = await load_learnstate()
|
||||
status = {fmt: ist_completed(topic, fmt, guides, progress, levels) for fmt in FORMATE}
|
||||
status["completed"] = topic_completed(topic, guides, progress, levels)
|
||||
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
|
||||
|
||||
|
||||
@@ -89,35 +105,28 @@ 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)
|
||||
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)
|
||||
shutil.rmtree(topic_dir(topic), ignore_errors=True)
|
||||
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, "Invalid project name")
|
||||
return name
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[ProjectResponse])
|
||||
async def list_projects():
|
||||
if not PROJECTS_DIR.is_dir():
|
||||
return []
|
||||
return [{"name": entry.name} for entry in sorted(PROJECTS_DIR.iterdir()) if entry.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, "Project not found")
|
||||
shutil.rmtree(pdir)
|
||||
# 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}
|
||||
|
||||
|
||||
@@ -155,19 +164,103 @@ async def create_blocks(req: BlocksCreateRequest):
|
||||
# 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()
|
||||
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":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
_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, ab_phase=req.ab_phase, ab_step=req.ab_step, to_step=req.to_step, research=req.research))
|
||||
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.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):
|
||||
@@ -179,16 +272,20 @@ async def cancel_blocks_route(topic: str):
|
||||
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.post("/blocks/reset-step")
|
||||
async def reset_blocks_step(req: BlocksResetStepRequest):
|
||||
topic = req.topic.strip()
|
||||
@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"} # don't interfere with a running generation
|
||||
await reset_blocks_ab_step(topic, req.ab_step)
|
||||
return {"ok": True}
|
||||
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")
|
||||
@@ -199,14 +296,17 @@ async def reset_block_progress(topic: str, block: str):
|
||||
|
||||
|
||||
def _validate_source(type: str, location: str) -> None:
|
||||
"""Check source input (same rules as on creation)."""
|
||||
"""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":
|
||||
if not location.lower().startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "Link must start with http:// or https://.")
|
||||
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)
|
||||
@@ -226,33 +326,49 @@ async def update_blocks_source(req: BlocksSourceUpdate):
|
||||
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/kanban")
|
||||
async def get_kanban_board(topic: str):
|
||||
"""Live card counts per kanban column (empty dict when the streaming inventory is not in use)."""
|
||||
import database as db
|
||||
return await db.kanban_stage_counts(topic)
|
||||
|
||||
|
||||
@router.get("/blocks/agents")
|
||||
async def get_active_agents(topic: str):
|
||||
"""Currently running agents for this topic + their runtime (seconds). Label = key minus prefix."""
|
||||
from agents import active_agents
|
||||
prefix = f"blocks-{topic}-"
|
||||
return [{"label": a["key"][len(prefix):], "runtime": a["runtime"]} for a in active_agents(prefix)]
|
||||
|
||||
|
||||
@router.post("/blocks/research")
|
||||
async def add_research(topic: str):
|
||||
"""Attach one more research agent to the running kanban flow (live breadth boost)."""
|
||||
from kanban import add_research_agent
|
||||
return {"started": add_research_agent(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)."""
|
||||
@@ -261,22 +377,66 @@ async def get_question_pattern(topic: str, block: str):
|
||||
return {"pattern": await load_question_pattern_free(topic, block, fe)}
|
||||
|
||||
|
||||
@router.get("/blocks/artefakte")
|
||||
async def get_artefakte(topic: str, type: str | None = None):
|
||||
"""Learning artifacts (flashcards/examples) per topic, grouped by block norm — per subblock."""
|
||||
rows = await get_sub_artefakte(topic, type)
|
||||
out: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = out.setdefault(r["block_norm"], {"block": r["block"], "flashcard": [], "example": []})
|
||||
if r["block"] and not b["block"]:
|
||||
b["block"] = r["block"]
|
||||
# --- 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
|
||||
if r["type"] in ("flashcard", "example"):
|
||||
b[r["type"]].append({"subblock": r["sub_title"], **data})
|
||||
return {"artefakte": out}
|
||||
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 ---
|
||||
@@ -319,15 +479,26 @@ async def block_chat_route(req: BlockChatRequest):
|
||||
|
||||
# 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], asyncio.Lock] = {}
|
||||
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
|
||||
|
||||
|
||||
def _check_lock(topic: str, block: str) -> asyncio.Lock:
|
||||
@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)
|
||||
lock = _check_locks.get(key)
|
||||
if lock is None:
|
||||
lock = _check_locks[key] = asyncio.Lock()
|
||||
return lock
|
||||
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]:
|
||||
@@ -345,11 +516,10 @@ def _color(points: int) -> str:
|
||||
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); element once from beginner level.
|
||||
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)
|
||||
was_level = state["completed"] is not None # element guard: ever created already?
|
||||
basis, re_rating = _basis(state, question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
if not re_rating:
|
||||
@@ -362,10 +532,6 @@ async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int])
|
||||
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)
|
||||
# Create the learning element once, as soon as the first level (beginner) is reached.
|
||||
if not was_level and level_from_score(score, cf) is not None:
|
||||
if await set_block_completed(req.topic, req.block):
|
||||
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
|
||||
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
||||
|
||||
|
||||
@@ -496,8 +662,14 @@ async def block_exam_route(req: BlockExamRequest):
|
||||
|
||||
@router.post("/guides", response_model=GuideResponse)
|
||||
async def create(req: GuideCreateRequest):
|
||||
guides, progress, levels = await load_learnstate()
|
||||
reason = guide_lock(req.topic.strip(), req.format, guides, progress, levels)
|
||||
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())
|
||||
@@ -513,7 +685,7 @@ async def create(req: GuideCreateRequest):
|
||||
"updated_at": now,
|
||||
}
|
||||
await create_guide(guide)
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step))
|
||||
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
|
||||
return guide
|
||||
|
||||
|
||||
@@ -522,26 +694,42 @@ async def list_all():
|
||||
return await list_guides()
|
||||
|
||||
|
||||
@router.get("/guides/locks")
|
||||
async def guide_locks(topic: str):
|
||||
"""Lock reasons per format for the ▶ button — None = creatable."""
|
||||
guides, progress, levels = await load_learnstate()
|
||||
return {fmt: guide_lock(topic, fmt, guides, progress, levels) for fmt in ("FullGuide", "Rest", *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/steps")
|
||||
async def guide_steps(topic: str):
|
||||
"""Highest fully completed step index per format (artifact-based, -1 = none).
|
||||
Drives the clickable step bubbles (like the blocks phases)."""
|
||||
return {fmt: guide_done_step(guide_content_path(topic, fmt)) for fmt in ("Guide", "FullGuide", "Rest")}
|
||||
|
||||
|
||||
@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 not found")
|
||||
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")
|
||||
@@ -605,82 +793,6 @@ async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
|
||||
return res
|
||||
|
||||
|
||||
# --- Elements (personal summary) ---
|
||||
|
||||
@router.get("/elements", response_model=list[ElementResponse])
|
||||
async def get_elements(topic: str):
|
||||
return await list_elements(topic)
|
||||
|
||||
|
||||
@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("/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 not found")
|
||||
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 not found")
|
||||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||||
if change is None:
|
||||
raise HTTPException(502, "Revision failed — please try again")
|
||||
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 not found")
|
||||
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 not found")
|
||||
changes = await style_element(element, provider=req.provider)
|
||||
if changes is None:
|
||||
raise HTTPException(502, "Style check failed — please try again")
|
||||
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 not found")
|
||||
suggestions = await check_element(element, provider=req.provider)
|
||||
if suggestions is None:
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
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 not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/cancel")
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
@@ -689,12 +801,32 @@ async def cancel(guide_id: str):
|
||||
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 not found")
|
||||
await delete_progress(guide_id)
|
||||
await delete_guide(guide_id)
|
||||
# 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
|
||||
@@ -702,26 +834,10 @@ async def remove(guide_id: str, slots: bool = False):
|
||||
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_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 not found")
|
||||
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 not found")
|
||||
await set_progress(guide_id, req.chapter, req.done)
|
||||
return {"chapters": await list_progress(guide_id)}
|
||||
+37
-22
@@ -11,10 +11,10 @@ query loops per guide.
|
||||
|
||||
import json
|
||||
|
||||
from database import list_block_scores_all, subs_per_level_all, list_guides, list_progress_all
|
||||
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 blocks_path, guide_content_path
|
||||
from paths import guide_content_path
|
||||
from textkit import _norm_title
|
||||
|
||||
MAX_OFFENE_GUIDES = 3
|
||||
@@ -33,8 +33,8 @@ _LEVEL_WORT = {
|
||||
}
|
||||
|
||||
|
||||
async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str, dict[str, set[str]]]]:
|
||||
"""Guides + chapter progress + blocks per level.
|
||||
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).
|
||||
@@ -47,17 +47,29 @@ async def load_learnstate() -> tuple[list[dict], dict[str, set[str]], dict[str,
|
||||
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(), await list_progress_all(), levels
|
||||
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)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except ValueError:
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@@ -84,45 +96,48 @@ def _latest_done(guides: list[dict], fmt: str) -> dict[str, dict]:
|
||||
return latest
|
||||
|
||||
|
||||
def _guide_all(g: dict, progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
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], progress: dict[str, set[str]], levelset: dict[str, set[str]]) -> bool:
|
||||
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, progress, levelset)
|
||||
return g is not None and _guide_all(g, levelset)
|
||||
|
||||
|
||||
def ist_completed(topic: str, fmt: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
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, progress, levels["beginner"])
|
||||
return is_level(topic, fmt, guides, levels["beginner"])
|
||||
|
||||
|
||||
def topic_completed(topic: str, guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> bool:
|
||||
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, progress, levels["master"])
|
||||
return is_level(topic, "Guide", guides, levels["master"])
|
||||
|
||||
|
||||
def formats_stats(guides: list[dict], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> dict:
|
||||
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, progress, levels["beginner"]))
|
||||
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], progress: dict[str, set[str]], levels: dict[str, dict[str, set[str]]]) -> str | None:
|
||||
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 blocks_path(topic).exists():
|
||||
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"):
|
||||
@@ -132,9 +147,9 @@ def guide_lock(topic: str, fmt: str, guides: list[dict], progress: dict[str, set
|
||||
prereq = PRESTAGE.get(fmt)
|
||||
if prereq:
|
||||
level = FREISCHALT_LEVEL[fmt] # completed=10 · understood=20 · mastered=30
|
||||
if not is_level(topic, prereq, guides, progress, levels[level]):
|
||||
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, progress, levels).get(fmt, {"created": 0, "completed": 0})
|
||||
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)"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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():
|
||||
"""1–2 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):
|
||||
"""1–2 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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
|
||||
abzuwarten (gemessen: 4 Panel-Stalls à 160–230 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
|
||||
@@ -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) == []
|
||||
@@ -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 == ""
|
||||
@@ -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
|
||||
@@ -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"}]})
|
||||
@@ -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("12–15") == "12–15"
|
||||
|
||||
|
||||
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³))"
|
||||
@@ -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"]
|
||||
+72
-5
@@ -3,12 +3,22 @@
|
||||
No state, no IO — safe to import anywhere.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
|
||||
|
||||
|
||||
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 {}
|
||||
|
||||
|
||||
def _norm_title(s: str) -> str:
|
||||
"""Normalize a title for key comparison.
|
||||
|
||||
@@ -22,8 +32,55 @@ def _norm_title(s: str) -> str:
|
||||
return s.casefold()
|
||||
|
||||
|
||||
# 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 _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:
|
||||
return entry.split(" — ")[0].strip() or entry
|
||||
"""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]:
|
||||
@@ -55,12 +112,22 @@ def _resolve_title(idx: dict[str, int], t: str) -> int | None:
|
||||
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
|
||||
|
||||
|
||||
_DASH_SEP_RE = re.compile(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*")
|
||||
|
||||
|
||||
def _norm_dash(s: str) -> str:
|
||||
"""Space-surrounded dash variants (en/em/figure/bar/hyphen) → uniform separator ' — '.
|
||||
"""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.
|
||||
The ASCII hyphen "-" is left untouched (otherwise it would split formulas like "n - 1")."""
|
||||
return re.sub(r"\s+[‒–—―‐]\s+", " — ", s)
|
||||
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 "12–15" 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]:
|
||||
|
||||
@@ -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, ~5–8 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 0–10; 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()
|
||||
@@ -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]))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -4,9 +4,14 @@ 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:
|
||||
|
||||
+135
-100
@@ -1,13 +1,13 @@
|
||||
<script setup>
|
||||
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, resetBlocksFromStep as apiResetBausteineAbStep, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
||||
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 BlocksOverview from './components/BlocksOverview.vue'
|
||||
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||
import ElementsOverview from './components/ElementsOverview.vue'
|
||||
import GenerationView from './components/GenerationView.vue'
|
||||
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
||||
import PracticePanel from './components/PracticePanel.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const backendTopics = ref([])
|
||||
@@ -21,30 +21,33 @@ const darkMode = ref(
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: localStorage.getItem('darkMode') === 'true',
|
||||
)
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
|
||||
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 | elements | general | detail — exclusive main-area view
|
||||
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 levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
|
||||
const _storedLevel = localStorage.getItem('level')
|
||||
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
|
||||
const stats = ref(null)
|
||||
const progress = ref({})
|
||||
const locks = ref({}) // lock reasons per format (backend = single rule source)
|
||||
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
|
||||
const uiError = ref(null) // surface rejected actions (409/400)
|
||||
const elementsOpen = ref(false) // right sidebar
|
||||
const elementsVersion = ref(0) // increment = reload overview
|
||||
const elementOpenId = ref(null) // open element from overview in sidebar
|
||||
const elementOpenTick = ref(0)
|
||||
|
||||
// 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() {
|
||||
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
||||
}
|
||||
@@ -169,13 +172,9 @@ async function loadBlocks() {
|
||||
if (selectedTopic.value) {
|
||||
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
||||
progress.value = await fetchTopicProgress(selectedTopic.value)
|
||||
locks.value = await fetchGuideLocks(selectedTopic.value)
|
||||
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
|
||||
} else {
|
||||
blocks.value = { ...EMPTY_BLOCKS }
|
||||
progress.value = {}
|
||||
locks.value = {}
|
||||
guideStepsDone.value = {}
|
||||
}
|
||||
if (activeBlocks.value.length && !polling.running()) startPolling()
|
||||
} catch (e) {
|
||||
@@ -183,14 +182,31 @@ async function loadBlocks() {
|
||||
}
|
||||
}
|
||||
|
||||
// „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')
|
||||
}
|
||||
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
|
||||
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
|
||||
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)
|
||||
loadBlocks()
|
||||
}
|
||||
@@ -202,34 +218,45 @@ watch(previewGuide, (g) => {
|
||||
|
||||
async function handleCancelBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCancelBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiDeleteBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetFromStep(step) {
|
||||
async function handleAddResearch() {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
|
||||
await apiAddResearch(selectedTopic.value, provider.value)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null, research = true }) {
|
||||
async function handleRequeueDead() {
|
||||
if (!selectedTopic.value) return
|
||||
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 {
|
||||
// Source is already fixed here; abPhase/abStep set the start, toStep an optional end limit.
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep, toStep, research)
|
||||
// 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
|
||||
@@ -248,6 +275,7 @@ async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt })
|
||||
}
|
||||
await loadTopics()
|
||||
selectTopic(topic)
|
||||
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
|
||||
startPolling()
|
||||
}
|
||||
|
||||
@@ -278,23 +306,53 @@ function handleOpenBlocksView() {
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions, abStep = 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, abStep)
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
handleOpenGuideBoard(format) // Start → direkt aufs Live-Board
|
||||
await loadGuides()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
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 loadGuides()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function handleGuideBoardPreview() {
|
||||
const g = doneByFormat.value[guideBoardFormat.value]
|
||||
if (g) handlePreview(g)
|
||||
}
|
||||
|
||||
function handlePreview(guide) {
|
||||
@@ -308,20 +366,20 @@ function handleGeneralExam() {
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
function handleOpenElements() {
|
||||
function handlePractice() {
|
||||
if (!selectedTopic.value) return
|
||||
mainView.value = 'elements'
|
||||
// Right sidebar stays closed — it opens only when an element is clicked.
|
||||
}
|
||||
|
||||
function handleOpenElementDetail(el) {
|
||||
elementOpenId.value = el.id
|
||||
elementOpenTick.value++
|
||||
elementsOpen.value = true
|
||||
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
|
||||
}
|
||||
@@ -335,17 +393,12 @@ const polling = usePolling(
|
||||
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
|
||||
@@ -374,8 +427,6 @@ onMounted(async () => {
|
||||
:selectedTopic="selectedTopic"
|
||||
:stats="stats"
|
||||
:fortschritt="progress"
|
||||
:locks="locks"
|
||||
:guideStepsDone="guideStepsDone"
|
||||
:uiError="uiError"
|
||||
:doneByFormat="doneByFormat"
|
||||
:latestByFormat="latestByFormat"
|
||||
@@ -395,44 +446,55 @@ onMounted(async () => {
|
||||
@setAnsicht="setView"
|
||||
@setStufe="setLevel"
|
||||
@generalExam="handleGeneralExam"
|
||||
@practice="handlePractice"
|
||||
@select="selectTopic"
|
||||
@createThema="handleCreateTopic"
|
||||
@updateSource="handleUpdateSource"
|
||||
@openBausteineView="handleOpenBlocksView"
|
||||
@formatClick="handleFormatClick"
|
||||
@openGuideBoard="handleOpenGuideBoard"
|
||||
@bausteineClick="handleBlocksClick"
|
||||
@cancelBlocks="handleCancelBlocks"
|
||||
@resetBausteine="handleResetBlocks"
|
||||
@deleteTopic="handleDeleteTopic"
|
||||
@cancelGuide="handleCancel"
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@dismissError="handleDismissError"
|
||||
@dismissUiError="uiError = null"
|
||||
@preview="handlePreview"
|
||||
@openElements="handleOpenElements"
|
||||
@openGeneration="handleOpenGeneration"
|
||||
@togglePin="toggleSidebarPin"
|
||||
@sidebarLeave="onSidebarLeave"
|
||||
/>
|
||||
<BlocksOverview
|
||||
v-if="selectedTopic && mainView === 'blocks'"
|
||||
:topic="selectedTopic"
|
||||
:steps="blocks.feine_steps || []"
|
||||
:generating="blocks.generating"
|
||||
:progress="blocks.progress"
|
||||
:ready="blocks.ready"
|
||||
:partial="blocks.partial"
|
||||
@close="mainView = 'detail'"
|
||||
@restartFrom="(r) => handleBlocksClick({ instructions: '', abStep: r.from, toStep: r.to })"
|
||||
@resetFrom="handleResetFromStep"
|
||||
@restartAll="(o) => handleBlocksClick({ research: o?.research ?? false })"
|
||||
@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"
|
||||
/>
|
||||
<ElementsOverview
|
||||
v-else-if="selectedTopic && mainView === 'elements'"
|
||||
:topic="selectedTopic"
|
||||
:version="elementsVersion"
|
||||
@open="handleOpenElementDetail"
|
||||
@cancelGuide="handleCancel"
|
||||
@startGuide="handleFormatClick"
|
||||
@preview="handleGuideBoardPreview"
|
||||
@removeFormat="handleRemoveGuideFormat"
|
||||
@setAuto="handleSetAuto"
|
||||
/>
|
||||
<GeneralExamPanel
|
||||
v-else-if="selectedTopic && mainView === 'general'"
|
||||
@@ -441,13 +503,15 @@ onMounted(async () => {
|
||||
@progressChanged="loadStats(); loadBlocks()"
|
||||
@fokus-active="focusOpen = $event"
|
||||
/>
|
||||
<PracticePanel
|
||||
v-else-if="selectedTopic && mainView === 'practice'"
|
||||
:key="selectedTopic"
|
||||
:topic="selectedTopic"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-else-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
:dark="darkMode"
|
||||
:provider="provider"
|
||||
:elementsOpen="elementsOpen"
|
||||
:doneByFormat="doneByFormat"
|
||||
:themaAbgeschlossen="!!progress.completed"
|
||||
:ansichtModus="viewMode"
|
||||
:stufeAnsicht="levelView"
|
||||
@@ -459,20 +523,6 @@ onMounted(async () => {
|
||||
<div v-else class="empty-main">
|
||||
<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>
|
||||
|
||||
@@ -626,19 +676,4 @@ textarea::placeholder {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Only visible when the elements sidebar sits as an overlay on mobile.
|
||||
A tap next to it closes it. */
|
||||
.elements-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.elements-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 29;
|
||||
background: var(--shadow);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+106
-252
@@ -1,305 +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 fetchGuideSteps(topic) {
|
||||
const res = await fetch(`${BASE}/guides/steps?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 fetchGuideLocks(topic) {
|
||||
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchActiveBlocks = () => req('/blocks/active')
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
|
||||
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
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 fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
|
||||
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
|
||||
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null, research = true) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep, to_step: toStep, research }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// 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 resetBlocksFromStep(topic, abStep) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-step`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
// 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 cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
|
||||
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
|
||||
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
|
||||
export const addBlocksResearch = (topic, provider = 'claude') =>
|
||||
req('/blocks/research', { method: 'POST', query: { topic, provider } })
|
||||
|
||||
export const removeGuideFormat = (topic, format) =>
|
||||
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export const requeueBlocksDead = (topic) =>
|
||||
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
|
||||
|
||||
// Live-Board der Guide-Erzeugung.
|
||||
export const fetchGuideBoard = (topic, format = 'Guide') =>
|
||||
req('/guides/board', { query: { topic, format } })
|
||||
|
||||
// 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 const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
|
||||
|
||||
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
|
||||
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
|
||||
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
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 examBlock({
|
||||
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,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
}) => 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 fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchQuestionPattern = (topic, block) =>
|
||||
req('/blocks/question-pattern', { query: { topic, block } })
|
||||
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
|
||||
|
||||
export async function fetchStats() {
|
||||
const res = await fetch(`${BASE}/stats`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchStats = () => req('/stats')
|
||||
|
||||
export async function fetchProviders() {
|
||||
const res = await fetch(`${BASE}/providers`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchProviders = () => req('/providers')
|
||||
|
||||
export async function fetchFolders(kind) {
|
||||
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
|
||||
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
|
||||
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
|
||||
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
|
||||
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
|
||||
|
||||
// Live card counts per kanban column ({} when the streaming inventory is not in use).
|
||||
export async function fetchKanban(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/kanban?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
|
||||
|
||||
// Currently running agents for a topic + their runtime in seconds.
|
||||
export async function fetchAgents(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/agents?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
|
||||
|
||||
// Attach one more research agent to the running kanban flow.
|
||||
export async function addResearch(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const deleteGuide = (id, slots = false) =>
|
||||
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
|
||||
}
|
||||
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
|
||||
|
||||
export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
|
||||
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
|
||||
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm.
|
||||
export async function fetchArtefakte(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`)
|
||||
if (!res.ok) return { artefakte: {} }
|
||||
return res.json()
|
||||
}
|
||||
// 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 async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
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 async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
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 async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetBlockProgress = (topic, block) =>
|
||||
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
|
||||
|
||||
export async function fetchTopics() {
|
||||
const res = await fetch(`${BASE}/topics`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopics = () => req('/topics')
|
||||
|
||||
export async function createTopic(name) {
|
||||
await fetch(`${BASE}/topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
|
||||
|
||||
export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
|
||||
|
||||
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 async function fetchElements(topic) {
|
||||
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
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 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 async function deleteElement(id) {
|
||||
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
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 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-Exam fehlgeschlagen (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
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 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(`Exam fehlgeschlagen (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
|
||||
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })
|
||||
@@ -89,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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup>
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
import { renderMarkdown, renderBlocks } from '../markdown.js'
|
||||
import { stufeFuer, LEVELS } from '../levels.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 }
|
||||
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null },
|
||||
@@ -21,6 +18,7 @@ const props = defineProps({
|
||||
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'
|
||||
@@ -101,7 +99,27 @@ const level = computed(() => {
|
||||
const displayedText = computed(() =>
|
||||
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
|
||||
)
|
||||
const blocks = computed(() => renderBlocks(displayedText.value))
|
||||
// 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')
|
||||
|
||||
@@ -179,30 +197,31 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
<div class="fokus-body">
|
||||
<div ref="guideEl" class="fokus-col left">
|
||||
<div class="markdown">
|
||||
<template v-for="(b, i) in blocks" :key="i">
|
||||
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
|
||||
<div v-if="suggestions[i]" class="block-vorschlag">
|
||||
<div v-if="suggestions[i].running" class="bv-status">Check Section…</div>
|
||||
<template v-else>
|
||||
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
|
||||
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
|
||||
<div class="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @click="discardBlock(i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)">✏️</button>
|
||||
</div>
|
||||
<div v-if="suggestions[i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)">↻</button>
|
||||
<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>
|
||||
<template v-if="artefakte">
|
||||
<WorkedExampleBlock :examples="artefakte.example || []" />
|
||||
<FlashcardWidget :cards="artefakte.flashcard || []" />
|
||||
</template>
|
||||
</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>
|
||||
@@ -264,10 +283,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* 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). */
|
||||
@@ -284,9 +299,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -5,12 +5,13 @@ 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
|
||||
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
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)
|
||||
@@ -70,7 +71,7 @@ function tabClick(tab) {
|
||||
|
||||
// --- Block chat (ephemeral) ---
|
||||
const chat = useChat((msgs) => chatBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), ...payload,
|
||||
})
|
||||
if (run !== examRun) return
|
||||
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
|
||||
const pattern = takePattern()
|
||||
const base = {
|
||||
topic: props.topic, block: props.block, section: props.section,
|
||||
section_compact: props.sectionKompakt, provider: props.provider,
|
||||
section_compact: props.sectionCompact, provider: props.provider,
|
||||
}
|
||||
if (form === 'quiz' && pattern) {
|
||||
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
|
||||
@@ -381,7 +382,7 @@ async function quizAnswer() {
|
||||
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.sectionKompakt,
|
||||
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,
|
||||
})
|
||||
@@ -412,7 +413,7 @@ async function clozeAnswer() {
|
||||
? { 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.sectionKompakt,
|
||||
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
|
||||
@@ -451,7 +452,7 @@ async function quickEvaluate() {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
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
|
||||
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
|
||||
if (thorough) examLoading.value = true
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
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)
|
||||
@@ -629,26 +630,8 @@ function onExamKey(e) {
|
||||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||||
<!-- Block chat -->
|
||||
<div v-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-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="chat.loading.value" class="bp-msg assistant bp-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="bp-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
rows="2"
|
||||
placeholder="Question about the block…"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||||
{{ chat.loading.value ? '✕' : '➤' }}
|
||||
</button>
|
||||
</div>
|
||||
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
|
||||
placeholder="Frage zum Baustein…" />
|
||||
</div>
|
||||
|
||||
<!-- Exam: guided dialog -->
|
||||
@@ -663,7 +646,7 @@ function onExamKey(e) {
|
||||
<!-- 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">Cancel</button>
|
||||
<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">
|
||||
@@ -696,7 +679,7 @@ function onExamKey(e) {
|
||||
<!-- 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">Cancel</button>
|
||||
<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">
|
||||
@@ -707,7 +690,7 @@ function onExamKey(e) {
|
||||
id="bp-gap-input"
|
||||
v-model="clozeCurrent.input"
|
||||
:disabled="clozeCurrent.done"
|
||||
placeholder="Term for the gap…"
|
||||
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>
|
||||
@@ -744,7 +727,7 @@ function onExamKey(e) {
|
||||
<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="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
|
||||
<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>
|
||||
@@ -757,7 +740,7 @@ function onExamKey(e) {
|
||||
</div>
|
||||
|
||||
<div v-if="examPhase === 'idle'" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<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>
|
||||
|
||||
@@ -767,11 +750,11 @@ function onExamKey(e) {
|
||||
ref="examInputEl"
|
||||
v-model="examInput"
|
||||
rows="2"
|
||||
placeholder="Answer — or ask if unclear…"
|
||||
placeholder="Antwort — oder nachfragen…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<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>
|
||||
@@ -831,8 +814,8 @@ function onExamKey(e) {
|
||||
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, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
.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;
|
||||
|
||||
@@ -1,128 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchBlocksOverview, fetchKanban, fetchAgents, addResearch } from '../api.js'
|
||||
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 },
|
||||
steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
|
||||
generating: { type: Boolean, default: false },
|
||||
progress: { type: String, default: null },
|
||||
ready: { type: Boolean, default: false },
|
||||
partial: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
|
||||
|
||||
// Live kanban board (streaming inventory). Ordered columns + their card counts.
|
||||
const KANBAN_COLS = [
|
||||
['merge', 'Merge'], ['chain', 'Chain'], ['chain_verify', 'Verify'], ['naming', 'Naming'],
|
||||
['naming_verify', 'Name✓'], ['chain_filter', 'Filter'], ['filter_verify', 'Filter✓'],
|
||||
['block_assemble', 'Block'], ['small_blocks', 'Small'], ['small_verify', 'Small✓'],
|
||||
['dependency', 'Dep'], ['dependency_verify', 'Dep✓'], ['main', 'Main'], ['done_block', 'Done'],
|
||||
]
|
||||
const kanban = ref({})
|
||||
const agents = ref([])
|
||||
const kanbanCols = computed(() => KANBAN_COLS.map(([k, label]) => ({ key: k, label, n: kanban.value[k] || 0 })))
|
||||
const kanbanActive = computed(() => Object.values(kanban.value).some((n) => n > 0))
|
||||
function fmtRuntime(s) {
|
||||
const m = Math.floor(s / 60), sec = Math.floor(s % 60)
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
const researchBusy = ref(false)
|
||||
async function moreResearch() {
|
||||
researchBusy.value = true
|
||||
try { await addResearch(props.topic) } catch { /* ignore */ }
|
||||
setTimeout(() => { researchBusy.value = false }, 800) // brief debounce against double-clicks
|
||||
}
|
||||
let kanbanTimer = null
|
||||
async function pollKanban() {
|
||||
try { kanban.value = await fetchKanban(props.topic) } catch { /* ignore */ }
|
||||
try { agents.value = await fetchAgents(props.topic) } catch { /* ignore */ }
|
||||
}
|
||||
watch(() => [props.topic, props.generating], () => {
|
||||
clearInterval(kanbanTimer)
|
||||
pollKanban()
|
||||
if (props.generating) kanbanTimer = setInterval(pollKanban, 1000)
|
||||
}, { immediate: true })
|
||||
onUnmounted(() => clearInterval(kanbanTimer))
|
||||
|
||||
// Group sub-steps by phase, carrying the global index for the re-run.
|
||||
const phaseGroups = computed(() => {
|
||||
const out = []
|
||||
props.steps.forEach((s, i) => {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
|
||||
else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
|
||||
})
|
||||
return out
|
||||
})
|
||||
|
||||
const startSel = ref(null) // marked start point (step index) — only ≤ current stand
|
||||
const endSel = ref(null) // optional end point (step index, > start) — generation stops there
|
||||
const confirm = ref(null) // which destructive action currently shows "Sure?"
|
||||
const startLabel = computed(() => props.steps[startSel.value]?.label || '')
|
||||
const endLabel = computed(() => props.steps[endSel.value]?.label || '')
|
||||
|
||||
// Start is only valid up to the current stand: done/active steps, never a pending one (never ran).
|
||||
function startDisabled(idx) { return startSel.value === null && props.steps[idx]?.state === 'pending' }
|
||||
function inRange(idx) { return startSel.value !== null && endSel.value !== null && idx > startSel.value && idx < endSel.value }
|
||||
|
||||
function stepClick(idx) {
|
||||
if (props.generating || startDisabled(idx)) return
|
||||
confirm.value = null
|
||||
if (startSel.value === null) { startSel.value = idx; endSel.value = null } // 1st click → start
|
||||
else if (idx === startSel.value) { startSel.value = null; endSel.value = null } // re-click start → clear
|
||||
else if (idx > startSel.value) { endSel.value = endSel.value === idx ? null : idx } // later step → toggle end
|
||||
else if (props.steps[idx]?.state !== 'pending') { startSel.value = idx; endSel.value = null } // earlier → new start
|
||||
}
|
||||
function clearSel() { startSel.value = null; endSel.value = null; confirm.value = null }
|
||||
// 2-click confirmation for destructive actions: first click "arms", second runs it.
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
// regenerate: re-run ONLY the picked step(s). No end → bound to the start itself (single step);
|
||||
// with an end → the whole [start, end] range. Later steps stay intact (bounded reset).
|
||||
function regenerateFromHere() { const from = startSel.value, to = endSel.value ?? startSel.value; clearSel(); emit('restartFrom', { from, to }) }
|
||||
// continue: run from the start point straight through to the end of the pipeline (full cascade).
|
||||
function continueFromHere() { const from = startSel.value; clearSel(); emit('restartFrom', { from, to: null }) }
|
||||
function deleteFromHere() { const from = startSel.value; clearSel(); emit('resetFrom', from) }
|
||||
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)
|
||||
|
||||
// Learning-path levels: order + label (color via CSS class st-<key>).
|
||||
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' },
|
||||
]
|
||||
// Legacy topics still carry einfach/mittel/schwer → map them to the new keys.
|
||||
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
if (!items.value.length) loading.value = true // Spinner nur beim Erstladen, Live-Reload flackert nicht
|
||||
error.value = null
|
||||
items.value = []
|
||||
try {
|
||||
items.value = await fetchBlocksOverview(props.topic)
|
||||
} catch (e) {
|
||||
items.value = []
|
||||
error.value = 'Overview not available — create blocks first.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Block relevant = has ≥1 relevant subblock (same rule as the guide).
|
||||
// Without relevance data (legacy topics) don't dim.
|
||||
function relevant(b) {
|
||||
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
|
||||
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
|
||||
}
|
||||
|
||||
// Only non-empty level groups per block (v-if + v-for not on one element)
|
||||
function groups(b) {
|
||||
return LEVELS
|
||||
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
|
||||
@@ -136,70 +71,43 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<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="Close" @click="emit('close')">✕</button>
|
||||
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section v-if="steps.length" class="bk-steps">
|
||||
<div class="bk-steps-top">
|
||||
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
|
||||
<div v-if="!generating" class="bk-global-actions">
|
||||
<button class="bk-act play" @click="emit('restartAll', { research: false })" title="Process the existing queue — search no new topics">Continue</button>
|
||||
<button class="bk-act play" @click="emit('restartAll', { research: true })" title="Start one research agent and process the queue">+ Research</button>
|
||||
<button
|
||||
v-if="ready || partial"
|
||||
class="bk-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => emit('removeAll'))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
</div>
|
||||
<div v-else class="bk-global-actions">
|
||||
<button class="bk-act play" :disabled="researchBusy" @click="moreResearch" title="Start one more research agent">+ Research</button>
|
||||
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="generating || kanbanActive" class="bk-kanban">
|
||||
<div v-for="c in kanbanCols" :key="c.key" class="bk-kcol" :class="{ 'bk-kactive': c.n > 0 }">
|
||||
<span class="bk-kcount">{{ c.n }}</span>
|
||||
<span class="bk-klabel">{{ c.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="agents.length" class="bk-agents">
|
||||
<span class="bk-agents-label">{{ agents.length }} Agenten aktiv:</span>
|
||||
<span v-for="a in agents" :key="a.label" class="bk-agent">
|
||||
{{ a.label }} <span class="bk-agent-time">{{ fmtRuntime(a.runtime) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="bk-phasen">
|
||||
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
|
||||
<span class="bk-phase-label">{{ g.phase }}</span>
|
||||
<div class="bk-steps">
|
||||
<button
|
||||
v-for="s in g.steps"
|
||||
:key="s.idx"
|
||||
class="bk-step"
|
||||
:class="[s.state, { sel: startSel === s.idx, end: endSel === s.idx, 'in-range': inRange(s.idx) }]"
|
||||
:disabled="generating || startDisabled(s.idx)"
|
||||
:title="startDisabled(s.idx) ? `«${s.label}» — not reached yet` : (startSel !== null && s.idx > startSel ? `End at «${s.label}»` : `Start at «${s.label}»`)"
|
||||
@click="stepClick(s.idx)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="startSel !== null && !generating" class="bk-step-actions">
|
||||
<span class="bk-step-actions-label">From «{{ startLabel }}»<span v-if="endSel !== null"> to «{{ endLabel }}»</span>:</span>
|
||||
<button class="bk-act play" @click="regenerateFromHere" :title="endSel === null ? 'Re-run only this step; later steps stay' : 'Re-run every step in the range; later steps stay'">↻ regenerate {{ endSel === null ? 'step' : 'range' }}</button>
|
||||
<button v-if="endSel === null" class="bk-act" @click="continueFromHere" title="Run from here through to the end">▶ continue</button>
|
||||
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', deleteFromHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ delete all' }}</button>
|
||||
<button class="bk-act ghost" @click="clearSel">Cancel</button>
|
||||
<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">Loading…</div>
|
||||
<div v-else-if="error" class="bk-empty-state">{{ error }}</div>
|
||||
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
|
||||
<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
|
||||
@@ -216,12 +124,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<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="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
{{ 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">No subblocks.</p>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,10 +142,14 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
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;
|
||||
@@ -261,117 +173,72 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
}
|
||||
.bk-close:hover { border-color: var(--accent); }
|
||||
|
||||
/* Step overview above the blocks */
|
||||
.bk-steps {
|
||||
padding: 0.85rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.bk-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.bk-progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: bk-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.bk-phasen { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
|
||||
.bk-phase { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.bk-phase-label {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.bk-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.22rem 0.5rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bk-step:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
||||
.bk-step:disabled { cursor: default; opacity: 0.7; }
|
||||
.bk-step.done { border-color: var(--success-border); color: var(--success); }
|
||||
.bk-step.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
.bk-step.pending { color: var(--text-faint); }
|
||||
.bk-step.sel,
|
||||
.bk-step.end { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
.bk-step.in-range { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
.bk-step:disabled:not(.done):not(.active) { opacity: 0.45; }
|
||||
|
||||
/* Header: progress left, global buttons right */
|
||||
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
|
||||
.bk-steps-top .bk-progress { margin-bottom: 0; }
|
||||
|
||||
/* Live kanban board (streaming inventory) */
|
||||
.bk-kanban { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.7rem; }
|
||||
.bk-kcol {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1px;
|
||||
min-width: 3.1rem; padding: 0.3rem 0.4rem;
|
||||
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel);
|
||||
}
|
||||
.bk-kcol.bk-kactive { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.bk-kcount { font-size: 0.95rem; font-weight: 700; color: var(--text); }
|
||||
.bk-kactive .bk-kcount { color: var(--accent); }
|
||||
.bk-klabel { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-faint); }
|
||||
|
||||
/* Running agents + live runtime */
|
||||
.bk-agents { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.5rem; margin-bottom: 0.7rem; font-size: 0.78rem; }
|
||||
.bk-agents-label { color: var(--text-muted); font-weight: 600; }
|
||||
.bk-agent {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.12rem 0.5rem; border: 1px solid var(--accent); border-radius: 10px;
|
||||
background: var(--accent-soft); color: var(--text);
|
||||
}
|
||||
.bk-agent-time { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--accent); }
|
||||
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
/* Action bar for the selected start point */
|
||||
.bk-step-actions {
|
||||
.bk-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
.bk-act {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
margin: 0.85rem 2rem 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
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-act:hover { border-color: var(--accent); }
|
||||
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
.bk-act.play:hover { background: var(--accent-hover); }
|
||||
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
|
||||
.bk-act.danger.armed { background: var(--danger); color: #fff; }
|
||||
.bk-act.ghost { color: var(--text-muted); }
|
||||
|
||||
.bk-empty-state {
|
||||
flex: 1;
|
||||
@@ -383,7 +250,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
|
||||
.bk-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem 4rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
@@ -398,7 +264,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
/* Non-relevant blocks (no relevant subblock) dimmed */
|
||||
.bk-card.bk-irrelevant { opacity: 0.5; }
|
||||
|
||||
.bk-title {
|
||||
|
||||
@@ -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>
|
||||
@@ -1,167 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchElements } from '../api.js'
|
||||
import { renderMarkdown, plainText } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
version: { type: Number, default: 0 }, // increment = reload elements
|
||||
})
|
||||
|
||||
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('Failed to load elements:', e)
|
||||
}
|
||||
}
|
||||
|
||||
</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">Elements</span>
|
||||
</header>
|
||||
<p v-if="!elements.length" class="overview-empty">
|
||||
No elements yet. Enter a keyword in the sidebar on the right and click +.
|
||||
</p>
|
||||
<div class="element-grid">
|
||||
<article
|
||||
v-for="el in elements"
|
||||
:key="el.id"
|
||||
class="element-card"
|
||||
@click="emit('open', el)"
|
||||
>
|
||||
<h3>{{ plainText(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>Hints</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: base styles global (assets/markdown.css), here only the card base font */
|
||||
.markdown {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +1,37 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ cards: { type: Array, default: () => [] } })
|
||||
// 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 open = ref(false)
|
||||
const order = ref([])
|
||||
const pos = ref(0)
|
||||
const flipped = ref(false)
|
||||
|
||||
function reset() {
|
||||
order.value = props.cards.map((_, i) => i)
|
||||
pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
watch(() => props.cards, reset, { immediate: true })
|
||||
|
||||
const current = computed(() => props.cards[order.value[pos.value]] || null)
|
||||
const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
|
||||
|
||||
function next(known) {
|
||||
if (known) {
|
||||
pos.value++
|
||||
} else {
|
||||
// "Again" → push the card to the end of the round (light spacing).
|
||||
const [k] = order.value.splice(pos.value, 1)
|
||||
order.value.push(k)
|
||||
}
|
||||
if (pos.value >= order.value.length) pos.value = 0
|
||||
flipped.value = false
|
||||
}
|
||||
watch(() => props.card, () => { flipped.value = false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="cards.length" class="flashcards">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">🃏</span> Flashcards
|
||||
<span class="art-count">{{ cards.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="open && current" class="fc-body">
|
||||
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
|
||||
<div class="fc-zaehler">{{ counter }}</div>
|
||||
<div v-if="!flipped" class="fc-seite">
|
||||
<span class="fc-label">Question</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.question)"></div>
|
||||
<span class="fc-hint">Click to flip</span>
|
||||
</div>
|
||||
<div v-else class="fc-seite">
|
||||
<span class="fc-label">Answer</span>
|
||||
<div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div>
|
||||
</div>
|
||||
<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-if="flipped" class="fc-aktionen">
|
||||
<button class="fc-btn nochmal" @click="next(false)">Again</button>
|
||||
<button class="fc-btn gewusst" @click="next(true)">Knew it</button>
|
||||
<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>
|
||||
.flashcards { margin-top: 0.75rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.fc-body { margin-top: 0.5rem; }
|
||||
.fc-card {
|
||||
position: relative; min-height: 120px; cursor: pointer;
|
||||
@@ -83,10 +41,6 @@ function next(known) {
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.fc-card.flipped { border-color: var(--accent); }
|
||||
.fc-zaehler {
|
||||
position: absolute; top: 6px; right: 10px;
|
||||
font-size: 0.68rem; color: var(--text-faint);
|
||||
}
|
||||
.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;
|
||||
|
||||
@@ -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 0–10 → „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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
// Schmaler Fortschrittsbalken (0–1) 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>
|
||||
@@ -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>
|
||||
@@ -1,32 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js'
|
||||
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
import { stufeFuer, schwelle } from '../levels.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 FlashcardWidget from './FlashcardWidget.vue'
|
||||
import WorkedExampleBlock from './WorkedExampleBlock.vue'
|
||||
|
||||
// Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
|
||||
// attaching the artifacts (keyed by block_norm) to the section titles.
|
||||
function normTitle(s) {
|
||||
return (s || '').normalize('NFKC')
|
||||
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
|
||||
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
|
||||
.toLowerCase().replace(/ß/g, 'ss')
|
||||
}
|
||||
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 open → chat to the left
|
||||
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
|
||||
themaAbgeschlossen: { type: Boolean, default: false },
|
||||
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
|
||||
@@ -39,11 +26,6 @@ const content = ref(null)
|
||||
const loadError = ref(null)
|
||||
const scrollEl = ref(null)
|
||||
const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
|
||||
const artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
|
||||
|
||||
function artifactsFor(title) {
|
||||
return artifacts.value[normTitle(title)] || null
|
||||
}
|
||||
|
||||
// --- 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. ---
|
||||
@@ -51,13 +33,43 @@ 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 key = `${props.ansichtModus}:${s.num}`
|
||||
const lvl = viewLevelFor(s.title)
|
||||
const key = `${props.ansichtModus}:${s.num}:${lvl}:${props.stufeAnsicht}`
|
||||
let h = mdCache.get(key)
|
||||
if (h === undefined) {
|
||||
h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md)
|
||||
mdCache.set(key, h)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,7 +92,7 @@ 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, loadContent)
|
||||
watch(() => props.stufeAnsicht, () => mdCache.clear())
|
||||
|
||||
async function loadContent() {
|
||||
content.value = null
|
||||
@@ -91,7 +103,7 @@ async function loadContent() {
|
||||
const g = props.previewGuide
|
||||
if (!g || g.status !== 'done') return
|
||||
try {
|
||||
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
|
||||
content.value = await fetchGuideContent(g.id, 4) // Stufen-Filterung passiert lokal (auto/Override)
|
||||
} catch (e) {
|
||||
console.error('Error loading guide:', e)
|
||||
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
|
||||
@@ -100,9 +112,6 @@ async function loadContent() {
|
||||
try {
|
||||
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
|
||||
} catch { /* offline → empty */ }
|
||||
try {
|
||||
artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
|
||||
} catch { artifacts.value = {} }
|
||||
// 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')
|
||||
@@ -194,8 +203,7 @@ const chat = useChat((msgs) => {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, 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)
|
||||
|
||||
@@ -209,12 +217,6 @@ function closeChat() {
|
||||
chat.reset()
|
||||
}
|
||||
|
||||
// On mobile, chat and element sidebar are mutually exclusive —
|
||||
// there is no room side by side, the sidebar would cover the chat.
|
||||
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
|
||||
@@ -304,10 +306,6 @@ function extractContext() {
|
||||
</h3>
|
||||
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
|
||||
<div v-else class="section-body skeleton"></div>
|
||||
<template v-if="visible[s.num] && artifactsFor(s.title)">
|
||||
<WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
|
||||
<FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
|
||||
</template>
|
||||
<BlockPanel
|
||||
v-if="isCheckable(s)"
|
||||
mode="trigger"
|
||||
@@ -334,13 +332,13 @@ function extractContext() {
|
||||
<BlockFocus
|
||||
v-if="focusBlock"
|
||||
:block="focusBlock"
|
||||
:artefakte="artifactsFor(focusBlock.title)"
|
||||
: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"
|
||||
@@ -354,37 +352,15 @@ function extractContext() {
|
||||
@section-updated="onSectionUpdated"
|
||||
/>
|
||||
|
||||
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button>
|
||||
<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" :class="{ shifted: elementsOpen }">
|
||||
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
|
||||
<header class="chat-header">
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</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">Thinking…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="Ask a question…"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@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>
|
||||
@@ -472,8 +448,8 @@ function extractContext() {
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border: 1px solid #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border: 1px solid var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
|
||||
@@ -536,26 +512,26 @@ function extractContext() {
|
||||
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
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: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
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, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
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: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
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 */
|
||||
@@ -640,23 +616,6 @@ function extractContext() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Element sidebar (320px) open → show chat to its left */
|
||||
.chat-fab.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
|
||||
.chat-panel.shifted {
|
||||
right: calc(1.5rem + 320px);
|
||||
}
|
||||
|
||||
/* On mobile the element sidebar overlays the chat — hide FAB/panel */
|
||||
@media (max-width: 768px) {
|
||||
.chat-fab.shifted,
|
||||
.chat-panel.shifted {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
@@ -695,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>
|
||||
@@ -1,15 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
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 },
|
||||
selectedTopic: { type: String, default: null },
|
||||
stats: { type: Object, default: null },
|
||||
fortschritt: { type: Object, default: () => ({}) },
|
||||
locks: { type: Object, default: () => ({}) },
|
||||
guideStepsDone: { type: Object, default: () => ({}) }, // highest finished step per format (-1 = none)
|
||||
uiError: { type: String, default: null },
|
||||
doneByFormat: { type: Object, default: () => ({}) },
|
||||
latestByFormat: { type: Object, default: () => ({}) },
|
||||
@@ -26,7 +25,7 @@ const props = defineProps({
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', '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)
|
||||
@@ -59,6 +58,7 @@ const formats = [
|
||||
|
||||
// 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' },
|
||||
@@ -85,14 +85,6 @@ const activeGenerations = computed(() => {
|
||||
|
||||
const { pending: pendingConfirm, armOrRun } = useConfirm()
|
||||
|
||||
function confirmCancelBlocks() {
|
||||
armOrRun('blocks', () => emit('cancelBlocks'))
|
||||
}
|
||||
|
||||
function confirmResetBlocks() {
|
||||
armOrRun('blocks', () => emit('resetBausteine'))
|
||||
}
|
||||
|
||||
// Name click = open the block overview (generate/resume/remove all live there now).
|
||||
function onBlocksName() {
|
||||
emit('openBausteineView')
|
||||
@@ -108,38 +100,6 @@ function guideStatus(format) {
|
||||
return latest.status
|
||||
}
|
||||
|
||||
// Step dots of the guide pipeline
|
||||
const GUIDE_STEPS = ['Outline', 'Content', 'Content check', 'Writing', 'Reading exam']
|
||||
|
||||
// Dots from the artifact-based "done" marker (like blocks, not the DB counter):
|
||||
// ≤ done = done. Running → the next step (done+1) is active.
|
||||
function guideSteps(format) {
|
||||
const labels = GUIDE_STEPS
|
||||
const done = props.guideStepsDone[format] ?? -1
|
||||
const st = guideStatus(format)
|
||||
const active = st === 'generating' || st === 'queued' ? done + 1 : -1
|
||||
return labels.map((label, i) => ({
|
||||
label,
|
||||
state: i <= done ? 'done' : i === active ? 'active' : 'pending',
|
||||
}))
|
||||
}
|
||||
|
||||
// Re-run from a guide step (1-based dot per format). null = full/resume.
|
||||
const selectedStep = reactive({})
|
||||
// Dots clickable once artifacts exist (marker ≥ 0 or done) and not generating.
|
||||
function guideSelectable(format) {
|
||||
const st = guideStatus(format)
|
||||
if (st === 'generating' || st === 'queued') return false
|
||||
return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done'
|
||||
}
|
||||
function guideStepClick(format, n) {
|
||||
if (!guideSelectable(format)) return
|
||||
selectedStep[format] = selectedStep[format] === n ? null : n
|
||||
}
|
||||
function selectedStepLabel(format) {
|
||||
return GUIDE_STEPS[(selectedStep[format] || 0) - 1] || ''
|
||||
}
|
||||
|
||||
function errorMsg(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
|
||||
@@ -153,26 +113,11 @@ function aborted(format) {
|
||||
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
|
||||
}
|
||||
|
||||
// Name click: finished guide → preview, otherwise toggle action panel.
|
||||
// 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)
|
||||
else togglePanel('fmt-' + format)
|
||||
}
|
||||
|
||||
// Lock reasons come from the backend (GET /guides/locks) — the rules only
|
||||
// exist there now. While locks are not yet loaded: button enabled, the
|
||||
// backend rejects invalid starts anyway (visible via uiError).
|
||||
function playLock(format) {
|
||||
return props.locks?.[format] ?? null
|
||||
}
|
||||
|
||||
function handlePlay(format) {
|
||||
if (playLock(format)) return
|
||||
// Selected dot (1-based) → ab_step (0-based). Only for a (partially) built guide.
|
||||
const abStep = guideSelectable(format) && selectedStep[format] ? selectedStep[format] - 1 : null
|
||||
emit('formatClick', { format, instructions: '', abStep })
|
||||
selectedStep[format] = null
|
||||
else emit('openGuideBoard', format)
|
||||
}
|
||||
|
||||
// Flash-message behavior: × only hides, nothing is deleted
|
||||
@@ -181,25 +126,6 @@ function dismissError(format) {
|
||||
if (latest?.status === 'error') emit('dismissError', latest.id)
|
||||
}
|
||||
|
||||
function handleDelete(format) {
|
||||
if (!props.latestByFormat[format]) return
|
||||
armOrRun('fmt-' + format, () => {
|
||||
// Cancel all running generations of the format (also covers duplicates)
|
||||
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 (aborted(format)) {
|
||||
// Paused run: delete partial progress incl. step files (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: '' })
|
||||
@@ -254,10 +180,15 @@ async function toggleTopicPanel(t) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEditType(t) {
|
||||
editForm.value.type = t
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
// 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
|
||||
@@ -316,29 +247,12 @@ function saveSource() {
|
||||
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
|
||||
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
|
||||
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createTopic"
|
||||
/>
|
||||
<select
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<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">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
<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">
|
||||
@@ -354,7 +268,7 @@ function saveSource() {
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<span class="format-error-text">{{ uiError }}</span>
|
||||
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
|
||||
</div>
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
@@ -362,7 +276,7 @@ function saveSource() {
|
||||
<div class="ord-blocks">
|
||||
<div
|
||||
class="format-row blocks-row"
|
||||
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }"
|
||||
:class="{ 'is-active': blocksState === 'generating' || blocks.partial }"
|
||||
>
|
||||
<button class="format-name blocks-name" @click="onBlocksName">
|
||||
<span class="format-label">Blocks</span>
|
||||
@@ -372,20 +286,6 @@ function saveSource() {
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
</button>
|
||||
<button v-if="blocksState === 'generating' || blocks.ready || blocks.partial" class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('blocks')" class="action-panel">
|
||||
<template v-if="blocksState === 'generating'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="blocks.ready || blocks.partial"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'blocks' }"
|
||||
@click="confirmResetBlocks"
|
||||
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="blocksState === 'generating'" class="format-progress">
|
||||
{{ blocks.progress || 'Waiting…' }}
|
||||
@@ -393,10 +293,16 @@ function saveSource() {
|
||||
<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>
|
||||
<!-- 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), 'row-open': isOpen('fmt-' + f.key) }]">
|
||||
<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
|
||||
@@ -404,37 +310,7 @@ function saveSource() {
|
||||
class="resume-badge"
|
||||
title="Aborted — can be resumed"
|
||||
>Paused</span>
|
||||
<span class="step-dots" v-if="guideSteps(f.key).length">
|
||||
<span
|
||||
v-for="(s, i) in guideSteps(f.key)"
|
||||
:key="s.label"
|
||||
class="step-pill"
|
||||
:class="[s.state, { sel: selectedStep[f.key] === i + 1, klickbar: guideSelectable(f.key) }]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideSelectable(f.key) ? ' — Click: regenerate from here' : '')"
|
||||
@click.stop="guideStepClick(f.key, i + 1)"
|
||||
>{{ i + 1 }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Actions" @click.stop="togglePanel('fmt-' + f.key)">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('fmt-' + f.key)" class="action-panel">
|
||||
<template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'">
|
||||
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : 'Cancel' }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="panel-btn play"
|
||||
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
|
||||
:disabled="!!playLock(f.key)"
|
||||
@click="handlePlay(f.key)"
|
||||
>{{ selectedStep[f.key] ? `Restart from «${selectedStepLabel(f.key)}»` : aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
|
||||
class="panel-btn danger"
|
||||
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
|
||||
@click="handleDelete(f.key)"
|
||||
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : aborted(f.key) ? 'Delete progress' : 'Remove' }}</button>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
|
||||
@@ -442,7 +318,7 @@ function saveSource() {
|
||||
>{{ 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="Hide" @click="dismissError(f.key)">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="format-row ord-exam">
|
||||
@@ -450,9 +326,9 @@ function saveSource() {
|
||||
<span class="format-label">General Exam</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="format-row ord-elemente">
|
||||
<button class="format-name elements-btn" @click="emit('openElements')">
|
||||
<span class="format-label">Elements</span>
|
||||
<div class="format-row ord-practice">
|
||||
<button class="format-name elements-btn" @click="emit('practice')">
|
||||
<span class="format-label">Üben</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,37 +341,20 @@ function saveSource() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
<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">Loading…</p>
|
||||
<p v-if="editLoading" class="dlg-hint">Lädt…</p>
|
||||
<template v-else>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
|
||||
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
|
||||
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
|
||||
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="editForm.type === 'link'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<select
|
||||
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<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 ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -738,56 +597,7 @@ function saveSource() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-dots {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||
.step-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
color: var(--bg);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.step-pill.done {
|
||||
background: var(--success-border);
|
||||
}
|
||||
|
||||
.step-pill.active {
|
||||
background: var(--warning-border);
|
||||
animation: dot-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.step-pill.klickbar {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-pill.sel {
|
||||
border-color: var(--text);
|
||||
box-shadow: 0 0 0 1px var(--text);
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -799,6 +609,15 @@ function saveSource() {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -807,7 +626,7 @@ function saveSource() {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
.ord-practice {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
@@ -904,59 +723,10 @@ function saveSource() {
|
||||
.format-row.row-open,
|
||||
.topic-list li.li-open .topic-row { background: var(--panel-soft); }
|
||||
|
||||
.action-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0.75rem 0.55rem calc(0.75rem + 8px);
|
||||
}
|
||||
|
||||
.panel-btn {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.panel-btn:hover { border-color: var(--accent); }
|
||||
.panel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.panel-btn:disabled:hover { border-color: var(--border-strong); }
|
||||
.panel-btn.play {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
.panel-btn.play:hover { background: var(--success-soft-hover); }
|
||||
.panel-btn.danger { color: var(--danger); }
|
||||
.panel-btn.danger:hover { border-color: var(--danger); }
|
||||
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
|
||||
.format-x {
|
||||
display: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.format-name:hover .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Running/paused: always show × — there is no hover on touch */
|
||||
.fmt-generating .format-x,
|
||||
.fmt-queued .format-x,
|
||||
.fmt-paused .format-x,
|
||||
.blocks-row.is-active .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.format-x.armed,
|
||||
.format-error-x.armed,
|
||||
.delete-topic.armed {
|
||||
display: inline-block;
|
||||
@@ -1019,45 +789,6 @@ function saveSource() {
|
||||
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; }
|
||||
@@ -1102,18 +833,6 @@ function saveSource() {
|
||||
}
|
||||
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.dlg-textarea { resize: vertical; min-height: 2rem; }
|
||||
.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-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 {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { renderMarkdownInline } from '../markdown.js'
|
||||
|
||||
defineProps({ examples: { type: Array, default: () => [] } })
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="examples.length" class="worked">
|
||||
<button class="art-head" @click="open = !open">
|
||||
<span class="art-icon">📝</span> Examples
|
||||
<span class="art-count">{{ examples.length }}</span>
|
||||
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="we-body">
|
||||
<div v-for="(b, i) in examples" :key="i" class="we-card">
|
||||
<div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
|
||||
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
|
||||
<ol class="we-steps">
|
||||
<li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
|
||||
</ol>
|
||||
<div v-if="b.result" class="we-result">
|
||||
<span class="we-label">Result</span>
|
||||
<span v-html="renderMarkdownInline(b.result)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.worked { margin-top: 0.5rem; }
|
||||
.art-head {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
|
||||
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
|
||||
}
|
||||
.art-icon { font-size: 0.95rem; }
|
||||
.art-count {
|
||||
background: var(--panel-soft); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
|
||||
}
|
||||
.art-toggle { margin-left: auto; color: var(--text-faint); }
|
||||
|
||||
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.we-card {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--accent);
|
||||
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
|
||||
font-size: 0.92rem; line-height: 1.5;
|
||||
}
|
||||
.we-sub {
|
||||
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
|
||||
}
|
||||
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
|
||||
.we-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
|
||||
.we-steps li { margin: 0.2rem 0; }
|
||||
.we-result {
|
||||
display: flex; gap: 6px; align-items: baseline;
|
||||
padding-top: 0.35rem; border-top: 1px dashed var(--border);
|
||||
}
|
||||
.we-label {
|
||||
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--success); font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue'
|
||||
import { chatElement } from '../../api.js'
|
||||
import { useChat } from '../../composables/useChat.js'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['changes'])
|
||||
|
||||
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
|
||||
|
||||
// Different element selected → discard history
|
||||
watch(() => props.element.id, () => chat.reset())
|
||||
|
||||
async function send() {
|
||||
const res = await chat.send()
|
||||
if (res?.changes?.length) emit('changes', res.changes)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-chat">
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Adjusting…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
placeholder="Adjust element…"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 85%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.82rem;
|
||||
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-typing {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 72px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: 38px;
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
@@ -1,455 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
|
||||
import { renderMarkdown, plainText } from '../../markdown.js'
|
||||
import ElementSuggestion from './ElementSuggestion.vue'
|
||||
import ElementChatTab from './ElementChatTab.vue'
|
||||
import ElementEditTab from './ElementEditTab.vue'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'close', 'updated', 'changed'])
|
||||
|
||||
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
|
||||
const savingEdit = ref(false)
|
||||
|
||||
// Strip Markdown characters from the header title
|
||||
|
||||
// Different element selected → reset exam state and tab
|
||||
watch(() => props.element.id, () => {
|
||||
tab.value = 'overview'
|
||||
resetCheck()
|
||||
})
|
||||
|
||||
// --- AI exam for missing info (results land as inline suggestions) ---
|
||||
const checking = ref(false)
|
||||
const statusMsg = ref(null)
|
||||
|
||||
function resetCheck() {
|
||||
checking.value = false
|
||||
statusMsg.value = null
|
||||
resetStyle()
|
||||
}
|
||||
|
||||
let checkRun = 0 // identify the running exam; cancellation ignores its result
|
||||
|
||||
async function runCheck() {
|
||||
if (checking.value) { // second click = cancel
|
||||
checkRun++
|
||||
checking.value = false
|
||||
return
|
||||
}
|
||||
const run = ++checkRun
|
||||
checking.value = true
|
||||
statusMsg.value = null
|
||||
try {
|
||||
const res = await checkElement(props.element.id, props.provider)
|
||||
if (run !== checkRun) return // cancelled or a new exam started
|
||||
const mapped = res.suggestions.map((s) => ({
|
||||
text: s.text, action: 'add', target: s.target, index: null, content: s.content,
|
||||
}))
|
||||
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
|
||||
else statusMsg.value = 'No important gaps found.'
|
||||
} catch (e) {
|
||||
if (run !== checkRun) return
|
||||
console.error('Exam failed:', e)
|
||||
statusMsg.value = 'Exam failed — please try again.'
|
||||
} finally {
|
||||
if (run === checkRun) checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Style exam: AI proposes changes, user confirms ---
|
||||
const styleChanges = ref(null) // null = not yet examined
|
||||
const styling = ref(false)
|
||||
const applyingStyle = ref(false)
|
||||
const refiningIdx = ref(null)
|
||||
let styleRun = 0
|
||||
|
||||
function resetStyle() {
|
||||
styleChanges.value = null
|
||||
styling.value = false
|
||||
applyingStyle.value = false
|
||||
refiningIdx.value = null
|
||||
}
|
||||
|
||||
function suggBusy(i) {
|
||||
return applyingStyle.value || refiningIdx.value === i
|
||||
}
|
||||
|
||||
// Refine a single suggestion via instruction (pencil icon)
|
||||
async function refineChange(i, instruction) {
|
||||
if (refiningIdx.value !== null || applyingStyle.value) return
|
||||
refiningIdx.value = i
|
||||
try {
|
||||
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
|
||||
const next = [...styleChanges.value]
|
||||
next[i] = res.change
|
||||
styleChanges.value = next
|
||||
} catch (e) {
|
||||
console.error('Refinement failed:', e)
|
||||
statusMsg.value = 'Refinement failed — please try again.'
|
||||
} finally {
|
||||
refiningIdx.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runStyle() {
|
||||
if (styling.value) { // second click = cancel
|
||||
styleRun++
|
||||
styling.value = false
|
||||
return
|
||||
}
|
||||
const run = ++styleRun
|
||||
styling.value = true
|
||||
statusMsg.value = null
|
||||
try {
|
||||
const res = await styleElement(props.element.id, props.provider)
|
||||
if (run !== styleRun) return
|
||||
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
||||
else statusMsg.value = 'Style already fits.'
|
||||
} catch (e) {
|
||||
if (run !== styleRun) return
|
||||
console.error('Style exam failed:', e)
|
||||
statusMsg.value = 'Style exam failed — please try again.'
|
||||
} finally {
|
||||
if (run === styleRun) styling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Chat suggestions also land as inline suggestions in the overview
|
||||
function onChatChanges(changes) {
|
||||
styleChanges.value = [...(styleChanges.value || []), ...changes]
|
||||
}
|
||||
|
||||
// Show suggestions at the target location: adjust/remove at the affected entry …
|
||||
function styleAt(target, index = null) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
|
||||
}
|
||||
|
||||
// … additions at the end of the respective section
|
||||
function styleAdds(target) {
|
||||
if (!styleChanges.value) return []
|
||||
return styleChanges.value
|
||||
.map((c, i) => [i, c])
|
||||
.filter(([, c]) => c.target === target && c.action === 'add')
|
||||
}
|
||||
|
||||
function dismissStyleChange(i) {
|
||||
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
|
||||
}
|
||||
|
||||
async function applyStyleChange(i) {
|
||||
if (applyingStyle.value) return
|
||||
const c = styleChanges.value[i]
|
||||
applyingStyle.value = true
|
||||
try {
|
||||
const STRING_TARGETS = ['title', 'description']
|
||||
const fields = {
|
||||
title: props.element.title,
|
||||
description: props.element.description,
|
||||
examples: [...props.element.examples],
|
||||
hints: [...props.element.hints],
|
||||
}
|
||||
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
|
||||
else if (c.action === 'add') {
|
||||
if (c.target === 'title') fields.title = c.content
|
||||
else if (c.target === 'description')
|
||||
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
|
||||
else fields[c.target].push(c.content)
|
||||
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
|
||||
else fields[c.target][c.index] = c.content
|
||||
|
||||
const updated = await updateElement(props.element.id, fields)
|
||||
emit('updated', updated)
|
||||
|
||||
// Keep remaining suggestions; indices after a removal shift up
|
||||
const rest = styleChanges.value.filter((_, j) => j !== i)
|
||||
if (c.action === 'remove') {
|
||||
for (const r of rest) {
|
||||
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
|
||||
}
|
||||
}
|
||||
styleChanges.value = rest
|
||||
} catch (e) {
|
||||
console.error('Apply failed:', e)
|
||||
} finally {
|
||||
applyingStyle.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit tab: save fields directly ---
|
||||
async function saveEdit(fields) {
|
||||
if (savingEdit.value) return
|
||||
savingEdit.value = true
|
||||
try {
|
||||
const updated = await updateElement(props.element.id, fields)
|
||||
emit('updated', updated)
|
||||
tab.value = 'overview'
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="el-header">
|
||||
<button class="el-back" title="Back to list" @click="emit('back')">←</button>
|
||||
<span class="el-title">{{ plainText(element.title) }}</span>
|
||||
<button
|
||||
class="el-tool" :class="{ busy: checking }"
|
||||
:title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
|
||||
>🔍</button>
|
||||
<button
|
||||
class="el-tool" :class="{ busy: styling }"
|
||||
:title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
|
||||
>✨</button>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
|
||||
<nav class="el-tabs">
|
||||
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
|
||||
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
|
||||
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
|
||||
</nav>
|
||||
|
||||
<!-- Overview: inseparably intertwined with styleChanges/apply → stays here -->
|
||||
<div v-show="tab === 'overview'" class="el-detail">
|
||||
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
||||
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
<template v-for="(ex, i) in element.examples" :key="i">
|
||||
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAt('examples', i)"
|
||||
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</template>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAdds('examples')"
|
||||
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
|
||||
<h4>Hints</h4>
|
||||
<ul class="el-hints">
|
||||
<li v-for="(h, i) in element.hints" :key="i">
|
||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAt('hints', i)"
|
||||
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<ElementSuggestion
|
||||
v-for="[ci, c] in styleAdds('hints')"
|
||||
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
|
||||
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="checking || styling || statusMsg" class="el-check">
|
||||
<p v-if="checking" class="check-empty busy-text">Checking for missing info…</p>
|
||||
<p v-if="styling" class="check-empty busy-text">Checking the style…</p>
|
||||
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v-show preserves the chat history when switching tabs -->
|
||||
<ElementChatTab
|
||||
v-show="tab === 'chat'"
|
||||
:element="element"
|
||||
:provider="provider"
|
||||
@changes="onChatChanges"
|
||||
/>
|
||||
|
||||
<!-- v-if loads the edit fields fresh on every open -->
|
||||
<ElementEditTab
|
||||
v-if="tab === 'edit'"
|
||||
:element="element"
|
||||
:saving="savingEdit"
|
||||
@save="saveEdit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-back,
|
||||
.el-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.el-back:hover,
|
||||
.el-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-tool {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.el-tool:hover {
|
||||
background: var(--panel-soft);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.el-tool.busy {
|
||||
filter: none;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.busy-text {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.el-tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.el-tabs button:hover:not(.active) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-detail {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.el-desc {
|
||||
margin: 0 0 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.el-hints-block {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.el-hints-block h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.el-entry {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.el-entry:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.el-hints {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.el-hints li {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Keep hint text inline next to the bullet (p is block otherwise) */
|
||||
.el-hints li > .markdown {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.el-hints li > .markdown :deep(p) {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
|
||||
.markdown :deep(pre) {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* --- AI exam --- */
|
||||
.el-check {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px dashed var(--border-strong);
|
||||
}
|
||||
|
||||
.check-empty {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-faint);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,164 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
element: { type: Object, required: true },
|
||||
saving: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
const edit = ref({ title: '', description: '', examples: [], hints: [] })
|
||||
|
||||
watch(() => props.element, load, { immediate: true })
|
||||
|
||||
function load() {
|
||||
edit.value = {
|
||||
title: props.element.title,
|
||||
description: props.element.description,
|
||||
examples: [...props.element.examples],
|
||||
hints: [...props.element.hints],
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.saving) return
|
||||
emit('save', {
|
||||
title: edit.value.title,
|
||||
description: edit.value.description,
|
||||
examples: edit.value.examples.filter((s) => s.trim()),
|
||||
hints: edit.value.hints.filter((s) => s.trim()),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-edit">
|
||||
<button class="edit-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
|
||||
<label>Title</label>
|
||||
<input v-model="edit.title" placeholder="Title" />
|
||||
|
||||
<label>Description</label>
|
||||
<textarea v-model="edit.description" placeholder="Description"></textarea>
|
||||
|
||||
<label>Examples</label>
|
||||
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
|
||||
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.examples.push('')">+ Example</button>
|
||||
|
||||
<label>Hints</label>
|
||||
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
|
||||
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
|
||||
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-edit {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.el-edit label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.el-edit input,
|
||||
.el-edit textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.el-edit textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
overflow: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.el-edit input:focus,
|
||||
.el-edit textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.edit-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.edit-row textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-del {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
align-self: stretch;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-add {
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.edit-save {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
margin-bottom: 0.3rem;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
</style>
|
||||
@@ -1,196 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../../composables/useConfirm.js'
|
||||
import { plainText } from '../../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, required: true },
|
||||
creating: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'remove'])
|
||||
|
||||
const query = ref('')
|
||||
const { isArmed, armOrRun } = useConfirm()
|
||||
|
||||
// Strip Markdown characters for title and list preview
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.elements
|
||||
return props.elements.filter(
|
||||
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function add() {
|
||||
if (props.creating) return
|
||||
emit('create', query.value.trim())
|
||||
query.value = ''
|
||||
}
|
||||
|
||||
// Inline confirmation: first click "Sure?", second deletes
|
||||
function confirmDelete(el) {
|
||||
armOrRun('el-' + el.id, () => emit('remove', el))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="el-new">
|
||||
<input
|
||||
v-model="query"
|
||||
placeholder="Search or keyword…"
|
||||
:disabled="creating"
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<button :disabled="creating" title="Create element via AI" @click="add">+</button>
|
||||
</div>
|
||||
<div v-if="creating" class="el-creating">AI is creating element…</div>
|
||||
<ul class="el-list">
|
||||
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
|
||||
<div class="el-item-main">
|
||||
<span class="el-item-title">{{ plainText(el.title) }}</span>
|
||||
<span class="el-item-desc">{{ plainText(el.description) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="el-delete"
|
||||
:class="{ armed: isArmed('el-' + el.id) }"
|
||||
title="Delete element"
|
||||
@click.stop="confirmDelete(el)"
|
||||
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length && !creating" class="el-empty">
|
||||
{{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-new {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.el-new input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.el-new input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.el-new button {
|
||||
width: 38px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-new button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.el-creating {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.el-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.el-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.el-list li:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.el-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-item-title {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-item-desc {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-delete {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.el-list li:hover .el-delete {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.el-delete.armed {
|
||||
visibility: visible;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.el-empty {
|
||||
cursor: default !important;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.el-empty:hover {
|
||||
background: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,184 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { renderMarkdown } from '../../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
change: { type: Object, required: true },
|
||||
busy: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['apply', 'dismiss', 'refine'])
|
||||
|
||||
const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
|
||||
|
||||
const editing = ref(false)
|
||||
const instruction = ref('')
|
||||
const inputEl = ref(null)
|
||||
|
||||
function toggleEdit() {
|
||||
editing.value = !editing.value
|
||||
if (editing.value) nextTick(() => inputEl.value?.focus())
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const text = instruction.value.trim()
|
||||
if (!text || props.busy) return
|
||||
emit('refine', text)
|
||||
instruction.value = ''
|
||||
editing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="style-sugg" :class="{ busy }">
|
||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
|
||||
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
|
||||
<div class="style-sugg-actions">
|
||||
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
|
||||
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
|
||||
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit">✏️</button>
|
||||
</div>
|
||||
<div v-if="editing" class="sugg-edit-row">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="instruction"
|
||||
placeholder="Instruction for the suggestion…"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<button :disabled="!instruction.trim() || busy" @click="submit">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.style-sugg {
|
||||
margin: 0.3rem 0 0.6rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.style-sugg.busy {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.style-sugg-text {
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.style-sugg-text strong {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.style-sugg-preview {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.style-sugg-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.sugg-ok,
|
||||
.sugg-no {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sugg-ok {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.sugg-no {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sugg-no:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.sugg-edit {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.sugg-edit:hover {
|
||||
background: var(--border);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.sugg-ok:disabled,
|
||||
.sugg-no:disabled,
|
||||
.sugg-edit:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sugg-edit-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.sugg-edit-row input {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sugg-edit-row input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.sugg-edit-row button {
|
||||
width: 30px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sugg-edit-row button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Markdown: base is global (assets/markdown.css); compact preview code blocks */
|
||||
.markdown :deep(pre) {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { fetchElements, createElement, deleteElement } from '../../api.js'
|
||||
import ElementList from './ElementList.vue'
|
||||
import ElementDetail from './ElementDetail.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
provider: { type: String, default: 'claude' },
|
||||
openId: { type: String, default: null }, // Element ID that should be opened
|
||||
openTick: { type: Number, default: 0 }, // increment = (re)open openId
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'changed'])
|
||||
|
||||
const elements = ref([])
|
||||
const creating = ref(false)
|
||||
const selected = ref(null)
|
||||
|
||||
watch(() => props.topic, load, { immediate: true })
|
||||
|
||||
async function load() {
|
||||
selected.value = null
|
||||
try {
|
||||
elements.value = await fetchElements(props.topic)
|
||||
} catch (e) {
|
||||
console.error('Failed to load elements:', e)
|
||||
}
|
||||
openFromProp()
|
||||
}
|
||||
|
||||
// Open the element clicked in the overview of the main area
|
||||
watch(() => props.openTick, openFromProp)
|
||||
|
||||
function openFromProp() {
|
||||
if (!props.openId) return
|
||||
const el = elements.value.find((e) => e.id === props.openId)
|
||||
if (el) selected.value = el
|
||||
}
|
||||
|
||||
async function create(hint) {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
const el = await createElement(props.topic, hint, props.provider)
|
||||
elements.value.unshift(el)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
console.error('Failed to create element:', e)
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(el) {
|
||||
await deleteElement(el.id)
|
||||
elements.value = elements.value.filter((e) => e.id !== el.id)
|
||||
if (selected.value?.id === el.id) selected.value = null
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Keep the edited element in sync within the list and selection
|
||||
function onUpdated(el) {
|
||||
selected.value = el
|
||||
const idx = elements.value.findIndex((e) => e.id === el.id)
|
||||
if (idx !== -1) elements.value.splice(idx, 1, el)
|
||||
emit('changed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="elements-sidebar">
|
||||
<ElementDetail
|
||||
v-if="selected"
|
||||
:element="selected"
|
||||
:provider="provider"
|
||||
@back="selected = null"
|
||||
@close="emit('close')"
|
||||
@updated="onUpdated"
|
||||
@changed="emit('changed')"
|
||||
/>
|
||||
<template v-else>
|
||||
<header class="el-header">
|
||||
<span class="el-title">Elements</span>
|
||||
<button class="el-close" title="Close" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<ElementList
|
||||
:elements="elements"
|
||||
:creating="creating"
|
||||
@select="(el) => (selected = el)"
|
||||
@create="create"
|
||||
@remove="remove"
|
||||
/>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.elements-sidebar {
|
||||
width: 320px;
|
||||
min-width: 320px;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--border);
|
||||
/* Above the guide chat (FAB/panel: z-index 20) */
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Mobile/narrow: lay it as an overlay over the main content instead of
|
||||
squeezing it into the flex flow. */
|
||||
@media (max-width: 768px) {
|
||||
.elements-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(100vw, 380px);
|
||||
min-width: 0;
|
||||
box-shadow: -4px 0 16px var(--shadow);
|
||||
}
|
||||
}
|
||||
|
||||
.el-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-faint);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.el-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
// Anzeige-Formatierer, geteilt über Board-Ansichten.
|
||||
|
||||
// Sekunden → „m:ss" / „h:mm:ss" (Laufzeit eines Laufs oder Agenten).
|
||||
export function fmtRuntime(s) {
|
||||
s = Math.max(0, Math.round(s || 0))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
if (h) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Token-Zahl kompakt (48.234 → „48,2k", 1.2M → „1,2M").
|
||||
export function fmtTokens(n) {
|
||||
n = n || 0
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')}M`
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', ',')}k`
|
||||
return String(n)
|
||||
}
|
||||
+12
-6
@@ -19,12 +19,6 @@ export function stufeFuer(score, cap) {
|
||||
return s
|
||||
}
|
||||
|
||||
// Next not-yet-reached level (the goal) or null (= Master).
|
||||
export function naechste(score, cap) {
|
||||
for (const st of LEVELS) if (score < schwelle(st.floor, cap)) return st
|
||||
return null
|
||||
}
|
||||
|
||||
// Error-penalty display by progress (against cap_aktuell): ≤25%→−5 · ≤50%→−10 · ≤75%→−15 · >75%→−20.
|
||||
export function malusRegel(score, cap) {
|
||||
const pct = cap ? score / cap : 0
|
||||
@@ -33,3 +27,15 @@ export function malusRegel(score, cap) {
|
||||
if (pct <= 0.75) return '−15'
|
||||
return '−20'
|
||||
}
|
||||
|
||||
// Reached badge level (LEVELS key) → view level 1..4, so unlocked SUBS match the badge:
|
||||
// Advanced-Badge → advanced subs visible. Same threshold as stufeFuer, no drift.
|
||||
export const BADGE_VIEW_RANK = { beginner: 1, advanced: 2, expert: 3, master: 4 }
|
||||
|
||||
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
|
||||
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
|
||||
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
|
||||
export const VIEW_FARBE = {
|
||||
1: 'var(--level-beginner)', 2: 'var(--level-advanced)',
|
||||
3: 'var(--level-expert)', 4: 'var(--level-master)',
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/markdown.css'
|
||||
import './assets/shared.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -73,10 +73,6 @@ export function renderMarkdownInline(text) {
|
||||
}
|
||||
|
||||
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
||||
export function plainText(text) {
|
||||
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
||||
}
|
||||
|
||||
// Split markdown into top-level blocks: each block { raw (exact source), html }.
|
||||
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
|
||||
export function renderBlocks(text) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
You are the quality checker for the learning artefacts of ONE block of the topic "{topic}". Another agent produced question patterns and worked examples. Check both.
|
||||
|
||||
GROUND TRUTH — the supported facts (measure only against these):
|
||||
{facts}
|
||||
|
||||
QUESTION PATTERNS (rows: (subblock) question):
|
||||
{table}
|
||||
{fehlend}
|
||||
WORKED EXAMPLES (numbered; PROBLEM / STEPS / RESULT):
|
||||
{examples}
|
||||
|
||||
TASKS:
|
||||
1. **pattern** — return the CLEANED-UP final version of ALL patterns: each question EXACTLY ONE question mark, one thing, 1–2 sentences, neutral, hits the subblock's core, answerable from the facts without invented assumptions. Keep good ones unchanged; rephrase violations. Exactly one pattern per subblock; carry `block`/`subblock` over unchanged. Questions in GERMAN.
|
||||
2. **pattern_ergaenzt** — for each subblock listed above as MISSING a question: create ONE pattern (same rules). Empty list if none are missing.
|
||||
3. **examples_probleme** — object to an example ONLY if clearly faulty: calculation error, wrong inference, contradicts/invents beyond the facts, or would imprint a wrong path. Recompute yourself; conservative — when in doubt, keep. Give 1-based `index`.
|
||||
|
||||
Reply with ONLY the JSON — no code fences, no other text. Format:
|
||||
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"pattern_ergaenzt": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"examples_probleme": [{{"index": 2}}]}}
|
||||
{extra}
|
||||
@@ -0,0 +1,25 @@
|
||||
Build the learning artefacts for the subblocks of ONE block of the topic "{topic}": per subblock ONE question pattern and ONE flashcard, plus a worked example where it carries.
|
||||
|
||||
BLOCK WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
|
||||
{blocks}
|
||||
|
||||
1. **pattern** — exactly ONE question pattern per subblock (exam question pool):
|
||||
- ONE concrete, concise question about the subblock's CORE concept, phrased neutrally (difficulty is set later by the learner's level). Exactly one question mark, one thing — no multi-parter, no "name three …". At most 1–2 sentences, no scenario build-up.
|
||||
- Answerable from the supplied core points and cited facts. Assume NOTHING that is not in the facts (no "shown" code/diagrams, no invented values).
|
||||
|
||||
2. **cards** — exactly ONE flashcard per subblock (active recall):
|
||||
- `question`: brief recall question testing exactly one key point (≤ 15 words). `answer`: the short, precise answer from the supported facts (≤ 25 words). No prose, no lead-in, invent nothing.
|
||||
|
||||
3. **examples** — a worked example ONLY where it carries understanding:
|
||||
- `problem` (1 sentence) → `steps` (2–5 followable steps, right order) → `result` (1 sentence). Rely on `example_idea` and the supported facts; compute cleanly. A pure definition or meta-knowledge gets NO forced example — leave it out.
|
||||
|
||||
Common rules:
|
||||
- `block`/`subblock` are exactly the titles above.
|
||||
- **Mathematics ALWAYS as LaTeX**, never raw characters: inline `$…$`, longer set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$` instead. The supplied facts often contain raw Unicode math — convert it. Real code/paths/identifiers in backticks.
|
||||
- ALL content in GERMAN.
|
||||
|
||||
Reply with ONLY the JSON as your final message — no code fences, no other text. EXACTLY this format:
|
||||
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
|
||||
"cards": [{{"block": "…", "subblock": "…", "question": "…", "answer": "…"}}],
|
||||
"examples": [{{"block": "…", "subblock": "…", "problem": "…", "steps": ["…"], "result": "…"}}]}}
|
||||
{extra}
|
||||
@@ -1,25 +0,0 @@
|
||||
You are a correctness checker for worked examples on the topic "{topic}". Another agent produced them — they are meant to demonstrate a **correct** solution to the learner. A wrong example imprints a wrong solution path. Find the faulty ones.
|
||||
|
||||
GROUND TRUTH — the supported facts (measure only against these):
|
||||
{facts}
|
||||
|
||||
EXAMPLES TO CHECK (numbered; PROBLEM / STEPS / RESULT):
|
||||
{examples}
|
||||
|
||||
Object to an example if ANY of these applies:
|
||||
- **Calculation error** in a step (a number, a transformation, a unit).
|
||||
- **Wrong inference:** a step does not follow from the previous one; the RESULT does not follow from the steps.
|
||||
- **Not covered:** a step/value contradicts the facts or invents something not stated there.
|
||||
- **Incomplete/misleading:** the path would lead the learner to a wrong conclusion.
|
||||
|
||||
Rules:
|
||||
- Measure ONLY against the facts + internal logic. Recompute it yourself.
|
||||
- **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it.
|
||||
- Give the 1-based number (`index`) of each faulty example.
|
||||
|
||||
Write ONLY the JSON file to: {out_path} — one of the two:
|
||||
{{"ok": true}}
|
||||
{{"problems": [{{"index": 2}}, {{"index": 5}}]}}
|
||||
|
||||
Output no other text.
|
||||
{extra}
|
||||
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user