Compare commits

...

10 Commits

Author SHA1 Message Date
team3
8488737303 Training-Harness (ACO, Multi-Fidelity), Prüfstand-Benchmark, Agenten-README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:47:19 +02:00
team3
8d8f6c8e51 update 2026-07-04 12:21:45 +02:00
team3
2f5d5b9ca1 update 2026-07-04 03:25:02 +02:00
team3
c4caf31ed0 update 2026-07-04 02:32:31 +02:00
team3
91b0d00aa1 update 2026-07-03 12:50:32 +02:00
team3
9754cbcfae update 2026-07-03 11:55:38 +02:00
team3
abcadd145d update 2026-07-03 11:45:27 +02:00
team3
285317927d update 2026-07-02 22:48:57 +02:00
team3
41c9f29a37 update 2026-07-02 03:05:57 +02:00
root
afa8b36105 update 2026-07-01 20:00:57 +00:00
133 changed files with 15547 additions and 5478 deletions

View File

@@ -5,3 +5,10 @@ CLAUDE_CODE_OAUTH_TOKEN=
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
MINIMAX_API_KEY=
# Optional — Rollen-Mixing über Anbieter-Grenzen (Standard: die UI-Auswahl gilt für alles).
# Wert: Provider-Name ("claude"/"minimax"/"lokal") oder "provider:modell".
#ROLE_QUICK=
#ROLE_JUDGE=
#ROLE_GUIDE=
#ROLE_FAST=

View File

@@ -14,6 +14,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gnupg \
poppler-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g @anthropic-ai/claude-code opencode-ai \

View File

@@ -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
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
@@ -61,13 +62,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 +89,47 @@ 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)
# 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

125
README.md
View File

@@ -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 010 |
| `make qa-guide TOPIC=… [LLM=1]` | Guide-QA |
| `make train-init` | Frozen-Inventar-Vorlage für das Training bauen (einmalig) |
| `make train [TRIALS] [STUNDEN] [AMEISEN]` | Ameisen-Optimierung der Parameter (anytime) |
| `CREATOR_FAKE_AGENTS=1 make dev` | Server antwortet aus der Fake-Welt — UI-Smoke in Sekunden |
| `CREATOR_PARAMS='{"X":1}'` | Parameter-Override pro Prozess (Registry: `backend/train_params.py`) |
Diagnose-Quellen: `events`-Tabelle (Agent-Dauern/Tokens/Status je Lauf),
`storage/qa/<topic>/*.json` (Report-Historie), `arbeit/lauf-summary.json`,
OpenCode-Session-DB (`~/.local/share/opencode/opencode.db` — Turns/Tokens je Agent).
## Training (`make train`)
Ameisen-Algorithmus (ACO), anytime: Pheromon-Gewichte je Parameter-Stufe steuern die
Kandidaten; je länger er läuft, desto gezielter die Tests. Drei Fidelity-Stufen:
F0 Fake-E2E (0,5 s, Invarianten + Struktur-Proxy), F1 Frozen-Inventar (~58 min, Board 2
auf kopiertem Inventar), F2 Volllauf mit Soll-Abgleich gegen
`benchmarks/pruefstand/soll.json` (konstruiertes Thema mit bekannter Lösung und
eingebauten Fallen). Übernahme nur nach Bestätigungslauf. Ergebnis:
`storage/train/aco/{report.md, beste_params.json}`; Übernahme nach `config.py` ist
manuell.
## Entwicklungs-Meilensteine (was schon gelernt wurde)
- MECE-Regelkreis: In-Block-Konsolidierung (2-Judge-Panel, Einstimmigkeit), Lücken-Nachfass
mit hartem Beleg-Gate, Cross-Block-Dedup mit Stichentscheid — Sub-Zahl 425→~213 bei
steigender Note.
- Drei stabile QA-Noten (Inventar/Artefakte/Guide) mit Bestätiger-Pässen gegen
Judge-Rauschen; Repair arbeitet Befunde gezielt ab.
- Resume-Dateien tragen einen Sub-Satz-Hash — Re-Runs übernehmen nie stale Ergebnisse;
Finalize löscht Alt-Reste (Lösch-Hygiene überall).
- Facts-Nachfass: kein consensus-Sub ohne Grounding (sonst flutet das Fakten-Gate).
- Judge-Stalls (20 % Timeouts) lagen an einer Provider-Route — Messen vor Raten.
- Naming darf abstrahieren, aber nur korpus-verankert (Kanon-Halluzinations-Schutz).
## Offene Ideen / nächste Schritte
- Training auf dem Server laufen lassen (siehe unten), wirksame Parameter übernehmen.
- Facts-Chunks parallelisieren; Live-Aktivität (Token-Zähler) an laufenden Karten zeigen.
- Bekannte Cross-Dubletten knapp unter dem 0.75-Kandidaten-Floor.
- Roadmap-Lernarchitektur: ein Guide + Stufen-Ansichten, ELO-Score mit wachsendem Cap.
## Server-Betrieb mit 8 GB RAM
- `.env`: `MAX_CONCURRENT_AGENTS=6`, `MAX_CONCURRENT_AGENTS_PER_TOPIC=6`.
- Training: `make train AMEISEN=1` (jeder parallele Trial lädt das Embedding-Modell, ~1 GB).
- Embedding + Readability halten zusammen ~1 GB im Backend-Prozess. 24 GB Swap anlegen.
---
# Features (Endnutzer-Sicht)
## Quellen
- Freies Thema: die KI recherchiert den Stoff selbst im Web.

View File

@@ -2,24 +2,45 @@
Both runners are independent. If a binary/key is missing, only the
respective provider fails — the other keeps running unchanged.
Role routing: config.resolve_role maps (run_provider, role) → (provider, model)
ACROSS stacks, so one run can generate on MiniMax and judge on Claude. If the
routed provider is unavailable, the call falls back to the run's provider.
"""
import asyncio
import heapq
import logging
import os
import re
import shutil
import signal
import sqlite3
import tempfile
import time
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from config import PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS, MAX_CONCURRENT_INTERACTIVE
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE,
resolve_role)
log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_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, label, runtime}] sorted longest-first."""
now = time.time()
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
for k, t in list(_active_started.items())
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
# 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
@@ -41,14 +62,96 @@ def _scope_cancelled(agent_key: str) -> bool:
# Caps the real CLI processes — independent of the pipeline semaphore in
# generator.py. The acquire happens BEFORE the spawn so that queue wait time
# does not count against the agent timeout.
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS)
class _PrioritySemaphore:
"""asyncio.Semaphore variant: when slots are scarce, the LOWEST priority number is served first
(FIFO within the same priority). Lets earlier pipeline columns grab agents before later ones."""
def __init__(self, value: int):
self._value = value
self._waiters: list = [] # heap of [priority, seq, future]
self._seq = 0
async def acquire(self, priority: int = 100):
if self._value > 0:
self._value -= 1
return
fut = asyncio.get_event_loop().create_future()
entry = [priority, self._seq, fut]
self._seq += 1
heapq.heappush(self._waiters, entry)
try:
await fut # release() hands us the slot directly (no value change)
except BaseException:
entry[2] = None # tombstone so release() skips this dead waiter
if fut.done() and not fut.cancelled():
self.release() # granted just before we were cancelled → pass it on
raise
def release(self):
while self._waiters:
entry = heapq.heappop(self._waiters)
if entry[2] is not None and not entry[2].done():
entry[2].set_result(None) # hand the slot straight to the highest-priority waiter
return
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# 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.
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
# MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. Priority-based too, so the
# per-topic queue can't undo the global priority when one topic is the only load.
_topic_sems: dict[str, _PrioritySemaphore] = {}
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
"dedup", "grouping", "gruppierung", "supplement", "outline", "artifact",
"question", "relevance", "level", "facts", "subblock")
def _agent_priority(key: str) -> int:
for i, tag in enumerate(_STAGE_PRIORITY):
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
return i
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int):
"""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."""
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
if topic_sem is not None:
await topic_sem.acquire(priority)
await _batch_sem.acquire(priority)
try:
yield
finally:
_batch_sem.release()
if topic_sem is not None:
topic_sem.release()
# Space OpenCode starts: processes starting simultaneously collide on the internal
# session DB ("database is locked", exit after <1s). Token bucket instead of a lock
# held through spawn+sleep: the lock only assigns a start slot, the sleep happens
# outside — a wave of starts is spaced by OPENCODE_START_DELAY without a global convoy.
_opencode_start_lock = asyncio.Lock()
_OPENCODE_START_DELAY = 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))
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools
_CLAUDE_TOOLS = {
@@ -85,6 +188,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())."""
@@ -102,12 +221,18 @@ def kill_process(agent_key_prefix: str) -> None:
for key, process in list(_active_processes.items()):
if process.returncode is not None: # clean up dead entries while iterating
_active_processes.pop(key, None)
_active_started.pop(key, None)
continue
if key.startswith(agent_key_prefix):
log.debug("kill agent %s", key)
_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,
@@ -116,23 +241,69 @@ async def run_agent(
role: str = "fast",
capabilities: str = "none",
lane: str = "batch",
scope: str | None = None,
on_line=None,
label: str = "",
) -> tuple[int, str, str]:
if os.getenv("CREATOR_FAKE_AGENTS"): # Sekunden-Smoke: deterministische Antworten statt LLM
import fake_agents
return await fake_agents.respond(agent_key, prompt, capabilities)
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled"
if provider not in PROVIDERS:
return 1, "", f"Unknown provider: {provider}"
run_provider = provider
provider, model = resolve_role(run_provider, role)
if provider != run_provider and not _available_cached(provider):
provider, model = run_provider, PROVIDERS[run_provider].get(role, "")
if not model:
return 1, "", f"No model for role '{role}' (provider: {provider})"
if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
sem = _interactive_sem if lane == "interactive" else _batch_sem
async with sem:
queued = time.monotonic()
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
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)
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 = ""
try:
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
if 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 err_tail:
meta["stderr"] = err_tail
if 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) -> 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) -> tuple[int, str, str]:
start = time.monotonic()
async def spawn():
@@ -142,21 +313,45 @@ 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,
)
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
await _opencode_slot() # spaced start slot; spawn itself is not serialized
process = await spawn()
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
track_key = agent_key
n = 2
while track_key in _active_processes:
track_key = f"{agent_key}~{n}"
n += 1
_active_processes[track_key] = process
_active_started[track_key] = time.time()
_active_labels[track_key] = label
try:
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
if on_line is not None:
# Streaming path: read stdout line by line, hand each raw line to on_line LIVE.
out_chunks: list[str] = []
async def _pump():
async for raw in process.stdout:
s = raw.decode("utf-8", errors="replace")
out_chunks.append(s)
try:
on_line(s)
except Exception:
log.debug("on_line callback failed", exc_info=True)
await asyncio.wait_for(_pump(), timeout=timeout)
await process.wait()
stderr_b = await process.stderr.read()
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
except asyncio.TimeoutError:
_kill(process)
try:
@@ -173,21 +368,48 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
finally:
# 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]
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)
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]:
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
def _session_tokens(agent_key: str) -> dict | None:
"""Token counters of the newest OpenCode session titled `agent_key` (set via run --title).
Best-effort read-only lookup — None when DB/row is missing; never fails the agent.
Timeouts count too: their sessions consumed tokens, that waste should be visible."""
try:
con = sqlite3.connect(f"file:{_OPENCODE_DB}?mode=ro", uri=True, timeout=1)
try:
row = con.execute(
"SELECT tokens_input, tokens_output, tokens_reasoning,"
" tokens_cache_read, tokens_cache_write"
" FROM session WHERE title=? ORDER BY time_created DESC LIMIT 1",
(agent_key,)).fetchone()
finally:
con.close()
except Exception:
return None
if row is None:
return None
keys = ("input", "output", "reasoning", "cache_read", "cache_write")
return {k: int(v or 0) for k, v in zip(keys, row)}
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
cfg = PROVIDERS[provider]
# Prompt 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:
@@ -198,14 +420,23 @@ 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)
return rc, _clean_opencode_output(stdout), stderr
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)

File diff suppressed because it is too large Load Diff

661
backend/board_artefacts.py Normal file
View File

@@ -0,0 +1,661 @@
"""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:
subblocks → facts → levels → relevance → question_pattern → artefacts → finalize
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
files + the DB tables. `outline` is a topic-wide BARRIER singleton at the very end
(prerequisite graph → chapter order), re-run once per generation run.
The heavy lifting is the existing per-block functions in blocks.py — each card gets its own
work subdirectory + facts/artefakte paths, so their slot files never collide across blocks."""
import asyncio
import hashlib
import json
import logging
import re
import database as db
import blocks
import embedding
from blocks import (
ARTEFACT_TYPES, _artefacts_block, _facts_block, _facts_nachfass, _konsolidiere_subblocks,
_levels_block, _luecken_runde, _match_sub, _neg_set, _question_pattern_block, _relevance_block,
_sink_json, _subblocks_block, _outline_block,
)
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
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", "subblocks", {
"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 _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:
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
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
# Fix 4: 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"]
instr = instructions
sd = [s for s in seeds.get(norm, []) if s]
if sd:
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
"Blocks (prüfen; wenn belegt UND noch nicht durch einen anderen Eintrag "
"abgedeckt, aufnehmen — nicht wörtlich übernehmen, sondern als eigenständige "
"Aussage formulieren):\n"
+ "\n".join(f"- {s}" for s in sd))
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
seeds=sd or None, lbl=f"{p.get('title', norm)} · ",
sources=p.get("sources"))
if raw is None:
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
p["raw"] = raw
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "facts")
await _gather_cards(ctx, flow, cards, one)
async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
raw = p.get("raw") or {}
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
folder, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
if res is None:
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
facts_map, discarded = res
if discarded: # unsupportable subs vanish from raw too (guide never sees them)
for bt, sns in discarded.items():
if bt in raw:
raw[bt] = [s for s in raw[bt] if _norm_title(s) not in sns]
raw = {bt: subs for bt, subs in raw.items() if subs}
# In-block consolidation: two-judge panel folds same-statement/subset subs, bundles
# catalogs, drops off-topic ones — the facts are in hand (key points as evidence),
# questions/artefacts not yet built. Reported gaps get ONE follow-up finder round.
luecken = await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if ctx.is_cancelled():
return None
nachgefasst = 0
for bt, lk in (luecken or {}).items():
nachgefasst += await _luecken_runde(ctx, _pfiles(files, norm), bt, lk, raw, facts_map,
q, folder, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
if ctx.is_cancelled():
return None
if nachgefasst: # close the loop: follow-up finds get the SAME duplicate test as the
# rest (new subs-hash → fresh judge files); their gap report is deliberately ignored
await _konsolidiere_subblocks(ctx, _pfiles(files, norm), raw, facts_map,
instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if ctx.is_cancelled():
return None
raw = {bt: subs for bt, subs in raw.items() if subs}
# consolidation renames/catalogs can leave consensus subs without grounding — the
# guide fact gate then flags their correct statements wholesale (measured: 74/210)
await _facts_nachfass(ctx, _pfiles(files, norm), raw, facts_map, q, folder,
instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ", sources=p.get("sources"))
if ctx.is_cancelled():
return None
p["raw"], p["facts"] = raw, facts_map
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "levels")
await _gather_cards(ctx, flow, cards, one)
def _cross_schema(data) -> dict[int, str] | None:
"""{"pairs": {"1": "a"|"b"|"nein"}} → {pair_nr: verdict} · otherwise None."""
if not isinstance(data, dict) or not isinstance(data.get("pairs"), dict):
return None
out: dict[int, str] = {}
for k, v in data["pairs"].items():
try:
nr = int(k)
except (ValueError, TypeError):
continue
s = str(v).strip().casefold()
if s in ("a", "b", "nein"):
out[nr] = s
return out or None
async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""BARRIER/drain — cross-block sub dedup: the SAME statement carried by two blocks
(measured on Markdown: tab handling in 3 blocks, HTML blocks, backslash escapes — the
in-block paths never see these). Embedding candidates (block≠block, cos ≥
SUB_DUP_KANDIDAT_COS) go to a two-judge panel; UNANIMITY decides which block keeps the
statement. The loser leaves its card's raw/facts and turns DB `variant` — before
questions/artefacts exist, so no orphans. Fail-open on judge failure/dissent."""
topic = flow.topic
work_dir = flow.work_dir
package_norms = {c["card_id"] for c in cards}
entries: list[tuple[int, str, str]] = [] # (card idx, block title, sub title); idx -1 = context
for ci, c in enumerate(cards):
for bt, subs in (c["payload"].get("raw") or {}).items():
for s in subs:
entries.append((ci, bt, s))
n_pkg = len(entries)
# Context: consensus subs of blocks already PAST this barrier (late spawns via the
# gap-check feedback would otherwise never be compared). Context never folds —
# its card payload lives downstream (board-1 rule: confirmed context always wins).
for r in await db.list_subblocks(topic):
if r["status"] == "consensus" and r["block_norm"] not in package_norms:
entries.append((-1, r["block"], r["sub_title"]))
ctx_facts: dict[str, dict] = {} # facts of downstream cards (DB rows carry none yet)
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
if bc["card_id"] not in package_norms:
for bt, fm in (bc["payload"].get("facts") or {}).items():
ctx_facts[_norm_title(bt)] = fm
async def _advance_all():
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], "question_pattern") for c in cards])
flow.wake.set()
if n_pkg < 1 or len(entries) < 2 or not EMBEDDING_AKTIV or not await asyncio.to_thread(embedding.available):
await _advance_all()
return
sims = await asyncio.to_thread(embedding.embed_sims, [s for _, _, s in entries])
if sims is None:
await _advance_all()
return
negs = [_neg_set(s) for _, _, s in entries]
pairs = [(i, j) for i in range(len(entries)) for j in range(i + 1, len(entries))
if entries[i][0] != entries[j][0] and negs[i] == negs[j]
and float(sims[i][j]) >= SUB_DUP_KANDIDAT_COS]
if not pairs:
await _advance_all()
return
def _kp(ci: int, bt: str, s: str) -> list:
if ci < 0:
f = ctx_facts.get(_norm_title(bt)) or {}
else:
f = (cards[ci]["payload"].get("facts") or {}).get(bt) or {}
return (f.get(_norm_title(s)) or {}).get("key_points") or []
def _side(tag: str, ci: int, bt: str, s: str) -> str:
return f"{tag}: [Block: {bt}] {s}" + "".join(f"\n - {p}" for p in _kp(ci, bt, s))
# chunked judging: ONE call over all pairs scaled its timeout past 50 min, and a hung
# call blocked the barrier for the full window (measured on aak: 196 pairs, 2×54 min)
chunks = [pairs[lo:lo + CROSS_CHUNK_PAARE] for lo in range(0, len(pairs), CROSS_CHUNK_PAARE)]
async def _urteile_chunk(chunk: list[tuple[int, int]]) -> dict[int, str]:
"""Two judges (+ substitute, + tie-breaker) over one pair chunk → {local_k: verdict};
empty dict = fail-open (pairs stay)."""
lines = "\n\n".join(
f"{k}.\n{_side('A', *entries[i])}\n{_side('B', *entries[j])}"
for k, (i, j) in enumerate(chunk, 1))
h = hashlib.md5(lines.encode()).hexdigest()[:8]
paths = [work_dir / f"sub-crossblock-{h}-j{j}.json" for j in (1, 2)]
async def _judge(j, path, plines, n):
if _cross_schema(_json_file(path)) is not None:
return # resume
status, _v = await run_single_slot(
ctx, f"Sub-Crossblock j{j}", key=f"blocks-{topic}-sub-crossblock-{h}-j{j}",
prompt=_prompt("Subblock-Crossblock", topic=topic, pairs=plines, extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=path: _sink_json(result, p, _cross_schema),
timeout=_timeout("subblock_check", n))
if status == FAILED:
_log(topic, f"Sub-Crossblock j{j} ohne Ergebnis — fail-open")
await asyncio.gather(*[_judge(j, p, lines, len(chunk)) for j, p in zip((1, 2), paths)])
if ctx.is_cancelled():
return {}
outs = [o for p in paths if (o := _cross_schema(_json_file(p))) is not None]
if len(outs) == 1: # Ersatz-Richter statt fail-open bei EINEM Ausfall
ersatz = work_dir / f"sub-crossblock-{h}-jE.json"
await _judge("E", ersatz, lines, len(chunk))
if ctx.is_cancelled():
return {}
outs = [o for p in [*paths, ersatz] if (o := _cross_schema(_json_file(p))) is not None]
if len(outs) != 2:
if outs:
_log(topic, "Sub-Crossblock: nur 1/2 Richter — fail-open")
return {}
final = {k: (outs[0].get(k, "nein") if outs[0].get(k, "nein") == outs[1].get(k, "nein")
else "uneinig") for k in range(1, len(chunk) + 1)}
disputed = [k for k, v in final.items() if v == "uneinig"]
if disputed: # tie-breaker: a third judge sees ONLY the disputed pairs, majority 2/3
d_lines = "\n\n".join(
f"{x}.\n{_side('A', *entries[chunk[k - 1][0]])}\n{_side('B', *entries[chunk[k - 1][1]])}"
for x, k in enumerate(disputed, 1))
p3 = work_dir / f"sub-crossblock-{h}-j3.json"
await _judge(3, p3, d_lines, len(disputed))
if ctx.is_cancelled():
return {}
v3 = _cross_schema(_json_file(p3)) or {}
if not v3:
_log(topic, "Sub-Crossblock j3 ohne Ergebnis — strittige Paare bleiben")
for x, k in enumerate(disputed, 1):
t = v3.get(x, "nein")
if t in (outs[0].get(k, "nein"), outs[1].get(k, "nein")):
final[k] = t # majority 2/3; anything else stays disputed → no fold
return final
chunk_finals = await asyncio.gather(*[_urteile_chunk(c) for c in chunks])
if ctx.is_cancelled():
return
final_all: dict[int, str] = {} # global pair index (1-based over `pairs`) → verdict
for cnr, fin in enumerate(chunk_finals):
for k, v in fin.items():
final_all[cnr * CROSS_CHUNK_PAARE + k] = v
journal = {"paare": len(pairs), "chunks": len(chunks), "gefaltet": [], "verdicts": []}
gone: set[int] = set()
touched: set[int] = set()
for k, (i, j) in enumerate(pairs, 1):
verdict = final_all.get(k, "nein")
journal["verdicts"].append({"a": f"{entries[i][1]} · {entries[i][2]}",
"b": f"{entries[j][1]} · {entries[j][2]}",
"verdict": verdict})
if verdict not in ("a", "b"):
continue
lose = j if verdict == "a" else i
if entries[lose][0] < 0: # context never folds — the package side goes instead
lose = i if lose == j else j
keep = i if lose == j else j
if lose in gone or keep in gone: # keeper already folded → don't chain away the content
continue
ci, bt, s = entries[lose]
p = cards[ci]["payload"]
if s in (p.get("raw") or {}).get(bt, []):
p["raw"][bt].remove(s)
(p.get("facts") or {}).get(bt, {}).pop(_norm_title(s), None)
sc = (p.get("sidecar") or {}).get(bt)
if isinstance(sc, list): # questions/artefacts consume the sidecar downstream
p["sidecar"][bt] = [e for e in sc
if _norm_title(str((e or {}).get("title", ""))) != _norm_title(s)]
await db.set_subblock_fields(topic, _norm_title(bt), _norm_title(s), status="variant")
gone.add(lose)
touched.add(ci)
journal["gefaltet"].append({"weg": f"{bt} · {s}",
"bleibt": f"{entries[keep][1]} · {entries[keep][2]}"})
for ci in touched:
p = cards[ci]["payload"]
p["raw"] = {bt: subs for bt, subs in (p.get("raw") or {}).items() if subs}
await db.kanban_set_payload(topic, BOARD, cards[ci]["card_id"], p)
if journal["gefaltet"]:
_log(topic, f"Sub-Crossblock: {len(journal['gefaltet'])} blockübergreifende Dublette(n) gefaltet")
hg = hashlib.md5("\n".join(f"{i}:{j}" for i, j in pairs).encode()).hexdigest()[:8]
atomic_write_json(work_dir / f"sub-crossblock-{hg}.json", journal, indent=1)
await _advance_all()
async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if sidecar is None:
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
facts_map = p.get("facts") or {}
for btitle, subs in sidecar.items(): # merge facts into the subs (mirror + guide)
fm = facts_map.get(btitle, {})
for sub in subs:
# level agents paraphrase titles — exact miss falls back to the unique
# prefix/containment match, else the sub silently loses its grounding
sn = _sub_key(set(fm), _norm_title(sub["title"]))
if (fk := fm.get(sn)):
sub["facts"] = fk
p["sidecar"] = sidecar
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "relevance")
await _gather_cards(ctx, flow, cards, one)
async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
sidecar = p.get("sidecar") or {}
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
sidecar, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if rel is None:
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
gid = 0
for subs in sidecar.values():
for sub in subs:
gid += 1
sub["relevance"] = rel.get(gid, "relevant")
p["sidecar"] = sidecar
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "konsolidierung")
await _gather_cards(ctx, flow, cards, one)
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
if pattern is None:
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
p["pattern"] = pattern
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "artefacts")
await _gather_cards(ctx, flow, cards, one)
async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
if artefacts is None and ctx.is_cancelled():
return None
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
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) ─────────────────────────────
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, "subblocks", lambda cs: _proc_subblocks(ctx, flow, files, instructions, cs)),
Stage(BOARD, "facts", lambda cs: _proc_facts(ctx, flow, files, q, folder, instructions, cs)),
Stage(BOARD, "levels", lambda cs: _proc_levels(ctx, flow, files, instructions, cs)),
Stage(BOARD, "relevance", lambda cs: _proc_relevance(ctx, flow, files, instructions, cs)),
# Barrier sits AFTER the sub-local stages: cards used to idle here median 36 min
# while levels/relevance work was still ahead of them
Stage(BOARD, "konsolidierung",
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
barrier=True, drain=True),
Stage(BOARD, "question_pattern",
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
barrier=True, drain=True, gate=research_done),
]

1978
backend/board_inventory.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -8,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,
@@ -21,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".
@@ -41,11 +61,60 @@ 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
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.910.95 → negation guard required.
SUB_VARIANT_COS = 0.90
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
# subs are statements: true covers measure 0.270.38 while a wrong hit measured 0.76. The
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
SEED_COVER_COS = 0.80
# Sub duplicate CANDIDATE floor for the judge paths (in-block consolidation band hint,
# cross-block stage, QA detector): the bulk of real paraphrase duplicates measures 0.750.90
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
# judge decides. Candidates only; a merge still needs judge unanimity.
SUB_DUP_KANDIDAT_COS = 0.75
# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min
# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min.
CROSS_CHUNK_PAARE = 40
# Cap for concurrent CLI agent processes (across all generations).
# Own lane for interactive calls (chat, elements) so they don't hang behind
# running writers in the queue.
MAX_CONCURRENT_AGENTS = 10
# 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
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…)
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
GROUP_MIN_COS_FLOOR = 0.15
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
FRAGMENT_MIN_COS = 0.15
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
# dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10).
# Own lane for interactive calls (chat, elements) so they don't hang behind running writers.
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16")) # global, all topics
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "12")) # per topic
MAX_CONCURRENT_INTERACTIVE = 8
# Grace window of the consensus races (blocks, guide, OnePager): after the first
@@ -53,11 +122,6 @@ MAX_CONCURRENT_INTERACTIVE = 8
# (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.
@@ -78,27 +142,90 @@ CRAWL_MIN_CHARS = 400 # too little te
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)
# QA gate: after the inventory phase an automatic QA run scores the blocks; below the
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
# Guide section length per relevant sub (ausführlich part) — QA detector AND the
# deterministic readability-stage trigger share these bounds (writers overshot 2.74.1×).
GUIDE_LAENGE_MIN = 150
GUIDE_LAENGE_MAX = 1200
# 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_CHUNK = 10 # subblock finder: 1 agent per ~10 blocks, capped
SUBBLOCK_MAX = 40 # chunk cap
LEVEL_CHUNK = 100 # classifying is cheap → large packages
RESEARCH_BATCH = 20 # crawl pages per batch
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)
SUBBLOCK_CAP = 900 # subblock find loop per chunk (seconds)
SUBBLOCK_MIN = 5 # below this consensus count → focused catch-up rounds
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds
SUBBLOCK_MAX_ROUNDS = 3 # hard round cap (rounds 45 burned 29 % of finders for ~0 gain)
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path)
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
QUESTION_CHUNK_SUBS = 25 # target relevant subs per question chunk (LPT)
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern
FACTS_CHUNK_SUBS = 10 # facts extraction chunk (chunk count = parallelism)
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples bulk chunk
FACTS_CHECK_PANEL = 3 # judges per facts-check chunk (majority)
CONSOLIDATION_PANEL = 3 # mapping judges per chunk
SUBBLOCK_PANEL = 3 # judges in the subblock clarification
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
MAX_WRITER_ROUNDS = 2 # guide coverage→writer loop cap
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below this → log only (falsch fixt immer)
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
JUDGE_CHUNK = 40 # repair: findings per judge call
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": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
"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
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
# Judge caps tightened 2026-07-04: judge p50 is 672 s; a stalled call burns the whole
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
"content_check": (150, 8), # content exam per block in the package
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
"subblock_check": (150, 10), # judge decides contested subblocks in the chunk
"konsolidierung": (300, 20), # consolidation judge sees ALL subs with key points
"level": (300, 10), # classify subblocks per chunk
"level_check": (300, 10), # judge decides contested levels in the chunk
"level_check": (150, 8), # judge decides contested levels in the chunk
"relevance": (300, 10), # subblocks relevant/peripheral per chunk
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
"relevance_check": (150, 8), # 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
"question_pattern_check": (150, 8), # critic cleans up the pattern table per block
"writer": (450, 60), # per section — split keeps sections ≤30 subs
"lese_check": (300, 10), # per section in the package
# guide board (per card = one block)
"lernziele": (300, 5), # backward-design objectives per block
"fakten_gate": (600, 5), # CoVe claim check per block
"coverage": (300, 5), # objective↔section mapping per block
}
# Purpose per format — flows into the outline judge (what the guide should achieve).
@@ -131,7 +258,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",
},
@@ -145,3 +273,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()

File diff suppressed because it is too large Load Diff

View File

@@ -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

345
backend/fake_agents.py Normal file
View File

@@ -0,0 +1,345 @@
"""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+)\)")
_PAIR_RE = re.compile(r"^(\d+)\.\s*\nA: \[Block: (.*?)\] (.*?)\n", re.MULTILINE)
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 Konsolidierung/Cross-Block."""
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:
zeilen = []
n = 1
for t, b in self.bloecke.items():
zeilen.append(f"{n}. {t}{b['beschreibung']}")
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 "-filter-" in key: # auch filter-recheck
return j({"fragments": {}, "drop": []})
if "-gruppierung-completion-" in key:
return j({"additions": []})
if "-gruppierung-" in key:
return j({"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:
return j({"blocks": []})
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
if "-luecken-" in key:
return "<!-- block: leer -->\n" # Lücken-Nachfass findet nichts Neues
if "-subblock-final-" in key or "-subblock-" in key: # Finder + Judge, gleiches Format
teile = []
for t in self._bloecke_im_prompt(prompt):
subs = "\n".join(f"- {s}" for s in self.bloecke[t]["subs"])
teile.append(f"<!-- block: {t} -->\n{subs}")
return "\n".join(teile) or "<!-- block: leer -->\n"
if "-sub-konsolidierung-" 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})
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": []})
if "-sub-crossblock-" in key:
urteile = {}
for m in _PAIR_RE.finditer(prompt):
urteile[m.group(1)] = "a" # identischer Text (nur so wird gepaart) → A behält
return j({"pairs": urteile or {"1": "nein"}})
if "-facts-check-" in key:
return j({"ok": True})
if "-facts-" in key: # facts / facts-fix / facts-erg: gleiches Format
eintraege = []
for t in self._bloecke_im_prompt(prompt):
for s in self.bloecke[t]["subs"]:
if s in prompt:
eintraege.append(self._fakt(t, s))
for kt, _m in self.kataloge:
if kt in prompt:
eintraege.append(self._fakt(t, kt))
if not eintraege: # Nachfass-Fälle: Subs ohne Blockkontext im Prompt
eintraege = [self._fakt(bt, s) for bt, b in self.bloecke.items()
for s in b["subs"] if s in prompt]
return j({"facts": eintraege})
if "-level-" in key: # Rater + final: alle geforderten Nummern
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"levels": {k: "beginner" for k in sorted(nums, key=int)} or {"1": "beginner"}})
if "-relevance-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevance": {k: "relevant" for k in sorted(nums, key=int)} or {"1": "relevant"}})
if "-question-pattern-" in key:
eintraege = []
for t in self._bloecke_im_prompt(prompt):
for s in self._subs_im_prompt(prompt):
eintraege.append({"block": t, "subblock": s, "question": f"Was ist {s}?"})
return j({"pattern": eintraege})
if "-artifact-example-check-" in key:
return j({"ok": True})
if "-artifact-flashcard-" in key:
karten = [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"}
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
return j({"cards": karten})
if "-artifact-example-" in key:
bsp = [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"}
for t in self._bloecke_im_prompt(prompt) for s in self._subs_im_prompt(prompt)]
return j({"examples": bsp})
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 "-gatefix-" in key or "-lesefix-" in key:
return self._section_aus_prompt(prompt) or "<!-- section: X -->\nRepariert."
if "-gate-" in key:
return j({"ok": True})
if "-cov-" in key:
ids = sorted(set(_ZIEL_RE.findall(prompt)))
return j({"ziele": {z: True for z in ids}, "luecken": [], "ballast": []})
if "-lese-" in key:
return j({"ok": True})
if "-w-" in key:
return self._writer_md(prompt)
# QA / Repair / Guide-QA (alle Text, _yesno_schema)
if key.startswith(("qa-guide-",)):
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "nein" for k in sorted(nums, key=int)} or {"1": "nein"}})
if key.startswith(("qa-", "repair-")):
# Semantik je Template: Bausteine „ja" = echt; Dubletten/Lücken „nein" = kein Befund
wert = "ja" if ("bausteine" in key or "beleg" in key or "fremd" in key) else "nein"
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: wert for k in sorted(nums, key=int)} or {"1": wert}})
return None
# ── Bausteine der Antworten ─────────────────────────────────────────────────────
@staticmethod
def _fakt(block: str, sub: str) -> dict:
return {"block": block, "subblock": sub,
"key_points": [f"Kernaussage zu {sub}", f"Zweite Aussage zu {sub}"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": f"Beleg für {sub}", "source": "Fake-Quelle"}],
"example_idea": f"Beispiel zu {sub}"}
def _writer_md(self, prompt: str) -> str:
"""Section im Marker-Format; Subs aus der SUBBLOCKS-Liste des Prompts
(`- [label] titel`), Länge je Sub im 1501080-Rahmen."""
block = next(iter(self._bloecke_im_prompt(prompt)), None) or "Abschnitt"
subs = [(lv or "beginner", t) for lv, t in _SUBLIST_RE.findall(prompt)
if _norm(t) in self._alle_subs()]
if not subs:
subs = [("beginner", s) for s in self.bloecke.get(block, {}).get("subs", ["Inhalt"])]
kompakt = "\n".join(f"<!-- sub: {lv} | {t} -->\n- Merksatz zu {t}" for lv, t in subs)
prosa = "\n".join(f"<!-- sub: {lv} | {t} -->\n" + (f"Lehrtext über {t}. " * 12)
for lv, t in subs)
return (f"<!-- kapitel: Kapitel 1 -->\n<!-- section: {block} -->\n"
f"<!-- compact -->\n{kompakt}\n<!-- ausführlich -->\n"
f"Einstieg in {block}.\n{prosa}")
@staticmethod
def _section_aus_prompt(prompt: str) -> str | None:
"""Fix-Agenten geben die Section unverändert zurück (minimal-invasiv)."""
m = re.search(r"(<!--\s*(?:kapitel|section):.*)", prompt, re.DOTALL)
return m.group(1).strip() if m else None
def standard_bloecke() -> dict:
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
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", "Gemeinsamer Grundbegriff"]},
"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_artefacts as ba
import board_inventory as bi
import guide
import guide_board
import kanban
import pipeline
import qa
import repair
async def fake_run_agent(agent_key, prompt, timeout, provider="claude", role="fast",
capabilities="none", lane="batch", scope=None, on_line=None, label=""):
return welt.respond(agent_key, prompt, capabilities)
for mod in (agents, pipeline, blocks, guide, repair):
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 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
@staticmethod
def embed_sims(texts):
arr = _FakeEmb.embed(texts)
return arr @ arr.T
for mod in (blocks, ba, qa):
setattr_fn(mod, "embedding", _FakeEmb)
async def emb_ok(flow): # Board-1-Vektorpfade aus — Judge-Wellen reichen
return False
setattr_fn(bi, "_emb_ok", emb_ok)

View File

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

View File

@@ -24,7 +24,8 @@ from config import (
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 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, 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
@@ -41,20 +42,16 @@ from textkit import (
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 +60,19 @@ _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:
if r["status"] == "consensus" and r["sub_title"]:
try:
facts = json.loads(r["facts"]) if r.get("facts") else {}
except (ValueError, TypeError):
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 +93,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 +102,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 +197,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",
)
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",
)
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",
)
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",
)
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",
)
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",
)
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,
@@ -892,14 +256,18 @@ 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.
import guide_board # lazy — guide_board imports helpers from this module
# Re-run from stage: cards from `ab_step` onward back to that column.
# A FINISHED guide without ab_step → complete fresh start (board + slots wiped).
# Otherwise cards are leftovers of an abort/error → resume at their stored stage.
if ab_step is not None:
_reset_guide_from_step(content_path, ab_step)
await guide_board.reset_from_stage(topic, format_name, ab_step)
elif content_path.exists():
for p_alt in guide_slot_files(content_path):
p_alt.unlink(missing_ok=True)
counts = await guide_stage_counts(topic, format_name)
if not counts or set(counts) == {"done"}:
await delete_guide_board(topic, format_name)
for p_alt in guide_slot_files(content_path):
p_alt.unlink(missing_ok=True)
bs = await list_blocks(topic, status="consensus")
if bs:
@@ -912,12 +280,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 +399,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."

758
backend/guide_board.py Normal file
View File

@@ -0,0 +1,758 @@
"""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
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
lesbarkeit judge-Rolle Lese-Check + deterministic readability gate → fix → done
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 (FORMAT_PURPOSE, GUIDE_LAENGE_MAX, GUIDE_LAENGE_MIN, READABILITY_ACTIVE,
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC)
from fsutil import atomic_write_json
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", "fakten_gate", "coverage", "lesbarkeit")
STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "Writer",
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
from config import GATE_FIX_MIN, MAX_WRITER_ROUNDS, 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) >= 12:
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 _coverage_schema(data, ziel_ids: set[str]):
"""{"ziele":{id:bool}, "luecken":[{ziel,fehlt}], "ballast":[str]} — ziele must cover all ids."""
if not isinstance(data, dict) or not isinstance(data.get("ziele"), dict):
return None
ziele = {}
for k, v in data["ziele"].items():
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
if 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()]
return {"ziele": ziele, "luecken": luecken, "ballast": ballast}
def _problems_schema(data):
"""Lese-Check: {"ok":true} → [] · {"problems":[{section,problem}]} → [problem…]."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return []
probs = data.get("problems")
if not isinstance(probs, list) or not probs:
return None
out = [str(p.get("problem", "")).strip() for p in probs
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
return out or None
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 _card_facts(env: _Env, block_title: str) -> str:
from guide import _facts_grounding # lazy: guide imports this module
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
return grounding or env.fallback_facts
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."""
rows = await db.get_sub_artefakte(env.topic, type="example", block_norm=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)
def _writer_budget(n_subs: int, sockel: int = 800) -> int:
"""Length guideline (chars) for the detailed version — unguided sections measured 24×
too long (23k) or, after the readability fix, far too thin (180 chars/sub)."""
return sockel + 400 * max(n_subs, 1)
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=_writer_budget(len(parts[i]), sockel=400),
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 = await db.list_lernziele(env.topic, norm)
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
gaps = ""
if card["writer_rounds"] > 0 and card.get("gate_info"):
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
"below, cut the listed ballast, keep everything else as-is.\n"
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
# 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="fakten_gate", 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=gaps, spec=env.spec,
budget=_writer_budget(len(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="fakten_gate", status="open")
return True
async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
return False
facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
if ex: # the fix agent sees the same facts variable — examples survive the fix pass
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
path = env.slot(f"gate-{_safe(norm)}-r{card['writer_rounds']}.json")
status, claims = await run_single_slot(
env.ctx, f"Fakten-Gate {card['block']}", key=f"{env.guide_id}-gate-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fakten-Gate", topic=env.topic, block=card["block"],
section=sec["md"], facts=facts, out_path=path,
extra=_extra(env.instructions)),
role="judge", capabilities="files",
payload=lambda result: _gate_schema(_json_file(path)),
timeout=_timeout("fakten_gate", 1))
if status == CANCELLED:
return False
if status == FAILED:
claims = [] # gate failure must not block the card — logged, text stands
_log(env.topic, f"Fakten-Gate {card['block']}: kein Ergebnis — Text bleibt ungeprüft")
falsch = [c for c in claims if c.get("urteil") == "falsch"] if claims else []
if claims and not falsch and len(claims) < GATE_FIX_MIN:
# 12 merely UNSUPPORTED claims don't justify a fix pass (it ran for 19/20 blocks,
# 40 agent-minutes) — but a WRONG claim always does: one slipped through this
# threshold and cost the guide 1.5 QA points
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claim(s) unter Schwelle — kein Fix")
claims = []
if claims:
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claims ({len(falsch)} falsch) → Fix")
fixp = env.slot(f"gatefix-{_safe(norm)}-r{card['writer_rounds']}.md")
fixp.unlink(missing_ok=True)
claims_text = "\n".join(f"- {c['text']}" + (f" ({c['grund']})" if c["grund"] else "")
for c in claims)
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"Fakten-Fix {card['block']}", key=f"{env.guide_id}-gatefix-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
section=card["md"], claims=claims_text, facts=facts,
out_path=fixp, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
timeout=_timeout("fakten_gate", 1))
if fstatus == CANCELLED:
return 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"Fakten-Fix {card['block']} ohne Sub-Marker — verworfen")
else:
card["md"] = fixed
await _set(env, card, md=card["md"], stage="coverage", status="open")
return True
async def _stage_coverage(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, norm)
if not ziele:
await _set(env, card, stage="lesbarkeit")
return True
sec = _first_section(card["md"])
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele)
path = env.slot(f"coverage-{_safe(norm)}-r{card['writer_rounds']}.json")
ids = {z["ziel_id"] for z in ziele}
status, res = await run_single_slot(
env.ctx, f"Coverage {card['block']}", key=f"{env.guide_id}-cov-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Coverage", topic=env.topic, block=card["block"],
ziele=ziele_text, section=sec["md"] if sec else card["md"],
out_path=path, extra=_extra(env.instructions)),
role="judge", capabilities="files",
payload=lambda result: _coverage_schema(_json_file(path), ids),
timeout=_timeout("coverage", len(ziele)))
if status == CANCELLED:
return False
if status == FAILED:
_log(env.topic, f"Coverage {card['block']}: kein Ergebnis — weiter ohne Gate")
await _set(env, card, stage="lesbarkeit")
return True
for zid, ok in res["ziele"].items():
if zid in ids:
await db.set_ziel_covered(env.topic, norm, zid, ok)
if res["luecken"] and card["writer_rounds"] < MAX_WRITER_ROUNDS:
info = "\n".join(f"- Lücke ({l['ziel']}): {l['fehlt']}" for l in res["luecken"])
if res["ballast"]:
info += "\n" + "\n".join(f"- Ballast (kürzen): {b}" for b in res["ballast"])
_log(env.topic, f"Coverage {card['block']}: {len(res['luecken'])} Lücke(n) → Writer-Runde "
f"{card['writer_rounds'] + 1}")
await _set(env, card, writer_rounds=card["writer_rounds"] + 1, gate_info=info,
stage="writer", status="open")
return True
if res["luecken"]:
_log(env.topic, f"Coverage {card['block']}: Lücken bleiben nach {MAX_WRITER_ROUNDS} Runden")
await _set(env, card, gate_info="", stage="lesbarkeit")
return True
async def _stage_lesbarkeit(env: _Env, card: dict) -> bool:
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
problems: list[str] = []
path = env.slot(f"lese-{_safe(norm)}-r{card['writer_rounds']}.json")
# Text-Antwort + Engine-Sink: Datei-schreibende Judges lieferten invalides JSON
# (3 kaputte Check-Dateien im Messlauf) — der Sink validiert vor dem Persistieren
status, res = await run_single_slot(
env.ctx, f"Lese-Check {card['block']}", key=f"{env.guide_id}-lese-{_safe(norm)}",
prompt=_prompt("Guide-Lese-Check", topic=env.topic, format_name=env.format,
spec=env.spec, sections=f"SECTION: {card['block']}\n{sec['md']}",
extra=_extra(env.instructions)),
role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, _problems_schema),
timeout=_timeout("lese_check", 1))
if status == CANCELLED:
return False
if status == OK and res:
problems += res
if READABILITY_ACTIVE: # deterministic gate, external grounding
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
if hints.get(1):
problems.append(hints[1])
# deterministic length trigger, same formula as the QA detector: prompt guidelines
# alone left writers 2.74.1× over target — a measured overshoot forces the fix pass
subs_all = env.subs_by_title.get(card["block"], [])
n_rel = max(sum(1 for s in subs_all if s.get("relevance") != "peripheral"), 1)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
pro_sub = len(aus[1] if len(aus) == 2 else sec["md"]) / n_rel
if not (GUIDE_LAENGE_MIN <= pro_sub <= GUIDE_LAENGE_MAX * 0.9):
ziel = _writer_budget(len(subs_all))
problems.append(
f"Länge {round(pro_sub)} Zeichen/Sub (Rahmen {GUIDE_LAENGE_MIN}{round(GUIDE_LAENGE_MAX * 0.9)}): "
f"schreibe den ausführlich-Teil auf etwa {ziel} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
if problems:
from guide import _level_label
subs = env.subs_by_title.get(card["block"], [])
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
tasks = (f"SECTION: {card['block']}\n"
f"SUBBLOCKS (set one `<!-- sub: LABEL | title -->` marker each, label/order as here):\n{sub_list}\n"
f"LENGTH TARGET: about {_writer_budget(len(subs))} characters for the detailed "
f"version (guideline — covering every subblock beats brevity).\n"
f"PROBLEM: {' · '.join(problems)}\nCURRENT CONTENT:\n{sec['md']}")
fixp = env.slot(f"lesefix-{_safe(norm)}.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"Lese-Fix {card['block']}", key=f"{env.guide_id}-lesefix-{_safe(norm)}",
prompt=_prompt("Guide-Sections-Fix", topic=env.topic, format_name=env.format,
facts=_card_facts(env, card["block"]), spec=env.spec, tasks=tasks,
out_path=fixp, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload,
timeout=_timeout("writer", 1))
if fstatus == CANCELLED:
return False
if fstatus == OK and fixed:
new_sec = _first_section(fixed)
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
_log(env.topic, f"Lese-Fix {card['block']} ohne Sub-Marker — verworfen")
else:
card["md"] = fixed
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
return True
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
"writer": _stage_writer, "fakten_gate": _stage_fakten_gate,
"coverage": _stage_coverage, "lesbarkeit": _stage_lesbarkeit}
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 _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
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
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)
for num, line in entries.items():
title = _title(line)
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
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)
async def _progress():
while True:
counts = await db.guide_stage_counts(topic, format_name)
done = counts.get("done", 0)
total = sum(counts.values())
await db.update_guide(guide_id, progress=f"Board: {done}/{total} Karten fertig")
await asyncio.sleep(2.0)
reporter = asyncio.create_task(_progress())
try:
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
finally:
reporter.cancel()
db.set_current_run(topic, None)
else:
db.set_current_run(topic, None)
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
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
tdir = qa_mod.QA_DIR / topic
greports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
note_guide = (_json_file(greports[-1]) or {}).get("note_guide") if greports else None
return {"columns": columns, "qa_guide": note_guide}
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
if block_norm not in cards:
return False
fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="")
if ab_stage <= 2:
fields["md"] = ""
if ab_stage == 0:
await db.delete_lernziele(topic, block_norm)
await db.set_guide_card(topic, format_name, block_norm, **fields)
return True
async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
target = GUIDE_STAGES[ab_stage]
stages = list(GUIDE_STAGES[ab_stage:]) + ["done"]
if ab_stage == 0:
for c in await db.list_guide_cards(topic, format_name):
await db.delete_lernziele(topic, c["block_norm"])
moved = await db.reset_guide_cards_from_stage(topic, format_name, stages, target,
clear_md=ab_stage <= 2)
return moved

223
backend/guide_qa.py Normal file
View File

@@ -0,0 +1,223 @@
"""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 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
from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN
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
def laengen_ausreisser(cards: list[dict], subs_rel: dict[str, set]) -> list[dict]:
out = []
for c in cards:
n = max(len(subs_rel.get(c["block_norm"], set())), 1)
pro_sub = len(_ausfuehrlich(c["md"])) / n
if not (LAENGE_MIN <= pro_sub <= LAENGE_MAX):
out.append({"block": c["block"], "zeichen_pro_sub": round(pro_sub)})
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."""
from agents import run_agent
from jsonio import parse_json_text
from pipeline import _yesno_schema
async def _pass(kandidaten: list[dict], tag: str) -> list[str]:
out = []
for lo in range(0, len(kandidaten), 5):
chunk = kandidaten[lo:lo + 5]
listing = "\n\n".join(f"{k}. SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}"
for k, c in enumerate(chunk, 1))
rc, txt, _err = await run_agent(
f"qa-guide-{topic}-fakten{tag}-{lo}", qa._qa_prompt("QA-Guide-Fakten", topic=topic, extra="", sections=listing),
600, role="judge", capabilities="none", scope=topic, label=f"Guide-QA Fakten{tag} {lo}")
v = (_yesno_schema(parse_json_text(txt)) or {}) if rc == 0 else {}
out += [c["block"] for k, c in enumerate(chunk, 1) if v.get(k) == "ja"]
return out
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] = {}
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"])
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, subs_rel)
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))

262
backend/kanban.py Normal file
View File

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

View File

@@ -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)]
@@ -637,21 +655,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)

View File

@@ -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()

View File

@@ -17,27 +17,66 @@ class GuideCreateRequest(BaseModel):
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
ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume
class GuideBoardResetRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType = "Guide"
ab_stage: int = Field(ge=0, le=5) # reset cards back to this board stage (no generation)
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
class RepairRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
class BlocksCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude"
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: drain the existing kanban queue, no new search
qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
class BlocksResetStepRequest(BaseModel):
class BlocksCardRestartRequest(BaseModel):
topic: str = Field(min_length=1)
card_id: str = Field(min_length=1, max_length=200)
class GuideFormatRequest(BaseModel):
topic: str = Field(min_length=1)
format: str = Field(min_length=1)
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 GuideCardResetRequest(BaseModel):
topic: str = Field(min_length=1)
format: str = Field(min_length=1)
block_norm: str = Field(min_length=1, max_length=200)
ab_stage: int = Field(ge=0, le=5)
class BlocksResetStageRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
ab_step: int = Field(ge=0) # ONLY reset from this sub-step (no regeneration)
board: Literal["inventory", "artefacts"]
stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to
class BlocksStep(BaseModel):
@@ -61,10 +100,6 @@ class BlocksStatusResponse(BaseModel):
feine_steps: list[BlocksFineStep] = []
class ProjectResponse(BaseModel):
name: str
class FolderResponse(BaseModel):
name: str
location: str # path relative to the repo root (e.g. "projects/foo")
@@ -129,85 +164,6 @@ 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):

View File

@@ -164,10 +164,10 @@ _relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
_MAX_RESTARTS = 2
from config import MAX_RESTARTS as _MAX_RESTARTS # noqa: E402 — zentral tunebar
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) -> list | None:
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:
"""Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -180,17 +180,27 @@ 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))}
tasks: dict[asyncio.Task, int] = {}
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:
slot = slots[i]
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, on_line=slot.get("on_line"), label=lbl,
))
tasks[task] = i
@@ -202,12 +212,22 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks:
if cancelled and cancelled():
return None
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
# 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:
return results
# Grace set and minimum reached → only wait for the remaining deadline
wait_timeout = None
# Wake up for the earliest relevant deadline (grace, min, or max).
waits = []
if deadline is not None and len(results) >= quorum:
wait_timeout = max(0.0, deadline - loop.time())
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())
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
@@ -234,7 +254,8 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
_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):
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
and (min_deadline is None or loop.time() >= min_deadline)):
return results
continue
@@ -272,13 +293,13 @@ OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
async def run_single_slot(
ctx: GenContext, label: str, *,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
) -> tuple[str, object]:
"""One agent, one valid result (race with quorum 1).
→ (OK, value) | (CANCELLED, None) | (FAILED, None)
"""
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}]
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload, "on_line": on_line}]
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
if ctx.is_cancelled():
return CANCELLED, None

3
backend/pytest.ini Normal file
View File

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

456
backend/qa.py Normal file
View File

@@ -0,0 +1,456 @@
"""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 json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import database as db
import embedding
from config import 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
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
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); Dubletten-VERDACHT enthält
# bewusst Rauschen und wiegt daher wenig.
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "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:
out[f.name] = f.read_text(encoding="utf-8")
except OSError:
continue
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
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)."""
return [x for x in lk if x.get("llm") != "nein"] if llm else lk
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 _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -> dict[int, str]:
from agents import run_agent
from pipeline import _yesno_schema
from jsonio import parse_json_text
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(items, 1))
slot = {"Dubletten": "pairs", "Luecken": "sections", "Bausteine": "blocks", "Sub": "pairs"}[template.split("-")[1]]
rc, out, _err = await run_agent(f"qa-{topic}-{key}", _qa_prompt(template, topic=topic, extra="", **{slot: listing}),
600, role="judge", capabilities="none", scope=topic, label=f"QA {key}")
return (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
# ── Report ──────────────────────────────────────────────────────────────────────────
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 []
bl = beleg(blocks, sub_rows)
hy = hygiene(blocks)
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
if llm and d:
v = await _llm_verdicts("QA-Dubletten", topic, "dubletten",
[f"A: {p['a']}\nB: {p['b']}" for p in d[:LLM_SAMPLE]])
for k, p in enumerate(d[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?")
if llm and lk:
v = await _llm_verdicts("QA-Luecken", topic, "luecken",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk[:LLM_SAMPLE]])
for k, x in enumerate(lk[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
for lo in range(0, len(sd), 40):
chunk = sd[lo:lo + 40]
v = await _llm_verdicts("QA-Sub-Dubletten", topic, f"sub-dubletten-{lo}",
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
for k, p in enumerate(chunk, 1):
p["llm"] = v.get(k, "?")
unecht: list[str] | None = None
if llm and blocks:
verdacht = []
for lo in range(0, len(blocks), 80): # ein Call je 80 Titel
chunk = blocks[lo:lo + 80]
v = await _llm_verdicts("QA-Bausteine", topic, f"bausteine-{lo}",
[f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in chunk])
verdacht += [b for k, b in enumerate(chunk, 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 _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
[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"]
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
quoten_art["sub_dubletten"] = round(sum(1 for p in sd if p.get("llm") == "ja") / 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) / max(len(blocks), 1), 3),
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
"fremd": round(len(fr) / max(len(blocks), 1), 3),
"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 {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "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)
# by mtime: run-id names (…-1311-5e5c) and timestamp names don't sort lexicographically.
# guide-* reports share the directory but are a SEPARATE series (guide_qa.py).
older = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
key=lambda p: p.stat().st_mtime)
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
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, _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))

324
backend/repair.py Normal file
View File

@@ -0,0 +1,324 @@
"""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, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur nach
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
import asyncio
import json
import logging
import re
import database as db
import qa
from agents import run_agent
from blocks import _blocks_files, _evidence_pack, source_folder
from fsutil import atomic_write_json
from jsonio import parse_json_text, read_json_file as _json_file
from pipeline import _yesno_schema
from textkit import _norm_title, _title, clean_title
log = logging.getLogger("creator.repair")
JUDGE_TIMEOUT = 600
from config import EVIDENCE_PER_BLOCK, JUDGE_CHUNK # noqa: E402 — zentral tunebar
async def repair_befunde(topic: str) -> dict:
tdir = qa.QA_DIR / topic
reports = sorted((p for p in tdir.glob("*.json") if not p.name.startswith("guide-")),
key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
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}
hygiene = await _fix_hygiene(topic, report, by_norm, files)
merges = await _merge_dubletten(topic, report, by_norm, files)
sub_merges = await _merge_sub_dubletten(topic, report, files)
entfernt = await _entferne_fremd_unecht(topic, report, by_norm, files)
aufgeraeumt = await _raeume_waisen(topic)
# 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 asyncio.to_thread(qa._write_report, neu)
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
"""No-Tool-Judge-Wellen über alle Items (fail-open: Fehler → leeres Verdikt = behalten)."""
verdicts: dict[int, str] = {}
for lo in range(0, len(items), JUDGE_CHUNK):
chunk = items[lo:lo + JUDGE_CHUNK]
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(chunk, 1))
try:
rc, out, _err = await run_agent(
f"repair-{topic}-{key}-{lo}", qa._qa_prompt(template, topic=topic, extra="", **{slot: listing}),
JUDGE_TIMEOUT, role="judge", capabilities="none", scope=topic, label=f"Repair {key}")
v = (_yesno_schema(parse_json_text(out)) or {}) if rc == 0 else {}
except Exception:
log.exception("[%s] Repair-Judge %s fehlgeschlagen — Befunde bleiben", topic, key)
v = {}
verdicts.update({lo + k: urteil for k, urteil in v.items()})
return verdicts
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 — bleiben Befund."""
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_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Nur QA-bestätigte Paare (llm=ja); eine Zweitmeinung, Merge nur bei erneut ja.
Merge spiegelt die dedup-Stage: Union ins Gewinner-Payload, Verlierer → grouped."""
paare = [p for p in report.get("dubletten", []) if p.get("llm") == "ja"
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
if not paare:
return []
v = await _judge("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in paare])
merged = []
for i, p in enumerate(paare, 1):
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
if v.get(i) != "ja" or not a or not b or a["card_id"] == b["card_id"]:
continue
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)
merged.append(f"{lp.get('title')}{wp.get('title')}")
return merged
_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):
try:
kp = len((json.loads(r.get("facts") or "{}")).get("key_points") or [])
except ValueError:
kp = 0
return (kp, len(r.get("sub_title") or ""))
return (a, b) if score(a) >= score(b) else (b, a)
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> 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 = await _judge("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])
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 db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
gone.add(lk)
# Fragen/Artefakte des Verlierers: umhängen, wenn der Gewinner den Typ nicht hat
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"])
merged.append(f"{lose['sub_title'][:40]}{win['sub_title'][:40]}")
return merged
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) -> list[str]:
out = []
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 = await _judge("QA-Repair-Beleg", topic, "fremd", "blocks", lines)
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 = await _judge("QA-Bausteine", topic, "unecht", "blocks", lines)
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
async def _raeume_waisen(topic: str) -> int:
"""Artefakte/Fragen mit totem Ziel löschen (Sub verworfen oder weg) — inert, der
Übungs-Join spielt sie nie aus, aber sie drücken die Artefakt-Note. Mehrdeutige
Präfix-Treffer bleiben (könnten lebend sein — Löschen wäre riskanter als behalten)."""
lebt = {(r["block_norm"], r["sub_norm"]) for r in await db.list_subblocks(topic)
if r["status"] != "discarded"}
def tot(bn: str, sn: str) -> bool:
if (bn, sn) in lebt:
return False
return not any(b == bn and s.startswith(sn + ":") for b, s in lebt)
n = 0
for r in await db.get_sub_artefakte(topic):
if tot(r["block_norm"], r["sub_norm"]):
await db.delete_artefakt_row(topic, r["block_norm"], r["sub_norm"], r["type"])
n += 1
for r in await db.list_question_pattern(topic):
if tot(r["block_norm"], r["sub_norm"]):
await db.delete_frage_row(topic, r["block_norm"], r["sub_norm"])
n += 1
return n
async def _reject(topic: str, title: str, by_norm: dict, files: dict, grund: str) -> None:
norm = _norm_title(title)
card = by_norm.pop(norm, None)
if not card:
return
p = dict(card["payload"])
p["reason"] = grund
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.kanban_advance(topic, "inventory", card["card_id"], "rejected")
await _purge_block(topic, title, files)
async def _purge_block(topic: str, title: str, files: dict) -> None:
"""Abgeleitete Daten eines Blocks gezielt entfernen (DB-Spiegel, Board-2-Karte, Sidecars)."""
norm = _norm_title(title)
await db.set_block_status(topic, norm, "discarded")
await db.delete_subblocks(topic, norm)
await db.delete_question_pattern(topic, norm)
await db.delete_sub_artefakte(topic, norm)
await db.kanban_delete_card(topic, "artefacts", norm)
for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
d = _json_file(files[key])
if isinstance(d, dict):
hits = [k for k in d if _norm_title(k) == norm]
if hits:
for k in hits:
d.pop(k)
atomic_write_json(files[key], d, indent=1)
art = _json_file(files["artefakte"])
if isinstance(art, dict):
neu = {t: [e for e in (es if isinstance(es, list) else [])
if _norm_title(_title(str(e.get("block", "")))) != norm]
for t, es in art.items()}
if neu != art:
atomic_write_json(files["artefakte"], neu, indent=1)
def _rename_in_files(files: dict, norm: str, neu: str) -> None:
"""Titel-Keys der Sidecar-JSONs + artefakte-`block`-Felder auf den bereinigten Titel."""
for key in ("sidecar", "facts", "question_pattern", "sub_roh"):
d = _json_file(files[key])
if isinstance(d, dict):
hits = [k for k in d if _norm_title(k) == norm and k != neu]
if hits:
for k in hits:
d[neu] = d.pop(k)
atomic_write_json(files[key], d, indent=1)
art = _json_file(files["artefakte"])
if isinstance(art, dict):
changed = False
for es in art.values():
for e in es if isinstance(es, list) else []:
if _norm_title(_title(str(e.get("block", "")))) == norm and e.get("block") != neu:
e["block"] = neu
changed = True
if changed:
atomic_write_json(files["artefakte"], art, indent=1)

View File

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

View File

@@ -1,44 +1,44 @@
import asyncio
import json
import logging
import shutil
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from agents import provider_available
from agents import active_agents, provider_available
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
create_guide, delete_guide, get_guide, list_guides,
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,
)
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, reset_board_from_stage, restart_artefact_card
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,
GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideCardResetRequest, GuideFormatRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
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 +65,19 @@ 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("/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
@@ -95,29 +95,8 @@ async def remove_topic(topic: str):
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)
import qa
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
return {"ok": True}
@@ -164,7 +143,124 @@ async def create_blocks(req: BlocksCreateRequest):
raise HTTPException(400, "Link must start with http:// or https://.")
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))
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
research=req.research, qa_force=req.qa_force))
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"]}
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 asyncio.to_thread(qa._write_report, report)
note_guide = None
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
import guide_qa
from database import list_guide_cards
if 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)
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 = "claude"):
"""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/reset-stage")
async def reset_blocks_stage(req: BlocksResetStageRequest):
"""Reset cards from a column onward back to that column (no regeneration)."""
topic = req.topic.strip()
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "generating"} # don't interfere with a running generation
moved = await reset_board_from_stage(topic, req.board, req.stage, _blocks_files(topic))
return {"ok": True, "moved": moved}
@router.post("/blocks/requeue-dead")
async def requeue_blocks_dead(topic: str):
return {"ok": True, "requeued": await requeue_dead(topic)}
@router.post("/blocks/card-restart")
async def blocks_card_restart(req: BlocksCardRestartRequest):
if (await blocks_status(req.topic))["generating"]:
return {"ok": True, "status": "generating"}
if not await restart_artefact_card(req.topic, req.card_id):
raise HTTPException(404, "Card not found")
return {"ok": True}
@router.post("/guides/board/card-reset")
async def guide_card_reset(req: GuideCardResetRequest):
running = [g for g in await list_guides()
if g["topic"] == req.topic and g["format"] == req.format
and g["status"] in ("queued", "generating")]
if running:
return {"ok": True, "status": "generating"}
from guide_board import reset_card
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
raise HTTPException(404, "Card not found")
return {"ok": True}
@@ -179,15 +275,7 @@ 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
return {"ok": True}
@router.post("/blocks/reset-step")
async def reset_blocks_step(req: BlocksResetStepRequest):
topic = req.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)
await kanban_reset(topic) # kanban cards + cluster membership gone
return {"ok": True}
@@ -226,6 +314,43 @@ 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")
subs = 0
for b in blocks:
subs += sum(1 for s in await list_subblocks(topic, b["title_norm"]) if s["status"] == "consensus")
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)
@@ -239,22 +364,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 ---
@@ -323,11 +492,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:
@@ -340,10 +508,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}
@@ -474,8 +638,8 @@ 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()
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
if reason:
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
await create_topic(req.topic.strip())
@@ -500,26 +664,34 @@ 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)
if guide is None:
raise HTTPException(404, "Guide not found")
return guide
@router.post("/guides/board/reset")
async def reset_guide_board(req: GuideBoardResetRequest):
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""
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 and guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating"}
moved = await guide_board.reset_from_stage(topic, req.format, req.ab_stage)
return {"ok": True, "moved": moved}
@router.get("/guides/{guide_id}/content")
@@ -583,82 +755,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)
@@ -667,12 +763,29 @@ 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)
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
@@ -680,26 +793,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)}

View File

@@ -11,7 +11,7 @@ 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
@@ -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,7 +47,7 @@ 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
def _content_json(topic: str, fmt: str) -> dict | None:
@@ -84,39 +84,39 @@ 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]]]) -> 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,
@@ -132,9 +132,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)"

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

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

View File

@@ -0,0 +1,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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,197 @@
"""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, Konsolidierung, Cross-Block, QA-Gate.
"""
import asyncio
import pytest
import board_inventory as bi
from pipeline import GenContext
from tests.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}
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
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_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"-sub-crossblock-.*-j1$", "modus": "fehler", "mal": 3}, # Ersatzrichter jE
{"muster": r"-sub-konsolidierung-.*-j1$", "modus": "garbage", "mal": 1}, # Retry heilt
{"muster": r"-facts-c\d+$", "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_crossblock_dissent_failopen(fake_welt, testdb, tmp_path):
"""j1 sagt a, j2 sagt b, j3 fällt aus → Paar bleibt (fail-open), Rest konsistent."""
fake_welt.stoerungen += [
{"muster": r"-sub-crossblock-.*-j2$", "modus": "antwort",
"antwort": '{"pairs": {"1": "b"}}', "mal": 1, "rest": 1},
{"muster": r"-sub-crossblock-.*-j3$", "modus": "fehler", "mal": 3, "rest": 3},
]
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "consensus"] # kein Fold
assert await pruefe_invarianten(TOPIC, files) == []
async def test_e2e_inblock_gruppe_faltet(fake_welt, testdb, tmp_path):
"""Welt-Regel: „Alpha Eigenschaften" faltet unter „Definition Alpha" — beide Judges
liefern die Gruppe, der Verlierer wird variant, seine facts wandern zum Gewinner."""
fake_welt.gruppen.append(("Definition Alpha", ["Alpha Eigenschaften"]))
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha-konzept")}
assert rows.get("alpha eigenschaften") == "variant"
assert rows.get("definition alpha") == "consensus"
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")]
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Kanon-Klassiker"
for c in alle)
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
for c in alle) # nie zum Block geworden
done = {c["payload"].get("title") for c in alle
if c["kind"] == "block" and c["stage"] == "done_block"}
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done

View File

@@ -0,0 +1,319 @@
"""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", 10) == 400 + 150
assert _timeout("content", 10) == 450 + 300
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_restart_artefact_card_wipes_only_that_block(testdb):
import board_inventory as bi
db = testdb
for norm in ("alpha", "beta"):
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
assert await db.list_subblocks(TOPIC, "alpha") == []
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
async def test_guide_reset_card_single(testdb):
import guide_board as gb
db = testdb
for n in ("alpha", "beta"):
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
writer_rounds=2, md="# SECTION Text", gate_info="x")
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
# ab_stage 3 (fakten_gate) behält md
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
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):
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}

View File

@@ -0,0 +1,386 @@
"""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_coverage_schema():
res = gb._coverage_schema({"ziele": {"z1": True, "z2": "false"},
"luecken": [{"ziel": "z2", "fehlt": "Beweis"}],
"ballast": ["Abschweifung"]}, {"z1", "z2"})
assert res["ziele"] == {"z1": True, "z2": False}
assert res["luecken"][0]["fehlt"] == "Beweis"
assert gb._coverage_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 missing
def test_problems_schema():
assert gb._problems_schema({"ok": True}) == []
assert gb._problems_schema({"problems": [{"section": "S", "problem": "zu lang"}]}) == ["zu lang"]
assert gb._problems_schema({"problems": []}) is None
async def test_reset_from_stage(testdb):
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "a", "A")
await db.upsert_guide_card(TOPIC, FMT, "b", "B")
await db.set_guide_card(TOPIC, FMT, "a", stage="done", md="text", writer_rounds=2)
await db.set_guide_card(TOPIC, FMT, "b", stage="coverage", md="text")
await db.put_lernziel(TOPIC, "a", "z1", "Ziel")
# reset ab writer (idx 2): beide Karten zurück, md geleert, Ziele bleiben
moved = await gb.reset_from_stage(TOPIC, FMT, 2)
assert moved == 2
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["a"]["stage"] == "writer" and cards["a"]["md"] == "" and cards["a"]["writer_rounds"] == 0
assert cards["b"]["stage"] == "writer"
assert await db.list_lernziele(TOPIC, "a")
# reset ab lernziele (idx 0): Ziele weg
await gb.reset_from_stage(TOPIC, FMT, 0)
assert not await db.list_lernziele(TOPIC, "a")
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "lernziele"
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="coverage")
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate 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_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
"""Gate-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", []
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
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)
card = {"block_norm": "gross", "block": "Gross", "stage": "fakten_gate", "status": "open",
"writer_rounds": 0, "gate_info": "",
"md": "<!-- section: Gross -->\n<!-- ausführlich -->\nText."}
ok = await gb._stage_fakten_gate(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"] == "fakten_gate"
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_lese_check_text_sink(testdb, tmp_path, monkeypatch):
"""Lese-Check antwortet als Text, Engine-Sink persistiert; capabilities none."""
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_lesbarkeit(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._writer_budget(2)) in seen["prompt"] # 800 + 2×400
async def test_fakten_gate_schwelle(testdb, tmp_path, monkeypatch):
"""12 Claims → kein Fix-Rewrite (nur Log); ab GATE_FIX_MIN läuft der Fix."""
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 -->\nText."
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
keys = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
keys.append(key)
if "-gate-" in key:
return gb.OK, [{"text": "c1", "grund": ""}, {"text": "c2", "grund": ""}]
raise AssertionError("Fix darf unter der Schwelle nicht laufen")
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fakten_gate(env, card)
assert all("-gate-" in k for k in keys)
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_fakten_gate_falsch_claim_erzwingt_fix(testdb, tmp_path, monkeypatch):
"""Ein einzelner FALSCH-Claim läuft in den Fix, auch unter GATE_FIX_MIN —
ein durchgerutschter kostete den Guide 1.5 QA-Punkte."""
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 -->\nText."
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
keys = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
keys.append(key)
if "-gate-" in key:
return gb.OK, [{"text": "c1", "grund": "widerspricht", "urteil": "falsch"}]
return gb.FAILED, None # Fix-Agent liefert nichts — Text bleibt, aber der Call MUSS kommen
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fakten_gate(env, card)
assert any("-gatefix-" in k for k in keys)
async def test_laengen_trigger_startet_lesefix(testdb, tmp_path, monkeypatch):
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem
mit hartem Zeichenziel landet im Lese-Fix-Auftrag."""
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}
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
if "-lesefix-" in key:
seen["tasks"] = prompt
return gb.FAILED, None
return gb.OK, payload((0, '{"ok": true}', "")) # Lese-Check: keine Probleme
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_lesbarkeit(env, card)
assert "Länge" in seen["tasks"] and str(gb._writer_budget(1)) in seen["tasks"]

View File

@@ -0,0 +1,106 @@
"""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():
duenn = _card("Alpha", "<!-- ausführlich -->\nkurz")
ok = _card("Beta", "<!-- ausführlich -->\n" + "x" * 500)
out = gq.laengen_ausreisser([duenn, ok], {"alpha": {"s1"}, "beta": {"s1"}})
assert [x["block"] for x in out] == ["Alpha"]
def test_redundanz_findet_absatz_doppel():
a = "Der Kantenzug verbindet Knoten über mehrere Kanten und darf Knoten wiederholen. " * 3
b = "Der Kantenzug verbindet Knoten über mehrere Kanten und darf Knoten wiederholen, genau. " * 3
c = "Völlig anderes Thema: Matrizen, Determinanten und lineare Abbildungen im Vektorraum. " * 3
cards = [_card("Alpha", f"<!-- ausführlich -->\n{a}\n\n{c}"),
_card("Beta", f"<!-- ausführlich -->\n{b}")]
out = gq.redundanz(cards)
assert len(out) == 1 and out[0]["a"].startswith("Alpha")
def test_lesbarkeit_fail_open(monkeypatch):
def kaputt(md_by_num):
raise RuntimeError("Modell fehlt")
monkeypatch.setattr(gq.readability, "rate_sections", kaputt)
assert gq.lesbarkeit([_card("Alpha", AUSF)]) == []
def test_note_guide_kalibrierung():
"""Gewicht = Punktabzug bei 100 %: 10 % fachlich falsch × 3.0 → 7.0; ungemessen zählt nicht."""
assert qa.note({"fachlich_falsch": 0.1}, gq.NOTE_GEWICHTE_GUIDE) == 7.0
ohne = {"marker_fehlend": 0.0, "ziel_ohne_anker": 0.0}
assert qa.note(ohne, gq.NOTE_GEWICHTE_GUIDE) == 10.0
def test_marker_escaping_tolerant():
"""Escapte Titel (h\\~2\\~o) sind kein „Marker fehlt" — Writer und DB escapen verschieden."""
md = "<!-- ausführlich -->\n<!-- sub: beginner | ^ und ~ müssen escaped werden (h\\\\~2\\\\~o) -->\nText."
cards = [{"block": "Hoch", "block_norm": "hoch", "md": md}]
rel = {"hoch": {gq._norm_title("^ und ~ müssen escaped werden (h~2~o)")}}
assert gq.marker_fehlend(cards, rel) == []
async def test_fachlich_falsch_braucht_beide_bestaetiger(monkeypatch):
"""Befund zählt nur, wenn BEIDE Bestätiger zustimmen — Einzel-/Zweifach-Urteile
ließen die Note desselben Guides zwischen 2.0 und 6.6 springen."""
import agents
calls = {"n": 0}
async def fake_agent(key, prompt, timeout, **kw):
calls["n"] += 1
if "-fakten-3-" in key: # Bestätiger 2: nur #1 bleibt
return 0, '{"relevant": {"1": "ja"}}', ""
if "-fakten-2-" in key: # Bestätiger 1: #1 und #2
return 0, '{"relevant": {"1": "ja", "2": "ja"}}', ""
return 0, '{"relevant": {"1": "ja", "2": "ja", "3": "nein"}}', "" # Pass 1
monkeypatch.setattr(agents, "run_agent", fake_agent)
cards = [_card("Alpha", AUSF), _card("Beta", AUSF), _card("Gamma", AUSF)]
out = await gq._fachlich_falsch("t", cards)
assert out == ["Alpha"] and calls["n"] == 3
async def test_fachlich_falsch_ohne_verdacht_kein_zweiter_pass(monkeypatch):
import agents
calls = {"n": 0}
async def fake_agent(key, prompt, timeout, **kw):
calls["n"] += 1
return 0, '{"relevant": {"1": "nein", "2": "nein"}}', ""
monkeypatch.setattr(agents, "run_agent", fake_agent)
out = await gq._fachlich_falsch("t", [_card("Alpha", AUSF), _card("Beta", AUSF)])
assert out == [] and calls["n"] == 1

View File

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

View File

@@ -0,0 +1,608 @@
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
import json
import numpy as np
import pytest
import blocks
import board_artefacts as ba
from kanban import Flow
from pipeline import FAILED, OK, GenContext
TOPIC = "konsolidierung"
def _ctx():
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
def _fake_slot(antworten):
"""run_single_slot-Fake: pro Judge-Key eine Antwort; schreibt via payload (wie der Engine-Sink)."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
calls.append({"key": key, "prompt": prompt})
j = key.rsplit("-", 1)[-1] # "j1"/"j2"
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_block(db, bnorm, subs):
for s in subs:
await db.put_subblock(TOPIC, bnorm, blocks._norm_title(s), bnorm.title(), s, status="consensus")
# ── In-Block ────────────────────────────────────────────────────────────────────────
async def test_merge_on_unanimity(testdb, tmp_path, monkeypatch):
"""Beide Judges gruppieren 1+2 → Gewinner (mehr key_points) bleibt, facts-Union,
Verlierer wird DB-variant und fliegt aus raw/facts_map."""
db = testdb
subs = ["Durchstreichung: ~~text~~", "Durchstreichung: ~~text~~ streicht Text durch", "Fett: **text**"]
await _seed_block(db, "betonung", subs)
raw = {"Betonung": list(subs)}
facts = {"Betonung": {
blocks._norm_title(subs[0]): {"key_points": ["kp-a"], "cited_facts": [{"text": "z1"}]},
blocks._norm_title(subs[1]): {"key_points": ["kp-b", "kp-c"], "cited_facts": [{"text": "z1"}, {"text": "z2"}]},
}}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": ["Marker-Escaping fehlt"]},
"j2": {"gruppen": [[2, 1]], "luecken": ["Escaping von Markern"]}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Betonung"] == [subs[1], "Fett: **text**"] # Gewinner: 2 key_points > 1
wf = facts["Betonung"][blocks._norm_title(subs[1])]
assert wf["key_points"] == ["kp-b", "kp-c", "kp-a"]
assert wf["cited_facts"] == [{"text": "z1"}, {"text": "z2"}] # Union ohne Doppel
assert blocks._norm_title(subs[0]) not in facts["Betonung"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "betonung")}
assert rows[blocks._norm_title(subs[0])] == "variant"
assert rows[blocks._norm_title(subs[1])] == "consensus"
journale = list(tmp_path.glob("sub-konsolidierung-*.json"))
j = json.loads([p for p in journale if "-j" not in p.stem][0].read_text())
# Lücken-Schnitt: Token-Überlappung beider Judges, Formulierung von j1 gewinnt
assert j["gruppen"][0]["behalten"] == subs[1] and j["luecken"] == ["Marker-Escaping fehlt"]
async def test_dissent_keeps_everything(testdb, tmp_path, monkeypatch):
"""Nur ein Judge gruppiert → keine Einstimmigkeit → kein Merge."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
"j2": {"gruppen": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == subs
assert all(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "block"))
async def test_judge_failure_fail_open(testdb, tmp_path, monkeypatch):
"""Ein Judge UND der Ersatz ohne Ergebnis → fail-open, nichts ändert sich."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []}}) # j2 UND j3 → FAILED
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == subs
assert len(fake.calls) == 3 # j1, j2, Ersatz j3
async def test_ersatzrichter_bei_ausfall(testdb, tmp_path, monkeypatch):
"""j1 fällt aus → Ersatz j3 springt ein; Einstimmigkeit j2+j3 faltet.
Vorher entwertete EIN Timeout die gute Stimme (13 Links-Dubletten überlebten)."""
db = testdb
subs = ["Kurz", "Deutlich längerer Eintrag"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j2": {"gruppen": [[1, 2]], "luecken": []},
"j3": {"gruppen": [[2, 1]], "luecken": []}}) # j1 → FAILED
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == ["Deutlich längerer Eintrag"]
async def test_negation_guard_blocks_merge(testdb, tmp_path, monkeypatch):
"""Gegensätzliche Aussagen werden selbst bei einstimmigen Judges nicht gefaltet."""
db = testdb
subs = ["Tabs werden expandiert", "Tabs werden nicht expandiert"]
await _seed_block(db, "tabs", subs)
raw = {"Tabs": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [[1, 2]], "luecken": []},
"j2": {"gruppen": [[1, 2]], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Tabs"] == subs
async def test_resume_skips_judges(testdb, tmp_path, monkeypatch):
"""Vorhandene j-Dateien → kein neuer Agenten-Call, Ergebnis wird übernommen."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei lang"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
import hashlib
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
for j in (1, 2):
(tmp_path / f"sub-konsolidierung-{h}-j{j}.json").write_text(
json.dumps({"gruppen": [[1, 2]], "luecken": []}), encoding="utf-8")
async def kein_agent(*a, **kw):
raise AssertionError("Resume darf keinen Agenten starten")
monkeypatch.setattr(blocks, "run_single_slot", kein_agent)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == ["Eintrag zwei lang"]
def test_schema_accepts_both_group_forms():
"""Alte Listenform [1,4] und neue {haupt, weitere}-Form parsen beide; kataloge/fremd optional."""
alt = blocks._konsolidierung_schema({"gruppen": [[1, 4]], "luecken": []}, 5)
assert alt["gruppen"] == [{"haupt": None, "ids": [1, 4]}] and alt["fremd"] == set()
neu = blocks._konsolidierung_schema(
{"gruppen": [{"haupt": 4, "weitere": [1]}],
"kataloge": [{"titel": "Katalog: Symbole", "mitglieder": [2, 3]}],
"fremd": [5], "luecken": ["x"]}, 5)
assert neu["gruppen"] == [{"haupt": 4, "ids": [1, 4]}]
assert neu["kataloge"] == [{"titel": "Katalog: Symbole", "ids": [2, 3]}]
assert neu["fremd"] == {5} and neu["luecken"] == ["x"]
assert blocks._konsolidierung_schema({"gruppen": [{"haupt": 9, "weitere": [1]}]}, 5) == \
{"gruppen": [], "kataloge": [], "fremd": set(), "luecken": []} # id out of range
async def test_haupt_beats_heuristic(testdb, tmp_path, monkeypatch):
"""Judges nennen den kürzeren Eintrag als haupt → er gewinnt trotz weniger key_points."""
db = testdb
subs = ["Basis", "Detailregel mit sehr langem Titel und Facts"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts = {"Block": {blocks._norm_title(subs[1]): {"key_points": ["a", "b", "c"]}}}
fake = _fake_slot({"j1": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []},
"j2": {"gruppen": [{"haupt": 1, "weitere": [2]}], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Block"] == ["Basis"]
assert facts["Block"][blocks._norm_title("Basis")]["key_points"] == ["a", "b", "c"] # Union geerbt
async def test_katalog_bundles_to_new_row(testdb, tmp_path, monkeypatch):
"""Einstimmige Katalog-Mitglieder → neue consensus-Zeile mit Facts-Union, Mitglieder variant."""
db = testdb
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f", "Eigene Regel"]
await _seed_block(db, "mathe", subs)
raw = {"Mathe": list(subs)}
facts = {"Mathe": {blocks._norm_title(subs[0]): {"key_points": ["kp1"]},
blocks._norm_title(subs[1]): {"key_points": ["kp2"]}}}
kat = {"titel": "Symbolkatalog: Pfeile und Mengen", "mitglieder": [1, 2]}
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [kat], "luecken": []},
"j2": {"gruppen": [], "kataloge": [kat], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
assert raw["Mathe"] == ["Eigene Regel", "Symbolkatalog: Pfeile und Mengen"]
kn = blocks._norm_title("Symbolkatalog: Pfeile und Mengen")
assert sorted(facts["Mathe"][kn]["key_points"]) == ["kp1", "kp2"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "mathe")}
assert rows[kn] == "consensus"
assert rows[blocks._norm_title(subs[0])] == "variant"
async def test_katalog_dissent_keeps_members(testdb, tmp_path, monkeypatch):
db = testdb
subs = ["Pfeilsymbole: a b c", "Mengensymbole: d e f"]
await _seed_block(db, "mathe", subs)
raw = {"Mathe": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "kataloge": [{"titel": "K", "mitglieder": [1, 2]}], "luecken": []},
"j2": {"gruppen": [], "kataloge": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Mathe"] == subs
async def test_fremd_unanimous_discards(testdb, tmp_path, monkeypatch):
"""Einstimmig fremd → discarded + raus; einseitig fremd → bleibt."""
db = testdb
subs = ["CSS display überschreibt Verhalten", "Echte Markdown-Regel", "Nur einer hält es für fremd"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "fremd": [1, 3], "luecken": []},
"j2": {"gruppen": [], "fremd": [1], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert raw["Block"] == [subs[1], subs[2]]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
assert rows[blocks._norm_title(subs[0])] == "discarded"
assert rows[blocks._norm_title(subs[2])] == "consensus"
assert luecken == {}
async def test_luecken_nur_bei_einstimmigkeit(testdb, tmp_path, monkeypatch):
"""Nur Lücken mit Token-Überlappung BEIDER Judges überleben; einseitige fallen weg."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
fake = _fake_slot({"j1": {"gruppen": [], "luecken": ["Inline-HTML fehlt", "Front-Matter"]},
"j2": {"gruppen": [], "luecken": ["nichts zu Inline-HTML"]}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
luecken = await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, {})
assert luecken == {"Block": ["Inline-HTML fehlt"]}
async def test_kp_deckel_im_judge_prompt(testdb, tmp_path, monkeypatch):
"""Prompt zeigt max. 3 key_points je Sub (Timeout-Schutz); die Union bleibt voll."""
db = testdb
subs = ["Eintrag eins", "Eintrag zwei"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts = {"Block": {blocks._norm_title(subs[0]): {"key_points": [f"kp{i}" for i in range(1, 6)]}}}
fake = _fake_slot({"j1": {"gruppen": [], "luecken": []}, "j2": {"gruppen": [], "luecken": []}})
monkeypatch.setattr(blocks, "run_single_slot", fake)
await blocks._konsolidiere_subblocks(_ctx(), {"arbeit": tmp_path}, raw, facts)
prompt = fake.calls[0]["prompt"]
assert "kp3" in prompt and "kp4" not in prompt
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")
# ── Lücken-Nachfass ─────────────────────────────────────────────────────────────────
async def _nachfass_env(db, monkeypatch, facts_result):
subs = ["Eintrag eins"]
await _seed_block(db, "block", subs)
raw = {"Block": list(subs)}
facts_map = {"Block": {}}
async def fake_race(topic, label, slots, quorum, timeout, provider, cancelled=None, grace=0):
return [{"Block": ["Eintrag eins", "Neuer Aspekt"]}]
async def fake_facts(ctx, set_p, files, fraw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
assert slim is True # Nachfass nutzt die schlanke Facts-Variante
assert list(fraw["Block"]) == ["Neuer Aspekt"] # nur der frische Fund geht ins Gate
return facts_result
monkeypatch.setattr(blocks, "_race", fake_race)
monkeypatch.setattr(blocks, "_facts_block", fake_facts)
monkeypatch.setattr(blocks, "EMBEDDING_AKTIV", False)
return raw, facts_map
async def test_nachfass_adopts_backed_find(testdb, tmp_path, monkeypatch):
db = testdb
nn = blocks._norm_title("Neuer Aspekt")
raw, facts_map = await _nachfass_env(db, monkeypatch,
({"Block": {nn: {"key_points": ["kp"]}}}, {}))
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 1 and raw["Block"] == ["Eintrag eins", "Neuer Aspekt"]
assert facts_map["Block"][nn]["key_points"] == ["kp"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "block")}
assert rows[nn] == "consensus"
async def test_nachfass_drops_unbacked_find(testdb, tmp_path, monkeypatch):
db = testdb
nn = blocks._norm_title("Neuer Aspekt")
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {"Block": {nn}}))
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 0 and raw["Block"] == ["Eintrag eins"]
assert not any(r["sub_norm"] == nn for r in await db.list_subblocks(TOPIC, "block"))
async def test_nachfass_drops_find_without_facts(testdb, tmp_path, monkeypatch):
"""HARTES Gate: kein Facts-Eintrag = kein Beleg = keine Übernahme — nicht nur
aktiv Verworfenes fliegt (Bilder-Lauf: 13 von 18 kamen ohne Beleg durch)."""
db = testdb
raw, facts_map = await _nachfass_env(db, monkeypatch, ({}, {})) # Facts fand NICHTS
n = await blocks._luecken_runde(_ctx(), {"arbeit": tmp_path}, "Block", ["Aspekt"],
raw, facts_map, {"type": "thema"}, None)
assert n == 0 and raw["Block"] == ["Eintrag eins"]
async def test_facts_stage_konsolidiert_nachfass_funde_erneut(testdb, tmp_path, monkeypatch):
"""Kreis geschlossen: nach Übernahmen läuft die Konsolidierung ein zweites Mal;
deren Lücken lösen KEINEN weiteren Nachfass aus."""
db = testdb
payload = {"title": "Alpha", "raw": {"Alpha": ["s1"]}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts", payload)
calls = {"kons": 0, "nf": 0}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None, slim=False):
return {"Alpha": {}}, {}
async def fake_kons(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
calls["kons"] += 1
return {"Alpha": ["Lücke X"]} # meldet auch in Runde 2 — darf nicht erneut nachfassen
async def fake_nf(ctx, files, title, luecken, raw, facts_map, q, folder,
instructions="", ns="", lbl="", sources=None):
calls["nf"] += 1
return 2
monkeypatch.setattr(ba, "_facts_block", fake_facts)
monkeypatch.setattr(ba, "_konsolidiere_subblocks", fake_kons)
monkeypatch.setattr(ba, "_luecken_runde", fake_nf)
flow = Flow(TOPIC, work_dir=tmp_path)
await ba._proc_facts(_ctx(), flow, {"arbeit": tmp_path}, {"type": "thema"}, None, "",
[{"card_id": "alpha", "payload": payload}])
assert calls == {"kons": 2, "nf": 1}
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "levels"
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")}
# ── Cross-Block ─────────────────────────────────────────────────────────────────────
class _FakeEmb:
"""Gleicher Text → gleicher Einheitsvektor, sonst orthogonal (cos 1.0 / 0.0)."""
@staticmethod
def available():
return True
@staticmethod
def embed_sims(texts):
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 @ arr.T
async def _cross_env(db, tmp_path):
flow = Flow(TOPIC, work_dir=tmp_path)
cards = []
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Nur in Alpha"]),
("beta", ["Gleiche Aussage", "Nur in Beta"])):
payload = {"title": bnorm.title(),
"raw": {bnorm.title(): list(subs)},
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": [f"kp {s}"]} for s in subs}}}
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
await _seed_block(db, bnorm, subs)
cards.append({"card_id": bnorm, "payload": payload})
return flow, cards
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
assert "Gleiche Aussage" in fake.calls[0]["prompt"]
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["stage"] == "question_pattern"
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
assert blocks._norm_title("Gleiche Aussage") not in beta["payload"]["facts"]["Beta"]
# Barriere liegt jetzt hinter levels/relevance → auch die sidecar muss den Fold tragen
assert [e["title"] for e in beta["payload"]["sidecar"]["Beta"]] == ["Nur in Beta"]
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["stage"] == "question_pattern"
assert alpha["payload"]["raw"]["Alpha"] == ["Gleiche Aussage", "Nur in Alpha"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "variant"
async def test_crossblock_tiebreaker_folds(testdb, tmp_path, monkeypatch):
"""j1/j2 uneinig → j3 entscheidet mit Mehrheit; hier „a" → Beta verliert."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "nein"}},
"j3": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
assert len(fake.calls) == 3
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
async def test_crossblock_dissent_without_tiebreaker_keeps_both(testdb, tmp_path, monkeypatch):
"""j3 liefert nichts (FAILED) → fail-open, Paar bleibt."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "b"}}}) # j3 fehlt → FAILED
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["stage"] == "question_pattern"
assert beta["payload"]["raw"]["Beta"] == ["Gleiche Aussage", "Nur in Beta"]
async def test_crossblock_ersatzrichter(testdb, tmp_path, monkeypatch):
"""Nur ein Richter liefert → Ersatz jE als zweite Stimme; Einstimmigkeit faltet."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
monkeypatch.setattr(ba, "embedding", _FakeEmb)
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "jE": {"pairs": {"1": "a"}}}) # j2 → FAILED
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypatch):
db = testdb
flow, cards = await _cross_env(db, tmp_path)
class _Aus:
@staticmethod
def available():
return False
async def kein_agent(*a, **kw):
raise AssertionError("ohne Embedding kein Judge")
monkeypatch.setattr(ba, "embedding", _Aus)
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
for cid in ("alpha", "beta"):
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
async def test_crossblock_context_wins(testdb, tmp_path, monkeypatch):
"""Kontext-Sub (Block schon hinter der Barrier) gewinnt auch bei Verdict „b"
die Paket-Seite fällt, der Kontext bleibt unangetastet."""
db = testdb
flow = Flow(TOPIC, work_dir=tmp_path)
payload = {"title": "Alpha", "raw": {"Alpha": ["Gleiche Aussage"]}, "facts": {"Alpha": {}}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "konsolidierung", payload)
await _seed_block(db, "alpha", ["Gleiche Aussage"])
cards = [{"card_id": "alpha", "payload": payload}]
# Kontext-Block "gamma" ist bereits weiter (Stage levels) und hält dieselbe Aussage
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "levels",
{"title": "Gamma", "raw": {"Gamma": ["Gleiche Aussage"]}, "facts": {}})
await _seed_block(db, "gamma", ["Gleiche Aussage"])
monkeypatch.setattr(ba, "embedding", _FakeEmb)
# Verdict „a": das Paket (A) soll behalten — Kontext faltet trotzdem nie
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["payload"]["raw"].get("Alpha", []) == [] # Paket-Seite gefaltet
gamma_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "gamma")}
assert gamma_rows[blocks._norm_title("Gleiche Aussage")] == "consensus" # Kontext unberührt
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"}
async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
"""Paare werden gechunkt beurteilt (ein Hänger blockiert nur noch seinen Chunk);
die Verdicts falten global über alle Chunks."""
db = testdb
flow = Flow(TOPIC, work_dir=tmp_path)
cards = []
for bnorm, subs in (("alpha", ["Gleiche Aussage", "Zweite gleiche Aussage", "Nur in Alpha"]),
("beta", ["Gleiche Aussage", "Zweite gleiche Aussage"])):
payload = {"title": bnorm.title(),
"raw": {bnorm.title(): list(subs)},
"sidecar": {bnorm.title(): [{"title": s, "level": "beginner"} for s in subs]},
"facts": {bnorm.title(): {blocks._norm_title(s): {"key_points": []} for s in subs}}}
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
await _seed_block(db, bnorm, subs)
cards.append({"card_id": bnorm, "payload": payload})
monkeypatch.setattr(ba, "embedding", _FakeEmb)
monkeypatch.setattr(ba, "CROSS_CHUNK_PAARE", 1) # 2 Paare → 2 Chunks
fake = _fake_slot({"j1": {"pairs": {"1": "a"}}, "j2": {"pairs": {"1": "a"}}})
monkeypatch.setattr(ba, "run_single_slot", fake)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"].get("Beta", []) == [] # beide Dubletten global gefaltet
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
"""Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
gesehen = {}
async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
sources=None, slim=False):
gesehen["raw"] = raw
gesehen["slim"] = slim
return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
"mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
{"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert
monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
assert n == 1
assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf
async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
"""Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
(eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
db = testdb
payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
"facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
{"key_points": ["kp"]}}}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
cards = [{"card_id": "alpha", "payload": payload}]
async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel
monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}

View File

@@ -0,0 +1,71 @@
"""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")
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_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

View File

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

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

@@ -0,0 +1,250 @@
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
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_verdacht_zaehlt_nicht():
"""dubletten_verdacht ist Verdachtsliste, kein Urteil — beeinflusst die Note nicht."""
assert qa.note({"dubletten_verdacht": 1.0}) == 10.0
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_verdicts(template, topic, key, items):
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, "_llm_verdicts", fake_verdicts)
report = await qa.qa_report("t", llm=True)
assert report["unecht"] == ["Wackelkandidat"]
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()

View File

@@ -0,0 +1,230 @@
"""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"}}', ""
monkeypatch.setattr(repair, "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):
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
monkeypatch.setattr(repair, "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")
monkeypatch.setattr(repair, "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")
monkeypatch.setattr(repair, "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"}}', ""
monkeypatch.setattr(repair, "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"}}', ""
monkeypatch.setattr(repair, "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_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")
monkeypatch.setattr(repair, "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)]

View File

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

View File

@@ -0,0 +1,444 @@
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
import json
import re
import numpy as np
import pytest
import blocks as blx
from pipeline import GenContext
TOPIC = "t"
_MD_PATH = re.compile(r"(/\S+\.md)")
# ── _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
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
def _fake_sims(texts):
"""Markertoken matrix: same first word → 0.95, else 0."""
n = len(texts)
m = np.eye(n)
key = lambda t: t.split()[0].casefold()
for i in range(n):
for j in range(n):
if i != j and key(texts[i]) == key(texts[j]):
m[i][j] = 0.95
return m
def _mk_race(finder_by_agent):
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
prompts = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
outs = []
for slot in slots:
key, prompt = slot["key"], slot["prompt"]
prompts.append((key, prompt))
fake_race.slots_seen.append(slot)
if "-subblock-final-" in key:
# no-tool judges reply as TEXT; the payload sink writes the j-file itself
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
if l.startswith("- ") and l != "- (keiner)"]
if subs:
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
outs.append(slot["payload"]((0, text, "")))
continue
if "-r1-" in key:
agent = int(key.rsplit("-", 1)[1])
subs = finder_by_agent.get(agent) or []
if subs and (m := _MD_PATH.search(prompt)):
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(text)
outs.append(slot["payload"](None))
outs = [o for o in outs if o]
return outs or None
fake_race.slots_seen = []
return fake_race, prompts
@pytest.fixture
def sub_env(testdb, tmp_path, monkeypatch):
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
monkeypatch.setattr(blx.embedding, "available", lambda: True)
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
files = {"arbeit": tmp_path}
return testdb, ctx, files
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
fake, prompts = _mk_race(finder_by_agent)
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", seeds=seeds)
return raw, prompts
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
nicht als „Unsicher" ins Panel."""
db, ctx, files = sub_env
raw, prompts = await _run(ctx, files, monkeypatch, {
1: ["Umbruch braucht Marker"],
2: ["Umbruch erfordert explizite Marker!"],
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
})
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
rows = await db.list_subblocks(TOPIC, "alpha")
status = sorted(r["status"] for r in rows)
assert status == ["consensus", "variant", "variant"]
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
async def test_seed_promotes_single_find(sub_env, monkeypatch):
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
2: ["Alpha Grundlagen"],
}, seeds=["Zeilenumbruch Regeln"])
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
if r["sub_norm"] == "zeilenumbruch regeln im detail")
assert row["status"] == "consensus"
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
}, seeds=["Fußnoten Syntax"])
assert "Fußnoten Syntax" in raw["Alpha"]
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
if r["sub_title"] == "Fußnoten Syntax")
assert row["status"] == "consensus"
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
}, seeds=["Tabs"])
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
db, ctx, files = sub_env
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert first == second
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
Konsens wächst; zweite Runde ohne Neues → Ende."""
db, ctx, files = sub_env
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
base = fake
hit = {"n": 0}
async def with_catchup(topic, label, slots, *a, **k):
if any("-subblock-x" in s["key"] for s in slots):
hit["n"] += 1
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
return [slot["payload"](None) for slot in slots[:2]]
return None
return await base(topic, label, slots, *a, **k)
monkeypatch.setattr(blx, "_race", with_catchup)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
assert (tmp_path / "subblock-final-c1-x1.md").exists()
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
# ── 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]
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
db, ctx, files = sub_env
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
async def with_r2(topic, label, slots, *a, **k):
if any("-r2-" in s["key"] for s in slots):
outs = []
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
outs.append(slot["payload"](None))
return outs
return await base_fake(topic, label, slots, *a, **k)
monkeypatch.setattr(blx, "_race", with_r2)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
assert raw["Alpha"] # Konsens steht
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
rows = await db.list_subblocks(TOPIC, "alpha")
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
db, ctx, files = sub_env
_, prompts = _mk_race({})
async def endless(topic, label, slots, *a, **k):
if "-subblock-final-" in slots[0]["key"]:
return None # panel fails → consensus fallback
outs = []
import re as _re
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
n = rn.group(1) if rn else "x"
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
prompts.append((slot["key"], slot["prompt"]))
outs.append(slot["payload"](None))
return outs
monkeypatch.setattr(blx, "_race", endless)
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
# ── Inline-Evidenz für Judges (Token-Umbau) ──────────────────────────────────────────
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"
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: blx._levels_schema(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()
async def test_clarify_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Mit Korpus: Judges bekommen Auszüge inline und laufen ohne Tools (Text-Antwort);
die j-Datei schreibt die Engine. Finder bleiben unverändert bei capabilities=files."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
monkeypatch.setattr(blx, "load_source", lambda t: {"type": "uni"})
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", sources=["Skript.txt"])
assert raw == {"Alpha": ["Alpha Grundlagen"]}
judges = [s for s in fake.slots_seen if "-subblock-final-" in s["key"]]
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert judges and all(s["capabilities"] == "none" for s in judges)
assert "── Skript.txt" in judges[0]["prompt"]
assert "ls/find" not in judges[0]["prompt"] # keine Selbst-Recherche-Anweisung mehr
assert finders and all(s["capabilities"] == "files" for s in finders)
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
async def test_facts_check_inline_cited_evidence(sub_env, monkeypatch, tmp_path):
"""Facts-Check: zitierte Zeilenbereiche gehen inline mit, Judge läuft ohne Tools,
die Check-Datei schreibt die Engine aus der Text-Antwort."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
sh = blx._subs_hash({"Alpha": ["Sub Eins"]}) # Resume-Dateien tragen den Sub-Satz-Hash
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
if "-facts-erg-" in key:
return blx.FAILED, None
(tmp_path / f"facts-{sh}-c0.json").write_text(json.dumps(facts), encoding="utf-8")
return blx.OK, None
seen = []
async def fake_agent(key, prompt, timeout, provider="", role="", capabilities="", scope=None, label="", **kw):
seen.append((key, capabilities, prompt))
return (0, '{"ok": true}', "")
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
monkeypatch.setattr(blx, "run_agent", fake_agent)
res = await blx._facts_block(ctx, lambda *a, **k: None, {"arbeit": tmp_path},
{"Alpha": ["Sub Eins"]}, {"type": "uni"}, d, "", ns="x-")
assert res is not None
facts_map, discarded = res
assert "Alpha" in facts_map and not discarded
assert len(seen) == blx.FACTS_CHECK_PANEL
key, caps, prompt = seen[0]
assert caps == "none" and "── Skript.txt · Z." in prompt
assert (tmp_path / f"facts-check-{sh}-c0-j1.json").exists() # Engine persistiert die Antwort
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"}]})

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

@@ -0,0 +1,162 @@
"""Training-Harness: Registry↔config, ENV-Override, ACO-Trainer (Stub-Runner), Soll-Abgleich."""
import json
import subprocess
import sys
from pathlib import Path
import config
import train_params
from train import AmeisenTrainer, score
from train_lauf import soll_abgleich
BACKEND = Path(__file__).resolve().parent.parent
def test_registry_spiegelt_config():
"""Jeder Registry-Parameter existiert in config mit identischem Default, flow-sicheren
Rändern und einer Fidelity-Zuordnung — sonst optimiert der Trainer Phantome."""
for name, p in train_params.PARAMS.items():
assert getattr(config, name, None) == p["default"], name
assert p["min"] <= p["default"] <= p["max"], name
assert p["step"] > 0, name
assert p["fidelity"] in ("board2", "voll"), name
def test_creator_params_override_wirkt_im_subprozess():
out = subprocess.run(
[sys.executable, "-c", "import config; print(config.FACTS_CHUNK_SUBS, config.TIMEOUTS['subblock_check'][0])"],
capture_output=True, text=True, cwd=BACKEND,
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"FACTS_CHUNK_SUBS": 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 (FACTS_CHUNK_SUBS=6) 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("FACTS_CHUNK_SUBS") == 6 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=120)
best = await t.run()
assert best.get("FACTS_CHUNK_SUBS") == 6
taus = t.pheromon["FACTS_CHUNK_SUBS"]
assert max(taus, key=lambda k: taus[k]) == "6"
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("FACTS_CHUNK_SUBS") == 6:
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("FACTS_CHUNK_SUBS") != 6
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("FACTS_CHUNK_SUBS") == 6 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["FACTS_CHUNK_SUBS"])
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["FACTS_CHUNK_SUBS"] == tau
async def test_budget_stoppt(tmp_path):
runner, f0 = _stub(lambda p: _metrics())
t = _trainer(tmp_path, runner, f0, max_trials=4)
await t.run()
assert len(runner.calls) <= 4
def test_score_richtungen():
basis = _metrics()
assert score(_metrics(note=9.0), basis) > score(basis, basis)
assert score(_metrics(dauer=20.0, tokens=2_000_000), basis) < score(basis, basis)
assert score(_metrics(fremd=0.2, luecken=0.1), basis) < score(basis, basis)
mit_soll = dict(_metrics(), soll={"f1": 1.0})
ohne_soll = dict(_metrics(), soll={"f1": 0.5})
assert score(mit_soll, basis) > score(ohne_soll, basis)
def test_soll_abgleich():
soll = {"bloecke": [{"titel": "Symmetrische Verschlüsselung"},
{"titel": "Asymmetrische Verschlüsselung",
"alternativen": ["Public-Key-Kryptographie"]},
{"titel": "Digitale Signaturen"}]}
r = soll_abgleich(["Symmetrische Verschlüsselung", "Public-Key-Kryptographie", "Quantencomputer"], soll)
assert r["fehlend"] == ["Digitale Signaturen"]
assert r["extra"] == ["Quantencomputer"]
assert 0 < r["f1"] < 1
async def test_copy_topic_dupliziert_karten_und_bloecke(testdb):
db = testdb
await db.kanban_upsert_card("q", "inventory", "b1", "block", "done_block", {"title": "Alpha"})
await db.upsert_block("q", "alpha", "Alpha", "Beschreibung", ["s1"], "r1")
await db.copy_topic("q", "z")
karten = await db.kanban_cards("z")
assert [c["card_id"] for c in karten] == ["b1"]
bloecke = await db.list_blocks("z")
assert [b["title"] for b in bloecke] == ["Alpha"]

View File

@@ -26,6 +26,15 @@ def _title(entry: str) -> str:
return entry.split("")[0].strip() or entry
def clean_title(s: str) -> str:
"""Strip markdown noise from a DISPLAY title (norm keys use _norm_title).
Only clearly-markdown characters go: `**` pairs and backticks. Single `*`,
underscores and pipes stay — they are legitimate in math titles
(„2|prec, pi∈{1,2}|Cmax", „x_i", „P*")."""
s = (s or "").replace("**", "").replace("`", "")
return re.sub(r"\s+", " ", s).strip()
def _unique_title(entries: dict[int, str]) -> dict[int, str]:
"""Make titles unique (suffix " (2)", " (3)" …) so they work as keys."""
seen: dict[str, int] = {}
@@ -56,11 +65,14 @@ def _resolve_title(idx: dict[str, int], t: str) -> int | None:
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 "1215" untouched."""
return re.sub(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*", "", s)
def _parse_selection(text: str) -> dict[int, str]:

342
backend/train.py Normal file
View File

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

59
backend/train_f0.py Normal file
View File

@@ -0,0 +1,59 @@
"""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 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="claude", 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 tests.invarianten import pruefe_invarianten
fehler = await pruefe_invarianten("f0", files)
atomic_write_json(Path(out), {
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),
"dauer_s": round(time.monotonic() - start, 1)}, indent=1)
await database.close_db()
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Nutzung: python3 train_f0.py <ausgabe.json>")
asyncio.run(f0(sys.argv[1]))

127
backend/train_lauf.py Normal file
View File

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

56
backend/train_params.py Normal file
View File

@@ -0,0 +1,56 @@
"""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"},
# Subbausteine
"SUBBLOCK_CHUNK": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"},
"SUBBLOCK_MIN": {"default": 5, "min": 2, "max": 10, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
"SUBBLOCK_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
"SUBBLOCK_EXTRA_ROUNDS": {"default": 2, "min": 0, "max": 4, "step": 1, "kategorie": "auswahl", "fidelity": "board2"},
"SUBBLOCK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
# Facts / Artefakte / Fragen
"FACTS_CHUNK_SUBS": {"default": 10, "min": 4, "max": 20, "step": 2, "kategorie": "laufzeit", "fidelity": "board2"},
"FACTS_CHECK_PANEL": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"QUESTION_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
"QUESTION_MAX_ROUNDS": {"default": 3, "min": 1, "max": 5, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"ARTEFACT_CHUNK_SUBS": {"default": 25, "min": 10, "max": 50, "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"},
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
# Guide
"MAX_WRITER_ROUNDS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"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"},
"EVIDENCE_BUDGET_CHARS": {"default": 48000, "min": 16000, "max": 64000, "step": 8000, "kategorie": "tokens", "fidelity": "board2"},
"QUELLE_RELEVANZ_CHUNK": {"default": 12, "min": 6, "max": 24, "step": 3, "kategorie": "laufzeit", "fidelity": "voll"},
}
def schritte(name: str) -> tuple[float, float]:
"""(wert_runter, wert_hoch) je einen step von default, an die Ränder geklemmt."""
p = PARAMS[name]
lo = max(p["min"], round(p["default"] - p["step"], 4))
hi = min(p["max"], round(p["default"] + p["step"], 4))
return lo, hi

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, 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([])
@@ -27,18 +27,14 @@ 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) {
@@ -169,13 +165,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) {
@@ -187,9 +179,7 @@ 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'
localStorage.setItem('lastTopic', topic)
loadBlocks()
@@ -212,24 +202,47 @@ async function handleResetBlocks() {
await loadBlocks()
}
async function handleResetFromStep(step) {
async function handleResetStage({ board, stage, restart = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
await apiResetBlocksStage(selectedTopic.value, board, stage)
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
if (restart) startPolling()
}
async function handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null }) {
async function handleAddResearch() {
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)
await apiAddResearch(selectedTopic.value, provider.value)
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
startPolling()
}
async function handleRequeueDead() {
if (!selectedTopic.value) return
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
await loadBlocks()
startPolling()
}
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
} catch (e) {
uiError.value = e.message
return
@@ -248,6 +261,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,14 +292,14 @@ function handleOpenBlocksView() {
previewGuide.value = null
}
async function handleFormatClick({ format, instructions, abStep = null }) {
async function handleFormatClick({ format, instructions = '', abStep = null }) {
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)
@@ -293,10 +307,68 @@ async function handleFormatClick({ format, instructions, abStep = null }) {
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()
}
async function handleRestartCard(cardId) {
uiError.value = null
try {
await apiRestartBlocksCard(selectedTopic.value, cardId)
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
} catch (e) {
uiError.value = e.message
}
}
async function handleResetGuideCard({ format, blockNorm, abStage }) {
uiError.value = null
try {
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
} catch (e) {
uiError.value = e.message
}
}
async function handleGuideBoardReset({ format, abStage }) {
uiError.value = null
try {
await apiResetGuideBoard(selectedTopic.value, format, abStage)
} catch (e) {
uiError.value = e.message
}
}
function handleGuideBoardPreview() {
const g = doneByFormat.value[guideBoardFormat.value]
if (g) handlePreview(g)
}
function handlePreview(guide) {
previewGuide.value = guide
mainView.value = 'detail'
@@ -308,20 +380,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
}
@@ -374,8 +446,6 @@ onMounted(async () => {
:selectedTopic="selectedTopic"
:stats="stats"
:fortschritt="progress"
:locks="locks"
:guideStepsDone="guideStepsDone"
:uiError="uiError"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
@@ -395,44 +465,54 @@ 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="() => handleBlocksClick({ abPhase: blocks.ready ? 1 : null })"
@openGeneration="handleOpenGeneration"
/>
<GenerationView
v-else-if="selectedTopic && mainView === 'generation'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
:guideFormat="guideBoardFormat"
@close="mainView = 'blocks'"
@resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
@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"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"
@@ -441,13 +521,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 +541,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 +694,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>

View File

@@ -18,16 +18,6 @@ export async function fetchGuides() {
return res.json()
}
export async function fetchGuideSteps(topic) {
const res = await fetch(`${BASE}/guides/steps?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function fetchGuideLocks(topic) {
const res = await fetch(`${BASE}/guides/locks?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
const res = await fetch(`${BASE}/guides`, {
method: 'POST',
@@ -47,20 +37,100 @@ export async function fetchBlocksStatus(topic) {
return res.json()
}
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null) {
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) {
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 }),
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce }),
})
return jsonOrThrow(res)
}
export async function resetBlocksFromStep(topic, abStep) {
const res = await fetch(`${BASE}/blocks/reset-step`, {
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
export async function fetchBlocksBoard(topic) {
const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
export async function runQa(topic, llm = true) {
const res = await fetch(`${BASE}/blocks/qa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, ab_step: abStep }),
body: JSON.stringify({ topic, llm }),
})
return jsonOrThrow(res)
}
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge).
export async function runRepair(topic) {
const res = await fetch(`${BASE}/blocks/repair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic }),
})
return jsonOrThrow(res)
}
// Karten ab Spalte zurücksetzen (keine Generierung).
export async function resetBlocksStage(topic, board, stage) {
const res = await fetch(`${BASE}/blocks/reset-stage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, board, stage }),
})
return jsonOrThrow(res)
}
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
export async function addBlocksResearch(topic, provider = 'claude') {
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function restartBlocksCard(topic, cardId) {
const res = await fetch(`${BASE}/blocks/card-restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, card_id: cardId }),
})
return jsonOrThrow(res)
}
export async function removeGuideFormat(topic, format) {
const res = await fetch(`${BASE}/guides/board/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) {
const res = await fetch(`${BASE}/guides/board/card-reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
export async function requeueBlocksDead(topic) {
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
return jsonOrThrow(res)
}
// Live-Board der Guide-Erzeugung.
export async function fetchGuideBoard(topic, format = 'Guide') {
const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`)
return jsonOrThrow(res)
}
export async function resetGuideBoard(topic, format, abStage) {
const res = await fetch(`${BASE}/guides/board/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
@@ -142,6 +212,11 @@ export async function updateSource(topic, { type, ort = '', spec = '' }) {
return jsonOrThrow(res)
}
export async function fetchBlocksCompleteness(topic) {
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchBlocksOverview(topic) {
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
@@ -161,11 +236,19 @@ export async function fetchGuideContent(id, level = 4) {
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()
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
export async function fetchPracticeDeck(topic) {
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Leitner-Schritt buchen (correct = „Gewusst").
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
const res = await fetch(`${BASE}/practice/answer`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
})
return jsonOrThrow(res)
}
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
@@ -220,68 +303,3 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
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()
}

View File

@@ -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 } 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 },
@@ -199,10 +196,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
</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>

View File

@@ -1,91 +1,65 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { fetchBlocksOverview } from '../api.js'
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.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'])
// 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
}
function regenerateFromHere() { const from = startSel.value, to = endSel.value; clearSel(); emit('restartFrom', { from, to }) }
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).
let timer = null
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
onUnmounted(stopPoll)
// ── 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) }))
@@ -105,48 +79,36 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<button class="bk-close" title="Close" @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')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</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 danger" @click="emit('cancel')">Cancel</button>
</div>
</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"> regenerate</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="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
<div v-else class="bk-grid">
@@ -182,10 +144,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;
@@ -209,21 +175,6 @@ 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;
@@ -233,71 +184,64 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
}
@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; }
.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;
@@ -309,7 +253,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));
@@ -324,7 +267,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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -0,0 +1,343 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.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 },
guideFormat: { type: String, default: 'Guide' },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
const board = ref(null)
let timer = null
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ }
}
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen
}, { immediate: true })
onUnmounted(stopPoll)
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)
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
confirm.value = null
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
function restartCard() {
const k = selCard.value
selCard.value = null
confirm.value = null
later(() => emit('restartCard', k.card_id))
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
}
const qaBusy = ref(false)
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try {
await runQa(props.topic)
} finally {
qaBusy.value = false
pollBoard()
guideRefresh.value++
}
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
if (repairBusy.value) return
repairBusy.value = true
repairInfo.value = ''
try {
const r = await runRepair(props.topic)
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0)
repairInfo.value = 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`
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
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="Close" @click="emit('close')"></button>
</header>
<div v-if="qa && qa.pausiert" class="qa-pause">
<strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} pausiert.</strong>
<span v-if="qa.befunde.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
</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>
<div v-if="!generating" class="gen-actions">
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
@click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="repairBusy"
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
@click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
<button
v-if="ready || partial"
class="gen-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => later(() => emit('removeAll')))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
</div>
<button
v-if="dead.length"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button>
</div>
<div class="gen-board-label">
Inventar
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="gen-board-label">
Artefakte
<span v-if="qa && qa.note_artefakte != null" class="qa-note"
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<section class="gen-section">
<GuideBoardSection
:topic="topic"
:format="guideFormat"
:refresh="guideRefresh"
@cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')"
@removeFormat="(f) => emit('removeFormat', f)"
@resetCard="(p) => emit('resetGuideCard', p)"
/>
</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 {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 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: gen-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.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-step-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; 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.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; }
.gen-act.ghost { color: var(--text-muted); }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
}
.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); }
.qa-pause {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.5rem 0.8rem;
margin-bottom: 0.8rem;
border: 1px solid color-mix(in srgb, #ef4444 40%, transparent);
border-radius: 8px;
background: color-mix(in srgb, #ef4444 8%, transparent);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.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
})
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
const board = ref(null)
let timer = null
async function poll() {
try {
board.value = await fetchGuideBoard(props.topic, props.format)
} catch { /* Board noch leer */ }
}
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
watch(() => props.refresh, () => poll())
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
onUnmounted(stopPoll)
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)
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
const sel = ref(null)
const selCard = ref(null)
const confirm = ref(null)
function stageClick(c) {
if (generating.value || !STAGES.includes(c.key)) return
confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
}
function cardClick(k) {
if (generating.value || !k.card_id) return
confirm.value = null
sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
}
function resetCardHere() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400)
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function restartHere() {
const s = sel.value
sel.value = null
emit('startGuide', { format: props.format, abStep: s.idx })
startPoll()
}
function resetHere() {
const s = sel.value
sel.value = null
emit('resetStage', { format: props.format, abStage: s.idx })
setTimeout(poll, 400)
}
</script>
<template>
<section class="gb-board">
<div class="gb-top">
<span class="gb-title">Guide · {{ format }}</span>
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'"
title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div class="gb-actions">
<template v-if="generating">
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template>
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
</template>
</div>
</div>
<KanbanBoard
:columns="columns"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.key || null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
<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; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
.gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-qa {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
}
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.gb-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.gb-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gb-pulse 1.2s ease-in-out infinite;
}
@keyframes gb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gb-error { color: var(--danger); font-size: 0.82rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gb-stage-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.gb-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;
}
.gb-act:hover { border-color: var(--accent); }
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gb-act.play:hover { background: var(--accent-hover); }
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gb-act.danger.armed { background: var(--danger); color: #fff; }
.gb-act.ghost { color: var(--text-muted); }
</style>

View File

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

View File

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

View File

@@ -1,32 +1,18 @@
<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, viewLevelFuer } 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')
}
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 +25,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 +32,37 @@ 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)
const l = learnstate.value[title]
return viewLevelFuer(l?.good_answers || 0, l?.cap || 10)
}
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' && lvl > 1 && rank === lvl) {
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
} else {
parts.push(body)
}
}
h = parts.join('')
}
mdCache.set(key, h)
return h
}
@@ -80,7 +85,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 +96,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 +105,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')
@@ -209,12 +211,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 +300,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,7 +326,6 @@ function extractContext() {
<BlockFocus
v-if="focusBlock"
:block="focusBlock"
:artefakte="artifactsFor(focusBlock.title)"
:topic="previewGuide.topic"
:guide-id="previewGuide.id"
:provider="provider"
@@ -354,9 +345,9 @@ 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>
@@ -640,23 +631,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;
@@ -790,4 +764,25 @@ function extractContext() {
.chat-input button.cancel {
background: var(--danger);
}
/* Neu freigeschaltete Subbausteine (Auto-Stufe): dezenter Rand + Stufen-Badge */
.sub-neu {
position: relative;
border-left: 3px solid var(--neu-farbe);
padding-left: 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;
}
</style>

View File

@@ -8,8 +8,6 @@ const props = defineProps({
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 +24,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 +57,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 +84,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 +99,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 +112,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 +125,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: '' })
@@ -362,7 +287,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 +297,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 +304,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 +321,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'"
@@ -450,9 +337,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>
@@ -738,46 +625,12 @@ function saveSource() {
cursor: pointer;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
/* Coarse phases as numbered pills (15) — 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; }
@@ -799,6 +652,16 @@ 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: gen-side-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ord-blocks {
order: 2;
}
@@ -807,7 +670,7 @@ function saveSource() {
order: 4;
}
.ord-elemente {
.ord-practice {
order: 5;
}
@@ -904,34 +767,7 @@ 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); }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,19 @@ export function malusRegel(score, cap) {
if (pct <= 0.75) return '15'
return '20'
}
// 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)',
}
// Auto view level per block: reached learning level + 1 (nothing reached → A).
// beginner → F unlocked, advanced → E, expert/master → V.
export function viewLevelFuer(score, cap) {
const s = stufeFuer(score, cap)
if (!s) return 1
return Math.min(4, LEVELS.findIndex((l) => l.key === s.key) + 2)
}

View File

@@ -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) {

View File

@@ -17,7 +17,7 @@ Rules:
- **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:
Reply with ONLY the JSON — no other text, no code fences — one of the two:
{{"ok": true}}
{{"problems": [{{"index": 2}}, {{"index": 5}}]}}

View File

@@ -14,7 +14,7 @@ A good worked example:
Write `problem`, the `steps` and `result` in GERMAN.
Write all examples as ONE JSON into the file {out_path} (use your write tool), EXACTLY like this:
Reply with ONLY the JSON (all examples) as your final message — no code fences, do NOT write a file. EXACTLY like this:
{{"examples": [
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
"problem": "…", "steps": ["…", "…"], "result": "…"}}

View File

@@ -13,7 +13,7 @@ A good flashcard:
Write `question` and `answer` in GERMAN.
Write all cards as ONE JSON into the file {out_path} (use your write tool), EXACTLY like this:
Reply with ONLY the JSON (all cards) as your final message — no code fences, do NOT write a file. EXACTLY like this:
{{"cards": [
{{"block": "<exact block title>", "subblock": "<exact subblock title>",
"question": "…", "answer": "…"}}

View File

@@ -1,20 +0,0 @@
Below are numbered block candidates for the topic "{topic}". They come from a similarity cluster. Some refer to the SAME block or a property of it, others are distinct. Group them.
CANDIDATES:
{entries}
Rules:
- Form groups: numbers that belong to the SAME block go into ONE group.
- **Watch the core entity** (the problem/object): Clique, Vertex Cover, Set Cover, Knapsack, Dominating Set, LPT/List Scheduling … Different entity → different groups, even with similar phrasing ("Lower Bound Clique" ≠ "Lower Bound Vertex Cover").
- True paraphrases go TOGETHER, even when worded differently ("List Scheduling" = "LPT-Algorithmus"; "Set Cover" = "Mengenüberdeckung").
- **A problem's properties belong TO the problem block — not on their own.** Bundle with the problem: its complexity status (∈ NP, NP-schwer, NP-vollständig), its verifier / certificate / NDTM, "… als Sprache / Definition", its individual lower-bound parameters (k / r / |U|).
- Example: "Knapsack", "Knapsack ∈ NP", "Knapsack NP-schwer", "Knapsack NP-vollständig", "Verifizierer für Knapsack" → ONE group (the "Knapsack" block).
- Example: "Hitting Set Lower Bound (k)", "(r)", "(|U|)" → ONE group.
- Keep SEPARATE (own blocks): different problems (Clique-Member ≠ Clique-Nomember); a REDUCTION between two problems is its own technique (e.g. "3-SAT ⪯ k-Clique" stays separate); different methods/theorems with their own statement.
- When in doubt between two different problems → SEPARATE. For a problem + its property → BUNDLE.
- EVERY number goes into EXACTLY ONE group. A standalone block is a group with one element.
Write ONLY the JSON file to: {out_path}
Format (lists of candidate numbers; each number exactly once):
{{"groups": [[1, 3], [2], [4, 5]]}}

View File

@@ -0,0 +1,29 @@
The FINAL block inventory for the topic "{topic}" was assembled from several sources. Despite earlier filtering it can still carry duplicates: the same concept listed under two names. For EACH pair below, decide: do A and B denote the SAME block → **ja**, or TWO DIFFERENT blocks → **nein**?
THE MOST COMMON ERROR is merging a named VARIANT with its base entity. A variant is NEVER its base: "3-X" ≠ "X", "Max-X" ≠ "Max-3-X", "Modified X" ≠ "X", "k-X" ≠ "X". A digit or qualifier prefix that restricts the entity makes it a DIFFERENT entity → **nein**.
PAIRS:
{pairs}
## How to decide (per pair)
**STEP 1 — Name the CANONICAL ENTITY of each side.** Strip catalogue/reference additions („Definition 6.19", „Satz 7.8", „Kapitel 3"), parenthesized qualifiers that only restate or explain the name („X (full spelling)", „X (Problem)", „X-Problem"), spelling/spacing/hyphenation variants, translations of the same name, and genitive/apostrophe variants. „X" and „X (long form of X)" share one canonical entity.
**STEP 2 — Same entity, or different?**
- **SAME canonical entity → ja**, even when A and B emphasize DIFFERENT FACETS: formal definition vs. property vs. mechanism vs. characterization vs. naming variant. Two blocks about the same entity from different sources are duplicates.
- **DIFFERENT canonical entity → nein**, however similar the wording:
- a named **variant, special case or modification** is its own entity („X" ≠ „Modified X", „3-X" ≠ „X");
- a **relation between two entities** (reduction, implication, comparison, mapping) is individuated by BOTH operands AND the direction. Same words, swapped direction → DIFFERENT. One shared operand, other operand differs → DIFFERENT. A relation is never a duplicate of one of its operands.
- a different parameter, restriction or scope is a different entity.
**STEP 3 — „When in doubt → nein"** applies only when STEP 1 is genuinely ambiguous. Differing descriptions of the same entity are still **ja**.
## Examples (example domain: graph theory — the rules hold for any topic)
- A: „SAT" B: „SAT (Satisfiability Problem)" → same entity, naming only → **ja**
- A: „P (Definition 6.20)" B: „P (Polynomialzeit)" → both = class **P**, definition vs. characterization → **ja**
- A: „Hamiltonian Cycle ≤ Hamiltonian Path" B: „Hamiltonian Path ≤ Hamiltonian Cycle" → same words, opposite direction → **nein**
- A: „Greedy-Algorithmus" B: „Modifizierter Greedy-Algorithmus" → base vs. named variant → **nein**
Write ONLY the JSON file to: {out_path}
Format (each pair number from the list with „ja" or „nein"; no other text in the file):
{{"pairs": {{"1": "ja", "2": "nein"}}}}

View File

@@ -0,0 +1,25 @@
Topic "{topic}". You are the SECOND OPINION of a filter pass. Each entry below was either FLAGGED as a likely **fragment** (a property, sub-form, detail, or notation that belongs to another block) or PROPOSED for demotion by a first judge. Re-judge each one independently and carefully — real fragments SHOULD be demoted or dropped, but never sacrifice a genuine standalone concept.
RE-JUDGE THESE (by their number):
{survivors}
FULL BLOCK LIST (context — to find a parent number):
{list}
## Decide each entry → one of three
- **demote (→ parent number):** it presupposes another block as its subject — a property/status („X ist NP-vollständig", „X ist optional"), a **sub-form/variant** of a base entry („ATX-Überschrift" → Überschriften), a **bound/güte/runtime facet**, a **bare theorem/remark** about X, a **proof-example/gadget**, a proof-variable. Put `{{"<nr>": <parent-nr>}}` in `fragments`.
- **drop:** pure exercise/reference scaffolding with NO real content and NO parent — a bare label („Remark 7.28", „Satz D*"), a one-off notation assignment („r = n + m"). Put its number in `drop`.
- **keep:** it IS a self-contained concept. Do NOT touch it. (Just omit it.)
## KEEP-guards — these are real blocks, never demote/drop them
- **Similarity is NOT containment:** an element with its own syntax/definition and its own purpose is a SIBLING of its neighbours, not their part — even with similar syntax, the same category, or shared context (a blockquote is not part of a code block; a footnote is not part of a task list). KEEP.
- A **named theorem WITH its own statement or an author**: „Satz 6.24 Cook/Levin — SAT ist NP-vollständig". KEEP.
- A **fundamental (in)equality / open question** of the field: „P = NP?", „NL = coNL". KEEP.
- Anything headed „**Definition**", a **problem**, an **algorithm/method**, a **relation between two named things** („3-SAT ≤ Clique"). KEEP.
Judge by the CONTENT (after „—"), not the label. When unsure whether something is a fragment or a concept: demote only if the entry clearly makes a statement ABOUT its parent or is a form OF it; otherwise → keep (never drop on doubt).
Write ONLY the JSON file to: {out_path}
Format (both keys; each may be empty):
{{"fragments": {{"3": 17, "9": 41}}, "drop": [12]}}

View File

@@ -8,32 +8,43 @@ JUDGE the numbers **{from_n} to {to_n}** — go through them **ONE BY ONE**, one
## Procedure per entry (mandatory for EACH one)
For each entry {from_n}{to_n}:
1. What is the **subject**? (What is being talked about?)
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PROOF PART, a NOTATION, or a RUNTIME DETAIL about it?
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PART, a SUB-FORM, or a DETAIL of it?
- **Yes → fragment**, parent = the number of that subject.
- No, it stands on its own → block (keep).
Lines marked with **⚠** are suspected cases (property/runtime/notation) — check them especially carefully. Decide by the content, not by the marking.
Lines marked with **⚠** are suspected cases (property/detail/notation) — check them especially carefully. Decide by the content, not by the marking.
## What is a BLOCK (standalone learning unit — keep)?
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject.
- A **problem**: „3-SAT", „Clique", „Knapsack", „Dominating Set".
- A **method/algorithm**: „LPT Scheduling", „Christofides", „FPTAS".
- A **definition/concept**: „NP", „Reduktion", „Verifizierer", „KNF".
- A **named theorem WITH its own statement**: „Cook-Levin: SAT ist NP-vollständig".
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject. A distinct element, concept, method, problem, or named theorem with its own statement stands on its own.
- **CRITICAL — similarity is NOT containment.** Two entries with similar syntax, related purpose, or the same category are SIBLINGS, not parent and part. A blockquote is not part of a code block just because both mark lines with a prefix; a footnote is not part of a task list just because both are extensions of the same standard. Demote ONLY when the entry makes a statement ABOUT the parent or is a form OF the parent — never because the two are alike or usually taught together.
- An element with its own syntax/definition and its own purpose is its own block, even if a bigger neighbour exists.
- Examples across domains: a **problem** („3-SAT", „Knapsack"), a **method/algorithm** („Christofides", „Quicksort"), a **definition/concept** („NP", „Reduktion", „Blockquote", „Directive"), a **named theorem WITH its own statement** („Cook-Levin: SAT ist NP-vollständig").
## What is a FRAGMENT (belongs to another block → demote)?
Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block.
- **Property/status** of a problem X (that is itself in the list): „X ist NP-vollständig", „X ∈ NP", „NP-Schwere von X", „Approximationsgüte von X". → parent = X.
- **Proof/reduction gadget**: „αEnde", „A-Komponente", „Dummy Items", „Schedule D*", „Knoten z", auxiliary variables. → parent = the theorem/reduction in whose proof it appears.
- **Pure notation/symbol**: „|x|", „Σ∗", „Güte 2". → parent = the defining definition.
- **Runtime/size detail**: „O(|V|⁴) Verifizierer-Laufzeit", „|V'| = |V| bei Reduktion", „Reduktion in O(|E|)". → parent = the algorithm/reduction.
- **Property/status of X** (X itself in the list): „X ist NP-vollständig", „X ∈ NP", „X ist optional", „Standard-Verhalten von X". → parent = X.
- **Parent named in the entry's OWN title:** if the title itself contains another block's name as its subject („Lower Bound für **VERTEX COVER**", „**List Scheduling** Güte", „Anker für **Überschriften**"), that named block IS the parent — demote to it. Do not keep such an entry just because you would scan the whole list; the parent is right there in the title.
- **Sub-form/variant of a base entry** that is itself in the list: „ATX-Überschrift" and „Setext-Überschrift" are forms of „Überschriften"; „Even-Knapsack" is an exercise-tweaked variant of „Rucksackproblem". → parent = the base entry. (A genuinely different concept with its own rules stays its own block — see the sibling rule above.)
- The following patterns are typical for THEORY-SCRIPT topics (use them when they fit, ignore them otherwise):
- **Bare theorem / proof reference**: „Satz 6.12: P ⊆ NP", „Beweis Satz 6.16 (⇒)", „Satz 7.20 (Sahni)" — a restated inclusion/membership or a bare „Satz N"/„Beweis …" is a proof detail. → parent = the object it is about.
- **Proof/reduction gadget or variable**: „αEnde", „A-Komponente", „Dummy Items", „Austausch-Argument". → parent = the theorem/reduction in whose proof it appears.
- **Bound/guarantee/runtime facet**: „ETH untere Schranke HITTING SET", „Güte 2 1/m", „O(|V|⁴) Verifizierer-Laufzeit", „Reduktion in O(|E|)". → parent = the problem/algorithm it bounds.
## What is an EXERCISE ARTEFACT (no concept at all → hard-drop)?
Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffolding that is neither a learnable concept nor a fragment of one AND has no parent in the list. These forms all count, no matter where the marker sits:
- a lettered OR **Roman-numbered** sub-claim, in any parenthesization: „Aussage (a): …", „(Aussage i)", „(Aussage ii)", „NP-schwer ≠ P (Aussage ii)", „Teil (b)", „Fall (2)";
- a bare sheet/task reference: „Blatt 10", „Aufgabe 3", „Übung 7.31";
- a worked-example / table / figure reference: „Scheduling Beispiel Tab. 7.1", „Beispiel 3.2", „Abbildung 4.5";
- a one-off framing with no standalone content.
Put its number in `drop`. NEVER drop anything that names a real concept/element/method/definition/theorem — if there is any doubt, keep it (or demote it as a fragment with a parent). A **named theorem WITH its own statement** is a real block, never an artefact. If it has a parent in the list, prefer demoting (fragment) over dropping.
## Rules
- A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it).
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/notation/proof part WITH a parent in the list IS a fragment — don't keep it out of caution.
- A standalone **reduction between two problems** is a block, NOT a fragment („3-SAT ≤ Clique").
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/sub-form/detail WITH a parent in the list IS a fragment — don't keep it out of caution.
- A standalone **relation between two named things** is a block, NOT a fragment („3-SAT ≤ Clique").
- A **named theorem WITH its own relational statement** — a biconditional/implication/reduction between two named objects — is a block; keep it even if it references other blocks. Only a BARE label with no statement, a unary status („X ist NP-vollständig"), or a bound/proof facet is a fragment.
- Judge by the CONTENT (after the „—"), not the title.
Write ONLY the JSON file to: {out_path}
Format (only the fragment numbers from {from_n}{to_n}, each with its parent number; `fragments` may be empty):
{{"fragments": {{"12": 5, "13": 5, "27": 19}}}}
Format — always include `fragments` (fragment number → parent number, may be empty); `drop` is the list of exercise-artefact numbers with NO parent (usually empty). Only numbers from {from_n}{to_n}:
{{"fragments": {{"12": 5, "13": 5, "27": 19}}, "drop": [17]}}

View File

@@ -0,0 +1,18 @@
Topic "{topic}". Umbrella blocks were formed, each bundling the constituent parts of ONE model/definition. Some parts were missed and are still listed as standalone blocks. Your job: for each umbrella, find which of the remaining standalone blocks are ALSO constituent parts of that same parent, so the model is complete.
UMBRELLAS (parent — already-collected parts):
{umbrellas}
REMAINING STANDALONE BLOCKS (numbered):
{rest}
## Rule — attach a block to an umbrella only if BOTH hold
1. **Presupposition:** the block's definition **requires the umbrella's parent to already exist** — it makes no sense as a topic on its own without that model (e.g. „Alphabet Σ", „Übergangsfunktion δ", „Konfiguration", „Akzeptierende Berechnung" all presuppose the Turing-machine; „Literale", „Klausel", „Belegung" presuppose the KNF/logic definition). The parent must NOT presuppose the block (directional).
2. **Not standalone:** the block is a *definitional component / notation*, NOT itself a named **algorithm, problem, theorem, reduction, or complexity class** (those stay their own block — a downstream guard will reject them anyway).
Do NOT attach a block merely because it shares a topic. When unsure → leave it standalone. Most remaining blocks will NOT be attached; a few genuine missed parts will.
Write ONLY the JSON file to: {out_path}
Format (`additions` may be empty; `umbrella` = the UMBRELLA index, `members` = standalone block numbers to attach):
{{"additions": [{{"umbrella": 0, "members": [8, 12, 34]}}]}}

View File

@@ -0,0 +1,34 @@
Topic "{topic}". A previous step produced a flat list of learning blocks that is TOO FINE-GRAINED — several blocks are **constituent sub-definitions / notation of ONE larger definition or model** and should become a single umbrella block. Find these groups. A good run finds several genuine umbrellas AND leaves most blocks standalone; judge each candidate on its merits.
**Propose generously.** A deterministic guard downstream rejects any umbrella that swallows a named algorithm/problem/theorem, so a wrong-but-plausible merge is cheap — a missed umbrella is not. Do NOT withhold a merge merely because the members are lexically dissimilar (facets of one model routinely are) or because you are unsure of the parent's exact name.
CANDIDATES (your starting point):
{candidates}
FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the same definition):
{list}
## Merge test — propose an umbrella when ALL THREE hold
1. **One parent.** The members are constituent parts/facets of ONE named parent model or definition — each member PRESUPPOSES that parent (you cannot introduce the member without first invoking the parent). TM-model parts (Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung) presuppose „Turingmaschine"; KNF parts (Literale, Klauseln, Boolesche Variablen) presuppose „Konjunktive Normalform".
2. **Studied together.** A learner meets them together as one unit.
3. **No standalone unit among them.** No member is itself a named **algorithm, problem, theorem, reduction/transformation, or complexity class** („List Scheduling", „3-SAT", „Cook/Levin", „3-SAT ≤ Clique", „NP", „NP-Vollständigkeit", „Polynomielle Transformation"). Each of those is its own concept, so a group containing one is a set of SIBLINGS, not the decomposition of one model — keep them separate.
*Note on test 1: „can this be defined at all?" is the WRONG question — Alphabet Σ and DTM CAN be stated in isolation, yet in THIS topic they are parts of the Turing-machine model and belong together. The question is whether the member PRESUPPOSES the shared parent, not whether a standalone sentence exists.*
## Examples
DO NOT MERGE — distinct named units that merely share a topic:
- „Greedy-Algorithmus GA" + „ModifiedGreedy" + „Multiple-Choice-Knapsack" → two algorithms + a problem, each standalone (test 3 fails). Keep separate.
MERGE — one definition decomposed (the canonical cases — end here so this is your default lens):
- „Alphabet Σ" + „NDTM" + „DTM" + „Akzeptierende Berechnung" + „Folgekonfiguration" → ONE umbrella **„Turingmaschine (Modell)"**. (The members are lexically very different from each other — that is EXPECTED for facets of one model and is NOT a reason to keep them apart.)
- „Klausel" + „Boolesche Variable" + „Erfüllende Belegung" + „KNF" → ONE umbrella **„Aussagenlogik & KNF"**.
## Synthesize each umbrella
- `title`: the parent concept's name (e.g. „Turingmaschine (Modell)"). A real self-contained definition; must NOT contain „ — " (a reserved separator) — use „(…)" or „:".
- `description`: **name EVERY merged child explicitly** — the next step recovers the children as sub-points from the source. E.g. „Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration."
- `members`: the block NUMBERS (from the full list) folded in. At least 2 per umbrella; each number appears in at most one umbrella.
Write ONLY the JSON file to: {out_path}
Format (`umbrellas` may be empty):
{{"umbrellas": [{{"title": "Turingmaschine (Modell)", "description": "Formales TM-Modell: Konfiguration, Übergangsfunktion δ, Alphabet Σ, Akzeptierende Berechnung, Folgekonfiguration.", "members": [1, 2, 5, 12, 34]}}]}}

View File

@@ -23,7 +23,7 @@ Examples:
- "Satz 7.18" (no description) → **verwerfen** (mere reference).
Rules:
- When in doubt about standalone-ness → lean toward including. Duplicates are removed separately later; here only this counts: real block or junk.
- Decide by the standalone Define test above: an entry that only makes sense INSIDE a specific proof, reduction, or spot in the script is NOT a block → discard it. Include only entries that stand on their own (a concept you could teach on its own); duplicates are removed separately later. This is single-mention material, so hold a firm bar — but never discard a genuine standalone concept hiding behind a reference title (see the rename rule above).
- Copy included entries VERBATIM ("Title — Kurzbeschreibung"), do not rephrase.{final}
Write ONLY the JSON file to: {out_path}

View File

@@ -0,0 +1,22 @@
The numbered entries below all describe the SAME block for the topic "{topic}".
CURRENT TITLE: {current_title}
MEMBERS:
{members}
Check whether the current title is the best canonical name for this block.
Rules:
- Current title good (concrete, precise, self-explanatory, no reference/placeholder like "Satz 7.18") → confirm it.
- Otherwise pick the best member ("best" REQUIRED) and optionally propose a SHORTER name: max 8 words, ONLY terms that appear in the members, exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention. NO catalog/reference brackets ("(Satz 6.33)"); a known acronym in brackets ("(VC)") is fine.
- When in doubt, confirm the current title.
Reply with ONLY the JSON — no code fences, no other text.
Format:
{{"ok": true}}
or
{{"best": 2}}
or
{{"best": 2, "name": "Kurzer kanonischer Titel"}}

View File

@@ -0,0 +1,16 @@
The numbered entries below all describe the SAME block (concept) for the topic "{topic}", just worded differently. Pick the ONE entry whose title is the best canonical name for this block — and optionally propose a SHORTER canonical name if none of the titles states the shared core precisely.
MEMBERS:
{members}
Rules:
- "best" is REQUIRED: the member whose title fits the shared concept best (most concrete, precise, self-explanatory, established term; avoid reference/placeholder titles like "Satz 7.18", "Punkt 3", "(**)").
- "name" is OPTIONAL — set it ONLY when no member title states the shared core well. It must be SHORT (max 8 words), use ONLY terms that appear in the members themselves, and stay exactly as concrete as the shared content — never a broader umbrella term, never a textbook concept the members don't mention.
- In "name": NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"); a known acronym in brackets ("(VC)") is fine.
Reply with ONLY the JSON — no code fences, no other text.
Format (chosen member number, optional short name):
{{"best": 1}}
or
{{"best": 1, "name": "Kurzer kanonischer Titel"}}

View File

@@ -1,20 +1,36 @@
Two research passes have noted blocks for the topic "{topic}". For EACH pair, decide whether A and B denote the SAME block (the same concept, just worded differently) → **ja**, or whether they are TWO DIFFERENT blocks → **nein**.
Two research passes noted blocks for the topic "{topic}" as "Title — description". For EACH pair, decide: do A and B denote the SAME block **ja**, or TWO DIFFERENT blocks → **nein**?
PAIRS:
{pairs}
Rules:
- **Watch the CORE ENTITY first** (the problem/object in question): Clique, Vertex Cover, Independent Set, Dominating Set, Set Cover, FVS, Knapsack … If the entities are DIFFERENT → **nein**, no matter how identical the phrasing.
- Identical phrasing is deceptive. These pairs are **nein** (different entity despite nearly identical wording):
- "Lower Bound **Clique** bzgl. Knoten" ↔ "Lower Bound **Vertex Cover** bzgl. Knoten"
- "Lower Bound Clique bzgl. **Knoten**" ↔ "Lower Bound Clique bzgl. **Kanten**"
- "Verifizierer für **FVS**" ↔ "Verifizierer für **Knapsack**"
- "**Cliquenproblem**" ↔ "**Vertex-Cover-Problem**"
- **ja** only on genuine semantic equivalence: same solution to the same problem, the same entity, just different wording/naming (e.g. "SET COVER" ↔ "Mengenüberdeckungsproblem", "Cliquenproblem" ↔ "k-CLIQUE", "List Scheduling" ↔ "LPT-Algorithmus").
- **nein** also for different aspects of the same problem: "Set Cover (Problem)" ↔ "Set Cover ETH-Schranke"; a problem ↔ its reduction to another; a problem ↔ its verifier.
- When in doubt **nein** — better two separate blocks than wrongly merging two concepts.
## How to decide (per pair)
**STEP 1 — Name the CANONICAL ENTITY of each side.** Strip catalogue numbers („Definition 6.19", „Satz 7.8"), drop generic tags like „(Problem)"/„-Problem", ignore case, spacing and hyphenation. So „HITTING SET" and „HittingSet (Problem)" share one canonical entity; „Definition 6.19 NP" and „NP" both name the entity **NP**; „Algorithmus ΔTSP1" and „ΔTSP1" both name **ΔTSP1**.
**STEP 2 — Same entity, or different?**
- **DIFFERENT canonical entity → nein**, however identical the wording:
- two different problems/objects: „Clique" ≠ „Vertex Cover"; „Lower Bound Clique bzgl. Knoten" ≠ „… bzgl. Kanten" (a different parameter is a different result);
- a distinct **variant** is its own entity: „GA" ≠ „ModifiedGreedy (MGA)"; „SAT" ≠ „3-SAT"; „Knapsack" ≠ „Multiple-Choice-Knapsack";
- a **reduction between two problems** is its own entity: „Clique" ≠ „3-SAT ≤ Clique";
- two reductions/relations that share ONE side but differ on the OTHER (or run in the opposite direction) are DIFFERENT results → **nein**: „SAT ≤ Clique" ≠ „SAT ≤ 3-Dim-Matching", „VertexCover ≤ FVS" ≠ „VertexCover ≤ Δ-Cover". A restriction/special case („3-SAT ≤ X") is NARROWER than the general („SAT ≤ X"), never the same.
- **SAME canonical entity → ja**, even when A and B emphasize DIFFERENT FACETS of it. Facets of one and the same object include: its **formal definition**, a **mechanism/step** (how it works), a **property** (approximation ratio, a bound, complexity, ∈ NP), a **characterization**, a **naming variant**. Two entries describing different facets of the SAME entity are duplicates.
**STEP 3 — „When in doubt → nein" applies ONLY when STEP 1 is ambiguous** (you genuinely cannot tell whether the two names denote the same object). It does NOT fire merely because the two descriptions differ — differing descriptions of the SAME entity are **ja**.
## Examples
DUPLICATE (ja) — same entity, different facet/wording:
- A: „Algorithmus ΔTSP1 — MST, Kanten verdoppeln, Eulerkreis, Abkürzungen" B: „ΔTSP1 — TSP-Approximationsalgorithmus mit Rate 2" → both = algorithm **ΔTSP1** (steps vs. its ratio) → **ja**
- A: „Definition 6.19 NP — L ∈ NP ⇔ ∃ NDTM …" B: „NP — Klasse aller polynomiell verifizierbaren Sprachen" → both = **NP** (definition vs. characterization) → **ja**
- A: „HITTING SET" B: „HittingSet (Problem)" → same entity, only naming → **ja**
- A: „SET COVER" B: „Mengenüberdeckungsproblem" → **ja**
NOT A DUPLICATE (nein) — different entity:
- A: „Greedy-Algorithmus GA" B: „ModifiedGreedy (MGA)" → two different algorithms → **nein**
- A: „Lower Bound Clique bzgl. Knoten" B: „Lower Bound Clique bzgl. Kanten" → different parameter → **nein**
- A: „Clique" B: „3-SAT ≤ Clique" → a problem vs. a reduction (its own block) → **nein**
- A: „VertexCover ≤ FVS" B: „VertexCover ≤ Δ-Cover" → same source, different target → different reductions → **nein**
- A: „Cliquenproblem" B: „Vertex-Cover-Problem" → different problems → **nein**
Write ONLY the JSON file to: {out_path}
Format (each pair number from the list with "ja" or "nein"; no other text in the file):
Format (each pair number from the list with ja" or nein"; no other text in the file):
{{"pairs": {{"1": "ja", "2": "nein"}}}}

View File

@@ -1,16 +0,0 @@
{n} research agents have independently determined the blocks of the topic "{topic}". Exactly identical titles have already been merged; the number in parentheses says how many research passes name the block. Consolidate the list.
{entries}
Rules:
- Recognize the SAME concepts under different titles and merge them into one block. The mention counts of the merged entries add up (each research pass counts a concept only once).
- A block solves EXACTLY ONE PROBLEM. Entries that are variants of the same solution are combined into ONE block (right: one block `<input>` for all types, one block "Modalverben" for all modal verbs; wrong: one entry per input type or per verb, but also collective entries that mix several problems).
- A block is ATOMIC: exactly one idea, complete in itself. Test: you can remove nothing without making it incomplete — and nothing is missing to understand it.
- CONSOLIDATE the granularity: a block is a LEARNING UNIT, not a dictionary entry. If the research passes deliver dozens of micro-entries of the same kind (one CSS property, one verb, one gesture per entry), group them by problem (right: "Flexbox-Ausrichtung" instead of six entries for justify-content, align-items, …). More than ~150 blocks is almost always a granularity problem — then check specifically for such series.
- Then split into two lists: blocks that (after merging) are named by AT LEAST TWO research passes → `blocks`. Named only once or doubtful on the merits → `rest`. Discard only what is obviously fabricated.
- Drop the sources. Title and short description (max. ~12 words) in GERMAN (code identifiers stay original). Every title must be UNIQUE.
Write ONLY the JSON file to: {out_path}
Format (each entry a string "Title — Kurzbeschreibung"; no other text in the file):
{{"blocks": ["Title — Kurzbeschreibung"], "rest": ["Title — Kurzbeschreibung"]}}

View File

@@ -12,14 +12,15 @@ Rules:
- Pure NOTATION/symbols belong to their definition: „|x|", „Σ∗" — not their own entry.
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique").
- NO categories, NO ranking, NO ordering by importance — only a flat, numbered list.
- There is NO target count. Stop only when the research yields nothing new.
- Invent nothing: include only blocks you have backed by research. Note the source per block (URL or file path). If there is no individual source, the collective source suffices (handbook chapter, textbook, overview page, directory).
- Aim for the natural number of genuine learning units for this material — there is no hard quota, but do NOT split hairs to inflate the count (prefer the "families learned together = ONE block" rule above). STOP when the only remaining candidates are exercises, meta-questions, administrative notes, or duplicates of blocks you already listed.
- **EXCLUDE non-content — these are NEVER learning blocks:** exercise/task/assignment scaffolding ("Aufgabe 3", "Bonusaufgabe", "Übung", "Blatt 11", point values), meta/assessment items ("welche der folgenden…", true/false prompts, hand-in / exam-date notes), administrative & boilerplate (headings, page/room/exam numbers, names, copyright), and any **file names, paths, or URLs** (those are provenance, recorded separately — never a block or part of one).
- Invent nothing: include only blocks actually supported by the provided material.
- Write title and description in GERMAN (technical terms/code identifiers stay original).
- Description at most ~12 words.
Write ONLY the Markdown file to: {blocks_path}
Format: EXACTLY one line per block: `N. Title — Kurzbeschreibung — Source`
The source (3rd segment) MUST be the exact file name or URL of the crawl page the block comes from — it drives the coverage check.
Format: EXACTLY one line per block: `N. Title — Kurzbeschreibung`
Use the em-dash " — " (a space on EACH side) ONLY to separate the title from the short description — never elsewhere in the line, and never inside the title or the description. Do NOT append the source file name, path, or URL to the line — provenance is recorded separately by the pipeline.
{focus}
{extra}

View File

@@ -0,0 +1,3 @@
SOURCE EXCERPTS — selected from the learning material. WORK EXCLUSIVELY WITH THESE EXCERPTS: do not search the web, do not read files. Cite from them (file name + heading/line if given). Whatever is not backable in the excerpts counts as NOT backed by the material.
{excerpts}

View File

@@ -0,0 +1,14 @@
You are the material gate for supplement proposals on the topic "{topic}". A research agent proposed blocks that canonically belong to the subject — but the inventory must ONLY contain what the LEARNING MATERIAL itself covers. Decide per proposal: do the excerpts show the material actually TREATS this block → **ja**, or does the material never treat it → **nein**?
PROPOSALS (each with its best-matching material excerpts):
{proposals}
Rules:
- **ja** requires real coverage: a heading, definition, algorithm, proof or exercise about the proposal's subject.
- Passing mentions, shared vocabulary or generic words (e.g. „Algorithmus" appears everywhere) are NOT coverage → **nein**.
- When in doubt → **nein**. The inventory already covers the material — supplements are a bonus, scope creep is not.
Reply with ONLY the JSON — no other text, no code fences.
Format (one verdict per number):
{{"relevant": {{"1": "ja", "2": "nein"}}}}
{extra}

View File

@@ -0,0 +1,20 @@
Check the block inventory for the topic "{topic}" for completeness against the LEARNING MATERIAL — the material defines the scope, nothing else.
The material is in the folder {project} (PDFs are provided as same-named .txt files — ALWAYS read the .txt, never the PDF). Get an overview with Bash (ls/find), read the files, then compare: which distinct concepts, methods, algorithms or theorems does the MATERIAL treat that the inventory misses?
EXISTING BLOCKS:
{blocks}
Rules:
- Propose ONLY blocks the material itself treats (a heading, definition, algorithm, proof or exercise about it) and that are missing from the inventory.
- Do NOT search the web. Do NOT propose textbook canon beyond the material.
- A block solves EXACTLY ONE PROBLEM and is ATOMIC — same standards as the inventory.
- NO variants, rephrasings, or deep-dives of existing blocks — only genuine gaps.
- Title and description in GERMAN (technical terms stay original), description at most ~12 words.
- If there are no gaps, return an empty list — that is a valid result.
Write ONLY the JSON file to: {out_path}
Format:
{{"blocks": [{{"title": "…", "description": "…"}}]}}
No gaps: {{"blocks": []}}

View File

@@ -7,6 +7,7 @@ EXISTING BLOCKS:
Rules:
- Research the subject area (textbooks, standard references) and add ONLY blocks that canonically belong and are missing from the inventory.
- The inventory mirrors a CONCRETE source (script/project). Propose only gaps the MATERIAL itself covers — no textbook canon beyond the source. Proposals without material coverage are discarded downstream.
- A block solves EXACTLY ONE PROBLEM and is ATOMIC — same standards as the inventory.
- NO variants, rephrasings, or deep-dives of existing blocks — only genuine gaps.
- Invent nothing: only blocks you have backed by research.

View File

@@ -1,29 +0,0 @@
You help adapt a learning element of a personal summary on the topic "{topic}". You change NOTHING directly — you propose changes, and the user confirms each one individually.
CURRENT ELEMENT (JSON):
{element_json}
CHAT TRANSCRIPT SO FAR:
{transcript}
Turn the latest user instruction into change proposals. Keep the element rules:
1. title — a concise title (max. 8 words, plain text, no Markdown/backticks)
2. description — what it is and what for: AT MOST 12 sentences
3. examples — SHORT and SIMPLE: the minimal example in a topic-appropriate format (a code block with language tag for code topics, otherwise example sentences/mini-dialogue/mini-scenario as plain text). Each with a variant label: a code comment (e.g. `<!-- Einzelner Absatz -->`) or a **bold** label (e.g. **Höfliche Bitte:**).
4. hints — every hint must be IMPORTANT or USEFUL. Telegraphic style: just the core statement. Example: "Keine Blockelemente in `<p>`."
Length: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. Markdown: `inline-code` for identifiers, tags and commands — ALWAYS in backticks. Tone: clear German, direct, no filler sentences.
Each proposal:
- text: short, what is changed (max. 12 words, plain text)
- action: "remove" | "adjust" | "add"
- target: "title" | "description" | "examples" | "hints"
- index: 0-based position in the CURRENT examples or hints array (null for title/description and for "add")
- content: the new complete content (empty for "remove")
"remove" only for examples/hints. Only make proposals that the user instruction calls for.
Write the user-facing fields (`reply`, `text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
{{"reply": "short reply to the user (12 sentences)", "changes": [{{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}]}}
A pure question with no change request → answer it in reply, "changes": []

View File

@@ -1,19 +0,0 @@
You research what information might still be missing from a learning element of a personal summary on the topic "{topic}".
CURRENT ELEMENT (JSON):
{element_json}
CONTEXT (excerpts from the topic material):
{context}
RESEARCH — gather candidates broadly: missing key points, important variants, common pitfalls, best practices. Better one candidate too many than one too few — the evaluation happens in a second step. Propose nothing the element already contains.
Each candidate:
- text: a short description of the gap (max. 12 words, plain text)
- target: "description" | "examples" | "hints"
- content: ready-to-insert content. AS SHORT AS POSSIBLE, as long as needed. Markdown: `inline-code` for identifiers; examples in a topic-appropriate format (a code block with language tag for code, otherwise example sentences/mini-scenario as plain text), each with a variant label (a code comment or a **bold** label); hints only if IMPORTANT or USEFUL, in telegraphic style (just the core statement, e.g. "Keine Blockelemente in `<p>`."). Tags/identifiers in running text ALWAYS in backticks.
Write the candidate fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
No candidates → {{"suggestions": []}}

View File

@@ -1,32 +0,0 @@
You create a short learning element for a personal summary on the topic "{topic}".
KEYWORD FROM THE USER:
{hint}
CONTEXT (excerpts from the topic material):
{context}
Create EXACTLY ONE element for the keyword:
1. title — a concise title (max. 8 words, plain text, no Markdown/backticks)
2. description — what it is and what for: AT MOST 12 sentences
3. examples — EXACTLY ONE example: SHORT and SIMPLE, the minimal example in a topic-appropriate format (see EXAMPLE FORMAT), no real-world complexity.
4. hints — ALWAYS an empty list. The user adds hints later. (If ever required: TELEGRAPHIC style, max. 10 words.)
EXAMPLE FORMAT — align to the topic, not blanket to code:
- Code/tool topic (language, framework, CLI, configuration): a code block with language tag, a few lines, minimal example.
- Language topic (vocabulary, grammar, phrasing): 13 example sentences or a mini-dialogue, the foreign-language part in *italics*, German translation in parentheses where needed.
- Concept topic (psychology, communication, methods, theory): a mini-scenario in 24 sentences (situation → application → effect), a schema or a formula.
Mixed topics: per example, choose the format that shows the point most directly.
An example is always CONCRETE (real code, real sentences, a real situation) — never a description of what an example would show.
Each example names its variant: in code as a comment in the code syntax (e.g. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in prose as a leading **bold** label (e.g. **Höfliche Bitte:**).
The element is ATOMIC: understandable on its own, without the reader having read anything else. Resolve any terms used in a half-sentence.
Length: AS SHORT AS POSSIBLE, as long as needed — applies to description, examples and hints. Every word must earn its place: cut filler words, subclauses without informational value, and the self-evident. The length comes from the NUMBER of examples (variants), never from long texts.
Tone: clear German, direct, practical. Explain technical terms briefly on first use. No filler sentences, no introductory clichés.
Markdown in description and examples: normal paragraphs, `inline-code` for identifiers, **bold** sparingly for key points. No headings. Code examples ALWAYS as a code block with language tag (```sprache), never as inline code; prose examples (sentences, dialogues, scenarios) as plain text, NEVER forced into a code block. Identifiers, tags and commands (e.g. `<p>`, `git add`) in running text ALWAYS in backticks — never bare.
Write the element (title, description, examples) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n``` OR **Variante:** prose example"], "hints": []}}

View File

@@ -1,24 +0,0 @@
You revise EXACTLY ONE change proposal for a learning element on the topic "{topic}", following a user instruction.
CURRENT ELEMENT (JSON):
{element_json}
CURRENT PROPOSAL (JSON):
{suggestion_json}
USER INSTRUCTION:
{instruction}
Adjust the proposal per the instruction. Keep action/target/index, unless the instruction requires otherwise.
Style rules for content: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. `inline-code` for identifiers, tags and commands — ALWAYS in backticks. examples in a topic-appropriate format (a code block with language tag ONLY for code, otherwise example sentences/mini-scenario) with a variant label (a code comment or a **bold** label). hints in telegraphic style: just the core statement.
Fields:
- text: short, what is changed (max. 12 words, plain text)
- action: "remove" | "adjust" | "add"
- target: "title" | "description" | "examples" | "hints"
- index: 0-based position in the examples/hints array (otherwise null)
- content: the new complete content (empty for "remove")
Write the user-facing fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
{{"change": {{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}}}

View File

@@ -1,37 +0,0 @@
You check a learning element of a personal summary on the topic "{topic}" against the style rules and propose changes. The element is NOT changed directly — the user confirms each change individually.
CURRENT ELEMENT (JSON):
{element_json}
STYLE RULES:
1. title — concise, max. 8 words, plain text, no Markdown/backticks
2. description — what it is and what for: AT MOST 12 sentences
3. examples — SHORT and SIMPLE: the minimal example in a topic-appropriate format (see EXAMPLE FORMAT), no real-world complexity. One example per relevant variant, ordered from the usual to the special. A code block around a prose example is a style violation — as is a code example without a code block.
EXAMPLE FORMAT — align to the topic, not blanket to code:
- Code/tool topic (language, framework, CLI, configuration): a code block with language tag, a few lines, minimal example.
- Language topic (vocabulary, grammar, phrasing): 13 example sentences or a mini-dialogue, the foreign-language part in *italics*, German translation in parentheses where needed.
- Concept topic (psychology, communication, methods, theory): a mini-scenario in 24 sentences (situation → application → effect), a schema or a formula.
Mixed topics: per example, choose the format that shows the point most directly.
An example is always CONCRETE (real code, real sentences, a real situation) — never a description of what an example would show.
Each example names its variant: in code as a comment in the code syntax (e.g. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), in prose as a leading **bold** label (e.g. **Höfliche Bitte:**).
4. hints — every hint must be IMPORTANT or USEFUL: a pitfall, a mnemonic or a best practice with real practical value. Remove the self-evident, niche knowledge and anything redundant with the element. Telegraphic style: just the core statement, cut filler verbs and derivations.
Before: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
After: "Browser-Abstand um `<p>` per `margin` anpassbar."
5. Length: AS LONG AS NEEDED and AS SHORT AS POSSIBLE. Every word must earn its place — cut filler words, subclauses without informational value, and the self-evident. But: never shorten at the cost of comprehensibility or correctness.
6. Markdown: `inline-code` for identifiers, tags and commands in running text (e.g. `<p>`, `git add`) — ALWAYS in backticks, never bare. Foreign-language example sentences in *italics*. **bold** sparingly. No headings.
7. Tone: clear German, direct, practical. No filler sentences.
For each style violation propose EXACTLY ONE change:
- text: short, what and why (max. 12 words, plain text)
- action: "remove" | "adjust" | "add"
- target: "title" | "description" | "examples" | "hints"
- index: 0-based position in the CURRENT examples or hints array (null for title/description; null for "add")
- content: the new/complete content (empty for "remove")
"remove" only for examples/hints. "add" sparingly — only when a style rule requires it (e.g. a missing variant comment belongs to "adjust", not "add"). If something already meets the rules: do NOT touch it.
Write the user-facing fields (`text`, `content`) in GERMAN. Output ONLY valid JSON, no code fence, no other text:
{{"changes": [{{"text": "...", "action": "adjust", "target": "hints", "index": 0, "content": "..."}}]}}
No style violation → {{"changes": []}}

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