Compare commits

..
30 Commits
Author SHA1 Message Date
team3 2178c6faf4 update 2026-07-08 23:46:35 +02:00
team3 f9d77a113b update 2026-07-08 21:14:33 +02:00
team3 9a6ab0937b update 2026-07-08 18:55:36 +02:00
team3 224ba1ed4f update 2026-07-07 19:10:30 +02:00
team3 27364d0789 update 2026-07-07 02:37:12 +02:00
team3 f2bc6bcedb update 2026-07-07 02:25:29 +02:00
team3 41f96330fd update 2026-07-07 01:07:10 +02:00
team3 f0c1bdacfa update 2026-07-07 00:25:23 +02:00
team3 f2feb1dbd0 update 2026-07-06 20:57:41 +02:00
team3 1441c01f2c update 2026-07-06 20:30:15 +02:00
team3 9c55b05373 update 2026-07-06 18:53:43 +02:00
team3 72a82ed9dd update 2026-07-06 18:25:57 +02:00
team3 73ac3e532f update 2026-07-06 17:32:52 +02:00
team3 cc27e53b9e update 2026-07-06 14:44:20 +02:00
team3 f4c5116abb update 2026-07-05 23:03:00 +02:00
team3 ed5cf146c3 update 2026-07-05 20:43:25 +02:00
Team3 250ea0b764 refactor 2026-07-05 15:26:22 +02:00
Team3 07e14fb82e update 2026-07-05 13:26:32 +02:00
Team3 fb3e967fdb update 2026-07-05 13:07:12 +02:00
Team3 6a765b7d89 update 2026-07-05 11:21:29 +02:00
Team3 9d5940e21f update 2026-07-05 11:12:41 +02:00
Team3 54cb6f10e7 update 2026-07-05 10:52:36 +02:00
Team3 9b6bfa45c6 update 2026-07-05 10:38:24 +02:00
Team3 db58d52567 update 2026-07-05 00:54:21 +02:00
Team3 219993ffd5 update 2026-07-05 00:38:03 +02:00
Team3 d25824229e update 2026-07-05 00:25:53 +02:00
Team3 4105146c59 update 2026-07-04 23:47:04 +02:00
Team3 2d9ca00b47 update 2026-07-04 21:27:47 +02:00
Team3 c05421a8c1 update 2026-07-04 19:20:48 +02:00
Team3 92c69c1561 update 2026-07-04 16:50:50 +02:00
119 changed files with 20393 additions and 6644 deletions

No files matched your search

+14
View File
@@ -1,3 +1,6 @@
# Standard-Provider für alle Agenten (Pflicht — es gibt keinen Code-Default)
DEFAULT_PROVIDER=minimax
# Datei nach .env kopieren (wird nicht committet).
# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen.
@@ -12,3 +15,14 @@ MINIMAX_API_KEY=
#ROLE_JUDGE=
#ROLE_GUIDE=
#ROLE_FAST=
# Parallelität (Defaults: siehe backend/config.py). Prozess-Tier = opencode-Prozesse
# (~310 MB RSS pro Agent), API-Tier = direkte MiniMax-Calls für tool-lose Agenten (~0 RAM).
# Auf 8-GB-Maschinen Prozess-Limit klein halten; das API-Tier darf hoch.
#MAX_CONCURRENT_AGENTS=8
#MAX_CONCURRENT_AGENTS_PER_TOPIC=24
#MAX_CONCURRENT_API_AGENTS=28
# opencode-Starts warten, wenn weniger als dieser Anteil RAM frei ist (0 = aus).
#RAM_MIN_FREE_PCT=20
# Kill-Switch: 0 = Text-Calls wieder über opencode-Prozesse statt direkter API.
#CREATOR_TEXT_API=0
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
# Ziele
- Auswahl der Bausteine / Subbausteine optimieren: Also Entfernen würde eine Lücke erzeugen und Hinzufügen würde eine Dopplung erzeugen
- Performance maximieren: Der Makespan soll so gering wie möglich sein
- Tokenverbrauch minimieren: Gesamtverbraucht der Tokens soll so gering wie möglich sein
- Korrektheit: Faschinformationen so gerin wie möglich halten
# Soll folgende Probleme lösen
- Perfektionismus: Ich brauche die Info was 100% vom Thema ist
- Kontrolle: Ich muss wissen wie weit ich bin, um es für mich zu planen und tracken
- Optimale Auswahl: Zu große Auswahl verlängert die Bearbeitungszeit und die Motivation bricht weg, wenn ich zu wenig Fortschritt sehe; Zu gerine Auswahl triggert den Perfektionismus
- Tokenverbrauch: Je weniger Token verbaucht werden, desto stärkere Modelle kann ich nehmen
- Performance: Bei zu langer Generierungen erschöpft meine Geduld
+9 -3
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 qa test test-e2e train train-init
.PHONY: install dev prod stop logs remove auth sync sync-projects sync-all sync-all-reverse projects searxng ollama qa test test-e2e train train-init train-server
COMPOSE = docker compose
@@ -47,10 +47,11 @@ stop:
logs:
$(COMPOSE) logs -f
remove: stop
remove:
@read -p "storage/ (DB + alle Nutzdaten) wirklich löschen? [y/N] " a && [ "$$a" = "y" ]
@echo "Lösche Datenbank und generierte Dateien..."
rm -rf storage/*
@echo "Fertig."
@echo "Fertig. (Server ggf. separat stoppen: make stop)"
searxng:
docker run -d --name searxng --restart unless-stopped -p 8888:8080 searxng/searxng
@@ -129,6 +130,11 @@ train:
@set -a; [ -f .env ] && . ./.env; set +a; \
cd backend && python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
# Training im Container starten (auf dem Server ausführen; detached, überlebt ssh-Abbruch).
# Fortschritt: storage/train/<sitzung>/trials.jsonl
train-server:
docker exec -d creator python3 train.py --trials $(or $(TRIALS),40) --stunden $(or $(STUNDEN),12) --ameisen $(or $(AMEISEN),3)
# Frozen-Inventar-Vorlage für das Training bauen (einmalig, echter Mini-Lauf)
train-init:
@set -a; [ -f .env ] && . ./.env; set +a; \
+176 -16
View File
@@ -22,13 +22,16 @@ import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
import httpx
from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_INTERACTIVE,
resolve_role)
MAX_CONCURRENT_AGENTS_PER_TOPIC, MAX_CONCURRENT_API_AGENTS,
MAX_CONCURRENT_INTERACTIVE, RAM_MIN_FREE_PCT, resolve_role)
log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_api: set[str] = set() # agent_keys of running direct-API calls (no process to kill)
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
@@ -39,9 +42,20 @@ def active_agents(scope_prefix: str | None = None) -> list[dict]:
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))]
if (k in _active_processes or k in _active_api)
and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
# Board-2-Calls tragen diese Marker im agent_key; alles andere unter blocks-{topic}- ist Board 1.
_ARTEFAKT_MARKER = ("-art-gen", "-art-check", "-sb-enrich", "-sb-verify", "-sb-fix",
"-outline")
def agent_ebene(key: str) -> str:
"""Generierungs-Ebene eines Blocks-Agenten (für Board-Anzeige/Accounting): inventory|artefacts."""
return "artefacts" if any(m in key for m in _ARTEFAKT_MARKER) else "inventory"
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
# in the semaphore queue are also stopped immediately on abort instead of still starting.
@@ -95,7 +109,8 @@ class _PrioritySemaphore:
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS) # process tier (~310 MB RSS each)
_batch_sem_api = _PrioritySemaphore(MAX_CONCURRENT_API_AGENTS) # direct-API tier (~0 RAM)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# Per-topic caps (lazily created): each topic gets its own priority semaphore of size
@@ -119,17 +134,19 @@ def _agent_priority(key: str) -> int:
@asynccontextmanager
async def _batch_gate(scope: str | None, priority: int):
async def _batch_gate(scope: str | None, priority: int, api: bool = False):
"""Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally."""
agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally.
The per-topic cap is shared across both tiers; only the global cap is tiered (process vs API)."""
topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
global_sem = _batch_sem_api if api else _batch_sem
if topic_sem is not None:
await topic_sem.acquire(priority)
await _batch_sem.acquire(priority)
await global_sem.acquire(priority)
try:
yield
finally:
_batch_sem.release()
global_sem.release()
if topic_sem is not None:
topic_sem.release()
@@ -151,6 +168,58 @@ async def _opencode_slot() -> None:
_opencode_next_start = start_at + _OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, start_at - now))
# RAM-adaptive admission for opencode spawns (~310 MB RSS each): below RAM_MIN_FREE_PCT
# free memory new processes wait instead of starting. Gates admission only — running
# processes are never touched.
_RAM_GATE_FLOOR = 2 # below this many running: always admit (deadlock guard)
_RAM_PER_PROC_KB = 350 * 1024 # commit estimate for a batch agent (opencode/claude, no MCP)
_RAM_PER_PROC_FULL_KB = 1250 * 1024 # `full` agent: opencode + 3 MCP servers (~300 MB each)
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
_RAM_POLL_S = 2.0
_cli_running = 0 # CLI spawns (opencode AND claude) — the deadlock-floor counter
_cli_recent_starts: list[tuple[float, int]] = [] # (monotonic ts, est_kb) of recent admissions
def _meminfo() -> tuple[int, int] | None:
"""(MemAvailable_kB, MemTotal_kB) from /proc/meminfo; None → gate fails open (non-Linux)."""
try:
text = Path("/proc/meminfo").read_text()
except OSError:
return None
m = {k: v for k, v in re.findall(r"^(MemTotal|MemAvailable):\s+(\d+)", text, re.MULTILINE)}
if "MemTotal" not in m or "MemAvailable" not in m:
return None
return int(m["MemAvailable"]), int(m["MemTotal"])
async def _ram_gate(agent_key: str, est_kb: int) -> bool:
"""True = start admitted (commit registered), False = scope cancelled while waiting.
Gates ALL CLI spawns (opencode + claude). `est_kb` = this spawn's RAM estimate (a `full`
agent drags 3 MCP servers). Check and commit-append happen in the same synchronous block
(no await between) — concurrent waiters cannot double-admit on the same free RAM."""
if RAM_MIN_FREE_PCT <= 0:
return True
waited = False
while True:
if _scope_cancelled(agent_key):
return False
mem = _meminfo()
if mem is None or _cli_running < _RAM_GATE_FLOOR:
break # fail open / floor
avail_kb, total_kb = mem
now = time.monotonic()
_cli_recent_starts[:] = [(t, kb) for t, kb in _cli_recent_starts
if now - t < _RAM_COMMIT_WINDOW_S]
committed_kb = sum(kb for _, kb in _cli_recent_starts)
if avail_kb - committed_kb >= total_kb * RAM_MIN_FREE_PCT / 100:
break
if not waited:
log.info("agent %s: RAM gate waiting (%.0f%% free)", agent_key, avail_kb * 100 / total_kb)
waited = True
await asyncio.sleep(_RAM_POLL_S)
_cli_recent_starts.append((time.monotonic(), est_kb))
return True
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools
@@ -170,6 +239,17 @@ _OPENCODE_AGENTS = {
}
def _use_text_api(provider: str, model: str, capabilities: str, on_line) -> bool:
"""Direct API path only for tool-less, non-streaming MiniMax calls. Model-prefix check
instead of cli check: "lokal" (ollama) also runs via opencode. Missing key or
CREATOR_TEXT_API=0 (kill switch) falls back to the opencode process path."""
return (capabilities == "none" and on_line is None
and PROVIDERS[provider]["cli"] == "opencode"
and model.split("/", 1)[0] in ("minimax", "minimax-kalt")
and bool(os.environ.get("MINIMAX_API_KEY"))
and os.getenv("CREATOR_TEXT_API", "1") != "0")
def provider_available(provider: str) -> bool:
cfg = PROVIDERS.get(provider)
if not cfg:
@@ -258,10 +338,11 @@ async def run_agent(
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:
use_api = _use_text_api(provider, model, capabilities, on_line)
if not use_api and shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
queued = time.monotonic()
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key), api=use_api)
async with gate:
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled"
@@ -270,9 +351,13 @@ async def run_agent(
status = "error"
rc = None
err_tail = ""
api_tokens = None
try:
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
if PROVIDERS[provider]["cli"] == "opencode":
if use_api:
rc_, out_, err_, api_tokens = await _run_text_api(agent_key, prompt, timeout, model, label=label)
res = (rc_, out_, err_)
elif PROVIDERS[provider]["cli"] == "opencode":
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
else:
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
@@ -291,9 +376,13 @@ async def run_agent(
if on_event is not None and scope is not None: # batch pipeline only, never fatal
try:
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if agent_key.startswith("blocks-"): # Ebene fürs Per-Board-Accounting
meta["board"] = agent_ebene(agent_key)
if err_tail:
meta["stderr"] = err_tail
if PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
if api_tokens: # direct-API path: usage from the response, even on rc!=0
meta["tokens"] = api_tokens
elif PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
if (tok := await asyncio.to_thread(_session_tokens, agent_key)):
meta["tokens"] = tok
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
@@ -303,7 +392,7 @@ async def run_agent(
log.debug("on_event failed", exc_info=True)
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None) -> tuple[int, str, str]:
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None, est_kb: int = _RAM_PER_PROC_KB) -> tuple[int, str, str]:
start = time.monotonic()
async def spawn():
@@ -316,9 +405,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
env=env,
)
global _cli_running
if stagger:
await _opencode_slot() # spaced start slot; spawn itself is not serialized
if not await _ram_gate(agent_key, est_kb):
return 1, "", "cancelled" # like the cancelled path in run_agent
await _opencode_slot() # gate BEFORE the start slot: an admission wave still gets spaced
process = await spawn()
if stagger:
_cli_running += 1
# Collision-safe tracking: identical keys (e.g. same chunk label from parallel cards)
# get a ~n suffix — prefix-based kill/cancel still matches, nothing becomes an orphan.
track_key = agent_key
@@ -366,6 +460,8 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
)
return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
finally:
if stagger:
_cli_running -= 1
# Pop only on identity: a slot restart under the same key must not evict
# the NEW process from tracking.
if _active_processes.get(track_key) is process:
@@ -381,7 +477,8 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str,
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
# stagger=True: claude-CLI (~310 MB) also passes the RAM gate — previously ungated.
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, stagger=True, label=label)
_OPENCODE_DB = Path.home() / ".local" / "share" / "opencode" / "opencode.db"
@@ -435,12 +532,75 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
# `full` keeps the 3 MCP servers (~300 MB each) → charge the gate accordingly.
est_kb = _RAM_PER_PROC_FULL_KB if capabilities == "full" else _RAM_PER_PROC_KB
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env, est_kb=est_kb)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)
# Direct text-API path (MiniMax, Anthropic Messages format): saves the ~310 MB RSS opencode
# process for tool-less calls. The native "minimax" and "minimax-kalt" opencode providers both
# resolve to this base URL — only the per-model options differ. Options source of truth:
# dev-ops/opencode.json (+ -slim); keep in sync on changes there.
_API_URL = "https://api.minimax.io/anthropic/v1/messages"
_API_VERSION = "2023-06-01"
_API_MAX_TOKENS = 32_000 # required Messages field; generate calls are long
_API_MODEL_OPTS = { # prefix "minimax" (native) → endpoint defaults (no entry)
"minimax-kalt/MiniMax-M3": {"temperature": 0.2, "thinking": {"type": "disabled"}},
"minimax-kalt/MiniMax-M2.7-highspeed": {"temperature": 0.3},
}
async def _run_text_api(agent_key: str, prompt: str, timeout: int, model: str,
label: str = "") -> tuple[int, str, str, dict | None]:
"""→ (rc, text, err, tokens). Tokens come straight from the response usage (same key set
as _session_tokens) and are returned even on rc!=0 — waste stays visible."""
body = {
"model": model.split("/", 1)[1], # without the opencode provider prefix
"max_tokens": _API_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
**_API_MODEL_OPTS.get(model, {}),
}
headers = {"x-api-key": os.environ.get("MINIMAX_API_KEY", ""), "anthropic-version": _API_VERSION}
track_key = agent_key
n = 2
while track_key in _active_api:
track_key = f"{agent_key}~{n}"
n += 1
_active_api.add(track_key)
_active_started[track_key] = time.time()
_active_labels[track_key] = label
start = time.monotonic()
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=30)) as client:
resp = await asyncio.wait_for( # belt: hard wall-clock cap like the process path
client.post(_API_URL, json=body, headers=headers), timeout=timeout)
except httpx.TimeoutException:
raise asyncio.TimeoutError # contract: run_agent/_race handle TimeoutError
except httpx.HTTPError as e:
return 1, "", f"{type(e).__name__}: {e}", None
finally:
_active_api.discard(track_key)
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
log.info("agent %s: api done after %.1fs", agent_key, time.monotonic() - start)
if resp.status_code != 200:
return 1, "", f"HTTP {resp.status_code}: {resp.text[:300]}", None
data = resp.json()
# Only text blocks count — thinking blocks (native route) are skipped.
text = "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")
u = data.get("usage") or {}
tokens = {"input": int(u.get("input_tokens") or 0), "output": int(u.get("output_tokens") or 0),
"reasoning": 0, "cache_read": int(u.get("cache_read_input_tokens") or 0),
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
if not text.strip():
return 1, "", f"empty response (stop_reason={data.get('stop_reason')})", tokens
err = "stop_reason=max_tokens (truncated)" if data.get("stop_reason") == "max_tokens" else ""
return 0, text, err, tokens
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+43
View File
@@ -0,0 +1,43 @@
"""Auto-Repair-Loop einer Ebene: QA → Befunde beheben, bis 100 % (10.0/10),
Stillstand (Repair verbessert die Note nicht mehr) oder max_iter erreicht ist.
Reine Primitive — keine Kanban-/DB-Kopplung. Der Aufrufer übergibt zwei Coroutinen:
`messen()` liefert die aktuelle QA-Note (0–10), `reparieren()` behebt die Befunde.
So bleibt der Loop pro Ebene (Inventar/Artefakte/Guide) identisch testbar.
"""
import logging
log = logging.getLogger("creator.auto_loop")
VOLL = 10.0 # 100 % = 10.0/10
MAX_ITER = 10 # Sicherheitslimit: mehr Runden ⇒ etwas im System stimmt nicht
async def auto_repair_loop(ebene: str, erst_note: float, reparieren, max_iter: int = MAX_ITER) -> dict:
"""Loop QA→Repair für eine Ebene.
erst_note : float — bereits gemessene QA-Note vor dem ersten Repair (0–10).
reparieren() -> (float, bool): behebt die Befunde UND gibt die neu gemessene Note
zurück (die Repair-Bausteine messen ohnehin am Ende — keine Doppel-
messung) plus „bewegt": ob die Runde etwas getan hat (Fixes, Merges,
Freisprüche, Nach-Recherche). Eine bewegte Runde darf die Note auch
mal transient senken (neue Blöcke → neue Verdachte) — Stillstand ist
erst, wenn NICHTS mehr passiert UND die Note nicht steigt.
Stopp bei: Note == VOLL (fertig), unbewegt ohne Verbesserung (stillstand),
oder max_iter Runden (limit — begrenzt „bewegt ohne Notengewinn"-Livelocks).
Returns {ebene, note, runden, grund} mit grund ∈ {"fertig","stillstand","limit"}.
"""
if erst_note >= VOLL:
return {"ebene": ebene, "note": erst_note, "runden": 0, "grund": "fertig"}
note = erst_note
for runde in range(1, max_iter + 1):
neu, bewegt = await reparieren()
log.info("[%s] Auto-Loop Runde %d: %.1f → %.1f (%s)",
ebene, runde, note, neu, "bewegt" if bewegt else "unbewegt")
if neu >= VOLL:
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "fertig"}
if not bewegt and neu <= note: # nichts getan UND keine Verbesserung ⇒ kein Repair-Pfad
return {"ebene": ebene, "note": neu, "runden": runde, "grund": "stillstand"}
note = max(note, neu)
return {"ebene": ebene, "note": note, "runden": max_iter, "grund": "limit"}
+668
View File
@@ -0,0 +1,668 @@
"""Board 2, verschmolzene Call-Struktur: 3 Bausteine pro Block statt ~20 serieller Segmente.
Die alte Stage-Treppe (Finder-Runden → Facts find/erg/check → Konsolidierung → Lücken →
Nachfass → Levels → Relevanz → Fragen → Kritik → Flashcards → Beispiele → Check) kostete
pro Block 35–55 Calls und ~14 min Wandzeit — bei p50 20–50 s pro Call zählt NUR die Zahl
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
= 4–5 Segmente, 6–9 Calls. Unabhängigkeit bleibt: Generatoren und Prüfer sind getrennte
Agenten, Konsens (≥2 unabhängige Nennungen) und Einstimmigkeits-Faltung wie zuvor.
Output-Kontrakt unverändert (finalize/QA/Guide/Übungssystem lesen dieselben Strukturen):
raw {block: [sub]}, facts {block: {sub_norm: 5-Felder}}, sidecar {block: [{title, level,
relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashcard/example}."""
import asyncio
import hashlib
import logging
import database as db
import embedding
from blocks import (
_SOURCE_TEMPLATE, _FACTS_FIELDS, _agreed_cliques, _cited_evidence, _dedup_subblocks,
_evidence_pack, _facts_lines, _facts_union, _luecken_schnitt, _neg_set, _pairs_of,
_sink_json, _sub_tokens, _subs_hash, _variant_clusters, load_source, material_folder,
source_folder,
)
from config import (ART_SPLIT_SUBS, EMBEDDING_AKTIV, GEN_PANEL, SEED_COVER_COS,
VERIFY_PANEL)
from jsonio import read_json_file as _json_file
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _race, _timeout, run_single_slot
from textkit import _norm_title, clean_title
log = logging.getLogger("creator.block_calls")
_STUFEN = ("beginner", "advanced", "expert")
_RELEVANZ = ("relevant", "peripheral")
def _h8(*parts: str) -> str:
return hashlib.md5("|".join(parts).encode("utf-8")).hexdigest()[:8]
# ── Schemas ─────────────────────────────────────────────────────────────────────────
def _gen_schema(data) -> list[dict] | None:
"""{"subs": [{title, level, relevance, …facts}]} → normalisierte Liste · sonst None.
Feld-Normalisierung wie _facts_schema; ungültiges level/relevance fällt auf ""
(die Stimme entfällt im Vote, der Sub bleibt)."""
if not isinstance(data, dict) or not isinstance(data.get("subs"), list):
return None
out = []
for e in data["subs"]:
if not isinstance(e, dict) or not str(e.get("title", "")).strip():
continue
bf = [{"text": t, "source": str(f.get("source", "")).strip()}
for f in (e.get("cited_facts") or []) if isinstance(f, dict) and (t := str(f.get("text", "")).strip())]
lv = str(e.get("level", "")).strip().casefold()
rv = str(e.get("relevance", "")).strip().casefold()
out.append({
"title": clean_title(str(e["title"]).strip()),
"level": lv if lv in _STUFEN else "",
"relevance": rv if rv in _RELEVANZ else "",
"key_points": [k for x in (e.get("key_points") or []) if (k := str(x).strip())],
"prerequisites": str(e.get("prerequisites", "")).strip(),
"hurdles": str(e.get("hurdles", "")).strip(),
"cited_facts": bf,
"example_idea": str(e.get("example_idea", "")).strip(),
})
return out or None
def _vid(x, n: int) -> int | None:
"""Prüfer-Nummer → int in 1..n, else None (bools sind keine ids)."""
if isinstance(x, bool):
return None
if isinstance(x, str) and x.isdigit():
x = int(x)
return x if isinstance(x, int) and 1 <= x <= n else None
def _verify_schema(data, n: int) -> dict | None:
"""Prüfer-Output → normalisiertes Verdikt · None wenn kaputt. Alle Felder optional
außer der Grundform (dict) — ein leeres Verdikt {"gruppen": []} heißt „alles ok"."""
if not isinstance(data, dict):
return None
pflicht = ("gruppen", "kataloge", "fremd", "luecken", "uebernehmen", "facts_probleme",
"levels", "relevanz")
if not any(k in data for k in pflicht):
return None
def _ids(lst):
return sorted({i for x in (lst or []) if (i := _vid(x, n)) is not None})
gruppen = []
for g in data.get("gruppen") or []:
if not isinstance(g, dict):
continue
haupt = _vid(g.get("haupt"), n)
ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or []))
if len(ids) >= 2:
gruppen.append({"haupt": haupt if haupt in ids else None, "ids": ids})
kataloge = []
for k in data.get("kataloge") or []:
if not isinstance(k, dict):
continue
ids = _ids(k.get("mitglieder"))
titel = str(k.get("titel") or "").strip()
if len(ids) >= 2 and titel:
kataloge.append({"titel": titel, "ids": ids})
uebernehmen = {}
for k, v in (data.get("uebernehmen") or {}).items() if isinstance(data.get("uebernehmen"), dict) else []:
if (i := _vid(k, n)) is not None:
uebernehmen[i] = str(v).strip().casefold()
probleme = []
for p in data.get("facts_probleme") or []:
if isinstance(p, dict) and (i := _vid(p.get("nr"), n)) is not None:
probleme.append({"nr": i, "discard": bool(p.get("discard")),
"hinweis": str(p.get("hinweis", "")).strip()})
def _enum_map(key, allowed):
out = {}
raw = data.get(key)
for k, v in (raw.items() if isinstance(raw, dict) else []):
if (i := _vid(k, n)) is not None and str(v).strip().casefold() in allowed:
out[i] = str(v).strip().casefold()
return out
return {"gruppen": gruppen, "kataloge": kataloge, "fremd": set(_ids(data.get("fremd"))),
"luecken": [s.strip() for s in data.get("luecken") or [] if isinstance(s, str) and s.strip()],
"uebernehmen": uebernehmen, "facts_probleme": probleme,
"levels": _enum_map("levels", _STUFEN), "relevanz": _enum_map("relevanz", _RELEVANZ)}
def _pattern_liste(lst) -> list[dict]:
out = []
for e in lst or []:
if isinstance(e, dict):
blk, sub, q = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question"))
if blk and sub and q:
out.append({"block": blk, "subblock": sub, "question": q})
return out
def _art_gen_schema(data) -> dict | None:
"""{"pattern": […], "cards": […], "examples": […]} → normalisiert · None wenn kaputt.
pattern ist Pflicht (Leitner hängt an Fragen), cards/examples best-effort."""
if not isinstance(data, dict):
return None
pattern = _pattern_liste(data.get("pattern"))
if not pattern:
return None
cards = []
for e in data.get("cards") or []:
if isinstance(e, dict):
blk, sub, q, a = (str(e.get(k, "")).strip() for k in ("block", "subblock", "question", "answer"))
if blk and sub and q and a:
cards.append({"block": blk, "subblock": sub, "question": q, "answer": a})
examples = []
for e in data.get("examples") or []:
if isinstance(e, dict):
blk, sub, pr, res = (str(e.get(k, "")).strip() for k in ("block", "subblock", "problem", "result"))
steps = [s for x in (e.get("steps") or []) if (s := str(x).strip())]
if blk and sub and pr and steps:
examples.append({"block": blk, "subblock": sub, "problem": pr, "steps": steps, "result": res})
return {"pattern": pattern, "cards": cards, "examples": examples}
def _art_check_schema(data) -> dict | None:
"""{"ok": true} → leeres Verdikt · sonst pattern (bereinigt) + pattern_ergaenzt +
examples_probleme (1-basierte Indizes)."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
if not any(k in data for k in ("pattern", "pattern_ergaenzt", "examples_probleme")):
return None
probleme = set()
for p in data.get("examples_probleme") or []:
i = p.get("index") if isinstance(p, dict) else p
if isinstance(i, str) and i.isdigit():
i = int(i)
if isinstance(i, int) and not isinstance(i, bool) and i >= 1:
probleme.add(i)
return {"pattern": _pattern_liste(data.get("pattern")),
"pattern_ergaenzt": _pattern_liste(data.get("pattern_ergaenzt")),
"examples_probleme": probleme}
# ── Gemeinsames ─────────────────────────────────────────────────────────────────────
def _inline_source(topic: str, sources: list[str] | None, queries: list[str]) -> tuple[str, str]:
"""→ (source-Slot, capabilities). Korpus-Auszüge inline (uni/projekt/link oder
thema-Research-Material); ohne Treffer fail-open auf die alte Selbst-Recherche."""
mat = material_folder(topic)
ev = _evidence_pack(mat, sources, queries) if mat else ""
if ev:
return _prompt("Blocks-Source-Inline", excerpts=ev), "none"
_type = load_source(topic).get("type", "thema")
folder = source_folder(topic)
if _type in _SOURCE_TEMPLATE:
return _prompt(_SOURCE_TEMPLATE[_type], project=folder), ("files" if folder else "full")
return _prompt("Blocks-Source-Thema", topic=topic), "full"
async def _sims_of(titles: list[str]):
"""Ähnlichkeitsmatrix fürs Variant-Clustering; ohne Modell exakte Norm-Gleichheit."""
if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
sims = await asyncio.to_thread(embedding.embed_sims, titles)
if sims is not None:
return sims
norms = [_norm_title(t) for t in titles]
return [[1.0 if norms[i] == norms[j] else 0.0 for j in range(len(titles))]
for i in range(len(titles))]
def _fk_of(e: dict) -> dict:
return {k: e.get(k) for k in _FACTS_FIELDS}
# ── Generate ────────────────────────────────────────────────────────────────────────
async def _generate_block(ctx: GenContext, files: dict, title: str, description: str,
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None,
seeds: list[str] | None = None, melde=None) -> dict | None:
"""Bottom-up-Anreicherung: die Subs stehen FEST — Board 1 hat die Atome geclustert und
als Subs persistiert. EIN Call liefert pro vorgegebenem Sub die Lern-Facts + Level +
Relevanz aus dem Material. Er entdeckt und entfernt KEINE Subs (Discovery/Konsens/Seeds
entfallen; die Vollständigkeit kommt aus Board 1). → {raw, facts, unsicher, votes} | None."""
topic = ctx.topic
work_dir = files["arbeit"]
bnorm = _norm_title(title)
# feste Subs aus Board 1 (Reihenfolge erhalten, dublettenfrei)
subs: list[str] = []
seen: set[str] = set()
for r in await db.list_subblocks(topic, bnorm):
st = str(r.get("sub_title") or "").strip()
sn = _norm_title(st)
if st and sn and sn not in seen and r.get("status") in ("consensus", "candidate"):
seen.add(sn)
subs.append(st)
if not subs:
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
source, caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + subs)
if melde:
melde("Anreichern")
h = _h8(title, description, "enrich")
pfad = work_dir / f"enrich-{h}.json"
sub_liste = "\n".join(f"{i}. {t}" for i, t in enumerate(subs, 1))
prompt = _prompt("Subblock-Anreichern", topic=topic,
block=f"{title} — {description}" if description else title,
subs=sub_liste, source=source, extra=_extra(instructions))
if _gen_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Anreichern", key=f"blocks-{topic}-{ns}sb-enrich-{h}",
prompt=prompt, role="quick", capabilities=caps,
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
timeout=_timeout("generate", len(subs)))
if status == FAILED:
_log(topic, f"Anreichern {title} ohne Ergebnis — Facts bleiben leer, Prüfer misst")
if ctx.is_cancelled():
return None
out = _gen_schema(_json_file(pfad)) or []
by_norm = {_norm_title(e["title"]): e for e in out}
facts: dict[str, dict] = {}
votes: dict[str, dict] = {}
for st in subs:
sn = _norm_title(st)
e = by_norm.get(sn)
if e:
fk = _fk_of(e)
votes[sn] = {"level": [e["level"]] if e.get("level") else [],
"relevance": [e["relevance"]] if e.get("relevance") else []}
else: # der Call ließ diesen Sub aus — leere Facts, der Prüfer/QA sieht die Lücke
fk = {k: ([] if k in ("key_points", "cited_facts") else "") for k in _FACTS_FIELDS}
votes[sn] = {"level": [], "relevance": []}
facts[sn] = fk
await db.put_subblock(topic, bnorm, sn, title, st, status="consensus")
return {"raw": {title: subs}, "facts": {title: facts}, "unsicher": [], "votes": votes}
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
def _default_vote(stimmen: list[str], default: str) -> str:
"""Mehrheit über die Stimmen (Gen-Vorschläge + implizite/explizite Prüfer-Stimmen);
Patt oder leer → default."""
counter: dict[str, int] = {}
for s in stimmen:
if s:
counter[s] = counter.get(s, 0) + 1
best = max(counter.values(), default=0)
winners = [s for s, v in counter.items() if v == best]
return winners[0] if len(winners) == 1 and best else default
async def _verify_block(ctx: GenContext, files: dict, title: str, gen: dict, q: dict,
instructions: str = "", ns: str = "", lbl: str = "",
sources: list[str] | None = None, melde=None,
keep_all: bool = False) -> dict | None:
"""VERIFY_PANEL unabhängige Prüfer auditieren den Block in EINEM Call (MECE-Faltung,
Fremd, Lücken, Unsicher-Übernahme, Facts-Korrektheit, Level/Relevanz). Auswertung mit
Schnittmengen-Semantik pro Befundklasse (Faltung/Fremd/Übernahme einstimmig, Discard
2/2, Korrektur ≥1 Stimme); Fix-Tail ist EIN Call für Korrekturen + belegte Lücken.
keep_all=True (Bottom-up): kein Atom wird entfernt/gefaltet/als neu erfunden — nur
Facts-Korrektur + Level/Relevanz bleiben (Board 1 hat schon MECE geclustert).
→ {raw, facts, sidecar} | None (nur bei Cancel)."""
topic = ctx.topic
work_dir = files["arbeit"]
bnorm = _norm_title(title)
subs = list(gen["raw"].get(title) or [])
bfacts: dict[str, dict] = dict(gen["facts"].get(title) or {})
unsicher: list[dict] = list(gen.get("unsicher") or [])
votes: dict[str, dict] = gen.get("votes") or {}
nummern = subs + [u["title"] for u in unsicher] # 1-basiert: consensus, dann unsicher
n = len(nummern)
def _kp(t: str) -> list:
fk = bfacts.get(_norm_title(t)) or next(
(u for u in unsicher if u["title"] == t), {})
return (fk.get("key_points") or [])[:3]
if melde:
melde("Verify")
verdicts: list[dict] = []
if n:
zeilen = "\n".join(f"{k}. {t}" + "".join(f"\n - {p}" for p in _kp(t))
for k, t in enumerate(nummern, 1))
u_txt = ""
if unsicher:
erste = len(subs) + 1
u_txt = (f"\nUNSICHER — entries {erste}–{n} were named by only ONE generator "
"(or are seed candidates). Judge their adoption under `uebernehmen`.\n")
cites = [bf.get("source", "") for fk in bfacts.values() for bf in fk.get("cited_facts", [])]
mat = material_folder(topic)
ev = _cited_evidence(mat, sources, cites, [title] + nummern) if mat else ""
if ev:
source = _prompt("Blocks-Source-Inline", excerpts=ev)
else:
source, _caps = await asyncio.to_thread(_inline_source, topic, sources, [title] + nummern)
sh = _subs_hash({title: nummern})
pfade = {j: work_dir / f"verify-{sh}-j{j}.json" for j in (*range(1, VERIFY_PANEL + 1), "E")}
async def _judge(j):
if _verify_schema(_json_file(pfade[j]), n) is not None:
return # resume
status, _v = await run_single_slot(
ctx, f"{lbl}Verify j{j}", key=f"blocks-{topic}-{ns}sb-verify-{sh}-j{j}",
prompt=_prompt("Subblock-Verify", topic=topic, block=title, subs=zeilen,
unsicher=u_txt, source=source, extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=pfade[j]: _sink_json(result, p, lambda d: _verify_schema(d, n)),
timeout=_timeout("verify", n))
if status == FAILED:
_log(topic, f"Verify {title} j{j} ohne Ergebnis — fail-open")
await asyncio.gather(*[_judge(j) for j in range(1, VERIFY_PANEL + 1)])
if ctx.is_cancelled():
return None
verdicts = [v for j in range(1, VERIFY_PANEL + 1)
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None]
if len(verdicts) == 1 and VERIFY_PANEL >= 2: # Ersatz-Richter statt fail-open
await _judge("E")
if ctx.is_cancelled():
return None
verdicts = [v for j in (*range(1, VERIFY_PANEL + 1), "E")
if (v := _verify_schema(_json_file(pfade[j]), n)) is not None][:2]
einstimmig = len(verdicts) >= 2
if n and not einstimmig:
_log(topic, f"Verify {title}: nur {len(verdicts)}/2 Prüfer — fail-open, unsicher verworfen")
negs = [_neg_set(t) for t in nummern]
gone: set[int] = set()
keep = list(subs)
korrekturen: list[dict] = [] # {titel, hinweis}
luecken: list[str] = []
def _titel(k: int) -> str:
return nummern[k - 1]
async def _fold(k: int, wf: dict | None):
t = _titel(k)
lf = bfacts.pop(_norm_title(t), None) or {}
if wf is not None:
_facts_union(wf, lf)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="variant")
if t in keep:
keep.remove(t)
gone.add(k)
if einstimmig:
v1, v2 = verdicts[0], verdicts[1]
if keep_all:
# Bottom-up: jedes Board-1-Atom bleibt. Destruktive/erzeugende Befunde
# neutralisieren — die Loops unten laufen dann leer. Nur Facts-Korrektur
# (ohne discard) + Level/Relevanz überleben.
for v in (v1, v2):
v["fremd"] = set()
v["gruppen"] = []
v["kataloge"] = []
v["uebernehmen"] = {}
v["luecken"] = []
# discard abschalten (kein Atom entfernen), aber den Korrektur-Hinweis behalten
v["facts_probleme"] = [{**p, "discard": False} for p in v["facts_probleme"]]
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
for k in sorted(v1["fremd"] & v2["fremd"]):
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
# 2. Unsicher-Übernahme (2/2 „ja"): wird consensus samt Generator-Facts
for k in range(len(subs) + 1, n + 1):
if k in gone:
continue
u = unsicher[k - len(subs) - 1]
if v1["uebernehmen"].get(k) == "ja" and v2["uebernehmen"].get(k) == "ja":
sn = _norm_title(u["title"])
bfacts[sn] = _fk_of(u)
keep.append(u["title"])
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
else:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
gone.add(k)
# 3. Gruppen (einstimmige Paare): haupt-Votum, sonst key_points/Länge
haupt_votes: dict[int, int] = {}
for v in (v1, v2):
for g in v["gruppen"]:
if g["haupt"]:
haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["gruppen"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
win = max(g, key=lambda k: (haupt_votes.get(k, 0), len(_kp(_titel(k))), len(_titel(k)), -k))
wf = bfacts.setdefault(_norm_title(_titel(win)), {})
for k in g:
if k != win:
await _fold(k, wf)
# 4. Kataloge: Aufzählungszeilen → EIN neuer benannter Sub (Facts-Union)
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["kataloge"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
titel = next((clean_title(x["titel"]) for x in v1["kataloge"] + v2["kataloge"]
if set(x["ids"]) & set(g) and clean_title(x["titel"])), "")
kn = _norm_title(titel)
if not kn or kn in {_norm_title(s) for s in keep}:
continue
kf: dict = {}
for k in g:
await _fold(k, kf)
bfacts[kn] = kf
keep.append(titel)
await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus")
# 5. Facts-Probleme: discard nur 2/2 (irreversibel), Korrektur ab 1 Stimme
d1 = {p["nr"] for p in v1["facts_probleme"] if p["discard"]}
d2 = {p["nr"] for p in v2["facts_probleme"] if p["discard"]}
for k in sorted(d1 & d2):
if k in gone:
continue
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
for p in v1["facts_probleme"] + v2["facts_probleme"]:
k = p["nr"]
if k in gone or not p["hinweis"]:
continue
t = _titel(k)
if t in keep and all(x["titel"] != t for x in korrekturen):
korrekturen.append({"titel": t, "hinweis": p["hinweis"]})
# 6. Lücken (Schnitt beider Prüfer, Cap)
luecken = _luecken_schnitt(v1["luecken"], v2["luecken"])
else:
# fail-open: consensus bleibt, unsicher wird verworfen (wie heutiges Clarify-Aus)
for u in unsicher:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
# 7. Level/Relevanz: Stimmen = Generatoren + Prüfer (explizite Korrektur schlägt
# die implizite Zustimmung); Patt → advanced/relevant (heutige Defaults)
sidecar_subs = []
for t in keep:
sn = _norm_title(t)
try:
k = nummern.index(t) + 1
except ValueError:
k = 0 # Katalog-/Fix-Neuzugänge haben keine Nummer
stimmen_l = list((votes.get(sn) or {}).get("level") or [])
stimmen_r = list((votes.get(sn) or {}).get("relevance") or [])
for v in verdicts[:2]:
if k and k in v["levels"]:
stimmen_l += [v["levels"][k]] * 2 # explizite Korrektur wiegt doppelt
if k and k in v["relevanz"]:
stimmen_r += [v["relevanz"][k]] * 2
fk = bfacts.get(sn) or {}
sidecar_subs.append({"title": t, "level": _default_vote(stimmen_l, "advanced"),
"relevance": _default_vote(stimmen_r, "relevant"), "facts": fk})
# 8. Fix-Tail (0–1 Call): Korrekturen + belegte Lücken
if (korrekturen or luecken) and not ctx.is_cancelled():
if melde:
melde("Fix")
neu = await _fix_befunde(ctx, files, title, korrekturen, luecken,
[s["title"] for s in sidecar_subs], instructions, ns, lbl, sources)
for e in neu or []:
sn = _norm_title(e["title"])
vorhanden = next((s for s in sidecar_subs if _norm_title(s["title"]) == sn), None)
if vorhanden is not None: # Korrektur: Facts ersetzen, Einstufung bleibt
vorhanden["facts"] = _fk_of(e)
bfacts[sn] = vorhanden["facts"]
elif not keep_all: # Lücken-Fund: neuer consensus-Sub
# (Bottom-up unterdrückt das: die Subs stehen fest, der Fix-Call darf keine
# neuen einschleusen — sonst wächst die Sub-Zahl über die Board-1-Atome hinaus)
bfacts[sn] = _fk_of(e)
keep.append(e["title"])
sidecar_subs.append({"title": e["title"],
"level": e["level"] or "advanced",
"relevance": e["relevance"] or "relevant",
"facts": bfacts[sn]})
await db.put_subblock(topic, bnorm, sn, title, e["title"], status="consensus")
if len(keep) != len(subs):
_log(topic, f"Verify {title}: {len(subs)} consensus + {len(unsicher)} unsicher → {len(keep)}")
return {"raw": {title: [s["title"] for s in sidecar_subs]},
"facts": {title: bfacts},
"sidecar": {title: sidecar_subs}}
async def _fix_befunde(ctx: GenContext, files: dict, title: str, korrekturen: list[dict],
luecken: list[str], vorhanden: list[str], instructions: str,
ns: str, lbl: str, sources: list[str] | None) -> list[dict]:
"""EIN Call korrigiert beanstandete Facts und füllt gemeldete Lücken. Hartes Beleg-Gate
für Neuzugänge (key_points/cited_facts nicht leer) + Dedup gegen den Bestand — ein
unbelegter Lücken-„Fund" flutet sonst das Fakten-Gate des Guides."""
topic = ctx.topic
work_dir = files["arbeit"]
auftraege = [f"- KORRIGIEREN: „{k['titel']}“ — {k['hinweis']}" for k in korrekturen]
auftraege += [f"- LÜCKE (neuer Subbaustein, nur wenn belegbar): {l}" for l in luecken]
source, caps = await asyncio.to_thread(
_inline_source, topic, sources,
[title] + [k["titel"] for k in korrekturen] + list(luecken))
sh = _h8(title, *sorted(a for a in auftraege))
pfad = work_dir / f"fix-{sh}.json"
if _gen_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Fix", key=f"blocks-{topic}-{ns}sb-fix-{sh}",
prompt=_prompt("Subblock-Fix", topic=topic, block=title, source=source,
auftraege="\n".join(auftraege), extra=_extra(instructions)),
role="quick", capabilities=caps,
payload=lambda result, p=pfad: _sink_json(result, p, _gen_schema),
timeout=_timeout("fix", len(auftraege)))
if status == FAILED:
_log(topic, f"Fix {title} ohne Ergebnis — Befunde bleiben offen")
return []
out = _gen_schema(_json_file(pfad)) or []
korrektur_norms = {_norm_title(k["titel"]) for k in korrekturen}
have_norms = {_norm_title(t) for t in vorhanden}
angenommen = []
for e in out:
sn = _norm_title(e["title"])
if sn in korrektur_norms:
angenommen.append(e)
continue
if sn in have_norms or not (e["key_points"] or e["cited_facts"]):
continue # unbelegt oder Dublette → verfällt
st = _sub_tokens(e["title"])
if any(st <= _sub_tokens(t) or _sub_tokens(t) <= st for t in vorhanden):
continue
angenommen.append(e)
have_norms.add(sn)
return angenommen
# ── Artefakte ───────────────────────────────────────────────────────────────────────
def _subs_text(title: str, sidecar_subs: list[dict]) -> str:
return f"BLOCK: {title}\n" + "\n".join(
f"- {s['title']}\n" + "\n".join(f" {z}" for z in _facts_lines(s.get("facts") or {}).splitlines())
for s in sidecar_subs)
async def _artefakte_block(ctx: GenContext, files: dict, title: str,
sidecar_subs: list[dict], instructions: str = "",
ns: str = "", lbl: str = "", melde=None) -> dict | None:
"""EIN Generator-Call liefert Fragen + Flashcards + Beispiele (Split in 2 parallele
Calls bei > ART_SPLIT_SUBS Subs), EIN Prüfer-Call verifiziert Beispiele, bereinigt
die Fragen und ergänzt fehlende. → {pattern, artefacts} | None (nur Cancel)."""
topic = ctx.topic
work_dir = files["arbeit"]
if not sidecar_subs:
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
if melde:
melde("Artefakte gen")
sh = _subs_hash({title: sidecar_subs})
haelften = ([sidecar_subs] if len(sidecar_subs) <= ART_SPLIT_SUBS
else [sidecar_subs[:len(sidecar_subs) // 2], sidecar_subs[len(sidecar_subs) // 2:]])
async def _gen(gi: int, teil: list[dict]):
pfad = work_dir / f"art-{sh}-t{gi}.json"
if _art_gen_schema(_json_file(pfad)) is not None:
return
status, _v = await run_single_slot(
ctx, f"{lbl}Artefakte {gi}", key=f"blocks-{topic}-{ns}art-gen-{sh}-t{gi}",
prompt=_prompt("Artefakt-Generate", topic=topic, blocks=_subs_text(title, teil),
extra=_extra(instructions)),
role="quick", capabilities="none",
payload=lambda result, p=pfad: _sink_json(result, p, _art_gen_schema),
timeout=_timeout("artefakt", len(teil)))
if status == FAILED:
_log(topic, f"Artefakte {title} Teil {gi} ohne Ergebnis")
await asyncio.gather(*[_gen(gi, teil) for gi, teil in enumerate(haelften, 1)])
if ctx.is_cancelled():
return None
pattern: list[dict] = []
cards: list[dict] = []
examples: list[dict] = []
for gi in range(1, len(haelften) + 1):
o = _art_gen_schema(_json_file(work_dir / f"art-{sh}-t{gi}.json"))
if o:
pattern += o["pattern"]
cards += o["cards"]
examples += o["examples"]
# Prüfer: Beispiele verifizieren, Fragen bereinigen + fehlende ergänzen
sub_titles = [s["title"] for s in sidecar_subs]
fehlend = [t for t in sub_titles
if _norm_title(t) not in {_norm_title(p["subblock"]) for p in pattern}]
if pattern or examples:
if melde:
melde("Artefakte check")
tabelle = "\n".join(f"({p['subblock']}) {p['question']}" for p in pattern) or "(keine)"
beisp = "\n\n".join(
f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e["steps"])
+ (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "")
for k, e in enumerate(examples, 1)) or "(keine)"
fehlend_txt = ("\nSUBBLOCKS STILL MISSING A QUESTION:\n"
+ "\n".join(f"- {t}" for t in fehlend) + "\n") if fehlend else "\n"
pfad = work_dir / f"artcheck-{sh}.json"
if _art_check_schema(_json_file(pfad)) is None:
status, _v = await run_single_slot(
ctx, f"{lbl}Artefakt-Check", key=f"blocks-{topic}-{ns}art-check-{sh}",
prompt=_prompt("Artefakt-Check", topic=topic, facts=_subs_text(title, sidecar_subs),
table=tabelle, fehlend=fehlend_txt, examples=beisp,
extra=_extra(instructions)),
role="judge", capabilities="none",
payload=lambda result, p=pfad: _sink_json(result, p, _art_check_schema),
timeout=_timeout("artefakt_check", len(sidecar_subs)))
if status == FAILED:
_log(topic, f"Artefakt-Check {title} ohne Ergebnis — Rohfassung übernommen")
check = _art_check_schema(_json_file(pfad))
if check:
if check["examples_probleme"]:
examples = [e for k, e in enumerate(examples, 1)
if k not in check["examples_probleme"]]
_log(topic, f"Artefakt-Check {title}: {len(check['examples_probleme'])} Beispiel(e) verworfen")
if check["pattern"]: # bereinigte Fassung ersetzt die Rohfassung
pattern = check["pattern"]
pattern += check["pattern_ergaenzt"]
pattern_map = {title: [{"subblock": p["subblock"], "question": p["question"]}
for p in pattern]}
# block-Feld auf den Karten-Block normieren (Ein-Block-Call — Agent-Echos abfangen)
for e in cards + examples:
e["block"] = title
return {"pattern": pattern_map,
"artefacts": {"flashcard": [{k: e[k] for k in ("block", "subblock", "question", "answer")} for e in cards],
"example": [{k: e[k] for k in ("block", "subblock", "problem", "steps", "result")} for e in examples]}}
+214 -1839
View File
File diff suppressed because it is too large. Load diff
+108 -348
View File
@@ -1,33 +1,25 @@
"""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
generate → verify (inkl. Fix-Tail) → artefakte (Gen + Prüfer) → finalize
(die verschmolzenen Calls liegen in block_calls.py — 4–5 serielle Segmente statt ~20).
finalize (SERIAL) merges the block's results into the global sidecar/facts/pattern/artefakte
files + the DB tables. `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."""
files + the DB tables. Danach eine topic-weite BARRIERE: `outline` (prerequisite graph →
chapter order), re-run once per generation run."""
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 block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _outline_block
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from kanban import Flow, Stage
from pipeline import FAILED, GenContext, _extra, _log, _prompt, _timeout, run_single_slot
from pipeline import GenContext, _log
from textkit import _norm_title, _title
log = logging.getLogger("creator.board_artefacts")
@@ -99,7 +91,7 @@ def make_spawner(topic: str, files: dict):
norm = payload.get("mirrored_norm")
if not norm:
return
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "subblocks", {
await db.kanban_upsert_card(topic, BOARD, norm, "ablock", "generate", {
"title": payload.get("title", ""),
"description": payload.get("description", ""),
"n_size": payload.get("n_size", 0), # LPT estimate until subs_n exists
@@ -107,10 +99,27 @@ def make_spawner(topic: str, files: dict):
return spawn
async def seed_artefact_cards(topic: str, files: dict) -> int:
"""Board-2-only (Inventar läuft NICHT mit): für jeden fertigen Block eine ablock-Karte
erzeugen, falls noch keine existiert — sonst liefe ohne den Board-1-Spawn-Hook nichts
durch Board 2. Vorhandene Karten bleiben (sie resümieren in ihrer Spalte). → neue Karten."""
spawn = make_spawner(topic, files)
existing = {c["card_id"] for c in await db.kanban_cards(topic, board=BOARD, kind="ablock")}
n = 0
for c in await db.kanban_cards(topic, board="inventory", kind="block", stage="done_block"):
norm = c["payload"].get("mirrored_norm")
if norm and norm not in existing:
await spawn(c["card_id"], c["payload"])
n += 1
return n
async def _gather_cards(ctx: GenContext, flow: Flow, cards, one):
results = await asyncio.gather(*[one(c) for c in cards], return_exceptions=True)
errs = [r for r in results if isinstance(r, Exception)]
if errs:
for e in errs[1:]: # nur errs[0] wird re-raised — der Rest darf nicht stumm verschwinden
log.error("weitere Karten-Exception im Batch: %r", e)
raise errs[0]
flow.wake.set()
@@ -171,353 +180,90 @@ async def _seed_map(topic: str) -> dict[str, list[str]]:
return seeds
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
def _melder(flow: Flow, norm: str):
"""Live-Stepper der Karte: Phasenname → card_info (ging bei der Call-Verschmelzung
verloren — Karten liefen ohne Badge/Stepper durch das Board)."""
set_p = _card_set_p(flow, norm)
def melde(schritt: str) -> None:
try:
set_p(f"{schritt}…", step=blocks._step_idx(flow.topic, schritt))
except ValueError:
set_p(f"{schritt}…")
return melde
async def _proc_generate(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Verschmolzener Erzeuger: 2 unabhängige Generatoren liefern Subs+Facts+Einstufung
in EINEM Call, Konsens im Code (block_calls._generate_block)."""
topic = flow.topic
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
# 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
gen = await _generate_block(ctx, _pfiles(files, norm), p.get("title", ""),
p.get("description", ""), instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ",
sources=p.get("sources"),
seeds=[s for s in seeds.get(norm, []) if s] or None,
melde=_melder(flow, norm))
if gen is None:
return _fail_or_cancel(ctx, f"Generate {p.get('title', norm)}")
p["raw"], p["facts"] = gen["raw"], gen["facts"]
p["unsicher"], p["votes"] = gen["unsicher"], gen["votes"]
p["subs_n"] = sum(len(v) for v in gen["raw"].values()) + len(gen["unsicher"])
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "facts")
await db.kanban_advance(topic, BOARD, norm, "verify")
await _gather_cards(ctx, flow, cards, one)
async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
instructions: str, cards):
async def _proc_verify(ctx: GenContext, flow: Flow, files: dict, q: dict, instructions: str, cards):
"""Verschmolzener Prüfer: MECE + Facts + Einstufung in einem Panel-Call, Fix-Tail
inklusive (block_calls._verify_block) — ersetzt facts-check/konsolidierung/levels/
relevance als eigene Stages."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
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"))
title = p.get("title", "")
gen = {"raw": p.get("raw") or {}, "facts": p.get("facts") or {},
"unsicher": p.get("unsicher") or [], "votes": p.get("votes") or {}}
res = await _verify_block(ctx, _pfiles(files, norm), title, gen, q, instructions,
ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
sources=p.get("sources"), melde=_melder(flow, norm),
keep_all=True)
if res is None:
return _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
return None # nur Cancel — Karte bleibt liegen
p["raw"], p["facts"], p["sidecar"] = res["raw"], res["facts"], res["sidecar"]
p.pop("unsicher", None)
p.pop("votes", None)
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "levels")
await db.kanban_advance(topic, BOARD, norm, "artefakte")
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):
async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter
(block_calls._artefakte_block)."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
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
title = p.get("title", "")
res = await _artefakte_block(ctx, _pfiles(files, norm), title,
(p.get("sidecar") or {}).get(title) or [],
instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
melde=_melder(flow, norm))
if res is None:
return None # nur Cancel
p["pattern"] = res["pattern"]
p["artefacts"] = res["artefacts"]
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "finalize")
@@ -639,23 +385,37 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
# ── Stage list (appended after board 1 in chain order) ─────────────────────────────
_ALT_STAGES = ("subblocks", "facts", "levels", "relevance", "question_pattern", "artefacts",
"konsolidierung") # abgeschaltete Stage: verwaiste Karten auf generate zurück
async def migriere_alt_karten(topic: str) -> int:
"""Harter Schnitt: Karten der alten Stage-Treppe beim Flow-Start auf `generate`
zurücksetzen (Payload auf die Spawn-Felder reduziert — Zwischenstände der alten
Struktur sind für die verschmolzenen Calls wertlos). → Anzahl migrierter Karten."""
moves = []
for c in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
if c["stage"] in _ALT_STAGES:
p = c["payload"]
await db.kanban_set_payload(topic, BOARD, c["card_id"], {
"title": p.get("title", ""), "description": p.get("description", ""),
"n_size": p.get("n_size", 0), "sources": p.get("sources")})
moves.append((c["card_id"], "generate"))
if moves:
await db.kanban_advance_many(topic, BOARD, moves)
return len(moves)
def artefact_stages(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
instructions: str) -> list[Stage]:
research_done = lambda: flow.research_done # noqa: E731
return [
Stage(BOARD, "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, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
# Cross-Block-Dedup (konsolidierung) entfällt im Bottom-up: Board 1 hat die Atome
# global geclustert/dedupliziert, es gibt keine block-übergreifenden Sub-Dubletten mehr.
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
barrier=True, drain=True, gate=research_done),
]
+498 -132
View File
@@ -39,27 +39,27 @@ import kanban
from kanban import Flow, Stage, chain_stages
import blocks
from blocks import (
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
RESEARCH_THEMA_AGENTS, _FILTER_NOTATION, _GROUP_STANDALONE,
_FILTER_NOTATION,
_build_research_prompt, _canonical, _canonical_key, _chunk_nums, _cliques,
_completion_schema, _containment_parent, _crawl_index, _file_payload,
_filter_schema, _filter_suspect, _is_artifact, _is_named_statement,
_is_parentless_noise, _is_reference, _pairs_schema, _read,
_relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
_is_parentless_noise, _is_reference, _reference_strip, _pairs_schema, _read,
_direction_conflict, _relation_conflict, _root, _supplement_schema, _text_sections, _umbrella_schema,
_aspect_marker, _title_variants, _corpus_files, _evidence_pack, _sink_json, source_folder,
)
from config import (QA_GATE_NOTE, QA_GATE_LLM,
from config import (QA_GATE_NOTE, QA_GATE_LLM, LUECKEN_RESEARCH_MAX,
DEDUP_GLOBAL_FLOOR, DEDUP_PAIR_FLOOR, DEDUP_PAIRS_CHUNK, DEDUP_TITLE_AUTO, FILTER_CHUNK,
FILTER_RECHECK_PANEL, CONSOLIDATION_PANEL, RESEARCH_BATCH, RESEARCH_READERS,
RESEARCH_THEMA_AGENTS,
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
GROUP_RECONCILE_FLOOR, GROUP_THEMES_PER_SQRT,
)
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
_runde_schema, _timeout, _yesno_schema, run_single_slot)
from textkit import _norm_title, _parse_selection, _title, clean_title
from textkit import _norm_title, _parse_selection, _title, _unclosed, clean_title
log = logging.getLogger("creator.board_inventory")
@@ -104,6 +104,20 @@ def _naming_schema(data, count: int) -> tuple[int | None, str | None, bool] | No
return n, name, False
def _sanierung_schema(data) -> tuple[str, str] | None:
"""{"title": …, "description": …} → (title, description), beides getrimmt; Titel über
80 Zeichen verfällt (leerer String = kein Vorschlag, Feld bleibt wie es ist)."""
if not isinstance(data, dict):
return None
t = str(data.get("title") or "").strip()
d = str(data.get("description") or "").strip()
if not t and not d:
return None
if len(t) > 80:
t = ""
return t, d
def _name_verankert(name: str, rows: list[dict], ctoks: set[str] | None) -> bool:
"""Abstraction guard: a free-formed title must be anchored — in the corpus (uni/projekt)
or in the members' own words (thema). Unanchored names drift to textbook canon
@@ -195,6 +209,10 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi
work_dir = flow.work_dir
caps = "files" if folder else "full"
p = work_dir / f"research-{tag}.md"
mp = None
if folder is None: # thema: Fundstellen als Material für die Inline-Folgeschritte sichern
(work_dir / "material").mkdir(parents=True, exist_ok=True)
mp = work_dir / "material" / f"research-{tag}.txt"
stop = asyncio.Event()
buf: list[str] = [] # assistant text streamed live from the JSON events
@@ -223,7 +241,7 @@ async def _research_once(ctx: GenContext, flow: Flow, q: dict, folder, instructi
await run_single_slot(
ctx, f"Research {tag}", key=f"blocks-{ctx.topic}-research-{tag}",
prompt=_build_research_prompt(ctx.topic, p, instructions, q["type"], folder,
fokus=fokus, section=section),
fokus=fokus, section=section, material_path=mp),
role="quick", capabilities=caps,
payload=(lambda result, p=p: _file_payload(p)),
timeout=RESEARCH_RUNTIME, on_line=_on_line,
@@ -405,7 +423,8 @@ async def _pair_one(ctx: GenContext, flow: Flow, c):
for ci, chunk in enumerate(chunks):
verdict = _pairs_schema(_json_file(flow.work_dir / f"pair-{cid}-{h}-c{ci}.json")) or {}
for j, (a, b) in enumerate(chunk):
if verdict.get(j + 1) and not _relation_conflict(rows[a]["title"], rows[b]["title"]):
# Judge entscheidet Operanden-Identität selbst; nur getauschte Richtung bleibt geblockt
if verdict.get(j + 1) and not _direction_conflict(rows[a]["title"], rows[b]["title"]):
edges.append((a, b))
if sims_title is not None: # auto recall net: near-identical titles merge without the judge
for a, b in ordered:
@@ -470,6 +489,20 @@ def _hat_anker(title: str, ctoks: set[str]) -> bool:
return False
def _sanierung_noetig(p: dict, ctoks: set[str] | None) -> bool:
"""QA-messbare Befund-Formen am Entstehungsort: Titel ohne Korpus-Anker (QA: fremd —
misst Token-Anker, nicht Semantik) oder leere Beschreibung (QA: hygiene). Zusätzlich ein
Referenz-Titel (reine Katalog-Nummer), eine unbalancierte Klammer oder ein überlanger Titel
(>80). Gilt auch für Singleton-Cluster, die das Naming sonst überspringen — Reader-Rohtitel
gingen wörtlich bis done_block durch (gemessen: 'k-Coloring' statt Korpus-Form 'k-Color')."""
title = p.get("title", "")
if not (p.get("description") or "").strip():
return True
if _is_reference(title) or _unclosed(title) or len(title) > 80:
return True
return bool(ctoks) and not _hat_anker(title, ctoks)
async def _anker_beleg(ctx: GenContext, flow: Flow, kandidaten: list[tuple[str, dict]]) -> set[str]:
"""Evidence judge for quorum titles WITHOUT any corpus anchor — two readers naming the
same famous canon independently beat the quorum although the material never mentions it
@@ -545,6 +578,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
else:
moves.append((cid, "naming"))
else:
# single-reader find: majority of the Klärung panel suffices (was unanimity — a single
# "nein" discarded a real once-mentioned concept as failed-quorum, e.g. Cook-Levin)
p["quorum"] = "majority"
moves.append((cid, "clarify"))
await db.kanban_set_payload(topic, BOARD, cid, p)
if anker_kandidaten:
@@ -564,8 +600,9 @@ async def _proc_consensus_gate(ctx: GenContext, flow: Flow, cards):
async def _proc_clarify(ctx: GenContext, flow: Flow, cards):
"""Single-reader finds: deterministic pre-reject, then a 3-judge panel (Blocks-Klaerung).
Quorum: UNANIMITY for single-reader clusters (origin-split C1), majority for reference-titled
consensus clusters. Kept reference titles get the panel's rename."""
Quorum: MAJORITY of the panel for single-reader clusters and for reference-titled consensus
clusters (unanimity dropped real once-mentioned concepts on a single dissent). Kept reference
titles get the panel's rename."""
topic = flow.topic
moves: list[tuple[str, str]] = []
pending = []
@@ -703,9 +740,59 @@ async def _name_one(ctx: GenContext, flow: Flow, c):
p["title"] = custom or w["title"]
p["description"] = w.get("description") or p.get("description", "")
await db.kanban_set_payload(topic, BOARD, cid, p)
await _saniere_one(ctx, flow, cid, p)
await db.kanban_advance(topic, BOARD, cid, "naming_check")
async def _saniere_one(ctx: GenContext, flow: Flow, cid: str, p: dict) -> None:
"""Fremd-/Hygiene-Sanierung am Entstehungsort (nur Korpus-Quellen): Titel ohne
Korpus-Anker auf die Oberflächenform des Materials umschreiben, leere Beschreibung
belegt nachfassen. Läuft NACH der Member-Wahl auf dem finalen Payload — greift damit
auch für Singleton-Cluster und unverankerte Naming-Gewinner. Vorschläge werden
deterministisch validiert (_hat_anker, dieselbe Messlatte wie QA-fremd); fail-open:
unverankerter Vorschlag oder Judge-Ausfall lässt die Karte unverändert weiter."""
from qa import _distinctive
topic = flow.topic
folder = source_folder(topic)
if folder is None:
return # thema: kein Korpus, keine Oberflächenform zum Verankern
ctoks = flow.state.get("korpus_tokens")
if ctoks is None: # Resume: das Konsens-Gate dieses Flows lief nie
ctoks = flow.state["korpus_tokens"] = await asyncio.to_thread(_korpus_tokens, folder)
if not _sanierung_noetig(p, ctoks):
return
titel, beschr = p.get("title", ""), (p.get("description") or "").strip()
toks = _distinctive(titel) | _distinctive(beschr)
ev = _evidence_pack(folder, p.get("sources") or None, [" ".join(sorted(toks))],
budget=4000) if toks else ""
if not ev:
return # ohne Auszüge kein Anker-Rewrite — Anker-Beleg hat Echtheit schon entschieden
h = _h(titel, beschr, "sanierung")
path = flow.work_dir / f"sanierung-{cid}-{h}.json"
verdict = _sanierung_schema(_json_file(path))
if verdict is None:
status, verdict = await run_single_slot(
ctx, f"Sanierung {cid}", key=f"blocks-{topic}-sanierung-{cid}-{h}",
prompt=_prompt("Blocks-Sanierung", topic=topic, title=titel,
description=beschr or "(leer)", excerpts=ev),
role="judge", capabilities="none",
payload=lambda result, p2=path: _sink_json(result, p2, _sanierung_schema),
timeout=_timeout("selection_mapping", 1))
if status != OK or verdict is None:
return
neu_titel, neu_beschr = verdict
changed = False
if (neu_titel and _norm_title(neu_titel) != _norm_title(titel)
and _hat_anker(neu_titel, ctoks)):
p["title"] = clean_title(neu_titel)
changed = True
if not beschr and neu_beschr:
p["description"] = neu_beschr
changed = True
if changed:
await db.kanban_set_payload(topic, BOARD, cid, p)
async def _proc_naming_check(ctx: GenContext, flow: Flow, cards):
results = await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
errs = [r for r in results if isinstance(r, Exception)]
@@ -731,6 +818,12 @@ async def _namecheck_one(ctx: GenContext, flow: Flow, c):
p["description"] = w.get("description") or p.get("description", "")
readers = sorted(set().union(*[set(r.get("readers") or []) for r in rows])) if rows else []
sources = sorted(set().union(*[set(r.get("sources") or []) for r in rows])) if rows else []
# deterministic catalogue-number strip (final title): a source numbering is never canonical —
# "Satz 7.13 (Christofides)" → "Christofides", "Satz 7.6: Kriterium …" → "Kriterium …". Runs AFTER
# naming_check so a re-picked member title cannot re-introduce the number.
stripped = _reference_strip(p.get("title", ""))
if stripped and _norm_title(stripped) != _norm_title(p.get("title", "")) and not _is_reference(stripped):
p["title"] = clean_title(stripped)
await db.kanban_upsert_card(topic, BOARD, f"b-{cid}", "block", "fragment_filter", {
"title": p.get("title", ""), "description": p.get("description", ""),
"readers": readers, "sources": sources, "n_size": len(readers),
@@ -946,13 +1039,21 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
for nr in nums:
if nr in fragments or nr in honored:
continue
votes = len(dem.get(nr, []))
if drp.get(nr, 0) >= 2 and (_is_artifact(allrows[nr - 1]["title"])
or _is_parentless_noise(allrows[nr - 1]["title"])):
honored.add(nr)
elif len(dem.get(nr, [])) >= 2:
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
elif nr in proposals:
overruled.append(nr)
# a pass-1 judge already proposed this demote — overturn it ONLY on a
# UNANIMOUS recheck keep (zero demote votes). 15/17 majority-rescued cards
# were later QA duplicates, so a single recheck demote reconfirms the fragment.
if votes >= 1:
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
else:
overruled.append(nr)
elif votes >= 2:
# pure ⚠ suspect without a pass-1 proposal: still needs a panel majority
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
# embedding backstop: veto confirmed non-containment demotes whose direct title pair is
# literally structureless (see FRAGMENT_MIN_COS) — applied BEFORE _root resolution.
floor_veto: list[int] = []
@@ -1104,7 +1205,10 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
detail: dict[tuple[int, int], str] = {}
def _edge(a, b, kanal):
if _relation_conflict(allrows[a]["title"], allrows[b]["title"]):
# Auto-Merges (kein Judge): voller String-Guard. Judge-Kanal ("ja"): nur der deterministische
# Richtungs-Guard — Operanden-Identität (Synonym vs. echt verschieden) hat der Judge entschieden.
guard = _direction_conflict if kanal == "ja" else _relation_conflict
if guard(allrows[a]["title"], allrows[b]["title"]):
detail[(a, b)] = "guard_veto"
else:
edges.append((a, b))
@@ -1168,6 +1272,80 @@ async def _proc_dedup(ctx: GenContext, flow: Flow, cards):
flow.wake.set()
async def _attach_nachzuegler(ctx: GenContext, flow: Flow, rows: list[dict]) -> set[str]:
"""Nachzügler-Atome (Supplement-/Lücken-Welle) zuerst an BESTEHENDE fertige Blöcke
anschließen: das Atom wird Sub des Blocks (Bottom-up-Persistenz) statt Zwergblock
mit einem Sub; die Board-2-Karte des Blocks geht zurück auf generate (Re-Anreicherung,
Resume-Hash ändert sich mit der Sub-Menge). Erstwelle hat keine done_block-Blöcke →
no-op. Ohne Zuordnung bleibt das Atom standalone (echtes neues Konzept).
→ card_ids der angeschlossenen rows."""
topic = flow.topic
done = [c for c in await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
if c["payload"].get("mirrored_norm")]
if not done or not rows:
return set()
work_dir = flow.work_dir
h = _h(*[r["card_id"] for r in rows], "attach")
path = work_dir / f"gruppierung-attach-{h}.json"
ids = set(range(1, len(rows) + 1))
add = _completion_schema(_json_file(path), len(done), ids)
if add is None:
anchors = "\n".join(
f"UMBRELLA {k}: {c['payload'].get('title', '')} — {(c['payload'].get('description') or '')[:160]}"
for k, c in enumerate(done))
rest = "\n".join(f"{i}. {_t_text(r)}" for i, r in enumerate(rows, 1))
status, add = await run_single_slot(
ctx, "Gruppierung Anschluss", key=f"blocks-{topic}-gruppierung-attach-{h}",
prompt=_prompt("Blocks-Gruppierung-Completion", topic=topic, umbrellas=anchors,
rest=rest, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _completion_schema(_json_file(p), len(done), ids),
timeout=_timeout("research_mapping", len(rows)))
if status != OK or add is None:
return set()
attached: set[str] = set()
moves = []
for k, members in (add or []):
c = done[k]
bnorm = c["payload"]["mirrored_norm"]
btitle = c["payload"].get("title", "")
traf = False
for m in members:
if not (1 <= m <= len(rows)) or rows[m - 1]["card_id"] in attached:
continue
r = rows[m - 1]
sn = _norm_title(r["title"])
if not sn:
continue
await db.put_subblock(topic, bnorm, sn, btitle, r["title"], status="consensus")
r["payload"].update(reason="attach", merged_into=btitle)
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
moves.append((r["card_id"], "grouped"))
attached.add(r["card_id"])
ch = c["payload"].setdefault("children", [])
if r["title"] not in ch:
ch.append(r["title"])
traf = True
if traf:
await db.kanban_set_payload(topic, BOARD, c["card_id"], c["payload"])
b2 = await db.kanban_get_card(topic, "artefacts", bnorm)
if b2 and b2["stage"] != "generate": # neuen Sub anreichern lassen
await db.kanban_advance(topic, "artefacts", bnorm, "generate")
if moves:
await db.kanban_advance_many(topic, BOARD, moves)
_log(topic, f"Gruppierung: {len(moves)} Nachzügler an bestehende Blöcke angeschlossen")
return attached
def _themen_zielband(n: int) -> tuple[int, int]:
"""Weiches Prompt-Band der Themenzahl: k = GROUP_THEMES_PER_SQRT·√n, ±30 %, min 2.
n ist die Item-Zahl der GROUPING-WELLE — die Supplement-Welle bekommt bewusst ein
kleines Band aus ihrem kleinen n (sie sortiert nur Neuzugänge nach)."""
k = GROUP_THEMES_PER_SQRT * math.sqrt(max(n, 1))
lo = max(2, math.floor(0.7 * k))
return lo, max(lo + 1, math.ceil(1.3 * k))
async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
"""BARRIER/drain — umbrella grouping over the filter survivors: embedding sibling clusters
(low floor, high recall) + top-down pass, one judge per cluster, type gate + min-cos backstop,
@@ -1178,6 +1356,13 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
"title": c["payload"].get("title", ""),
"description": c["payload"].get("description") or "",
"title_norm": _norm_title(c["payload"].get("title", ""))} for c in cards]
# Nachzügler-Wellen: erst an bestehende fertige Blöcke anschließen (Sub statt Zwergblock)
attached = await _attach_nachzuegler(ctx, flow, rows)
if attached:
rows = [r for r in rows if r["card_id"] not in attached]
if not rows:
flow.wake.set()
return
n = len(rows)
if not (BLOCKS_GRUPPIERUNG_AKTIV and await _emb_ok(flow)) or n < 3:
await db.kanban_advance_many(topic, BOARD, [(r["card_id"], "gap_check") for r in rows])
@@ -1196,11 +1381,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
all_ids = set(range(1, n + 1))
full_list = "\n".join(f"{i}. {texts[i - 1]}" for i in range(1, n + 1))
h = _h(*[r["card_id"] for r in rows])
def _min_cos(idxs):
if len(idxs) < 2:
return 1.0
return round(min(float(sims[i][j]) for a, i in enumerate(idxs) for j in idxs[a + 1:]), 3)
theme_lo, theme_hi = _themen_zielband(n)
async def _assess(tag, cand_text, count):
path = work_dir / f"gruppierung-{h}-c{tag}.json"
@@ -1208,7 +1389,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
status, _v = await run_single_slot(
ctx, f"Gruppierung {tag}", key=f"blocks-{topic}-gruppierung-{h}-c{tag}",
prompt=_prompt("Blocks-Gruppierung", topic=topic, candidates=cand_text,
list=full_list, out_path=path),
list=full_list, out_path=path,
theme_lo=theme_lo, theme_hi=theme_hi, n_items=n),
role="judge", capabilities="files",
payload=lambda result, p=path: _umbrella_schema(_json_file(p), all_ids),
timeout=_timeout("research_mapping", count))
@@ -1219,8 +1401,8 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
# ONE wave: TOP + all cluster judges in parallel. The used-ties precedence lives in the
# ORDER of `sources` (TOP first), not in execution order — a failed judge simply leaves
# no valid file and is skipped in the collection below (legacy semantics).
jobs = [("TOP", "Scan the ENTIRE block list below and propose EVERY genuine umbrella "
"you find — do not restrict yourself to any subset.", n)]
jobs = [("TOP", "Card-sort the ENTIRE list below into themes: assign as MANY items as "
"possible to a coherent theme, each item into exactly one.", n)]
jobs += [(str(ci), "\n".join(f"{g + 1}. {texts[g]}" for g in cluster), len(cluster))
for ci, cluster in enumerate(multi)]
await asyncio.gather(*[_assess(tag, cand, count) for tag, cand, count in jobs],
@@ -1242,16 +1424,9 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
members = [m for m in members if m not in used]
if len(members) < 2:
continue
# Bottom-up card-sorting: keine type-gate/min-cos-Vetos mehr — jedes Item soll in
# ein Thema; ein „Fehl-Merge" ist billig (Item bleibt als Sub erhalten, keine Lücke).
mrows = [rows[m - 1] for m in members]
if any(_GROUP_STANDALONE.search(r["title"]) for r in mrows):
skipped.append({"umbrella": title, "grund": "type-gate",
"mitglieder": [r["title"] for r in mrows]})
continue
mc = _min_cos([m - 1 for m in members])
if mc < GROUP_MIN_COS_FLOOR:
skipped.append({"umbrella": title, "grund": "min-cos", "min_cos": mc,
"mitglieder": [r["title"] for r in mrows]})
continue
unorm = _norm_title(title)
member_norms = {r["title_norm"] for r in mrows}
if unorm not in member_norms and unorm in seen_norm:
@@ -1260,7 +1435,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
continue
used.update(members)
seen_norm.add(unorm)
chosen.append({"umbrella": title, "description": desc, "min_cos": mc, "members": members})
chosen.append({"umbrella": title, "description": desc, "members": members})
# reconcile: same parent proposed twice under different titles → union
if len(chosen) >= 2:
uv = await _vec_rows(flow, [f"{c['umbrella']} — {c['description']}" for c in chosen])
@@ -1308,7 +1483,7 @@ async def _proc_grouping(ctx: GenContext, flow: Flow, cards):
add = []
for k, new_members in (add or []):
for m in new_members:
if m in used or not (1 <= m <= n) or _GROUP_STANDALONE.search(rows[m - 1]["title"]):
if m in used or not (1 <= m <= n):
continue
used.add(m)
chosen[k]["members"].append(m)
@@ -1364,24 +1539,25 @@ async def _proc_gap_check(ctx: GenContext, flow: Flow, cards):
flow.wake.set()
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list) -> list:
async def _supplement_beleg(ctx: GenContext, flow: Flow, supplements: list, tag: str = "") -> list:
"""Evidence gate for supplement proposals: keyword excerpts per proposal, ONE no-tool
judge marks material coverage (ja/nein). Proposals without any matching excerpt drop
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round)."""
immediately; a failed gate keeps nothing (creep is costlier than a lost bonus round).
`tag` trennt Resume-Datei/Key je Aufrufer (Lücken-Recherche läuft mehrere Runden)."""
topic = flow.topic
folder = source_folder(topic)
packs = [(t, d, _evidence_pack(folder, None, [t], budget=6000)) for t, d in supplements]
cands = [(t, d, ev) for t, d, ev in packs if ev]
kept: list = []
if cands:
path = flow.work_dir / "supplement-beleg.json"
path = flow.work_dir / f"supplement-beleg{tag}.json"
ids = set(range(1, len(cands) + 1))
verdict = _yesno_schema(_json_file(path), ids)
if verdict is None:
lines = "\n\n".join(f"{k}. {t} — {d}\nAUSZÜGE:\n{ev}"
for k, (t, d, ev) in enumerate(cands, 1))
status, verdict = await run_single_slot(
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg",
ctx, "Supplement-Beleg", key=f"blocks-{topic}-supplement-beleg{tag}",
prompt=_prompt("Blocks-Supplement-Beleg", topic=topic, proposals=lines,
extra=_extra(flow.state.get("instructions", ""))),
role="judge", capabilities="none",
@@ -1427,11 +1603,21 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
# treats). Only proposals the material itself covers may enter the inventory.
if supplements and source_folder(topic):
supplements = await _supplement_beleg(ctx, flow, supplements)
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
# in the dedup: those were rejected as non-blocks, not lost as content.
new, reopened = await _ingest_proposals(topic, supplements or [])
if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
async def _ingest_proposals(topic: str, supplements: list) -> tuple[int, int]:
"""Vorschläge (title, description) als Titel-Karten einspeisen — gemeinsamer Ingest-
Schwanz von Supplement- und Lücken-Recherche. → (neu, wiedereröffnet).
Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
must NOT dedup a proposal — their content is gone. A hit on a dead title REOPENS the
lineage instead: the title card rejoins its cluster (live re-cluster) and the respawned
block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay in the dedup:
those were rejected as non-blocks, not lost as content."""
dead_reasons = {"fragment", "drop-collateral", "drop"}
cards = await db.kanban_cards(topic, board=BOARD)
dead_clusters = {c["payload"].get("cluster") for c in cards
@@ -1465,7 +1651,7 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
if (k := _canonical_key(tt)):
known_keys.add(k)
new = reopened = 0
for t, d in (supplements or []):
for t, d in supplements:
t, d = clean_title(t), clean_title(d)
norm = _norm_title(t)
key = _canonical_key(t)
@@ -1489,9 +1675,57 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
new += 1
if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
return new, reopened
async def _luecken_producer(ctx: GenContext, flow: Flow, report: dict) -> int:
"""Gezielte Nach-Recherche für offene QA-Lücken (Auto-Loop): EIN Agent sieht Bestand
plus Lücken-Fundstellen (Abschnittstexte deterministisch re-extrahiert, gleicher
Splitter wie die QA) und schlägt NUR dafür Blöcke vor — leere Liste erlaubt.
Danach Evidence-Gate + Ingest wie beim Supplement; neue Titel durchlaufen alle
Inventar-Gates bis done_block. → Zahl neuer/wiedereröffneter Karten."""
import qa as _qa
topic = flow.topic
lk, kl = _qa.offene_luecken(report)
if not (lk or kl):
return 0
flow.add_producer() # Engine am Leben halten, solange der Research-Call läuft
try:
texts = _qa._corpus_texts(topic)
sections = {f: _qa._sections(t) for f, t in texts.items()}
gaps = []
for x in lk[:20]:
sec = sections.get(x.get("datei"), [])
k = int(x.get("abschnitt") or 0)
volltext = sec[k - 1][:1500] if 0 < k <= len(sec) else x.get("vorschau", "")
gaps.append(f"[{x.get('datei')} #{k}]\n{volltext}")
gaps += [f"NAMED RESULT (no covering block): {n}" for n in kl[:20]]
done = await db.kanban_cards(topic, board=BOARD, kind="block", stage="done_block")
runde = flow.state.get("luecken_runde", 0)
path = flow.work_dir / f"luecken-research-r{runde}.json"
supplements = _supplement_schema(_json_file(path))
if supplements is None:
status, supplements = await run_single_slot(
ctx, f"Lücken-Recherche r{runde}", key=f"blocks-{topic}-luecken-nachfass-r{runde}",
prompt=_prompt("Blocks-Luecken-Research", topic=topic,
blocks="\n".join(f"- {c['payload'].get('title', '')}" for c in done),
gaps="\n\n".join(gaps), out_path=path,
extra=_extra(flow.state.get("instructions", ""))),
role="quick", capabilities="files",
payload=lambda result, p=path: _supplement_schema(_json_file(p)),
timeout=_timeout("ergaenzung"))
if status != OK or supplements is None:
_log(topic, "Lücken-Recherche ohne Ergebnis — übersprungen")
return 0
if supplements and source_folder(topic):
supplements = await _supplement_beleg(ctx, flow, supplements, tag=f"-l{runde}")
new, reopened = await _ingest_proposals(topic, supplements or [])
if new or reopened:
_log(topic, f"Lücken-Recherche: {new} Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
return new + reopened
finally:
flow.done_producer()
async def _proc_done(ctx: GenContext, flow: Flow, cards):
@@ -1518,6 +1752,19 @@ async def _proc_done(ctx: GenContext, flow: Flow, cards):
await db.upsert_block(topic, norm, title, p.get("description", ""), p.get("sources") or [])
await db.set_block_status(topic, norm, "consensus",
title=title, description=p.get("description", ""))
# Bottom-up-Persistenz: die geclusterten Atome bleiben als Subs (jedes Atom genau
# ein Sub). Ein childless Standalone-Block ist sein eigener einziger Sub. Board 2
# reichert diese an, entdeckt sie nie neu — so überlebt jedes Board-1-Atom.
sub_titles = [(ch.get("title") if isinstance(ch, dict) else ch)
for ch in (p.get("children") or [])]
sub_titles = [s for s in sub_titles if s] or [title]
seen_sub: set[str] = set()
for st in sub_titles:
sn = _norm_title(st)
if not sn or sn in seen_sub:
continue
seen_sub.add(sn)
await db.put_subblock(topic, norm, sn, title, st, status="consensus")
mirrored[norm] = c["card_id"]
p.update(mirrored_norm=norm, title=title)
await db.kanban_set_payload(topic, BOARD, c["card_id"], p)
@@ -1569,14 +1816,24 @@ async def _preload_state(flow: Flow):
async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
research: bool = True, artefacts: bool = True, qa_force: bool = False) -> bool:
research: bool = True, artefacts: bool = True, qa_force: bool = False,
auto_inventory: bool = True, auto_artefacts: bool = True,
inventory: bool = True, status_out: dict | None = None) -> bool:
"""Run the inventory board (plus board 2 „Artefakte") until quiescence.
inventory=False = artefacts-only: board 1 is NOT run; board-2 cards are seeded from the
finished blocks and the QA gate stays open (the inventory is already done).
research=False = Continue: drain the existing queue, search nothing new.
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen")."""
qa_force=True overrides a failed QA gate (user clicked „Trotzdem fortsetzen").
auto_inventory: after the inventory QA, loop repair until 100 %/stall/10×, then open the
board-2 gate; off = pause after the QA. auto_artefacts: same loop after board 2.
status_out (optional): filled with {"weiter": bool, "note_artefakte": float|None} — the
orchestrator uses „weiter" to decide whether the guide level may start next."""
topic = ctx.topic
flow = Flow(topic, files["arbeit"])
flow.state["instructions"] = instructions
flow.state["qa_force"] = qa_force
flow.state["auto_inventory"] = auto_inventory
flow.state["auto_artefacts"] = auto_artefacts
run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-{uuid.uuid4().hex[:4]}"
flow.state["run_id"] = run_id
flow.state["run_started"] = datetime.now(timezone.utc).isoformat()
@@ -1585,28 +1842,37 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
pages = await db.list_content(topic)
flow.state["pages"] = pages or sorted(set(_crawl_index(folder).values()))
await _preload_state(flow)
stages = inventory_stages(ctx, flow)
stages = inventory_stages(ctx, flow) if inventory else []
inv_names = [st.stage for st in stages]
if artefacts:
import board_artefacts # lazy — board_artefacts imports blocks too
flow.state["spawn_artefact"] = board_artefacts.make_spawner(topic, files)
await board_artefacts.ensure_outline_card(topic)
if not inventory: # board 1 does not run → seed the board-2 cards from the finished blocks
geseedet = await board_artefacts.seed_artefact_cards(topic, files)
if geseedet:
_log(topic, f"Board 2 (nur Artefakte): {geseedet} Karte(n) aus fertigen Blocks erzeugt")
migriert = await board_artefacts.migriere_alt_karten(topic)
if migriert:
_log(topic, f"Board 2: {migriert} Karte(n) der alten Stage-Struktur → generate")
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
if QA_GATE_NOTE > 0:
if QA_GATE_NOTE > 0 and inventory:
# QA gate: board 2 waits until the inventory QA passed (or the user forces).
# Costs pipelining (board 2 no longer starts per finished block) but saves
# tokens on a bad foundation — the watcher below runs the QA and decides.
sub = next(st for st in stages if st.stage == "subblocks")
sub = next(st for st in stages if st.stage == "generate")
sub.gate = lambda: bool(flow.state.get("qa_ok") or flow.state.get("qa_force"))
elif not inventory:
flow.state["qa_ok"] = True # artefacts-only: no inventory gate, board 2 runs immediately
stages = chain_stages(stages)
if artefacts:
# Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts
# artefact stages from its barrier so it runs parallel to levels…finalize of the
# slowest block (makespan tail). Inventory stages all stay — no late blocks.
# Outline needs every block's TITLE + FACTS (aus generate), nothing later: cut the
# post-generate stages from its barrier so it runs parallel to verify…finalize
# des langsamsten Blocks (makespan tail).
outline = next(s for s in stages if s.stage == "outline")
outline.upstream = [u for u in outline.upstream if u not in
("levels", "relevance", "question_pattern", "artefacts", "finalize")]
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
("verify", "artefakte", "finalize")]
producers = _build_producers(ctx, flow, q, folder, instructions) if (research and inventory) else []
async def _as_producer(coro):
try:
@@ -1623,37 +1889,63 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
# cancel hook: blocks.cancel_blocks flips is_cancelled; stop the flow with it
stopper = asyncio.create_task(_stop_on_cancel(ctx, flow))
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
if artefacts and QA_GATE_NOTE > 0 else None)
if artefacts and QA_GATE_NOTE > 0 and inventory else None)
try:
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
try:
await kanban.run_flow(flow, stages, [_as_producer(p) for p in producers], set_p)
finally:
stopper.cancel()
if watcher:
watcher.cancel()
if ctx.is_cancelled():
return False
if flow.state.get("infra_paused"):
from blocks import _blocks_errors # lazy: Import-Zyklus vermeiden
_blocks_errors[topic] = flow.state.get("infra_error") \
or "Pausiert: API-Ratelimit (429) — «Fortsetzen», sobald Kontingent zurück"
if status_out is not None:
status_out["weiter"] = False
return True # kein Fehler: Karten warten resümierbar in ihrer Spalte
if flow.state.get("qa_paused"):
if status_out is not None:
status_out["weiter"] = False # Inventar pausiert → Board 2 lief nicht
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
# Board 2 lief durch → Artefakt-Ebene: QA + (Auto) „Befunde beheben"-Loop.
if artefacts and not ctx.is_cancelled():
await _artefakt_auto_loop(ctx, flow, set_p)
await _write_final(topic, files)
await _write_run_summary(topic, flow)
if status_out is not None:
status_out["weiter"] = not (ctx.is_cancelled() or flow.state.get("artefakt_stopp"))
status_out["note_artefakte"] = flow.state.get("note_artefakte")
return True
finally:
stopper.cancel()
if watcher:
watcher.cancel()
# erst NACH der Abschluss-QA leeren: deren Judge-Events gehören zum Lauf —
# vorher fielen sie ohne run_id aus jeder Run-Aggregation (Lauf 20260704-1452-b223)
db.set_current_run(topic, None)
if ctx.is_cancelled():
return False
if flow.state.get("qa_paused"):
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in subblocks
await _write_final(topic, files)
await _write_run_summary(topic, flow)
return True
_QA_GATE_POLL = 2.0 # Sekunden zwischen Quiescence-Checks des QA-Wächters
async def _warte_inventar_ruhe(flow: Flow, inv_names: list[str]) -> None:
"""Bis das Inventar quiescent ist (Research fertig, keine aktiven Karten in den
Inventar-Stages) oder der Flow stoppt — Poll-Kopf des QA-Wächters, auch nach
Lücken-Recherche-Runden gebraucht (neue Karten laufen erst durch alle Gates)."""
while not flow.stop:
if (flow.research_done and not flow.active_in(inv_names)
and await db.kanban_count(flow.topic, inv_names) == 0):
return
await asyncio.sleep(_QA_GATE_POLL)
async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_p):
"""Companion task: once the inventory is quiescent, run the QA once and decide —
open the board-2 gate or pause the flow. Fail-OPEN on errors (QA is a helper,
not a jailer); qa_force short-circuits to open."""
topic = flow.topic
try:
while not flow.stop:
if (flow.research_done and not flow.active_in(inv_names)
and await db.kanban_count(topic, inv_names) == 0):
break
await asyncio.sleep(_QA_GATE_POLL)
await _warte_inventar_ruhe(flow, inv_names)
if flow.stop or ctx.is_cancelled():
return
if flow.state.get("qa_force"):
@@ -1665,17 +1957,66 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
report = await qa.qa_report(topic, llm=QA_GATE_LLM)
note = float(report["note"]) if report else 10.0
flow.state["qa_note"] = note
if report:
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
await asyncio.to_thread(qa._write_report, report)
except Exception:
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
if note >= QA_GATE_NOTE:
_log(topic, f"QA-Gate: Note {note} ≥ {QA_GATE_NOTE} — Board 2 startet")
if not report:
flow.state["qa_ok"] = True # nichts messbar → fail-open (QA ist Helfer, nicht Jailer)
flow.wake.set()
return
try: # Report-Persistenz ist Komfort — ein Schreibfehler darf das Gate nicht öffnen
await qa.write_report(report)
except Exception:
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
if not flow.state.get("auto_inventory", True):
# Auto aus: nach der QA anhalten (Kontrollpunkt), egal welche Note.
_log(topic, f"Inventar-QA {round(note * 10)} % — Auto aus, pausiert")
set_p(f"Inventar-QA {round(note * 10)} % — Auto aus, «Fortsetzen» startet Board 2")
flow.state["qa_paused"] = True
flow.stop = True
flow.wake.set()
return
# Auto an: „Befunde beheben"-Loop bis 100 % / Stillstand / 10×. Offene Lücken
# bekommen erst bis zu LUECKEN_RESEARCH_MAX gezielte Nach-Recherche-Runden;
# das Lücken-Urteil (Freispruch/bestätigt) fällt erst, wenn die Recherche
# nichts mehr bewegt — sonst würde eine füllbare Lücke vorschnell verurteilt.
from auto_loop import auto_repair_loop
import repair as _repair
async def _reparieren():
rep = qa.latest_report(topic) or {}
research_bewegt = False
runde = flow.state.get("luecken_runde", 0)
lk, kl = qa.offene_luecken(rep)
if (lk or kl) and runde < LUECKEN_RESEARCH_MAX:
flow.state["luecken_runde"] = runde + 1
set_p(f"Lücken-Recherche {runde + 1}/{LUECKEN_RESEARCH_MAX}…")
neue = await _luecken_producer(ctx, flow, rep)
research_bewegt = neue > 0
if neue: # neue Titel durchlaufen alle Gates — erst Ruhe, dann messen
await _warte_inventar_ruhe(flow, inv_names)
if flow.stop or ctx.is_cancelled():
return float(flow.state.get("qa_note") or 0.0), False
set_p("Befunde beheben (Inventar)…")
r = await _repair.repair_befunde(topic, "inventory",
luecken_urteil=not research_bewegt)
n = float(r.get("note", 10.0))
flow.state["qa_note"] = n
return n, bool(r.get("aktionen", 0)) or research_bewegt
res = await auto_repair_loop("Inventar", note, _reparieren)
end_note = res["note"]
if res["grund"] == "fertig":
_log(topic, f"Inventar: 100 % nach {res['runden']} Runde(n) — Board 2 startet")
flow.state["qa_ok"] = True
else:
_log(topic, f"QA-Gate: Note {note} < {QA_GATE_NOTE} — pausiert (Continue erzwingt)")
set_p(f"QA-Note {note} < {QA_GATE_NOTE} — pausiert")
# Stillstand/Limit ohne 100 % → Abbruch: Repair hat keinen Pfad für die Restbefunde.
msg = (f"Inventar bleibt bei {round(end_note * 10)} % ({res['grund']}, "
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad für die "
f"Restbefunde (Systemfehler)")
_log(topic, msg)
set_p(msg)
from blocks import _blocks_errors
_blocks_errors[topic] = msg
flow.state["qa_paused"] = True
flow.stop = True
flow.wake.set()
@@ -1687,6 +2028,55 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
flow.wake.set()
async def _artefakt_auto_loop(ctx: GenContext, flow: Flow, set_p):
"""Nach Board 2: Artefakt-QA → (Auto) „Befunde beheben"-Loop bis 100 %/Stillstand/10×.
Auto aus = nur messen (Kontrollpunkt). Setzt flow.state[„note_artefakte"] und bei
Stopp flow.state[„artefakt_stopp"] (der Guide startet dann nicht automatisch).
Fail-open: ein QA-Fehler überspringt die Ebene (QA ist Helfer, nicht Jailer)."""
topic = ctx.topic
try:
import qa
rep = await qa.qa_report(topic, llm=QA_GATE_LLM)
na = rep.get("note_artefakte") if rep else None
if na is None: # Board 2 hat (noch) nichts geliefert → nichts zu messen
return
na = float(na)
flow.state["note_artefakte"] = na
try:
await qa.write_report(rep)
except Exception:
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
if na >= 10.0:
return
if not flow.state.get("auto_artefacts", True):
_log(topic, f"Artefakt-QA {round(na * 10)} % — Auto aus, Guide startet nicht automatisch")
set_p(f"Artefakt-QA {round(na * 10)} % — Auto aus")
flow.state["artefakt_stopp"] = True
return
from auto_loop import auto_repair_loop
import repair as _repair
async def _reparieren():
set_p("Befunde beheben (Artefakte)…")
r = await _repair.repair_befunde(topic, "artefacts")
return float(r.get("note_artefakte") or 10.0), bool(r.get("aktionen", 0))
res = await auto_repair_loop("Artefakte", na, _reparieren)
flow.state["note_artefakte"] = res["note"]
if res["grund"] != "fertig":
msg = (f"Artefakte bleiben bei {round(res['note'] * 10)} % ({res['grund']}, "
f"{res['runden']} Runden) — «Befunde beheben» hat keinen Pfad (Systemfehler)")
_log(topic, msg)
set_p(msg)
from blocks import _blocks_errors
_blocks_errors[topic] = msg
flow.state["artefakt_stopp"] = True
else:
_log(topic, f"Artefakte: 100 % nach {res['runden']} Runde(n)")
except Exception:
log.exception("[%s] Artefakt-Auto-Loop fehlgeschlagen — übersprungen (fail-open)", topic)
async def _write_run_summary(topic: str, flow: Flow):
"""lauf-summary.json: the per-run numbers block QA diffs against. Never fatal."""
try:
@@ -1707,7 +2097,7 @@ async def _write_run_summary(topic: str, flow: Flow):
summary["note"] = report["note"]
summary["note_artefakte"] = report.get("note_artefakte")
summary["artefakte"] = report.get("artefakte", {})
await asyncio.to_thread(qa._write_report, report)
await qa.write_report(report)
except Exception:
log.exception("[%s] Abschluss-QA fehlgeschlagen", topic)
atomic_write_json(flow.work_dir / "lauf-summary.json", summary, indent=1)
@@ -1752,13 +2142,9 @@ COLUMNS = [
("inventory", "done_block", "Fertig", "block"),
("inventory", "rejected", "Verworfen", None),
("inventory", "grouped", "Zusammengelegt", "block"),
("artefacts", "subblocks", "Subbausteine", "ablock"),
("artefacts", "facts", "Fakten", "ablock"),
("artefacts", "levels", "Stufen", "ablock"),
("artefacts", "relevance", "Relevanz", "ablock"),
("artefacts", "konsolidierung", "Konsolidierung", "ablock"),
("artefacts", "question_pattern", "Fragen", "ablock"),
("artefacts", "artefacts", "Lernkarten", "ablock"),
("artefacts", "generate", "Erzeugen", "ablock"),
("artefacts", "verify", "Prüfen", "ablock"),
("artefacts", "artefakte", "Lernmittel", "ablock"),
("artefacts", "finalize", "Zusammenführen", "ablock"),
("artefacts", "outline", "Gliederung", "outline"),
("artefacts", "done_artefact", "Fertig", "ablock"),
@@ -1767,18 +2153,16 @@ COLUMNS = [
_TITLE_STAGES = ["ingest", "cluster"]
_CLUSTER_STAGES = ["pair_check", "consensus_gate", "clarify", "naming", "naming_check"]
_BLOCK_STAGES = ["fragment_filter", "dedup", "grouping", "gap_check", "done"]
_ART_STAGES = ["subblocks", "facts", "levels", "relevance", "konsolidierung", "question_pattern",
"artefacts", "finalize", "outline"]
_ART_STAGES = ["generate", "verify", "artefakte", "finalize", "outline"]
# where a requeued dead card restarts, by kind
_DEAD_RESTART = {"title": "cluster", "cluster": "pair_check", "block": "fragment_filter",
"ablock": "subblocks", "outline": "outline"}
"ablock": "generate", "outline": "outline"}
_VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mirrored_norm")
DONE_ART = "done_artefact"
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels",
"relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"}
_STAGE_PHASE = {"generate": "Generate", "verify": "Verify", "artefakte": "Artefakte"}
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
@@ -1828,28 +2212,25 @@ async def board_snapshot(topic: str, limit: int = 20) -> dict:
def _qa_view(topic: str, counts: dict, flow) -> dict | None:
"""Latest QA report digest for the board header. `pausiert` = the gate stopped the
flow (score below threshold, board-2 cards waiting, no flow running)."""
flow (score below 100 %, board-2 cards waiting, no flow running) — das Auto-Gate
öffnet nur bei VOLL; eine 9.6 pausierte den Loop, galt hier aber nicht als pausiert."""
# no inventory (deleted/never built) → no badge; the report files stay on purpose,
# so the first run after a rebuild diffs against the old state
if not counts.get("inventory", {}).get("done_block", 0) and not (
flow and flow.state.get("qa_note") is not None):
return None
import qa
tdir = qa.QA_DIR / topic
# by mtime: a re-run overwrites the run-id-named file, which sorts before timestamp names.
# guide-* reports are the guide_qa series — they must not shadow the inventory badge.
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 []
if not reports:
from auto_loop import VOLL
r = qa.latest_report(topic)
if r is None:
return None
r = _json_file(reports[-1]) or {}
note = r.get("note")
if note is None:
return None
wartend = counts.get("artefacts", {}).get("subblocks", 0)
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
wartend = counts.get("artefacts", {}).get("generate", 0)
pausiert = bool(note < VOLL and wartend and flow is None)
return {"note": note, "note_artefakte": r.get("note_artefakte"),
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
"schwelle": VOLL, "pausiert": pausiert,
"quoten": r.get("quoten", {}),
"befunde": (r.get("fremd", []) + r.get("unecht", []))[:6]}
@@ -1885,9 +2266,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
await _requeue(r, stage)
await db.kanban_delete_cards(topic, "inventory", "cluster")
await db.kanban_delete_cards(topic, "inventory", "block")
dbc = await db.get_db()
await dbc.execute("DELETE FROM kanban_members WHERE topic = ?", (topic,))
await dbc.commit()
await db.kanban_delete_members(topic)
await db.delete_blocks(topic)
await _clean_artefact_state(topic, files)
elif board == "inventory" and stage in _CLUSTER_STAGES:
@@ -1915,13 +2294,17 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
await _requeue(r, "outline")
elif r["stage"] in later:
await _requeue(r, stage)
if stage == "subblocks": # full artefact re-derive → also the DB mirrors
await db.delete_subblocks(topic)
if stage == "generate": # re-derive enrichment + artefacts; the Board-1 subs STAY
# Bottom-up: subblocks belong to Board 1 (the clustered atoms). Deleting them would
# leave Board 2 nothing to enrich → cards fall straight through to done. Only the
# DERIVED artefacts/questions + sidecar files are wiped; the enrichment (facts/level)
# gets overwritten by the fresh Board-2 run.
await db.delete_question_pattern(topic)
await db.delete_sub_artefakte(topic)
await db.delete_outline(topic) # the outline is a blocks artifact too (set in board_artefacts)
# global sidecar files and per-block resume slots: leftovers of the previous
# derive would re-merge/resume into the fresh run — nothing here is reused
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
files[k].unlink(missing_ok=True)
for d in files["arbeit"].glob("ab-*"):
if d.is_dir():
@@ -1930,23 +2313,6 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
return moved
async def restart_artefact_card(topic: str, card_id: str) -> bool:
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
(per-block work-dir slots overwrite themselves; finalize re-upserts later).
Only call while nothing is generating (the route guards). → False if unknown."""
card = await db.kanban_get_card(topic, "artefacts", card_id)
if card is None or card["kind"] != "ablock":
return False
await db.delete_subblocks(topic, card_id)
await db.delete_question_pattern(topic, card_id)
await db.delete_sub_artefakte(topic, card_id)
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
await db.kanban_set_payload(topic, "artefacts", card_id, p)
await db.kanban_advance(topic, "artefacts", card_id, "subblocks")
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
return True
async def requeue_dead(topic: str) -> int:
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
n = 0
+58 -75
View File
@@ -75,9 +75,6 @@ SEED_COVER_COS = 0.80
# (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
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
@@ -94,16 +91,13 @@ EMBEDDING_SIBLING_CAP = 18 # a rich model (TM) can have many constituent p
# 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
# Zielband der Themen-Blockzahl im Gruppierungs-Prompt: k = GROUP_THEMES_PER_SQRT·√n
# (n = Items der Grouping-Welle), Band [0.7k, 1.3k] — weiche Vorgabe, kein Cap. √n hält
# Blockzahl UND mittlere Blockgröße sublinear (n=30 → ~4–8, n=285 → ~12–22; das alte
# statische „15–25" passte implizit nur zu n≈225–625).
GROUP_THEMES_PER_SQRT = 1.0
# Fragment-demote backstop (no-structure floor): fragment↔parent cosine is a BAD fragment
# detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
@@ -114,9 +108,17 @@ FRAGMENT_MIN_COS = 0.15
# 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
# Per-topic higher than the process cap: it is SHARED between process and API tier — at 12 a
# single-topic run (the normal case) would never benefit from the cheap API tier.
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "24")) # per topic
# Direct-API text calls (agents._run_text_api): ~0 RAM, only network — own, higher global cap.
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28"))
MAX_CONCURRENT_INTERACTIVE = 8
# RAM guard for opencode spawns: below this free share (MemAvailable/MemTotal in %) new
# processes wait instead of starting (agents._ram_gate). 0 = off.
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20"))
# Grace window of the consensus races (blocks, guide, OnePager): after the first
# valid result the remaining agents may still become done for this many seconds
# (kill only once the minimum is already in).
@@ -127,30 +129,15 @@ CONSENSUS_GRACE = 300
# check loops leave any remaining objections standing after that.
CONSENSUS_MAX_ROUNDS = 3
# Crawler triage (content/noise) — deterministic rule filter instead of an LLM.
# Match: substring (lowercase) against URL AND file name. Order: keep > noise > min_chars > keep.
# Just add special rules here.
CRAWL_KEEP_PATTERNS = ["learn-unit", "learn-course"] # always content
CRAWL_NOISE_PATTERNS = [ # clearly off-topic → out
"clubs", "events", "podcasts", "resources", "-u-",
"academy", "pricing", "/plans", "career", "newsletter", "impressum", "login",
]
CRAWL_MIN_CHARS = 400 # too little text → out
# LLM topic relevance gate (after the rule filter): per content page yes/no against the spec.
# Separates the subject area (e.g. backend vs frontend), which the global CRAWL_* rules can't.
QUELLE_RELEVANZ_CHUNK = 12 # pages per rater package (small, since a snippet ships per page)
QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL is the primary signal)
# 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: after the inventory phase an automatic QA run scores the blocks; below 100 %
# the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
# QA_GATE_NOTE ist nur noch der An/Aus-Schalter (> 0 = Gate aktiv) — das Gate selbst
# öffnet ausschließlich bei 100 % (auto_loop.VOLL); Repair/Recherche arbeiten dahin.
QA_GATE_NOTE = 9.5 # 0 = gate off
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
# Guide section length per relevant sub (ausführlich part) — QA detector AND the
# deterministic readability-stage trigger share these bounds (writers overshot 2.7–4.1×).
GUIDE_LAENGE_MIN = 150
GUIDE_LAENGE_MAX = 1200
# Lücken-Recherche im Auto-Loop: max. gezielte Nach-Recherche-Runden pro Lauf, bevor die
# Rest-Lücken dem Stichentscheid vorgelegt werden (Repair-Budget, keine Detektor-Konstante).
LUECKEN_RESEARCH_MAX = 2
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
@@ -161,79 +148,72 @@ EVIDENCE_CTX_LINES = 15 # context lines around a cited source position
# ── 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_BATCH = 1 # eine Seite pro Reader — er kann nichts übersehen (Vollständigkeit)
RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
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 4–5 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
# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur.
# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment.
GEN_PANEL = 2 # unabhängige Generator-Calls pro Block
VERIFY_PANEL = 2 # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall)
ART_SPLIT_SUBS = 20 # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
FILTER_RECHECK_PANEL = 3 # judges in the survivor-recheck
MAX_WRITER_ROUNDS = 2 # guide coverage→writer loop cap
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below this → log only (falsch fixt immer)
GATE_FIX_MIN = 3 # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer)
ZIELE_MAX = 12 # Lernziele-Cap pro Block (mehr verwässert Coverage-Prüfung und Writer-Fokus)
# Prüfer-Längenband um guide_qa.block_budget: enger als das QA-Band (0.35–1.5), damit der
# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py.
FIX_LAENGE_BAND = (0.5, 1.2)
WRITER_SPLIT_SUBS = 30 # guide writer splits sections above this sub count
KANBAN_BATCH = 5 # cards a worker pulls per micro-batch
MAX_CARD_RETRIES = 3 # failures per card → dead-letter
RETRY_BACKOFF = 30.0 # base seconds; backoff = base · 2^(retries-1)
MAX_RESTARTS = 2 # agent restart cap per race slot
INFRA_MAX_RETRIES = 3 # 429/Timeout/Netz: Retries pro Slot, dann Lauf-Pause (kein fail-open)
INFRA_BACKOFF_BASE = 8.0 # Pause = base · 2^(n-1) → 8/16/32 s
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160–230 s
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden
# Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus.
HEDGE_NACH_S = 90
JUDGE_CHUNK = 40 # repair: findings per judge call
EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt)
EVIDENCE_PER_BLOCK = 6000 # repair: excerpt chars per fremd candidate
ABSCHLUSS_QA_LLM = 1 # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)
# Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = {
"research": (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": (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": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
# Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole
# cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
"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": (150, 8), # judge decides contested levels in the chunk
"relevance": (300, 10), # subblocks relevant/peripheral per 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": (150, 8), # critic cleans up the pattern table per block
# Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente
"generate": (450, 0), # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
"verify": (300, 10), # Audit über alle Subs (n = Subs), key points gekappt
"fix": (300, 15), # Korrekturen + Lücken (n = Aufträge)
"artefakt": (450, 15), # Fragen+Karten+Beispiele (n = Subs)
"artefakt_check": (200, 8), # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
"writer": (450, 60), # per section — split keeps sections ≤30 subs
"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).
# German strings: these are inserted verbatim into the judge prompt → kept German on purpose.
FORMAT_PURPOSE = {
"Guide": "einen fokussierten Guide — alles Relevante ohne Randthemen",
"FullGuide": "einen Komplett-Guide — das ganze Thema inkl. Randthemen",
"Rest": "einen Ergänzungs-Guide — nur die Randthemen",
"pruefer": (600, 5), # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
# QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
"qa_judge": (600, 0),
}
# Provider stacks: completely independent, any one can be removed at any time.
@@ -242,7 +222,10 @@ FORMAT_PURPOSE = {
# "judge" = mapping/judge/check agents — cold (low temperature,
# no thinking) for stable verdicts; Claude/local map to "fast",
# "guide" = large generation (proposals, writer).
DEFAULT_PROVIDER = "claude"
# Kein Provider-Default im Code (Betreiber-Vorgabe): die .env entscheidet.
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
if not DEFAULT_PROVIDER:
raise RuntimeError("DEFAULT_PROVIDER fehlt in der .env (z. B. DEFAULT_PROVIDER=minimax)")
PROVIDERS = {
"claude": {
"cli": "claude",
+23 -51
View File
@@ -1,9 +1,9 @@
"""Bounded domain crawler for link sources — renders JS via Playwright (Chromium).
"""Page loader for link sources — renders JS via Playwright (Chromium).
Loads pages + PDFs starting from a start URL — ONLY the same domain, limited depth
and page count. HTML pages are rendered in a headless browser (needed for SPAs), then
links + text are pulled from the finished DOM. PDFs are loaded directly as bytes.
Deterministic, bounded; runs via asyncio.to_thread (sync API, no event loop).
Loads a FIXED list of URLs the user supplied — one page each, NO link following.
HTML pages are rendered in a headless browser (needed for SPAs), then the main text
is pulled from the finished DOM. PDFs are loaded directly as bytes. Deterministic,
bounded; runs via asyncio.to_thread (sync API, no event loop).
"""
import hashlib
@@ -17,8 +17,6 @@ from fsutil import atomic_write_text
log = logging.getLogger("creator.crawl")
MAX_DEPTH = 3
MAX_PAGES = 500
PAGE_TIMEOUT = 30 # seconds per page (render or PDF download)
CRAWL_SETTLE_MS = 3000 # capped settle after domcontentloaded (SPA render); no 30s networkidle hang
MAX_BYTES = 10_000_000 # 10 MB cap per PDF
@@ -33,7 +31,7 @@ def _fetch_bytes(url: str) -> bytes | None:
data = resp.read(MAX_BYTES + 1)
return None if len(data) > MAX_BYTES else data
except Exception as e:
log.debug("crawl: PDF fetch failed %s: %s", url, e)
log.debug("load: PDF fetch failed %s: %s", url, e)
return None
@@ -47,21 +45,6 @@ def _is_pdf(url: str) -> bool:
return url.lower().split("?")[0].rstrip("/").endswith(".pdf")
def _scope_prefix(start_url: str) -> str:
"""First non-empty path segment of the start URL as the crawl scope, e.g.
`/learn/path/x` → `/learn`. No path segment → `""` (whole domain, no narrowing)."""
seg = [s for s in urlparse(start_url).path.split("/") if s]
return f"/{seg[0]}" if seg else ""
def _in_scope(url: str, prefix: str) -> bool:
"""Segment-exact prefix match (no `/learn` ⊃ `/learning-x`). Empty prefix → everything allowed."""
if not prefix:
return True
p = urlparse(url).path
return p == prefix or p.startswith(prefix + "/")
def _page_text(page) -> str:
"""Main text of the rendered page — nav/footer/boilerplate removed via trafilatura.
Falls back to the raw body text when extraction is empty/too short (non-article pages)."""
@@ -78,34 +61,34 @@ def _page_text(page) -> str:
return text.strip()
def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages: int = MAX_PAGES, cancelled=None) -> int:
"""Crawl from start_url (same domain only), render JS and store pages/PDFs in `target`.
def load_pages(urls: list[str], target: Path, *, cancelled=None) -> int:
"""Load each URL in `urls` (one page each, NO link following), render JS and store
pages/PDFs in `target`.
BFS up to `max_depth` / `max_pages`. Errors on individual pages are skipped.
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it,
so a restart crawls again. Returns the number of saved sources.
Duplicate/fragment-only URLs collapse to one. Errors on individual pages are skipped.
Writes a `.done` marker at the END; an abort (`cancelled()` → True) omits it, so a
restart loads again. Returns the number of saved sources.
"""
# Lazy: this way the backend starts even without Playwright installed; only crawling then fails.
# Lazy: this way the backend starts even without Playwright installed; only loading then fails.
from playwright.sync_api import sync_playwright
target.mkdir(parents=True, exist_ok=True)
domain = urlparse(start_url).netloc
prefix = _scope_prefix(start_url) # only follow links under this path segment
seen: set[str] = set()
queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)]
todo: list[str] = []
for u in urls:
nu = urldefrag(u)[0].strip()
if nu and nu not in seen:
seen.add(nu)
todo.append(nu)
saved = 0
with sync_playwright() as pw:
browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app)
page = browser.new_page(user_agent=_UA)
try:
while queue and saved < max_pages:
for url in todo:
if cancelled and cancelled():
return saved # abort → NO .done marker → restart crawls again
url, depth = queue.pop(0)
if url in seen:
continue
seen.add(url)
return saved # abort → NO .done marker → restart loads again
# PDFs need no rendering — load directly.
if _is_pdf(url):
@@ -120,7 +103,7 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
try:
page.goto(url, wait_until="domcontentloaded", timeout=PAGE_TIMEOUT * 1000)
except Exception as e:
log.debug("crawl: goto incomplete %s: %s", url, e) # still try to read the content
log.debug("load: goto incomplete %s: %s", url, e) # still try to read the content
try:
page.wait_for_load_state("networkidle", timeout=CRAWL_SETTLE_MS)
except Exception:
@@ -129,20 +112,9 @@ def crawl(start_url: str, target: Path, *, max_depth: int = MAX_DEPTH, max_pages
if text:
atomic_write_text(target / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}")
saved += 1
if depth < max_depth:
try:
hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)")
except Exception:
hrefs = []
for href in hrefs:
nxt = urldefrag(href)[0]
if (nxt.startswith(("http://", "https://"))
and urlparse(nxt).netloc == domain and _in_scope(nxt, prefix)
and nxt not in seen):
queue.append((nxt, depth + 1))
finally:
browser.close()
(target / ".done").write_text("ok", encoding="utf-8") # ran through cleanly
log.info("crawl %s → %d sources in %s", start_url, saved, target)
log.info("load_pages → %d sources in %s", saved, target)
return saved
+504 -408
View File
File diff suppressed because it is too large. Load diff
+98 -83
View File
@@ -21,7 +21,6 @@ _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:
@@ -30,7 +29,7 @@ def _norm(s: str) -> str:
class Welt:
"""Deterministisches Themen-Modell. bloecke: {titel: {"beschreibung": str,
"subs": [titel]}}; optionale Regeln steuern Konsolidierung/Cross-Block."""
"subs": [titel]}}; optionale Regeln steuern das Störfall-Verhalten."""
def __init__(self, bloecke: dict | None = None, *, gruppen: list | None = None,
kataloge: list | None = None, stoerungen: list | None = None):
@@ -101,11 +100,14 @@ class Welt:
j = json.dumps
# Board 1 / Inventar
if "-research-" in key:
# Bottom-up: Research liefert die ATOME flach; Board 1 gruppiert sie zu den
# Cluster-Bausteinen. Jedes Atom gehört zu genau einem Cluster.
zeilen = []
n = 1
for t, b in self.bloecke.items():
zeilen.append(f"{n}. {t} — {b['beschreibung']}")
n += 1
for cl, b in self.bloecke.items():
for atom in b["subs"]:
zeilen.append(f"{n}. {atom} — Atom aus {cl}")
n += 1
return "\n".join(zeilen)
if "-pair-" in key:
n = prompt.count("\nA: ") or 1
@@ -118,31 +120,61 @@ class Welt:
return j({"keep": keep, "rest": []})
if "-naming-" in key: # deckt auch naming_check (gleicher Key)
return j({"best": 1})
if "-sanierung-" in key: # Titel/Beschreibung unverändert zurück (Fake-Welt ist sauber)
t = re.search(r"^Title: (.+)$", prompt, re.M)
d = re.search(r"^Description: (.+)$", prompt, re.M)
beschr = (d.group(1).strip() if d else "")
if beschr == "(leer)":
beschr = "Beschreibung aus dem Material."
return j({"title": t.group(1).strip() if t else "", "description": beschr})
if "-filter-" in key: # auch filter-recheck
return j({"fragments": {}, "drop": []})
if "-gruppierung-completion-" in key:
return j({"additions": []})
if "-gruppierung-completion-" in key or "-gruppierung-attach-" in key:
return j({"additions": []}) # Anschluss-Judge: Fake-Nachzügler bleiben standalone
if "-gruppierung-" in key:
return j({"umbrellas": []})
# jedes Atom seinem Cluster zuordnen (nur die FULL ITEM LIST, 1..n eindeutig)
seg = prompt.split("FULL ITEM LIST", 1)[-1]
atom_cluster = {_norm(a): cl for cl, b in self.bloecke.items() for a in b["subs"]}
umb: dict[str, list[int]] = {}
for m in _NUM_RE.finditer(seg):
nr, zeile = int(m.group(1)), _norm(m.group(2))
for an, cl in atom_cluster.items():
if zeile.startswith(an):
umb.setdefault(cl, []).append(nr)
break
umbrellas = [{"title": cl, "description": self.bloecke[cl]["beschreibung"],
"members": ms} for cl, ms in umb.items() if ms]
return j({"umbrellas": umbrellas})
if "-supplement-beleg" in key or "-anker-beleg-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
if "-supplement" in key:
return j({"blocks": []})
if "-supplement" in key or "-luecken-nachfass-" in key:
return j({"blocks": []}) # Fake-Welt ist vollständig — Lücken-Research findet nichts
if "-source-relevance-" in key:
nums = {m.group(1) for m in _NUM_RE.finditer(prompt)}
return j({"relevant": {k: "ja" for k in sorted(nums, key=int)} or {"1": "ja"}})
# Board 2 / Artefakte
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 = []
# Board 2 / Artefakte (verschmolzene Calls, block_calls.py)
if "-sb-enrich-" in key: # Bottom-up: feste Subs (aus dem Prompt) mit Facts anreichern
seg = prompt.split("FIXED SUBBLOCKS", 1)[-1].split("\n\n", 1)[0]
subs = []
for m in re.finditer(r"^\s*\d+\.\s+(.+)$", seg, re.M):
s = m.group(1).strip()
f = self._fakt("", s)
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
"cited_facts", "example_idea")}})
return j({"subs": subs})
if "-sb-gen-" in key:
subs = []
for t in self._bloecke_im_prompt(prompt):
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:
for s in self.bloecke[t]["subs"]:
f = self._fakt(t, s)
subs.append({"title": s, "level": "beginner", "relevance": "relevant",
**{k: f[k] for k in ("key_points", "prerequisites", "hurdles",
"cited_facts", "example_idea")}})
return j({"subs": subs})
if "-sb-verify-" in key:
nummern = {_norm(m.group(2)): m.group(1) for m in _NUM_RE.finditer(prompt)}
gruppen = []
for haupt, weitere in self.gruppen:
@@ -155,50 +187,24 @@ class Welt:
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:
unsicher = {}
if (u := prompt.find("UNSICHER")) != -1: # alle Unsicher-Nummern übernehmen
import re as _re
for m in _re.finditer(r"entries (\d+)–(\d+)", prompt[u:u + 200]):
unsicher = {str(k): "ja" for k in range(int(m.group(1)), int(m.group(2)) + 1)}
return j({"gruppen": gruppen, "kataloge": kataloge, "fremd": [], "luecken": [],
"uebernehmen": unsicher, "facts_probleme": [], "levels": {}, "relevanz": {}})
if "-sb-fix-" in key:
return j({"subs": []})
if "-art-gen-" in key:
subs = self._subs_im_prompt(prompt)
t = (self._bloecke_im_prompt(prompt) or ["?"])[0]
return j({"pattern": [{"block": t, "subblock": s, "question": f"Was ist {s}?"} for s in subs],
"cards": [{"block": t, "subblock": s, "question": f"F: {s}?", "answer": f"A: {s}"} for s in subs],
"examples": [{"block": t, "subblock": s, "problem": f"Aufgabe zu {s}",
"steps": ["Schritt 1", "Schritt 2"], "result": "Ergebnis"} for s in subs]})
if "-art-check-" in key:
return j({"ok": True})
if "-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:
@@ -212,15 +218,12 @@ class Welt:
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:
if "-gfix-" in key:
return self._section_aus_prompt(prompt) or "<!-- section: X -->\nRepariert."
if "-gate-" in key:
return j({"ok": True})
if "-cov-" in key:
if "-pruef-" in key: # verschmolzener Prüfer: Fakten + Coverage + Lesbarkeit
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})
return j({"claims": [], "ziele": {z: True for z in ids}, "luecken": [],
"ballast": [], "lese_probleme": []})
if "-w-" in key:
return self._writer_md(prompt)
@@ -267,13 +270,15 @@ class Welt:
def standard_bloecke() -> dict:
"""3 Blöcke; „Gemeinsamer Grundbegriff" liegt in Alpha UND Beta (Cross-Block-Fall)."""
"""3 Themen-Cluster; die subs sind die ATOME, die Board 1 bottom-up zu genau diesem
Cluster gruppiert. Jedes Atom liegt in genau einem Cluster (kein geteiltes Atom mehr —
das globale Clustern in Board 1 macht Cross-Block-Dubletten unmöglich)."""
return {
"Alpha-Konzept": {"beschreibung": "Das erste Grundkonzept",
"subs": ["Definition Alpha", "Alpha Eigenschaften",
"Gemeinsamer Grundbegriff"]},
"Beta-Verfahren": {"beschreibung": "Das zentrale Verfahren",
"subs": ["Beta Ablauf", "Beta Grenzen", "Gemeinsamer Grundbegriff"]},
"subs": ["Beta Ablauf", "Beta Grenzen"]},
"Gamma-Anwendung": {"beschreibung": "Praktische Anwendung",
"subs": ["Gamma Praxisfall", "Gamma Werkzeuge"]},
}
@@ -297,20 +302,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
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",
async def fake_run_agent(agent_key, prompt, timeout, provider="", role="fast",
capabilities="none", lane="batch", scope=None, on_line=None, label=""):
return welt.respond(agent_key, prompt, capabilities)
for mod in (agents, pipeline, blocks, guide, repair):
for mod in (agents, pipeline, guide):
setattr_fn(mod, "run_agent", fake_run_agent)
setattr_fn(blocks, "CONSENSUS_GRACE", 0)
setattr_fn(bi, "_QA_GATE_POLL", 0.05)
@@ -325,11 +328,18 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
@staticmethod
def embed(texts):
import hashlib
import numpy as np
uniq = {t: k for k, t in enumerate(dict.fromkeys(texts))}
arr = np.zeros((len(texts), max(len(uniq), 1)))
# FESTE Dimension (hash → One-hot): identischer Text = gleiche Spalte = cos 1.0,
# verschiedener Text = andere Spalte = cos 0.0. Anders als eine pro-Aufruf
# variable Breite lassen sich so Vektoren aus verschiedenen embed()-Aufrufen
# concatenieren (Board-1-Cluster-Cache) ohne Dimensions-Mismatch.
D = 4096
arr = np.zeros((len(texts), D))
for r, t in enumerate(texts):
arr[r, uniq[t]] = 1.0
h = int(hashlib.blake2b(t.encode("utf-8"), digest_size=8).hexdigest(), 16) % D
arr[r, h] = 1.0
return arr
@staticmethod
@@ -337,9 +347,14 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
arr = _FakeEmb.embed(texts)
return arr @ arr.T
for mod in (blocks, ba, qa):
setattr_fn(mod, "embedding", _FakeEmb)
# reine Matrix-/Union-Find-Helfer (modell-unabhängig) ans echte Modul delegieren
import embedding as _real_emb
capped_blocks = staticmethod(_real_emb.capped_blocks)
_find = staticmethod(_real_emb._find)
_union = staticmethod(_real_emb._union)
async def emb_ok(flow): # Board-1-Vektorpfade aus — Judge-Wellen reichen
return False
setattr_fn(bi, "_emb_ok", emb_ok)
for mod in (blocks, qa, bi):
setattr_fn(mod, "embedding", _FakeEmb)
# _emb_ok bleibt echt (True über _FakeEmb): Bottom-up braucht den Grouping-Stage.
# _FakeEmb bildet nur bei identischem Text Nachbarn — verschiedene Atome erzeugen keine
# falschen Vorab-Cluster; das eigentliche Clustern macht der TOP-Judge (Card-sort).
+21 -34
View File
@@ -11,7 +11,6 @@ Step files are kept → an abort preserves progress, ▶ resumes at the open ste
import asyncio
import json
import logging
import math
from datetime import datetime, timezone
from pathlib import Path
@@ -19,26 +18,14 @@ import uuid
from agents import run_agent
from blocks import _convert_pdfs, source_folder
from config import (
DEFAULT_PROVIDER, FORMAT_PURPOSE, CONSENSUS_GRACE,
READABILITY_ACTIVE, TEMPLATES_DIR,
)
import readability
from config import DEFAULT_PROVIDER, TEMPLATES_DIR
from database import (list_guides, update_guide, list_blocks, list_subblocks, set_guide_content,
get_guide_content, get_outline, guide_stage_counts, delete_guide_board)
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file, parse_json_text as _parse_json_text
from paths import blocks_path, guide_content_path, project_dir, subblocks_path
from pipeline import (
CANCELLED, FAILED, GenContext, _claude_error, _extra,
_fail, _gather_error, _gather_progress, _log, _prompt, _race,
_semaphore, _set_progress, _set_step, _timeout, clear_guide_cancelled,
is_guide_cancelled, run_single_slot,
)
from textkit import (
_unique_title, _load_blocks, _norm_title, _parse_fragment, _split_chunks,
_title, _resolve_title, _title_index,
)
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from paths import blocks_path, guide_content_path, subblocks_path
from pipeline import _fail, _prompt, _semaphore, clear_guide_cancelled, is_guide_cancelled
from textkit import _unique_title, _load_blocks, _norm_title, _title, parse_facts
log = logging.getLogger("creator.guide")
@@ -66,10 +53,7 @@ async def _load_subblocks(topic: str) -> dict[str, list[dict]]:
out: dict[str, list[dict]] = {}
for r in await list_subblocks(topic):
if r["status"] == "consensus" and r["sub_title"]:
try:
facts = json.loads(r["facts"]) if r.get("facts") else {}
except (ValueError, TypeError):
facts = {}
facts = parse_facts(r.get("facts"))
level = r["level"] if r["level"] in _LEVELS_OK else "advanced"
out.setdefault(r["block"], []).append(
{"title": r["sub_title"], "level": level, "relevance": r["relevance"], "facts": facts})
@@ -234,13 +218,19 @@ async def reconcile_guides() -> None:
file write and status update.
"""
for g in await list_guides():
if g["status"] == "done" and not guide_content_path(g["topic"], g["format"]).exists():
log.warning("[%s] Guide %s: done without content file — set to error", g["topic"], g["id"])
now = datetime.now(timezone.utc).isoformat()
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
if g["status"] != "done":
continue
# DB-first wie die Content-Route — die Datei ist nur Legacy-Fallback
if await get_guide_content(g["topic"], g["format"]) is not None:
continue
if guide_content_path(g["topic"], g["format"]).exists():
continue
log.warning("[%s] Guide %s: done without content — set to error", g["topic"], g["id"])
now = datetime.now(timezone.utc).isoformat()
await update_guide(g["id"], status="error", error_msg="Content missing — regenerate", updated_at=now)
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_step: int | None = None) -> None:
async def generate_guide(guide_id: str, topic: str, format_name: str, instructions: str = "", provider: str = DEFAULT_PROVIDER) -> None:
async with _semaphore:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide_id, status="generating", progress="Starting…", updated_at=now)
@@ -257,12 +247,9 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio
await asyncio.to_thread(_convert_pdfs, project)
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:
await guide_board.reset_from_stage(topic, format_name, ab_step)
elif content_path.exists():
# A FINISHED guide → complete fresh start (board + slots wiped). Otherwise the cards
# are leftovers of an abort/error/repair → resume at their stored stage.
if content_path.exists():
counts = await guide_stage_counts(topic, format_name)
if not counts or set(counts) == {"done"}:
await delete_guide_board(topic, format_name)
+358 -305
View File
@@ -3,9 +3,10 @@
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
pruefer judge-Rolle EIN Call: CoVe-Fakten + Coverage + Lesbarkeit (lasen vorher
denselben Text in 3 seriellen Calls) + deterministische Gates
fix guide-Rolle EIN Rewrite unter allen Auflagen; bei falsch/Lücken danach
genau ein Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
needed); stage transitions are persisted in guide_cards, so the board is live and
@@ -21,9 +22,9 @@ 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 config import (FIX_LAENGE_BAND, READABILITY_ACTIVE, TEMPLATES_DIR,
MAX_CONCURRENT_AGENTS_PER_TOPIC, ZIELE_MAX)
from guide_qa import block_budget
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
_timeout, is_guide_cancelled, run_single_slot)
@@ -31,11 +32,10 @@ from textkit import _norm_title, _parse_fragment, _title
log = logging.getLogger("creator.guide_board")
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "fakten_gate", "coverage", "lesbarkeit")
GUIDE_STAGES = ("lernziele", "zuweisung", "writer", "pruefer", "fix")
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
"pruefer": "Prüfen", "fix": "Fix", "done": "Fertig"}
from config import GATE_FIX_MIN, WRITER_SPLIT_SUBS # zentral tunebar
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
@@ -56,7 +56,7 @@ def _ziele_schema(data):
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:
if not zid or not text or zid in seen or len(out) >= ZIELE_MAX:
continue
seen.add(zid)
out.append({"id": zid, "text": text, "sub": str(z.get("sub", "")).strip()})
@@ -87,33 +87,31 @@ def _gate_schema(data):
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):
def _pruefer_schema(data, ziel_ids: set[str]):
"""Verschmolzenes Prüfer-Verdikt: Claims (Fakten-Gate-Semantik via _gate_schema) +
Coverage (ziele/luecken/ballast) + Lesbarkeit (lese_probleme). {"ok":true} = leeres
Verdikt. `ziele` muss alle ids abdecken, wenn Ziele existieren — sonst optional."""
if not isinstance(data, dict):
return None
if data.get("ok") is True:
return {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
if not any(k in data for k in ("claims", "ziele", "luecken", "ballast", "lese_probleme")):
return None
claims = _gate_schema({"claims": data["claims"]}) if data.get("claims") else []
if claims is None:
return None
ziele = {}
for k, v in data["ziele"].items():
for k, v in (data.get("ziele") or {}).items() if isinstance(data.get("ziele"), dict) else []:
ziele[str(k)] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
if not ziel_ids <= set(ziele):
if ziel_ids and not ziel_ids <= set(ziele):
return None
luecken = [{"ziel": str(l.get("ziel", "")), "fehlt": str(l.get("fehlt", ""))}
for l in data.get("luecken", []) if isinstance(l, dict) and str(l.get("fehlt", "")).strip()]
ballast = [str(b).strip() for b in data.get("ballast", []) if str(b).strip()]
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
lese = [str(p.get("problem", "")).strip() for p in data.get("lese_probleme", [])
if isinstance(p, dict) and str(p.get("problem", "")).strip()]
return {"claims": claims, "ziele": ziele, "luecken": luecken, "ballast": ballast,
"lese_probleme": lese}
def _first_section(md: str) -> dict | None:
@@ -141,10 +139,35 @@ class _Env:
return self.content_path.parent / f"{self.content_path.stem}.{name}"
def _memo(env, attr: str) -> dict:
"""Lazy per-Karte-Cache auf dem env-Objekt (funktioniert auch für Test-Mocks). Lernziele,
Beispiel-Rows und Facts-Grounding sind während EINES Laufs immutabel, wurden aber je Karte
2–3× neu geholt (writer, pruefer, re-pruefer). Karten haben disjunkte block_norm-Keys und
laufen ihre Stages seriell → kein Race."""
d = env.__dict__.get(attr)
if d is None:
d = env.__dict__[attr] = {}
return d
async def _ziele(env: _Env, block_norm: str) -> list[dict]:
cache = _memo(env, "_ziele_cache")
if block_norm not in cache:
cache[block_norm] = await db.list_lernziele(env.topic, block_norm)
return cache[block_norm]
def _ziele_text(ziele: list[dict]) -> str:
return "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
def _card_facts(env: _Env, block_title: str) -> str:
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
cache = _memo(env, "_facts_cache")
if block_title not in cache:
from guide import _facts_grounding # lazy: guide imports this module
grounding = _facts_grounding({block_title: env.subs_by_title.get(block_title, [])})
cache[block_title] = grounding or env.fallback_facts
return cache[block_title]
async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
@@ -152,7 +175,10 @@ async def _card_examples(env: _Env, block_norm: str, subs: list[dict],
"""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)
cache = _memo(env, "_example_rows")
if block_norm not in cache:
cache[block_norm] = await db.get_sub_artefakte(env.topic, type="example", block_norm=block_norm)
rows = cache[block_norm]
if not rows:
return ""
wanted = {_norm_title(s["title"]) for s in subs}
@@ -276,12 +302,6 @@ def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
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 2–4×
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)."""
@@ -318,7 +338,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
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),
budget=block_budget(parts[i]),
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
@@ -333,13 +353,7 @@ async def _write_split(env: _Env, card: dict, ziele_text: str):
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")
ziele_text = _ziele_text(await _ziele(env, norm))
# oversized first drafts: two halves, merged into one canonical section
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
text = await _write_split(env, card, ziele_text)
@@ -348,7 +362,7 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
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")
await _set(env, card, md=text, stage="pruefer", status="open")
return True
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
@@ -366,8 +380,8 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
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"], []))),
gaps="", spec=env.spec,
budget=block_budget(env.subs_by_title.get(card["block"], [])),
out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
@@ -376,190 +390,186 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
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")
await _set(env, card, md=text, stage="pruefer", status="open")
return True
async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
def _n_rel(env: _Env, card: dict) -> int:
return sum(1 for s in env.subs_by_title.get(card["block"], [])
if s.get("relevance") != "peripheral")
def _det_hinweise(env: _Env, card: dict, sec: dict) -> list[str]:
"""Deterministische Befunde (extern geerdet, kein LLM): Readability-Modell +
Längenbudget — dieselbe Formel wie der QA-Detektor (guide_qa.block_budget), nur mit
engerem Band, damit der Fix VOR der QA-Grenze greift. Gehen direkt in den Fix
und als „nicht wiederholen"-Notiz in den Prüfer-Prompt."""
out: list[str] = []
subs_all = env.subs_by_title.get(card["block"], [])
if not any(s.get("relevance") != "peripheral" for s in subs_all):
return out # kein Inventar als Budget-Basis → kein Längen-Urteil (wie der QA-Detektor)
budget = block_budget(subs_all)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
zeichen = len(aus[1] if len(aus) == 2 else sec["md"])
lo, hi = FIX_LAENGE_BAND
if not (lo * budget <= zeichen <= hi * budget):
out.append(
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(lo * budget)}–{round(hi * budget)}): "
f"schreibe den ausführlich-Teil auf etwa {budget} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
return out
async def _det_readability(sec: dict) -> list[str]:
if not READABILITY_ACTIVE:
return []
hints = await asyncio.to_thread(readability.rate_sections, {1: sec["md"]})
return [hints[1]] if hints.get(1) else []
def _auftraege(verdict: dict, det: list[str], n_rel: int = 0) -> tuple[list[str], bool]:
"""Prüfer-Verdikt → Fix-Auftragszeilen. kritisch = falsch-Claims oder Lücken
(nur die rechtfertigen den Re-Prüfer-Pass — Fakten/Coverage sind der Qualitätskern).
Claims-Schwelle: wenige nur-„unbelegt" lohnen keinen Fix-Pass — bei kleinen Sektionen
sinkt sie auf die Sub-Zahl (2 unbelegte Claims in 2 Subs sind viel, nicht wenig)."""
claims = verdict["claims"]
falsch = [c for c in claims if c["urteil"] == "falsch"]
schwelle = min(GATE_FIX_MIN, n_rel) if n_rel else GATE_FIX_MIN
if claims and not falsch and len(claims) < schwelle:
claims = []
zeilen = [f"- CLAIM ({c['urteil']}): {c['text']}" + (f" — {c['grund']}" if c['grund'] else "")
for c in claims]
zeilen += [f"- LÜCKE ({l['ziel']}): {l['fehlt']}" for l in verdict["luecken"]]
zeilen += [f"- BALLAST (kürzen): {b}" for b in verdict["ballast"]]
zeilen += [f"- LESBARKEIT: {p}" for p in verdict["lese_probleme"]]
zeilen += [f"- LESBARKEIT: {p}" for p in det]
return zeilen, bool(falsch or verdict["luecken"])
async def _pruefer_call(env: _Env, card: dict, sec: dict, tag: str, det: list[str]) -> dict | None:
"""EIN Judge-Call prüft Fakten + Coverage + Lesbarkeit (die drei lasen vorher denselben
Section-Text in drei seriellen Calls). Text-Antwort + Engine-Sink (Datei-schreibende
Judges lieferten invalides JSON). → Verdikt | None (FAILED/CANCELLED)."""
norm = card["block_norm"]
ziele = await _ziele(env, norm)
ziele_text = _ziele_text(ziele)
ids = {z["ziel_id"] for z in ziele}
facts = _card_facts(env, card["block"])
ex = await _card_examples(env, norm, env.subs_by_title.get(card["block"], []))
if ex: # der Fix sieht dieselben Facts — Beispiele überleben den Fix-Pass
facts += "\n\nVERIFIED WORKED EXAMPLES (count as verified facts for this check):\n" + ex
hinweise = ("\nALREADY NOTED deterministically (do NOT repeat, they go to the fix anyway):\n"
+ "\n".join(f"- {d}" for d in det) + "\n") if det else "\n"
path = env.slot(f"pruefer-{_safe(norm)}-{tag}.json")
status, verdict = await run_single_slot(
env.ctx, f"Prüfer {card['block']}", key=f"{env.guide_id}-pruef-{_safe(norm)}-{tag}",
prompt=_prompt("Guide-Pruefer", topic=env.topic, block=card["block"],
section=sec["md"], facts=facts, ziele=ziele_text, spec=env.spec,
hinweise=hinweise, extra=_extra(env.instructions)),
role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, lambda d: _pruefer_schema(d, ids)),
timeout=_timeout("pruefer", 1))
if status != OK or verdict is None:
return None
for zid, ok in verdict["ziele"].items():
if zid in ids:
await db.set_ziel_covered(env.topic, norm, zid, ok)
return verdict
async def _stage_pruefer(env: _Env, card: dict) -> bool:
"""Verschmolzener Qualitäts-Pass: Fakten-Gate + Coverage + Lese-Check in EINEM Call
(vorher 3 serielle Judges + bis zu 3 Edit-Pässe, die einander überschrieben und deren
letzter ungeprüft blieb). Befunde → Fix-Stage; ohne Befund → done."""
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Writer-Fragment unlesbar")
return False
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:
# 1–2 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:
det = (await _det_readability(sec)) + _det_hinweise(env, card, sec)
verdict = await _pruefer_call(env, card, sec, f"r{card['writer_rounds']}", det)
if verdict is None:
if is_guide_cancelled(env.guide_id):
return False
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")
# fail-open wie das alte Gate: Karte nie blockieren — deterministische Befunde
# gehen trotzdem in den Fix
_log(env.topic, f"Prüfer {card['block']}: kein Ergebnis — nur deterministische Checks")
verdict = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
zeilen, kritisch = _auftraege(verdict, det, _n_rel(env, card))
if not zeilen:
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info="")
return True
_log(env.topic, f"Prüfer {card['block']}: {len(zeilen)} Befund(e){' (kritisch)' if kritisch else ''} → Fix")
await _set(env, card, gate_info=("KRITISCH\n" if kritisch else "") + "\n".join(zeilen),
stage="fix", status="open")
return True
async def _stage_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:
async def _stage_fix(env: _Env, card: dict) -> bool:
"""EIN kompletter Section-Rewrite unter allen Auflagen (ersetzt Fakten-Fix +
Writer-Revision + Lese-Fix). Danach GENAU EIN Re-Prüfer-Pass, wenn der Fix wegen
falsch-Claims/Lücken lief — der alte Lese-Fix blieb ungeprüft. Rest-Befunde bleiben
sichtbar (gate_info), keine weitere Fix-Runde."""
from guide import _level_label
norm = card["block_norm"]
sec = _first_section(card["md"])
if sec is None:
await _set(env, card, status="error", gate_info="Fragment unlesbar")
return False
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']}",
info = card.get("gate_info") or ""
kritisch = info.startswith("KRITISCH\n")
auftraege = info.removeprefix("KRITISCH\n")
subs = env.subs_by_title.get(card["block"], [])
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
fixp = env.slot(f"fix-{_safe(norm)}-r{card['writer_rounds']}.md")
fixp.unlink(missing_ok=True)
def _fixload(result):
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
return text if _first_section(text) else None
fstatus, fixed = await run_single_slot(
env.ctx, f"Fix {card['block']}", key=f"{env.guide_id}-gfix-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fix", topic=env.topic, format_name=env.format, block=card["block"],
section=card["md"], facts=_card_facts(env, card["block"]), spec=env.spec,
auftraege=auftraege, sub_list=sub_list, out_path=fixp,
extra=_extra(env.instructions)),
role="judge", capabilities="none",
payload=lambda result: _sink_json(result, path, _problems_schema),
timeout=_timeout("lese_check", 1))
if status == CANCELLED:
role="guide", capabilities="files", payload=_fixload,
timeout=_timeout("writer", 1))
if fstatus == 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.7–4.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:
angewandt = False
if fstatus == OK and fixed:
new_sec = _first_section(fixed)
# marker invariant: a fix that loses the sub markers kills the level filter → discard
if sec.get("subs") and not (new_sec and new_sec.get("subs")):
_log(env.topic, f"Fix {card['block']} ohne Sub-Marker — verworfen")
else:
card["md"] = fixed
angewandt = True
rest = ""
if not angewandt:
# Fix ohne Ergebnis: Befunde nicht stumm löschen — sie bleiben im gate_info sichtbar
rest = "Fix ohne Ergebnis — offene Befunde:\n" + auftraege
_log(env.topic, f"Fix {card['block']}: nicht angewandt — Befunde bleiben sichtbar")
if kritisch and angewandt:
sec2 = _first_section(card["md"])
verdict = await _pruefer_call(env, card, sec2, "re", [])
if verdict is None and is_guide_cancelled(env.guide_id):
return False
if 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="")
if verdict is None:
rest = "Re-Prüfer ohne Ergebnis — Fix ungeprüft übernommen"
_log(env.topic, f"Re-Prüfer {card['block']}: kein Ergebnis — Fix ungeprüft übernommen")
else:
zeilen, _k = _auftraege(verdict, [], _n_rel(env, card))
if zeilen:
rest = "Rest-Befunde nach Fix:\n" + "\n".join(zeilen)
_log(env.topic, f"Re-Prüfer {card['block']}: {len(zeilen)} Rest-Befund(e) bleiben")
await _set(env, card, md=card["md"], stage="done", status="ok", gate_info=rest)
return True
_STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
"writer": _stage_writer, "fakten_gate": _stage_fakten_gate,
"coverage": _stage_coverage, "lesbarkeit": _stage_lesbarkeit}
"writer": _stage_writer, "pruefer": _stage_pruefer, "fix": _stage_fix}
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
@@ -592,6 +602,22 @@ async def _run_card_inner(env: _Env, card: dict) -> None:
# ── Orchestration ──────────────────────────────────────────────────────────────────
async def _progress_reporter(guide_id: str, topic: str, format_name: str, takt: float = 2.0) -> None:
"""Live-Fortschritt fürs Frontend; ein DB-Fehler darf den Reporter nie beenden
(der Fortschritt fror sonst still ein), unveränderter Stand wird nicht geschrieben."""
zuletzt = None
while True:
try:
counts = await db.guide_stage_counts(topic, format_name)
stand = (counts.get("done", 0), sum(counts.values()))
if stand != zuletzt:
zuletzt = stand
await db.update_guide(guide_id, progress=f"Board: {stand[0]}/{stand[1]} Karten fertig")
except Exception:
log.exception("[%s] guide progress reporter", topic)
await asyncio.sleep(takt)
async def _chapter_map(topic: str, entries: dict[int, str]) -> dict[str, tuple[str, int]]:
"""block_norm → (chapter title, global order) from the outline artefact."""
from guide import _outline_from_db, _fallback_outline, _with_remainder
@@ -617,74 +643,73 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
is_cancelled=lambda: is_guide_cancelled(guide_id), guide_id=guide_id)
import uuid
from datetime import datetime, timezone
# „-g"+hex4-Suffix markiert Guide-Läufe (Blocks-Suffixe sind reines Hex) —
# database.latest_board_runs erkennt Guide-run_ids an genau diesem Muster.
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
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:
try:
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
subs_raw = await _load_subblocks(topic)
project = source_folder(topic)
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
else _prompt("Guide-Facts-Thema"))
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
subs_raw, await _chapter_map(topic, entries), fallback, spec)
await db.upsert_guide_cards_many(
topic, format_name,
[(_norm_title(_title(line)), _title(line)) for line in entries.values()])
cards = await db.list_guide_cards(topic, format_name)
open_cards = [c for c in cards if c["stage"] != "done"]
if open_cards:
sem = asyncio.Semaphore(CARD_CONCURRENCY)
reporter = asyncio.create_task(_progress_reporter(guide_id, topic, format_name))
try:
ergebnisse = await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards],
return_exceptions=True)
for c, r in zip(open_cards, ergebnisse):
if isinstance(r, BaseException):
log.error("[%s] guide card task %s: %r", topic, c["block"], r)
finally:
reporter.cancel()
if is_guide_cancelled(guide_id):
return None
# assembly — identical shape to the legacy pipeline
cards = await db.list_guide_cards(topic, format_name)
chapters: list[dict] = []
by_chapter: dict[str, list[dict]] = {}
order: list[str] = []
for c in sorted(cards, key=lambda c: (c["ord"], c["block_norm"])):
if c["stage"] != "done":
_log(topic, f"Guide: Karte '{c['block']}' nicht fertig ({c['stage']}) — Abschnitt fehlt")
continue
sec = _first_section(c["md"])
if sec is None:
continue
ch = c["chapter"] or "Inhalte"
if ch not in by_chapter:
by_chapter[ch] = []
order.append(ch)
by_chapter[ch].append({
"num": c["ord"], "title": c["block"], "md": sec["md"],
"compact": sec.get("compact", ""), "anchor": sec.get("anchor", ""),
"anker_compact": sec.get("anker_compact", ""), "subs": sec.get("subs", []),
"checkable": format_name == "Guide" or bool(
any(s.get("relevance") == "relevant" for s in subs_raw.get(c["block"], []))),
})
for ch in order:
chapters.append({"title": ch, "sections": by_chapter[ch]})
if chapters:
try: # Abschluss-Guide-QA (best-effort): speist das Badge mit einer frischen Note
import guide_qa
rep = await guide_qa.guide_qa_report(topic, llm=True)
if rep:
await asyncio.to_thread(guide_qa._write_report, rep)
except Exception:
log.exception("[%s] Abschluss-Guide-QA fehlgeschlagen", topic)
return chapters or None
finally:
# erst NACH der Abschluss-Guide-QA leeren: deren Judge-Events gehören zum
# Lauf — vorher fielen sie ohne run_id aus jeder Run-Aggregation
db.set_current_run(topic, None)
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:
@@ -723,36 +748,64 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
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
note_guide = (qa_mod.latest_report(topic, guide=True) or {}).get("note_guide")
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))
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair (Pendant zum Blocks-Repair: „Score unter 10
muss einen Fix-Pfad haben"). Kritische Befunde (marker_fehlend/ziel_ohne_anker/
fachlich_falsch) → zurück auf `pruefer` (brauchen Facts-/Ziele-Kontext + Re-Prüfer).
Reine Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit expliziten
Auftragszeilen: der Prüfer sieht pro Karte nur die EIGENE Section und kann
Cross-Karten-Redundanz nie wiederfinden — der Fix bekommt den Kontext hier hinein
(kein KRITISCH-Präfix → genau EIN Rewrite ohne Re-Prüfer). generate_guide resumt
die offenen Karten und misst am Ende neu. → betroffene Blocktitel."""
import qa as qa_mod
from guide_qa import block_budget, LAENGE_BAND
reports = qa_mod.report_paths(topic, guide=True)
rep = _json_file(reports[-1]) if reports else None
if not rep:
return []
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
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
kritisch: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
kritisch.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
kritisch.add(str(e).split(" · ")[0])
for t in rep.get("fachlich_falsch", []) or []:
kritisch.add(_norm_title(str(t)))
stil: dict[str, list[str]] = {}
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
a, b = str(e.get("a", "")), str(e.get("b", ""))
bn = _norm_title(b.split(":")[0])
auszug = b.split(":", 1)[1].strip() if ":" in b else b
stil.setdefault(bn, []).append(
f"- BALLAST (kürzen): Absatz doppelt zu Block «{a.split(':')[0]}» („{auszug}…\") — "
"hier straffen, der Inhalt bleibt im anderen Block")
for e in rep.get("laengen_ausreisser", []): # {"block", "zeichen", "budget"}
if not isinstance(e, dict):
continue
bn = _norm_title(e.get("block", ""))
budget = e.get("budget") or block_budget([])
lo, hi = LAENGE_BAND
stil.setdefault(bn, []).append(
f"- LÄNGE: {e.get('zeichen')} Zeichen (Budget {budget}, erlaubt "
f"{round(lo * budget)}–{round(hi * budget)}): schreibe den ausführlich-Teil auf etwa "
f"{budget} Zeichen GESAMT um — Sockel-Prosa und Wiederholungen streichen, "
"alle Sub-Marker und Beispiele behalten")
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
bn = _norm_title(str(e).split(":")[0])
hint = str(e).split(":", 1)[1].strip() if ":" in str(e) else str(e)
stil.setdefault(bn, []).append(f"- LESBARKEIT: {hint}")
betroffen = []
for n in sorted((kritisch | set(stil)) & set(cards)):
if n in kritisch:
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
else:
await db.set_guide_card(topic, format_name, n, stage="fix", status="open",
gate_info="\n".join(stil[n]))
betroffen.append(cards[n]["block"])
return betroffen
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
+46 -24
View File
@@ -10,6 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
"""
import asyncio
import logging
import re
import sys
from datetime import datetime, timezone
@@ -18,9 +19,9 @@ import database as db
import qa
import readability
from fsutil import atomic_write_json
from textkit import _norm_title
from textkit import _norm_title, parse_facts
from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN
log = logging.getLogger("creator.guide_qa")
JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten
ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal
@@ -71,13 +72,40 @@ def ziel_ohne_anker(cards: list[dict], ziele: list[dict]) -> list[str]:
return out
def laengen_ausreisser(cards: list[dict], subs_rel: dict[str, set]) -> list[dict]:
# Längenbudget je Sub aus der Inventar-Substanz — ersetzt den festen Rahmen 150–1200/Sub:
# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler.
# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem
# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar.
BUDGET_BASIS = 140 # Einstieg/Übergang je Sub
BUDGET_KEY_POINT = 110 # ~1 Satz Erklärung je key_point
BUDGET_FAKT = 45 # zitierter Fakt, in den Text eingewoben
BUDGET_BEISPIEL = 170 # knappes Beispiel
LAENGE_BAND = (0.35, 1.5) # QA-Toleranz um das Blockbudget
def sub_budget(facts: dict) -> int:
"""Zeichenbudget für den ausführlich-Teil EINES Subs (facts = Inventar-JSON des Subs)."""
kp = len(facts.get("key_points") or [])
cf = len(facts.get("cited_facts") or [])
ex = 1 if str(facts.get("example_idea") or "").strip() else 0
return BUDGET_BASIS + BUDGET_KEY_POINT * kp + BUDGET_FAKT * cf + BUDGET_BEISPIEL * ex
def block_budget(subs: list[dict]) -> int:
"""Budget einer Section: Summe über die relevanten Subs ({relevance, facts}-Dicts)."""
return max(BUDGET_BASIS, sum(sub_budget(s.get("facts") or {}) for s in subs
if s.get("relevance") != "peripheral"))
def laengen_ausreisser(cards: list[dict], budget_by_norm: dict[str, int]) -> list[dict]:
out = []
for c in cards:
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)})
budget = budget_by_norm.get(c["block_norm"])
if not budget:
continue
zeichen = len(_ausfuehrlich(c["md"]))
if not (LAENGE_BAND[0] * budget <= zeichen <= LAENGE_BAND[1] * budget):
out.append({"block": c["block"], "zeichen": zeichen, "budget": budget})
return out
@@ -111,22 +139,11 @@ 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
items = [f"SECTION {c['block']}:\n{_ausfuehrlich(c['md'])[:LLM_SECTION_CHARS]}" for c in kandidaten]
v = await qa.judge_wave("QA-Guide-Fakten", topic, f"fakten{tag}", "sections", items,
chunk=5, prefix="qa-guide", label="Guide-QA")
return [c["block"] for k, c in enumerate(kandidaten, 1) if v.get(k) == "ja"]
verdacht = await _pass(cards, "")
if not verdacht:
@@ -148,14 +165,19 @@ async def guide_qa_report(topic: str, llm: bool = False) -> dict | None:
print(f"Keine Guide-Karten für '{topic}' — Guide noch nicht gebaut?")
return None
subs_rel: dict[str, set] = {}
subs_by_norm: dict[str, list[dict]] = {}
for r in await db.list_subblocks(topic):
if r["status"] == "consensus" and r["relevance"] != "peripheral":
if r["status"] != "consensus":
continue
subs_by_norm.setdefault(r["block_norm"], []).append(
{"relevance": r["relevance"], "facts": parse_facts(r["facts"])})
if r["relevance"] != "peripheral":
subs_rel.setdefault(r["block_norm"], set()).add(r["sub_norm"])
ziele = [dict(r) for r in await db.list_lernziele(topic)]
mf = marker_fehlend(cards, subs_rel)
za = ziel_ohne_anker(cards, ziele)
la = laengen_ausreisser(cards, subs_rel)
la = laengen_ausreisser(cards, {n: block_budget(s) for n, s in subs_by_norm.items()})
rd = redundanz(cards)
lb = lesbarkeit(cards)
falsch = await _fachlich_falsch(topic, cards) if llm else None
File renamed without changes.
+7
View File
@@ -19,6 +19,7 @@ import logging
import database as db
from config import KANBAN_BATCH, MAX_CARD_RETRIES, MAX_CONCURRENT_AGENTS_PER_TOPIC, RETRY_BACKOFF
from pipeline import AgentInfraError
log = logging.getLogger("creator.kanban")
@@ -165,6 +166,12 @@ async def _worker(flow: Flow, spec: Stage, inflight: int, all_stages: list[str])
flow.active_cards.update(f"{spec.board}:{i}" for i in ids)
try:
await spec.process(cards)
except AgentInfraError as e: # 429/Timeout erschöpft → Lauf pausieren, NICHT dead-letter
log.warning("kanban %s/%s: infra-pause: %s", topic, spec.stage, e)
flow.state["infra_paused"] = True
flow.state["infra_error"] = str(e)
flow.stop = True
flow.wake.set()
except Exception as e: # one bad package must not kill the worker → backoff/dead-letter
log.info("kanban %s/%s: %s: %s", topic, spec.stage, type(e).__name__, e)
try:
+29 -14
View File
@@ -269,7 +269,13 @@ def _rating_text(rating: dict) -> str:
# Deterministic guard against double questions — the AI critic misses "…, and which…".
_QUESTION_WORD = r"(was|welche[rsnm]?|wie|wieso|warum|wofür|wozu|wann|wo|wer|wem|wen|nenne)"
_DOUBLE_RE = re.compile(r"[,;]?\s+(und|sowie|außerdem|bzw\.?)\s+" + _QUESTION_WORD + r"\b", re.IGNORECASE)
# Optional preposition between the conjunction and the question word — the second question
# hides behind it: "und IN welcher Datei", "und UNTER welcher Bedingung".
_PREP = r"(?:in|unter|auf|für|mit|bei|zu[rm]?|von|vom|über|durch|aus|nach|an|am|um|gegen|ohne|wobei)\s+"
_DOUBLE_RE = re.compile(
r"[,;]?\s+(?:und|sowie|außerdem|bzw\.?)\s+(?:" + _PREP + r")?" + _QUESTION_WORD + r"\b",
re.IGNORECASE,
)
def _double_question_flaw(question: str) -> str | None:
@@ -368,9 +374,9 @@ def _avoid_block(avoid: list[str] | None) -> str:
# the model takes the easy path (mere recall) — the cues lift higher tiers to apply/analyze/transfer.
TIER_ROLE = {
"beginner": "The learner is a BEGINNER. Cognitive: REMEMBER/UNDERSTAND. Ask about the basic understanding — the core concept, simple and direct.",
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Pose a small concrete situation and have the concept applied to it — don't just ask for the definition.",
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Have them distinguish/compare, classify a special case or uncover a typical pitfall (hurdle) — don't quiz textbook knowledge.",
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have the concept transferred to a NEW problem, justify a decision or weigh a trade-off.",
"advanced": "The learner is ADVANCED. Cognitive: APPLY. Have the concept applied to ONE minimal case — name the case in a half-sentence (no story, no roles, no backstory), then ask ONE single thing.",
"expert": "The learner is an EXPERT. Cognitive: ANALYZE. Ask for ONE distinction, comparison, or typical pitfall — a single question, no scenario prose.",
"master": "The learner is at MASTER level. Cognitive: EVALUATE/TRANSFER. Have them justify ONE decision or weigh ONE trade-off — one question, no build-up, no second sub-question.",
}
@@ -409,18 +415,27 @@ async def exam_question_variant(
pattern: str, tier: str = "beginner", provider: str = DEFAULT_PROVIDER,
) -> str | None:
"""Action 'question' with a pattern: from a predefined pattern, phrase a concrete question in
the addressee role of the tier. No critic (the pattern is build-checked).
The style guard stays as a cheap protection against double questions · None on error."""
the addressee role of the tier. No AI critic (the pattern is build-checked), but the
deterministic double-question guard runs and forces a regenerate on a flaw · None on error."""
try:
section_block, compact_block = _section_blocks(section, compact)
data = await _gen_call(
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
)
if data is None:
return None
return data["question"]
kritik_block = "(none)"
question = None
for _ in range(CRITIC_MAX_ROUNDS):
data = await _gen_call(
"Block-Question-Variante", "guide", _question_schema, provider, lane="batch",
topic=topic, block=block, section_block=section_block,
compact_block=compact_block, pattern=pattern, tier_block=_tier_block(tier),
kritik_block=kritik_block,
)
if data is None:
return None
question = data["question"]
flaw = _double_question_flaw(question)
if not flaw:
return question
kritik_block = _critique_block(question, [flaw])
return question # best-effort after the last round
except Exception:
log.warning("[%s] Question variant failed (%s)", topic, block, exc_info=True)
return None
+19 -45
View File
@@ -7,6 +7,8 @@ FormatType = Literal[
"Rest",
]
from config import DEFAULT_PROVIDER
ProviderType = Literal["claude", "minimax", "lokal"]
SourceType = Literal["thema", "projekt", "uni", "link"]
@@ -16,14 +18,7 @@ class GuideCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
format: FormatType
instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude"
ab_step: int | None = Field(default=None, ge=0, le=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)
provider: ProviderType = DEFAULT_PROVIDER
class TopicCreateRequest(BaseModel):
@@ -33,25 +28,28 @@ class TopicCreateRequest(BaseModel):
class QaRunRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
llm: bool = True # wie das Gate: Echtheits-/Dubletten-Stichprobe inklusive
ebene: str | None = None # None = alle Ebenen; "inventory"/"artefacts" = ohne Guide-QA-Overhead
class RepairRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
ebene: str | None = None # None = alle Fixes, "inventory" oder "artefacts" filtert je Ebene
class BlocksCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
instructions: str = Field(default="", max_length=2000)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
source_type: SourceType = "thema"
source_location: str = Field(default="", max_length=2000)
source_location: str = Field(default="", max_length=20000) # link mode: one URL per line
research: bool = True # False = Continue: drain the existing kanban queue, no new search
qa_force: bool = False # True = übersteuert ein pausierendes QA-Gate („Trotzdem fortsetzen")
class BlocksCardRestartRequest(BaseModel):
topic: str = Field(min_length=1)
card_id: str = Field(min_length=1, max_length=200)
# „Auto" pro Ebene: nach der QA im Loop „Befunde beheben", bis 100 % / Stillstand / 10×.
# Aus = nach der QA stoppen (Kontrollpunkt). Default an.
auto_inventory: bool = True
auto_artefacts: bool = True
auto_guide: bool = True
only_artefacts: bool = False # True = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate)
class GuideFormatRequest(BaseModel):
@@ -66,28 +64,6 @@ class PracticeAnswerRequest(BaseModel):
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)
board: Literal["inventory", "artefacts"]
stage: str = Field(min_length=1, max_length=40) # kanban column to reset back to
class BlocksStep(BaseModel):
label: str
state: Literal["done", "active", "pending"]
class BlocksFineStep(BaseModel):
label: str
phase: str = ""
state: Literal["done", "active", "pending"]
class BlocksStatusResponse(BaseModel):
@@ -96,8 +72,6 @@ class BlocksStatusResponse(BaseModel):
progress: str | None = None
error: str | None = None
partial: bool = False
steps: list[BlocksStep] = []
feine_steps: list[BlocksFineStep] = []
class FolderResponse(BaseModel):
@@ -108,7 +82,7 @@ class FolderResponse(BaseModel):
class BlocksSourceUpdate(BaseModel):
topic: str = Field(min_length=1, max_length=100)
type: SourceType = "thema"
location: str = Field(default="", max_length=2000)
location: str = Field(default="", max_length=20000) # link mode: one URL per line
spec: str = Field(default="", max_length=2000)
@@ -157,7 +131,7 @@ class GuideChatRequest(BaseModel):
section: str = Field(default="", max_length=20000)
outline: str = Field(default="", max_length=8000)
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class GuideChatResponse(BaseModel):
@@ -172,7 +146,7 @@ class BlockChatRequest(BaseModel):
section: str = Field(default="", max_length=20000) # detailed version
section_compact: str = Field(default="", max_length=20000) # compact version (mnemonics)
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class BlockChatResponse(BaseModel):
@@ -203,7 +177,7 @@ class BlockExamRequest(BaseModel):
# Base + cap are kept server-side (anchor / subs×25) — the client cap is only a hint.
cap: int = Field(default=10, ge=1, le=10000) # score cap = unlocked subblocks × 25
messages: list[ChatMessage] = [] # dialog so far; empty = first question
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
thorough: bool = False # "thorough check": rating with a strong model (role guide)
@@ -246,7 +220,7 @@ class BlockPruefenRequest(BaseModel):
spot: str = "ausführlich" # "compact" | "ausführlich" (displayed field)
snippet: str = Field(min_length=1, max_length=20000) # raw markdown block
hint: str = Field(default="", max_length=2000) # optional addition (✏️)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class BlockPruefenResponse(BaseModel):
@@ -258,7 +232,7 @@ class BlockUebernehmenRequest(BaseModel):
spot: str = "ausführlich"
alt: str = Field(min_length=1, max_length=20000)
revised: str = Field(default="", max_length=20000)
provider: ProviderType = "claude"
provider: ProviderType = DEFAULT_PROVIDER
class BlockUebernehmenResponse(BaseModel):
+78 -37
View File
@@ -9,14 +9,11 @@ import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
from agents import run_agent, kill_process, cancel_scope, clear_scope
from config import MAX_CONCURRENT_GENERATIONS, TEMPLATES_DIR, TIMEOUTS
from database import update_guide
from jsonio import read_json_file as _json_file
from textkit import _STUFEN
log = logging.getLogger("creator.pipeline")
@@ -80,6 +77,15 @@ def _claude_error(label: str, returncode: int, stdout: str, stderr: str) -> str:
return f"{label} (exit {returncode}, no output)"
_INFRA_MARKERS = ("HTTP 429", "HTTP 5", "rate_limit", "Timeout after",
"ConnectError", "ConnectTimeout", "ReadError", "RemoteProtocolError")
def _is_infra(err: str) -> bool:
"""Transport-/Infra-Fehler (retry + pause) statt inhaltlichem Fehlschlag."""
return any(m in (err or "") for m in _INFRA_MARKERS)
def _gather_error(label: str, results: list) -> str:
for r in results:
if isinstance(r, BaseException):
@@ -132,7 +138,6 @@ def _runde_schema(data, final: bool = False):
return include, rest
_RELEVANCE = ("relevant", "peripheral")
_YESNO = ("ja", "nein")
@@ -159,15 +164,14 @@ def _enum_map_schema(key: str, allowed):
return parse
_levels_schema = _enum_map_schema("levels", _STUFEN) # level ∈ beginner/advanced/expert
_relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈ relevant/peripheral
_yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate ∈ ja/nein
from config import MAX_RESTARTS as _MAX_RESTARTS # noqa: E402 — zentral tunebar
from config import (MAX_RESTARTS as _MAX_RESTARTS, HEDGE_NACH_S as _HEDGE_NACH_S, # noqa: E402 — zentral tunebar
INFRA_MAX_RETRIES as _INFRA_MAX_RETRIES, INFRA_BACKOFF_BASE as _INFRA_BACKOFF)
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, cancelled=None, *, grace: int | None = None) -> list | None:
"""Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -180,29 +184,33 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
a timer of `grace` seconds. After it expires, running agents are only
killed if the minimum stands — otherwise the race, including restarts,
keeps running until it stands. Returns: `quorum` to `len(slots)` results.
`min_runtime` (wall-clock from start): the race does not return before it
elapses while agents are still running — gives them time to search thoroughly.
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
(or None if nothing), killing the rest. Both default off; only Research sets them.
"""
attempts = {i: 0 for i in range(len(slots))}
infra_attempts = {i: 0 for i in range(len(slots))}
infra_erschoepft: set[int] = set()
tasks: dict[asyncio.Task, int] = {}
keys: dict[asyncio.Task, str] = {}
born: dict[asyncio.Task, float] = {}
hedged: set[int] = set() # slot got its one twin — no hedge cascades
fertig: set[int] = set() # slot delivered a valid result (late twins are ignored)
# Hedge-Schwelle relativ zum Call-Timeout (HEDGE_NACH_S = Untergrenze): pauschale 90 s
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110–135 s laufen.
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
loop = asyncio.get_running_loop()
start = loop.time()
min_deadline = start + min_runtime if min_runtime else None
max_deadline = start + max_runtime if max_runtime else None
deadline: float | None = None
def spawn(i: int) -> None:
def spawn(i: int, suffix: str = "") -> None:
slot = slots[i]
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
key = slot["key"] + suffix
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
key, slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, on_line=slot.get("on_line"), label=lbl,
))
tasks[task] = i
keys[task] = key
born[task] = loop.time()
for i in range(len(slots)):
spawn(i)
@@ -212,27 +220,38 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks:
if cancelled and cancelled():
return None
# Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
if max_deadline is not None and loop.time() >= max_deadline:
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
return results or None
min_ok = min_deadline is None or loop.time() >= min_deadline
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
if deadline is not None and len(results) >= quorum and loop.time() >= deadline:
return results
# Wake up for the earliest relevant deadline (grace, min, or max).
# Hedge: a slot running HEDGE_NACH_S without result gets ONE parallel twin
# (key -h) — first valid result wins. Stalled provider calls burned the full
# timeout cap before the restart even began (measured: 160–230 s per stall).
if hedge_s:
now = loop.time()
for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig
and now - born[t] >= hedge_s]:
i = tasks[t]
hedged.add(i)
spawn(i, suffix="-h")
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet")
# Wake up for the earliest relevant deadline (grace or next hedge).
waits = []
if deadline is not None and len(results) >= quorum:
waits.append(deadline - loop.time())
if min_deadline is not None:
waits.append(min_deadline - loop.time())
if max_deadline is not None:
waits.append(max_deadline - loop.time())
if hedge_s:
naechste = [born[t] + hedge_s - loop.time() for t in tasks
if tasks[t] not in hedged | fertig]
if naechste:
waits.append(max(0.0, min(naechste)))
wait_timeout = max(0.0, min(waits)) if waits else None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
if not done:
continue
for task in done:
i = tasks.pop(task)
keys.pop(task, None)
born.pop(task, None)
if i in fertig:
continue # späte Zwillinge eines bereits gewerteten Slots
payload, err = None, None
try:
result = task.result()
@@ -249,30 +268,47 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
if payload is not None:
results.append(payload)
fertig.add(i)
for t2 in [t2 for t2, i2 in tasks.items() if i2 == i]: # Zwilling killen
kill_process(keys.get(t2, slots[i]["key"]))
t2.cancel()
if grace is not None and deadline is None:
deadline = loop.time() + grace
_log(topic, f"{label}: first result — grace {grace}s running")
if on_update:
on_update(len(results))
if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
and (min_deadline is None or loop.time() >= min_deadline)):
if len(results) >= quorum and (grace is None or loop.time() >= deadline):
return results
continue
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {err}")
infra = _is_infra(err)
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}{' infra' if infra else ''}): {err}")
attempts[i] += 1
# If the minimum already stands, restarts are pointless — the restart
# would be killed at the grace end anyway.
# would be killed at the grace end anyway. A still-running twin IS the retry.
enough = grace is not None and len(results) >= quorum
if attempts[i] <= _MAX_RESTARTS and not enough and not (cancelled and cancelled()):
zwilling = any(i2 == i for i2 in tasks.values())
if enough or zwilling or (cancelled and cancelled()):
continue
if infra:
# 429/Timeout/Netz: eigener Zähler + wachsende Pause, dann Eskalation
infra_attempts[i] += 1
if infra_attempts[i] > _INFRA_MAX_RETRIES:
infra_erschoepft.add(i) # kein Respawn → raise am Quorum-Miss
continue
await asyncio.sleep(_INFRA_BACKOFF * 2 ** (infra_attempts[i] - 1))
spawn(i)
elif attempts[i] <= _MAX_RESTARTS:
spawn(i)
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
return results
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
if infra_erschoepft:
raise AgentInfraError(
f"{label}: {len(infra_erschoepft)} Slot(s) nach {_INFRA_MAX_RETRIES} "
"Infra-Retries erschöpft (429/Timeout)")
return None
finally:
for task, i in tasks.items():
kill_process(slots[i]["key"])
kill_process(keys.get(task, slots[i]["key"]))
task.cancel()
if tasks:
await asyncio.gather(*tasks.keys(), return_exceptions=True)
@@ -291,6 +327,11 @@ class GenContext:
OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
class AgentInfraError(Exception):
"""Slot nach Infra-Retries erschöpft (429/Timeout). KEIN inhaltliches Urteil —
der Aufrufer pausiert den Lauf, nie fail-open."""
async def run_single_slot(
ctx: GenContext, label: str, *,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
+238 -46
View File
@@ -11,7 +11,8 @@ previous report of the same topic.
"""
import asyncio
import json
import hashlib
import logging
import re
import sys
from datetime import datetime, timezone
@@ -19,23 +20,28 @@ from pathlib import Path
import database as db
import embedding
from config import STORAGE_DIR, SUB_DUP_KANDIDAT_COS
from config import JUDGE_CHUNK, STORAGE_DIR, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from paths import arbeit_dir
from textkit import _norm_title
log = logging.getLogger("creator.qa")
QA_DIR = STORAGE_DIR / "qa"
JACCARD_FLOOR = 0.5 # title token overlap that makes a pair suspicious
EMB_FLOOR = 0.82 # casefolded title cosine (own threshold, NOT the pipeline's 0.65)
SECTION_CHARS = 4000 # own paragraph splitter — independent of _text_sections
COVER_MIN_TOKENS = 2 # distinctive block tokens a section must share to count as covered
FREMD_MIN_TOKENS = 1 # distinctive title tokens that must appear in the corpus
CONCEPT_COVER_RATIO = 0.6 # fraction of a named result's stems a block must share to count as covered
LLM_SAMPLE = 12 # pairs/sections per judge call with --llm
# Note 0-10, deterministisch aus den Quoten (transparent, diffbar — keine LLM-"Gefühlsnote").
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff); 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}
# Lücken/Fremd wiegen am schwersten (fehlender/falscher Stoff). konzept_luecken (benannte Kernresultate
# ohne Baustein) ist heuristisch → mittleres Gewicht. Dubletten-VERDACHT enthält bewusst Rauschen,
# bildet aber das Nutzerproblem (Dopplungen) ab → leichtestes Gewicht, aber nicht null.
NOTE_GEWICHTE = {"luecken": 3.0, "fremd": 2.5, "unechte_bloecke": 2.5, "konzept_luecken": 1.5,
"dubletten_verdacht": 1.0, "hygiene": 0.5}
# subs/artefacts only exist after board 2 — at gate time these quotas would always be 0
# and water down the inventory score, hence a separate score.
# sub_dubletten counts only with --llm (confirmed pairs); the bare candidate list is
@@ -92,9 +98,15 @@ def _corpus_texts(topic: str) -> dict[str, str]:
out = {}
for f in sorted(folder.glob("*.txt")):
try:
out[f.name] = f.read_text(encoding="utf-8")
text = f.read_text(encoding="utf-8")
except OSError:
continue
# QUELLE:-Header (Metadata, kein Lerninhalt) strippen — sonst bildet er bei großen
# Seiten einen isolierten, block-losen Abschnitt = False-Positive-Lücke.
lines = text.splitlines()
if lines and lines[0].startswith("QUELLE:"):
text = "\n".join(lines[1:]).lstrip("\n")
out[f.name] = text
return out
@@ -179,6 +191,55 @@ def luecken(blocks: list[dict], subs_by_norm: dict[str, list[str]], corpus: dict
return out
# A NAMED result carries a concept name, not just a number: "Satz 7.13 (Christofides)",
# "Satz 6.24: Cook-Levin". These are the core statements the token-based `luecken` misses — 108
# over-granular blocks cover every SECTION, yet the named core result may have no block at all.
# Generic + domain-safe: the NUMBER is mandatory (a numbered labeled unit is a formal statement in
# a structured document, any field), so prose like "ein Satz von Goethe" (no number) never matches.
_NAMED_RESULT_RE = re.compile(
r'\b(?:Satz|Lemma|Korollar|Theorem|Proposition|Folgerung|Definition|Algorithmus)\s+\d+(?:\.\d+)*\s*'
r'(?:\(\s*([^()\n]{3,60}?)\s*\)|:\s*([^\n.;·]{3,60}?)\s*(?:[.\n;·]|$))', re.M)
def _stem(t: str) -> str:
"""Declension-tolerant token stem: drop trailing digits, keep the 5-char prefix
('Eulerschen'/'Eulerscher' → 'euler', 'Kreise'/'Kreis' → 'kreis')."""
return t.rstrip("0123456789")[:5]
def _named_results(corpus: dict[str, str]) -> dict[str, set[str]]:
"""Named/attributed corpus results → {concept name: distinctive stems}. A bare 'Satz 7.18'
(number, no name) yields nothing to match. Same catalogue vocabulary as the title strip.
Umbruch-Schutz (generisch): endet ein Doppelpunkt-Fang am Zeilenumbruch und läuft die
nächste Zeile klein weiter, ist es ein UMBROCHENER SATZ, kein Name — solche Fragmente
(„Satz 6.16: Wenn ein … in P⏎liegt …") kann kein Block je ankern (Phantom-Lücke)."""
out: dict[str, set[str]] = {}
for text in corpus.values():
for m in _NAMED_RESULT_RE.finditer(text):
name = (m.group(1) or m.group(2) or "").strip()
if m.group(2) and m.end() > 0 and text[m.end() - 1] == "\n":
rest = text[m.end():].lstrip(" \t")
if rest[:1].islower():
continue # Satzfragment: Fortsetzung klein auf der nächsten Zeile
toks = _distinctive(name)
if len(name) >= 3 and toks:
out.setdefault(name, set()).update(_stem(t) for t in toks)
return out
def konzept_luecken(blocks: list[dict], named: dict[str, set[str]]) -> list[str]:
"""Named corpus results that NO block covers — concept gaps the token-based `luecken` cannot see.
Covered = a block whose title+description share ≥ CONCEPT_COVER_RATIO of the result's distinctive
stems (declension-tolerant). Errs toward 'covered' so the heuristic never invents a false gap."""
anchors = [{_stem(t) for t in _distinctive(b["title"]) | _distinctive(b.get("description") or "")}
for b in blocks]
out = []
for name, stems in named.items():
if not any(len(stems & a) >= CONCEPT_COVER_RATIO * len(stems) for a in anchors):
out.append(name)
return sorted(out)
def fremd(blocks: list[dict], corpus: dict[str, str]) -> list[str]:
"""Blocks whose distinctive title tokens never appear in the corpus (scope creep).
Token/stem match, NOT raw substring — 'bergang' ⊂ 'Übergang' had whitewashed the
@@ -224,8 +285,10 @@ def hygiene(blocks: list[dict]) -> list[dict]:
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
ones dragged the note although the judge cleared them (aak: 5 of 8, weight 3.0).
Persistierte Freisprüche (2:1 im Repair) zählen nie."""
offen = [x for x in lk if not x.get("freispruch")]
return [x for x in offen if x.get("llm") != "nein"] if llm else offen
def note(quoten: dict, gewichte: dict = NOTE_GEWICHTE) -> float:
@@ -281,19 +344,106 @@ def _qa_prompt(name: str, **kwargs) -> str:
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]:
async def judge_wave(template: str, topic: str, key: str, slot: str, items: list[str],
*, chunk: int = JUDGE_CHUNK, prefix: str = "qa", label: str = "QA",
extra: str = "") -> dict[int, str]:
"""Gechunkte Ja/Nein-Judge-Welle über ALLE Items, Chunks parallel (die Semaphoren in
agents.py begrenzen); Ergebnis mit globalen 1-basierten Indizes. Fail-open pro Chunk
(Items bleiben ohne Urteil), aber nie stumm. Ersetzt die drei strukturgleichen
Handkopien in repair/qa/guide_qa. `extra` füllt den {extra}-Slot des Templates
(z. B. Inventar-Kontext für das Lücken-Urteil im Repair)."""
from agents import run_agent
from pipeline import _yesno_schema
from pipeline import _timeout, _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 {}
async def _chunk(lo: int) -> dict[int, str]:
teil = items[lo:lo + chunk]
listing = "\n\n".join(f"{k}. {it}" for k, it in enumerate(teil, 1))
try:
rc, out, _err = await run_agent(
f"{prefix}-{topic}-{key}-{lo}", _qa_prompt(template, topic=topic, extra=extra, **{slot: listing}),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic, label=f"{label} {key}")
except Exception:
log.exception("[%s] %s-Judge %s+%d fehlgeschlagen — Items ohne Urteil", topic, label, key, lo)
return {}
if rc != 0:
log.warning("[%s] %s-Judge %s+%d fehlgeschlagen (rc=%s) — %d Items ohne Urteil",
topic, label, key, lo, rc, len(teil))
return {}
return _yesno_schema(parse_json_text(out)) or {}
offsets = range(0, len(items), chunk)
results = await asyncio.gather(*[_chunk(lo) for lo in offsets])
return {lo + k: urteil for lo, v in zip(offsets, results) for k, urteil in v.items()}
# ── Report ──────────────────────────────────────────────────────────────────────────
def report_paths(topic: str, guide: bool = False) -> list[Path]:
"""QA-Reports eines Topics, mtime-aufsteigend (Run-ID- und Timestamp-Namen sortieren
lexikographisch nicht). guide=True → die separate guide-*-Serie (guide_qa.py).
freispruch.json teilt den Ordner, ist aber kein Report — immer außen vor."""
tdir = QA_DIR / topic
if not tdir.is_dir():
return []
return sorted((p for p in tdir.glob("*.json")
if p.name.startswith("guide-") == guide and p.name != "freispruch.json"),
key=lambda p: p.stat().st_mtime)
_latest_cache: dict[tuple[str, bool], tuple[float, dict]] = {}
def latest_report(topic: str, guide: bool = False) -> dict | None:
"""Jüngster Report als geparstes dict, mtime-gecacht — die Board-Snapshots lesen das
im 1,2-s-Frontend-Takt, ein JSON-Read je Poll war unnötiges Datei-I/O. glob+stat
bleiben (billig), der Read passiert nur bei geänderter mtime."""
reports = report_paths(topic, guide)
if not reports:
return None
p = reports[-1]
mtime = p.stat().st_mtime
key = (topic, guide)
cached = _latest_cache.get(key)
if cached is None or cached[0] != mtime:
_latest_cache[key] = (mtime, _json_file(p) or {})
return _latest_cache[key][1]
def freispruch_pfad(topic: str) -> Path:
return QA_DIR / topic / "freispruch.json"
def _paar_key(a: str, b: str) -> str:
return "||".join(sorted((_norm_title(a), _norm_title(b))))
def luecken_key(item: dict) -> str:
"""Stabiler Freispruch-Schlüssel einer Lücken-Fundstelle: Datei + Vorschau-Hash.
Abschnittsnummern verschieben sich bei Korpusänderung — dann veralten die Keys
und werden inert (gewollt: neue Fundstellen brauchen ein neues Urteil)."""
v = " ".join(str(item.get("vorschau", "")).casefold().split())
return f"{item.get('datei', '')}||{hashlib.sha1(v.encode()).hexdigest()[:16]}"
def offene_luecken(report: dict) -> tuple[list[dict], list[str]]:
"""Zählbare offene Lücken eines Reports: (luecken-Items ohne Freispruch/LLM-nein,
konzept_luecken-Namen). Grundlage für Lücken-Research und Lücken-Urteil im Repair."""
lk = [x for x in report.get("luecken", [])
if not x.get("freispruch") and x.get("llm") != "nein"]
return lk, list(report.get("konzept_luecken", []))
def lade_freispruch(topic: str) -> dict[str, list[str]]:
"""Persistierte 2:1-Freisprüche des Repair-Stichentscheids (repair._mit_stichentscheid):
mehrheitlich als „behalten" geurteilte Befunde zählen nicht mehr in die Note — sonst
pendelte sie dauerhaft unter 10 ohne Fix-Pfad (gemessen: kanban-smoke 9.4, aak 9.2).
Die Detektoren bleiben unverändert; ein Freispruch ist ein persistiertes Urteil,
kein Detektor-Tuning. Freigesprochene bleiben im Report sichtbar."""
d = _json_file(freispruch_pfad(topic))
return d if isinstance(d, dict) else {}
async def qa_report(topic: str, llm: bool = False) -> dict | None:
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
if not cards:
@@ -313,43 +463,67 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
sd = sub_dubletten(sub_rows)
lk = luecken(blocks, subs_by_norm, corpus) if corpus else []
fr = fremd(blocks, corpus) if corpus else []
named = _named_results(corpus) if corpus else {}
kl = konzept_luecken(blocks, named) if corpus else []
bl = beleg(blocks, sub_rows)
hy = hygiene(blocks)
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
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):
frei = lade_freispruch(topic)
frei_fremd = set(frei.get("fremd") or [])
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
# Persistierte 2:1-Freisprüche: markiert bleibt sichtbar (diffbar), zählt aber nicht mehr
# in Quote und Judge-Welle — sonst pendelte der Verdachts-Floor ewig unter 10.
frei_dub = set(frei.get("dubletten") or [])
for p in d:
if _paar_key(p["a"], p["b"]) in frei_dub:
p["freispruch"] = True
d_offen = [p for p in d if not p.get("freispruch")]
frei_lk = set(frei.get("luecken") or [])
for x in lk:
if luecken_key(x) in frei_lk:
x["freispruch"] = True
lk_offen = [x for x in lk if not x.get("freispruch")]
frei_kl = set(frei.get("konzept_luecken") or [])
kl_frei = [nm for nm in kl if _norm_title(nm) in frei_kl]
kl = [nm for nm in kl if _norm_title(nm) not in frei_kl]
if llm and d_offen:
v = await judge_wave("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in d_offen[:LLM_SAMPLE]])
for k, p in enumerate(d_offen[:LLM_SAMPLE], 1):
p["llm"] = v.get(k, "?")
if llm and lk:
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):
if llm and lk_offen:
v = await judge_wave("QA-Luecken", topic, "luecken", "sections",
[f"[{x['datei']} #{x['abschnitt']}] {x['vorschau']}" for x in lk_offen[:LLM_SAMPLE]])
for k, x in enumerate(lk_offen[:LLM_SAMPLE], 1):
x["llm"] = v.get(k, "?")
if llm and sd: # full coverage in chunks — a sampled quota would mislead the note
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, "?")
v = await judge_wave("QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in sd])
for k, p in enumerate(sd, 1):
p["llm"] = v.get(k, "?")
frei_sub = set(frei.get("sub_dubletten") or [])
for p in sd:
if p.get("llm") == "ja" and _paar_key(p["a"], p["b"]) in frei_sub:
p["freispruch"] = True # 2:1-Urteil „behalten" — sichtbar, aber notenfrei
unecht: list[str] | None = None
if llm and blocks:
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"]
v = await judge_wave("QA-Bausteine", topic, "bausteine", "blocks",
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in blocks],
chunk=80)
verdacht = [b for k, b in enumerate(blocks, 1) if v.get(k) == "nein"]
# Bestätiger-Pass nur über die Geflaggten: der Einzel-Judge flaggte pro Lauf ANDERE
# Blöcke (gemessen aak: Note pendelte 9.3↔10.0 bei identischem Bestand) — nur
# doppelt-„nein" zählt; Repair hat als dritte Sicherung die eigene Zweitmeinung
unecht = []
if verdacht:
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
v2 = await judge_wave("QA-Bausteine", topic, "bausteine-b2", "blocks",
[f"{b['title']} — {b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
frei_unecht = set(frei.get("unecht") or [])
unecht = [t for t in unecht if _norm_title(t) not in frei_unecht]
art_rows = [dict(r) for r in await db.get_sub_artefakte(topic)]
fragen = [dict(r) for r in await db.list_question_pattern(topic)]
@@ -362,22 +536,28 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
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)
if llm: # confirmed pairs only — the bare candidate list is suspicion, not damage;
# freigesprochene (2:1 „behalten") zählen nicht mehr
quoten_art["sub_dubletten"] = round(
sum(1 for p in sd if p.get("llm") == "ja" and not p.get("freispruch")) / n_cons, 3)
summary = _json_file(arbeit_dir(topic) / "lauf-summary.json") or {}
report = {
"topic": topic, "erstellt": datetime.now(timezone.utc).isoformat(),
"run_id": summary.get("run_id", ""), "bloecke": len(blocks),
"quoten": {
"dubletten_verdacht": round(len(d) / max(len(blocks), 1), 3),
"dubletten_verdacht": round(len(d_offen) / max(len(blocks), 1), 3),
"luecken": round(len(_zaehlbare_luecken(lk, llm)) / n_sections, 3),
"fremd": round(len(fr) / max(len(blocks), 1), 3),
**({"konzept_luecken": round(len(kl) / max(len(named), 1), 3)} if corpus else {}),
"hygiene": round(len(hy) / max(len(blocks), 1), 3),
**({"unechte_bloecke": round(len(unecht) / max(len(blocks), 1), 3)} if unecht is not None else {}),
},
"quoten_artefakte": quoten_art,
**({"unecht": unecht} if unecht is not None else {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
**({"konzept_luecken_freigesprochen": kl_frei} if kl_frei else {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "konzept_luecken": kl,
"fremd": fr, "beleg": bl, "hygiene": hy,
"artefakte": art,
"lauf": summary,
}
@@ -400,10 +580,7 @@ def _diff(prev: dict | None, cur: dict) -> dict:
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)
older = report_paths(report["topic"])
prev = _json_file(older[-1]) if older else None
report["diff_zum_vorlauf"] = _diff(prev, report)
name = report["run_id"] or datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -412,6 +589,21 @@ def _write_report(report: dict) -> Path:
return path
async def write_report(report: dict) -> Path:
"""_write_report + kompaktes kind='qa'-Event. Die Report-JSONs liegen nur auf der
Lauf-Maschine (storage/qa/) — ein DB-Pull reichte nicht, um Note/Quoten eines Runs
zu rekonstruieren (Analyse 20260704-1452-b223). Nur die Kennzahlen, kein Volltext;
run_id stempelt add_event aus der Registry (gesetzt im Lauf, leer bei manueller QA)."""
path = await asyncio.to_thread(_write_report, report)
try: # Event ist Komfort — ein DB-Fehler darf den Report nicht kosten (fail-open)
await db.add_event(report["topic"], "qa", key=path.stem, meta={
"note": report["note"], "note_artefakte": report.get("note_artefakte"),
"quoten": report["quoten"], "quoten_artefakte": report.get("quoten_artefakte", {})})
except Exception:
pass
return path
def _digest(report: dict, path: Path):
na = report.get("note_artefakte")
print(f"QA {report['topic']} — {report['bloecke']} Blöcke (run {report['run_id'] or '—'})"
@@ -443,7 +635,7 @@ async def main(topic: str, llm: bool):
report = await qa_report(topic, llm=llm)
if report is None:
sys.exit(1)
_digest(report, _write_report(report))
_digest(report, await write_report(report))
finally:
await db.close_db()
+343 -100
View File
@@ -2,77 +2,272 @@
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."""
deterministisch (+ Beschreibung/Suffix per Sanierung/Rename), bestätigte Dubletten mergen
(Zweitmeinung), Fremd/Unecht nur nach Gegen-Judge entfernen, Beleg-Nachfass je Sub,
Lücken per Stichentscheid klären (Freispruch oder bestätigt = braucht Recherche).
Fail-open überall: Zweifel/Judge-Fehler → behalten, nichts persistieren."""
import 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 blocks import _blocks_files, _evidence_pack, material_folder, source_folder
from config import EVIDENCE_PER_BLOCK
from fsutil import atomic_write_json
from jsonio import parse_json_text, read_json_file as _json_file
from pipeline import _yesno_schema
from textkit import _norm_title, _title, clean_title
from jsonio import read_json_file as _json_file
from textkit import _norm_title, _title, clean_title, parse_facts
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 []
async def repair_befunde(topic: str, ebene: str | None = None, luecken_urteil: bool = True) -> dict:
"""ebene=None → alle Fixes; „inventory" → nur Inventar; „artefacts" → nur Artefakte.
luecken_urteil: offene Lücken/Konzept-Lücken dem Stichentscheid vorlegen (Freispruch
oder bestätigt) — der QA-Gate-Watch schaltet es aus, solange seine Lücken-Recherche
noch Runden hat. Misst am Ende IMMER beide Noten neu. `aktionen` zählt alles, was die
Runde getan hat (Fixes+Merges+Freisprüche) — der Auto-Loop stoppt erst bei 0."""
reports = qa.report_paths(topic)
report = _json_file(reports[-1]) if reports else None
if not report:
return {"fehler": "kein QA-Report — erst QA laufen lassen"}
files = _blocks_files(topic)
cards = await db.kanban_cards(topic, board="inventory", stage="done_block")
by_norm = {_norm_title(c["payload"].get("title", "")): c for c in cards}
inv = ebene in (None, "inventory")
art = ebene in (None, "artefacts")
hygiene = await _fix_hygiene(topic, report, by_norm, files)
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)
hygiene = await _fix_hygiene(topic, report, by_norm, files) if inv else []
hygiene += (await _fix_beschreibungen(topic, report, by_norm)) if inv else []
hygiene += (await _fix_suffixe(topic, report, by_norm, files)) if inv else []
merges, frei_paare = (await _merge_dubletten(topic, report, by_norm, files)) if inv else ([], [])
sub_merges, frei_subs = (await _merge_sub_dubletten(topic, report, files)) if art else ([], [])
entfernt, frei_bloecke = (await _entferne_fremd_unecht(topic, report, by_norm, files)) if inv else ([], [])
frei_luecken = (await _pruefe_luecken(topic, report, by_norm)) if inv and luecken_urteil else []
beleg_fix = (await _belege_nachfassen(topic, report, files)) if art else []
aufgeraeumt = (await _raeume_waisen(topic)) if art else 0
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
neu = await qa.qa_report(topic, llm=True)
if neu:
await asyncio.to_thread(qa._write_report, neu)
await qa.write_report(neu)
na = neu.get("note_artefakte") if neu else None
freigesprochen = frei_paare + frei_subs + frei_bloecke + frei_luecken
lk_rest, kl_rest = qa.offene_luecken(neu or {})
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
"beleg_fix": beleg_fix, "aufgeraeumt": aufgeraeumt, "freigesprochen": freigesprochen,
"aktionen": (len(hygiene) + len(merges) + len(sub_merges) + len(entfernt)
+ len(beleg_fix) + aufgeraeumt + len(freigesprochen)),
"braucht_research": len(lk_rest) + len(kl_rest), # offene Lücken NACH dieser Runde
"note": float(neu["note"]) if neu else 10.0, # neu gemessene Inventar-Note
"note_artefakte": float(na) if na is not None else None} # None solange Board 2 leer
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> 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))
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str],
extra: str = "") -> dict[int, str]:
"""No-Tool-Judge-Welle (fail-open: Fehler → leeres Verdikt = behalten)."""
return await qa.judge_wave(template, topic, key, slot, items, prefix="repair", label="Repair",
extra=extra)
def _speichere_freispruch(topic: str, kategorie: str, keys: list[str]) -> None:
d = qa.lade_freispruch(topic)
alt = set(d.get(kategorie) or [])
d[kategorie] = sorted(alt | set(keys))
qa.freispruch_pfad(topic).parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qa.freispruch_pfad(topic), d, indent=1)
async def _mit_stichentscheid(template: str, topic: str, key: str, slot: str,
lines: list[str], befund: str, kategorie: str = "",
ids: list[str] | None = None, extra: str = "") -> tuple[dict[int, str], list[str]]:
"""Zweitmeinung + Stichentscheid: Der Repair-Judge kann den QA-Befund kippen — bei
Dissens (QA sagt Befund, Judge sagt behalten) entscheidet ein DRITTER Judge nur über
die strittigen Items, Mehrheit 2/3 (Muster Crossblock-Tiebreaker). Ohne ihn pendelte
die Note dauerhaft unter 10 ohne Fix-Pfad (gemessen: aak-fremd 9.2, kanban-smoke-
Dublette 9.4 — „keine behebbaren Befunde" trotz Befund).
Explizites 2:1-„behalten" wird als FREISPRUCH persistiert (kategorie+ids) — die QA
zählt das Item ab dann nicht mehr (qa.lade_freispruch). j3-AUSFALL persistiert nicht
(fail-open ist kein Urteil). → (verdicts, freigesprochene Zeilen)."""
v = await _judge(template, topic, key, slot, lines, extra=extra)
strittig = [i for i in range(1, len(lines) + 1) if v.get(i) != befund]
frei: list[str] = []
if strittig:
v3 = await _judge(template, topic, f"{key}-st", slot, [lines[i - 1] for i in strittig], extra=extra)
gegen = "nein" if befund == "ja" else "ja"
frei_keys: list[str] = []
for pos, i in enumerate(strittig, 1):
if v3.get(pos) == befund:
v[i] = befund # 2:1 für den QA-Befund → handeln
elif v3.get(pos) == gegen and kategorie and ids:
frei_keys.append(ids[i - 1])
frei.append(lines[i - 1].splitlines()[0][:80])
if frei_keys:
_speichere_freispruch(topic, kategorie, frei_keys)
log.info("[%s] Repair %s: %d Befund(e) per 2:1 freigesprochen", topic, kategorie, len(frei_keys))
return v, frei
async def _fix_beschreibungen(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Hygiene-Resttyp „leere-beschreibung": EINE Beschreibung aus Material-Auszügen
generieren (Blocks-Sanierung-Template; der Titel bleibt — Repair ist flow-los und
hat keinen Korpus-Anker-Kontext für Renames). Ohne Material/Auszüge bleibt der Befund."""
from agents import run_agent
from pipeline import _prompt, _timeout
from jsonio import parse_json_text
from board_inventory import _sanierung_schema
items = [h for h in report.get("hygiene", []) if "leere-beschreibung" in (h.get("probleme") or [])]
folder = source_folder(topic) or material_folder(topic)
if not items or not folder:
return []
out: list[str] = []
for h in items:
norm = _norm_title(h.get("titel", ""))
card = by_norm.get(norm)
if not card or (card["payload"].get("description") or "").strip():
continue
titel = card["payload"].get("title", "")
ev = _evidence_pack(folder, card["payload"].get("sources") or None, [titel], budget=4000)
if not ev:
continue
try:
rc, 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 {}
rc, txt, _err = await run_agent(
f"repair-{topic}-sanierung-{norm[:24]}",
_prompt("Blocks-Sanierung", topic=topic, title=titel, description="(leer)", excerpts=ev),
_timeout("qa_judge"), role="judge", capabilities="none", scope=topic,
label="Repair Beschreibung")
except Exception:
log.exception("[%s] Repair-Judge %s fehlgeschlagen — Befunde bleiben", topic, key)
v = {}
verdicts.update({lo + k: urteil for k, urteil in v.items()})
return verdicts
log.exception("[%s] Beschreibungs-Sanierung fehlgeschlagen — Befund bleibt", topic)
continue
verdict = _sanierung_schema(parse_json_text(txt)) if rc == 0 else None
if not verdict or not verdict[1].strip():
continue
p = dict(card["payload"])
p["description"] = verdict[1].strip()
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
await db.set_block_status(topic, norm, "consensus", description=p["description"])
card["payload"] = p
out.append(f"beschrieben: {titel}")
return out
_SUFFIX_RE = re.compile(r"\s*\(\d+\)\s*$")
async def _fix_suffixe(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Hygiene-Resttyp „kollisions-suffix" („Titel (2)", entstanden beim Spiegeln):
Basis-Norm frei (Partner inzwischen gemerged/verworfen) → Rename auf den Basistitel
inkl. Re-Key aller angehängten Tabellen (db.rename_block_norm) + Board-2-Karte +
Sidecars. Basis besetzt → Judge entscheidet Dublette (Merge); sonst bleibt der
Befund sichtbar (zwei echte gleichnamige Konzepte sind ein Naming-Problem)."""
items = [h for h in report.get("hygiene", []) if "kollisions-suffix" in (h.get("probleme") or [])]
out: list[str] = []
for h in items:
alt = h.get("titel", "")
basis = _SUFFIX_RE.sub("", alt).strip()
alt_norm, basis_norm = _norm_title(alt), _norm_title(basis)
card = by_norm.get(alt_norm)
if not card or not basis or basis_norm == alt_norm:
continue
if basis_norm in by_norm: # Basis lebt noch → Dubletten-Frage statt Rename
merged, _frei = await _merge_dubletten(
topic, {"dubletten": [{"a": basis, "b": alt, "llm": "?"}]}, by_norm, files)
out += [f"suffix-merge: {z}" for z in merged]
continue
await db.rename_block_norm(topic, alt_norm, basis_norm, basis)
b2 = await db.kanban_get_card(topic, "artefacts", alt_norm)
if b2:
p2 = dict(b2["payload"])
p2["title"] = basis
await db.kanban_upsert_card(topic, "artefacts", basis_norm, b2["kind"], b2["stage"], p2)
await db.kanban_delete_card(topic, "artefacts", alt_norm)
p = dict(card["payload"])
p["title"] = basis
if p.get("mirrored_norm"):
p["mirrored_norm"] = basis_norm
await db.kanban_set_payload(topic, "inventory", card["card_id"], p)
card["payload"] = p
_rename_in_files(files, alt_norm, basis)
by_norm.pop(alt_norm, None)
by_norm[basis_norm] = card
out.append(f"suffix: {alt} → {basis}")
return out
async def _pruefe_luecken(topic: str, report: dict, by_norm: dict) -> list[str]:
"""Lücken-Urteil (2:1): offene luecken-Fundstellen und konzept_luecken-Namen dem
Stichentscheid vorlegen — 2:1-„keine echte Lücke" wird als Freispruch persistiert,
bestätigte bleiben als braucht_research sichtbar (Gate-Watch recherchiert, der Nutzer
klickt „+ Recherche"). Das Inventar geht als Kontext in den {extra}-Slot, damit die
Judges Abdeckung gegen den Bestand prüfen können."""
lk, kl = qa.offene_luecken(report)
if not lk and not kl:
return []
extra = ("\n\nEXISTING INVENTORY BLOCKS (judge coverage against these):\n"
+ "\n".join(f"- {c['payload'].get('title', '')}" for c in by_norm.values()))
frei: list[str] = []
if lk:
_v, f = await _mit_stichentscheid(
"QA-Luecken", topic, "luecken", "sections",
[f"[{x.get('datei')} #{x.get('abschnitt')}] {x.get('vorschau', '')}" for x in lk],
"ja", kategorie="luecken", ids=[qa.luecken_key(x) for x in lk], extra=extra)
frei += f
if kl:
_v, f = await _mit_stichentscheid(
"QA-Konzept-Luecken", topic, "konzept-luecken", "results", list(kl),
"ja", kategorie="konzept_luecken", ids=[_norm_title(n) for n in kl], extra=extra)
frei += f
return frei
async def _belege_nachfassen(topic: str, report: dict, files: dict) -> list[str]:
"""subs_ohne_beleg (mentions=0): Beleg-Nachfass je Sub mit Material-Auszügen.
Judge+QA einig „nein" (kein Beleg) → Sub verwerfen (Artefakte/Fragen räumt das
nachlaufende _raeume_waisen); 2:1-„ja" → mentions=1 nachtragen — Datenfix am
gemessenen Datum selbst, kein Freispruch nötig, Detektor unverändert. Produktiv ist
der Detektor derzeit inaktiv (alle Schreiber setzen mentions=1) — Pfad ist Robustheit.
Ohne Quellordner kein Urteil möglich → Befund bleibt."""
eintraege = (report.get("beleg") or {}).get("subs_ohne_beleg") or []
folder = source_folder(topic) or material_folder(topic)
if not eintraege or not folder:
return []
rows = {f"{r['block']} · {r['sub_title']}": r for r in await db.list_subblocks(topic)
if r["status"] != "variant"}
items = [(e, rows[e]) for e in eintraege if e in rows and not rows[e]["mentions"]]
if not items:
return []
lines = []
for e, r in items:
ev = _evidence_pack(folder, None, [r["block"], r["sub_title"]], budget=EVIDENCE_PER_BLOCK)
lines.append(f"{e}\n{ev or '(keine Treffer im Material)'}")
v1 = await _judge("QA-Repair-Beleg", topic, "beleg", "blocks", lines)
strittig = [i for i in range(1, len(items) + 1) if v1.get(i) == "ja"]
v3 = (await _judge("QA-Repair-Beleg", topic, "beleg-st", "blocks",
[lines[i - 1] for i in strittig])) if strittig else {}
out: list[str] = []
async def _verwerfen(e: str, r: dict):
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], status="discarded")
_entferne_sub_in_files(files, r["block_norm"], r["sub_norm"])
out.append(f"entfernt: {e[:60]}")
for i, (e, r) in enumerate(items, 1):
if v1.get(i) == "nein": # QA (mentions=0) + Judge einig → weg
await _verwerfen(e, r)
elif v1.get(i) == "ja": # Dissens → Stichentscheid
pos = strittig.index(i) + 1
if v3.get(pos) == "ja":
await db.set_subblock_fields(topic, r["block_norm"], r["sub_norm"], mentions=1)
out.append(f"belegt: {e[:60]}")
elif v3.get(pos) == "nein":
await _verwerfen(e, r)
return out
async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
"""Nur der norm-invariante Teil (`**`/Backticks); `(n)`-Suffix und leere Beschreibung
ändern die Norm bzw. brauchen Inhalt — bleiben Befund."""
ändern die Norm bzw. brauchen Inhalt — eigene Handler (_fix_suffixe/_fix_beschreibungen)."""
fixed = []
for h in report.get("hygiene", []):
alt = h.get("titel", "")
@@ -92,33 +287,68 @@ async def _fix_hygiene(topic: str, report: dict, by_norm: dict, files: dict) ->
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
async def _merge_paar(topic: str, p: dict, by_norm: dict, files: dict) -> str | None:
"""Ein bestätigtes Dubletten-Paar mergen (spiegelt die dedup-Stage: Union ins
Gewinner-Payload, Verlierer → grouped). → Journalzeile oder None."""
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
if not a or not b or a["card_id"] == b["card_id"]:
return None
win, lose = sorted((a, b), key=lambda c: (len(c["payload"].get("description") or ""),
len(c["payload"].get("title") or "")), reverse=True)
wp, lp = dict(win["payload"]), dict(lose["payload"])
wp["readers"] = sorted(set(wp.get("readers") or []) | set(lp.get("readers") or []))
wp["sources"] = sorted(set(wp.get("sources") or []) | set(lp.get("sources") or []))
lp.update(reason="merged", merged_into=wp.get("title", ""))
await db.kanban_set_payload(topic, "inventory", win["card_id"], wp)
await db.kanban_set_payload(topic, "inventory", lose["card_id"], lp)
await db.kanban_advance(topic, "inventory", lose["card_id"], "grouped")
await _purge_block(topic, lp.get("title", ""), files)
by_norm.pop(_norm_title(lp.get("title", "")), None)
return f"{lp.get('title')} → {wp.get('title')}"
async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
"""QA-bestätigte Paare (llm=ja): Zweitmeinung, Merge nur bei erneut ja; 2:1-„behalten"
persistiert jetzt als Freispruch (fehlte — die Paare pendelten ewig im Verdacht).
Widerlegte/unbeurteilte Paare (llm=nein/?) bekommen den Klärungskanal: Judge-„nein" →
Freispruch (mit QA-nein 2:0; beim bloßen Verdacht reicht das klare Gegen-Urteil),
Judge-„ja" → Stichentscheid (ja → Merge 2:1, nein → Freispruch 2:1, Ausfall → nichts)."""
alle = [p for p in report.get("dubletten", []) if not p.get("freispruch")
and _norm_title(p.get("a", "")) in by_norm and _norm_title(p.get("b", "")) in by_norm]
ja = [p for p in alle if p.get("llm") == "ja"]
offen = [p for p in alle if p.get("llm") != "ja"]
merged: list[str] = []
frei: list[str] = []
if ja:
v, f = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in ja], "ja",
kategorie="dubletten",
ids=[qa._paar_key(p["a"], p["b"]) for p in ja])
frei += f
for i, p in enumerate(ja, 1):
if v.get(i) == "ja" and (zeile := await _merge_paar(topic, p, by_norm, files)):
merged.append(zeile)
if offen:
ids = [qa._paar_key(p["a"], p["b"]) for p in offen]
v1 = await _judge("QA-Dubletten", topic, "dubletten-v", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in offen])
frei_keys = [ids[i - 1] for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
frei += [f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80]
for i in range(1, len(offen) + 1) if v1.get(i) == "nein"]
strittig = [i for i in range(1, len(offen) + 1) if v1.get(i) == "ja"]
if strittig:
v3 = await _judge("QA-Dubletten", topic, "dubletten-v-st", "pairs",
[f"A: {offen[i - 1]['a']}\nB: {offen[i - 1]['b']}" for i in strittig])
for pos, i in enumerate(strittig, 1):
if v3.get(pos) == "ja" and (zeile := await _merge_paar(topic, offen[i - 1], by_norm, files)):
merged.append(zeile)
elif v3.get(pos) == "nein":
frei_keys.append(ids[i - 1])
frei.append(f"{offen[i - 1]['a']} <-> {offen[i - 1]['b']}"[:80])
if frei_keys:
_speichere_freispruch(topic, "dubletten", frei_keys)
log.info("[%s] Repair dubletten: %d Verdacht(e) freigesprochen", topic, len(frei_keys))
return merged, frei
_SUB_PAAR = re.compile(r"^\[(.+?)\] (.+)$", re.S)
@@ -127,15 +357,37 @@ _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
kp = len(parse_facts(r.get("facts")).get("key_points") or [])
return (kp, len(r.get("sub_title") or ""))
return (a, b) if score(a) >= score(b) else (b, a)
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[str]:
async def falte_sub(topic: str, files: dict, win: dict, lose: dict) -> None:
"""Verlierer-Sub falten: Status variant, Fragen/Artefakte zum Gewinner umhängen (oder
löschen, wenn der Typ dort existiert), Sidecar-Dateien bereinigen. Gemeinsamer Kern
von QA-Repair und Cross-Block-Dedup (Board 2, Run-Ende) — win/lose sind subblocks-Rows."""
await db.set_subblock_fields(topic, lose["block_norm"], lose["sub_norm"], status="variant")
w_fragen = {r["sub_norm"] for r in await db.list_question_pattern(topic, win["block_norm"])}
for r in await db.list_question_pattern(topic, lose["block_norm"]):
if r["sub_norm"] != lose["sub_norm"]:
continue
if win["sub_norm"] not in w_fragen:
await db.upsert_question_pattern(topic, win["block_norm"], win["sub_norm"],
win["block"], win["sub_title"], r["question"])
await db.delete_frage_row(topic, lose["block_norm"], lose["sub_norm"])
w_typen = {r["type"] for r in await db.get_sub_artefakte(topic, block_norm=win["block_norm"])
if r["sub_norm"] == win["sub_norm"]}
for r in await db.get_sub_artefakte(topic, block_norm=lose["block_norm"]):
if r["sub_norm"] != lose["sub_norm"]:
continue
if r["type"] not in w_typen:
await db.put_sub_artifact(topic, win["block_norm"], win["sub_norm"], r["type"],
r["data"], win["block"], win["sub_title"])
await db.delete_artefakt_row(topic, lose["block_norm"], lose["sub_norm"], r["type"])
_entferne_sub_in_files(files, lose["block_norm"], lose["sub_norm"])
async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> tuple[list[str], list[str]]:
"""QA-bestätigte Sub-Paare (llm=ja) nach Zweitmeinung falten: Verlierer → variant,
seine Fragen/Artefakte wandern zum Gewinner (oder fallen weg, wenn er den Typ hat).
Repair hatte dafür keinen Handler — die Paare überlebten jeden Repair-Zyklus."""
@@ -150,10 +402,13 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
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])
return [], []
v, frei = await _mit_stichentscheid(
"QA-Sub-Dubletten", topic, "sub-dubletten", "pairs",
[f"A: [{a['block']}] {a['sub_title']}\nB: [{b['block']}] {b['sub_title']}" for a, b in paare],
"ja", kategorie="sub_dubletten",
ids=[qa._paar_key(f"[{a['block']}] {a['sub_title']}", f"[{b['block']}] {b['sub_title']}")
for a, b in paare])
merged: list[str] = []
gone: set[tuple] = set()
for i, (a, b) in enumerate(paare, 1):
@@ -161,29 +416,10 @@ async def _merge_sub_dubletten(topic: str, report: dict, files: dict) -> list[st
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")
await falte_sub(topic, files, win, lose)
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
return merged, frei
def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
@@ -214,8 +450,9 @@ def _entferne_sub_in_files(files: dict, bnorm: str, sub_norm: str) -> None:
atomic_write_json(files["artefakte"], neu, indent=1)
async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> list[str]:
async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files: dict) -> tuple[list[str], list[str]]:
out = []
frei_alle: list[str] = []
fremd = [t for t in report.get("fremd", []) if _norm_title(t) in by_norm]
if fremd:
folder = source_folder(topic)
@@ -224,7 +461,10 @@ async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files:
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)
v, frei = await _mit_stichentscheid("QA-Repair-Beleg", topic, "fremd", "blocks", lines,
"nein", kategorie="fremd",
ids=[_norm_title(t) for t in fremd])
frei_alle += frei
for i, t in enumerate(fremd, 1):
if v.get(i) == "nein":
await _reject(topic, t, by_norm, files, "qa-fremd")
@@ -233,12 +473,15 @@ async def _entferne_fremd_unecht(topic: str, report: dict, by_norm: dict, files:
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)
v, frei = await _mit_stichentscheid("QA-Bausteine", topic, "unecht", "blocks", lines,
"nein", kategorie="unecht",
ids=[_norm_title(t) for t in unecht])
frei_alle += frei
for i, t in enumerate(unecht, 1):
if v.get(i) == "nein":
await _reject(topic, t, by_norm, files, "qa-unecht")
out.append(t)
return out
return out, frei_alle
async def _raeume_waisen(topic: str) -> int:
+1
View File
@@ -1,6 +1,7 @@
fastapi
uvicorn[standard]
aiosqlite
httpx
playwright
trafilatura
pymupdf4llm
+117 -76
View File
@@ -3,15 +3,16 @@ import json
import logging
import shutil
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
from agents import active_agents, provider_available
from config import PROJECTS_DIR, UNI_DIR, PROVIDERS
from agents import active_agents, agent_ebene, provider_available
from config import DEFAULT_PROVIDER, PROJECTS_DIR, UNI_DIR, PROVIDERS
from database import (
create_guide, delete_guide, get_guide, list_guides,
create_guide, delete_guide, get_guide, list_guides, update_guide,
create_topic, list_topics as db_list_topics, delete_topic,
list_block_progress, get_block_progress, set_open_question,
set_block_score_and_streak,
@@ -19,10 +20,11 @@ from database import (
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board,
get_practice_progress, upsert_practice_progress, sub_levels_norm, subs_per_level_norm,
list_runs, latest_board_runs, get_db,
)
from 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 board_inventory import add_research_agent, board_snapshot, requeue_dead
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup, leitner_step, PRACTICE_NEW_PER_SESSION
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide
@@ -30,9 +32,9 @@ from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learns
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideCardResetRequest, GuideFormatRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
BlocksCreateRequest, BlocksStatusResponse, QaRunRequest, RepairRequest,
GuideFormatRequest,
GuideChatRequest, GuideChatResponse,
ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse,
@@ -72,6 +74,20 @@ async def get_stats():
return {"topics": len(topics), "formats": formats_stats(guides, levels)}
@router.get("/health")
async def health():
await (await get_db()).execute("SELECT 1")
return {"ok": True}
@router.get("/runs")
async def get_runs(topic: str, limit: int = 10):
"""Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
`latest` = jüngster Lauf MIT Daten je Ebene (inventory/artefacts/guide) — die
Board-Kopfzeilen zeigen so auch nach „Nur Artefakte"-/Guide-Läufen ihre Zahlen."""
return {"runs": await list_runs(topic, limit), "latest": await latest_board_runs(topic)}
@router.get("/topics/progress")
async def topic_progress(topic: str):
"""Completion status per format + topic completion — for unlocking the next expansion stage."""
@@ -89,14 +105,28 @@ async def add_topic(req: TopicCreateRequest):
@router.delete("/topics")
async def remove_topic(topic: str):
guides = [g for g in await list_guides() if g["topic"] == topic]
status = await blocks_status(topic)
if status["generating"] or any(g["status"] == "generating" for g in guides):
raise HTTPException(409, "Generierung läuft — erst abbrechen")
await delete_topic(topic)
await delete_block_data(topic)
await delete_topic_pipeline(topic)
await delete_source(topic) # topic config (DB) — removed together with the topic
await delete_guide_content(topic)
shutil.rmtree(topic_dir(topic), ignore_errors=True)
# guides/Board/Kanban mitlöschen — GET /topics leitet Topics aus guides ab,
# sonst taucht das gelöschte Topic sofort wieder auf
for g in guides:
await delete_guide(g["id"])
await delete_guide_board(topic)
await kanban_reset(topic)
import qa
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
# rmtree (potenziell große Topic-Ordner) in den Threadpool — der Event-Loop bedient
# parallel laufende Flows/Polls, blockierendes Datei-I/O friert die alle ein
def _wipe():
shutil.rmtree(topic_dir(topic), ignore_errors=True)
shutil.rmtree(qa.QA_DIR / topic, ignore_errors=True) # QA reports belong to the topic
await asyncio.to_thread(_wipe)
return {"ok": True}
@@ -134,17 +164,13 @@ async def create_blocks(req: BlocksCreateRequest):
# Persist the source only the FIRST time; ▶/Resume keeps the existing choice.
if not qp.exists():
type, location = req.source_type, req.source_location.strip()
if type in ("projekt", "uni"):
folder = safe_folder(location)
if folder is None or not folder.is_dir():
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
elif type == "link":
if not location.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link must start with http:// or https://.")
_validate_source(type, location)
qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider,
research=req.research, qa_force=req.qa_force))
research=req.research, qa_force=req.qa_force,
auto_inventory=req.auto_inventory, auto_artefacts=req.auto_artefacts,
auto_guide=req.auto_guide, only_artefacts=req.only_artefacts))
return {"ok": True}
@@ -156,7 +182,8 @@ async def get_blocks_board(topic: str):
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"]}
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"),
"runtime": a["runtime"], "ebene": agent_ebene(a["key"])}
for a in active_agents(f"blocks-{topic}-")]
return snap
@@ -175,13 +202,15 @@ async def run_qa_route(req: QaRunRequest):
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)
await qa.write_report(report)
note_guide = None
# ebene-scoped: ein Inventar-/Artefakt-QA-Klick misst NICHT den (teuren) Guide mit.
guide_scope = req.ebene in (None, "guide")
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
import guide_qa
from database import list_guide_cards
if any(c["stage"] == "done" and (c.get("md") or "").strip()
for c in await list_guide_cards(req.topic)):
if guide_scope and any(c["stage"] == "done" and (c.get("md") or "").strip()
for c in await list_guide_cards(req.topic)):
grep = await guide_qa.guide_qa_report(req.topic, llm=req.llm)
if grep:
await asyncio.to_thread(guide_qa._write_report, grep)
@@ -208,7 +237,7 @@ async def run_repair_route(req: RepairRequest):
_repair_laeuft.add(topic)
try:
import repair
res = await repair.repair_befunde(topic)
res = await repair.repair_befunde(topic, req.ebene)
if "fehler" in res:
raise HTTPException(status_code=404, detail=res["fehler"])
return res
@@ -217,7 +246,7 @@ async def run_repair_route(req: RepairRequest):
@router.post("/blocks/research")
async def add_blocks_research(topic: str, provider: str = "claude"):
async def add_blocks_research(topic: str, provider: str = DEFAULT_PROVIDER):
"""Attach one more research agent — to the live flow, or attach-or-start."""
if add_research_agent(topic):
return {"ok": True, "attached": True}
@@ -227,43 +256,11 @@ async def add_blocks_research(topic: str, provider: str = "claude"):
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}
@router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str):
if not cancel_blocks(topic):
@@ -279,6 +276,18 @@ async def remove_blocks(topic: str):
return {"ok": True}
@router.delete("/blocks/artefacts")
async def remove_artefacts(topic: str):
"""Artefakte-Ebene leeren: Board-2-Daten (Subs/Facts/Flashcards) weg, Inventar bleibt.
Karten gehen zurück auf `generate` — via „Artefakte generieren" (Fortsetzen) neu baubar."""
topic = topic.strip()
if (await blocks_status(topic))["generating"]:
return {"ok": True, "status": "generating"}
from board_inventory import reset_board_from_stage
moved = await reset_board_from_stage(topic, "artefacts", "generate", _blocks_files(topic))
return {"ok": True, "moved": moved}
@router.delete("/blocks/progress")
async def reset_block_progress(topic: str, block: str):
"""Reset learning progress of ONE block to zero (score/streak/flags/open question)."""
@@ -287,14 +296,17 @@ async def reset_block_progress(topic: str, block: str):
def _validate_source(type: str, location: str) -> None:
"""Check source input (same rules as on creation)."""
"""Check source input (shared by create + edit)."""
if type in ("projekt", "uni"):
folder = safe_folder(location)
if folder is None or not folder.is_dir():
raise HTTPException(400, "Folder invalid or not found (path relative to the project root, no ../).")
elif type == "link":
if not location.lower().startswith(("http://", "https://")):
raise HTTPException(400, "Link must start with http:// or https://.")
urls = [ln.strip() for ln in location.splitlines() if ln.strip()]
if not urls:
raise HTTPException(400, "Enter at least one link.")
if not all(u.lower().startswith(("http://", "https://")) for u in urls):
raise HTTPException(400, "Each link must start with http:// or https:// (one per line).")
@router.get("/blocks/source", response_model=BlocksSourceResponse)
@@ -325,9 +337,10 @@ async def blocks_completeness(topic: str):
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")
# ein Query statt N+1 (pro Block ein list_subblocks) — in Python nach consensus zählen
consensus_blocks = {b["title_norm"] for b in blocks}
subs = sum(1 for s in await list_subblocks(topic)
if s["status"] == "consensus" and s["block_norm"] in consensus_blocks)
ziele = await list_lernziele(topic)
dead = sum(v for board in counts.values() for s, v in board.items() if s == "dead")
degradiert = ueberstimmt = 0
@@ -466,15 +479,26 @@ async def block_chat_route(req: BlockChatRequest):
# Serialize ratings per (topic, block) — otherwise two simultaneous ratings would
# overwrite the absolute score with a stale base (race).
_check_locks: dict[tuple[str, str], asyncio.Lock] = {}
_check_locks: dict[tuple[str, str], tuple[asyncio.Lock, list]] = {}
def _check_lock(topic: str, block: str) -> asyncio.Lock:
@asynccontextmanager
async def _check_lock(topic: str, block: str):
"""Per-(topic,block)-Lock mit Refcount, das den Eintrag nach dem letzten Nutzer
entfernt — die Map wuchs sonst unbegrenzt (ein Lock pro je geprüftem Block)."""
key = (topic, block)
lock = _check_locks.get(key)
if lock is None:
lock = _check_locks[key] = asyncio.Lock()
return lock
entry = _check_locks.get(key)
if entry is None:
entry = _check_locks[key] = (asyncio.Lock(), [0])
lock, ref = entry
ref[0] += 1
try:
async with lock:
yield
finally:
ref[0] -= 1
if ref[0] == 0 and _check_locks.get(key) is entry:
del _check_locks[key]
def _basis(state: dict, question: str) -> tuple[int, bool]:
@@ -639,7 +663,13 @@ async def block_exam_route(req: BlockExamRequest):
@router.post("/guides", response_model=GuideResponse)
async def create(req: GuideCreateRequest):
guides, levels = await load_learnstate()
reason = guide_lock(req.topic.strip(), req.format, guides, levels)
from database import list_blocks
from paths import blocks_path
topic = req.topic.strip()
# DB is authoritative (generate_guide builds from it); blocks.md is only a legacy fallback
# and is absent when the inventory paused at the QA gate — don't lock the guide on the file.
has_blocks = bool(await list_blocks(topic, status="consensus")) or blocks_path(topic).exists()
reason = guide_lock(topic, req.format, guides, levels, has_blocks)
if reason:
raise HTTPException(400 if reason == "Erst Blocks erstellen" else 409, reason) # string matches rules.py contract
await create_topic(req.topic.strip())
@@ -655,7 +685,7 @@ async def create(req: GuideCreateRequest):
"updated_at": now,
}
await create_guide(guide)
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider, ab_step=req.ab_step))
asyncio.create_task(generate_guide(guide["id"], guide["topic"], guide["format"], guide["instructions"], req.provider))
return guide
@@ -681,17 +711,25 @@ async def get_guide_board(topic: str, format: str = "Guide"):
return snap
@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)."""
@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideFormatRequest):
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide 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}
if guide is None:
raise HTTPException(404, "Kein Guide für dieses Topic/Format")
if guide["status"] in ("queued", "generating"):
return {"ok": True, "status": "generating", "betroffen": []}
betroffen = await guide_board.repair_karten(topic, req.format)
if betroffen:
now = datetime.now(timezone.utc).isoformat()
await update_guide(guide["id"], status="queued", progress="Repair…", updated_at=now)
asyncio.create_task(generate_guide(guide["id"], topic, req.format,
guide.get("instructions") or "", DEFAULT_PROVIDER))
return {"ok": True, "betroffen": betroffen}
@router.get("/guides/{guide_id}/content")
@@ -778,6 +816,9 @@ async def remove_guide_format(req: GuideFormatRequest):
for p in guide_slot_files(content):
p.unlink(missing_ok=True)
content.unlink(missing_ok=True)
import qa # Guide-QA-Report mitlöschen — sonst zeigt board_snapshot die alte Note weiter
for p in qa.report_paths(req.topic, guide=True):
p.unlink(missing_ok=True)
return {"ok": True, "removed": len(doomed)}
+22 -7
View File
@@ -14,7 +14,7 @@ import json
from database import list_block_scores_all, subs_per_level_all, list_guides
from guide import guide_slot_files
from learning import cap_final, LEVELS, _threshold
from paths import blocks_path, guide_content_path
from paths import guide_content_path
from textkit import _norm_title
MAX_OFFENE_GUIDES = 3
@@ -50,14 +50,26 @@ async def load_learnstate() -> tuple[list[dict], dict[str, dict[str, set[str]]]]
return await list_guides(), levels
_content_cache: dict[str, tuple[float, dict | None]] = {}
def _content_json(topic: str, fmt: str) -> dict | None:
"""Guide-Content-JSON (kann MB groß sein), mtime-gecacht — /stats und /topics/progress
lasen die Datei bei JEDEM Frontend-Poll neu und synchron im Event-Loop."""
path = guide_content_path(topic, fmt)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
mtime = path.stat().st_mtime
except OSError:
_content_cache.pop(str(path), None)
return None
cached = _content_cache.get(str(path))
if cached is None or cached[0] != mtime:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except ValueError:
data = None
_content_cache[str(path)] = (mtime, data)
return _content_cache[str(path)][1]
@@ -116,13 +128,16 @@ def formats_stats(guides: list[dict], levels: dict[str, dict[str, set[str]]]) ->
return formats
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]]) -> str | None:
def guide_lock(topic: str, fmt: str, guides: list[dict], levels: dict[str, dict[str, set[str]]],
has_blocks: bool) -> str | None:
"""Reason why a fresh start for topic+format is locked — None = allowed.
Exactly the rules from POST /guides: blocks required, no duplicate start,
learning debt only for genuine new creations (resume/regenerate are free).
`has_blocks` = DB consensus blocks OR the legacy blocks.md exist — checked by the
caller, because generate_guide builds from the DB (a paused inventory writes no file).
"""
if not blocks_path(topic).exists():
if not has_blocks:
return "Create blocks first"
for g in guides:
if g["topic"] == topic and g["format"] == fmt and g["status"] in ("queued", "generating"):
+284
View File
@@ -0,0 +1,284 @@
"""Direkter Text-API-Pfad (MiniMax) + RAM-Gate für opencode-Spawns."""
import asyncio
import httpx
import pytest
import agents
TOPIC = "t"
# ── Routing: wann läuft ein Call über die API statt über opencode? ───────────────────
def test_use_text_api_routing(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.delenv("CREATOR_TEXT_API", raising=False)
api = agents._use_text_api
assert api("minimax", "minimax/MiniMax-M3", "none", None) is True
assert api("minimax", "minimax-kalt/MiniMax-M2.7-highspeed", "none", None) is True
# Tools, Streaming, andere Provider/Modelle → Prozess-Pfad
assert api("minimax", "minimax/MiniMax-M3", "files", None) is False
assert api("minimax", "minimax/MiniMax-M3", "none", lambda s: None) is False
assert api("claude", "claude-sonnet-4-6", "none", None) is False
assert api("lokal", "ollama/qwen3.5:9b", "none", None) is False
# Kill-Switch und fehlender Key → Fallback
monkeypatch.setenv("CREATOR_TEXT_API", "0")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
monkeypatch.delenv("CREATOR_TEXT_API")
monkeypatch.delenv("MINIMAX_API_KEY")
assert api("minimax", "minimax/MiniMax-M3", "none", None) is False
async def test_run_agent_dispatches_to_api(monkeypatch):
"""none+minimax → API-Runner; opencode wird nicht angefasst (auch kein which-Check)."""
called = {}
async def fake_api(agent_key, prompt, timeout, model, label=""):
called["api"] = (agent_key, model)
return 0, "out", "", {"input": 1, "output": 1, "reasoning": 0, "cache_read": 0, "cache_write": 0}
async def fail_oc(*a, **kw):
raise AssertionError("opencode-Pfad darf nicht laufen")
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_run_opencode", fail_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: None) # API-Pfad braucht kein Binary
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, out, err = await agents.run_agent("blocks-t-a", "p", 5, provider="minimax", role="judge")
assert (rc, out) == (0, "out") and called["api"][1] == "minimax/MiniMax-M3"
async def test_run_agent_kill_switch_uses_opencode(monkeypatch):
called = {}
async def fake_oc(agent_key, prompt, timeout, provider, model, capabilities, on_line=None, label=""):
called["oc"] = agent_key
return 0, "out", ""
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setenv("CREATOR_TEXT_API", "0")
monkeypatch.setattr(agents, "_run_opencode", fake_oc)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-b", "p", 5, provider="minimax")
assert rc == 0 and called["oc"] == "blocks-t-b"
async def test_run_agent_api_tokens_in_event_meta(monkeypatch):
"""API-Tokens landen im Event-Meta; die OpenCode-Session-DB wird NICHT konsultiert."""
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_api(agent_key, prompt, timeout, model, label=""):
return 0, "out", "", {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
monkeypatch.delenv("CREATOR_FAKE_AGENTS", raising=False)
monkeypatch.setenv("MINIMAX_API_KEY", "k")
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_text_api", fake_api)
monkeypatch.setattr(agents, "_session_tokens", lambda k: pytest.fail("Session-DB-Lookup im API-Pfad"))
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("minimax", "minimax/MiniMax-M3"))
rc, *_ = await agents.run_agent("blocks-t-tok", "p", 5, provider="minimax", scope=TOPIC)
assert rc == 0
assert recorded and recorded[0]["meta"]["tokens"] == {
"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 0}
# ── API-Runner: Request-Bau, Antwort-Extraktion, Fehlerfälle ─────────────────────────
class _FakeResp:
def __init__(self, status_code=200, data=None, text=""):
self.status_code = status_code
self._data = data or {}
self.text = text
def json(self):
return self._data
def _fake_client(monkeypatch, seen, resp=None, exc=None):
class _Client:
def __init__(self, **kw):
seen["client_kw"] = kw
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None, headers=None):
seen.update(url=url, body=json, headers=headers)
if exc is not None:
raise exc
return resp
monkeypatch.setattr(agents.httpx, "AsyncClient", _Client)
async def test_api_request_and_response(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "geheim")
seen = {}
resp = _FakeResp(data={
"content": [{"type": "thinking", "thinking": "hm"},
{"type": "text", "text": "A"}, {"type": "text", "text": "B"}],
"usage": {"input_tokens": 5, "output_tokens": 2,
"cache_read_input_tokens": 100, "cache_creation_input_tokens": 1},
"stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, tokens = await agents._run_text_api("k", "PROMPT", 5, "minimax-kalt/MiniMax-M3")
assert (rc, out, err) == (0, "AB", "") # Thinking-Block übersprungen
assert tokens == {"input": 5, "output": 2, "reasoning": 0, "cache_read": 100, "cache_write": 1}
assert seen["url"] == agents._API_URL
assert seen["headers"]["x-api-key"] == "geheim"
assert seen["headers"]["anthropic-version"] == agents._API_VERSION
b = seen["body"]
assert b["model"] == "MiniMax-M3" and b["max_tokens"] == agents._API_MAX_TOKENS
assert b["messages"] == [{"role": "user", "content": "PROMPT"}]
assert b["temperature"] == 0.2 and b["thinking"] == {"type": "disabled"} # kalt-Route
async def test_api_model_options_per_route(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "x"}], "usage": {}})
_fake_client(monkeypatch, seen, resp=resp)
await agents._run_text_api("k", "p", 5, "minimax-kalt/MiniMax-M2.7-highspeed")
assert seen["body"]["temperature"] == 0.3 and "thinking" not in seen["body"]
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3") # nativ: Endpunkt-Defaults
assert "temperature" not in seen["body"] and "thinking" not in seen["body"]
async def test_api_errors(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
_fake_client(monkeypatch, seen, resp=_FakeResp(status_code=500, text="kaputt"))
rc, out, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and out == "" and "HTTP 500" in err and tokens is None
_fake_client(monkeypatch, seen, exc=httpx.ConnectError("down"))
rc, _, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "ConnectError" in err
_fake_client(monkeypatch, seen, exc=httpx.ReadTimeout("langsam"))
with pytest.raises(asyncio.TimeoutError):
await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
# leere Antwort (nur Thinking) → rc 1, Tokens bleiben sichtbar
resp = _FakeResp(data={"content": [{"type": "thinking", "thinking": "…"}],
"usage": {"input_tokens": 3}, "stop_reason": "end_turn"})
_fake_client(monkeypatch, seen, resp=resp)
rc, _, err, tokens = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 1 and "empty response" in err and tokens["input"] == 3
async def test_api_truncation_flagged(monkeypatch):
monkeypatch.setenv("MINIMAX_API_KEY", "k")
seen = {}
resp = _FakeResp(data={"content": [{"type": "text", "text": "halb"}],
"usage": {}, "stop_reason": "max_tokens"})
_fake_client(monkeypatch, seen, resp=resp)
rc, out, err, _ = await agents._run_text_api("k", "p", 5, "minimax/MiniMax-M3")
assert rc == 0 and out == "halb" and "max_tokens" in err
# ── RAM-Gate ─────────────────────────────────────────────────────────────────────────
@pytest.fixture
def ram_gate(monkeypatch):
monkeypatch.setattr(agents, "RAM_MIN_FREE_PCT", 20)
monkeypatch.setattr(agents, "_RAM_POLL_S", 0.01)
monkeypatch.setattr(agents, "_cli_recent_starts", [])
return monkeypatch
_EST = agents._RAM_PER_PROC_KB # default batch-agent estimate
async def test_ram_gate_admits_with_free_ram(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
assert await agents._ram_gate("k", _EST) is True
assert len(agents._cli_recent_starts) == 1 # Commit registriert
async def test_ram_gate_waits_when_low(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000)) # 10 % frei
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
# RAM wird frei → Gate lässt nach ≥1 Poll durch
vals = iter([(800_000, 8_000_000)])
ram_gate.setattr(agents, "_meminfo", lambda: next(vals, (4_000_000, 8_000_000)))
assert await agents._ram_gate("k", _EST) is True
async def test_ram_gate_floor_and_fail_open(ram_gate):
ram_gate.setattr(agents, "_meminfo", lambda: (100_000, 8_000_000)) # fast nichts frei
ram_gate.setattr(agents, "_cli_running", 1) # unter Floor
assert await agents._ram_gate("k", _EST) is True
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
assert await agents._ram_gate("k", _EST) is True
ram_gate.setattr(agents, "RAM_MIN_FREE_PCT", 0) # Gate aus
ram_gate.setattr(agents, "_meminfo", lambda: pytest.fail("Gate aus liest kein meminfo"))
assert await agents._ram_gate("k", _EST) is True
async def test_ram_gate_commit_accounting(ram_gate):
"""Knapp über der Schwelle, aber 2 frische Zulassungen → deren geschätzter RSS zählt.
Ein `full`-Spawn wiegt mehr (MCP-Server) und drückt die verfügbare RAM stärker."""
import time as _time
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
agents._cli_recent_starts.extend([(_time.monotonic(), _EST), (_time.monotonic(), _EST)])
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k", _EST), 0.1)
async def test_ram_gate_full_weighs_more(ram_gate):
"""Ein frischer Vorgänger belegt Commit für das Fenster. Ein leichter (350 MB) lässt den
nächsten noch durch; ein `full` (1250 MB, MCP-Server) reißt die Schwelle → nächster wartet."""
import time as _time
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (2_000_000, 8_000_000)) # 25 % frei
agents._cli_recent_starts[:] = [(_time.monotonic(), _EST)] # leichter Vorgänger
assert await agents._ram_gate("nach-leicht", _EST) is True
agents._cli_recent_starts[:] = [(_time.monotonic(), agents._RAM_PER_PROC_FULL_KB)] # full-Vorgänger
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("nach-full", _EST), 0.1)
async def test_ram_gate_cancelled_scope(ram_gate):
ram_gate.setattr(agents, "_cli_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (800_000, 8_000_000))
agents.cancel_scope("blocks-cxl-")
try:
assert await agents._ram_gate("blocks-cxl-x", _EST) is False
finally:
agents.clear_scope("blocks-cxl-")
def test_meminfo_reads_proc():
mem = agents._meminfo()
assert mem is not None and 0 < mem[0] <= mem[1] # Linux-Testumgebung
def test_agent_ebene_klassifiziert_board():
"""Board-2-Marker (art-/sb-/outline) → artefacts; alles andere → inventory."""
T = "shopware"
for key in (f"blocks-{T}-research-a1", f"blocks-{T}-pair-x", f"blocks-{T}-naming-y",
f"blocks-{T}-gruppierung-h-cTOP", f"blocks-{T}-dedup-z"):
assert agents.agent_ebene(key) == "inventory", key
for key in (f"blocks-{T}-alpha-sb-enrich-h", f"blocks-{T}-alpha-sb-verify-h-j1",
f"blocks-{T}-alpha-art-gen-h-t1", f"blocks-{T}-alpha-art-check-h",
f"blocks-{T}-outline-judge"):
assert agents.agent_ebene(key) == "artefacts", key
+52
View File
@@ -0,0 +1,52 @@
"""Auto-Repair-Loop-Primitive: Stopp bei 100 %, Stillstand (unbewegt), Limit."""
import auto_loop
async def test_fertig_ohne_repair():
async def rep():
raise AssertionError("darf bei 100 % nie reparieren")
r = await auto_loop.auto_repair_loop("x", 10.0, rep)
assert r == {"ebene": "x", "note": 10.0, "runden": 0, "grund": "fertig"}
async def test_verbessert_bis_voll():
noten = iter([9.0, 10.0])
async def rep():
return next(noten), True
r = await auto_loop.auto_repair_loop("inv", 8.0, rep) # 8.0 → 9.0 → 10.0
assert r["grund"] == "fertig" and r["runden"] == 2
async def test_stillstand():
async def rep():
return 8.0, False # nichts getan, Note steht
r = await auto_loop.auto_repair_loop("art", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 1
assert r["note"] == 8.0
async def test_bewegt_ohne_notengewinn_laeuft_weiter():
"""Eine bewegte Runde (z. B. Lücken-Research senkt die Note transient) ist KEIN
Stillstand — erst unbewegt + keine Verbesserung stoppt; max_iter begrenzt Livelocks."""
laeufe = iter([(7.5, True), (8.0, True), (8.0, False)])
async def rep():
return next(laeufe)
r = await auto_loop.auto_repair_loop("inv", 8.0, rep)
assert r["grund"] == "stillstand" and r["runden"] == 3
async def test_limit():
stand = {"n": 1.0}
async def rep():
stand["n"] += 0.1 # verbessert stetig, erreicht aber nie 10.0
return stand["n"], True
r = await auto_loop.auto_repair_loop("guide", 1.0, rep, max_iter=10)
assert r["grund"] == "limit" and r["runden"] == 10
+533
View File
@@ -0,0 +1,533 @@
"""Verschmolzene Board-2-Calls (block_calls.py): Generate-Konsens, Verify-Faltung mit
Fix-Tail, Artefakte in einem Durchgang — Agenten gefaked, gegen Test-DB."""
import json
import pytest
import block_calls as bc
import blocks as blx
import board_artefacts as ba
from pipeline import FAILED, OK, GenContext
from textkit import _norm_title
TOPIC = "t"
def _ctx():
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
def _sub(title, level="beginner", relevance="relevant", kp=None, cf=None):
return {"title": title, "level": level, "relevance": relevance,
"key_points": [f"kp {title}"] if kp is None else kp,
"prerequisites": "", "hurdles": "", "cited_facts": cf or [], "example_idea": ""}
# ── Schemas ─────────────────────────────────────────────────────────────────────────
def test_gen_schema_normalisiert():
"""Gültige Einträge werden normalisiert; ungültiges level/relevance fällt auf ""
(Stimme entfällt, der Sub bleibt); Einträge ohne Titel fliegen."""
out = bc._gen_schema({"subs": [
{"title": " **A** ", "level": "Beginner", "relevance": "RELEVANT",
"key_points": ["k", ""], "cited_facts": [{"text": "t", "source": " s "},
{"text": ""}, "quatsch"]},
{"title": "B", "level": "profi", "relevance": "mittel"},
{"title": " "},
]})
assert [e["title"] for e in out] == ["A", "B"]
assert out[0]["level"] == "beginner" and out[0]["relevance"] == "relevant"
assert out[0]["key_points"] == ["k"]
assert out[0]["cited_facts"] == [{"text": "t", "source": "s"}]
assert out[1]["level"] == "" and out[1]["relevance"] == ""
def test_gen_schema_kaputt_ist_none():
assert bc._gen_schema(None) is None
assert bc._gen_schema({"subs": "x"}) is None
assert bc._gen_schema({"subs": []}) is None
assert bc._gen_schema({"subs": [{"level": "beginner"}]}) is None # nur titellose Einträge
def test_verify_schema_pflichtkeys_und_leeres_verdikt():
"""Mindestens EIN Pflicht-Key muss da sein; leere Listen heißen „alles ok"."""
assert bc._verify_schema({}, 3) is None
assert bc._verify_schema({"irgendwas": 1}, 3) is None
v = bc._verify_schema({"gruppen": []}, 3)
assert v["gruppen"] == [] and v["fremd"] == set() and v["luecken"] == []
assert v["uebernehmen"] == {} and v["facts_probleme"] == [] and v["levels"] == {}
def test_verify_schema_grenzen_und_normalisierung():
"""ids außerhalb 1..n und bools fallen raus; Ein-Element-Gruppen zählen nicht;
uebernehmen/levels werden casefolded bzw. enum-geprüft."""
v = bc._verify_schema({
"gruppen": [{"haupt": 2, "weitere": [1, 9, True]}, {"haupt": 3, "weitere": []}],
"kataloge": [{"titel": " K ", "mitglieder": [1, 2]}, {"titel": "", "mitglieder": [1, 2]}],
"fremd": [True, 1, "2", 9],
"luecken": [" x ", "", 7],
"uebernehmen": {"3": " JA ", "9": "ja"},
"facts_probleme": [{"nr": 2, "discard": 1, "hinweis": " h "}, {"nr": 9}, "quatsch"],
"levels": {"1": "expert", "2": "quatsch"},
"relevanz": {"1": "peripheral"},
}, 3)
assert v["gruppen"] == [{"haupt": 2, "ids": [1, 2]}]
assert v["kataloge"] == [{"titel": "K", "ids": [1, 2]}]
assert v["fremd"] == {1, 2}
assert v["luecken"] == ["x"]
assert v["uebernehmen"] == {3: "ja"}
assert v["facts_probleme"] == [{"nr": 2, "discard": True, "hinweis": "h"}]
assert v["levels"] == {1: "expert"} and v["relevanz"] == {1: "peripheral"}
def test_art_gen_schema_pattern_ist_pflicht():
"""Ohne verwertbares pattern kein Verdikt (Leitner hängt an den Fragen);
cards/examples sind best-effort und werden einzeln validiert."""
assert bc._art_gen_schema({"cards": [], "examples": []}) is None
assert bc._art_gen_schema("x") is None
out = bc._art_gen_schema({
"pattern": [{"block": "B", "subblock": "S", "question": "F?"},
{"block": "B", "subblock": "", "question": "F?"}],
"cards": [{"block": "B", "subblock": "S", "question": "F?", "answer": "A"},
{"block": "B", "subblock": "S", "question": "F?"}],
"examples": [{"block": "B", "subblock": "S", "problem": "P", "steps": ["s1", ""], "result": ""},
{"block": "B", "subblock": "S", "problem": "P", "steps": []}],
})
assert len(out["pattern"]) == 1 and len(out["cards"]) == 1
assert out["examples"] == [{"block": "B", "subblock": "S", "problem": "P",
"steps": ["s1"], "result": ""}]
def test_art_check_schema_varianten():
"""{"ok": true} → leeres Verdikt; ohne bekannten Key None; Beispiel-Indizes sind
1-basiert, bools/0 zählen nicht."""
ok = bc._art_check_schema({"ok": True})
assert ok == {"pattern": [], "pattern_ergaenzt": [], "examples_probleme": set()}
assert bc._art_check_schema({"foo": 1}) is None
v = bc._art_check_schema({"examples_probleme": [1, "2", {"index": 3}, True, 0, -1],
"pattern_ergaenzt": [{"block": "B", "subblock": "S", "question": "F?"}]})
assert v["examples_probleme"] == {1, 2, 3}
assert len(v["pattern_ergaenzt"]) == 1 and v["pattern"] == []
# ── Generate ────────────────────────────────────────────────────────────────────────
@pytest.fixture
def env(testdb, tmp_path, monkeypatch):
"""Ohne Modell (exakte Norm-Gleichheit), ohne Korpus (thema-Selbst-Recherche)."""
monkeypatch.setattr(bc, "EMBEDDING_AKTIV", False)
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", False) # _dedup_subblocks aus
monkeypatch.setattr(bc, "material_folder", lambda t: None)
monkeypatch.setattr(bc, "load_source", lambda t: {"type": "thema"})
return testdb, _ctx(), {"arbeit": tmp_path}
def _mk_enrich_slot(output):
"""run_single_slot-Fake für die Anreicherung: schreibt die gescriptete Antwort via
payload (wie der Engine-Sink); output=None → Ausfall."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if output is None:
return FAILED, None
return OK, payload((0, json.dumps(output), ""))
fake.calls = calls
return fake
async def test_enrich_reichert_feste_subs_an(env, monkeypatch):
"""Die Subs stehen fest (Board 1). EIN Call füllt Facts + Level + Relevanz — er
erfindet und entfernt nichts; die Board-1-Subs bleiben in Reihenfolge."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [
_sub("Sub A", kp=["k1"]),
_sub("Sub B", level="expert", relevance="peripheral", kp=["k2"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1"]
assert gen["unsicher"] == []
assert gen["votes"]["sub b"] == {"level": ["expert"], "relevance": ["peripheral"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_enrich_ohne_subs_kein_call(env, monkeypatch):
"""Kein Board-1-Sub → nichts anzureichern, kein Modell-Call."""
db, ctx, files = env
called = []
async def spy(*a, **k):
called.append(1)
return OK, None
monkeypatch.setattr(bc, "run_single_slot", spy)
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen == {"raw": {"Alpha": []}, "facts": {"Alpha": {}}, "unsicher": [], "votes": {}}
assert called == []
async def test_enrich_ausgelassener_sub_bleibt_leer(env, monkeypatch):
"""Lässt der Call einen Sub aus, bleibt er erhalten (Vollständigkeit) — mit leeren
Facts, die der Prüfer/QA als Lücke sieht."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A", "Sub B"])
monkeypatch.setattr(bc, "run_single_slot", _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]}))
gen = await bc._generate_block(ctx, files, "Alpha", "d")
assert gen["raw"] == {"Alpha": ["Sub A", "Sub B"]}
assert gen["facts"]["Alpha"]["sub b"]["key_points"] == []
assert gen["facts"]["Alpha"]["sub b"]["cited_facts"] == []
async def test_enrich_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene enrich-Datei → kein neuer Call, Ergebnis wird übernommen."""
db, ctx, files = env
await _seed_rows(db, "alpha", ["Sub A"])
fake = _mk_enrich_slot({"subs": [_sub("Sub A", kp=["k1"])]})
monkeypatch.setattr(bc, "run_single_slot", fake)
gen1 = await bc._generate_block(ctx, files, "Alpha", "d")
n = len(fake.calls)
gen2 = await bc._generate_block(ctx, files, "Alpha", "d")
assert len(fake.calls) == n # aus Datei resumed
assert gen2["raw"] == gen1["raw"]
# ── Verify (+ Fix-Tail) ─────────────────────────────────────────────────────────────
def _gen_von(title, subs, unsicher=None, votes=None):
"""Karten-Payload wie aus _generate_block: raw/facts/unsicher/votes."""
return {"raw": {title: list(subs)},
"facts": {title: {_norm_title(s): {"key_points": [f"kp {s}"], "prerequisites": "",
"hurdles": "", "cited_facts": [], "example_idea": ""}
for s in subs}},
"unsicher": unsicher or [], "votes": votes or {}}
def _judge_slot(antworten, fix=None):
"""run_single_slot-Fake: Prüfer-Antwort je j-Suffix, Fix-Antwort für -sb-fix-;
fehlender Eintrag = FAILED."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-sb-fix-" in key:
if fix is None:
return FAILED, None
return OK, payload((0, json.dumps(fix), ""))
j = key.rsplit("-j", 1)[-1]
antwort = antworten.get(j)
if antwort is None:
return FAILED, None
return OK, payload((0, json.dumps(antwort), ""))
fake.calls = calls
return fake
async def _seed_rows(db, bnorm, titles, status="consensus"):
for t in titles:
await db.put_subblock(TOPIC, bnorm, _norm_title(t), bnorm.title(), t, status=status)
async def test_verify_gruppe_faltet_einstimmig(env, monkeypatch):
"""Beide Prüfer gruppieren 1+2 → haupt gewinnt, Verlierer wird variant und seine
Facts wandern per Union zum Gewinner; Dissens-Gruppen falten nicht."""
db, ctx, files = env
subs = ["Marker Regel", "Marker Regel im Detail erklärt", "Eigenes Thema"]
await _seed_rows(db, "alpha", subs)
verdikt = {"gruppen": [{"haupt": 2, "weitere": [1]}]}
fake = _judge_slot({"1": verdikt, "2": {"gruppen": [{"haupt": 2, "weitere": [1]},
{"haupt": 3, "weitere": [2]}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": [subs[1], subs[2]]} # Gruppe 2+3 war einseitig → kein Fold
wf = res["facts"]["Alpha"][_norm_title(subs[1])]
assert wf["key_points"] == [f"kp {subs[1]}", f"kp {subs[0]}"] # Union geerbt
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title(subs[0])] == "variant"
assert rows[_norm_title(subs[1])] == "consensus"
async def test_verify_fremd_nur_einstimmig(env, monkeypatch):
"""Fremd 2/2 → discarded + raus; einseitig fremd → bleibt."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1, 2]}, "2": {"fremd": [1]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["css regel"] == "discarded" and rows["echte regel"] == "consensus"
async def test_verify_keep_all_behaelt_alle_atome(env, monkeypatch):
"""Bottom-up (keep_all=True): kein Board-1-Atom wird entfernt oder gefaltet, selbst
wenn beide Prüfer fremd/gruppen/discard melden. Nur Facts-Korrektur (Hinweis) und
Level/Relevanz greifen weiter."""
db, ctx, files = env
subs = ["Sub A", "Sub B", "Sub C"]
await _seed_rows(db, "alpha", subs)
# Fix-Call liefert die Korrektur für Sub A UND einen belegten NEUEN Fund — der darf
# bei keep_all NICHT als Sub eingeschleust werden (Sub-Zahl bleibt bei den Board-1-Atomen).
fix = {"subs": [_sub("Sub A", kp=["korrigiert"]),
_sub("Extra Fund", kp=["belegt"], cf=[{"text": "x", "source": "s"}])]}
verdikt = {"fremd": [3], "gruppen": [{"haupt": 1, "weitere": [2]}],
"facts_probleme": [{"nr": 1, "discard": True, "hinweis": "Zahl falsch"},
{"nr": 2, "discard": True}],
"levels": {"2": "expert"}}
fake = _judge_slot({"1": verdikt, "2": verdikt}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {}, keep_all=True)
assert res["raw"] == {"Alpha": subs} # exakt die drei Atome — nichts entfernt, nichts neu
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert all(rows[_norm_title(s)] == "consensus" for s in subs)
assert "extra fund" not in rows # der Fix-Neuzugang wurde unterdrückt
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigiert"] # Hinweis trotz discard=True
assert side["Sub B"]["level"] == "expert" # Level-Korrektur greift
async def test_verify_facts_discard_nur_2von2(env, monkeypatch):
"""Facts-Discard ist irreversibel → nur 2/2; die einseitige Stimme ohne Hinweis
löst auch keinen Fix aus."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "discard": True},
{"nr": 2, "discard": True}]},
"2": {"facts_probleme": [{"nr": 1, "discard": True}]}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub B"]}
assert not any("-sb-fix-" in c["key"] for c in fake.calls)
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "discarded" and rows["sub b"] == "consensus"
async def test_verify_korrektur_ab_einer_stimme(env, monkeypatch):
"""Ein Hinweis EINES Prüfers reicht: der Fix-Call läuft und ersetzt die Facts des
beanstandeten Subs; die Einstufung bleibt."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Sub A", kp=["korrigierte Aussage"])]}
fake = _judge_slot({"1": {"facts_probleme": [{"nr": 1, "hinweis": "Zahl falsch"}]},
"2": {"gruppen": []}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert any("-sb-fix-" in c["key"] for c in fake.calls)
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["facts"]["key_points"] == ["korrigierte Aussage"]
assert res["facts"]["Alpha"]["sub a"]["key_points"] == ["korrigierte Aussage"]
assert side["Sub B"]["facts"]["key_points"] == ["kp Sub B"] # unbeanstandet
async def test_verify_luecke_belegt_wird_neuer_sub(env, monkeypatch):
"""Lücken-Schnitt beider Prüfer → Fix legt den belegten Fund als neuen consensus-Sub
an; ein unbelegter „Fund" verfällt am Beleg-Gate."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fix = {"subs": [_sub("Escaping von Sonderzeichen", level="expert", kp=["belegt"]),
_sub("Unbelegte Behauptung", kp=[])]}
fake = _judge_slot({"1": {"luecken": ["Escaping fehlt"]},
"2": {"luecken": ["Escaping unbehandelt"]}}, fix=fix)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Sub A", "Escaping von Sonderzeichen"]}
neu = next(s for s in res["sidecar"]["Alpha"] if s["title"] == "Escaping von Sonderzeichen")
assert neu["level"] == "expert" and neu["facts"]["key_points"] == ["belegt"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows[_norm_title("Escaping von Sonderzeichen")] == "consensus"
assert _norm_title("Unbelegte Behauptung") not in rows
async def test_verify_ersatzrichter_bei_ausfall(env, monkeypatch):
"""Fällt EIN Prüfer aus, springt der Ersatz jE ein — Einstimmigkeit mit ihm faltet."""
db, ctx, files = env
subs = ["CSS Regel", "Echte Regel"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"fremd": [1]}, "E": {"fremd": [1]}}) # j2 → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert res["raw"] == {"Alpha": ["Echte Regel"]}
assert [c["key"].rsplit("-j", 1)[-1] for c in fake.calls] == ["1", "2", "E"]
async def test_verify_failopen_verwirft_nur_unsicher(env, monkeypatch):
"""Nur 1 Prüfer (auch der Ersatz fällt aus) → fail-open: consensus bleibt unangetastet,
unsicher wird verworfen (ohne Panel keine Übernahme-Entscheidung)."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B")]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B"], status="candidate")
fake = _judge_slot({"1": {"fremd": [1], "uebernehmen": {"2": "ja"}}}) # j2+jE → FAILED
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, unsicher=unsicher), {})
assert res["raw"] == {"Alpha": ["Sub A"]} # fremd-Einzelstimme wirkt NICHT
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus" and rows["unsicher b"] == "discarded"
async def test_verify_level_korrektur_wiegt_doppelt(env, monkeypatch):
"""Explizite Prüfer-Korrektur (×2) schlägt die Generator-Stimme; Patt fällt auf
advanced/relevant (heutige Defaults)."""
db, ctx, files = env
subs = ["Sub A", "Sub B"]
await _seed_rows(db, "alpha", subs)
votes = {"sub a": {"level": ["beginner"], "relevance": []},
"sub b": {"level": ["beginner", "expert"], "relevance": []}}
fake = _judge_slot({"1": {"levels": {"1": "expert"}}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs, votes=votes), {})
side = {s["title"]: s for s in res["sidecar"]["Alpha"]}
assert side["Sub A"]["level"] == "expert" # 2× Korrektur > 1× Generator
assert side["Sub B"]["level"] == "advanced" # 1:1-Patt → Default
assert side["Sub A"]["relevance"] == "relevant" # keine Stimme → Default
async def test_verify_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene verify-j-Dateien → kein neuer Prüfer-Call."""
db, ctx, files = env
subs = ["Sub A"]
await _seed_rows(db, "alpha", subs)
fake = _judge_slot({"1": {"gruppen": []}, "2": {"gruppen": []}})
monkeypatch.setattr(bc, "run_single_slot", fake)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
n = len(fake.calls)
await bc._verify_block(ctx, files, "Alpha", _gen_von("Alpha", subs), {})
assert len(fake.calls) == n
# ── Artefakte ───────────────────────────────────────────────────────────────────────
def _sidecar(titles):
return [{"title": t, "level": "beginner", "relevance": "relevant",
"facts": {"key_points": [f"kp {t}"]}} for t in titles]
def _art_slot(gen_out, check_out):
"""run_single_slot-Fake für Artefakte: gen_out je Teil (dict oder callable(prompt)),
check_out fürs Prüfer-Verdikt."""
calls = []
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append({"key": key, "prompt": prompt})
if "-art-gen-" in key:
out = gen_out(prompt) if callable(gen_out) else gen_out
return OK, payload((0, json.dumps(out), ""))
if "-art-check-" in key:
if check_out is None:
return FAILED, None
return OK, payload((0, json.dumps(check_out), ""))
raise AssertionError(f"unerwarteter Call {key}")
fake.calls = calls
return fake
async def test_artefakte_ein_call_liefert_alles(env, monkeypatch):
"""EIN Generator-Call liefert pattern+cards+examples, der Prüfer sagt ok →
Rohfassung wird übernommen, block-Feld auf den Karten-Block normiert."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Echo", "subblock": "Sub A", "question": "F?"}],
"cards": [{"block": "Echo", "subblock": "Sub A", "question": "F?", "answer": "A"}],
"examples": [{"block": "Echo", "subblock": "Sub A", "problem": "P",
"steps": ["s1"], "result": "R"}]}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert [c["key"] for c in fake.calls if "-art-gen-" in c["key"]].__len__() == 1
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
assert res["artefacts"]["flashcard"] == [{"block": "Alpha", "subblock": "Sub A",
"question": "F?", "answer": "A"}]
assert res["artefacts"]["example"][0]["block"] == "Alpha" # Agent-Echo „Echo" normiert
async def test_artefakte_check_entfernt_beispiel_und_ergaenzt_frage(env, monkeypatch):
"""examples_probleme wirft das beanstandete Beispiel; pattern_ergaenzt füllt die
fehlende Frage nach — der Prüfer-Prompt listet den fraglosen Sub."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [],
"examples": [{"block": "Alpha", "subblock": "Sub A", "problem": "P1", "steps": ["x"], "result": ""},
{"block": "Alpha", "subblock": "Sub A", "problem": "P2", "steps": ["y"], "result": ""}]}
check = {"examples_probleme": [{"index": 1}],
"pattern_ergaenzt": [{"block": "Alpha", "subblock": "Sub B", "question": "F B?"}]}
fake = _art_slot(gen_out, check)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B"]))
assert [e["problem"] for e in res["artefacts"]["example"]] == ["P2"]
assert res["pattern"]["Alpha"] == [{"subblock": "Sub A", "question": "F?"},
{"subblock": "Sub B", "question": "F B?"}]
check_prompt = next(c["prompt"] for c in fake.calls if "-art-check-" in c["key"])
assert "STILL MISSING A QUESTION" in check_prompt and "Sub B" in check_prompt
async def test_artefakte_split_ab_schwelle(env, monkeypatch):
"""> ART_SPLIT_SUBS Subs → ZWEI parallele Generator-Calls, jeder sieht nur seine
Hälfte; die Ergebnisse werden zusammengeführt."""
db, ctx, files = env
monkeypatch.setattr(bc, "ART_SPLIT_SUBS", 2)
def gen_out(prompt):
subs = [t for t in ("Sub A", "Sub B", "Sub C") if f"- {t}" in prompt]
return {"pattern": [{"block": "Alpha", "subblock": s, "question": f"F {s}?"} for s in subs],
"cards": [], "examples": []}
fake = _art_slot(gen_out, {"ok": True})
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A", "Sub B", "Sub C"]))
gen_keys = [c["key"] for c in fake.calls if "-art-gen-" in c["key"]]
assert len(gen_keys) == 2 and gen_keys[0].endswith("-t1") and gen_keys[1].endswith("-t2")
assert [p["subblock"] for p in res["pattern"]["Alpha"]] == ["Sub A", "Sub B", "Sub C"]
async def test_artefakte_check_ausfall_uebernimmt_rohfassung(env, monkeypatch):
"""Prüfer ohne Ergebnis → fail-open, die Generator-Rohfassung zählt."""
db, ctx, files = env
gen_out = {"pattern": [{"block": "Alpha", "subblock": "Sub A", "question": "F?"}],
"cards": [], "examples": []}
fake = _art_slot(gen_out, None)
monkeypatch.setattr(bc, "run_single_slot", fake)
res = await bc._artefakte_block(ctx, files, "Alpha", _sidecar(["Sub A"]))
assert res["pattern"] == {"Alpha": [{"subblock": "Sub A", "question": "F?"}]}
async def test_artefakte_leerer_block(env):
db, ctx, files = env
res = await bc._artefakte_block(ctx, files, "Alpha", [])
assert res == {"pattern": {"Alpha": []}, "artefacts": {"flashcard": [], "example": []}}
# ── Migration der alten Stage-Treppe ────────────────────────────────────────────────
async def test_migriere_alt_karten(testdb):
"""Karten in alten Stages gehen mit reduziertem Payload zurück nach generate
(alte Zwischenstände sind für die verschmolzenen Calls wertlos); Terminal- und
Neu-Struktur-Karten bleiben unangetastet."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "facts",
{"title": "Alpha", "description": "d", "n_size": 3,
"sources": ["s1"], "raw": {"Alpha": ["alt"]},
"facts": {"Alpha": {}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "beta", "ablock", "question_pattern",
{"title": "Beta", "sidecar": {}})
await db.kanban_upsert_card(TOPIC, "artefacts", "gamma", "ablock", "done_artefact",
{"title": "Gamma", "raw": {"Gamma": ["bleibt"]}})
await db.kanban_upsert_card(TOPIC, "artefacts", "delta", "ablock", "verify",
{"title": "Delta", "raw": {"Delta": ["neu"]}})
n = await ba.migriere_alt_karten(TOPIC)
assert n == 2
alpha = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert alpha["stage"] == "generate"
assert alpha["payload"] == {"title": "Alpha", "description": "d", "n_size": 3, "sources": ["s1"]}
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "generate"
gamma = await db.kanban_get_card(TOPIC, "artefacts", "gamma")
assert gamma["stage"] == "done_artefact" and gamma["payload"]["raw"] == {"Gamma": ["bleibt"]}
delta = await db.kanban_get_card(TOPIC, "artefacts", "delta")
assert delta["stage"] == "verify" and delta["payload"]["raw"] == {"Delta": ["neu"]}
+536 -77
View File
@@ -70,47 +70,39 @@ async def board_env(testdb, tmp_path, monkeypatch):
return False
monkeypatch.setattr(bi, "_emb_ok", no_emb)
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
title = list(entries.values())[0].split(" — ")[0]
return {title: ["Sub Eins", "Sub Zwei"]}
async def fake_generate(ctx, files, title, description, instructions="", ns="", lbl="",
sources=None, seeds=None, melde=None):
subs = ["Sub Eins", "Sub Zwei"]
return {"raw": {title: list(subs)},
"facts": {title: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
for s in subs}},
"unsicher": [], "votes": {}}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="", sources=None):
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
for s in subs} for t, subs in raw.items()}
return facts, {}
async def fake_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
subs = gen["raw"].get(title) or []
bfacts = gen["facts"].get(title) or {}
sidecar = [{"title": s, "level": "beginner",
"relevance": "relevant" if i == 0 else "peripheral",
"facts": bfacts.get(_norm_title(s)) or {}}
for i, s in enumerate(subs)]
return {"raw": {title: list(subs)}, "facts": {title: bfacts}, "sidecar": {title: sidecar}}
async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
return {1: "relevant", 2: "peripheral"}
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
for t, subs in sidecar.items()}
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
for t, subs in sidecar.items()], "example": []}
async def fake_artefakte(ctx, files, title, sidecar_subs, instructions="", ns="", lbl="", melde=None):
if not sidecar_subs:
return {"pattern": {title: []}, "artefacts": {"flashcard": [], "example": []}}
first = sidecar_subs[0]["title"]
return {"pattern": {title: [{"subblock": first, "question": f"Was ist {title}?"}]},
"artefacts": {"flashcard": [{"block": title, "subblock": first, "front": "F", "back": "B"}],
"example": []}}
async def fake_outline(ctx, set_p, files, entries, instructions):
return {"chapters": [{"title": "Kapitel 1", "numbers": sorted(entries)}]}
async def fake_konsolidierung(ctx, files, raw, facts_map, instructions="", ns="", lbl=""):
return None
for name, fn in [("_subblocks_block", fake_subblocks), ("_facts_block", fake_facts),
("_levels_block", fake_levels), ("_relevance_block", fake_relevance),
("_question_pattern_block", fake_pattern), ("_artefacts_block", fake_artefacts),
("_outline_block", fake_outline), ("_konsolidiere_subblocks", fake_konsolidierung)]:
for name, fn in [("_generate_block", fake_generate), ("_verify_block", fake_verify),
("_artefakte_block", fake_artefakte), ("_outline_block", fake_outline)]:
monkeypatch.setattr(ba, name, fn)
class _EmbOff: # Cross-Block-Barrier reicht ohne Modell alle Karten durch
@staticmethod
def available():
return False
monkeypatch.setattr(ba, "embedding", _EmbOff)
work = tmp_path / "arbeit"
work.mkdir()
files = {"arbeit": work, "final": tmp_path / "blocks.md",
@@ -176,6 +168,38 @@ async def test_board1_full_flow(board_env):
assert summary["boards"].get("inventory", {}).get("done_block") == 4
async def test_abschluss_qa_events_tragen_run_id(board_env, monkeypatch):
"""Abschluss-QA läuft NACH run_flow — ihre Judge-Events müssen trotzdem die run_id
des Laufs tragen (Lauf 20260704-1452-b223: run_id leer → aus jeder Aggregation gefallen)."""
import asyncio
import qa as qa_mod
db, ctx, files = board_env
await _seed(db)
async def qa_mit_judge_event(topic, llm=False):
# wie die echten LLM-Judges: run_agent schreibt ein agent-Event
await db.add_event(topic, "agent", key=f"qa-{topic}-bausteine-0", status="ok")
return {"note": 10.0, "topic": topic, "quoten": {}, "fremd": [],
"artefakte": {"status": "nicht generiert"}}
monkeypatch.setattr(qa_mod, "qa_report", qa_mit_judge_event)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
timeout=30)
assert ok
summary = json.loads((files["arbeit"] / "lauf-summary.json").read_text(encoding="utf-8"))
conn = await db.get_db()
rows = await (await conn.execute(
"SELECT run_id FROM events WHERE topic=? AND key=?",
(TOPIC, f"qa-{TOPIC}-bausteine-0"))).fetchall()
assert rows and all(r[0] == summary["run_id"] for r in rows)
# Registry nach dem Lauf geleert: manuelle QA bleibt korrekt ohne run_id
await db.add_event(TOPIC, "agent", key="qa-manuell", status="ok")
row = await (await conn.execute(
"SELECT run_id FROM events WHERE topic=? AND key='qa-manuell'", (TOPIC,))).fetchone()
assert row[0] == ""
async def test_filter_judges_run_parallel(board_env, monkeypatch):
"""40 Blöcke → 2 Filter-Chunks: die Judge-Welle muss parallel laufen (Perf-Fix)."""
import asyncio
@@ -206,16 +230,17 @@ async def test_filter_judges_run_parallel(board_env, monkeypatch):
async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypatch):
"""Legitim leere Subbausteine ({} statt None) → Karte läuft bis done_artefact durch."""
"""Legitim leere Subbausteine (leere raw-Liste statt None) → Karte läuft bis done_artefact durch."""
import asyncio
import board_artefacts as ba
import blocks as blx
db, ctx, files = board_env
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl="", sources=None):
return {}
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
async def empty_gen(ctx, files, title, description, instructions="", ns="", lbl="",
sources=None, seeds=None, melde=None):
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
monkeypatch.setattr(ba, "_generate_block", empty_gen)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "generate",
{"title": "Leerer Block", "description": "d"})
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
@@ -294,8 +319,9 @@ async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "dedup"
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
async def test_single_recheck_vote_confirms_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: ein pass-1-Vorschlag wird schon von EINER Recheck-Stimme bestätigt → rejected.
(15/17 mehrheitlich geretteten Karten waren später QA-Dubletten — Overturn nur einstimmig.)"""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
@@ -305,6 +331,23 @@ async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
assert c1["stage"] == "rejected"
assert c1["payload"]["reason"] == "fragment"
assert c1["payload"]["parent_norm"] == "codeblock"
async def test_unanimous_recheck_keep_overrules_proposal(board_env, tmp_path, monkeypatch):
"""M3.2: nur ein EINSTIMMIGES Recheck-Panel (0 Demote-Stimmen) hebt den pass-1-Vorschlag auf."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes(set(), {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
]))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "dedup"
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["ueberstimmt"] == ["Blockzitat"]
@@ -557,6 +600,49 @@ def test_canonical_key_camel_and_catalogue():
assert k("Section 2.1 Matching") == k("Matching")
def test_canonical_key_glued_problem_suffix():
"""Verklebtes Kompositum-Suffix „…problem" wird abgetrennt, damit „Cliquenproblem" und
„Clique" denselben Blocking-Key teilen (aak: sonst nie Dedup-Kandidat). Fugen-n/-s + Plural
inklusive; die generische Suffix-Regel darf keine echten Varianten über-mergen."""
from blocks import _canonical_key as k
assert k("Clique") == k("Cliquenproblem") == k("Cliquenprobleme") != ""
assert k("Set Cover") == k("SetCover-Problem") # bestehender Hyphen-Pfad bleibt
assert k("SAT") != k("3-SAT") # Varianten-Ziffer bleibt Signal
assert k("Problem") != k("Clique") # bloßes „Problem" wird nicht zum Stamm
def test_reference_strip_and_is_reference():
"""Katalog-Nummern werden gestrippt, der Konzeptname bleibt; reine Nummern → leer + Referenz.
Reale aak-Schadensfälle (Satz-/Bemerkungs-Titel liefen wörtlich bis done)."""
from blocks import _reference_strip as strip, _is_reference as isref
assert strip("Satz 7.13 (Christofides)") == "Christofides"
assert strip("Satz 7.6: Kriterium für Eulerschen Kreis") == "Kriterium für Eulerschen Kreis"
assert strip("N P via nicht-deterministische Turingmaschine (Definition 6.19)") \
== "N P via nicht-deterministische Turingmaschine"
assert strip("Bemerkung 7.22") == ""
assert strip("Vertex Cover") == "Vertex Cover" # kein Katalog-Gerüst → unverändert
# Zuschreibungs-Klammer vor ':' gehört zum Katalog-Gerüst (aak: "(Lawler 1976): …" überlebte)
assert strip("Satz 7.12 (Lawler 1976): Minimum-Weight Perfect Matching") \
== "Minimum-Weight Perfect Matching"
assert strip("(a1=1)-SubsetSum Problem") == "(a1=1)-SubsetSum Problem" # Mathe-Klammer bleibt
assert isref("Bemerkung 7.22") and isref("Satz 7.18") and isref("Korollar 6.18")
assert isref("Bedingung (**)")
assert not isref("Satz 7.13 (Christofides)") # hat Konzept → keine Referenz
assert not isref("Satz 7.6: Kriterium für Eulerschen Kreis")
assert not isref("P⊆NP") and not isref("Σ*") # kurze Symbole bleiben echt
def test_is_named_statement_construction_suffix():
"""Reduktion mit Konstruktions-Suffix (: / = nach dem Ziel) ist ein Fragment, kein Statement —
darf nicht mehr vor Demotion geschützt sein (aak: „3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")."""
from blocks import _is_named_statement as named
assert not named("3-SAT ≤ K-COLOR: G=(V,E) Konstruktion")
assert named("3-SAT ≤ Clique") # saubere Reduktion bleibt geschützt
assert named("Clique → Vertex Cover")
assert not named("X ist NP-vollständig") # unäre Aussage bleibt demotable
assert named("Satz von Cook/Levin: SAT ist NP-vollständig ⇔ …") # benanntes Ergebnis bleibt
def test_relation_guard_ignores_trailing_scaffolding():
"""Trailing „Reduktion/Transformation" ist kein Operand — sonst blockt der Guard
den korrekten Merge; Richtungs-Konflikte bleiben erkannt."""
@@ -680,18 +766,20 @@ def test_agent_priority_order():
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
"""Gliederung startet, sobald alle Karten die generate-Stage passiert haben —
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
import asyncio
import board_artefacts as ba
db, ctx, files = board_env
await _seed(db)
base_levels = ba._levels_block
base_verify = ba._verify_block
snapshot = {}
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
async def slow_verify(ctx, files, title, gen, q, instructions="", ns="", lbl="", sources=None,
melde=None, keep_all=False):
await asyncio.sleep(0.8) # keeps one card in `verify` while the outline fires
return await base_verify(ctx, files, title, gen, q, instructions, ns=ns, lbl=lbl,
sources=sources, keep_all=keep_all)
base_outline = ba._outline_block
@@ -700,7 +788,7 @@ async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
return await base_outline(ctx, set_p, files, entries, instructions)
monkeypatch.setattr(ba, "_levels_block", slow_levels)
monkeypatch.setattr(ba, "_verify_block", slow_verify)
monkeypatch.setattr(ba, "_outline_block", spy_outline)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
@@ -718,7 +806,7 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
db, ctx, files = board_env
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
{"title": "Alpha", "description": "d"})
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels",
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "verify",
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
@@ -732,16 +820,16 @@ async def test_outline_facts_from_payloads(board_env, tmp_path):
async def test_card_view_stepper(testdb):
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0,
r = {"board": "artefacts", "card_id": "alpha", "stage": "verify", "retries": 0,
"payload": {"title": "Alpha"}}
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Fix 1/2…", "step": "Fix"}})
assert v["status"] == "active" and v["info"] == "Fix 1/2…"
assert v["step_i"] == 2 and v["step_n"] == 2 and v["steps"][0] == "Verify"
# legacy plain-string live info → no stepper, no crash
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Verify 0/1…"})
assert v2["info"] == "Verify 0/1…" and "step_n" not in v2
# step outside the card's stage group (e.g. supplement note) → no stepper
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Generate"}})
assert "step_n" not in v3
@@ -776,17 +864,17 @@ async def test_seed_map_resolves_cascade(testdb):
def test_per_block_functions_accept_wrapper_kwargs():
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
"""Die board_artefacts-Prozessoren übergeben ns/lbl (generate auch seeds/sources) —
ein fehlender Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken
die Signatur)."""
import inspect
import blocks as blx
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
"_question_pattern_block", "_artefacts_block"):
params = inspect.signature(getattr(blx, fn)).parameters
import block_calls as bc
for fn in ("_generate_block", "_verify_block", "_artefakte_block"):
params = inspect.signature(getattr(bc, fn)).parameters
assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
for fn in ("_subblocks_block", "_facts_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(blx, fn)).parameters, fn
assert "seeds" in inspect.signature(bc._generate_block).parameters
for fn in ("_generate_block", "_verify_block"): # Board 2 reicht die Block-Quellen durch
assert "sources" in inspect.signature(getattr(bc, fn)).parameters, fn
# ── Inventar-Härtung: Sanitizer, Akronym-Regel, Supplement-Beleg ─────────────────────
@@ -873,24 +961,115 @@ async def _run_flow(ctx, files, timeout=30, **kw):
timeout=timeout)
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
async def test_qa_gate_auto_stillstand_pauses(board_env, monkeypatch):
"""Auto an, aber Repair schließt die Befunde nicht → Stillstand → Flow pausiert,
Board-2-Karten warten in generate."""
import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env
async def bad_qa(topic, llm=False):
return {"note": 5.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 5.0, "aktionen": 0} # bewegt nichts
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="subblocks")
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
assert len(warten) == 4 # alle Blöcke gespawnt, keiner verarbeitet
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0
async def test_qa_gate_luecken_research_runden(board_env, monkeypatch):
"""Offene Lücken im Auto-Loop: erst LUECKEN_RESEARCH_MAX Recherche-Runden
(Lücken-Urteil ausgesetzt), dann das Urteil — erst danach Stillstand/Pause."""
import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env
gap_report = {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {},
"luecken": [{"datei": "f.txt", "abschnitt": 1, "vorschau": "x"}],
"konzept_luecken": []}
async def bad_qa(topic, llm=False):
return dict(gap_report)
research_calls = []
async def fake_producer(ctx2, flow, rep):
research_calls.append(1)
return 1 # Recherche hat Karten erzeugt → Runde gilt als bewegt
urteile = []
async def stuck_repair(topic, ebene=None, luecken_urteil=True):
urteile.append(luecken_urteil)
return {"note": 8.0, "aktionen": 0}
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "latest_report", lambda t, guide=False: dict(gap_report))
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
monkeypatch.setattr(bi, "_luecken_producer", fake_producer)
monkeypatch.setattr(repair_mod, "repair_befunde", stuck_repair)
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
assert len(research_calls) == 2 # LUECKEN_RESEARCH_MAX
assert urteile[:2] == [False, False] and urteile[2] is True
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") == 0 # pausiert
async def test_qa_gate_auto_reaches_100(board_env, monkeypatch):
"""Auto an, Repair hebt die Note auf 100 % → Gate öffnet, Board 2 läuft durch."""
import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env
async def bad_qa(topic, llm=False):
return {"note": 8.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def good_repair(topic, ebene=None, luecken_urteil=True):
return {"note": 10.0, "aktionen": 1}
monkeypatch.setattr(qa_mod, "qa_report", bad_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
monkeypatch.setattr(repair_mod, "repair_befunde", good_repair)
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
async def test_qa_gate_auto_off_pauses(board_env, monkeypatch):
"""Auto aus → nach der QA anhalten (Kontrollpunkt), egal welche Note; kein Repair."""
import qa as qa_mod
import repair as repair_mod
db, ctx, files = board_env
async def good_qa(topic, llm=False):
return {"note": 10.0, "topic": TOPIC, "quoten": {}, "fremd": [], "artefakte": {}}
async def no_repair(topic, ebene=None, luecken_urteil=True):
raise AssertionError("Auto aus darf nicht reparieren")
monkeypatch.setattr(qa_mod, "qa_report", good_qa)
monkeypatch.setattr(qa_mod, "_write_report", lambda r: None)
monkeypatch.setattr(repair_mod, "repair_befunde", no_repair)
monkeypatch.setattr(bi, "_QA_GATE_POLL", 0.05)
await _seed(db)
ok = await _run_flow(ctx, files, auto_inventory=False)
assert ok
warten = await db.kanban_cards(TOPIC, board="artefacts", stage="generate")
assert len(warten) == 4 # Board 2 wartet trotz Note 10.0 (Auto aus = Kontrollpunkt)
async def test_qa_gate_force_overrides(board_env, monkeypatch):
"""qa_force=True („Trotzdem fortsetzen") übersteuert die schlechte Note."""
import qa as qa_mod
@@ -907,7 +1086,7 @@ async def test_qa_gate_force_overrides(board_env, monkeypatch):
ok = await _run_flow(ctx, files, qa_force=True)
assert ok
assert await db.kanban_count(TOPIC, "done_artefact", board="artefacts") >= 5
assert called["n"] <= 1 # Gate-Lauf übersprungen; höchstens Abschluss-QA
assert called["n"] <= 2 # Gate-QA übersprungen (force); nur Artefakt-Loop + Abschluss-QA
async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
@@ -924,7 +1103,7 @@ async def test_qa_gate_off_means_no_qa_call(board_env, monkeypatch):
await _seed(db)
ok = await _run_flow(ctx, files)
assert ok
assert called["n"] == 1 # kein Gate-Lauf; nur die Abschluss-QA der Lauf-Summary
assert called["n"] == 2 # kein Gate-Lauf; Artefakt-Loop-QA + Abschluss-QA der Lauf-Summary
async def test_qa_gate_fail_open(board_env, monkeypatch):
@@ -949,9 +1128,14 @@ def test_qa_view_pausiert_logic(tmp_path, monkeypatch):
(tmp_path / TOPIC).mkdir()
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
{"note": 5.0, "quoten": {"fremd": 0.2}, "fremd": ["X"], "unecht": ["Y"]}), encoding="utf-8")
counts = {"inventory": {"done_block": 3}, "artefacts": {"subblocks": 4}}
counts = {"inventory": {"done_block": 3}, "artefacts": {"generate": 4}}
v = bi._qa_view(TOPIC, counts, None)
assert v["pausiert"] is True and v["note"] == 5.0 and v["befunde"] == ["X", "Y"]
# 9.6 lag über QA_GATE_NOTE, pausiert den Auto-Loop aber trotzdem (Gate öffnet nur bei VOLL)
(tmp_path / TOPIC / "r1.json").write_text(_json.dumps(
{"note": 9.6, "quoten": {}, "fremd": [], "unecht": []}), encoding="utf-8")
qa_mod._latest_cache.clear()
assert bi._qa_view(TOPIC, counts, None)["pausiert"] is True
from types import SimpleNamespace
laufend = SimpleNamespace(state={})
assert bi._qa_view(TOPIC, counts, laufend)["pausiert"] is False # Flow läuft noch
@@ -1083,24 +1267,27 @@ def test_hat_anker_ziffern_suffix():
assert not bi._hat_anker("Algorithmus Verfahren", ctoks) # nur Stopwörter → kein Anker
async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
"""Reset auf Spalte subblocks: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
async def test_reset_generate_behaelt_subs_loescht_artefakte(testdb, tmp_path):
"""Reset auf Spalte generate (Artefakte entfernen): die Board-1-Subs BLEIBEN (Board 2
reichert sie neu an), aber die abgeleiteten Fragen/Artefakte + globalen Sidecar-Dateien
+ ab-*-Resume-Slots gehen weg — sonst mergen Reste in den frischen Lauf zurück."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefacts", {"title": "Alpha"})
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "artefakte", {"title": "Alpha"})
await db.put_subblock(TOPIC, "alpha", "s1", "Alpha", "S1", status="consensus")
await db.upsert_question_pattern(TOPIC, "alpha", "s1", "Alpha", "S1", "Frage?")
arbeit = tmp_path / "arbeit"
(arbeit / "ab-alpha").mkdir(parents=True)
(arbeit / "ab-alpha" / "facts.json").write_text("{}", encoding="utf-8")
files = {"arbeit": arbeit}
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"):
files[k] = tmp_path / f"{k}.json"
files[k].write_text("{}", encoding="utf-8")
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "subblocks", files)
moved = await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
assert moved == 1
assert not await db.list_subblocks(TOPIC)
assert len(await db.list_subblocks(TOPIC)) == 1 # Sub bleibt (Board-1-Eigentum)
assert not await db.list_question_pattern(TOPIC, "alpha") # abgeleitete Frage weg
assert not (arbeit / "ab-alpha").exists()
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"))
assert all(not files[k].exists() for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte", "outline"))
# ── Naming-Abstraktion: freier Name nur mit Anker ───────────────────────────────────
@@ -1181,3 +1368,275 @@ async def test_namecheck_ok_behaelt_titel(testdb, tmp_path, monkeypatch):
block = await db.kanban_get_card(TOPIC, B, "b-c9")
assert block["payload"]["title"] == "Eigener Titel"
assert block["payload"]["description"] == "Eigene Beschreibung"
async def test_namecheck_strips_source_numbering(testdb, tmp_path, monkeypatch):
"""M1.2: wählt der Check-Judge einen numerierten Titel, wird die Katalog-Nummer deterministisch
zum Konzept gestrippt ('Satz 7.13 (Christofides)' → 'Christofides')."""
db = testdb
async def fake_members(topic, cid):
return [{"norm": "satz 7.13 (christofides)", "title": "Satz 7.13 (Christofides)",
"description": "3/2-Approximation für metrisches TSP", "readers": ["r1"], "sources": []},
{"norm": "b", "title": "B", "description": "db", "readers": ["r2"], "sources": []}]
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
return "ok", payload((0, '{"best": 1}', "")) # Judge wählt den numerierten Member-Titel
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
payload = {"title": "Satz 7.13 (Christofides)", "description": "3/2-Approximation",
"main_norm": "satz 7.13 (christofides)"}
await db.kanban_upsert_card(TOPIC, B, "c7", "cluster", "naming_check", payload)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._namecheck_one(ctx, _mk_flow(tmp_path), {"card_id": "c7", "payload": payload})
block = await db.kanban_get_card(TOPIC, B, "b-c7")
assert block["payload"]["title"] == "Christofides"
async def test_consensus_gate_single_reader_majority_quorum(testdb, tmp_path, monkeypatch):
"""M5.1: Einzel-Reader-Fund → clarify mit Mehrheits-Quorum statt Einstimmigkeit."""
db = testdb
monkeypatch.setattr(bi, "source_folder", lambda t: None) # thema: kein Korpus
async def fake_members(topic, cid):
return [{"title": "Seltenes Konzept", "description": "einmal erwähnt",
"readers": ["r1"], "supplement": False}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
monkeypatch.setattr(bi, "_rep", lambda rows: rows[0])
await db.kanban_upsert_card(TOPIC, B, "cs", "cluster", "consensus_gate", {})
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._proc_consensus_gate(ctx, _mk_flow(tmp_path), [{"card_id": "cs", "payload": {}}])
card = await db.kanban_get_card(TOPIC, B, "cs")
assert card["stage"] == "clarify"
assert card["payload"]["quorum"] == "majority"
# ── Sanierung: Titel auf Korpus-Form, Beschreibungspflicht (QA: fremd/hygiene) ──────
def test_sanierung_schema_varianten():
assert bi._sanierung_schema({"title": " k-Color ", "description": "d"}) == ("k-Color", "d")
assert bi._sanierung_schema({"title": "", "description": "nur Beschreibung"}) == ("", "nur Beschreibung")
assert bi._sanierung_schema({"title": "x" * 90, "description": "d"}) == ("", "d") # zu lang
assert bi._sanierung_schema({"title": "", "description": ""}) is None
assert bi._sanierung_schema("quatsch") is None
def test_sanierung_noetig():
ctoks = {"color", "graph"}
assert bi._sanierung_noetig({"title": "k-Color", "description": ""}, ctoks) # leere Beschreibung
assert bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, ctoks) # kein Korpus-Anker
assert not bi._sanierung_noetig({"title": "k-Color", "description": "d"}, ctoks)
assert not bi._sanierung_noetig({"title": "k-Coloring", "description": "d"}, None) # thema: kein Korpus
# M1.3: Referenz-Titel / unbalancierte Klammer / >80 Zeichen triggern auch ohne Korpus (Singletons)
assert bi._sanierung_noetig({"title": "Bemerkung 7.22", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "N P (Definition 6.19", "description": "d"}, None)
assert bi._sanierung_noetig({"title": "A" * 81, "description": "d"}, None)
assert not bi._sanierung_noetig({"title": "Clique", "description": "d"}, None)
def _sanierung_env(tmp_path, monkeypatch, antwort):
"""Korpus mit 'k-Color'-Oberflächenform; Judge antwortet mit `antwort`."""
(tmp_path / "korpus.txt").write_text(
"Das k-Color Problem: Kann der Graph mit k Farben gefärbt werden? NP-vollständig.",
encoding="utf-8")
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
seen[key] = prompt
return "ok", payload((0, json.dumps(antwort["val"]), ""))
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
return GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False), seen
async def test_singleton_unverankert_wird_saniert(testdb, tmp_path, monkeypatch):
"""Singleton-Cluster mit Reader-Titel ohne Korpus-Anker: Naming wird nicht mehr
übersprungen — der Titel wird auf die Korpus-Oberflächenform umgeschrieben
(gemessener aak-Fall 'k-Coloring' statt 'k-Color')."""
db = testdb
antwort = {"val": {"title": "k-Color", "description": "Kann der Graph mit k Farben gefärbt werden?"}}
ctx, seen = _sanierung_env(tmp_path, monkeypatch, antwort)
async def fake_members(topic, cid):
return [{"norm": "k-coloring", "title": "k-Coloring",
"description": "Kann der Graph mit k Farben gefärbt werden?",
"readers": ["r1", "r2"], "sources": []}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
await db.kanban_upsert_card(TOPIC, B, "c1", "cluster", "naming", payload)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c1", "payload": payload})
card = await db.kanban_get_card(TOPIC, B, "c1")
assert card["stage"] == "naming_check"
assert card["payload"]["title"] == "k-Color"
assert any("-sanierung-" in k for k in seen)
async def test_leere_beschreibung_wird_gefuellt(testdb, tmp_path, monkeypatch):
"""Verankerter Titel, leere Beschreibung (gemessener aak-Fall der SetCover-Fragmente):
Beschreibung wird belegt nachgefasst, Titel bleibt."""
db = testdb
antwort = {"val": {"title": "k-Color", "description": "Färbbarkeit mit k Farben."}}
ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)
async def fake_members(topic, cid):
return [{"norm": "k-color", "title": "k-Color", "description": "",
"readers": ["r1"], "sources": []}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
payload = {"title": "k-Color", "description": ""}
await db.kanban_upsert_card(TOPIC, B, "c5", "cluster", "naming", payload)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c5", "payload": payload})
card = await db.kanban_get_card(TOPIC, B, "c5")
assert card["payload"]["title"] == "k-Color"
assert card["payload"]["description"] == "Färbbarkeit mit k Farben."
async def test_sanierung_unverankerter_vorschlag_verfaellt(testdb, tmp_path, monkeypatch):
"""Judge-Vorschlag ohne Korpus-Anker wird verworfen (dieselbe Messlatte wie QA-fremd);
die Beschreibung wird trotzdem übernommen."""
db = testdb
antwort = {"val": {"title": "Graphfärbungsproblem", "description": "Färbbarkeit mit k Farben."}}
ctx, _seen = _sanierung_env(tmp_path, monkeypatch, antwort)
async def fake_members(topic, cid):
return [{"norm": "k-coloring", "title": "k-Coloring",
"description": "Kann der Graph mit k Farben gefärbt werden?",
"readers": ["r1"], "sources": []}]
monkeypatch.setattr(bi, "_member_rows", fake_members)
payload = {"title": "k-Coloring", "description": "Kann der Graph mit k Farben gefärbt werden?"}
await db.kanban_upsert_card(TOPIC, B, "c2", "cluster", "naming", payload)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c2", "payload": payload})
card = await db.kanban_get_card(TOPIC, B, "c2")
assert card["payload"]["title"] == "k-Coloring" # unverankert → verfällt
assert card["stage"] == "naming_check"
async def test_sanierung_fail_open(testdb, tmp_path, monkeypatch):
"""Judge-Ausfall → Karte läuft unverändert weiter (kein Deadletter am Naming)."""
db = testdb
(tmp_path / "korpus.txt").write_text("Der Graph ist endlich.", encoding="utf-8")
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
async def broken_slot(*a, **kw):
return "failed", None
async def fake_members(topic, cid):
return [{"norm": "x", "title": "Graph", "description": "", "readers": ["r1"], "sources": []}]
monkeypatch.setattr(bi, "run_single_slot", broken_slot)
monkeypatch.setattr(bi, "_member_rows", fake_members)
payload = {"title": "Graph", "description": ""}
await db.kanban_upsert_card(TOPIC, B, "c3", "cluster", "naming", payload)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c3", "payload": payload})
card = await db.kanban_get_card(TOPIC, B, "c3")
assert card["stage"] == "naming_check" and card["payload"]["title"] == "Graph"
async def test_sanierung_anker_und_beschreibung_ok_kein_judge(testdb, tmp_path, monkeypatch):
"""Verankerter Titel + Beschreibung vorhanden → kein Sanierungs-Call."""
db = testdb
(tmp_path / "korpus.txt").write_text("Das k-Color Problem im Graph.", encoding="utf-8")
monkeypatch.setattr(bi, "source_folder", lambda t: tmp_path)
async def never_slot(*a, **kw):
raise AssertionError("Sanierung darf ohne Befund-Form nicht laufen")
async def fake_members(topic, cid):
return [{"norm": "k-color", "title": "k-Color", "description": "d", "readers": ["r1"], "sources": []}]
monkeypatch.setattr(bi, "run_single_slot", never_slot)
monkeypatch.setattr(bi, "_member_rows", fake_members)
payload = {"title": "k-Color", "description": "d"}
await db.kanban_upsert_card(TOPIC, B, "c4", "cluster", "naming", payload)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
await bi._name_one(ctx, _mk_flow(tmp_path), {"card_id": "c4", "payload": payload})
assert (await db.kanban_get_card(TOPIC, B, "c4"))["stage"] == "naming_check"
def test_themen_zielband_skaliert_mit_atomzahl():
"""√n-Band: kleine Themen deutlich unter 15, große über dem alten 18er-Cluster-Cap."""
lo30, hi30 = bi._themen_zielband(30)
assert lo30 >= 2 and hi30 < 15
lo285, hi285 = bi._themen_zielband(285)
assert hi285 > 18
lo4, hi4 = bi._themen_zielband(4)
assert lo4 >= 2 and hi4 > lo4
# Monotonie: mehr Items → nie kleineres Band
prev = (0, 0)
for n in (4, 30, 100, 285, 1000):
band = bi._themen_zielband(n)
assert band >= prev
prev = band
def test_gruppierung_prompt_rendert_zielband(tmp_path):
"""Template↔Call-Site-Vertrag: alle Platzhalter versorgt, Band-String im Prompt."""
from pipeline import _prompt
p = _prompt("Blocks-Gruppierung", topic="t", candidates="x", list="1. a",
out_path=tmp_path / "g.json", theme_lo=4, theme_hi=8, n_items=9)
assert "~4–8 themes" in p and "(9 items)" in p
async def test_attach_nachzuegler_wird_sub_statt_zwergblock(testdb, tmp_path, monkeypatch):
"""Nachzügler-Atom (Supplement-/Lücken-Welle) mit passendem fertigen Block →
wird dessen Sub (Bottom-up), Karte → grouped/attach, Board-2-Karte → generate.
Ohne Zuordnung bleibt das Atom standalone (Erstwelle: keine done_blocks → no-op)."""
db = testdb
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
flow = _mk_flow(tmp_path)
# fertiger Block mit Board-2-Karte
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
{"title": "Turingmaschinen", "description": "Modelle und Akzeptanz",
"mirrored_norm": "turingmaschinen", "children": ["Palindrom-TM"]})
await db.kanban_upsert_card(TOPIC, "artefacts", "turingmaschinen", "ablock", "done_artefact",
{"title": "Turingmaschinen"})
rows = [{"card_id": "n1", "payload": {"title": "Halteproblem", "description": "hält die TM?"},
"title": "Halteproblem", "description": "hält die TM?", "title_norm": "halteproblem"}]
await db.kanban_upsert_card(TOPIC, B, "n1", "block", "grouping", rows[0]["payload"])
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout):
assert "-gruppierung-attach-" in key and "Turingmaschinen" in prompt
m = re.search(r"(/\S+\.json)", prompt) # files-Agent: out_path aus dem Prompt schreiben
from pathlib import Path
Path(m.group(1)).write_text('{"additions": [{"umbrella": 0, "members": [1]}]}', encoding="utf-8")
return bi.OK, payload((0, "", ""))
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
attached = await bi._attach_nachzuegler(ctx, flow, rows)
assert attached == {"n1"}
subs = {r["sub_norm"] for r in await db.list_subblocks(TOPIC, "turingmaschinen")}
assert "halteproblem" in subs
card = await db.kanban_get_card(TOPIC, B, "n1")
assert card["stage"] == "grouped" and card["payload"]["reason"] == "attach"
assert card["payload"]["merged_into"] == "Turingmaschinen"
b2 = await db.kanban_get_card(TOPIC, "artefacts", "turingmaschinen")
assert b2["stage"] == "generate" # Re-Anreicherung des neuen Subs
block = await db.kanban_get_card(TOPIC, B, "b1")
assert "Halteproblem" in block["payload"]["children"]
async def test_attach_nachzuegler_ohne_zuordnung_bleibt_standalone(testdb, tmp_path, monkeypatch):
db = testdb
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
flow = _mk_flow(tmp_path)
await db.kanban_upsert_card(TOPIC, B, "b1", "block", "done_block",
{"title": "Turingmaschinen", "mirrored_norm": "turingmaschinen"})
rows = [{"card_id": "n2", "payload": {"title": "Ganz neues Konzept"},
"title": "Ganz neues Konzept", "description": "", "title_norm": "ganz neues konzept"}]
await db.kanban_upsert_card(TOPIC, B, "n2", "block", "grouping", rows[0]["payload"])
async def fake_slot(ctx2, label, **kw):
m = re.search(r"(/\S+\.json)", kw["prompt"])
from pathlib import Path
Path(m.group(1)).write_text('{"additions": []}', encoding="utf-8")
return bi.OK, kw["payload"]((0, "", ""))
monkeypatch.setattr(bi, "run_single_slot", fake_slot)
assert await bi._attach_nachzuegler(ctx, flow, rows) == set()
assert (await db.kanban_get_card(TOPIC, B, "n2"))["stage"] == "grouping"
+36 -39
View File
@@ -1,7 +1,7 @@
"""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.
bis run_agent echt — _race, Quorum, Panels, In-Block-Konsolidierung, QA-Gate.
"""
import asyncio
@@ -10,7 +10,7 @@ import pytest
import board_inventory as bi
from pipeline import GenContext
from tests.invarianten import pruefe_invarianten, pruefe_guide_invarianten
from invarianten import pruefe_invarianten, pruefe_guide_invarianten
TOPIC = "t"
@@ -42,11 +42,16 @@ async def test_e2e_thema_vollpfad(fake_welt, testdb, tmp_path):
db = testdb
done = [c for c in await db.kanban_cards(TOPIC, board="inventory", stage="done_block")]
titel = {c["payload"]["title"] for c in done}
# Bottom-up: 7 Atome zu 3 Themen-Clustern gruppiert
assert titel == {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"}
# Cross-Block-Dublette: „Gemeinsamer Grundbegriff" überlebt in genau EINEM Block
subs = [dict(r) for r in await db.list_subblocks(TOPIC)]
# jedes Atom liegt in genau EINEM Cluster — keine Cross-Block-Dublette mehr
gemeinsam = [r for r in subs if r["sub_norm"] == "gemeinsamer grundbegriff"]
assert sorted(r["status"] for r in gemeinsam) == ["consensus", "variant"]
assert [r["status"] for r in gemeinsam] == ["consensus"]
# Vollständigkeit: alle 7 Atome sind als consensus-Sub erhalten
consensus = {r["sub_norm"] for r in subs if r["status"] == "consensus"}
assert consensus == {"definition alpha", "alpha eigenschaften", "gemeinsamer grundbegriff",
"beta ablauf", "beta grenzen", "gamma praxisfall", "gamma werkzeuge"}
fehler = await pruefe_invarianten(TOPIC, files)
assert fehler == []
@@ -67,6 +72,27 @@ async def test_e2e_guide(fake_welt, testdb, tmp_path):
assert await pruefe_guide_invarianten(TOPIC) == []
async def test_e2e_only_artefacts(fake_welt, testdb, tmp_path):
"""Artefakt-only (inventory=False): nach removeArtefacts läuft NUR Board 2 auf den
fertigen Blocks — kein Board 1, keine Research-Agenten. Karten aus done_block geseedet."""
ok, files = await _lauf(tmp_path)
assert ok
db = testdb
subs_vorher = {r["sub_norm"] for r in await db.list_subblocks(TOPIC)}
assert subs_vorher
# Artefakte entfernen (Karten zurück auf generate, Board-1-Subs bleiben)
await bi.reset_board_from_stage(TOPIC, "artefacts", "generate", files)
fake_welt.calls.clear()
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
ok2 = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=False, inventory=False), timeout=60)
assert ok2
assert not any("-research-" in k for k in fake_welt.calls) # Board 1 lief NICHT
assert any("-sb-enrich-" in k or "-sb-verify-" in k for k in fake_welt.calls) # Board 2 lief
assert {r["sub_norm"] for r in await db.list_subblocks(TOPIC)} == subs_vorher # vollständig
async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
"""Zweiter Lauf (Continue, research=False) hinterlässt keine Waisen/Reste."""
ok, files = await _lauf(tmp_path)
@@ -83,9 +109,8 @@ async def test_e2e_rerun_idempotent(fake_welt, testdb, tmp_path):
@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"-sb-verify-.*-j1$", "modus": "garbage", "mal": 1}, # Ersatz-Richter jE
{"muster": r"-art-gen-.*-t1$", "modus": "fehler", "mal": 1}, # Slot-Restart
{"muster": r"-research-2$", "modus": "fehler", "mal": 3}, # 1 Producer tot
])
async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
@@ -96,35 +121,6 @@ async def test_e2e_stoerungen_flow_endet(fake_welt, testdb, tmp_path, stoerung):
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."""
@@ -188,10 +184,11 @@ async def test_e2e_uni_anker_gate(fake_welt, testdb, tmp_path, monkeypatch):
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"
# das unbelegte Atom „Klassiker Detail" (einziges Atom von Kanon-Klassiker) wird rejected
assert any(c["stage"] == "rejected" and c["payload"].get("title") == "Klassiker Detail"
for c in alle)
assert not any(c["kind"] == "block" and c["payload"].get("title") == "Kanon-Klassiker"
for c in alle) # nie zum Block geworden
assert not any(c["payload"].get("title") in ("Klassiker Detail", "Kanon-Klassiker")
and c["stage"] == "done_block" for c in alle) # nie zum Block geworden
done = {c["payload"].get("title") for c in alle
if c["kind"] == "block" and c["stage"] == "done_block"}
assert {"Alpha-Konzept", "Beta-Verfahren", "Gamma-Anwendung"} <= done
+116 -38
View File
@@ -131,8 +131,8 @@ async def test_guide_error_event(testdb):
def test_timeout_calibration_smoke():
from pipeline import _timeout
assert _timeout("subblock", 10) == 400 + 150
assert _timeout("content", 10) == 450 + 300
assert _timeout("subblock_check", 10) == 150 + 100
assert _timeout("writer", 10) == 450 + 600
def test_env_file_wins(tmp_path, monkeypatch):
@@ -146,41 +146,6 @@ def test_env_file_wins(tmp_path, monkeypatch):
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
@@ -253,7 +218,7 @@ 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):
async def fake_comm(agent_key, cmd, stdin, timeout, stagger=False, on_line=None, label="", env=None, est_kb=None):
seen["cmd"] = cmd
return 0, "", ""
@@ -317,3 +282,116 @@ async def test_events_run_summary_aggregates(testdb):
assert s["agents"]["gesamt"] == 2 and s["agents"]["ok"] == 1 and s["agents"]["timeout"] == 1
assert s["agents"]["verlorene_min"] == 2
assert s["tokens"] == {"input": 15, "output": 2, "cache_read": 80, "cache_write": 1}
async def test_events_run_summary_per_board(testdb):
"""meta.board scoped die Aggregation: board="artefacts" summiert nur Artefakt-Agenten."""
db = testdb
db.set_current_run(TOPIC, "r2")
await db.add_event(TOPIC, "agent", key="inv", status="ok", dur_ms=1000,
meta={"board": "inventory", "tokens": {"input": 10, "output": 2}})
await db.add_event(TOPIC, "agent", key="art", status="ok", dur_ms=2000,
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
db.set_current_run(TOPIC, None)
art = await db.events_run_summary(TOPIC, "r2", board="artefacts")
assert art["agents"]["gesamt"] == 1
assert art["tokens"] == {"input": 5, "output": 7, "cache_read": 0, "cache_write": 0}
inv = await db.events_run_summary(TOPIC, "r2", board="inventory")
assert inv["agents"]["gesamt"] == 1 and inv["tokens"]["input"] == 10
assert (await db.events_run_summary(TOPIC, "r2"))["agents"]["gesamt"] == 2 # ohne board = alle
async def test_latest_board_runs_je_ebene(testdb):
"""Je Ebene der jüngste Lauf MIT Daten: ein „Nur Artefakte"-Lauf (r2) lässt die
Inventar-Anzeige auf r1 stehen; Guide-Läufe (Suffix -g+hex4) haben eine eigene Zeile."""
db = testdb
assert await db.latest_board_runs(TOPIC) == {"inventory": None, "artefacts": None, "guide": None}
db.set_current_run(TOPIC, "20260708-1000-aaaa") # Voll-Lauf: beide Ebenen
await db.add_event(TOPIC, "agent", key="i1", status="ok",
meta={"board": "inventory", "tokens": {"input": 10, "output": 1}})
await db.add_event(TOPIC, "agent", key="a1", status="ok", meta={"board": "artefacts"})
db.set_current_run(TOPIC, "20260708-1100-bbbb") # „Nur Artefakte"
await db.add_event(TOPIC, "agent", key="a2", status="ok",
meta={"board": "artefacts", "tokens": {"input": 5, "output": 7}})
db.set_current_run(TOPIC, "20260708-1200-gabc1") # Guide (Events untagged)
await db.add_event(TOPIC, "agent", key="g1", status="ok",
meta={"tokens": {"input": 3, "output": 4}})
latest = await db.latest_board_runs(TOPIC)
assert latest["inventory"]["run_id"] == "20260708-1000-aaaa"
assert latest["inventory"]["tokens"]["input"] == 10
assert latest["artefacts"]["run_id"] == "20260708-1100-bbbb"
assert latest["artefacts"]["tokens"]["output"] == 7
assert latest["guide"]["run_id"] == "20260708-1200-gabc1"
assert latest["guide"]["tokens"]["input"] == 3
# aktiv folgt der Registry: nur der Guide-Lauf ist noch der aktuelle
assert latest["guide"]["aktiv"] and not latest["inventory"]["aktiv"]
db.set_current_run(TOPIC, None)
async def test_topic_delete_entfernt_guides_und_kanban(testdb, tmp_path, monkeypatch):
"""DELETE /topics: guides/guide_cards/kanban_cards mitlöschen — GET /topics leitet
Topics aus guides ab, sonst taucht das gelöschte Topic sofort wieder auf."""
import routes, qa
db = testdb
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
monkeypatch.setattr(qa, "QA_DIR", tmp_path / "qa")
await db.create_topic(TOPIC)
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
"status": "done", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.kanban_upsert_card(TOPIC, "inventory", "b-1", "block", "done_block", {"title": "Alpha"})
res = await routes.remove_topic(TOPIC)
assert res["ok"]
assert all(g["topic"] != TOPIC for g in await db.list_guides())
assert await db.list_guide_cards(TOPIC, "Guide") == []
assert await db.kanban_cards(TOPIC, "inventory") == []
assert TOPIC not in await routes.get_topics()
async def test_topic_delete_409_bei_laufendem_guide(testdb, tmp_path, monkeypatch):
"""Läuft eine Generierung, wird nicht gelöscht (409) — ein laufender Flow schrieb
sonst nach dem Löschen munter neue Rows/Dateien."""
import pytest
from fastapi import HTTPException
import routes
db = testdb
monkeypatch.setattr(routes, "topic_dir", lambda t: tmp_path / "topics" / t)
await db.create_guide({"id": "g1", "topic": TOPIC, "format": "Guide", "instructions": "",
"status": "generating", "progress": None,
"created_at": "2026-01-01", "updated_at": "2026-01-01"})
with pytest.raises(HTTPException) as e:
await routes.remove_topic(TOPIC)
assert e.value.status_code == 409
assert any(g["topic"] == TOPIC for g in await db.list_guides())
async def test_runs_endpoint_liefert_bilanz(testdb):
"""GET /api/runs: pro run_id Zeitspanne + Agent-/Token-Bilanz + Fails, jüngster zuerst,
aktiv-Flag aus dem Run-Registry."""
import routes
db = testdb
db.set_current_run(TOPIC, "r1")
await db.add_event(TOPIC, "agent", key="a", status="ok", dur_ms=1000,
meta={"tokens": {"input": 10, "output": 20, "cache_read": 0, "cache_write": 0}})
await db.add_event(TOPIC, "agent", key="b", status="timeout", dur_ms=120000)
await db.add_event(TOPIC, "fail", key="inventory:b-1", status="dead", meta={"error": "kaputt"})
res = await routes.get_runs(TOPIC)
runs = res["runs"]
assert set(res["latest"]) == {"inventory", "artefacts", "guide"} # je Ebene letzter Lauf mit Daten
assert len(runs) == 1 and runs[0]["run_id"] == "r1" and runs[0]["aktiv"] is True
assert runs[0]["agents"]["gesamt"] == 2 and runs[0]["agents"]["timeout"] == 1
assert runs[0]["tokens"]["output"] == 20
assert runs[0]["fails"][0]["error"] == "kaputt" and runs[0]["fails"][0]["status"] == "dead"
assert runs[0]["start"] <= runs[0]["ende"]
db.set_current_run(TOPIC, "r2")
await db.add_event(TOPIC, "agent", key="c", status="ok")
db.set_current_run(TOPIC, None)
runs = (await routes.get_runs(TOPIC))["runs"]
assert [r["run_id"] for r in runs] == ["r2", "r1"]
assert runs[0]["aktiv"] is False # Registry geräumt → Lauf beendet
async def test_health(testdb):
import routes
assert (await routes.health())["ok"] is True
+80
View File
@@ -0,0 +1,80 @@
"""Finalize (board_artefacts._proc_finalize) und blocks-Helfer — Judges gefaked, gegen
Test-DB. Die In-Block-Konsolidierung lebt seit dem Verschmelzungs-Umbau in
block_calls._verify_block und wird in tests/test_block_calls.py getestet."""
import blocks
import board_artefacts as ba
from kanban import Flow
from pipeline import GenContext
TOPIC = "finalize"
def _ctx():
return GenContext(topic=TOPIC, provider="test", is_cancelled=lambda: False)
def test_luecken_schnitt_cap():
l1 = [f"Aspekt-{k} fehlt" for k in ("eins", "zwei", "drei", "vier", "fünf")]
assert blocks._luecken_schnitt(l1, list(l1)) == l1[:3] # Cap 3
assert blocks._luecken_schnitt(["Inline-HTML"], ["Tabellen-Syntax"]) == []
def test_neg_set_lemmatisiert():
"""kein/keine/keinen falten auf einen Stamm; nicht vs. ohne bleiben verschieden."""
a = blocks._neg_set("Fehlerverhalten (kein Syntaxfehler)")
b = blocks._neg_set("Fehlerverhalten (keine Syntax-Fehlermeldung)")
assert a == b == frozenset({"kein"})
assert blocks._neg_set("nicht expandiert") != blocks._neg_set("ohne Expansion")
assert blocks._neg_set("niemals gerendert") == blocks._neg_set("nie gerendert")
async def test_finalize_purges_stale_rows(testdb, tmp_path):
"""Re-Run-Waisen: Finalize löscht Alt-Fragen/-Artefakte des Blocks vor dem Upsert."""
db = testdb
await db.upsert_question_pattern(TOPIC, "alpha", "alt-sub", "Alpha", "Alt", "Alte Frage?")
await db.put_sub_artifact(TOPIC, "alpha", "alt-sub", "flashcard", "{}", "Alpha", "Alt")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
"pattern": {"Alpha": [{"subblock": "Neu", "question": "F?"}]},
"artefacts": {"flashcard": [{"block": "Alpha", "subblock": "Neu", "front": "F", "back": "B"}]}}}
flow = Flow(TOPIC, work_dir=tmp_path)
await ba._proc_finalize(_ctx(), flow, files, [card])
assert {r["sub_norm"] for r in await db.list_question_pattern(TOPIC)} == {"neu"}
assert {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)} == {("neu", "flashcard")}
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
"""Finalize klassifiziert level-/relevance-lose consensus-Rows (Default advanced/relevant)."""
db = testdb
await db.put_subblock(TOPIC, "alpha", "nachzuegler", "Alpha", "Nachzügler", status="consensus")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {"title": "Alpha", "raw": {}, "facts": {},
"sidecar": {}, "pattern": {}, "artefacts": {}}}
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
row = next(r for r in await db.list_subblocks(TOPIC, "alpha"))
assert row["level"] == "advanced" and row["relevance"] == "relevant"
async def test_finalize_loescht_stale_consensus(testdb, tmp_path):
"""Alt-consensus-Rows, die der Lauf-Sidecar nicht mehr trägt, fliegen raus —
variant-Rows bleiben (QA liest die Status). Wurzel der 25 Board-2-losen Waisen."""
db = testdb
await db.put_subblock(TOPIC, "alpha", "alt-rest", "Alpha", "Alt-Rest", status="consensus")
await db.put_subblock(TOPIC, "alpha", "alte-variante", "Alpha", "Alte Variante", status="variant")
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "finalize", {})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
card = {"card_id": "alpha", "payload": {
"title": "Alpha", "raw": {"Alpha": ["Neu"]}, "facts": {},
"sidecar": {"Alpha": [{"title": "Neu", "level": "beginner"}]},
"pattern": {}, "artefacts": {}}}
await ba._proc_finalize(_ctx(), Flow(TOPIC, work_dir=tmp_path), files, [card])
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows == {"neu": "consensus", "alte-variante": "variant"}
+218 -72
View File
@@ -30,48 +30,42 @@ def test_gate_schema():
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"})
def test_pruefer_schema():
"""Verschmolzenes Verdikt: ok-Kurzform, Claims-Normalisierung, Ziel-Vollständigkeit."""
assert gb._pruefer_schema({"ok": True}, {"z1"}) == {
"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
res = gb._pruefer_schema({"claims": [{"text": "A", "grund": "x", "urteil": "FALSCH"}],
"ziele": {"z1": True, "z2": "false"},
"luecken": [{"ziel": "z2", "fehlt": "Beweis"}],
"ballast": ["Abschweifung"],
"lese_probleme": [{"problem": "zu lang"}]}, {"z1", "z2"})
assert res["claims"] == [{"text": "A", "grund": "x", "urteil": "falsch"}]
assert res["ziele"] == {"z1": True, "z2": False}
assert res["luecken"][0]["fehlt"] == "Beweis"
assert gb._coverage_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 missing
assert res["luecken"][0]["fehlt"] == "Beweis" and res["lese_probleme"] == ["zu lang"]
assert gb._pruefer_schema({"ziele": {"z1": True}}, {"z1", "z2"}) is None # z2 fehlt
assert gb._pruefer_schema({"lese_probleme": []}, set()) is not None # leeres Verdikt ok
assert gb._pruefer_schema("quatsch", set()) is None
def test_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
def test_auftraege_schwelle_und_kritisch():
"""1–2 nur-unbelegt-Claims verfallen (GATE_FIX_MIN); falsch/Lücken sind kritisch."""
leer = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "", "urteil": "unbelegt"}] * 2}, [])
assert z == [] and k is False
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "w", "urteil": "falsch"}]}, [])
assert len(z) == 1 and k is True
z, k = gb._auftraege({**leer, "luecken": [{"ziel": "z1", "fehlt": "X"}]}, ["Länge 2000"])
assert len(z) == 2 and k is True
async def test_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="pruefer")
assert await gb.done_step(TOPIC, FMT) == 2 # bis writer fertig
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
@@ -162,8 +156,8 @@ def test_writer_template_has_examples_placeholder():
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
async def test_pruefer_counts_examples_as_facts(testdb, monkeypatch, tmp_path):
"""Prüfer-Prompt enthält die Beispiele als verifizierte Fakten — sonst fliegen
gerechnete Beispielwerte als „nicht belegt" raus."""
import json as _json
import guide_board as gb
@@ -177,18 +171,19 @@ async def test_fakten_gate_counts_examples_as_facts(testdb, monkeypatch, tmp_pat
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
captured["prompt"] = prompt
return "ok", []
return "ok", payload((0, '{"ok": true}', ""))
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "FAKT X")
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": [{"title": "Sub Eins", "level": "beginner"}]},
spec="", slot=lambda name: tmp_path / name)
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)
md = ("<!-- section: Gross -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20)
card = {"block_norm": "gross", "block": "Gross", "stage": "pruefer", "status": "open",
"writer_rounds": 0, "gate_info": "", "md": md}
ok = await gb._stage_pruefer(env, card)
assert ok is True
assert "VERIFIED WORKED EXAMPLES" in captured["prompt"] and "P1" in captured["prompt"]
@@ -229,7 +224,7 @@ async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path
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"
assert card["stage"] == "pruefer"
async def test_lernziele_retry_bei_leerer_liste(testdb, tmp_path, monkeypatch):
@@ -268,8 +263,8 @@ async def test_lernziele_zweimal_leer_laeuft_weiter(testdb, tmp_path, monkeypatc
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."""
async def test_pruefer_text_sink_ohne_befund_done(testdb, tmp_path, monkeypatch):
"""Prüfer antwortet als Text (Engine-Sink, capabilities none); ohne Befund → done."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-l", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
@@ -284,7 +279,7 @@ async def test_lese_check_text_sink(testdb, tmp_path, monkeypatch):
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_lesbarkeit(env, card)
assert await gb._stage_pruefer(env, card)
assert seen["caps"] == "none"
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
@@ -304,27 +299,25 @@ async def test_writer_prompt_traegt_budget(testdb, tmp_path, monkeypatch):
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
assert str(gb.block_budget(env.subs_by_title["Alpha"])) in seen["prompt"] # 2× Basis-Budget
async def test_fakten_gate_schwelle(testdb, tmp_path, monkeypatch):
"""1–2 Claims → kein Fix-Rewrite (nur Log); ab GATE_FIX_MIN läuft der Fix."""
async def test_pruefer_claims_schwelle(testdb, tmp_path, monkeypatch):
"""1–2 nur-unbelegt-Claims → kein Fix (Karte direkt done); die Schwelle lebt in _auftraege."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-g", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
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")
antwort = '{"claims": [{"text": "c1", "grund": ""}, {"text": "c2", "grund": ""}]}'
return gb.OK, payload((0, antwort, ""))
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)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "done"
async def test_load_subblocks_defaultet_levellose(testdb):
@@ -340,30 +333,46 @@ async def test_load_subblocks_defaultet_levellose(testdb):
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."""
async def test_falsch_claim_erzwingt_fix_und_repruefer(testdb, tmp_path, monkeypatch):
"""Ein einzelner FALSCH-Claim läuft in den Fix, auch unter GATE_FIX_MIN; nach
angewandtem Fix läuft GENAU EIN Re-Prüfer-Pass (der alte Lese-Fix blieb ungeprüft)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-gf", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md}
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
keys = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
keys.append(key)
if "-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
if "-pruef-" in key and key.endswith("-re"):
return gb.OK, payload((0, '{"ok": true}', ""))
if "-pruef-" in key:
return gb.OK, payload((0, '{"claims": [{"text": "c1", "grund": "widerspricht", "urteil": "falsch"}]}', ""))
if "-gfix-" in key: # Fix liefert eine valide Section über die Slot-Datei
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
return gb.OK, payload(None)
raise AssertionError(key)
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fakten_gate(env, card)
assert any("-gatefix-" in k for k in keys)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_pruefer(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "fix" and karte["gate_info"].startswith("KRITISCH")
card.update(stage="fix", gate_info=karte["gate_info"])
assert await gb._stage_fix(env, card)
assert any("-gfix-" in k for k in keys)
assert any(k.endswith("-re") for k in keys) # Re-Prüfer lief
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
async def test_laengen_trigger_startet_lesefix(testdb, tmp_path, monkeypatch):
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem
mit hartem Zeichenziel landet im Lese-Fix-Auftrag."""
async def test_laengen_trigger_startet_fix(testdb, tmp_path, monkeypatch):
"""Ausführlich-Teil über der Obergrenze → deterministisches Längen-Problem mit hartem
Zeichenziel landet als Auftrag in der Fix-Stage (unkritisch → kein Re-Prüfer)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-lz", TOPIC, FMT, "", tmp_path / "Guide.json",
@@ -371,16 +380,153 @@ async def test_laengen_trigger_startet_lesefix(testdb, tmp_path, monkeypatch):
{}, "(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}
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md, "gate_info": ""}
seen = {}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
if "-lesefix-" in key:
seen["tasks"] = prompt
if "-gfix-" in key:
seen["auftraege"] = prompt
return gb.FAILED, None
return gb.OK, payload((0, '{"ok": true}', "")) # Lese-Check: keine Probleme
if key.endswith("-re"):
raise AssertionError("unkritischer Befund darf keinen Re-Prüfer starten")
return gb.OK, payload((0, '{"ok": true}', "")) # Prüfer: keine LLM-Befunde
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
monkeypatch.setattr(gb, "READABILITY_ACTIVE", False)
assert await gb._stage_lesbarkeit(env, card)
assert "Länge" in seen["tasks"] and str(gb._writer_budget(1)) in seen["tasks"]
assert await gb._stage_pruefer(env, card)
assert (await db.list_guide_cards(TOPIC, FMT))[0]["stage"] == "fix"
card.update(stage="fix", gate_info=(await db.list_guide_cards(TOPIC, FMT))[0]["gate_info"])
assert await gb._stage_fix(env, card)
budget = gb.block_budget(env.subs_by_title["Alpha"])
assert "Länge" in seen["auftraege"] and f"etwa {budget} Zeichen GESAMT" in seen["auftraege"]
async def test_repair_karten_setzt_befundkarten_auf_pruefer(testdb, tmp_path, monkeypatch):
"""Guide-Repair: Karten mit QA-Befunden (alle Befundklassen-Formate) → pruefer,
md bleibt; Karten ohne Befund unangetastet. Kein Report → leere Liste."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
assert await gb.repair_karten(TOPIC, FMT) == [] # kein Report
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000000.json").write_text(_json.dumps({
"marker_fehlend": ["Alpha · sub eins"],
"fachlich_falsch": ["Beta"],
"ziel_ohne_anker": [], "laengen_ausreisser": [], "redundanz": [], "lesbarkeit": [],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "pruefer"
assert cards["alpha"]["md"] # Text bleibt — der Prüfer arbeitet auf dem Bestand
assert cards["gamma"]["stage"] == "done"
async def test_repair_karten_stil_befunde_direkt_auf_fix(testdb, tmp_path, monkeypatch):
"""Nur-Stil-Befunde (redundanz/laengen/lesbarkeit) → direkt auf `fix` mit
Auftragszeilen im gate_info (KEIN KRITISCH-Präfix → ein Rewrite ohne Re-Prüfer);
die Redundanz-Zeile trägt den Cross-Karten-Kontext, den der Prüfer nie sieht."""
import json as _json
import qa as qa_mod
db = testdb
monkeypatch.setattr(qa_mod, "QA_DIR", tmp_path / "qa")
for n in ("alpha", "beta", "gamma"):
await db.upsert_guide_card(TOPIC, FMT, n, n.title())
await db.set_guide_card(TOPIC, FMT, n, stage="done", status="ok", md="<!-- section: X -->\nText")
tdir = tmp_path / "qa" / TOPIC
tdir.mkdir(parents=True)
(tdir / "guide-20260705-000001.json").write_text(_json.dumps({
"marker_fehlend": [], "fachlich_falsch": ["Gamma"], "ziel_ohne_anker": [],
"redundanz": [{"a": "Alpha: gemeinsamer Absatztext", "b": "Beta: gemeinsamer Absatztext"}],
"laengen_ausreisser": [{"block": "Beta", "zeichen": 9000, "budget": 3000}],
"lesbarkeit": ["Alpha: Sätze kürzen"],
}), encoding="utf-8")
betroffen = await gb.repair_karten(TOPIC, FMT)
assert sorted(betroffen) == ["Alpha", "Beta", "Gamma"]
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, FMT)}
assert cards["gamma"]["stage"] == "pruefer" # kritisch → Prüfer
assert cards["alpha"]["stage"] == cards["beta"]["stage"] == "fix"
assert not cards["beta"]["gate_info"].startswith("KRITISCH")
assert "BALLAST" in cards["beta"]["gate_info"] and "Alpha" in cards["beta"]["gate_info"]
assert "LÄNGE" in cards["beta"]["gate_info"] and "3000" in cards["beta"]["gate_info"]
assert "LESBARKEIT: Sätze kürzen" in cards["alpha"]["gate_info"]
async def test_fix_failed_behaelt_befunde(testdb, tmp_path, monkeypatch):
"""Scheitert der Fix, dürfen die Prüfer-Befunde nicht stumm verschwinden — sie
bleiben im gate_info sichtbar (vorher wurde gate_info geleert)."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-ff", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\nText."
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
assert "-gfix-" in key
return gb.FAILED, None
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fix(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done"
assert "offene Befunde" in karte["gate_info"] and "Claim c1" in karte["gate_info"]
async def test_repruefer_ausfall_wird_vermerkt(testdb, tmp_path, monkeypatch):
"""Fällt der Re-Prüfer aus (kein Cancel), wird das im gate_info vermerkt statt die
Karte stumm als geprüft durchzuwinken."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-ra", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\n" + "Text im Rahmen. " * 20
card = {"block_norm": "alpha", "block": "Alpha", "writer_rounds": 0, "md": md,
"stage": "fix", "gate_info": "KRITISCH\n- Claim c1 (falsch): korrigieren"}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout):
if "-gfix-" in key:
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(md.replace("Text im Rahmen.", "Korrigiert."))
return gb.OK, payload(None)
return gb.FAILED, None # Re-Prüfer fällt aus
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
assert await gb._stage_fix(env, card)
karte = (await db.list_guide_cards(TOPIC, FMT))[0]
assert karte["stage"] == "done" and "Korrigiert." in karte["md"]
assert "Re-Prüfer ohne Ergebnis" in karte["gate_info"]
async def test_progress_reporter_ueberlebt_db_fehler(testdb, monkeypatch):
"""Ein DB-Fehler beendet den Reporter nicht (der Fortschritt fror sonst still ein);
unveränderter Stand wird nicht erneut geschrieben."""
import asyncio
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
calls = {"n": 0}
writes = []
orig = db.guide_stage_counts
async def flaky(topic, fmt):
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("kaputt")
return await orig(topic, fmt)
async def fake_update(guide_id, **kw):
writes.append(kw["progress"])
monkeypatch.setattr(gb.db, "guide_stage_counts", flaky)
monkeypatch.setattr(gb.db, "update_guide", fake_update)
task = asyncio.create_task(gb._progress_reporter("g-pr", TOPIC, FMT, takt=0.01))
await asyncio.sleep(0.08)
task.cancel()
assert calls["n"] >= 3 # lief nach dem Fehler weiter
assert writes.count(writes[0]) == 1 # gleicher Stand nur einmal geschrieben
+16 -2
View File
@@ -35,10 +35,24 @@ def test_ziel_ohne_anker():
def test_laengen_ausreisser():
"""Budget-Band statt Festrahmen: zu dünn und zu dick fallen auf, ohne Budget kein Urteil."""
duenn = _card("Alpha", "<!-- ausführlich -->\nkurz")
ok = _card("Beta", "<!-- ausführlich -->\n" + "x" * 500)
out = gq.laengen_ausreisser([duenn, ok], {"alpha": {"s1"}, "beta": {"s1"}})
assert [x["block"] for x in out] == ["Alpha"]
dick = _card("Gamma", "<!-- ausführlich -->\n" + "x" * 2000)
ohne = _card("Delta", "<!-- ausführlich -->\nkurz")
budgets = {"alpha": 500, "beta": 500, "gamma": 500} # delta: kein Inventar → übersprungen
out = gq.laengen_ausreisser([duenn, ok, dick, ohne], budgets)
assert [x["block"] for x in out] == ["Alpha", "Gamma"]
assert out[1]["zeichen"] >= 2000 and out[1]["budget"] == 500
def test_budget_aus_substanz():
"""Dichte Subs bekommen mehr Budget; periphere zählen nicht."""
dicht = {"relevance": "relevant", "facts": {"key_points": ["a", "b"], "cited_facts": [{}], "example_idea": "x"}}
duenn = {"relevance": "relevant", "facts": {}}
peripher = {"relevance": "peripheral", "facts": {"key_points": ["a"] * 9}}
assert gq.sub_budget(dicht["facts"]) > gq.sub_budget(duenn["facts"])
assert gq.block_budget([dicht, duenn, peripher]) == gq.block_budget([dicht, duenn])
def test_redundanz_findet_absatz_doppel():
+48
View File
@@ -0,0 +1,48 @@
"""Guide-Ebene der Auto-Sequenz: generieren → QA → Repair-Loop bis 100 %/Stillstand."""
import blocks
import guide
import guide_qa
import guide_board
def _patch(monkeypatch, noten, calls):
async def fake_gen(*a, **k):
calls["gen"] += 1
async def fake_rep(topic, fmt):
calls["rep"] += 1
return []
async def fake_qa(topic, llm=False):
return {"note_guide": noten.pop(0) if len(noten) > 1 else noten[0]}
monkeypatch.setattr(guide, "generate_guide", fake_gen)
monkeypatch.setattr(guide_board, "repair_karten", fake_rep)
monkeypatch.setattr(guide_qa, "guide_qa_report", fake_qa)
async def test_guide_fertig_ohne_repair(testdb, monkeypatch):
blocks._blocks_errors.pop("G1", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [10.0], calls)
await blocks._guide_ebene("G1", "", "claude", lambda: False, lambda *a, **k: None)
assert calls["gen"] == 1 and calls["rep"] == 0 # nur initial gebaut
assert "G1" not in blocks._blocks_errors
async def test_guide_repariert_bis_100(testdb, monkeypatch):
blocks._blocks_errors.pop("G2", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [7.0, 10.0], calls) # erst 7.0, nach einem Repair 10.0
await blocks._guide_ebene("G2", "", "claude", lambda: False, lambda *a, **k: None)
assert calls["rep"] == 1
assert "G2" not in blocks._blocks_errors
async def test_guide_stillstand_meldet_systemfehler(testdb, monkeypatch):
blocks._blocks_errors.pop("G3", None)
calls = {"gen": 0, "rep": 0}
_patch(monkeypatch, [6.0], calls) # Repair bewegt die Note nie
await blocks._guide_ebene("G3", "", "claude", lambda: False, lambda *a, **k: None)
assert "G3" in blocks._blocks_errors
assert "Guide" in blocks._blocks_errors["G3"]
-608
View File
@@ -1,608 +0,0 @@
"""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"]}
+44
View File
@@ -26,6 +26,7 @@ def test_convert_writes_markdown_txt(tmp_path):
def test_cache_skips_fresh_txt(tmp_path):
_mini_pdf(tmp_path / "a.pdf")
(tmp_path / ".pdf-txt-norm").write_text(str(blx._PDF_NORM_VERSION), encoding="utf-8")
marker = tmp_path / "a.txt"
marker.write_text("MARKER", encoding="utf-8")
now = time.time() + 60
@@ -34,6 +35,39 @@ def test_cache_skips_fresh_txt(tmp_path):
assert marker.read_text(encoding="utf-8") == "MARKER" # nicht neu konvertiert
def test_norm_version_erzwingt_rekonvertierung(tmp_path, monkeypatch):
"""Fehlender/alter .pdf-txt-norm-Marker ignoriert den mtime-Cache einmalig."""
_mini_pdf(tmp_path / "a.pdf")
stale = tmp_path / "a.txt"
stale.write_text("ALT", encoding="utf-8")
now = time.time() + 60
os.utime(stale, (now, now))
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
monkeypatch.setattr(blx, "_pdf_plaintext", lambda p: "neu konvertiert")
blx._convert_pdfs(tmp_path)
assert stale.read_text(encoding="utf-8") == "neu konvertiert"
assert (tmp_path / ".pdf-txt-norm").read_text(encoding="utf-8") == str(blx._PDF_NORM_VERSION)
stale.write_text("BLEIBT", encoding="utf-8")
os.utime(stale, (now, now))
blx._convert_pdfs(tmp_path) # Marker aktuell → Cache greift wieder
assert stale.read_text(encoding="utf-8") == "BLEIBT"
def test_entzerre_pdf_woerter():
"""Small-Caps-Splits mergen; Variablen-Schutzfall und unbelegte Paare bleiben."""
# Regel A inkl. Kette
assert blx._entzerre_pdf_woerter("Das H ITTING S ET Problem") == "Das HITTING SET Problem"
assert blx._entzerre_pdf_woerter("V ERTEX C OVER ist schwer") == "VERTEX COVER ist schwer"
# Schutzfall: Großlauf klein fortgesetzt → L ist Variable
assert blx._entzerre_pdf_woerter("die Sprache L NP-vollständig ist") == "die Sprache L NP-vollständig ist"
# Regel B: Paar nur mit Frequenz-Beleg (≥3× ungespalten im Dokument)
belegt = "N P ist zentral. " + "NP NP NP."
assert blx._entzerre_pdf_woerter(belegt).startswith("NP ist zentral.")
assert blx._entzerre_pdf_woerter("C Y bleibt getrennt") == "C Y bleibt getrennt"
# kein Match mitten im Wort
assert blx._entzerre_pdf_woerter("HALTTM IST hier") == "HALTTM IST hier"
def test_fallback_to_pdftotext(tmp_path, monkeypatch):
_mini_pdf(tmp_path / "b.pdf")
monkeypatch.setattr(blx, "_pdf_markdown", lambda p: None)
@@ -69,3 +103,13 @@ def test_fidelity_guard_prefers_faithful_plaintext():
assert blx._pick_conversion(None, plain)[1] == "pdftotext"
assert blx._pick_conversion(md_ok, None)[1] == "pymupdf4llm"
assert blx._pick_conversion(None, None) is None
def test_entzerre_hyphen_space():
"""Regel C: GROSSLAUF + Leerzeichen vor Bindestrich-Kompositum („NP -vollständig",
Makro-Spacing) wird gemerged; echter Gedankenstrich („X - y") bleibt."""
assert blx._entzerre_pdf_woerter("die NP -vollständigkeit gilt") == "die NP-vollständigkeit gilt"
assert blx._entzerre_pdf_woerter("Begriffe der NP -schwere und NP -vollständigkeit") == \
"Begriffe der NP-schwere und NP-vollständigkeit"
assert blx._entzerre_pdf_woerter("Term X - y bleibt") == "Term X - y bleibt"
assert blx._entzerre_pdf_woerter("Liste:\n-punkt eins") == "Liste:\n-punkt eins"
+102 -5
View File
@@ -1,5 +1,7 @@
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
import json
import qa
@@ -91,9 +93,53 @@ def test_note_kalibrierung():
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_dubletten_verdacht_zaehlt():
"""dubletten_verdacht bildet das Dopplungs-Nutzerproblem ab → zählt jetzt (Gewicht 1.0),
bleibt aber das leichteste Gewicht. 36 % Verdacht → −3.6 → 6.4; die Liste kann >1 sein → geklemmt."""
assert qa.note({"dubletten_verdacht": 0.0}) == 10.0
assert qa.note({"dubletten_verdacht": 0.36}) == 6.4
assert qa.note({"dubletten_verdacht": 2.0}) == 0.0 # >1 wird auf 1.0 geklemmt
# sub_dubletten_verdacht bleibt gewichtslos (Artefakt-Verdacht, kein Urteil)
assert qa.note({"sub_dubletten_verdacht": 1.0}, qa.NOTE_GEWICHTE_ARTEFAKTE) == 10.0
def test_note_konzept_luecken_zaehlt():
"""Fehlende benannte Kernresultate drücken die Note (Gewicht 1.5): 20 % → −3.0 → 7.0."""
assert qa.note({"konzept_luecken": 0.0}) == 10.0
assert qa.note({"konzept_luecken": 0.2}) == 7.0
def test_named_results_only_numbered(monkeypatch):
"""Benannte Ergebnisse: nur NUMMERIERTE Katalog-Referenzen mit Namen zählen — Prosa ohne
Nummer ('Satz von Goethe') und nackte Nummern ('Satz 7.18') liefern nichts (generisch)."""
corpus = {"skript.txt":
"Satz 7.13 (Christofides) liefert eine 3/2-Approximation.\n"
"Satz 6.24: Satz von Cook und Levin.\n"
"Nach Satz 7.18 folgt daraus die Schranke.\n"
"Ein Satz von Goethe steht hier."}
named = qa._named_results(corpus)
assert "Christofides" in named
assert any("Cook" in n and "Levin" in n for n in named)
assert not any("Goethe" in n for n in named) # keine Nummer → kein False Positive
assert all(n.strip() for n in named)
def test_konzept_luecken_flags_missing():
"""Benanntes Kernresultat ohne Baustein = Lücke; ein gedecktes Resultat nicht."""
corpus = {"s.txt": "Satz 7.13 (Christofides). Satz 6.16 (Kriterium für P=NP)."}
named = qa._named_results(corpus)
blocks = [{"title": "Christofides-Algorithmus", "description": "3/2-Approximation für TSP"}]
gaps = qa.konzept_luecken(blocks, named)
assert "Christofides" not in gaps
assert any("P=NP" in g or "Kriterium" in g for g in gaps)
def test_konzept_luecken_declension_tolerant():
"""Andere Flexion im Baustein deckt das Resultat trotzdem — kein falscher Lücken-Alarm."""
named = qa._named_results({"s.txt": "Satz 7.6: Kriterium für Eulerschen Kreis."})
blocks = [{"title": "Kriterium für Eulerscher Kreis", "description": "Grad aller Knoten gerade"}]
assert qa.konzept_luecken(blocks, named) == []
def test_note_artefakte_getrennt():
@@ -225,7 +271,7 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
{"title": titel, "description": "d"})
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
async def fake_verdicts(template, topic, key, items):
async def fake_wave(template, topic, key, slot, items, **kw):
if template != "QA-Bausteine":
return {}
if key.startswith("bausteine-b2"): # Bestätiger sieht nur die Geflaggten
@@ -233,11 +279,30 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
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)
monkeypatch.setattr(qa, "judge_wave", fake_wave)
report = await qa.qa_report("t", llm=True)
assert report["unecht"] == ["Wackelkandidat"]
async def test_write_report_spiegelt_note_als_event(testdb, tmp_path, monkeypatch):
"""Report-JSONs liegen nur auf der Lauf-Maschine — write_report spiegelt Note/Quoten
als kind='qa'-Event in die DB, damit ein DB-Pull für die Run-Analyse reicht."""
db = testdb
monkeypatch.setattr(qa, "QA_DIR", tmp_path)
report = {"topic": "t", "run_id": "20260704-1452-b223", "note": 9.3, "note_artefakte": 8.0,
"quoten": {"luecken": 0.1}, "quoten_artefakte": {"verwaiste": 0.0}}
path = await qa.write_report(report)
assert path.stem == "20260704-1452-b223"
conn = await db.get_db()
row = await (await conn.execute(
"SELECT key, meta, run_id FROM events WHERE topic='t' AND kind='qa'")).fetchone()
assert row and row[0] == "20260704-1452-b223"
meta = json.loads(row[1])
assert meta["note"] == 9.3 and meta["note_artefakte"] == 8.0
assert meta["quoten"] == {"luecken": 0.1} and meta["quoten_artefakte"] == {"verwaiste": 0.0}
assert row[2] == "" # manuelle QA ohne Lauf → leeres run_id ist korrekt
async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
"""DELETE /topics räumt auch storage/qa/<topic>/ — Reports gehören zum Topic."""
import routes
@@ -248,3 +313,35 @@ async def test_topic_delete_entfernt_qa_ordner(testdb, tmp_path, monkeypatch):
(qdir / "alt.json").write_text("{}", encoding="utf-8")
await routes.remove_topic("t")
assert not qdir.exists()
def test_luecken_key_stabil_gegen_whitespace():
"""Freispruch-Schlüssel: Datei + normalisierter Vorschau-Hash — Whitespace-Varianten
derselben Fundstelle mappen auf denselben Key, andere Vorschau nicht."""
a = {"datei": "f.txt", "abschnitt": 3, "vorschau": "Der Satz\nvon Foo"}
b = {"datei": "f.txt", "abschnitt": 7, "vorschau": "der satz von foo"}
c = {"datei": "f.txt", "abschnitt": 3, "vorschau": "ganz anderer Text"}
assert qa.luecken_key(a) == qa.luecken_key(b)
assert qa.luecken_key(a) != qa.luecken_key(c)
assert qa.luecken_key(a).startswith("f.txt||")
def test_offene_luecken_filtert_freispruch_und_nein():
rep = {"luecken": [{"datei": "f", "vorschau": "a", "llm": "ja"},
{"datei": "f", "vorschau": "b", "llm": "nein"},
{"datei": "f", "vorschau": "c", "freispruch": True}],
"konzept_luecken": ["Satz von Foo"]}
lk, kl = qa.offene_luecken(rep)
assert [x["vorschau"] for x in lk] == ["a"] and kl == ["Satz von Foo"]
def test_named_results_ueberspringt_umbrochene_saetze():
"""Doppelpunkt-Fang, der am Zeilenumbruch endet und klein weiterläuft, ist ein
umbrochener SATZ, kein Konzeptname — solche Phantom-Lücken kann kein Block ankern.
Echte Namen (auch \\n-terminiert mit großer Folgezeile) bleiben."""
text = ("Konsequenz von Satz 6.16: Wenn ein NP-vollständiges Entscheidungsproblem in P\n"
"liegt, dann sind alle NP Entscheidungsprobleme in P.\n\n"
"Satz 7.13 (Christofides). Beweis folgt.\n"
"Satz 6.24: Cook-Levin.\n"
"Satz 9.1: Vier-Farben-Satz\nDer Beweis nutzt Computer.\n")
assert sorted(qa._named_results({"f": text})) == ["Christofides", "Cook-Levin", "Vier-Farben-Satz"]
+103
View File
@@ -0,0 +1,103 @@
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
abzuwarten (gemessen: 4 Panel-Stalls à 160–230 s pro Lauf auf dem kritischen Pfad)."""
import asyncio
import pipeline
def _slot(payload=lambda r: r[1]):
return {"key": "k1", "prompt": "p", "role": "judge", "capabilities": "none", "payload": payload}
async def test_hedge_zwilling_rettet_stall(monkeypatch):
"""Original stallt → nach HEDGE_NACH_S startet der Zwilling (key -h), sein Ergebnis
gewinnt, das hängende Original wird gekillt."""
calls, killed = [], []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
return (0, "zwilling", "")
await asyncio.sleep(30) # Stall — würde sonst den ganzen Cap verbrennen
return (0, "original", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"]
assert "k1" in killed # das hängende Original läuft nicht weiter
async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch):
"""Kommt das Original doch noch vor dem Zwilling an, wird der Zwilling gekillt
und sein spätes Ergebnis nicht gewertet."""
killed = []
async def fake_agent(key, prompt, timeout, **kw):
await asyncio.sleep(0.3 if key.endswith("-h") else 0.15)
return (0, key, "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["k1"]
assert "k1-h" in killed
async def test_hedge_aus_bei_null(monkeypatch):
"""HEDGE_NACH_S=0 → kein Zwilling, Verhalten wie zuvor."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.1)
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
"""Die Schwelle ist max(HEDGE_NACH_S, timeout/2): ein gesunder Call, der länger als
die Untergrenze, aber kürzer als das halbe Timeout läuft, bekommt KEINEN Zwilling
(pauschale 90 s hedgten jeden normalen Fix-Call)."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.15) # > Untergrenze 0.05, < timeout/2 = 0.5
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 1.0, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
zusätzlichen Restart — der Zwilling ist der Retry."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
await asyncio.sleep(0.2)
return (0, "zwilling", "")
await asyncio.sleep(0.1)
return (1, "", "kaputt") # Fehler NACH dem Hedge-Start
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"] # kein dritter Spawn
+209 -7
View File
@@ -67,7 +67,7 @@ async def test_merge_confirmed_duplicate(env, monkeypatch):
calls.append(prompt)
return 0, '{"relevant": {"1": "ja"}}', ""
monkeypatch.setattr(repair, "run_agent", fake_agent)
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Problem"]
assert len(calls) == 1 and "Beta" not in calls[0] # nur das llm=ja-Paar zum Judge
@@ -86,9 +86,11 @@ async def test_fremd_removed_only_on_nein(env, monkeypatch):
write_report(_report(fremd=["Fremdling", "Echter"]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid über den strittigen „Echter": behalten
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
monkeypatch.setattr(repair, "run_agent", fake_agent)
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
monkeypatch.setattr(repair, "source_folder", lambda t: None)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == ["Fremdling"]
@@ -106,7 +108,7 @@ async def test_judge_failure_keeps_everything(env, monkeypatch):
async def broken_agent(key, prompt, timeout, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(repair, "run_agent", broken_agent)
import agents; monkeypatch.setattr(agents, "run_agent", broken_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
card = await db.kanban_get_card(TOPIC, "inventory", cid)
@@ -122,7 +124,7 @@ async def test_hygiene_cleans_title_norm_invariant(env, monkeypatch):
async def no_agent(*a, **kw):
raise AssertionError("Hygiene braucht keinen Agenten")
monkeypatch.setattr(repair, "run_agent", no_agent)
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["hygiene"] == ["**Fetter Titel** → Fetter Titel"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
@@ -175,7 +177,7 @@ async def test_sub_dubletten_merge(env, monkeypatch):
assert "Gibtsnicht" not in prompt
return 0, '{"relevant": {"1": "ja"}}', ""
monkeypatch.setattr(repair, "run_agent", fake_agent)
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Verlierer Sub → Gewinner Sub"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
@@ -197,13 +199,81 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', ""
monkeypatch.setattr(repair, "run_agent", fake_agent)
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == []
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub a"] == rows["sub b"] == "consensus"
async def test_sub_dubletten_stichentscheid_faltet(env, monkeypatch):
"""Dissens QA (Befund) vs. Zweitmeinung (behalten) → Stichentscheid-Judge (Key -st)
entscheidet mit 2:1 für den Befund → Merge. Vorher pendelte die Note dauerhaft
unter 10 ohne Fix-Pfad („keine behebbaren Befunde" trotz Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A",
facts='{"key_points": ["a"]}', status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
if "-st-" in key: # Stichentscheid bestätigt den QA-Befund
return 0, '{"relevant": {"1": "ja"}}', ""
return 0, '{"relevant": {"1": "nein"}}', "" # Zweitmeinung widerspricht
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == ["Sub B → Sub A"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, norm)}
assert rows["sub b"] == "variant" and rows["sub a"] == "consensus"
async def test_stichentscheid_behalten_persistiert_freispruch(env, monkeypatch):
"""2:1 „behalten" (Zweitmeinung + j3 einig gegen den QA-Befund) → Freispruch wird
persistiert und der Report des nächsten qa_report zählt das Paar nicht mehr —
vorher pendelte die Note dauerhaft unter 10 ohne Fix-Pfad."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "sub a", "Alpha", "Sub A", status="consensus")
await db.put_subblock(TOPIC, norm, "sub b", "Alpha", "Sub B", status="consensus")
write_report(_report(sub_dubletten=[{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Repair-Judges: behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["sub_merges"] == [] and len(res["freigesprochen"]) == 1
frei = qa_mod.lade_freispruch(TOPIC)
key = qa_mod._paar_key("[Alpha] Sub A", "[Alpha] Sub B")
assert key in set(frei.get("sub_dubletten") or [])
# QA-Seite: bestätigtes, aber freigesprochenes Paar zählt nicht in die Quote
sd = [{"a": "[Alpha] Sub A", "b": "[Alpha] Sub B", "llm": "ja"}]
frei_sub = set(frei["sub_dubletten"])
zaehlt = sum(1 for p in sd if p.get("llm") == "ja"
and qa_mod._paar_key(p["a"], p["b"]) not in frei_sub)
assert zaehlt == 0
async def test_fremd_stichentscheid_behalten(env, monkeypatch):
"""Dissens bei fremd, Stichentscheid sagt ebenfalls behalten (ja) → Block bleibt
(fail-open bei 1:2 gegen den Befund)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
write_report(_report(fremd=["Alpha"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', "" # beide: belegt/behalten
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["entfernt"] == []
async def test_waisen_cleanup(env, monkeypatch):
"""Artefakte/Fragen auf verworfene oder fehlende Subs fliegen; lebende und
mehrdeutig-präfixige bleiben."""
@@ -222,9 +292,141 @@ async def test_waisen_cleanup(env, monkeypatch):
async def no_agent(*a, **kw):
raise AssertionError("Aufräumen braucht keinen Agenten")
monkeypatch.setattr(repair, "run_agent", no_agent)
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC)
assert res["aufgeraeumt"] == 3
rest = {(r["sub_norm"], r["type"]) for r in await db.get_sub_artefakte(TOPIC)}
assert rest == {("sub0", "flashcard"), ("doppel", "example")}
assert not [r for r in await db.list_question_pattern(TOPIC)]
async def test_dubletten_verdacht_freispruch(env, monkeypatch):
"""Widerlegtes/unbeurteiltes Verdachtspaar bekommt den Klärungskanal: Judge sagt
behalten → Freispruch persistiert; der nächste qa_report zählt das Paar nicht mehr
(vorher zählte widerlegter Verdacht ewig in die Quote — Mess-Rauschen ohne Ausweg)."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
await seed("Alpha Kreis", "beschr")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # Judge: keine Dublette
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == [] and len(res["freigesprochen"]) == 1 and res["aktionen"] == 1
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod._paar_key("Alpha", "Alpha Kreis") in set(frei.get("dubletten") or [])
async def test_dubletten_verdacht_stichentscheid_merged(env, monkeypatch):
"""Verdachtspaar (llm=nein), aber Judge+Stichentscheid sagen beide Dublette →
Merge mit 2:1 gegen das QA-Urteil."""
db, seed, files, write_report = env
await seed("Alpha", "kurz")
await seed("Alpha Kreis", "deutlich längere Beschreibung — Gewinner")
write_report(_report(dubletten=[{"a": "Alpha", "b": "Alpha Kreis", "llm": "nein"}]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC)
assert res["merges"] == ["Alpha → Alpha Kreis"] and res["freigesprochen"] == []
async def test_beleg_nachfass_discard_und_datenfix(env, monkeypatch):
"""mentions=0-Subs: Judge bestätigt „kein Beleg" → Sub verworfen; Dissens mit
Stichentscheid „belegt" → mentions=1 nachgetragen (Datenfix, kein Freispruch)."""
db, seed, files, write_report = env
await seed("Alpha", "beschr")
norm = repair._norm_title("Alpha")
await db.put_subblock(TOPIC, norm, "ohne beleg", "Alpha", "Ohne Beleg", status="consensus")
await db.put_subblock(TOPIC, norm, "doch belegt", "Alpha", "Doch Belegt", status="consensus")
await db.set_subblock_fields(TOPIC, norm, "ohne beleg", mentions=0)
await db.set_subblock_fields(TOPIC, norm, "doch belegt", mentions=0)
write_report(_report(beleg={"subs_ohne_beleg": ["Alpha · Ohne Beleg", "Alpha · Doch Belegt"]}))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
if "-beleg-st-" in key:
return 0, '{"relevant": {"1": "ja"}}', "" # Stichentscheid: belegt
return 0, '{"relevant": {"1": "nein", "2": "ja"}}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "artefacts")
assert sorted(res["beleg_fix"]) == ["belegt: Alpha · Doch Belegt", "entfernt: Alpha · Ohne Beleg"]
rows = {r["sub_norm"]: r for r in await db.list_subblocks(TOPIC, norm)}
assert rows["ohne beleg"]["status"] == "discarded"
assert rows["doch belegt"]["status"] == "consensus" and rows["doch belegt"]["mentions"] == 1
async def test_fix_beschreibung_fuellt_leere(env, monkeypatch):
"""Hygiene „leere-beschreibung" → Sanierung schreibt EINE Beschreibung aus den
Auszügen; Titel bleibt unverändert."""
db, seed, files, write_report = env
cid = await seed("Leerling", "")
write_report(_report(hygiene=[{"titel": "Leerling", "probleme": ["leere-beschreibung"]}]))
monkeypatch.setattr(repair, "source_folder", lambda t: files["sidecar"].parent)
monkeypatch.setattr(repair, "_evidence_pack", lambda *a, **kw: "AUSZUG")
async def fake_agent(key, prompt, timeout, **kw):
assert "-sanierung-" in key
return 0, '{"title": "Leerling", "description": "Aus dem Material."}', ""
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "beschrieben: Leerling" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["description"] == "Aus dem Material."
async def test_fix_suffix_rename_rekeyt_alles(env, monkeypatch):
"""Kollisions-Suffix, Basis-Norm frei → Rename inkl. Re-Key von Subblocks/Fragen/
Artefakten + Board-2-Karte; danach keine Invarianten-Waisen."""
from invarianten import pruefe_invarianten
db, seed, files, write_report = env
cid = await seed("Gamma (2)", "beschr")
norm_alt = repair._norm_title("Gamma (2)")
await db.upsert_question_pattern(TOPIC, norm_alt, "sub0", "Gamma (2)", "Sub 0", "F?")
write_report(_report(hygiene=[{"titel": "Gamma (2)", "probleme": ["kollisions-suffix"]}]))
async def no_agent(*a, **kw):
raise AssertionError("Rename bei freier Basis braucht keinen Agenten")
import agents; monkeypatch.setattr(agents, "run_agent", no_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert "suffix: Gamma (2) → Gamma" in res["hygiene"]
card = await db.kanban_get_card(TOPIC, "inventory", cid)
assert card["payload"]["title"] == "Gamma" and card["payload"]["mirrored_norm"] == "gamma"
assert await db.kanban_get_card(TOPIC, "artefacts", norm_alt) is None
assert (await db.kanban_get_card(TOPIC, "artefacts", "gamma"))["payload"]["title"] == "Gamma"
assert {r["block_norm"] for r in await db.list_subblocks(TOPIC, "gamma")} == {"gamma"}
assert not await db.list_subblocks(TOPIC, norm_alt)
assert {r["block_norm"] for r in await db.list_question_pattern(TOPIC)} == {"gamma"}
befunde = await pruefe_invarianten(TOPIC) # DB-Sicht: keine Waisen nach dem Re-Key
assert not [b for b in befunde if "Waise" in b]
async def test_pruefe_luecken_freispruch(env, monkeypatch):
"""Offene Lücken + Konzept-Lücken: 2:1 „keine echte Lücke" → Freispruch persistiert;
_zaehlbare_luecken zählt freigesprochene Fundstellen nicht mehr."""
import qa as qa_mod
db, seed, files, write_report = env
await seed("Alpha", "beschr")
lk = [{"datei": "f.txt", "abschnitt": 1, "vorschau": "verwaister Abschnitt", "llm": "ja"}]
write_report(_report(luecken=lk, konzept_luecken=["Satz von Foo"]))
async def fake_agent(key, prompt, timeout, **kw):
return 0, '{"relevant": {"1": "nein"}}', "" # beide Judges: keine echte Lücke
import agents; monkeypatch.setattr(agents, "run_agent", fake_agent)
res = await repair.repair_befunde(TOPIC, "inventory")
assert len(res["freigesprochen"]) == 2
frei = qa_mod.lade_freispruch(TOPIC)
assert qa_mod.luecken_key(lk[0]) in set(frei.get("luecken") or [])
assert "satz von foo" in {s.casefold() for s in frei.get("konzept_luecken") or []}
lk[0]["freispruch"] = True
assert qa_mod._zaehlbare_luecken(lk, llm=True) == []
+55
View File
@@ -0,0 +1,55 @@
"""Link source = curated URL list (one page per line, no crawl following)."""
from pathlib import Path
import pytest
from fastapi import HTTPException
import blocks
from pipeline import GenContext
from routes import _validate_source
# --- _validate_source: per-line validation ---------------------------------
def test_validate_source_link_multiline_ok():
_validate_source("link", "https://a.com/x\nhttp://b.com/y") # no raise
def test_validate_source_link_rejects_bad_line():
with pytest.raises(HTTPException):
_validate_source("link", "https://a.com/x\nftp://bad")
def test_validate_source_link_rejects_empty():
with pytest.raises(HTTPException):
_validate_source("link", " \n ")
# --- _prepare_source: load every line, all pages as content, no triage -----
async def test_prepare_source_link_loads_all_urls_as_content(testdb, tmp_path, monkeypatch):
topic = "T"
folder = tmp_path / "source"
folder.mkdir()
captured = {}
def fake_load(urls, target, *, cancelled=None):
captured["urls"] = list(urls)
for i, u in enumerate(urls):
(Path(target) / f"p{i}.txt").write_text(f"QUELLE: {u}\n\nInhalt {i}", encoding="utf-8")
return len(urls)
monkeypatch.setattr(blocks, "load_pages", fake_load)
monkeypatch.setattr(blocks, "_convert_pdfs", lambda f: None)
monkeypatch.setattr(blocks, "_crawl_done", lambda t: False)
monkeypatch.setattr(blocks, "_step_idx", lambda t, n: 0) # step list needs source.json — irrelevant here
ctx = GenContext(topic=topic, provider="claude", is_cancelled=lambda: False)
q = {"type": "link", "location": " https://a.com/x \n\nhttps://b.com/y\n", "spec": ""}
ok = await blocks._prepare_source(ctx, lambda *a, **k: None, {"arbeit": tmp_path}, q, folder, "")
assert ok is True
assert captured["urls"] == ["https://a.com/x", "https://b.com/y"] # split + strip + blanks dropped
content = await testdb.list_content(topic)
assert len(content) == 2 # every page kept, no relevance triage
+18 -270
View File
@@ -1,16 +1,14 @@
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
"""Subbaustein-Helfer: Varianten-Cluster, Evidence-Packs, Outline-Review, Hash/Key-Auflösung.
Die verschmolzene Block-Pipeline (Generate/Verify/Artefakte) testet tests/test_block_calls.py."""
import json
import 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) ─────────────────────────────────────────────────────────
@@ -37,162 +35,6 @@ def test_variant_clusters_negation_guard():
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():
@@ -243,60 +85,8 @@ async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
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) ──────────────────────────────────────────
# ── Inline-Evidenz (Evidence-Packs für die verschmolzenen Calls) ─────────────────────
def _corpus(tmp_path):
d = tmp_path / "korpus"
@@ -353,70 +143,28 @@ def test_cited_evidence_lines_and_fallback(tmp_path):
def test_sink_json_writes_only_valid(tmp_path):
p = tmp_path / "level-final-c1.json"
from pipeline import _enum_map_schema
levels = _enum_map_schema("levels", ("beginner", "advanced", "expert"))
ok = blx._sink_json((0, 'Vorab {"levels": {"1": "beginner"}} nach', ""), p,
lambda d: blx._levels_schema(d, {1}))
lambda d: levels(d, {1}))
assert ok == {1: "beginner"}
assert json.loads(p.read_text(encoding="utf-8"))["levels"]["1"] == "beginner"
bad = blx._sink_json((0, "kein json", ""), tmp_path / "x.json", lambda d: d)
assert bad is None and not (tmp_path / "x.json").exists()
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_material_folder_fallbacks(monkeypatch, tmp_path):
"""Echte Quelle gewinnt; sonst arbeit/material mit Inhalt; sonst None."""
monkeypatch.setattr(blx, "source_folder", lambda t: tmp_path / "quelle")
assert blx.material_folder("t") == tmp_path / "quelle"
monkeypatch.setattr(blx, "source_folder", lambda t: None)
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
assert blx.material_folder("t") is None # kein Material-Ordner
md = tmp_path / "arbeit" / "material"
md.mkdir(parents=True)
assert blx.material_folder("t") is None # leer zählt nicht
(md / "research-1.txt").write_text("x", encoding="utf-8")
assert blx.material_folder("t") == md
def test_sub_key_resolves_short_titles():
+57
View File
@@ -0,0 +1,57 @@
"""Titel-Hygiene: klammer-bewusster Split + Dash-Normalisierung (M1.1).
Reale aak-Schadensfälle: ein en-Dash mit einseitigem Space INNERHALB eines Titels
wurde zum Separator → Split mitten im Titel („Aε-Algorithmus (Güte 1+ε, Laufzeit O(n3").
"""
from textkit import _norm_dash, _title, _split_top, _unclosed, _parse_selection
def test_norm_dash_top_level_only():
# einseitiger Space am Dash → Separator (Modelle liefern das so)
assert _norm_dash("Titel –Beschreibung") == "Titel — Beschreibung"
assert _norm_dash("Titel— Beschreibung") == "Titel — Beschreibung"
# Dash INNERHALB offener Klammer bleibt unangetastet (kein Separator)
assert _norm_dash("f (a – b) — Rest") == "f (a – b) — Rest"
# ASCII-Hyphen und geklebte/rangebasierte Dashes sind nie Separatoren
assert _norm_dash("3-SAT") == "3-SAT"
assert _norm_dash("12–15") == "12–15"
def test_split_top_bracket_aware():
assert _split_top("a (b — c) — d", " — ") == "a (b — c)"
assert _split_top("a — b", " — ") == "a"
assert _split_top("kein Separator hier", " — ") == "kein Separator hier"
def test_unclosed_bracket_detection():
assert _unclosed("a (b") is True
assert _unclosed("a [b {c") is True
assert _unclosed("a (b) c") is False
assert _unclosed("a) b") is False # überzähliger Schließer ist kein Abschnitt
def test_title_keeps_bracketed_dash():
"""Der Separator innerhalb der Klammer zerschneidet den Titel nicht mehr."""
t = _title("Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approx-Algorithmus")
assert t == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
def test_title_plain_split_unchanged():
assert _title("Vertex Cover — minimale Knotenüberdeckung") == "Vertex Cover"
assert _title("Ohne Separator bleibt ganz") == "Ohne Separator bleibt ganz"
def test_title_unclosed_bracket_keeps_entry():
"""Malformed: offene Klammer nie geschlossen → keinen abgeschnittenen Titel ausgeben,
stattdessen den ganzen Eintrag behalten (Repair = Split rückgängig)."""
e = "N P via Turingmaschine (Definition 6.19 — Rest"
assert _title(e) == e
def test_parse_selection_then_title_regression():
"""End-to-end der Parse-Kette (wie in der Pipeline): _norm_dash + _title lassen den
Klammer-Inhalt intakt statt mitten im Titel zu splitten (der reale aak-Bug)."""
line = "1. Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³)) — der Approximations-Algorithmus"
entries = _parse_selection(line)
assert _title(entries[1]) == "Aε-Algorithmus (Güte 1+ε – Laufzeit O(n³))"
+12 -12
View File
@@ -25,9 +25,9 @@ def test_registry_spiegelt_config():
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])"],
[sys.executable, "-c", "import config; print(config.GATE_FIX_MIN, config.TIMEOUTS['subblock_check'][0])"],
capture_output=True, text=True, cwd=BACKEND,
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"FACTS_CHUNK_SUBS": 6, "TIMEOUT_subblock_check_base": 77}'})
env={"PATH": "/usr/bin:/bin", "CREATOR_PARAMS": '{"GATE_FIX_MIN": 6, "TIMEOUT_subblock_check_base": 77}'})
assert out.stdout.split() == ["6", "77"], out.stderr
@@ -65,17 +65,17 @@ def _trainer(tmp_path, runner, f0, **kw):
async def test_aco_konvergiert_auf_optimum(tmp_path):
"""Gepflanztes Optimum (FACTS_CHUNK_SUBS=6) wird gefunden und bestätigt übernommen;
"""Gepflanztes Optimum (GEN_PANEL=3) wird gefunden und bestätigt übernommen;
die Pheromon-Spur konzentriert sich dort."""
def bewertung(params):
return _metrics(note=9.5, dauer=7.0) if params.get("FACTS_CHUNK_SUBS") == 6 else _metrics()
return _metrics(note=9.5, dauer=7.0) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=120)
best = await t.run()
assert best.get("FACTS_CHUNK_SUBS") == 6
taus = t.pheromon["FACTS_CHUNK_SUBS"]
assert max(taus, key=lambda k: taus[k]) == "6"
assert best.get("GEN_PANEL") == 3
taus = t.pheromon["GEN_PANEL"]
assert max(taus, key=lambda k: taus[k]) == "3"
async def test_uebernahme_braucht_bestaetigung(tmp_path):
@@ -83,7 +83,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
zustand = {"mal": 0}
def bewertung(params):
if params.get("FACTS_CHUNK_SUBS") == 6:
if params.get("GEN_PANEL") == 3:
zustand["mal"] += 1
return _metrics(note=9.5) if zustand["mal"] == 1 else _metrics(note=8.0)
return _metrics()
@@ -91,7 +91,7 @@ async def test_uebernahme_braucht_bestaetigung(tmp_path):
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=40)
best = await t.run()
assert best.get("FACTS_CHUNK_SUBS") != 6
assert best.get("GEN_PANEL") != 3
async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
@@ -111,16 +111,16 @@ async def test_f0_filter_verwirft_kaputte_kandidaten(tmp_path):
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()
return _metrics(note=9.5) if params.get("GEN_PANEL") == 3 else _metrics()
runner, f0 = _stub(bewertung)
t = _trainer(tmp_path, runner, f0, max_trials=60)
await t.run()
best, tau = t.best_params, dict(t.pheromon["FACTS_CHUNK_SUBS"])
best, tau = t.best_params, dict(t.pheromon["GEN_PANEL"])
runner2, f02 = _stub(bewertung)
t2 = _trainer(tmp_path, runner2, f02, max_trials=0) # kein Budget: alles aus Persistenz
assert t2.best_params == best
assert t2.pheromon["FACTS_CHUNK_SUBS"] == tau
assert t2.pheromon["GEN_PANEL"] == tau
async def test_budget_stoppt(tmp_path):
+58 -3
View File
@@ -3,12 +3,22 @@
No state, no IO — safe to import anywhere.
"""
import json
import re
import unicodedata
_CATEGORIES = ("KERN", "WICHTIG", "REST") # only for the legacy-format reader now
def parse_facts(raw) -> dict:
"""subblocks.facts ist ein JSON-Blob aus LLM-Hand — leer/kaputt/kein dict → {}."""
try:
d = json.loads(raw) if raw else {}
except (ValueError, TypeError):
return {}
return d if isinstance(d, dict) else {}
def _norm_title(s: str) -> str:
"""Normalize a title for key comparison.
@@ -22,8 +32,46 @@ def _norm_title(s: str) -> str:
return s.casefold()
# Bracket-aware separator handling: a ' — ' (or a dash that _norm_dash would normalize) INSIDE
# ()[]{} is part of the title, not a title/description separator. Without this a spaced dash within
# a math title truncates it: 'Aε-Algorithmus (Güte 1+ε, Laufzeit O(n³)) — …' → 'Aε-Algorithmus (Güte…'.
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
_CLOSERS = {v: k for k, v in _BRACKETS.items()}
def _unclosed(s: str) -> bool:
"""True if s has a dangling opening bracket ()[]{} — the symptom of a mid-bracket cut. Extra
closers (more ')' than '(') do NOT count; only an unclosed opener signals a truncated title."""
depth = 0
for c in s:
if c in _BRACKETS:
depth += 1
elif c in _CLOSERS and depth > 0:
depth -= 1
return depth > 0
def _split_top(s: str, sep: str) -> str:
"""First bracket-depth-0 segment of s split on sep; whole s if sep never occurs at depth 0."""
depth = 0
for i, c in enumerate(s):
if c in _BRACKETS:
depth += 1
elif c in _CLOSERS and depth > 0:
depth -= 1
elif depth == 0 and s.startswith(sep, i):
return s[:i]
return s
def _title(entry: str) -> str:
return entry.split(" — ")[0].strip() or entry
"""Title = text before the first TOP-LEVEL ' — '. A separator inside ()[]{} does not split
(keeps math titles intact); if the extracted title has an unclosed bracket (malformed source),
keep the whole entry rather than emit a truncated title."""
title = _split_top(entry, " — ")
if _unclosed(title):
return entry.strip() or entry
return title.strip() or entry
def clean_title(s: str) -> str:
@@ -64,6 +112,9 @@ def _resolve_title(idx: dict[str, int], t: str) -> int | None:
return idx.get(_norm_title(t)) or idx.get(_norm_title(_title(t)))
_DASH_SEP_RE = re.compile(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*")
def _norm_dash(s: str) -> str:
"""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
@@ -71,8 +122,12 @@ def _norm_dash(s: str) -> str:
space ("Titel —Beschreibung" / "Titel— Beschreibung") also breaks the split and leaks the source
filename into the description — so a dash with a space on either side is repaired too. The ASCII
hyphen "-" is deliberately NOT in the class (would split "n - 1"/"3-SAT"); requiring ≥1 surrounding
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched."""
return re.sub(r"\s*[‒–—―]\s+|\s+[‒–—―]\s*", " — ", s)
space keeps glued compounds like "Backtracking—Verfahren" and number ranges like "12–15" untouched.
A qualifying dash INSIDE an open bracket ()[]{} is left untouched — it is part of the title, not a
separator (else 'Algorithmus (Güte 1+ε – O(n³)) — …' would split mid-parenthetical)."""
def _repl(m):
return m.group(0) if _unclosed(s[:m.start()]) else " — "
return _DASH_SEP_RE.sub(_repl, s)
def _parse_selection(text: str) -> dict[int, str]:
+3 -2
View File
@@ -16,6 +16,7 @@ from pathlib import Path
# WICHTIG: config (mit CREATOR_PARAMS) lädt vor allen Pipeline-Modulen
import database
from config import DEFAULT_PROVIDER
from fake_agents import Welt, aktivieren
from fsutil import atomic_write_json
@@ -40,12 +41,12 @@ async def f0(out: str) -> None:
"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)
ctx = GenContext(topic="f0", provider=DEFAULT_PROVIDER, is_cancelled=lambda: False)
start = time.monotonic()
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "",
research=True, qa_force=True), timeout=180)
from tests.invarianten import pruefe_invarianten
from invarianten import pruefe_invarianten
fehler = await pruefe_invarianten("f0", files)
atomic_write_json(Path(out), {
"ok": bool(ok), "invarianten_fehler": fehler, "calls": len(welt.calls),
+1 -1
View File
@@ -105,7 +105,7 @@ async def _frozen_inventar(topic: str, vorlage: str) -> None:
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)
await board_inventory.reset_board_from_stage(topic, "artefacts", "generate", files)
async def _aufraeumen(topic: str) -> None:
+6 -15
View File
@@ -18,33 +18,24 @@ PARAMS: dict[str, dict] = {
"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"},
# Board 2, verschmolzene Calls (block_calls.py)
"GEN_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"VERIFY_PANEL": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"ART_SPLIT_SUBS": {"default": 20, "min": 10, "max": 40, "step": 5, "kategorie": "laufzeit", "fidelity": "board2"},
# Embedding-Schwellen (Auswahl-Kern)
"SUB_VARIANT_COS": {"default": 0.90, "min": 0.85, "max": 0.96, "step": 0.01, "kategorie": "auswahl", "fidelity": "board2"},
"SEED_COVER_COS": {"default": 0.80, "min": 0.7, "max": 0.9, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"SUB_DUP_KANDIDAT_COS": {"default": 0.75, "min": 0.65, "max": 0.85, "step": 0.02, "kategorie": "auswahl", "fidelity": "board2"},
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
"GROUP_THEMES_PER_SQRT": {"default": 1.0, "min": 0.6, "max": 1.6, "step": 0.2, "kategorie": "auswahl", "fidelity": "voll"},
# 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"},
"HEDGE_NACH_S": {"default": 90, "min": 30, "max": 240, "step": 30, "kategorie": "laufzeit", "fidelity": "board2"},
"EVIDENCE_BUDGET_CHARS": {"default": 48000, "min": 16000, "max": 64000, "step": 8000, "kategorie": "tokens", "fidelity": "board2"},
"QUELLE_RELEVANZ_CHUNK": {"default": 12, "min": 6, "max": 24, "step": 3, "kategorie": "laufzeit", "fidelity": "voll"},
}
+8 -3
View File
@@ -4,9 +4,14 @@ services:
context: .
container_name: creator
restart: unless-stopped
environment:
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
# komplette .env durchreichen — 3 Einzel-Vars ließen ROLE_*/MAX_CONCURRENT_* etc.
# still weg (Dev sourct die ganze .env, Prod bekam nur einen Teil)
env_file: .env
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
networks:
- web
volumes:
+49 -67
View File
@@ -1,6 +1,6 @@
<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, 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 { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
@@ -21,7 +21,7 @@ const darkMode = ref(
? window.matchMedia('(prefers-color-scheme: dark)').matches
: localStorage.getItem('darkMode') === 'true',
)
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
const blocks = ref({ ...EMPTY_BLOCKS })
const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude')
@@ -41,6 +41,13 @@ async function guard(label, fn) {
try { return await fn() } catch (e) { console.error(label, e) }
}
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
async function withUiError(fn) {
uiError.value = null
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
}
async function loadStats() {
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
}
@@ -175,12 +182,31 @@ async function loadBlocks() {
}
}
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
const autoInventory = ref(true)
const autoArtefacts = ref(true)
const autoGuide = ref(true)
function ladeAuto(topic) {
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
autoInventory.value = g('inventory')
autoArtefacts.value = g('artefacts')
autoGuide.value = g('guide')
}
function handleSetAuto({ ebene, value }) {
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
if (!r) return
r.value = value
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
}
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
ladeAuto(topic)
localStorage.setItem('lastTopic', topic)
loadBlocks()
}
@@ -192,28 +218,12 @@ watch(previewGuide, (g) => {
async function handleCancelBlocks() {
if (!selectedTopic.value) return
await apiCancelBausteine(selectedTopic.value)
await loadBlocks()
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleResetBlocks() {
if (!selectedTopic.value) return
await apiDeleteBausteine(selectedTopic.value)
await loadBlocks()
}
async function handleResetStage({ board, stage, restart = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
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()
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleAddResearch() {
@@ -231,18 +241,22 @@ async function handleAddResearch() {
async function handleRequeueDead() {
if (!selectedTopic.value) return
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
})
if (!ok) return
await loadBlocks()
startPolling()
}
async function handleBlocksClick({ instructions = '', research = true, qaForce = false }) {
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
} catch (e) {
uiError.value = e.message
return
@@ -292,7 +306,7 @@ function handleOpenBlocksView() {
previewGuide.value = null
}
async function handleFormatClick({ format, instructions = '', abStep = null }) {
async function handleFormatClick({ format, instructions = '' }) {
if (!selectedTopic.value) return
// No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some(
@@ -302,7 +316,7 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
if (running) { handleOpenGuideBoard(format); return }
uiError.value = null
try {
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
await apiCreate(selectedTopic.value, format, instructions, provider.value)
} catch (e) {
uiError.value = e.message
return
@@ -336,34 +350,6 @@ async function handleRemoveGuideFormat(format) {
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)
@@ -407,17 +393,12 @@ const polling = usePolling(
const startPolling = polling.start
async function handleCancel(guideId) {
await apiCancel(guideId)
await loadGuides()
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
}
async function handleDeleteTopic(topic) {
const topicGuides = guides.value.filter((g) => g.topic === topic)
for (const g of topicGuides) {
await deleteGuide(g.id)
}
await apiDeleteBausteine(topic)
await apiDeleteTopic(topic)
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
if (!await withUiError(() => apiDeleteTopic(topic))) return
await loadTopics()
if (selectedTopic.value === topic) {
selectedTopic.value = null
@@ -497,22 +478,23 @@ onMounted(async () => {
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
:error="blocks.error"
:guideFormat="guideBoardFormat"
:autoInventory="autoInventory"
:autoArtefacts="autoArtefacts"
:autoGuide="autoGuide"
@close="mainView = 'blocks'"
@resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
@addResearch="handleAddResearch"
@requeueDead="handleRequeueDead"
@removeAll="handleResetBlocks"
@cancel="handleCancelBlocks"
@cancelGuide="handleCancel"
@startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
@setAuto="handleSetAuto"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"
+94 -240
View File
@@ -1,305 +1,159 @@
const BASE = '/api'
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
async function jsonOrThrow(res) {
function qs(query) {
if (!query) return ''
const p = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) p.set(k, v)
}
const s = p.toString()
return s ? `?${s}` : ''
}
// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu
// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln.
async function req(path, { method = 'GET', body, query } = {}) {
const opts = { method }
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' }
opts.body = JSON.stringify(body)
}
const res = await fetch(`${BASE}${path}${qs(query)}`, opts)
if (!res.ok) {
let detail = `Fehler (HTTP ${res.status})`
try {
const data = await res.json()
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
} catch { /* kein JSON-Body */ }
throw new Error(detail)
const err = new Error(detail)
err.status = res.status
throw err
}
return res.json()
const text = await res.text() // DELETE/manche POSTs liefern keinen Body
return text ? JSON.parse(text) : null
}
export async function fetchGuides() {
const res = await fetch(`${BASE}/guides`)
return res.json()
}
export const fetchGuides = () => req('/guides')
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
const res = await fetch(`${BASE}/guides`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
})
return jsonOrThrow(res)
}
export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
export async function fetchActiveBlocks() {
const res = await fetch(`${BASE}/blocks/active`)
return res.json()
}
export const fetchActiveBlocks = () => req('/blocks/active')
export async function fetchBlocksStatus(topic) {
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
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, research, qa_force: qaForce }),
})
return jsonOrThrow(res)
}
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}, onlyArtefacts = false) =>
req('/blocks', { method: 'POST', body: {
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
only_artefacts: onlyArtefacts,
} })
// 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)
}
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
// 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, llm }),
})
return jsonOrThrow(res)
}
export const runQa = (topic, llm = true, ebene = null) => req('/blocks/qa', { method: 'POST', body: { topic, llm, ebene } })
// 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)
}
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
// 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)
}
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
// 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 const addBlocksResearch = (topic, provider = 'claude') =>
req('/blocks/research', { method: 'POST', query: { topic, provider } })
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 const removeGuideFormat = (topic, format) =>
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
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)
}
export const requeueBlocksDead = (topic) =>
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
// 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 const fetchGuideBoard = (topic, format = 'Guide') =>
req('/guides/board', { query: { topic, format } })
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)
}
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export const repairGuideBoard = (topic, format) =>
req('/guides/board/repair', { method: 'POST', body: { topic, format } })
export async function cancelBlocks(topic) {
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
export async function deleteBlocks(topic) {
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
}
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
// --- Block-Learning: Chat, Exam ---
export async function fetchBlockLearnState(topic) {
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
const res = await fetch(`${BASE}/blocks/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
})
return jsonOrThrow(res)
}
export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
export async function examBlock({
export const examBlock = ({
topic, block, section, section_compact = '', provider,
action = 'question', question = '', last_rating = '', avoid = [],
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
}) {
const res = await fetch(`${BASE}/blocks/exam`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
})
return jsonOrThrow(res)
}
}) => req('/blocks/exam', {
method: 'POST',
body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer },
})
export async function fetchQuestionPattern(topic, block) {
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
return jsonOrThrow(res)
}
export const fetchQuestionPattern = (topic, block) =>
req('/blocks/question-pattern', { query: { topic, block } })
export async function fetchTopicProgress(topic) {
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
export async function fetchStats() {
const res = await fetch(`${BASE}/stats`)
return res.json()
}
export const fetchStats = () => req('/stats')
export async function fetchProviders() {
const res = await fetch(`${BASE}/providers`)
return res.json()
}
export const fetchProviders = () => req('/providers')
export async function fetchFolders(kind) {
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
return jsonOrThrow(res)
}
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
export async function fetchSource(topic) {
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
export async function updateSource(topic, { type, ort = '', spec = '' }) {
const res = await fetch(`${BASE}/blocks/source`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, type, location: ort, spec }),
})
return jsonOrThrow(res)
}
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
export async function fetchBlocksCompleteness(topic) {
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
export async function fetchBlocksOverview(topic) {
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
export async function cancelGuide(id) {
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
}
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
export async function deleteGuide(id, slots = false) {
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
}
export const deleteGuide = (id, slots = false) =>
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
export async function fetchGuideContent(id, level = 4) {
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
if (!res.ok) throw new Error(`Content not available (${res.status})`)
return res.json()
}
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
// Ü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)
}
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
// 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)
}
export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ block, spot, snippet, hint, provider }),
})
return jsonOrThrow(res)
}
export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ block, spot, alt, revised, provider }),
})
return jsonOrThrow(res)
}
export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
// Reset a block's learning progress to zero (score/streak/flags/open question).
export async function resetBlockProgress(topic, block) {
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
method: 'DELETE',
})
return jsonOrThrow(res)
}
export const resetBlockProgress = (topic, block) =>
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
export async function fetchTopics() {
const res = await fetch(`${BASE}/topics`)
return res.json()
}
export const fetchTopics = () => req('/topics')
export async function createTopic(name) {
await fetch(`${BASE}/topics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
}
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
export async function deleteTopic(name) {
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
}
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
const res = await fetch(`${BASE}/guides/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, outline, messages, provider }),
})
return res.json()
}
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })
+45
View File
@@ -89,3 +89,48 @@
border: 1px solid var(--border-strong);
padding: 2px 6px;
}
/* Stufen-Segmente (Vollansicht): dezenter Rand + A/F/E-Badge. Global, weil TopicDetail
das Markup per v-html injiziert (scoped-Styles greifen dort nicht). */
.sub-stufe {
position: relative;
border-left: 3px solid color-mix(in srgb, var(--stufe-farbe) 55%, transparent);
background: color-mix(in srgb, var(--stufe-farbe) 8%, transparent);
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-stufe-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--stufe-farbe);
border: 1px solid color-mix(in srgb, var(--stufe-farbe) 60%, transparent);
border-radius: 4px;
padding: 0 4px;
opacity: 0.55;
}
/* Neu freigeschaltete Subbausteine (Auto-Stufe): kräftigerer Rand + Stufen-Badge */
.sub-neu {
position: relative;
border-left: 3px solid var(--neu-farbe);
background: color-mix(in srgb, var(--neu-farbe) 12%, transparent);
padding: 0.15rem 0.5rem 0.15rem 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-neu-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--neu-farbe);
border: 1px solid var(--neu-farbe);
border-radius: 4px;
padding: 0 4px;
opacity: 0.8;
}
+7
View File
@@ -0,0 +1,7 @@
/* Geteilte, komponentenübergreifende Stile (global, NICHT scoped). */
/* Live-Puls für „läuft"-Indikatoren — vorher als bk-/gb-/gen-/gen-side-pulse 4× kopiert. */
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
+43 -24
View File
@@ -1,7 +1,7 @@
<script setup>
import BlockPanel from './BlockPanel.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer } from '../levels.js'
import { stufeFuer, SUB_RANK, VIEW_KURZ, VIEW_FARBE } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
import { clearPruef } from '../pruefungCache.js'
import { useConfirm } from '../composables/useConfirm.js'
@@ -18,6 +18,7 @@ const props = defineProps({
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, beginner: 0, advanced: 0, expert: 0, master: 0 }) },
stufe: { type: Number, default: 4 }, // höchste sichtbare Stufe (1=A … 4=V) — wie die Standardansicht
})
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
@@ -98,7 +99,27 @@ const level = computed(() => {
const displayedText = computed(() =>
props.ansicht === 'compact' ? (props.block.compact || props.block.md) : props.block.md,
)
const blocks = computed(() => renderBlocks(displayedText.value))
// Stufen-Segmente: mit subs wird je Sub separat gerendert (Rand+Badge in Stufen-Farbe),
// ohne subs (Legacy) bleibt der flache Pfad. Globaler Block-Index läuft über alle
// Segmente durch — Check/Vorschläge arbeiten unverändert über Index + raw.
const segments = computed(() => {
const compact = props.ansicht === 'compact'
const subs = props.block.subs
let i = 0
const seg = (level, text) => ({ level, blocks: renderBlocks(text).map((b) => ({ ...b, i: i++ })) })
if (!subs || !subs.length) return [seg('', displayedText.value)]
const out = []
const anchor = compact ? (props.block.anker_compact || '') : (props.block.anchor || '')
if (anchor.trim()) out.push(seg('', anchor))
for (const sub of subs) {
const rank = SUB_RANK[sub.level] || 1
if (rank > props.stufe) continue // Stufen-Filter (wie die Standardansicht)
const body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
if (body && body.trim()) out.push(seg(sub.level, body)) // jede Stufe A/F/E/V markiert (Filter bleibt)
}
return out
})
const blocks = computed(() => segments.value.flatMap((s) => s.blocks))
// Field that edits are applied to (must match the displayed text).
const spot = computed(() => (props.ansicht === 'compact' && props.block.compact) ? 'compact' : 'ausführlich')
@@ -176,21 +197,26 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown">
<template v-for="(b, i) in blocks" :key="i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(i, $event)"></div>
<div v-if="suggestions[i]" class="block-vorschlag">
<div v-if="suggestions[i].running" class="bv-status">Check Section…</div>
<template v-else>
<p v-if="suggestions[i].error" class="bv-fehler">{{ suggestions[i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[i].revised)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Apply" @click="applyBlock(i)">✓</button>
<button class="bv-btn" title="Discard" @click="discardBlock(i)">✗</button>
<button class="bv-btn" :class="{ aktiv: suggestions[i].editOpen }" title="Add hint" @click="editBlock(i)">✏️</button>
</div>
<div v-if="suggestions[i].editOpen" class="bv-edit">
<input v-model="suggestions[i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(i)">↻</button>
<template v-for="(seg, si) in segments" :key="si">
<div :class="{ 'sub-stufe': !!seg.level }"
:style="seg.level ? { '--stufe-farbe': VIEW_FARBE[SUB_RANK[seg.level] || 1] } : null">
<template v-for="b in seg.blocks" :key="b.i">
<div class="md-block" v-html="b.html" @contextmenu.prevent="blockMenu(b.i, $event)"></div>
<div v-if="suggestions[b.i]" class="block-vorschlag">
<div v-if="suggestions[b.i].running" class="bv-status">Check Section…</div>
<template v-else>
<p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p>
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div>
<div class="bv-aktionen">
<button class="bv-btn ja" title="Übernehmen" @click="applyBlock(b.i)">✓</button>
<button class="bv-btn" title="Verwerfen" @click="discardBlock(b.i)">✗</button>
<button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)">✏️</button>
</div>
<div v-if="suggestions[b.i].editOpen" class="bv-edit">
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Zusatzinfo → erneut prüfen" @keyup.enter="sendBlockEdit(b.i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)">↻</button>
</div>
</template>
</div>
</template>
</div>
@@ -257,10 +283,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
font-size: 0.72rem; font-weight: 600;
border-radius: 999px; border: 1px solid; white-space: nowrap;
}
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
@@ -277,9 +299,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
);
}
.xp-seg { height: 100%; transition: width 0.3s ease; }
.xp-seg.gold { background: #d4af37; }
.xp-seg.lila { background: #8b5cf6; }
.xp-seg.gruen { background: var(--success-border); }
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
.fokus-btn {
display: inline-flex; align-items: center; justify-content: center;
+20 -37
View File
@@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
import { stufeFuer, malusRegel } from '../levels.js'
import { useChat, istUnten } from '../composables/useChat.js'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({
topic: { type: String, required: true },
block: { type: String, required: true },
section: { type: String, default: '' }, // detailed version
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
@@ -70,7 +71,7 @@ function tabClick(tab) {
// --- Block chat (ephemeral) ---
const chat = useChat((msgs) => chatBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
messages: msgs, provider: props.provider,
}))
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), ...payload,
})
if (run !== examRun) return
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
const pattern = takePattern()
const base = {
topic: props.topic, block: props.block, section: props.section,
section_compact: props.sectionKompakt, provider: props.provider,
section_compact: props.sectionCompact, provider: props.provider,
}
if (form === 'quiz' && pattern) {
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
@@ -381,7 +382,7 @@ async function quizAnswer() {
try {
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
selection: q.gewaehlt, correct, schwer: q.schwer,
})
@@ -412,7 +413,7 @@ async function clozeAnswer() {
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
})
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
@@ -451,7 +452,7 @@ async function quickEvaluate() {
examScroll()
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
})
if (mine !== evalRun) return
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
if (thorough) examLoading.value = true
try {
const res = await examBlock({
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
})
applyExam(res)
@@ -629,26 +630,8 @@ function onExamKey(e) {
<div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Block chat -->
<div v-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="bp-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking…</div>
</div>
<div class="bp-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
rows="2"
placeholder="Question about the block…"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
{{ chat.loading.value ? '✕' : '➤' }}
</button>
</div>
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
placeholder="Frage zum Baustein…" />
</div>
<!-- Exam: guided dialog -->
@@ -663,7 +646,7 @@ function onExamKey(e) {
<!-- Quiz: question + multiple choice (widget stays even at the cap — practice without points) -->
<template v-if="shownForm === 'quiz'">
<div v-if="!quizCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-quiz">
@@ -696,7 +679,7 @@ function onExamKey(e) {
<!-- Cloze: sentence with gap + input -->
<template v-else-if="shownForm === 'gaptext'">
<div v-if="!clozeCurrent" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
<div v-else class="bp-gap">
@@ -707,7 +690,7 @@ function onExamKey(e) {
id="bp-gap-input"
v-model="clozeCurrent.input"
:disabled="clozeCurrent.done"
placeholder="Term for the gap…"
placeholder="Begriff für die Lücke…"
@keyup.enter="clozeAnswer"
/>
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
@@ -744,7 +727,7 @@ function onExamKey(e) {
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked…</span>
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="submitThorough" />
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
<button class="bp-action" @click="thoroughMsg = null">×</button>
</div>
@@ -757,7 +740,7 @@ function onExamKey(e) {
</div>
<div v-if="examPhase === 'idle'" class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
</div>
@@ -767,11 +750,11 @@ function onExamKey(e) {
ref="examInputEl"
v-model="examInput"
rows="2"
placeholder="Answer — or ask if unclear…"
placeholder="Antwort — oder nachfragen…"
></textarea>
</div>
<div class="bp-actions">
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
<template v-else-if="examPhase === 'question_offen'">
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
@@ -831,8 +814,8 @@ function onExamKey(e) {
color: var(--text-muted);
}
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
.bp-chip.lila { background: color-mix(in srgb, var(--level-expert) 16%, var(--panel)); border-color: var(--level-expert); color: #6d28d9; }
.bp-chip.gold { background: color-mix(in srgb, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
.bp-panel {
margin-top: 0.6rem;
+12 -15
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { ref, computed, watch } from 'vue'
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
const props = defineProps({
topic: { type: String, required: true },
@@ -26,13 +27,10 @@ async function loadCompleteness() {
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 } }
// das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
const { start: startPoll } = usePolling(load, () => props.generating, 5000)
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
onUnmounted(stopPoll)
watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [
@@ -73,10 +71,10 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<div class="bk-view">
<header class="bk-head">
<h1>{{ topic }}</h1>
<span class="bk-sub">Blocks overview</span>
<span class="bk-sub">Bausteine-Übersicht</span>
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
<span class="bk-spacer"></span>
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
</header>
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
@@ -107,9 +105,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
</div>
</section>
<div v-if="loading" class="bk-empty-state">Loading…</div>
<div v-if="loading" class="bk-empty-state">Lädt…</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
<div v-else class="bk-grid">
<article
@@ -126,12 +124,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<span class="bk-level-label">{{ g.label }}</span>
<ul>
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthema — kommt später im „Rest">Rand</span>
</li>
</ul>
</div>
</div>
<p v-else class="bk-no-subs">No subblocks.</p>
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
</article>
</div>
</div>
@@ -180,9 +178,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: bk-pulse 1.2s ease-in-out infinite;
animation: pulse-soft 1.2s ease-in-out infinite;
}
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@@ -0,0 +1,88 @@
<script setup>
// Präsentationaler Chat (Nachrichtenliste + Typing + Eingabe). Die Mechanik (send/cancel/
// scroll/focus) lebt in useChat; hier wird nur das `chat`-Objekt gerendert. Ersetzt die
// zeichengleichen Chat-Blöcke in BlockPanel und TopicDetail.
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
chat: { type: Object, required: true }, // Rückgabe von useChat()
hint: { type: String, default: '' }, // Platzhaltertext bei leerer Historie
placeholder: { type: String, default: '' },
rows: { type: Number, default: 2 },
autoGrow: { type: Boolean, default: false },
maxHeight: { type: String, default: '320px' },
})
function onInput() {
if (props.autoGrow) props.chat.autoGrow()
}
</script>
<template>
<div :ref="chat.messagesEl" class="ct-messages" :style="{ maxHeight }" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="ct-hint">{{ hint }}</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
<div v-if="m.role === 'assistant'" class="ct-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="ct-msg user">{{ m.content }}</div>
</template>
<div v-if="chat.loading.value" class="ct-msg assistant ct-typing">Denkt nach…</div>
</div>
<div class="ct-input">
<textarea
:ref="chat.inputEl"
v-model="chat.input.value"
:rows="rows"
:placeholder="placeholder"
@input="onInput"
@keydown.enter.exact.prevent="chat.send"
></textarea>
<button
:disabled="!chat.input.value.trim() && !chat.loading.value"
:class="{ cancel: chat.loading.value }"
:title="chat.loading.value ? 'Abbrechen' : 'Senden'"
@click="chat.send"
>{{ chat.loading.value ? '✕' : '➤' }}</button>
</div>
</template>
<style scoped>
/* flex:1 füllt ein Panel mit fester Höhe (TopicDetail); maxHeight begrenzt sonst (BlockPanel-Tab). */
.ct-messages { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; overflow-y: auto; padding: 0.2rem; }
.ct-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
.ct-msg {
max-width: 85%;
padding: 0.4rem 0.6rem;
border-radius: 8px;
font-size: 0.85rem;
line-height: 1.4;
word-break: break-word;
}
.ct-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
.ct-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
.ct-typing { color: var(--text-faint); font-style: italic; }
.ct-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
.ct-input textarea {
flex: 1;
resize: none;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--input-bg, var(--panel));
color: var(--text);
font-family: inherit;
font-size: 0.85rem;
}
.ct-input textarea:focus { outline: none; border-color: var(--accent); }
.ct-input button {
flex: 0 0 auto;
width: 38px;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font-size: 1rem;
}
.ct-input button:disabled { opacity: 0.5; cursor: default; }
.ct-input button.cancel { background: var(--danger); }
</style>
+178 -158
View File
@@ -1,8 +1,12 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
@@ -10,112 +14,141 @@ const props = defineProps({
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
error: { type: String, default: null },
guideFormat: { type: String, default: 'Guide' },
autoInventory: { type: Boolean, default: true },
autoArtefacts: { type: Boolean, default: true },
autoGuide: { type: Boolean, default: true },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null)
let timer = null
const pollError = ref(null)
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ }
pollError.value = null
} catch (e) {
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen
else 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
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
function boardProgress(cols) {
const total = cols.reduce((n, c) => n + c.total, 0)
if (!total) return { value: 0, done: 0, total: 0 }
const last = Math.max(1, cols.length - 1)
let acc = 0
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
return { value: acc / total, done, total }
}
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
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 }
}
// Score 0–10 → „100 %"
const pct = (note) => Math.round(note * 10) + ' %'
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
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const latest = ref(null) // jüngster Lauf MIT Daten je Ebene — überlebt „Nur Artefakte"-Läufe
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const res = await fetchRuns(props.topic, 1)
run.value = res.runs[0] || null
latest.value = res.latest || null
} catch { run.value = null; latest.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
// Per-Ebene: Zeit + Tokens getrennt für Inventar vs. Artefakte. Live tickt nur die Ebene,
// deren Lauf der AKTUELLE ist (b.aktiv) — sonst zählt die alte Inventar-Zeile mit hoch.
function boardZeit(b) {
if (!b?.start) return null
const start = Date.parse(b.start)
const ende = b.aktiv ? now.value : Date.parse(b.ende || b.start)
return fmtRuntime((ende - start) / 1000)
}
function boardTokens(b) {
const t = b?.tokens
return t && (t.input || t.output) ? fmtTokens((t.input || 0) + (t.output || 0)) : null
}
const invStat = computed(() => latest.value?.inventory)
const artStat = computed(() => latest.value?.artefacts)
watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; latest.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
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 { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
function later(fn) { fn(); setTimeout(pollBoard, 600) }
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
const qaBusy = ref(false)
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
async function runQaClick() {
async function runQaClick(ebene = null) {
if (qaBusy.value) return
qaBusy.value = true
qaBusy.value = ebene || true // markiert die geklickte Ebene (Button-Label)
try { await runQa(props.topic, true, ebene) }
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
}
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
const repairInfo = ref({}) // { inventory, artefacts }
function repairText(r) {
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
return n === 0 ? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt`
}
// Auto an → „Befunde beheben"-Loop bis 100 % / Stillstand / 10×; sonst genau ein Durchlauf.
async function repairClick(ebene) {
if (repairBusy.value) return
const auto = ebene === 'inventory' ? props.autoInventory : props.autoArtefacts
const max = auto ? 10 : 1
repairBusy.value = ebene
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
let prev = null
try {
await runQa(props.topic)
for (let i = 0; i < max; i++) {
const r = await runRepair(props.topic, ebene)
const note = ebene === 'artefacts' ? r.note_artefakte : r.note
const suffix = max > 1 ? ` — Runde ${i + 1}${note != null ? ` (${Math.round(note * 10)} %)` : ''}` : ''
repairInfo.value = { ...repairInfo.value, [ebene]: repairText(r) + suffix }
if (note == null || note >= 10) break // 100 % erreicht
if (r.aktionen === 0 && prev != null && note <= prev) break // Stillstand: nichts getan, Note steht
prev = note
}
} catch (e) {
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
} finally {
qaBusy.value = false
pollBoard()
guideRefresh.value++
repairBusy.value = ''; pollBoard()
}
}
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()
}
function removeArtefactsClick() {
later(() => removeArtefacts(props.topic).then(pollBoard))
}
</script>
@@ -125,86 +158,74 @@ async function repairClick() {
<h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span>
<button class="gen-close" title="Close" @click="emit('close')">✕</button>
<button class="gen-close" title="Schließen" @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>
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
<section class="gen-section">
<div class="gen-steps-top">
<span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<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>
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')">⚠ {{ run.fails.length }} Fehler</span>
<div v-if="generating" class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
</div>
<button
v-if="dead.length"
v-if="dead.length && generating"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="later(() => emit('requeueDead'))"
>⟳ {{ dead.length }} dead</button>
</div>
<!-- Ebene Inventar -->
<div class="gen-board-label">
Inventar
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
<span v-if="boardZeit(invStat)" class="gen-run">⏱ {{ boardZeit(invStat) }}<template v-if="boardTokens(invStat)"> · {{ boardTokens(invStat) }} Tokens</template></span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
</label>
</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('inventory')">{{ qaBusy === 'inventory' ? 'QA läuft…' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert…' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button v-if="ready || partial || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard :columns="inventoryCols" :agents="(board?.agents || []).filter(a => a.ebene !== 'artefacts')" :generating="generating" />
<!-- Ebene Artefakte -->
<div class="gen-board-label">
Artefakte
<span v-if="qa && qa.note_artefakte != null" class="qa-note"
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
<span v-if="boardZeit(artStat)" class="gen-run">⏱ {{ boardZeit(artStat) }}<template v-if="boardTokens(artStat)"> · {{ boardTokens(artStat) }} Tokens</template></span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
</label>
</div>
<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 v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="!!qaBusy" @click="runQaClick('artefacts')">{{ qaBusy === 'artefacts' ? 'QA läuft…' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert…' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen — nur Artefakte, ohne Board 1" @click="emit('continueAll', { qaForce: true, onlyArtefacts: true })">Generieren</button>
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
title="Nur die Artefakte leeren — Inventar bleibt"
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard :columns="artefactCols" :agents="(board?.agents || []).filter(a => a.ebene === 'artefacts')" :generating="generating" />
</section>
<section class="gen-section">
@@ -212,12 +233,12 @@ async function repairClick() {
:topic="topic"
:format="guideFormat"
:refresh="guideRefresh"
:auto="autoGuide"
@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)"
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
/>
</section>
</div>
@@ -266,12 +287,15 @@ async function repairClick() {
}
.gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
margin: 0.7rem 0 0.3rem;
}
.gen-progress {
display: flex;
@@ -286,22 +310,25 @@ async function repairClick() {
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gen-pulse 1.2s ease-in-out infinite;
animation: pulse-soft 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-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.gen-step-actions {
display: flex;
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.auto-box input { cursor: pointer; margin: 0; }
.gen-act {
border: 1px solid var(--border-strong);
@@ -314,30 +341,23 @@ async function repairClick() {
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
.gen-act.ghost { color: var(--text-muted); }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: none;
letter-spacing: 0;
}
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
.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;
}
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
</style>
+169 -111
View File
@@ -1,125 +1,168 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard } from '../api.js'
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
format: { type: String, default: 'Guide' },
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
auto: { type: Boolean, default: true },
})
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
const board = ref(null)
let timer = null
const pollError = ref(null)
async function poll() {
try {
board.value = await fetchGuideBoard(props.topic, props.format)
} catch { /* Board noch leer */ }
pollError.value = null
} catch (e) {
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
watch(() => props.refresh, () => poll())
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
onUnmounted(stopPoll)
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
const generating = computed(() => !!board.value?.generating)
const columns = computed(() => board.value?.columns || [])
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
// QA-Badge nur wenn es ein Board gibt — sonst zeigt ein verwaister Report eine alte Note.
const qaNote = computed(() => (total.value ? board.value?.qa_guide : null))
// 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)
// Laufzeit + Tokens aus /api/runs — `latest.guide` ist der jüngste GUIDE-Lauf
// (der jüngste Lauf insgesamt kann ein Blocks-Lauf sein und zeigte hier dessen Zahlen).
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
run.value = (await fetchRuns(props.topic, 1)).latest?.guide || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => generating.value, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
watch(() => generating.value, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
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) }
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
const qaBusy = ref(false)
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; poll() }
}
function 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 removeClick() {
emit('removeFormat', props.format)
board.value = null // optimistisch: Karten + QA-Badge sofort weg, poll bestätigt
setTimeout(poll, 800)
}
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)
const repairBusy = ref(false)
const repairInfo = ref('')
// Wartet, bis die Guide-Regenerierung der offenen Karten fertig ist (für den Auto-Loop).
function warteBisFertig(timeoutMs = 300000) {
return new Promise((resolve) => {
const t0 = Date.now()
const iv = setInterval(async () => {
await poll()
if (!board.value?.generating || Date.now() - t0 > timeoutMs) { clearInterval(iv); resolve() }
}, 2000)
})
}
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)
// Auto an → Loop: Befunde beheben → regenerieren → QA, bis 100 % / Stillstand / 10×.
// Auto aus → genau ein Durchlauf (nur anstoßen).
async function repairClick() {
if (repairBusy.value) return
const max = props.auto ? 10 : 1
repairBusy.value = true
repairInfo.value = ''
let prev = null
try {
for (let i = 0; i < max; i++) {
const r = await repairGuideBoard(props.topic, props.format)
if (!(r.betroffen || []).length) {
repairInfo.value = i === 0 ? 'keine Befunde im letzten QA-Report' : `fertig nach ${i} Runde(n)`
break
}
repairInfo.value = `Runde ${i + 1}: ${r.betroffen.length} Abschnitt(e) → Prüfen…`
startPoll()
if (max === 1) break // ohne Auto: nur anstoßen, nicht auf Fertigstellung warten
await warteBisFertig() // Regenerierung abwarten
await runQa(props.topic) // neue Guide-QA messen
await poll()
const note = board.value?.qa_guide
if (note == null || note >= 10) { repairInfo.value = `100 % nach ${i + 1} Runde(n)`; break }
if (prev != null && note <= prev) { repairInfo.value = `Stillstand bei ${Math.round(note * 10)} %`; break }
prev = note
}
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
poll()
}
}
</script>
<template>
<section class="gb-board">
<div class="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 class="gen-board-label">
Guide · {{ format }}
<span v-if="qaNote != null" class="qa-note" :class="qaNote >= 10 ? 'ok' : 'bad'">QA {{ Math.round(qaNote * 10) }} %</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
</label>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gen-progress"><span class="gen-progress-dot"></span>{{ board.progress }}</div>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
<div 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 v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</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="generating" class="gen-bar">
<button class="gen-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</div>
<div v-else class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft…' : 'QA' }}</button>
<button v-if="total && qaNote != null && qaNote < 10" class="gen-act" :disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert…' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gen-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gen-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', removeClick)">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<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>
<ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
<div v-if="!total && !generating" class="gb-empty">Noch kein Board — «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
@@ -127,48 +170,63 @@ function resetHere() {
<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 {
.gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.2rem 0 0.3rem;
}
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: none;
letter-spacing: 0;
}
.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 {
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.gb-progress-dot {
.gen-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gb-pulse 1.2s ease-in-out infinite;
animation: pulse-soft 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-error { color: var(--danger); font-size: 0.82rem; text-transform: none; letter-spacing: 0; font-weight: 400; }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
.gb-act {
.gen-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
@@ -178,10 +236,10 @@ function resetHere() {
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); }
.gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
</style>
+9 -26
View File
@@ -1,24 +1,13 @@
<script setup>
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
// Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
import { fmtRuntime } from '../format.js'
const props = defineProps({
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false },
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>
@@ -29,26 +18,20 @@ function fmtRuntime(s) {
</div>
<div class="kb-cols">
<div
v-for="c in columns.filter(visible)"
v-for="c in columns"
:key="(c.board || '') + c.key"
class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
:class="{ active: c.total > 0, collapsed: !c.total }"
>
<button
class="kb-col-head"
:disabled="!selectable"
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
@click="selectable && emit('stageClick', c)"
>
<div class="kb-col-head" :title="c.label">
<span class="kb-col-label">{{ c.label }}</span>
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</button>
</div>
<ul v-if="c.cards && c.cards.length" class="kb-cards">
<li
v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="[k.status, { 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 })"
:class="k.status"
:title="k.info || k.title"
>
<div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
// Schmaler Fortschrittsbalken (0–1) mit Beschriftung und optionalem Hinweis.
defineProps({
value: { type: Number, default: 0 }, // 0..1
label: { type: String, default: '' },
hint: { type: String, default: '' }, // z. B. „Umfang wächst noch"
})
</script>
<template>
<div class="pb">
<div class="pb-track">
<div class="pb-fill" :style="{ width: Math.round(Math.min(1, Math.max(0, value)) * 100) + '%' }"></div>
</div>
<span v-if="label" class="pb-label">{{ label }}</span>
<span v-if="hint" class="pb-hint">{{ hint }}</span>
</div>
</template>
<style scoped>
.pb { display: flex; align-items: center; gap: 0.5rem; }
.pb-track { flex: 1; height: 6px; border-radius: 999px; background: var(--panel-soft); overflow: hidden; }
.pb-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s ease; }
.pb-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
.pb-hint { font-size: 0.74rem; color: var(--text-faint); white-space: nowrap; }
</style>
+73
View File
@@ -0,0 +1,73 @@
<script setup>
// Quellenauswahl (Typ-Buttons + Link-Feld/Ordnerwahl), geteilt zwischen dem Anlegen-Panel
// und dem Bearbeiten-Panel der Sidebar. v-model trägt { type, ort }.
const props = defineProps({
modelValue: { type: Object, required: true }, // { type, ort }
folders: { type: Object, default: () => ({}) }, // { projekt: [...], uni: [...] }
})
const emit = defineEmits(['update:modelValue', 'submit'])
const TYPES = [
{ key: 'thema', label: 'Thema' },
{ key: 'link', label: 'Link' },
{ key: 'projekt', label: 'Projekt' },
{ key: 'uni', label: 'Uni' },
]
function setType(key) {
emit('update:modelValue', { type: key, ort: '' }) // Typwechsel verwirft den alten Ort
}
function setOrt(ort) {
emit('update:modelValue', { ...props.modelValue, ort })
}
</script>
<template>
<div class="dlg-sources">
<button v-for="t in TYPES" :key="t.key" :class="{ active: modelValue.type === t.key }"
@click="setType(t.key)">{{ t.label }}</button>
</div>
<textarea
v-if="modelValue.type === 'link'"
class="dlg-input dlg-textarea" :value="modelValue.ort"
rows="4"
placeholder="https://… — ein Link pro Zeile"
@input="setOrt($event.target.value)"
/>
<select
v-else-if="modelValue.type === 'projekt' || modelValue.type === 'uni'"
class="dlg-input" :value="modelValue.ort"
@change="setOrt($event.target.value)"
>
<option value="" disabled>Ordner wählen…</option>
<option v-for="fo in (folders[modelValue.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
</template>
<style scoped>
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-input {
width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus { outline: none; border-color: var(--accent); }
select.dlg-input { cursor: pointer; }
.dlg-textarea { resize: vertical; min-height: 5rem; }
</style>
+31 -159
View File
@@ -2,10 +2,11 @@
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } from '../levels.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, BADGE_VIEW_RANK } from '../levels.js'
import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue'
import ChatTranscript from './ChatTranscript.vue'
const props = defineProps({
previewGuide: { type: Object, default: null },
@@ -35,8 +36,12 @@ 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)
// Auto = an die erreichte Badge-Stufe gekoppelt (gleiche Schwelle wie das Level-Abzeichen):
// Advanced-Badge → advanced-Subs sichtbar. Vorher hing es an `freie_level` (Sub-Zahl × 25),
// das bei vielen beginner-Subs SPÄTER umschaltet als der Badge (0.4 × cap) — dann zeigte der
// Badge „Advanced", die advanced-Inhalte blieben aber verborgen.
const st = levelOf(title)
return st ? (BADGE_VIEW_RANK[st.key] || 1) : 1
}
function htmlFor(s) {
@@ -54,10 +59,12 @@ function htmlFor(s) {
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>`)
if (props.stufeAnsicht === 'auto' && rank > 1 && rank === lvl) {
// gerade neu freigeschaltete Stufe: kräftigere Färbung (Rand), ohne Badge
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
} else {
parts.push(body)
// jede Stufe A/F/E/V leicht in ihrer Farbe kennzeichnen (Rand + Färbung), ohne Badge
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}">${body}</div>`)
}
}
h = parts.join('')
@@ -196,8 +203,7 @@ const chat = useChat((msgs) => {
section, outline, messages: msgs, provider: props.provider,
})
})
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
const autoGrow = () => chat.autoGrow()
const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
const chatOpen = ref(false)
const panelEl = ref(null)
@@ -332,6 +338,7 @@ function extractContext() {
:fortschritt="progress"
:status="learnstate[focusBlock.title]"
:cap="capOf(focusBlock.title)"
:stufe="viewLevelFor(focusBlock.title)"
:tab="focusTab"
:ansicht="ansichtModus"
:has-prev="focusIndex > 0"
@@ -352,30 +359,8 @@ function extractContext() {
<span>Questions about the guide</span>
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
</header>
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
<template v-for="(m, i) in messages" :key="i">
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="chat-msg user">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
rows="3"
placeholder="Ask a question…"
@input="autoGrow"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Cancel' : 'Send'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div>
<ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
placeholder="Frage stellen…" :rows="3" auto-grow max-height="none" />
</div>
</div>
</template>
@@ -463,8 +448,8 @@ function extractContext() {
font-weight: 600;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: color-mix(in srgb, #d4af37 20%, var(--panel));
border: 1px solid #d4af37;
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border: 1px solid var(--level-master);
color: #8a6d12;
}
@@ -527,26 +512,26 @@ function extractContext() {
/* Understood blocks (10/10): purple */
.block-done.understood {
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
border-color: #8b5cf6;
background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
border-color: var(--level-expert);
color: #6d28d9;
}
.guide-content .section-card.understood {
border-color: #8b5cf6;
border-top: 3px solid #8b5cf6;
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
border-color: var(--level-expert);
border-top: 3px solid var(--level-expert);
background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
}
/* Mastered blocks (master path 25/25): gold */
.block-done.mastered {
background: color-mix(in srgb, #d4af37 20%, var(--panel));
border-color: #d4af37;
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
border-color: var(--level-master);
color: #8a6d12;
}
.guide-content .section-card.mastered {
border-color: #d4af37;
border-top: 3px solid #d4af37;
background: color-mix(in srgb, #d4af37 8%, var(--panel));
border-color: var(--level-master);
border-top: 3px solid var(--level-master);
background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
}
/* Guides: cards carry the chapter accent color */
@@ -669,120 +654,7 @@ function extractContext() {
padding: 0 4px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 8px;
}
/* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
.chat-hint {
color: var(--text-faint);
font-size: 0.82rem;
text-align: center;
margin-top: 1rem;
}
.chat-msg {
max-width: 85%;
padding: 7px 11px;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-msg.markdown {
white-space: normal;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
align-items: stretch;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
min-height: 72px;
max-height: 200px;
overflow-y: auto;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.4;
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
flex-shrink: 0;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-input button.cancel {
background: var(--danger);
}
/* 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;
}
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
</style>
+25 -142
View File
@@ -1,7 +1,8 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { ref, computed } from 'vue'
import { useConfirm } from '../composables/useConfirm.js'
import { fetchSource } from '../api.js'
import SourceForm from './SourceForm.vue'
const props = defineProps({
topics: { type: Array, required: true },
@@ -179,10 +180,15 @@ async function toggleTopicPanel(t) {
}
}
function setEditType(t) {
editForm.value.type = t
editForm.value.ort = ''
}
// v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
const createSource = computed({
get: () => ({ type: form.value.sourceType, ort: form.value.sourceOrt }),
set: (v) => { form.value.sourceType = v.type; form.value.sourceOrt = v.ort },
})
const editSource = computed({
get: () => ({ type: editForm.value.type, ort: editForm.value.ort }),
set: (v) => { editForm.value.type = v.type; editForm.value.ort = v.ort },
})
function saveSource() {
if (!canSave.value || !editTopic.value) return
@@ -241,29 +247,12 @@ function saveSource() {
<!-- Create: inline expandable (no modal) -->
<div v-if="dlg" class="thema-panel">
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
<div class="dlg-sources">
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
</div>
<input
v-if="form.sourceType === 'link'"
class="dlg-input" v-model="form.sourceOrt"
placeholder="https://…" @keyup.enter="createTopic"
/>
<select
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
class="dlg-input" v-model="form.sourceOrt"
>
<option value="" disabled>Choose folder…</option>
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
<input class="dlg-input" v-model="form.name" placeholder="Themenname…" @keyup.enter="createTopic" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
<div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
</div>
</div>
<div class="provider-toggle" v-if="providers.length">
@@ -279,7 +268,7 @@ function saveSource() {
<div class="format-section" v-if="selectedTopic">
<div class="format-error ui-error" v-if="uiError">
<span class="format-error-text">{{ uiError }}</span>
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
</div>
<div class="progress-info" v-if="activeGenerations.length">
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
@@ -329,7 +318,7 @@ function saveSource() {
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
<div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button>
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div>
</div>
<div class="format-row ord-exam">
@@ -352,37 +341,20 @@ function saveSource() {
>
<div class="topic-row">
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)">▾</button>
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)">▾</button>
</div>
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
<p v-if="editLoading" class="dlg-hint">Loading…</p>
<p v-if="editLoading" class="dlg-hint">Lädt…</p>
<template v-else>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
<div class="dlg-sources">
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
</div>
<input
v-if="editForm.type === 'link'"
class="dlg-input" v-model="editForm.ort"
placeholder="https://…"
/>
<select
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
class="dlg-input" v-model="editForm.ort"
>
<option value="" disabled>Choose folder…</option>
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
</select>
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
<SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
<div class="dlg-actions">
<button
class="dlg-delete"
:class="{ armed: pendingConfirm === 'topic-' + t }"
@click="confirmDeleteTopic(t)"
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
</div>
</template>
</div>
@@ -626,21 +598,6 @@ function saveSource() {
}
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.format-section {
flex-shrink: 0;
@@ -658,9 +615,8 @@ function saveSource() {
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: gen-side-pulse 1.2s ease-in-out infinite;
animation: pulse-soft 1.2s ease-in-out infinite;
}
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ord-blocks {
order: 2;
@@ -771,28 +727,6 @@ function saveSource() {
.panel-btn.danger:hover { border-color: var(--danger); }
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
.format-x {
display: none;
color: var(--danger);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.format-name:hover .format-x {
display: inline;
}
/* Running/paused: always show × — there is no hover on touch */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.blocks-row.is-active .format-x {
display: inline;
}
.format-x.armed,
.format-error-x.armed,
.delete-topic.armed {
display: inline-block;
@@ -855,45 +789,6 @@ function saveSource() {
opacity: 1;
}
.format-actions {
display: flex;
gap: 2px;
margin-left: 6px;
}
.action-btn {
background: none;
border: 1px solid transparent;
border-radius: 4px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s;
}
.action-btn.play {
color: var(--success);
}
.action-btn.play:hover {
background: var(--success-soft);
border-color: var(--success-border);
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.action-btn:disabled:hover {
background: none;
border-color: transparent;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
@@ -938,18 +833,6 @@ function saveSource() {
}
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
.dlg-textarea { resize: vertical; min-height: 2rem; }
.dlg-sources { display: flex; gap: 0.3rem; }
.dlg-sources button {
flex: 1;
padding: 0.35rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
.dlg-actions button {
+19
View File
@@ -0,0 +1,19 @@
// Anzeige-Formatierer, geteilt über Board-Ansichten.
// Sekunden → „m:ss" / „h:mm:ss" (Laufzeit eines Laufs oder Agenten).
export function fmtRuntime(s) {
s = Math.max(0, Math.round(s || 0))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
// Token-Zahl kompakt (48.234 → „48,2k", 1.2M → „1,2M").
export function fmtTokens(n) {
n = n || 0
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')}M`
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', ',')}k`
return String(n)
}
+4 -8
View File
@@ -28,6 +28,10 @@ export function malusRegel(score, cap) {
return '−20'
}
// Reached badge level (LEVELS key) → view level 1..4, so unlocked SUBS match the badge:
// Advanced-Badge → advanced subs visible. Same threshold as stufeFuer, no drift.
export const BADGE_VIEW_RANK = { beginner: 1, advanced: 2, expert: 3, master: 4 }
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
@@ -35,11 +39,3 @@ 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)
}
+1
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/markdown.css'
import './assets/shared.css'
createApp(App).mount('#app')
+21
View File
@@ -0,0 +1,21 @@
You are the quality checker for the learning artefacts of ONE block of the topic "{topic}". Another agent produced question patterns and worked examples. Check both.
GROUND TRUTH — the supported facts (measure only against these):
{facts}
QUESTION PATTERNS (rows: (subblock) question):
{table}
{fehlend}
WORKED EXAMPLES (numbered; PROBLEM / STEPS / RESULT):
{examples}
TASKS:
1. **pattern** — return the CLEANED-UP final version of ALL patterns: each question EXACTLY ONE question mark, one thing, 1–2 sentences, neutral, hits the subblock's core, answerable from the facts without invented assumptions. Keep good ones unchanged; rephrase violations. Exactly one pattern per subblock; carry `block`/`subblock` over unchanged. Questions in GERMAN.
2. **pattern_ergaenzt** — for each subblock listed above as MISSING a question: create ONE pattern (same rules). Empty list if none are missing.
3. **examples_probleme** — object to an example ONLY if clearly faulty: calculation error, wrong inference, contradicts/invents beyond the facts, or would imprint a wrong path. Recompute yourself; conservative — when in doubt, keep. Give 1-based `index`.
Reply with ONLY the JSON — no code fences, no other text. Format:
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
"pattern_ergaenzt": [{{"block": "…", "subblock": "…", "question": "…"}}],
"examples_probleme": [{{"index": 2}}]}}
{extra}
+25
View File
@@ -0,0 +1,25 @@
Build the learning artefacts for the subblocks of ONE block of the topic "{topic}": per subblock ONE question pattern and ONE flashcard, plus a worked example where it carries.
BLOCK WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
{blocks}
1. **pattern** — exactly ONE question pattern per subblock (exam question pool):
- ONE concrete, concise question about the subblock's CORE concept, phrased neutrally (difficulty is set later by the learner's level). Exactly one question mark, one thing — no multi-parter, no "name three …". At most 1–2 sentences, no scenario build-up.
- Answerable from the supplied core points and cited facts. Assume NOTHING that is not in the facts (no "shown" code/diagrams, no invented values).
2. **cards** — exactly ONE flashcard per subblock (active recall):
- `question`: brief recall question testing exactly one key point (≤ 15 words). `answer`: the short, precise answer from the supported facts (≤ 25 words). No prose, no lead-in, invent nothing.
3. **examples** — a worked example ONLY where it carries understanding:
- `problem` (1 sentence) → `steps` (2–5 followable steps, right order) → `result` (1 sentence). Rely on `example_idea` and the supported facts; compute cleanly. A pure definition or meta-knowledge gets NO forced example — leave it out.
Common rules:
- `block`/`subblock` are exactly the titles above.
- **Mathematics ALWAYS as LaTeX**, never raw characters: inline `$…$`, longer set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$` instead. The supplied facts often contain raw Unicode math — convert it. Real code/paths/identifiers in backticks.
- ALL content in GERMAN.
Reply with ONLY the JSON as your final message — no code fences, no other text. EXACTLY this format:
{{"pattern": [{{"block": "…", "subblock": "…", "question": "…"}}],
"cards": [{{"block": "…", "subblock": "…", "question": "…", "answer": "…"}}],
"examples": [{{"block": "…", "subblock": "…", "problem": "…", "steps": ["…"], "result": "…"}}]}}
{extra}
@@ -1,25 +0,0 @@
You are a correctness checker for worked examples on the topic "{topic}". Another agent produced them — they are meant to demonstrate a **correct** solution to the learner. A wrong example imprints a wrong solution path. Find the faulty ones.
GROUND TRUTH — the supported facts (measure only against these):
{facts}
EXAMPLES TO CHECK (numbered; PROBLEM / STEPS / RESULT):
{examples}
Object to an example if ANY of these applies:
- **Calculation error** in a step (a number, a transformation, a unit).
- **Wrong inference:** a step does not follow from the previous one; the RESULT does not follow from the steps.
- **Not covered:** a step/value contradicts the facts or invents something not stated there.
- **Incomplete/misleading:** the path would lead the learner to a wrong conclusion.
Rules:
- Measure ONLY against the facts + internal logic. Recompute it yourself.
- **Conservative:** object only to what is **clearly** wrong. When in doubt, keep it.
- Give the 1-based number (`index`) of each faulty example.
Reply with ONLY the JSON — no other text, no code fences — one of the two:
{{"ok": true}}
{{"problems": [{{"index": 2}}, {{"index": 5}}]}}
Output no other text.
{extra}
-24
View File
@@ -1,24 +0,0 @@
Build a worked example for the subblocks of the topic "{topic}" — a fully worked-through case that carries understanding.
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (go through EVERY subblock):
{blocks}
A good worked example:
- **problem**: a concrete, small task/question about the subblock (1 sentence).
- **steps**: 2–5 followable steps from the problem to the solution. Each step a brief sentence, in the right order.
- **result**: the final result / the insight (1 sentence).
- Rely on `example_idea` and the supported facts of the subblock. Compute cleanly; invent no values that contradict the facts.
- **Only where it carries:** if a subblock cannot be sensibly shown with an example (a pure definition, meta-knowledge), LEAVE IT OUT — no forced example.
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer/worked-through formulas set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
Write `problem`, the `steps` and `result` in GERMAN.
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": "…"}}
]}}
Output no other text.
{extra}
-23
View File
@@ -1,23 +0,0 @@
Build ONE flashcard (question→answer) for active recall for each subblock of the topic "{topic}".
BLOCKS WITH SUBBLOCKS AND THEIR FACTS (process EVERY subblock):
{blocks}
A good flashcard:
- **question**: a brief recall question that tests exactly one key point (not "explain everything"). One question, one thing.
- **answer**: the short, precise answer — based on the supported facts/key points of the subblock. Invent nothing extra.
- Rely on the supplied facts. Where a supported fact exists, the answer must match it.
- Brief: question ≤ 15 words, answer ≤ 25 words. No prose, no lead-in.
- **Mathematics ALWAYS as LaTeX**, never as raw characters: inline `$…$` (e.g. `$T_A(n)$`, `$\Sigma^*$`, `$|x| = 5$`, `$O(n^d)$`, `$q_a$`), longer calculations set off as `$$…$$`. NO Unicode math substitutes (`≤`, `≥`, `Σ`, `δ`, `∈`, `⊆`, `×`) and no bare `_`/`^` — use `$\le$`, `$\ge$`, `$\Sigma$`, `$\delta$`, `$\in$`, `$\subseteq$`, `$\times$`, `$n^d$`, `$q_a$` instead. Real code/paths/identifiers (not math) in backticks.
- **The supplied facts often contain math as raw Unicode — convert it to LaTeX, do not take it over raw.**
Write `question` and `answer` in GERMAN.
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": "…"}}
]}}
Output no other text.
{extra}
@@ -27,5 +27,8 @@ HARD RULES:
- Address the learner directly, in clear German, no fluff.
- `$…$` ONLY for real mathematics. Code, paths, namespaces, file names, JSON and identifiers ALWAYS in backticks (`` `…` ``) — NEVER as bare text, NEVER in `$…$` (not `$Acme\Example$`, but `` `Acme\Example` ``).
CHECKER'S NOTES ON THE LAST VERSION:
{kritik_block}
Output ONLY this JSON (no other text):
{{"question": "exactly one short question"}}
+2
View File
@@ -1,5 +1,7 @@
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**?
Decide on the underlying CONCEPT — from title and description — not on wording alone. Merge only when A and B are EQUIVALENT (the same statement, mutually interchangeable); a special case, a restriction, or one direction of a relation is NOT equivalent → **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:
@@ -1,18 +1,17 @@
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.
Topic "{topic}". Theme blocks were formed by card-sorting a fine-grained item list. Some items were left unassigned. Your job: attach EACH remaining item to the theme it belongs to, so every item lands in a theme. This is organisation, not judgement — items are never discarded.
UMBRELLAS (parent — already-collected parts):
THEMES (heading — already-assigned members):
{umbrellas}
REMAINING STANDALONE BLOCKS (numbered):
REMAINING ITEMS (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.
## Rule
- Attach each remaining item to the ONE theme whose subject it shares — the theme a learner would meet it under (a variable/flag/field/instance/facet of that theme's model, system, setup, tool family, or category).
- Assign as many as you reasonably can. Only leave an item unassigned if it fits NO theme at all — it then becomes its own single-item block (a genuinely standalone concept).
- Each item goes into at most ONE theme.
Write ONLY the JSON file to: {out_path}
Format (`additions` may be empty; `umbrella` = the UMBRELLA index, `members` = standalone block numbers to attach):
Format (`additions` may be empty; `umbrella` = the THEME index, `members` = item numbers to attach):
{{"additions": [{{"umbrella": 0, "members": [8, 12, 34]}}]}}
+15 -24
View File
@@ -1,34 +1,25 @@
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.
Topic "{topic}". A previous step produced a flat list of learning items that is TOO FINE-GRAINED for a table of contents — single environment variables, single CLI flags, single config fields, near-synonyms. Your job is **card sorting**: group the items into a small set of **theme blocks**, so that EVERY item lands in exactly one theme. Nothing is discarded — grouping never loses an item; it only organises them.
**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.
Think of the result as chapters: ~{theme_lo}–{theme_hi} themes for a whole topic of this size ({n_items} items), each a coherent learning block that a learner meets as one unit. A theme with only one fitting item is fine (a genuinely standalone concept), but prefer pulling related items together over leaving many singletons.
CANDIDATES (your starting point):
CANDIDATES you may group (your starting point — a pre-clustered neighbourhood):
{candidates}
FULL BLOCK LIST (you may pull in ANY numbers below that are constituents of the same definition):
FULL ITEM LIST (you may pull in ANY numbers below that share the theme):
{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.
## How to form a theme
- **One coherent subject.** The members belong together because a learner would study them as one block — the parts/facets/instances of one model, system, setup, tool family, or category. Examples of the *kind* of grouping (not domain rules): many individual settings/variables/flags of one configuration → one „…-Konfiguration" theme; the parts of one setup → one „…-Setup" theme; a base concept plus its variants/instances → one theme.
- **Most specific home wins.** An item tied to ONE concrete object (an algorithm/theorem/counterexample FOR object X, a setting OF component X) belongs in X's theme — not in a technique/property family that also fits. A learner meets „the algorithm for X" while studying X, not in a generic „algorithms" chapter.
- **The title must be true of EVERY member.** Never assert a property or class in the theme title (a complexity claim, „deprecated …", „…-vollständige …") unless every single member has that property — otherwise pick a neutral title for the shared subject.
- **Lexical dissimilarity is NORMAL.** Facets of one theme often read very differently — that is not a reason to split them.
- **Title:** name the theme after the shared subject. A real self-contained heading; must NOT contain „ — " (reserved separator) — use „(…)" or „:".
- **Description:** name EVERY member explicitly (the next step recovers them as sub-points). E.g. „Environment-Konfiguration: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, …".
- **Members:** the item NUMBERS from the full list. Each number goes into exactly ONE theme; do not repeat a number across themes.
*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.
Assign as many items as you confidently can. Items you are unsure about you may leave out — a later completion step and a singleton fallback catch them; never force a bad fit.
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]}}]}}
Format (`umbrellas` = the themes; may be empty if nothing fits together):
{{"umbrellas": [{{"title": "Environment-Konfiguration", "description": "Konfiguration über Umgebungsvariablen: APP_ENV, APP_SECRET, DATABASE_URL, MAILER_DSN, .env-Dateien.", "members": [1, 2, 5, 12, 34]}}]}}
+1 -1
View File
@@ -14,7 +14,7 @@ What is NOT a block (→ discard, EVEN if a description is attached):
- Pure references WITHOUT substantive description: "Satz 7.18" (nothing meaningful after the —).
- Too vague or evidently fabricated.
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`.
**Reference title WITH real content → include AND rename:** If an entry has an uninformative title ("Satz 7.18", "Korollar 6.18", "Lemma 6.2") but the description describes a real concept → include it and give a speaking name (in GERMAN) drawn from the content in the `rename` field. In the `keep` entry the **original title** stays (for matching); the new name goes ONLY in `rename`. A source numbering is never a canonical title — even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …"), rename to the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering may remain in the description. A named theorem that states its own result stays a block — named after the RESULT, not the number.
Examples:
- "Clique-Member — k-Clique, die einen festen Knoten v enthält" → **keep** (standalone problem).
@@ -0,0 +1,20 @@
Topic "{topic}". A quality audit found GAPS: source-material passages and named results that NO existing inventory block covers. Your job: propose new learning blocks ONLY for these gaps — nothing else. An empty list is a valid answer (when the gaps are filler or already covered under another name).
EXISTING BLOCKS (do NOT re-propose these or near-synonyms of them):
{blocks}
UNCOVERED MATERIAL (gap passages and named results, with source excerpts):
{gaps}
Rules:
- One block per genuinely missing, teachable concept — self-contained, exam-relevant.
- Ground every proposal in the material above; never invent canon knowledge the material does not treat.
- Title: concrete concept name (max 8 words, no catalog references like "(Satz 6.33)").
- Description: ONE sentence naming what the block teaches, grounded in the material.
- Skip organizational text, prefaces, exercise scaffolding, duplicates of existing blocks.
Write ONLY the JSON file to: {out_path}
Format (`blocks` may be empty):
{{"blocks": [{{"title": "…", "description": "…"}}]}}
{extra}
+1
View File
@@ -10,6 +10,7 @@ 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.
- A source numbering is never a canonical title, even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium …") → treat such a current title as NOT good and rename to the concept/result; the numbering belongs in the description.
- When in doubt, confirm the current title.
Reply with ONLY the JSON — no code fences, no other text.
+1
View File
@@ -7,6 +7,7 @@ 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.
- The title is the CONCEPT name — a source numbering is never canonical. Even with a name attached ("Satz 7.13 (Christofides)", "Satz 7.6: Kriterium für Eulerschen Kreis"), pick or propose the concept/result ("Satz von Christofides", "Kriterium für Eulerschen Kreis"); the numbering belongs in the description, never in the title.
Reply with ONLY the JSON — no code fences, no other text.
+2
View File
@@ -12,6 +12,7 @@ PAIRS:
- 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.
- a **statement that RELATES several standalone objects** (an implication, a shared bound, a joint consequence — „Hypothese H ⇒ keine schnelle Lösung für X und Y") is its own entity, like a reduction. It is NEVER a duplicate of ONE of the objects it mentions — merging „X" into such a statement silently deletes X's own definition/properties from the inventory.
- **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**.
@@ -27,6 +28,7 @@ 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: „Partition" B: „Satz: ETH ⇒ kein 2^o(n) für Partition, SubSet Sum" → a problem vs. a joint bound over TWO problems → **nein**
- A: „VertexCover ≤ FVS" B: „VertexCover ≤ Δ-Cover" → same source, different target → different reductions → **nein**
- A: „Cliquenproblem" B: „Vertex-Cover-Problem" → different problems → **nein**
+4 -2
View File
@@ -8,9 +8,11 @@ Rules:
- Granularity: a block is a LEARNING UNIT, not a dictionary entry. Families learned together (e.g. the font-* properties, the modal verbs) are ONE block.
- Abstraction level (important): a block is self-contained — explainable WITHOUT presupposing ANOTHER block as its subject. These are problems, methods/algorithms, definitions, named theorems with their own statement. A detail that presupposes another concept belongs IN that concept's block (as a part), not as its own entry:
- A PROPERTY of a concept belongs to the concept: „Clique" is a block; „Clique ist NP-vollständig", „Clique ∈ NP", „Laufzeit von Clique" are parts of it — not their own entry.
- A PROOF/reduction component belongs to the theorem/reduction: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable" — not its own entry.
- A PROOF/reduction component or a constructed HELPER OBJECT belongs to the theorem/reduction it serves: „A-Komponente", „Dummy-Item", „αEnde", „Hilfsvariable", helper functions/weightings, helper schedules, constructed graphs/tours/gadgets — not its own entry.
- A single COMPUTED instance or result value belongs to the argument that produced it: a specific number, graph, tour, or schedule built inside one example/proof („OPT(…) = n", a concrete constructed input) is not its own entry.
- PARAMETER INSTANCES of the SAME argument are ONE block: the identical technique applied to different parameters or cases („… via k / via n / via |E|", „für n=1 / n=2") is a single block, not one entry per parameter.
- 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").
- A standalone REDUCTION between two problems, however, is its own block („3-SAT ≤ Clique"); likewise a named theorem that states its OWN result stays a block — named after the result, not the source numbering.
- NO categories, NO ranking, NO ordering by importance — only a flat, numbered list.
- 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).
+17
View File
@@ -0,0 +1,17 @@
The block below was distilled from source material for the topic "{topic}", but its wording drifted from the material: the title may use terms that never literally appear in the sources (translated, expanded, or spelling-corrected), and the description may be missing.
BLOCK:
Title: {title}
Description: {description}
MATERIAL EXCERPTS:
{excerpts}
Tasks:
- "title": If the title's terms do not literally appear in the excerpts, rewrite it using ONLY surface forms (exact spellings, even unusual ones) that appear in the excerpts — same meaning, same concreteness, max 8 words. If the title already matches the material wording, repeat it unchanged. Never broaden to a textbook term the excerpts don't use; NO catalog/reference brackets ("(Satz 6.33)", "(Kap. 4)"). A source numbering ("Satz 7.13", "Definition 6.19") is NOT a valid title even when it appears literally in the excerpts — use the concept/result name from the excerpts; the numbering may go into the description.
- "description": If the description is "(leer)", write ONE precise sentence grounded in the excerpts — no invented facts. Otherwise repeat it unchanged.
Reply with ONLY the JSON — no code fences, no other text.
Format:
{{"title": "…", "description": "…"}}
-26
View File
@@ -1,26 +0,0 @@
You are the fact-checker for the learning facts of the topic "{topic}". Another agent has extracted facts per subblock. Check ONLY the **backed facts** for truth — not the examples.
{source}
FACTS TO CHECK (per subblock):
{facts}
Check per subblock:
1. **Evidence fidelity**: Does each `cited_facts` entry appear that way in the source (accurate in substance)? Is the source citation correct? With source material (folder or excerpts): check against it. Without a source: is it established standard knowledge?
2. **Factual correctness**: Are formulas, values, definitions, signatures technically correct? A wrong formula/value is a defect.
3. **Fact vs. example**: Is a self-computed/invented example wrongly declared as `cited_facts`? That is a defect — it belongs in `example_idea`.
4. Do NOT check examples (`example_idea`) for source evidence — they are generative.
Note only REAL defects (wrong fact, wrong source, example disguised as a fact). Not matters of taste.
**Discard vs. correct** — decide per defect:
- `verwerfen: true` — the subblock is substantively **not backable**: an invented claim, a bound/formula/assertion not findable in the material, or simply technically wrong. The subblock is then REMOVED. Be sure — when in doubt, `false`.
- `verwerfen: false` — the core is right, only a fact/value/source is imprecise and correctable.
Reply with ONLY the JSON — no other text, no code fences.
Format — all in order:
{{"ok": true}}
Otherwise (subblock title EXACTLY as above):
{{"problems": [{{"subblock": "<exakter Subblock-Title>", "problem": "…", "discard": true}}]}}
{extra}
-30
View File
@@ -1,30 +0,0 @@
Extract the learning facts for each subblock of the topic "{topic}". These facts are the binding basis from which the guide text, the levels, and the exam questions are later created — they must be **correct**.
{source}
BLOCKS WITH SUBBLOCKS (process EVERY subblock):
{blocks}
Collect per subblock ONLY the essentials (superfluous material harms learning); write all field content in GERMAN (technical terms/code identifiers stay original):
- **key_points**: 1–3 concise statements — what must one understand about this subblock?
- **prerequisites**: what must one know beforehand (a half-sentence)? Empty if nothing.
- **hurdles**: typical beginner misconception (a half-sentence). Empty if none.
- **cited_facts**: hard facts (definitions, formulas, values, names, signatures) — **only what you can back up**. Each with a source:
- With a source file/script: cite **accurately in substance** and give the location (e.g. „Skript Def. 6.3, Z.66"). Invent no values, compute nothing yourself.
- Without a source (pure topic): only established standard knowledge; verify uncertain points via web search; source = „allgemein" or the URL.
- **example_idea**: ONE example that carries understanding — freely phrased. **Here** is where self-formed sentences, mini-scenarios, worked examples belong. Empty if an example adds nothing.
HARD SEPARATION — important:
- `cited_facts` = only backable material from the source/established knowledge. **NEVER** output a self-computed or invented example as a backed fact.
- A worked example, an invented sentence, a constructed case → belongs in `example_idea`, not in `cited_facts`.
- When in doubt: better to leave out than to claim falsely.
Reply with ONLY the JSON as your final message — no code fences, no prose around it. Do NOT write any file; your tools are for research only. EXACTLY this format:
{{"facts": [
{{"block": "<exakter Block-Title>", "subblock": "<exakter Subblock-Title>",
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
]}}
Output no other text.
{extra}
-25
View File
@@ -1,25 +0,0 @@
You are filling **gaps**. For each subblock of the topic "{topic}", the facts already captured are listed below. A first pass extracted them — and experience shows backable material slips through. Your task: add **only what is missing** from the source.
{source}
SUBBLOCKS WITH FACTS ALREADY CAPTURED:
{blocks}
Rules:
- Output **only NEW** points per subblock that are backed by the source and still **missing** above.
- **Repeat nothing** from what is already "captured" — not even rephrased. If it's already there → leave it out.
- **Only backable material.** With a source file/script: cite accurately in substance + give the location. Invent no values, compute nothing yourself. Without a source file (pure topic): only established standard knowledge.
- **HARD SEPARATION:** `cited_facts` = only backable material. A self-computed/invented example belongs in `example_idea`, NEVER in `cited_facts`.
- Add `key_points` only if an **essential** aspect is missing — no duplications, no trivialities.
- **If you find nothing new for a subblock → leave it out.** If you find nothing at all → empty list. Better nothing than fabrication.
- Write all field content (key_points, prerequisites, hurdles, cited_facts text, example_idea) in GERMAN, matching the source material (technical terms/code identifiers stay original).
Reply with ONLY the NEW facts as ONE JSON in your final message — no code fences, no file writing (tools are for research only). EXACTLY this format:
{{"facts": [
{{"block": "<exakter Block-Title>", "subblock": "<exakter Subblock-Title>",
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
]}}
Output no other text.
{extra}
-19
View File
@@ -1,19 +0,0 @@
Coverage gate ("Was fehlt?") for ONE written guide section on the topic "{topic}": check the text against its learning objectives. Objective without content = gap; content without objective = ballast.
LEARNING OBJECTIVES of block "{block}":
{ziele}
SECTION — current content (subblocks are marked with `<!-- sub: … -->`):
{section}
Procedure:
1. For EACH objective decide binary: does the text teach it well enough that a beginner could achieve the objective afterwards? Mentioning a keyword is NOT teaching — the how/why must be there.
2. For each NOT-covered objective state precisely WHAT is missing (German, concrete — the writer will patch exactly this).
3. List BALLAST: passages that serve none of the objectives (digressions, redundant repetition). Shortening candidates only — never a whole subblock. Worked-example passages that apply a concept belonging to an objective are teaching, not ballast.
4. Judge strictly binary per objective; no partial credit.
Write ONLY the JSON file to: {out_path}
Format (`ziele` maps EVERY objective id to true/false):
{{"ziele": {{"z1": true, "z2": false}}, "luecken": [{{"ziel": "z2", "fehlt": "…"}}], "ballast": ["passage …"]}}
{extra}
-19
View File
@@ -1,19 +0,0 @@
Remove unsupported claims from ONE guide section on the topic "{topic}" — minimal edit, everything else stays VERBATIM.
SECTION (block "{block}") — current content:
{section}
UNSUPPORTED CLAIMS (from the fact gate) — ONLY these may be touched:
{claims}
VERIFIED FACTS (the only allowed factual basis — for rephrasing, if a claim can be corrected instead of removed):
{facts}
Rules:
- Per claim: correct it IF the verified facts state the right version; otherwise DELETE it (smooth the surrounding sentence so the text stays fluent).
- Everything else stays word-for-word identical — no rewriting, no shortening, no new content.
- STRUCTURE INVARIANT (mandatory, the level filter dies without it): keep ALL marker lines exactly — `<!-- kapitel: … -->`, `<!-- section: … -->`, `<!-- compact -->`, `<!-- ausführlich -->` and every `<!-- sub: LABEL | title -->` in BOTH blocks, same order. Never delete a whole subblock; if all its claims fall, keep a minimal supported sentence.
- German text, same tone as the original.
Write ONLY the file {out_path} — the COMPLETE corrected section in exactly the original marker format.
{extra}
-23
View File
@@ -1,23 +0,0 @@
Fact-check ONE written guide section on the topic "{topic}" — Chain-of-Verification style, binary per claim.
SECTION (block "{block}"):
{section}
VERIFIED FACTS — the ONLY allowed factual basis (extract-once from the source):
{facts}
Procedure:
1. Decompose the section text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics (transitions, framing, mnemonic phrasing).
2. Worked-example passages (a concrete problem worked through in steps to a result) are DIDACTICS when they merely APPLY or ILLUSTRATE a verified fact or a provided worked example: their concretely chosen values and computed intermediates do NOT count as over-specific. Flag an example step ONLY if it contradicts the facts or smuggles in a NEW general claim not derivable from them.
3. CONTEXT sentences are DIDACTICS too, not claims: introductions and summaries that only preview/recap the section, uncontroversial general knowledge that merely places the topic (history, origin, what an adjacent well-known technology is), and paraphrases of the verified facts. Ignore them — a guide needs connective tissue. This exemption ends the moment a sentence makes a checkable statement about THIS block's own syntax, behavior or rules: that is a claim.
4. Check EACH claim INDIVIDUALLY and BINARY against the VERIFIED FACTS above: derivable from them → belegt. Not derivable, contradicting, or over-specific beyond the facts → nicht belegt. For unsupported claims set "urteil": **"falsch"** when the claim CONTRADICTS the verified facts or contradicts the section itself (e.g. a rule its own example violates); **"unbelegt"** when it is merely not derivable from the facts.
5. Do NOT search the web, do NOT use outside knowledge as EVIDENCE — a claim about the block that is true in the world but absent from the facts is still "nicht belegt".
6. When a sentence is genuinely ambiguous between context and claim → treat it as a claim (safety before cost).
Write ONLY the JSON file to: {out_path}
Format — everything supported:
{{"ok": true}}
Otherwise (ONLY unsupported claims, VERBATIM as they appear in the text — NEVER list supported ones):
{{"claims": [{{"text": "verbatim claim from the section", "grund": "why unsupported (German, short)", "urteil": "falsch|unbelegt"}}]}}
{extra}
+27
View File
@@ -0,0 +1,27 @@
Revise ONE guide section on the topic "{topic}" (format: {format_name}) in a single pass — fix exactly the findings below, everything else stays unchanged in substance.
SECTION (block "{block}") — current content:
{section}
VERIFIED FACTS (the only allowed factual basis):
{facts}
SECTION SPECIFICATION:
{spec}
FINDINGS TO FIX:
{auftraege}
Rules per finding type:
- **Claims (falsch/unbelegt):** correct IF the verified facts state the right version, otherwise DELETE and smooth the surrounding sentence. Touch nothing else.
- **Lücken:** add the missing content for the named objective — grounded in the verified facts, at the fitting subblock, beginner-friendly.
- **Ballast:** shorten the named passages without information loss — never drop a subblock.
- **Lesbarkeit/Länge:** fix exactly the noted problems (split sentences, lists instead of prose enumerations, cut repetition/filler; hit the stated length target when one is given).
STRUCTURE INVARIANT (mandatory — the level filter dies without it): keep the marker skeleton exactly — `<!-- kapitel: … -->`, `<!-- section: … -->`, `<!-- compact -->`, `<!-- ausführlich -->` and one `<!-- sub: LABEL | title -->` per subblock in BOTH blocks, labels/titles/order as in the subblock list:
{sub_list}
Write the revised section in GERMAN, same tone as the original.
Write ONLY the file {out_path} — the COMPLETE revised section in exactly the original marker format. No text outside the section.
{extra}
-33
View File
@@ -1,33 +0,0 @@
Review written sections of a learning guide on the topic "{topic}" (format: {format_name}) for readability.
Audience: beginners.
SECTION SPECIFICATION (target state):
{spec}
SECTIONS:
{sections}
Review each section:
1. Does the section teach the concept understandably for a beginner with no prior knowledge — does it frame it, explain the how/why, make an example concrete? Not so dense that only someone who already knows the topic can follow it.
2. Readability (note genuine flaws):
- Sentences over ~20 words (never over 25), or nested sentences with several interjections.
- An enumeration (steps/options/requirements) written as one long prose sentence that should be a Markdown list.
- Wall of text: one dense block without paragraphs that could be split into several.
- More than ~4 new technical terms left unexplained at first occurrence.
3. Conciseness (superfluous material harms learning — note genuine flaws):
- Repetition: the same statement multiple times, just reworded.
- Trivia spelled out at length, filler sentences, preambles, meta-comments with no new content.
- An example that contributes nothing to understanding.
→ note as "too long/redundant — shorten without loss of information". IMPORTANT: shortening never means dropping a subblock — each one stays.
4. Are the examples short, simple, plausibly correct — and in the topic-appropriate format per the specification (no code block around prose examples, no prose pseudo-example where code is required)?
5. Is the Markdown clean (no broken code blocks, no placeholders, no foreign text)?
You only REVIEW and note problems — you change nothing. Note only genuine flaws, no matters of taste.
Reply with ONLY the JSON — no other text, no code fences.
Format — all in order:
{{"ok": true}}
Otherwise (section title EXACTLY as above):
{{"problems": [{{"section": "exact section title", "problem": "…"}}]}}
{extra}
+36
View File
@@ -0,0 +1,36 @@
You are the combined quality gate for ONE written guide section on the topic "{topic}" — fact check (Chain-of-Verification style), coverage against the learning objectives, and readability. One verdict, three lenses.
SECTION (block "{block}") — subblocks are marked with `<!-- sub: … -->`:
{section}
VERIFIED FACTS — the ONLY allowed factual basis (extract-once from the source):
{facts}
LEARNING OBJECTIVES of this block:
{ziele}
SECTION SPECIFICATION (target state for readability):
{spec}
{hinweise}
LENS 1 — FACTS (binary per claim):
1. Decompose the text into its ATOMIC factual claims (definitions, properties, numbers, procedure steps, causal statements). Ignore pure didactics: transitions, framing, previews/recaps, uncontroversial placement knowledge, paraphrases of the verified facts. Worked-example passages that merely APPLY a verified fact are didactics; flag an example step ONLY if it contradicts the facts or smuggles in a NEW general claim.
2. Check each claim against the VERIFIED FACTS only — no web, no outside knowledge as evidence. Not derivable → claim with "urteil": **"falsch"** when it CONTRADICTS the facts or the section itself, **"unbelegt"** when merely not derivable. List ONLY unsupported claims, VERBATIM.
3. Genuinely ambiguous between context and claim → treat as claim.
LENS 2 — COVERAGE (binary per objective):
4. For EACH objective id: does the text teach it well enough that a beginner could achieve it afterwards (mentioning a keyword is NOT teaching)? `ziele` maps EVERY id to true/false.
5. Per uncovered objective one `luecken`-entry: WHAT exactly is missing (German, concrete — the fix will patch exactly this).
6. `ballast`: passages serving none of the objectives (digressions, redundant repetition) — shortening candidates only, never a whole subblock.
LENS 3 — READABILITY (note genuine flaws only, no taste):
7. Beginner-followable (framing, how/why, concrete example)? Sentences over ~25 words or nested; enumerations as prose instead of lists; walls of text; >4 unexplained new terms; repetition/filler/preambles; examples that add nothing; broken Markdown. → one `lese_probleme`-entry each (German, short).
Everything in order → `{{"ok": true}}`.
Reply with ONLY the JSON — no other text, no code fences. Format:
{{"claims": [{{"text": "verbatim claim", "grund": "warum unbelegt (kurz)", "urteil": "falsch|unbelegt"}}],
"ziele": {{"z1": true, "z2": false}},
"luecken": [{{"ziel": "z2", "fehlt": "…"}}],
"ballast": ["passage …"],
"lese_probleme": [{{"problem": "…"}}]}}
{extra}
-38
View File
@@ -1,38 +0,0 @@
Revise individual sections of a learning guide on the topic "{topic}" (format: {format_name}).
{facts}
SECTION SPECIFICATION:
{spec}
TO REVISE — per section, its subblocks, the noted problem, and the current content:
{tasks}
Per section, fix ONLY the noted problem; whatever is in order stays unchanged in substance.
MANDATORY STRUCTURE (same as the writer) — otherwise the staged display breaks:
- Each section has TWO versions: **compact** (one mnemonic per subblock) and **detailed** (the `ausführlich` layer: a coherent beginner text).
- In BOTH versions, each subblock carries a `<!-- sub: LABEL | subblock title -->` marker. LABEL and title EXACTLY from the subblock list above, same order in both blocks. The detailed text starts with a short anchor (framing) BEFORE the first marker.
- The markers are invisible interfaces, NOT visible headings — write fluently regardless.
KEEP IT CONCISE (if the problem is "too long/redundant"):
- Every sentence carries new information. Cut repetition, filler and meta sentences, preambles.
- Explain trivia briefly; give depth only where the material needs it. When in doubt, leave it out, don't add.
- An example only where it genuinely carries the understanding — not dutifully for every subblock.
- Shortening never means dropping a subblock. Each one stays with its marker.
Write all revised sections in GERMAN (the guide is for German-speaking learners), even though these instructions are in English.
Write ONLY the file {out_path} in EXACTLY this format — one section marker per flagged section (title EXACTLY as above):
<!-- section: exact section title -->
<!-- compact -->
<!-- sub: beginner | exact subblock title -->
- one mnemonic per subblock (concise, no explanation)
<!-- ausführlich -->
Anchor: brief framing of the block — before the first subblock.
<!-- sub: beginner | exact subblock title -->
Beginner-friendly prose for this subblock.
Write the marker lines exactly like that. No text outside the sections.
{extra}
+1 -1
View File
@@ -24,7 +24,7 @@ HOW TO WRITE the detailed version — ONE coherent text for a junior who is lear
- Resolve EVERY technical term at first mention in half a sentence. Use "Prerequisite" hints as anchors, "Hurdle" hints to clear misconceptions up front.
- Order of the subblocks as in the assignment: first `[beginner]`, then `[advanced]`, then `[expert]`, last `[peripheral]`.
- The NUMBER of subblocks is the depth signal: many subblocks → the block deserves length; few → keep it short. Never pad.
- LENGTH TARGET for the detailed version: about {budget} characters total (guideline, not a hard limit — covering ALL learning objectives always beats brevity). Unguided sections came out 2–4× too long and had to be rewritten.
- LENGTH CAP for the detailed version: {budget} characters total — a HARD ceiling, not a target. Cover EVERY learning objective, but write DENSELY: fewer words, not more; prefer cutting a sentence to adding one. If you approach the cap, tighten — do not spill over. Overshooting gets rewritten.
- CONCISE: every sentence carries new information. No repetition, no filler, no preamble. When in doubt, leave it out.
- Show "how" procedures step by step. An example ONLY where it genuinely carries the understanding.
- If VERIFIED WORKED EXAMPLES are provided above, weave each into its subblock as instructed there; do NOT invent additional worked examples for subblocks that already have one.
-21
View File
@@ -1,21 +0,0 @@
Several agents have classified the subblocks of the topic "{topic}" by learning-path position. On some they disagree. Decide the final level for each disputed subblock.
DISPUTED SUBBLOCKS (with the votes cast):
{disputed}
Level = learning-path position (WHEN you need the point), NOT difficulty:
- **beginner**: foundation — what you need first/at all; a prerequisite for the rest. Even if conceptually demanding.
- **advanced**: builds on it; common variants, typical application.
- **expert**: subtleties, special cases, niches, rare details — even if substantively simple (e.g. an obscure flag, a version detail).
Rules:
- A simple niche detail is `expert`; a fundamental complex concept is `beginner`.
- Weigh the votes, decide on the merits by learning-path position.
- **`advanced` is not a fallback choice.** Don't pick the middle because the votes are scattered — decide by the criterion "when do you need this?". Hit `beginner`/`expert` clearly where they apply.
- Exactly one level for EACH disputed number.
Reply with ONLY the JSON — no other text, no code fences.
Format (no other text):
{{"levels": {{"1": "beginner", "4": "expert"}}}}
{extra}
-33
View File
@@ -1,33 +0,0 @@
Assign each subblock of the topic "{topic}" to its **learning-path position**: beginner, advanced, or expert. The level controls when a learner sees the point (beginners see only „beginner", advanced learners more).
SUBBLOCKS (grouped by block; many state their core points with „Kern:"):
{subblocks}
Use the core points for a well-founded classification — judge on the content, not just the title.
DECISIVE — the level measures WHEN you need the point in the learning path, NOT how hard it is to understand:
- **beginner**: foundation. What you need first/at all to understand the block in the first place. A prerequisite for the rest. Even if the concept is conceptually demanding — if it is the foundation, it belongs here.
- **advanced**: builds on the foundation. Common variants, typical application, the next step.
- **expert**: subtleties, special cases, edge cases, niches, rare details. What you need only once you've mastered the basics — **even if it is substantively simple** (e.g. an obscure flag, a version detail).
IMPORTANT — difficulty is NOT the criterion:
- A **simple niche detail** (rarely needed, e.g. „seit 6.4.12.0") is `expert`, not `beginner`.
- A **fundamental but complex concept** (needed early) is `beginner`, not `expert`.
- Ask: "when in the learning path do you need this?" — not "how hard is it?".
Rules:
- Level RELATIVE within each block: what is foundation here, what is build-up, what is fine detail?
- Not every block needs a `beginner` point. Some topics presuppose prior knowledge and are entirely `advanced`/`expert` — that is intended. Level honestly by learning-path position, without inventing an artificial entry point.
- Only judge — invent nothing, change no subblocks.
Don't flee to the middle:
- **`advanced` is not a fallback choice.** Don't pick the middle because you're unsure.
- Decide each level by the question **"when in the learning path do you need this?"** — not by the safest middle option.
- **Differentiate within the block:** What is foundation (beginner), what is build-up (advanced), what is fine detail (expert)? Not everything is the middle.
- Hit `beginner` and `expert` clearly where they apply — no bonus for the middle.
Reply with ONLY the JSON — no other text, no code fences.
Format (exactly one level for EACH number; no other text):
{{"levels": {{"1": "beginner", "2": "advanced", "3": "expert"}}}}
{extra}
@@ -1,23 +0,0 @@
You are the quality reviewer for the question pool of a learning exam on the topic "{topic}". Another agent created question patterns for **several blocks** — exactly one pattern per subblock. Review them and return a **cleaned-up** final version.
PATTERNS TO REVIEW (grouped by block; rows: (subblock) question):
{table}
REVIEW AND CLEAN UP AGAINST THESE CRITERIA (per block, separately):
- **Style:** each question is EXACTLY ONE question, one question mark, one thing. No "and"/"as well as", no enumeration. At most 1-2 sentences. Violations → rephrase.
- **Unambiguously answerable:** clearly answerable from the block's knowledge, with no invented assumptions. Unanswerable ones → fix.
- **Core hit:** the question targets the subblock's core concept, phrased neutrally (not deliberately easy or hard — the difficulty is set later by the learner's level). If it misses the core → rephrase.
- **Exactly one per subblock:** exactly one pattern remains per subblock. Duplicates → keep one.
- Carry over the `block` and `subblock` of the entries unchanged (only `question` may change); do not invent new ones.
Keep good patterns unchanged. Change only what genuinely violates the criteria.
Keep every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English.
Reply with the cleaned-up final version of ALL blocks as ONE JSON — no other text, no code fences — EXACTLY in this format:
{{"pattern": [
{{"block": "<exact block title>", "subblock": "<exact title>", "question": "<one concrete question>"}}
]}}
Output no other text.
{extra}
@@ -1,28 +0,0 @@
You are building the question pool for a learning exam on the topic "{topic}". Create **question patterns** — **exactly one** concrete example question **per subblock**. Each pattern is later turned, at exam time, into a slightly varied question — matched to the learner's level (beginner to expert). So the difficulty is NOT in the pattern; it only comes in at exam time. Therefore ask about the **core concept** of the subblock, phrased neutrally.
You are working on **several blocks**. Each block has its own subblocks — many with core points, cited facts, and an example:
{blocks}
TASK (for **each** of the blocks above):
- For **each** of its subblocks, create **exactly one** pattern — a concrete, concise question about that subblock's core concept.
- Each pattern is ONE concrete question (1-2 sentences, exactly one question mark, one thing) — no multi-parter, no enumeration.
- The question must be answerable from the block's knowledge. Do not invent extra assumptions.
- Base the question on the supplied **core points and cited facts** (the reliable foundation). Hit the CORE CONCEPT of the subblock, not a detail to be memorized. Assume NOTHING that is not in the facts: no "shown" code snippets/diagrams, no concrete values/lists/numbers from some later guide text.
- The question hits the **core** of the subblock — the central point one must have understood. Not too narrow on a detail, not too broad across the whole block.
- `block` is exactly one of the block titles above. `subblock` is exactly one of the subblock titles of the respective block.
HARD STYLE RULES PER QUESTION:
- Exactly ONE question, one question mark, one thing. No "and"/"as well as", no "name three …".
- At most 1-2 sentences, no preamble, no scenario build-up.
- Phrased neutrally — not deliberately easy, not deliberately hard. The difficulty is set by the exam via the learner's level.
- Direct address, clear German.
Write every question in GERMAN (the questions are for German-speaking learners), even though these instructions are in English.
{extra}
Reply with ONLY the JSON (all patterns of all blocks) as your final message — no code fences, do NOT write a file. EXACTLY this format:
{{"pattern": [
{{"block": "<exact block title>", "subblock": "<exact subblock title>", "question": "<one concrete question>"}}
]}}
Output no other text.
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more