Compare commits

13 Commits

Author SHA1 Message Date
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
69 changed files with 3679 additions and 3741 deletions

View File

@@ -1,3 +1,6 @@
# Standard-Provider für alle Agenten (Pflicht — es gibt keinen Code-Default)
DEFAULT_PROVIDER=minimax
# Datei nach .env kopieren (wird nicht committet).
# Claude-Provider: lokal einmal 'claude setup-token' ausführen, Token eintragen.
@@ -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

12
CLAUDE.md Normal file
View File

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

View File

@@ -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
@@ -129,6 +129,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; \

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,7 +42,8 @@ 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"])
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
@@ -95,7 +99,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 +124,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 +158,55 @@ 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: RSS ramps up slowly after spawn
_RAM_COMMIT_WINDOW_S = 10.0 # fresh admissions count as already-spent RAM
_RAM_POLL_S = 2.0
_opencode_running = 0 # opencode spawns only (stagger path), not claude
_opencode_recent_starts: list[float] = [] # monotonic timestamps of 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) -> bool:
"""True = start admitted (commit registered), False = scope cancelled while waiting.
Check and commit-append happen in the same synchronous block (no await between) —
concurrent waiters on the loop 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 _opencode_running < _RAM_GATE_FLOOR:
break # fail open / floor
avail_kb, total_kb = mem
now = time.monotonic()
_opencode_recent_starts[:] = [t for t in _opencode_recent_starts
if now - t < _RAM_COMMIT_WINDOW_S]
if avail_kb - len(_opencode_recent_starts) * _RAM_PER_PROC_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)
_opencode_recent_starts.append(time.monotonic())
return True
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools
@@ -170,6 +226,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 +325,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 +338,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)
@@ -293,7 +365,9 @@ async def run_agent(
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if err_tail:
meta["stderr"] = err_tail
if PROVIDERS[provider]["cli"] == "opencode": # token accounting per agent
if 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,
@@ -316,9 +390,14 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
env=env,
)
global _opencode_running
if stagger:
await _opencode_slot() # spaced start slot; spawn itself is not serialized
if not await _ram_gate(agent_key):
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:
_opencode_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 +445,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:
_opencode_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:
@@ -441,6 +522,67 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
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")

696
backend/block_calls.py Normal file
View File

@@ -0,0 +1,696 @@
"""Board 2, verschmolzene Call-Struktur: 3 Bausteine pro Block statt ~20 serieller Segmente.
Die alte Stage-Treppe (Finder-Runden → Facts find/erg/check → Konsolidierung → Lücken →
Nachfass → Levels → Relevanz → Fragen → Kritik → Flashcards → Beispiele → Check) kostete
pro Block 3555 Calls und ~14 min Wandzeit — bei p50 2050 s pro Call zählt NUR die Zahl
der seriellen Segmente. Hier: Generate(∥2) → Verify(∥2, + Fix-Tail) → Artefakte(Gen+Check)
= 45 Segmente, 69 Calls. Unabhängigkeit bleibt: Generatoren und Prüfer sind getrennte
Agenten, Konsens (≥2 unabhängige Nennungen) und Einstimmigkeits-Faltung wie zuvor.
Output-Kontrakt unverändert (finalize/QA/Guide/Übungssystem lesen dieselben Strukturen):
raw {block: [sub]}, facts {block: {sub_norm: 5-Felder}}, sidecar {block: [{title, level,
relevance, facts}]}, pattern {block: [{subblock, question}]}, artefacts {flashcard/example}."""
import asyncio
import hashlib
import json
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:
"""GEN_PANEL unabhängige Generatoren liefern je Subs+Facts+Level/Relevanz in EINEM Call;
Konsens im Code (Variant-Cluster über beide Ausgaben, ≥2 unabhängige Generatoren =
consensus). Einzelnennungen und ungedeckte Seeds werden „unsicher" — der Prüfer
entscheidet mit Material (ersetzt Sättigungsrunden + Clarify-Panel). Degraded: liefert
nur EIN Generator, wird alles unsicher. → {raw, facts, unsicher, votes} | None."""
topic, provider = ctx.topic, ctx.provider
work_dir = files["arbeit"]
bnorm = _norm_title(title)
source, caps = await asyncio.to_thread(
_inline_source, topic, sources, [f"{title} {description}"])
seeds_txt = ""
if seeds:
seeds_txt = ("\nAlready identified sub-point CANDIDATES of this block (verify against "
"the material; if backed AND not already covered by another entry, include "
"them — rephrased as a standalone statement):\n"
+ "\n".join(f"- {s}" for s in dict.fromkeys(seeds) if s) + "\n")
if melde:
melde("Generate")
h = _h8(title, description, "gen")
paths = [work_dir / f"gen-{h}-g{g}.json" for g in range(1, GEN_PANEL + 1)]
prompt = _prompt("Subblock-Generate", topic=topic,
block=f"{title}{description}" if description else title,
source=source, seeds=seeds_txt, extra=_extra(instructions))
pending = [(g, p) for g, p in enumerate(paths, 1) if _gen_schema(_json_file(p)) is None]
if pending:
slots = [{
"key": f"blocks-{topic}-{ns}sb-gen-{h}-g{g}",
"prompt": prompt, "role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _sink_json(result, p, _gen_schema)),
} for g, p in pending]
await _race(topic, f"{lbl}Generate", slots, len(slots),
_timeout("generate", 10), provider, cancelled=ctx.is_cancelled)
if ctx.is_cancelled():
return None
outs = [o for p in paths if (o := _gen_schema(_json_file(p))) is not None]
if not outs:
return None
if len(outs) < len(paths):
_log(topic, f"Generate {title}: nur {len(outs)}/{len(paths)} Generatoren — alles unsicher, Prüfer entscheidet")
# Mentions in die DB (QA-Beleg-Signal), Karten-Re-Spawn darf nicht kumulieren
await db.delete_subblocks(topic, bnorm)
alle: list[tuple[dict, int]] = [] # (sub-Eintrag, Generator-Index)
for gi, subs in enumerate(outs):
seen: set[str] = set()
for e in subs:
sn = _norm_title(e["title"])
if not sn or sn in seen:
continue
seen.add(sn)
alle.append((e, gi))
await db.upsert_subblock(topic, bnorm, sn, title, e["title"])
if not alle:
return {"raw": {title: []}, "facts": {title: {}}, "unsicher": [], "votes": {}}
sims = await _sims_of([e["title"] for e, _ in alle])
raw: list[str] = []
facts: dict[str, dict] = {}
votes: dict[str, dict] = {}
unsicher: list[dict] = []
for c in _variant_clusters([e["title"] for e, _ in alle], [1] * len(alle), sims):
rep = alle[c["rep"]][0]
sn = _norm_title(rep["title"])
fk = _fk_of(rep)
for m in c["members"]:
if m != c["rep"]:
_facts_union(fk, _fk_of(alle[m][0]))
await db.set_subblock_fields(topic, bnorm, _norm_title(alle[m][0]["title"]),
status="variant")
votes[sn] = {"level": [v for m in c["members"] if (v := alle[m][0]["level"])],
"relevance": [v for m in c["members"] if (v := alle[m][0]["relevance"])]}
gens = {alle[m][1] for m in c["members"]}
# degraded (1 Generator): kein Konsens möglich — alles unsicher, Prüfer entscheidet
if len(gens) >= 2 and len(outs) >= 2:
raw.append(rep["title"])
facts[sn] = fk
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
else:
unsicher.append({**rep, **fk})
# Seed-Garantie: ungedeckte Seeds gehen als unsicher zum Prüfer (der ist das Beleg-Gate)
for seed in dict.fromkeys(s for s in (seeds or []) if s):
st = _sub_tokens(seed)
gedeckt = [t for t in raw + [u["title"] for u in unsicher]]
if not st or any(st <= _sub_tokens(t) for t in gedeckt):
continue
if gedeckt and EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available):
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + gedeckt)
if sims is not None and max(float(sims[0][j]) for j in range(1, len(gedeckt) + 1)) >= SEED_COVER_COS:
continue
unsicher.append({"title": seed, "level": "", "relevance": "", "key_points": [],
"prerequisites": "", "hurdles": "", "cited_facts": [], "example_idea": ""})
raw_map = {title: raw}
await _dedup_subblocks(topic, raw_map) # deterministischer Near-Dup-Filter
facts = {sn: fk for sn, fk in facts.items()
if sn in {_norm_title(s) for s in raw_map[title]}}
return {"raw": raw_map, "facts": {title: facts}, "unsicher": 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) -> 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.
{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]
# 1. Fremd (einstimmig): fürs THEMA fremde Aussagen → discarded
for k in sorted(v1["fremd"] & v2["fremd"]):
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
# 2. Unsicher-Übernahme (2/2 „ja"): wird consensus samt Generator-Facts
for k in range(len(subs) + 1, n + 1):
if k in gone:
continue
u = unsicher[k - len(subs) - 1]
if v1["uebernehmen"].get(k) == "ja" and v2["uebernehmen"].get(k) == "ja":
sn = _norm_title(u["title"])
bfacts[sn] = _fk_of(u)
keep.append(u["title"])
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
else:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
gone.add(k)
# 3. Gruppen (einstimmige Paare): haupt-Votum, sonst key_points/Länge
haupt_votes: dict[int, int] = {}
for v in (v1, v2):
for g in v["gruppen"]:
if g["haupt"]:
haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["gruppen"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
win = max(g, key=lambda k: (haupt_votes.get(k, 0), len(_kp(_titel(k))), len(_titel(k)), -k))
wf = bfacts.setdefault(_norm_title(_titel(win)), {})
for k in g:
if k != win:
await _fold(k, wf)
# 4. Kataloge: Aufzählungszeilen → EIN neuer benannter Sub (Facts-Union)
for g in _agreed_cliques([_pairs_of([x["ids"] for x in v["kataloge"]]) for v in (v1, v2)], negs, n):
g = [k for k in g if k not in gone]
if len(g) < 2:
continue
titel = next((clean_title(x["titel"]) for x in v1["kataloge"] + v2["kataloge"]
if set(x["ids"]) & set(g) and clean_title(x["titel"])), "")
kn = _norm_title(titel)
if not kn or kn in {_norm_title(s) for s in keep}:
continue
kf: dict = {}
for k in g:
await _fold(k, kf)
bfacts[kn] = kf
keep.append(titel)
await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus")
# 5. Facts-Probleme: discard nur 2/2 (irreversibel), Korrektur ab 1 Stimme
d1 = {p["nr"] for p in v1["facts_probleme"] if p["discard"]}
d2 = {p["nr"] for p in v2["facts_probleme"] if p["discard"]}
for k in sorted(d1 & d2):
if k in gone:
continue
t = _titel(k)
bfacts.pop(_norm_title(t), None)
await db.set_subblock_fields(topic, bnorm, _norm_title(t), status="discarded")
if t in keep:
keep.remove(t)
gone.add(k)
for p in v1["facts_probleme"] + v2["facts_probleme"]:
k = p["nr"]
if k in gone or not p["hinweis"]:
continue
t = _titel(k)
if t in keep and all(x["titel"] != t for x in korrekturen):
korrekturen.append({"titel": t, "hinweis": p["hinweis"]})
# 6. Lücken (Schnitt beider Prüfer, Cap)
luecken = _luecken_schnitt(v1["luecken"], v2["luecken"])
else:
# fail-open: consensus bleibt, unsicher wird verworfen (wie heutiges Clarify-Aus)
for u in unsicher:
await db.set_subblock_fields(topic, bnorm, _norm_title(u["title"]), status="discarded")
# 7. Level/Relevanz: Stimmen = Generatoren + Prüfer (explizite Korrektur schlägt
# die implizite Zustimmung); Patt → advanced/relevant (heutige Defaults)
sidecar_subs = []
for t in keep:
sn = _norm_title(t)
try:
k = nummern.index(t) + 1
except ValueError:
k = 0 # Katalog-/Fix-Neuzugänge haben keine Nummer
stimmen_l = list((votes.get(sn) or {}).get("level") or [])
stimmen_r = list((votes.get(sn) or {}).get("relevance") or [])
for v in verdicts[:2]:
if k and k in v["levels"]:
stimmen_l += [v["levels"][k]] * 2 # explizite Korrektur wiegt doppelt
if k and k in v["relevanz"]:
stimmen_r += [v["relevanz"][k]] * 2
fk = bfacts.get(sn) or {}
sidecar_subs.append({"title": t, "level": _default_vote(stimmen_l, "advanced"),
"relevance": _default_vote(stimmen_r, "relevant"), "facts": fk})
# 8. Fix-Tail (01 Call): Korrekturen + belegte Lücken
if (korrekturen or luecken) and not ctx.is_cancelled():
if melde:
melde("Fix")
neu = await _fix_befunde(ctx, files, title, korrekturen, luecken,
[s["title"] for s in sidecar_subs], instructions, ns, lbl, sources)
for e in neu or []:
sn = _norm_title(e["title"])
vorhanden = next((s for s in sidecar_subs if _norm_title(s["title"]) == sn), None)
if vorhanden is not None: # Korrektur: Facts ersetzen, Einstufung bleibt
vorhanden["facts"] = _fk_of(e)
bfacts[sn] = vorhanden["facts"]
else: # Lücken-Fund: neuer consensus-Sub
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]}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
"""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 — 45 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 zwei topic-weite BARRIEREN: `konsolidierung` (cross-block
sub dedup, faltet per repair.falte_sub) und `outline` (prerequisite graph → chapter order),
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
import asyncio
import hashlib
@@ -18,11 +17,8 @@ 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 block_calls import _artefakte_block, _generate_block, _verify_block
from blocks import ARTEFACT_TYPES, _match_sub, _neg_set, _sink_json, _outline_block
from config import CROSS_CHUNK_PAARE, EMBEDDING_AKTIV, SUB_DUP_KANDIDAT_COS
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
@@ -99,7 +95,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
@@ -171,88 +167,91 @@ 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))
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)
async def _proc_artefakte(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Fragen + Flashcards + Beispiele in einem Generator-Call, ein Prüfer-Call dahinter
(block_calls._artefakte_block)."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
title = p.get("title", "")
res = await _artefakte_block(ctx, _pfiles(files, norm), title,
(p.get("sidecar") or {}).get(title) or [],
instructions, ns=f"{_safe(norm)}-", lbl=f"{title or norm} · ",
melde=_melder(flow, norm))
if res is None:
return None # nur Cancel
p["pattern"] = res["pattern"]
p["artefacts"] = res["artefacts"]
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "finalize")
await _gather_cards(ctx, flow, cards, one)
@@ -274,61 +273,56 @@ def _cross_schema(data) -> dict[int, str] | 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 ≥
"""BARRIER/drain am RUN-ENDE — 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."""
statement. Sitzt seit dem Umbau NACH finalize: als Mittel-Barriere wartete jede fertige
Karte auf die langsamste (gemessen: 8:46 min Leerlauf pro Block, kanban-smoke). Der
Verlierer wird per repair.falte_sub gefaltet (variant + Fragen/Artefakte umhängen) —
die wenigen Cross-Dubletten kosten so ein paar umsonst generierte Artefakte statt
Minuten Wandzeit für alle. Fail-open on judge failure/dissent."""
from repair import falte_sub
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
# Resume-Karten aus der alten Stage-Position (Barriere lag vor den Fragen): erst fertig
# generieren — die Barriere feuert erneut, wenn alle wieder hier sind. Direkt dedupen
# ginge schief: finalize würde den gefalteten Sub aus dem Karten-Sidecar re-spiegeln.
nachzuegler = [(c["card_id"], "artefakte" if "sidecar" in c["payload"] else "generate")
for c in cards if "pattern" not in c["payload"]]
if nachzuegler:
await db.kanban_advance_many(topic, BOARD, nachzuegler)
flow.wake.set()
return
async def _advance_all():
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], "question_pattern") for c in cards])
await db.kanban_advance_many(topic, BOARD, [(c["card_id"], DONE) 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):
rows = [r for r in await db.list_subblocks(topic) if r["status"] == "consensus"]
if len(rows) < 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])
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
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]
negs = [_neg_set(r["sub_title"]) for r in rows]
pairs = [(i, j) for i in range(len(rows)) for j in range(i + 1, len(rows))
if rows[i]["block_norm"] != rows[j]["block_norm"] 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 _kp(r: dict) -> list:
try:
return (json.loads(r.get("facts") or "{}")).get("key_points") or []
except ValueError:
return []
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))
def _side(tag: str, r: dict) -> str:
return f"{tag}: [Block: {r['block']}] {r['sub_title']}" + "".join(f"\n - {p}" for p in _kp(r))
# 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)
@@ -338,7 +332,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
"""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])}"
f"{k}.\n{_side('A', rows[i])}\n{_side('B', rows[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)]
@@ -374,7 +368,7 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
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]])}"
f"{x}.\n{_side('A', rows[chunk[k - 1][0]])}\n{_side('B', rows[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))
@@ -397,39 +391,23 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
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()
gone: set[tuple] = 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]}",
journal["verdicts"].append({"a": f"{rows[i]['block']} · {rows[i]['sub_title']}",
"b": f"{rows[j]['block']} · {rows[j]['sub_title']}",
"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
win, lose = (rows[i], rows[j]) if verdict == "a" else (rows[j], rows[i])
wk = (win["block_norm"], win["sub_norm"])
lk = (lose["block_norm"], lose["sub_norm"])
if lk in gone or wk 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)
await falte_sub(topic, files, win, lose)
gone.add(lk)
journal["gefaltet"].append({"weg": f"{lose['block']} · {lose['sub_title']}",
"bleibt": f"{win['block']} · {win['sub_title']}"})
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]
@@ -437,93 +415,6 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
await _advance_all()
async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if sidecar is None:
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
facts_map = p.get("facts") or {}
for btitle, subs in sidecar.items(): # merge facts into the subs (mirror + guide)
fm = facts_map.get(btitle, {})
for sub in subs:
# level agents paraphrase titles — exact miss falls back to the unique
# prefix/containment match, else the sub silently loses its grounding
sn = _sub_key(set(fm), _norm_title(sub["title"]))
if (fk := fm.get(sn)):
sub["facts"] = fk
p["sidecar"] = sidecar
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "relevance")
await _gather_cards(ctx, flow, cards, one)
async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
sidecar = p.get("sidecar") or {}
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
sidecar, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if rel is None:
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
gid = 0
for subs in sidecar.values():
for sub in subs:
gid += 1
sub["relevance"] = rel.get(gid, "relevant")
p["sidecar"] = sidecar
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "konsolidierung")
await _gather_cards(ctx, flow, cards, one)
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
if pattern is None:
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
p["pattern"] = pattern
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "artefacts")
await _gather_cards(ctx, flow, cards, one)
async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
if artefacts is None and ctx.is_cancelled():
return None
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "finalize")
await _gather_cards(ctx, flow, cards, one)
# ── Finalize (SERIAL): merge into the global files + DB tables ─────────────────────
def _merge_json(path, block_keys: dict) -> None:
data = _json_file(path)
@@ -594,7 +485,7 @@ async def _proc_finalize(ctx: GenContext, flow: Flow, files: dict, cards):
data = json.dumps({k: v for k, v in e.items() if k not in ("block", "subblock")},
ensure_ascii=False)
await db.put_sub_artifact(topic, bnorm, sn, typ, data, bt, str(e.get("subblock", "")))
await db.kanban_advance(topic, BOARD, c["card_id"], DONE)
await db.kanban_advance(topic, BOARD, c["card_id"], "konsolidierung")
_log(topic, f"Artefakte fertig: {title}")
flow.wake.set()
@@ -639,23 +530,40 @@ 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")
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, "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 als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize
# per repair.falte_sub — spät gefundene Dubletten kosten Artefakt-Tokens, keine Wandzeit
Stage(BOARD, "konsolidierung",
lambda cs: _proc_konsolidierung(ctx, flow, files, instructions, cs),
barrier=True, drain=True),
Stage(BOARD, "question_pattern",
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
Stage(BOARD, "outline", lambda cs: _proc_outline(ctx, flow, files, instructions, cs),
barrier=True, drain=True, gate=research_done),
]

View File

@@ -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,
@@ -470,6 +488,16 @@ 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). 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')."""
if not (p.get("description") or "").strip():
return True
return bool(ctoks) and not _hat_anker(p.get("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
@@ -703,9 +731,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)]
@@ -1591,21 +1669,24 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
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)
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:
# 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"))
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 AND
# zur Cross-Dedup-Barriere 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")]
("verify", "artefakte", "finalize", "konsolidierung")]
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
async def _as_producer(coro):
@@ -1625,19 +1706,23 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
watcher = (asyncio.create_task(_qa_gate_watch(ctx, flow, inv_names, set_p))
if artefacts and QA_GATE_NOTE > 0 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("qa_paused"):
return True # kein Fehler: Flow hielt am QA-Gate, Karten warten in generate
await _write_final(topic, files)
await _write_run_summary(topic, flow)
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
@@ -1667,7 +1752,7 @@ async def _qa_gate_watch(ctx: GenContext, flow: Flow, inv_names: list[str], set_
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)
await qa.write_report(report)
except Exception:
log.exception("[%s] QA-Report schreiben fehlgeschlagen", topic)
if note >= QA_GATE_NOTE:
@@ -1707,7 +1792,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,14 +1837,11 @@ 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", "konsolidierung", "Konsolidierung", "ablock"),
("artefacts", "outline", "Gliederung", "outline"),
("artefacts", "done_artefact", "Fertig", "ablock"),
]
@@ -1767,18 +1849,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", "konsolidierung", "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}
@@ -1846,7 +1926,7 @@ def _qa_view(topic: str, counts: dict, flow) -> dict | None:
note = r.get("note")
if note is None:
return None
wartend = counts.get("artefacts", {}).get("subblocks", 0)
wartend = counts.get("artefacts", {}).get("generate", 0)
pausiert = bool(note < QA_GATE_NOTE and wartend and flow is None)
return {"note": note, "note_artefakte": r.get("note_artefakte"),
"schwelle": QA_GATE_NOTE, "pausiert": pausiert,
@@ -1915,7 +1995,7 @@ 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
if stage == "generate": # full artefact re-derive → also the DB mirrors
await db.delete_subblocks(topic)
await db.delete_question_pattern(topic)
await db.delete_sub_artefakte(topic)
@@ -1931,7 +2011,7 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
async def restart_artefact_card(topic: str, card_id: str) -> bool:
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
"""Restart ONE artefacts card from `generate` — 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)
@@ -1942,7 +2022,7 @@ async def restart_artefact_card(topic: str, card_id: str) -> bool:
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.kanban_advance(topic, "artefacts", card_id, "generate")
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
return True

View File

@@ -114,9 +114,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).
@@ -147,11 +155,6 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i
QA_GATE_NOTE = 9.5 # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
QA_GATE_LLM = True # include the LLM samples (Echtheit/Dubletten) in the gate run
# Guide section length per relevant sub (ausführlich part) — QA detector AND the
# deterministic readability-stage trigger share these bounds (writers overshot 2.74.1×).
GUIDE_LAENGE_MIN = 150
GUIDE_LAENGE_MAX = 1200
# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
# run's tokens were cache reads from those loops).
@@ -169,10 +172,6 @@ RESEARCH_READERS = 2 # reader agents per batch/section (consensus ≥2)
RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema")
RESEARCH_SECTION_CHARS = 12000 # uni/projekt section size (lost-in-the-middle guard)
RESEARCH_RUNTIME = 900 # one research agent, one round (tail ingests live)
SUBBLOCK_CAP = 900 # subblock find loop per chunk (seconds)
SUBBLOCK_MIN = 5 # below this consensus count → focused catch-up rounds
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds
SUBBLOCK_MAX_ROUNDS = 3 # hard round cap (rounds 45 burned 29 % of finders for ~0 gain)
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (fallback path)
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package
@@ -186,14 +185,25 @@ 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)
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
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160230 s
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden
# Fix-/Gate-Call (die laufen normal 110135 s). 0 = aus.
HEDGE_NACH_S = 90
JUDGE_CHUNK = 40 # repair: findings per judge call
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)
@@ -220,6 +230,12 @@ TIMEOUTS = {
"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)
@@ -242,7 +258,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",

View File

@@ -118,6 +118,13 @@ 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:
@@ -133,16 +140,17 @@ class Welt:
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-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 +163,29 @@ 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": []})
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 "-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:
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 +199,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)
@@ -306,7 +290,7 @@ def aktivieren(welt: Welt, setattr_fn=setattr) -> None:
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)

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,8 +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,
from config import (FORMAT_PURPOSE, READABILITY_ACTIVE,
TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC)
from guide_qa import block_budget
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
@@ -31,11 +33,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).
@@ -87,33 +88,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:
@@ -276,12 +275,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 24×
too long (23k) or, after the readability fix, far too thin (180 chars/sub)."""
return sockel + 400 * max(n_subs, 1)
async def _write_split(env: _Env, card: dict, ziele_text: str):
"""First draft in two halves (parallel), merged into one section.
→ merged text | None (failed) | False (cancelled)."""
@@ -318,7 +311,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))
@@ -335,11 +328,6 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, norm)
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
gaps = ""
if card["writer_rounds"] > 0 and card.get("gate_info"):
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
"below, cut the listed ballast, keep everything else as-is.\n"
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
# oversized first drafts: two halves, merged into one canonical section
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
text = await _write_split(env, card, ziele_text)
@@ -348,7 +336,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 +354,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 +364,178 @@ 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"])
if not (0.5 * budget <= zeichen <= 1.2 * budget):
out.append(
f"Länge {zeichen} Zeichen (Budget {budget}, erlaubt {round(0.5 * budget)}{round(1.2 * 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 db.list_lernziele(env.topic, norm)
ziele_text = "\n".join(f"- ({z['ziel_id']}) {z['text']}" for z in ziele) or "(keine definiert)"
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("fakten_gate", 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:
# 12 merely UNSUPPORTED claims don't justify a fix pass (it ran for 19/20 blocks,
# 40 agent-minutes) — but a WRONG claim always does: one slipped through this
# threshold and cost the guide 1.5 QA points
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claim(s) unter Schwelle — kein Fix")
claims = []
if claims:
_log(env.topic, f"Fakten-Gate {card['block']}: {len(claims)} Claims ({len(falsch)} falsch) → Fix")
fixp = env.slot(f"gatefix-{_safe(norm)}-r{card['writer_rounds']}.md")
fixp.unlink(missing_ok=True)
claims_text = "\n".join(f"- {c['text']}" + (f" ({c['grund']})" if c["grund"] else "")
for c in claims)
def _fixload(result):
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
return text if _first_section(text) else None
fstatus, fixed = await run_single_slot(
env.ctx, f"Fakten-Fix {card['block']}", key=f"{env.guide_id}-gatefix-{_safe(norm)}-r{card['writer_rounds']}",
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
section=card["md"], claims=claims_text, facts=facts,
out_path=fixp, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
timeout=_timeout("fakten_gate", 1))
if fstatus == CANCELLED:
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.74.1× over target — a measured overshoot forces the fix pass
subs_all = env.subs_by_title.get(card["block"], [])
n_rel = max(sum(1 for s in subs_all if s.get("relevance") != "peripheral"), 1)
aus = re.split(r"<!--\s*ausführlich\s*-->", sec["md"], maxsplit=1)
pro_sub = len(aus[1] if len(aus) == 2 else sec["md"]) / n_rel
if not (GUIDE_LAENGE_MIN <= pro_sub <= GUIDE_LAENGE_MAX * 0.9):
ziel = _writer_budget(len(subs_all))
problems.append(
f"Länge {round(pro_sub)} Zeichen/Sub (Rahmen {GUIDE_LAENGE_MIN}{round(GUIDE_LAENGE_MAX * 0.9)}): "
f"schreibe den ausführlich-Teil auf etwa {ziel} Zeichen GESAMT um — Sockel-Prosa und "
f"Wiederholungen streichen, alle Sub-Marker und Beispiele behalten")
if problems:
from guide import _level_label
subs = env.subs_by_title.get(card["block"], [])
sub_list = "\n".join(f"- [{_level_label(s)}] {s['title']}" for s in subs) or "(none)"
tasks = (f"SECTION: {card['block']}\n"
f"SUBBLOCKS (set one `<!-- sub: LABEL | title -->` marker each, label/order as here):\n{sub_list}\n"
f"LENGTH TARGET: about {_writer_budget(len(subs))} characters for the detailed "
f"version (guideline — covering every subblock beats brevity).\n"
f"PROBLEM: {' · '.join(problems)}\nCURRENT CONTENT:\n{sec['md']}")
fixp = env.slot(f"lesefix-{_safe(norm)}.md")
fixp.unlink(missing_ok=True)
def _fixload(result):
text = fixp.read_text(encoding="utf-8") if fixp.exists() else ""
return text if _first_section(text) else None
fstatus, fixed = await run_single_slot(
env.ctx, f"Lese-Fix {card['block']}", key=f"{env.guide_id}-lesefix-{_safe(norm)}",
prompt=_prompt("Guide-Sections-Fix", topic=env.topic, format_name=env.format,
facts=_card_facts(env, card["block"]), spec=env.spec, tasks=tasks,
out_path=fixp, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload,
timeout=_timeout("writer", 1))
if fstatus == CANCELLED:
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 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:
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:
@@ -618,73 +594,75 @@ async def run_guide_board(guide_id: str, topic: str, format_name: str, entries:
import uuid
from datetime import datetime, timezone
db.set_current_run(topic, f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}-g{uuid.uuid4().hex[:4]}")
spec = (TEMPLATES_DIR / "Format" / "Section.md").read_text(encoding="utf-8")
subs_raw = await _load_subblocks(topic)
project = source_folder(topic)
fallback = (_prompt("Guide-Facts-Projekt", project=project) if project
else _prompt("Guide-Facts-Thema"))
env = _Env(ctx, guide_id, topic, format_name, instructions, content_path,
subs_raw, await _chapter_map(topic, entries), fallback, spec)
for num, line in entries.items():
title = _title(line)
await db.upsert_guide_card(topic, format_name, _norm_title(title), title)
cards = await db.list_guide_cards(topic, format_name)
open_cards = [c for c in cards if c["stage"] != "done"]
if open_cards:
sem = asyncio.Semaphore(CARD_CONCURRENCY)
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)
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)
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:
reporter = asyncio.create_task(_progress())
try:
await asyncio.gather(*[_run_card(env, c, sem) for c in open_cards])
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:
@@ -729,6 +707,39 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
return {"columns": columns, "qa_guide": note_guide}
async def repair_karten(topic: str, format_name: str) -> list[str]:
"""QA-Befund-getriebenes Guide-Repair: Karten, die im jüngsten Guide-QA-Report
Befunde tragen, gehen zurück auf `pruefer` (md bleibt) — Prüfer+Fix beheben gezielt,
generate_guide resumt die offenen Karten und misst am Ende neu. Pendant zum
Blocks-Repair („Score unter 10 muss einen Fix-Pfad haben"). → betroffene Blocktitel."""
import qa as qa_mod
tdir = qa_mod.QA_DIR / topic
reports = sorted(tdir.glob("guide-*.json"), key=lambda p: p.stat().st_mtime) if tdir.is_dir() else []
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)}
norms: set[str] = set()
for e in rep.get("marker_fehlend", []): # "Block · sub"
norms.add(_norm_title(str(e).split(" · ")[0]))
for e in rep.get("ziel_ohne_anker", []): # "block_norm · (id) text"
norms.add(str(e).split(" · ")[0])
for e in rep.get("laengen_ausreisser", []): # {"block": titel}
norms.add(_norm_title(e.get("block", "") if isinstance(e, dict) else str(e)))
for e in rep.get("lesbarkeit", []): # "Block: hinweis"
norms.add(_norm_title(str(e).split(":")[0]))
for t in rep.get("fachlich_falsch", []) or []:
norms.add(_norm_title(str(t)))
for e in rep.get("redundanz", []): # {"a": "Block: absatz", "b": …}
for seite in ("a", "b"):
norms.add(_norm_title(str(e.get(seite, "")).split(":")[0]))
betroffen = []
for n in sorted(norms & set(cards)):
await db.set_guide_card(topic, format_name, n, stage="pruefer", status="open", gate_info="")
betroffen.append(cards[n]["block"])
return betroffen
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."""

View File

@@ -10,6 +10,7 @@ Report: storage/qa/<topic>/guide-<ts>.json + Konsolen-Digest.
"""
import asyncio
import json
import re
import sys
from datetime import datetime, timezone
@@ -20,8 +21,6 @@ import readability
from fsutil import atomic_write_json
from textkit import _norm_title
from config import GUIDE_LAENGE_MAX as LAENGE_MAX, GUIDE_LAENGE_MIN as LAENGE_MIN
JACCARD_ABSATZ = 0.6 # Wort-Jaccard, ab dem zwei Absätze als Doppel gelten
ABSATZ_MIN_CHARS = 200 # kürzere Absätze sind Übergänge — kein Dubletten-Signal
LLM_SECTION_CHARS = 2500 # Section-Auszug je Judge-Item
@@ -71,13 +70,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 1501200/Sub:
# ein dichter Sub (viele key_points, Fakten, Beispiel) trägt mehr Text als ein Einzeiler.
# Die Pipeline (Writer-Vorgabe, Prüfer-Trigger, Fix-Ziel) nutzt DIESELBE Formel mit engerem
# Band — Messlatte und Fix-Auftrag müssen übereinstimmen, sonst sind Befunde unfixbar.
BUDGET_BASIS = 200 # Einstieg/Übergang je Sub
BUDGET_KEY_POINT = 160 # ~12 Sätze Erklärung je key_point
BUDGET_FAKT = 60 # zitierter Fakt, in den Text eingewoben
BUDGET_BEISPIEL = 250 # ausgearbeitetes 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
@@ -148,14 +174,23 @@ 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
try:
facts = json.loads(r["facts"]) if r["facts"] else {}
except (ValueError, TypeError):
facts = {}
subs_by_norm.setdefault(r["block_norm"], []).append(
{"relevance": r["relevance"], "facts": facts if isinstance(facts, dict) else {}})
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

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,7 +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"
provider: ProviderType = DEFAULT_PROVIDER
ab_step: int | None = Field(default=None, ge=0, le=5) # re-run from board stage (0 lernziele … 5 lesbarkeit); None = full/resume
@@ -42,7 +44,7 @@ class RepairRequest(BaseModel):
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)
research: bool = True # False = Continue: drain the existing kanban queue, no new search
@@ -157,7 +159,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 +174,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 +205,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 +248,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 +260,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):

View File

@@ -164,10 +164,18 @@ _relevance_schema = _enum_map_schema("relevance", _RELEVANCE) # relevance ∈
_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
# Detached Nachzügler-Tasks (late-Fold): Referenz gegen GC, Aufräumen via done-callback.
_NACHZUEGLER: set[asyncio.Task] = set()
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:
def _detached(task: asyncio.Task) -> None:
_NACHZUEGLER.add(task)
task.add_done_callback(_NACHZUEGLER.discard)
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, late=None) -> list | None:
"""Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -185,24 +193,62 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
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.
`late(value)` (async): Nachzügler werden beim Quorum-Return NICHT gekillt, sondern
laufen detached weiter; jedes noch eintreffende valide Ergebnis geht an `late`.
Ersetzt den grace-Timer der Finder-Runden — der hielt die Runde bis 300 s offen,
nur damit die dritte Stimme zählt (gemessen: 73 s Warten pro Runde).
"""
attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {}
keys: dict[asyncio.Task, str] = {}
born: dict[asyncio.Task, float] = {}
hedged: set[int] = set() # slot got its one twin — no hedge cascades
fertig: set[int] = set() # slot delivered a valid result (late twins are ignored)
# Hedge-Schwelle relativ zum Call-Timeout (HEDGE_NACH_S = Untergrenze): pauschale 90 s
# hedgten jeden gesunden langen Call — z. B. Guide-Fixes, die normal 110135 s laufen.
hedge_s = max(_HEDGE_NACH_S, timeout / 2) if _HEDGE_NACH_S else 0
loop = asyncio.get_running_loop()
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()
spaet: set[int] = set() # je Slot zählt nur EIN spätes Ergebnis (Hedge-Zwilling = Echo)
def _detach_rest() -> None:
"""Quorum steht: Nachzügler an `late` übergeben statt killen (nur Erfolgs-Return)."""
if late is None:
return
for t, i in list(tasks.items()):
tasks.pop(t)
keys.pop(t, None)
born.pop(t, None)
async def _warte(t=t, i=i):
try:
r = await t
if i in spaet:
return
if r and r[0] == 0 and (val := slots[i]["payload"](r)) is not None:
spaet.add(i)
await late(val)
except (asyncio.CancelledError, Exception): # noqa: BLE001 — Nachzügler sind best-effort
pass
_detached(asyncio.create_task(_warte()))
for i in range(len(slots)):
spawn(i)
@@ -218,8 +264,20 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
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:
_detach_rest()
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: 160230 s per stall).
if hedge_s:
now = loop.time()
for t in [t for t in list(tasks) if tasks[t] not in hedged | fertig
and now - born[t] >= hedge_s]:
i = tasks[t]
hedged.add(i)
spawn(i, suffix="-h")
_log(topic, f"{label} {i + 1}: {round(hedge_s)}s ohne Ergebnis — Hedge-Zwilling gestartet")
# Wake up for the earliest relevant deadline (grace, min, max, or next hedge).
waits = []
if deadline is not None and len(results) >= quorum:
waits.append(deadline - loop.time())
@@ -227,12 +285,21 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
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,6 +316,10 @@ 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")
@@ -256,23 +327,26 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
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)):
_detach_rest()
return results
continue
_log(topic, f"{label} {i + 1} (attempt {attempts[i] + 1}): {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 attempts[i] <= _MAX_RESTARTS and not enough and not zwilling and not (cancelled and cancelled()):
spawn(i)
if len(results) >= quorum: # all slots done, minimum stands (only reachable with grace)
_detach_rest()
return results
_log(topic, f"{label}: quorum {quorum} not reached ({len(results)} valid)")
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)

View File

@@ -294,6 +294,24 @@ async def _llm_verdicts(template: str, topic: str, key: str, items: list[str]) -
# ── Report ──────────────────────────────────────────────────────────────────────────
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 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:
@@ -317,6 +335,11 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
hy = hygiene(blocks)
n_sections = sum(len(_sections(t)) for t in corpus.values()) or 1
frei = lade_freispruch(topic)
frei_fremd = set(frei.get("fremd") or [])
fremd_frei = [t for t in fr if _norm_title(t) in frei_fremd]
fr = [t for t in fr if _norm_title(t) not in frei_fremd]
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]])
@@ -334,6 +357,10 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
[f"A: {p['a']}\nB: {p['b']}" for p in chunk])
for k, p in enumerate(chunk, 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 = []
@@ -350,6 +377,8 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
v2 = await _llm_verdicts("QA-Bausteine", topic, "bausteine-b2",
[f"{b['title']}{b['description'] or '(ohne Beschreibung)'}" for b in verdacht])
unecht = [b["title"] for k, b in enumerate(verdacht, 1) if v2.get(k) == "nein"]
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,8 +391,10 @@ 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(),
@@ -377,6 +408,7 @@ async def qa_report(topic: str, llm: bool = False) -> dict | None:
},
"quoten_artefakte": quoten_art,
**({"unecht": unecht} if unecht is not None else {}),
**({"fremd_freigesprochen": fremd_frei} if fremd_frei else {}),
"dubletten": d, "sub_dubletten": sd, "luecken": lk, "fremd": fr, "beleg": bl, "hygiene": hy,
"artefakte": art,
"lauf": summary,
@@ -412,6 +444,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 +490,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()

View File

@@ -6,7 +6,6 @@ deterministisch, bestätigte Dubletten mergen (Zweitmeinung), Fremd/Unecht nur n
Gegen-Judge entfernen (fail-open: Zweifel/Fehler → behalten). Lücken brauchen Recherche,
Verwaiste den nächsten Board-2-Lauf — beides wird nur ausgewiesen."""
import asyncio
import json
import logging
import re
@@ -39,17 +38,18 @@ async def repair_befunde(topic: str) -> dict:
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)
sub_merges, frei_subs = await _merge_sub_dubletten(topic, report, files)
entfernt, frei_bloecke = await _entferne_fremd_unecht(topic, report, by_norm, files)
aufgeraeumt = await _raeume_waisen(topic)
# llm=True: gleiche Messlatte wie QA-Button/Abschluss-QA — der llm=False-Report
# blendete sub_dubletten aus und ließ die Note zwischen 10.0 und ~9 pendeln
neu = await qa.qa_report(topic, llm=True)
if neu:
await asyncio.to_thread(qa._write_report, neu)
await qa.write_report(neu)
return {"hygiene": hygiene, "merges": merges, "sub_merges": sub_merges, "entfernt": entfernt,
"aufgeraeumt": aufgeraeumt, "braucht_research": len(report.get("luecken", []))}
"aufgeraeumt": aufgeraeumt, "freigesprochen": frei_subs + frei_bloecke,
"braucht_research": len(report.get("luecken", []))}
async def _judge(template: str, topic: str, key: str, slot: str, items: list[str]) -> dict[int, str]:
@@ -70,6 +70,44 @@ async def _judge(template: str, topic: str, key: str, slot: str, items: list[str
return verdicts
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) -> 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)
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])
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_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."""
@@ -99,8 +137,8 @@ async def _merge_dubletten(topic: str, report: dict, by_norm: dict, files: dict)
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])
v, _frei = await _mit_stichentscheid("QA-Dubletten", topic, "dubletten", "pairs",
[f"A: {p['a']}\nB: {p['b']}" for p in paare], "ja")
merged = []
for i, p in enumerate(paare, 1):
a, b = by_norm.get(_norm_title(p["a"])), by_norm.get(_norm_title(p["b"]))
@@ -135,7 +173,32 @@ def _sub_gewinner(a: dict, b: dict) -> tuple[dict, dict]:
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 +213,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 +227,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 +261,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 +272,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 +284,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:

View File

@@ -1,6 +1,7 @@
fastapi
uvicorn[standard]
aiosqlite
httpx
playwright
trafilatura
pymupdf4llm

View File

@@ -9,9 +9,9 @@ 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 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,
@@ -175,7 +175,7 @@ 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
try: # fertiger Guide vorhanden → mitmessen (ein Klick, drei Noten); best-effort
import guide_qa
@@ -217,7 +217,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}
@@ -681,6 +681,27 @@ async def get_guide_board(topic: str, format: str = "Guide"):
return snap
@router.post("/guides/board/repair")
async def repair_guide_board(req: GuideBoardResetRequest):
"""Befunde beheben fürs Guide-Board: Karten mit QA-Befunden zurück auf `pruefer`,
dann resumt generate_guide die offenen Karten (Prüfer+Fix) und misst neu."""
import guide_board
topic = req.topic.strip()
guide = next((g for g in await list_guides()
if g["topic"] == topic and g["format"] == req.format), None)
if guide is None:
raise HTTPException(404, "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.post("/guides/board/reset")
async def reset_guide_board(req: GuideBoardResetRequest):
"""Reset cards from a stage onward — without generation (pendant to blocks reset-stage)."""

View File

@@ -0,0 +1,255 @@
"""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, "_opencode_recent_starts", [])
return monkeypatch
async def test_ram_gate_admits_with_free_ram(ram_gate):
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (4_000_000, 8_000_000)) # 50 % frei
assert await agents._ram_gate("k") is True
assert len(agents._opencode_recent_starts) == 1 # Commit registriert
async def test_ram_gate_waits_when_low(ram_gate):
ram_gate.setattr(agents, "_opencode_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"), 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") 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, "_opencode_running", 1) # unter Floor
assert await agents._ram_gate("k") is True
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: None) # kein /proc/meminfo
assert await agents._ram_gate("k") 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") 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."""
import time as _time
ram_gate.setattr(agents, "_opencode_running", 5)
ram_gate.setattr(agents, "_meminfo", lambda: (1_700_000, 8_000_000)) # 21 % frei
agents._opencode_recent_starts.extend([_time.monotonic(), _time.monotonic()])
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(agents._ram_gate("k"), 0.1)
async def test_ram_gate_cancelled_scope(ram_gate):
ram_gate.setattr(agents, "_opencode_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") 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

View File

@@ -0,0 +1,538 @@
"""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_gen_race(outputs):
"""_race-Fake: pro Generator-Slot (…-gN) die gescriptete Antwort als Text;
fehlender Eintrag = Ausfall."""
calls = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, **kw):
outs = []
for slot in slots:
calls.append(slot["key"])
g = int(slot["key"].rsplit("-g", 1)[1])
out = outputs.get(g)
if out is not None:
outs.append(slot["payload"]((0, json.dumps(out), "")))
return [o for o in outs if o] or None
fake_race.calls = calls
return fake_race
async def test_generate_schnittmenge_wird_consensus(env, monkeypatch):
"""Von beiden Generatoren genannt → consensus (Facts-Union); Einzelnennungen
werden unsicher und gehen zum Prüfer."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A", kp=["k1"]), _sub("Sub B")]},
2: {"subs": [_sub("Sub A", kp=["k2"]), _sub("Sub C")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert gen["facts"]["Alpha"]["sub a"]["key_points"] == ["k1", "k2"] # Union beider Nennungen
assert {u["title"] for u in gen["unsicher"]} == {"Sub B", "Sub C"}
assert gen["votes"]["sub a"]["level"] == ["beginner", "beginner"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["sub a"] == "consensus"
assert rows["sub b"] == rows["sub c"] == "candidate"
async def test_generate_degraded_alles_unsicher(env, monkeypatch):
"""Liefert nur EIN Generator, ist kein Konsens möglich — alles wird unsicher,
der Prüfer entscheidet mit Material."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A"), _sub("Sub B")]}, # g2 fällt aus
}))
gen = await bc._generate_block(ctx, files, "Alpha", "Grundkonzept")
assert gen["raw"] == {"Alpha": []}
assert {u["title"] for u in gen["unsicher"]} == {"Sub A", "Sub B"}
assert not any(r["status"] == "consensus" for r in await db.list_subblocks(TOPIC, "alpha"))
async def test_generate_beide_ausgefallen_ist_none(env, monkeypatch):
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({}))
assert await bc._generate_block(ctx, files, "Alpha", "d") is None
async def test_generate_seed_garantie(env, monkeypatch):
"""Ungedeckte Seeds gehen als unsicher zum Prüfer (Beleg-Gate liegt dort);
lexikalisch gedeckte Seeds erzeugen keine Dublette."""
db, ctx, files = env
monkeypatch.setattr(bc, "_race", _mk_gen_race({
1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]},
}))
gen = await bc._generate_block(ctx, files, "Alpha", "d",
seeds=["Escaping Regeln", "Sub"])
assert gen["raw"] == {"Alpha": ["Sub A"]}
assert [u["title"] for u in gen["unsicher"]] == ["Escaping Regeln"] # „Sub" ist gedeckt
assert gen["unsicher"][0]["key_points"] == [] # Seeds kommen ohne Beleg
async def test_generate_resume_ohne_neue_calls(env, monkeypatch):
"""Vorhandene gen-Dateien → kein neuer _race-Call, Ergebnis wird übernommen."""
db, ctx, files = env
fake = _mk_gen_race({1: {"subs": [_sub("Sub A")]}, 2: {"subs": [_sub("Sub A")]}})
monkeypatch.setattr(bc, "_race", 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 # alles 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_uebernahme_braucht_beide(env, monkeypatch):
"""Unsicher-Eintrag wird nur bei 2/2 „ja" consensus (samt Generator-Facts);
sonst discarded."""
db, ctx, files = env
subs = ["Sub A"]
unsicher = [_sub("Unsicher B", kp=["kp b"]), _sub("Unsicher C")]
await _seed_rows(db, "alpha", subs)
await _seed_rows(db, "alpha", ["Unsicher B", "Unsicher C"], status="candidate")
fake = _judge_slot({"1": {"uebernehmen": {"2": "ja", "3": "ja"}},
"2": {"uebernehmen": {"2": "ja", "3": "nein"}}})
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", "Unsicher B"]}
assert res["facts"]["Alpha"]["unsicher b"]["key_points"] == ["kp b"]
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert rows["unsicher b"] == "consensus" and rows["unsicher c"] == "discarded"
# der Prüfer-Prompt weist die Unsicher-Nummern aus
assert "UNSICHER" in fake.calls[0]["prompt"] and "entries 23" in fake.calls[0]["prompt"]
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"]}

View File

@@ -70,39 +70,36 @@ 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):
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
@@ -176,6 +173,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 +235,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),
@@ -680,18 +710,18 @@ 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):
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)
base_outline = ba._outline_block
@@ -700,7 +730,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 +748,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 +762,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 +806,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 ─────────────────────
@@ -874,7 +904,7 @@ async def _run_flow(ctx, files, timeout=30, **kw):
async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in subblocks."""
"""Note unter Schwelle → Flow endet sauber, Board-2-Karten warten in generate."""
import qa as qa_mod
db, ctx, files = board_env
@@ -886,7 +916,7 @@ async def test_qa_gate_pauses_on_bad_note(board_env, monkeypatch):
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
@@ -949,7 +979,7 @@ 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"]
from types import SimpleNamespace
@@ -1083,11 +1113,11 @@ 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
async def test_reset_generate_loescht_globale_dateien(testdb, tmp_path):
"""Reset auf Spalte generate: DB-Spiegel UND globale Sidecar-Dateien + ab-*-Resume-Slots
weg — Reste des Vor-Laufs würden sonst in den frischen Lauf zurückmergen."""
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")
arbeit = tmp_path / "arbeit"
(arbeit / "ab-alpha").mkdir(parents=True)
@@ -1096,7 +1126,7 @@ async def test_reset_subblocks_loescht_globale_dateien(testdb, tmp_path):
for k in ("sidecar", "facts", "sub_roh", "question_pattern", "artefakte"):
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 not (arbeit / "ab-alpha").exists()
@@ -1181,3 +1211,144 @@ 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"
# ── 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
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"

View File

@@ -84,8 +84,9 @@ 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"-sb-gen-.*-g1$", "modus": "fehler", "mal": 1}, # degraded: 1 Generator
{"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):

View File

@@ -156,7 +156,7 @@ async def test_restart_artefact_card_wipes_only_that_block(testdb):
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.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
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
@@ -175,10 +175,10 @@ async def test_guide_reset_card_single(testdb):
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
# ab_stage 3 (pruefer) 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"]
assert cards["beta"]["stage"] == "pruefer" and cards["beta"]["md"]
async def test_completeness_route(testdb, tmp_path, monkeypatch):

View File

@@ -30,19 +30,32 @@ 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():
"""12 nur-unbelegt-Claims verfallen (GATE_FIX_MIN); falsch/Lücken sind kritisch."""
leer = {"claims": [], "ziele": {}, "luecken": [], "ballast": [], "lese_probleme": []}
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "", "urteil": "unbelegt"}] * 2}, [])
assert z == [] and k is False
z, k = gb._auftraege({**leer, "claims": [{"text": "c", "grund": "w", "urteil": "falsch"}]}, [])
assert len(z) == 1 and k is True
z, k = gb._auftraege({**leer, "luecken": [{"ziel": "z1", "fehlt": "X"}]}, ["Länge 2000"])
assert len(z) == 2 and k is True
async def test_reset_from_stage(testdb):
@@ -50,7 +63,7 @@ async def test_reset_from_stage(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.set_guide_card(TOPIC, FMT, "b", stage="pruefer", 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)
@@ -70,8 +83,8 @@ async def test_done_step(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 +175,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 +190,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 +243,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 +282,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 +298,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 +318,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):
"""12 Claims → kein Fix-Rewrite (nur Log); ab GATE_FIX_MIN läuft der Fix."""
async def test_pruefer_claims_schwelle(testdb, tmp_path, monkeypatch):
"""12 nur-unbelegt-Claims → kein Fix (Karte direkt done); die Schwelle lebt in _auftraege."""
db = testdb
await db.upsert_guide_card(TOPIC, FMT, "alpha", "Alpha")
env = gb._Env(None, "g-g", TOPIC, FMT, "", tmp_path / "Guide.json", {"Alpha": []}, {}, "(q)", "spec")
md = "<!-- section: Alpha -->\n<!-- ausführlich -->\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 +352,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 +399,48 @@ 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"

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

View File

@@ -1,10 +1,10 @@
"""Sub-Konsolidierung: In-Block-Panel (blocks._konsolidiere_subblocks) und
Cross-Block-Barrier (board_artefacts._proc_konsolidierung) — Judges gefaked, gegen Test-DB."""
"""Cross-Block-Konsolidierung (board_artefacts._proc_konsolidierung) und Finalize —
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 json
import numpy as np
import pytest
import blocks
import board_artefacts as ba
@@ -39,218 +39,6 @@ async def _seed_block(db, bnorm, 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
@@ -266,91 +54,6 @@ def test_neg_set_lemmatisiert():
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
@@ -388,85 +91,88 @@ class _FakeEmb:
return arr @ arr.T
async def _cross_env(db, tmp_path):
async def _cross_env(db, tmp_path, finalisiert=True):
"""Zwei finalisierte Karten in der End-Barriere; die Sub-Rows liegen in der DB
(post-finalize ist die DB die Wahrheit, nicht mehr das Karten-Payload)."""
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}}}
payload = {"title": bnorm.title()}
if finalisiert:
payload.update(pattern={}, artefacts={})
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
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
return flow, cards, files
async def test_crossblock_folds_loser(testdb, tmp_path, monkeypatch):
"""Einstimmig „a" → Beta verliert die geteilte Aussage, Karten wandern zu levels."""
"""Einstimmig „a" → Betas geteilte Aussage wird variant, ihre Frage wandert zum
Gewinner (falte_sub), Karten gehen auf DONE."""
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
sn = blocks._norm_title("Gleiche Aussage")
await db.upsert_question_pattern(TOPIC, "beta", sn, "Beta", "Gleiche Aussage", "F?")
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)
await ba._proc_konsolidierung(_ctx(), flow, files, "", 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"
for cid in ("alpha", "beta"):
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
beta_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert beta_rows[sn] == "variant"
alpha_rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert alpha_rows[sn] == "consensus"
fragen = await db.list_question_pattern(TOPIC)
assert {(r["block_norm"], r["sub_norm"]) for r in fragen} == {("alpha", sn)} # umgehängt
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)
flow, cards, files = 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)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert len(fake.calls) == 3
beta = await db.kanban_get_card(TOPIC, "artefacts", "beta")
assert beta["payload"]["raw"]["Beta"] == ["Nur in Beta"]
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_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)
flow, cards, files = 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"]
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == ba.DONE
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
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)
flow, cards, files = 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"]
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
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_without_embedding_advances(testdb, tmp_path, monkeypatch):
db = testdb
flow, cards = await _cross_env(db, tmp_path)
flow, cards, files = await _cross_env(db, tmp_path)
class _Aus:
@staticmethod
@@ -478,33 +184,29 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
monkeypatch.setattr(ba, "embedding", _Aus)
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
await ba._proc_konsolidierung(_ctx(), flow, {}, "", cards)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
for cid in ("alpha", "beta"):
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == "question_pattern"
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
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."""
async def test_crossblock_nachzuegler_zurueck_zum_erzeugen(testdb, tmp_path, monkeypatch):
"""Resume-Karte ohne pattern im Payload → zurück nach generate (bzw. artefakte bei
vorhandenem sidecar), KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
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"])
flow, cards, files = await _cross_env(db, tmp_path, finalisiert=False)
cards[1]["payload"]["sidecar"] = {"Beta": []} # hat Verify schon hinter sich
await db.kanban_set_payload(TOPIC, "artefacts", "beta", cards[1]["payload"])
async def kein_agent(*a, **kw):
raise AssertionError("Nachzügler dürfen keinen Dedup auslösen")
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
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
await ba._proc_konsolidierung(_ctx(), flow, files, "", cards)
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "generate"
assert (await db.kanban_get_card(TOPIC, "artefacts", "beta"))["stage"] == "artefakte"
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert rows[blocks._norm_title("Gleiche Aussage")] == "consensus"
async def test_finalize_defaultet_level_nachzuegler(testdb, tmp_path):
@@ -547,62 +249,17 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
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}}}
payload = {"title": bnorm.title(), "pattern": {}, "artefacts": {}}
await db.kanban_upsert_card(TOPIC, "artefacts", bnorm, "ablock", "konsolidierung", payload)
await _seed_block(db, bnorm, subs)
cards.append({"card_id": bnorm, "payload": payload})
files = {k: tmp_path / f"{k}.json" for k in
("sub_roh", "facts", "sidecar", "question_pattern", "artefakte")}
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)
await ba._proc_konsolidierung(_ctx(), flow, files, "", 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"]}
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet

View File

@@ -1,5 +1,7 @@
"""QA-Detektoren: Fehler-Injektion auf Mini-Korpus — deterministisch, ohne LLM/Embedding."""
import json
import qa
@@ -238,6 +240,25 @@ async def test_unecht_braucht_doppelt_nein(testdb, tmp_path, monkeypatch):
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

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

@@ -0,0 +1,156 @@
"""_race-Hedging: Stall-Slots bekommen einen parallelen Zwilling statt den Timeout-Cap
abzuwarten (gemessen: 4 Panel-Stalls à 160230 s pro Lauf auf dem kritischen Pfad)."""
import asyncio
import pipeline
def _slot(payload=lambda r: r[1]):
return {"key": "k1", "prompt": "p", "role": "judge", "capabilities": "none", "payload": payload}
async def test_hedge_zwilling_rettet_stall(monkeypatch):
"""Original stallt → nach HEDGE_NACH_S startet der Zwilling (key -h), sein Ergebnis
gewinnt, das hängende Original wird gekillt."""
calls, killed = [], []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
return (0, "zwilling", "")
await asyncio.sleep(30) # Stall — würde sonst den ganzen Cap verbrennen
return (0, "original", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"]
assert "k1" in killed # das hängende Original läuft nicht weiter
async def test_hedge_original_gewinnt_zwilling_wird_gekillt(monkeypatch):
"""Kommt das Original doch noch vor dem Zwilling an, wird der Zwilling gekillt
und sein spätes Ergebnis nicht gewertet."""
killed = []
async def fake_agent(key, prompt, timeout, **kw):
await asyncio.sleep(0.3 if key.endswith("-h") else 0.15)
return (0, key, "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["k1"]
assert "k1-h" in killed
async def test_hedge_aus_bei_null(monkeypatch):
"""HEDGE_NACH_S=0 → kein Zwilling, Verhalten wie zuvor."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.1)
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_hedge_schwelle_skaliert_mit_timeout(monkeypatch):
"""Die Schwelle ist max(HEDGE_NACH_S, timeout/2): ein gesunder Call, der länger als
die Untergrenze, aber kürzer als das halbe Timeout läuft, bekommt KEINEN Zwilling
(pauschale 90 s hedgten jeden normalen Fix-Call)."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
await asyncio.sleep(0.15) # > Untergrenze 0.05, < timeout/2 = 0.5
return (0, "ok", "")
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 1.0, "claude")
assert res == ["ok"]
assert calls == ["k1"]
async def test_late_fold_nachzuegler_zaehlt_nach(monkeypatch):
"""Quorum 2 kehrt sofort zurück; der dritte Slot wird nicht gekillt, sein Ergebnis
geht an `late` (ersetzt den grace-Timer der Finder-Runden)."""
import time
killed, spaet = [], []
async def fake_agent(key, prompt, timeout, **kw):
if key == "k3":
await asyncio.sleep(0.2)
return (0, "dritter", "")
return (0, key, "")
async def late(val):
spaet.append(val)
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: killed.append(k))
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
"payload": lambda r: r[1]} for i in (1, 2, 3)]
t0 = time.monotonic()
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
assert time.monotonic() - t0 < 0.15 # kein Warten auf k3
assert sorted(res) == ["k1", "k2"]
assert "k3" not in killed
await asyncio.sleep(0.3)
assert spaet == ["dritter"]
async def test_late_fold_invalider_nachzuegler_ignoriert(monkeypatch):
"""Nachzügler mit invalidem Payload löst late NICHT aus (best-effort)."""
spaet = []
async def fake_agent(key, prompt, timeout, **kw):
if key == "k3":
await asyncio.sleep(0.1)
return (1, "", "kaputt")
return (0, key, "")
async def late(val):
spaet.append(val)
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0)
slots = [{"key": f"k{i}", "prompt": "p", "role": "quick", "capabilities": "none",
"payload": lambda r: r[1]} for i in (1, 2, 3)]
res = await pipeline._race("t", "Test", slots, 2, 60, "claude", late=late)
assert res is not None
await asyncio.sleep(0.25)
assert spaet == []
async def test_hedge_zwilling_ersetzt_restart(monkeypatch):
"""Scheitert das Original, während der Zwilling noch läuft, gibt es KEINEN
zusätzlichen Restart — der Zwilling ist der Retry."""
calls = []
async def fake_agent(key, prompt, timeout, **kw):
calls.append(key)
if key.endswith("-h"):
await asyncio.sleep(0.2)
return (0, "zwilling", "")
await asyncio.sleep(0.1)
return (1, "", "kaputt") # Fehler NACH dem Hedge-Start
monkeypatch.setattr(pipeline, "run_agent", fake_agent)
monkeypatch.setattr(pipeline, "kill_process", lambda k: None)
monkeypatch.setattr(pipeline, "_HEDGE_NACH_S", 0.05)
res = await pipeline._race("t", "Test", [_slot()], 1, 0.1, "claude")
assert res == ["zwilling"]
assert calls == ["k1", "k1-h"] # kein dritter Spawn

View File

@@ -86,6 +86,8 @@ 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)
@@ -204,6 +206,74 @@ async def test_sub_dubletten_zweitmeinung_nein(env, monkeypatch):
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
monkeypatch.setattr(repair, "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
monkeypatch.setattr(repair, "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
monkeypatch.setattr(repair, "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."""

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"
@@ -361,62 +151,18 @@ def test_sink_json_writes_only_valid(tmp_path):
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():

View File

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

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,7 +41,7 @@ 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, "",

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:

View File

@@ -18,18 +18,10 @@ 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"},
@@ -37,12 +29,12 @@ PARAMS: dict[str, dict] = {
"EMBEDDING_BLOCK_FLOOR": {"default": 0.5, "min": 0.35, "max": 0.65, "step": 0.05, "kategorie": "auswahl", "fidelity": "voll"},
"CROSS_CHUNK_PAARE": {"default": 40, "min": 15, "max": 80, "step": 10, "kategorie": "laufzeit", "fidelity": "board2"},
# Guide
"MAX_WRITER_ROUNDS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"GATE_FIX_MIN": {"default": 3, "min": 1, "max": 6, "step": 1, "kategorie": "qualitaet", "fidelity": "board2"},
"WRITER_SPLIT_SUBS": {"default": 30, "min": 15, "max": 45, "step": 5, "kategorie": "qualitaet", "fidelity": "board2"},
# Engine / Kosten
"CONSENSUS_GRACE": {"default": 300, "min": 0, "max": 600, "step": 60, "kategorie": "laufzeit", "fidelity": "board2"},
"MAX_RESTARTS": {"default": 2, "min": 1, "max": 3, "step": 1, "kategorie": "laufzeit", "fidelity": "board2"},
"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"},
}

View File

@@ -7,6 +7,7 @@ services:
environment:
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- MINIMAX_API_KEY=${MINIMAX_API_KEY:-}
- DEFAULT_PROVIDER=${DEFAULT_PROVIDER:-}
networks:
- web
volumes:

View File

@@ -135,6 +135,16 @@ export async function resetGuideBoard(topic, format, abStage) {
return jsonOrThrow(res)
}
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export async function repairGuideBoard(topic, format) {
const res = await fetch(`${BASE}/guides/board/repair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, ab_stage: 0 }),
})
return jsonOrThrow(res)
}
export async function cancelBlocks(topic) {
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
}

View File

@@ -89,3 +89,46 @@
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);
padding-left: 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);
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;
}

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'
@@ -98,7 +98,25 @@ 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 body = compact ? (sub.compact || sub.md) : (sub.md || sub.compact)
if (body && body.trim()) out.push(seg(sub.level, body))
}
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 +194,28 @@ 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">
<span v-if="seg.level" class="sub-stufe-badge"
:title="`Stufe ${VIEW_KURZ[SUB_RANK[seg.level] || 1]}`">{{ VIEW_KURZ[SUB_RANK[seg.level] || 1] }}</span>
<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="Apply" @click="applyBlock(b.i)"></button>
<button class="bv-btn" title="Discard" @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="Extra info → check again" @keyup.enter="sendBlockEdit(b.i)" />
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)"></button>
</div>
</template>
</div>
</template>
</div>

View File

@@ -106,10 +106,10 @@ async function repairClick() {
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)
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
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`
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt · ${(r.freigesprochen || []).length} freigesprochen`
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard } from '../api.js'
import { fetchGuideBoard, repairGuideBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
const props = defineProps({
@@ -32,7 +32,7 @@ const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
const sel = ref(null)
const selCard = ref(null)
const confirm = ref(null)
@@ -63,6 +63,23 @@ function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
repairBusy.value = true
repairInfo.value = ''
try {
const r = await repairGuideBoard(props.topic, props.format)
repairInfo.value = (r.betroffen || []).length
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
: 'keine Befunde im letzten QA-Report'
if ((r.betroffen || []).length) startPoll()
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
}
}
function restartHere() {
const s = sel.value
sel.value = null
@@ -93,6 +110,9 @@ function resetHere() {
<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 && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<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>

View File

@@ -56,6 +56,9 @@ function htmlFor(s) {
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
if (props.stufeAnsicht === 'auto' && lvl > 1 && rank === lvl) {
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
} else if (Number(props.stufeAnsicht) === 4) {
// Vollansicht: jede Stufe dezent kennzeichnen (Rand + Badge in Stufen-Farbe)
parts.push(`<div class="sub-stufe" style="--stufe-farbe:${VIEW_FARBE[rank]}"><span class="sub-stufe-badge" title="Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
} else {
parts.push(body)
}
@@ -765,24 +768,5 @@ function extractContext() {
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>

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, 12 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}

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 12 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` (25 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}

View File

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

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**: 25 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}

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}

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)").
- "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": "…"}}

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}

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**: 13 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}

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}

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}

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}

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}

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}

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}

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}

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}

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}

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}

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
Several agents have rated the subblocks of the topic "{topic}" by relevance (relevant/peripheral). For some they disagree. Decide the final relevance for each disputed subblock.
DISPUTED SUBBLOCKS (with the votes cast):
{disputed}
Relevance:
- **relevant**: core/standard knowledge that carries the topic — central concepts, definitions, properties. For theoretical topics, also central theorems with no practical use.
- **peripheral**: a side note, niche/special case, "nice to know", deprecated/exotic, purely historical details, concrete numeric examples, mere cross-references. NOT peripheral: a central theorem or a fundamental property. For the complete guide only.
Rules:
- Weigh the votes and decide by the criterion **core vs. peripheral within ITS OWN block**. `peripheral` is a genuine category — mark peripheral items deliberately as such. Only true core concepts/central theorems are never `peripheral`.
- Exactly one value for EACH disputed number.
Reply with ONLY the JSON — no other text, no code fences.
Format (no other text):
{{"relevance": {{"1": "relevant", "4": "peripheral"}}}}
{extra}

View File

@@ -1,24 +0,0 @@
Rate each subblock of the topic "{topic}" by relevance: relevant or peripheral. The relevance controls whether a point goes into the focused guides or only into the complete guide.
SUBBLOCKS (in brackets the parent block of the number; many state their core points with "Kern:"):
{subblocks}
Use the core points for a well-grounded rating — judge on the content, not just the title.
Relevance:
- **relevant**: core/standard knowledge that CARRIES the topic — central concepts, definitions, properties, common practice. For practical topics: what you need in actual use. For theoretical topics: central theorems/properties, EVEN without practical use. What you genuinely should know.
- **peripheral**: a side note, special/niche case, "nice to know", deprecated/exotic — PLUS purely historical details (who/when), concrete numeric examples of a general principle, mere cross-references. NOT peripheral: a central theorem or a fundamental property just because it seems "theoretical". Belongs only in a complete guide.
Rules:
- These subblocks **all already** belong to the topic — the question is not *whether* but *how central*. Judge **core vs. peripheral within its own block**, one at a time.
- **The basic syntax / core rule of a block — what the block TITLE promises — is NEVER peripheral.** A block "Headings" without its heading syntax marked relevant is broken. Check this per block before anything else.
- **When the BLOCK itself is a fringe/extension feature** (non-standard, niche, an add-on rather than core of the topic), only its basic syntax and purpose are relevant — its detail rules and special cases are `peripheral`. A fringe feature must not be decomposed deeper than the core topics (measured: a non-standard block carried 22 "relevant" detail rules while core blocks had 6).
- `peripheral` is a **genuine category** for clear cases: nice-to-know, special/niche cases, detail/numeric examples, historical notes, cross-references, style tips.
- `peripheral` items are CUT from the focused guides — a wrong `peripheral` loses content, a wrong `relevant` only adds a paragraph. **When in doubt → relevant.**
- Only judge — invent nothing, change no subblocks.
Reply with ONLY the JSON — no other text, no code fences.
Format (exactly one value for EACH number; no other text):
{{"relevance": {{"1": "relevant", "2": "peripheral", "3": "relevant"}}}}
{extra}

View File

@@ -0,0 +1,22 @@
You are correcting and completing the subblocks of ONE block of the topic "{topic}" — strictly from the source excerpts.
BLOCK: {block}
{source}
TASKS:
{auftraege}
Rules:
- Work ONLY from the excerpts. Invent no values, compute nothing yourself.
- For corrections: return the subblock with its EXACT title and the complete corrected fact set.
- For gaps: ONE new subblock per gap, atomic, GERMAN title (max ~10 words), fully backed — if the excerpts don't support it, leave it out entirely.
- Field rules as always: key_points 13; cited_facts only backable with location; example_idea free; level beginner|advanced|expert; relevance relevant|peripheral. All content in GERMAN.
Reply with ONLY the JSON — no code fences, no other text. EXACTLY this format (empty list if nothing is backable):
{{"subs": [
{{"title": "…", "level": "…", "relevance": "…",
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
]}}
{extra}

View File

@@ -0,0 +1,35 @@
Break the block below (topic "{topic}") into its SUBBLOCKS — the individual learnable sub-points — and extract per subblock the learning facts from the material. A later guide writes a short text PER subblock; the facts are the binding basis for guide text, levels, and exam questions — they must be **correct**.
BLOCK:
{block}
{source}
What a subblock is:
- A single sub-point you must learn for the block. One statement, one aspect, one pitfall.
- Examples: block `<img>``src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images. Block `<p>` → text paragraph as a block, allowed inline children, no nesting.
DECISIVE — the count follows the difficulty:
- A simple, trivial block has FEW subblocks (12). Do NOT inflate it.
- A complex, rich block has MANY subblocks. Leave out nothing essential the material covers.
- Each subblock is atomic (one sub-point), backed by the excerpts — a point the excerpts do not support is left out.
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
{seeds}
Per subblock, collect ONLY the essentials (all field content in GERMAN; technical terms/code identifiers stay original):
- **level**: beginner | advanced | expert — difficulty within this block.
- **relevance**: relevant (core of the topic) | peripheral (edge knowledge).
- **key_points**: 13 concise statements — what must one understand?
- **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) — **only what the excerpts back**, each with the location (e.g. „Skript Def. 6.3, Z.66"). Invent no values, compute nothing yourself.
- **example_idea**: ONE example that carries understanding — freely phrased. Empty if an example adds nothing.
HARD SEPARATION: `cited_facts` = only backable material. A worked example, an invented sentence, a constructed case → `example_idea`, NEVER `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 other text. EXACTLY this format:
{{"subs": [
{{"title": "…", "level": "beginner|advanced|expert", "relevance": "relevant|peripheral",
"key_points": ["…"], "prerequisites": "…", "hurdles": "…",
"cited_facts": [{{"text": "…", "source": "…"}}], "example_idea": "…"}}
]}}
{extra}

View File

@@ -1,29 +0,0 @@
Du prüfst die Zerlegung EINES Lernbausteins in Subbausteine für das Thema "{topic}". Jeder Subbaustein wird später ein eigener Absatz im Lernguide. Ziel ist eine 100%-Zerlegung: keinen Eintrag kann man weglassen, ohne dass eine Lücke entsteht — und keinen ergänzen, ohne dass Dopplung entsteht.
BLOCK: {block}
SUBBAUSTEINE (nummeriert, mit ihren Kernpunkten):
{subs}
## Auftrag (vier Urteile)
1. **gruppen — Dopplungen**: Einträge, die DIESELBE Aussage treffen (anders formuliert) oder reine TEILMENGE eines anderen sind. Pro Gruppe: `haupt` = der GRUNDLEGENDSTE, allgemeinste Eintrag (Basis vor Detail, Grundsyntax vor Sonderfall, nie ein inhaltsleerer Stub).
2. **kataloge — Aufzählungs-Bündel**: Einträge, die REINE Nachschlage-Listen gleicher Art sind (z. B. Symbol-, Befehls-, Namenslisten ohne eigene Verhaltensregel). Solche Listen bündelst du zu EINEM Katalog-Eintrag mit sprechendem Titel. Einträge mit eigener Regel oder eigenem Verhalten gehören NICHT in ein Bündel.
3. **fremd — Weglass-Test**: Einträge, deren Aussage nicht zum Thema "{topic}" gehört (anderes Fachgebiet, Grundlagen einer anderen Technologie) — auch wenn die Aussage stimmt und belegbar ist. Ließe man sie weg, fehlte dem Thema nichts.
4. **luecken**: Ein KERN-Aspekt des Blocks fehlt — ohne ihn ist die Zerlegung UNVOLLSTÄNDIG (die Grundregel oder ein Pflichtbestandteil fehlt). Katalog-, Detail- und Randwissen ist KEINE Lücke. Was man ergänzen KÖNNTE, ist keine Lücke — nur was fehlen DARF nicht. Im Zweifel: keine Lücke. Meist ist die Liste leer.
## Regeln — der häufigste Fehler zuerst
- **Verschiedene Schreibweisen, Marker, Befehle oder Syntaxen sind IMMER eigene Einheiten** — auch wenn sie derselben Kategorie angehören oder im selben Satz erklärt werden könnten. „Gehört zusammen" oder „ähnliches Thema" ist KEIN Gruppierungs-Grund. Nur „sagt DASSELBE" zählt.
- Zwei Einträge zum gleichen Konzept mit VERSCHIEDENEN Facetten (Grundregel vs. Sonderfall vs. Randbedingung) sind KEINE Dopplung.
- Eine benannte Variante oder ein Spezialfall ist NIE Dopplung seiner Basis.
- Einträge mit gegensätzlicher Aussage (Negation) nie gruppieren.
- Ein inhaltsleerer oder generischer Eintrag, dessen Titel ein anderer Eintrag vollständig abdeckt, ist Teilmenge → gruppieren, der konkrete Eintrag ist `haupt`.
- Im Zweifel: NICHT gruppieren, NICHT bündeln, NICHT als fremd markieren.
Antworte NUR mit JSON, ohne weiteren Text:
{{"gruppen": [{{"haupt": 1, "weitere": [4]}}], "kataloge": [{{"titel": "Symbolkatalog: Operatoren und Relationen", "mitglieder": [2, 5, 9]}}], "fremd": [7], "luecken": ["fehlender Kernaspekt"]}}
- gruppen: pro Gruppe haupt + weitere (Nummern). Keine Dopplungen → [].
- kataloge: pro Bündel Titel + Mitglieds-Nummern. Keine → [].
- fremd: Nummern themenfremder Einträge. Keine → [].
- luecken: fehlende Kernaspekte in Stichworten. Keine → [].
{extra}

View File

@@ -1,27 +0,0 @@
Below, for each block of the topic "{topic}", are the subblocks found. Return the **cleaned** final list per block.
{source}
FOUND SUBBLOCKS (two groups per block):
{blocks}
The groups:
- **Consensus (≥2 finders):** found independently multiple times — keep by default. Remove only fabrications/duplicates.
- **Uncertain (1×):** named by only one finder — **scrutinize strictly**. Include an uncertain entry ONLY if it is **clearly backed by the source AND a standalone point**. When in doubt, leave it out.
Rules:
- **Evidence check (important):** Check each subblock against the source. **Discard whatever is NOT backable in the material or clearly invented** — fabricated bounds, formulas, values, or claims not actually in the source. With source material (folder or excerpts): check against it. Without a source (pure topic): keep only established standard knowledge, drop the doubtful/false.
- **Merge duplicates:** Subblocks that state the same point in other words are ONE. Keep the clearest, drop the rephrasings. (e.g. „Broadcast Mode verfügbar" and „Broadcast-Modus aktivieren" → one.)
- Keep all technically **DISTINCT** and backable sub-points in full — leave out nothing essential.
- Discard whatever is too fine-grained, at the edge of the topic, or technically doubtful.
- Each point atomic (one statement). Copy kept points VERBATIM, do not rephrase, invent nothing.
- The count follows the difficulty: better few distinct than many redundant points.
Reply with ONLY the final lists — no other text, no code fences. One block marker per block (title EXACTLY as above), with the final subblock list below it:
<!-- block: Exact block title -->
- Subblock
- Subblock
Output the marker line exactly like this. Every block must appear. No text outside the blocks.
{extra}

View File

@@ -1,29 +0,0 @@
Break each assigned block of the topic "{topic}" into its SUBBLOCKS — the individual learnable sub-points the concept is made of. A later guide will write a short text PER subblock.
Assigned to you — binding: every block must appear, invent no additional ones:
{assignment}
What a subblock is:
- A single sub-point you must learn for the block. One statement, one aspect, one pitfall.
- Examples: block `<img>``src` (Bildquelle), `alt` (Alternativtext), empty `alt` for decorative images, `width`/`height` against layout shifts, void element without a closing tag. Block `<p>` → text paragraph as a block, allowed inline children, no nesting.
DECISIVE — the count follows the difficulty:
- A simple, trivial block has FEW subblocks (12). Do NOT inflate it.
- A complex, rich block has MANY subblocks. Leave out nothing essential.
- There is NO target count. `<img>` must have noticeably more subblocks than `<p>`.
Rules:
- Each subblock is atomic: exactly one sub-point. No two aspects in one point.
- Only what THIS block yields (scope). Don't inflate anything mentioned only in passing.
- Backed, not invented. Verify uncertain points via web search.
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
Write ONLY the file {out_path} — one block marker per block (title EXACTLY from the assignment), with the subblocks as a list below it:
<!-- block: Exact block title -->
- First subblock
- Second subblock
Write the marker line exactly like this. No text outside the blocks.
{known}
{extra}

View File

@@ -0,0 +1,26 @@
You are auditing the decomposition of ONE block of the topic "{topic}" into subblocks. Below are the numbered subblocks with their captured facts, then source excerpts. Apply the 100%-decomposition test: no entry removable without a gap, none addable without duplication — and verify the facts.
BLOCK: {block}
SUBBLOCKS (numbered; key points indented):
{subs}
{unsicher}
{source}
Judge ALL of the following (use the numbers):
1. **gruppen** — entries that state the SAME thing or where one is a subset of the other: group them and name the number that should remain (`haupt` = the base statement, not the detail).
2. **kataloge** — pure enumeration entries of ONE kind (e.g. five option rows): bundle them under ONE short GERMAN collective title.
3. **fremd** — entries off-topic for the topic "{topic}" (not this block — the TOPIC).
4. **luecken** — essential aspects of THIS block that the excerpts cover but no entry captures (short German phrases). Only real gaps, no nice-to-haves.
5. **uebernehmen** — for each UNSICHER-numbered entry: "ja" if the excerpts back it and it fills a real spot, else "nein".
6. **facts_probleme** — entries whose facts contain something wrong or unbackable: `discard: true` ONLY if the entry as a whole is unsupportable in substance; otherwise `discard: false` with a short `hinweis` what to correct.
7. **levels** / **relevanz** — ONLY entries whose level (beginner/advanced/expert) or relevance (relevant/peripheral) is clearly wrong: number → correct value.
Judge ONLY from the excerpts. If everything is fine, return empty lists/objects.
Reply with ONLY the JSON — no code fences, no other text. Format:
{{"gruppen": [{{"haupt": 1, "weitere": [4]}}], "kataloge": [{{"titel": "…", "mitglieder": [2, 5]}}],
"fremd": [7], "luecken": ["…"], "uebernehmen": {{"9": "ja"}},
"facts_probleme": [{{"nr": 3, "discard": false, "hinweis": "…"}}],
"levels": {{"2": "expert"}}, "relevanz": {{"5": "peripheral"}}}}
{extra}