This commit is contained in:
Team3
2026-07-04 23:47:04 +02:00
parent 2d9ca00b47
commit 4105146c59
40 changed files with 2053 additions and 3208 deletions

View File

@@ -12,3 +12,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

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

686
backend/block_calls.py Normal file
View File

@@ -0,0 +1,686 @@
"""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) -> 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")
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) -> 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]
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():
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 = "") -> 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": []}}
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:
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,14 +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 parallel) → 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. 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.
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."""
re-run once per generation run — outline läuft parallel zur Dedup-Barriere."""
import asyncio
import hashlib
@@ -19,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
@@ -100,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
@@ -172,88 +167,76 @@ 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):
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)
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"))
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} · ")
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)
@@ -290,8 +273,8 @@ async def _proc_konsolidierung(ctx: GenContext, flow: Flow, files: dict, instruc
# 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"], "question_pattern") for c in cards
if "pattern" not in c["payload"]]
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()
@@ -417,101 +400,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, "question_pattern")
await _gather_cards(ctx, flow, cards, one)
async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
"""Fragen UND Artefakte im Fächer: beide brauchen nur den sidecar, nichts voneinander —
als Stage-Treppe kosteten sie zwei serielle Call-Segmente auf dem kritischen Pfad.
Die artefacts-Stage bleibt für Resume-Karten alter Läufe registriert."""
topic = flow.topic
async def one(c):
p = c["payload"]
norm = c["card_id"]
pattern, artefacts = await asyncio.gather(
_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)} · "),
_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 pattern is None:
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
p["pattern"] = pattern
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)
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)
@@ -627,18 +515,33 @@ 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)),
Stage(BOARD, "question_pattern",
lambda cs: _proc_question_pattern(ctx, flow, files, instructions, cs)),
# Resume-Pfad: Karten alter Läufe, die noch in artefacts stehen
Stage(BOARD, "artefacts", lambda cs: _proc_artefacts(ctx, flow, files, instructions, cs)),
Stage(BOARD, "generate", lambda cs: _proc_generate(ctx, flow, files, instructions, cs)),
Stage(BOARD, "verify", lambda cs: _proc_verify(ctx, flow, files, q, instructions, cs)),
Stage(BOARD, "artefakte", lambda cs: _proc_artefakte(ctx, flow, files, instructions, cs)),
Stage(BOARD, "finalize", lambda cs: _proc_finalize(ctx, flow, files, cs), serial=True),
# Cross-Block-Dedup als END-Barriere: als Mittel-Barriere idelte jede fertige Karte
# auf die langsamste (8:46 min/Block gemessen); jetzt faltet sie nach finalize

View File

@@ -1669,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):
@@ -1712,7 +1715,7 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
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
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
@@ -1834,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"),
]
@@ -1849,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}
@@ -1928,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,
@@ -1997,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)
@@ -2013,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)
@@ -2024,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).
@@ -186,6 +194,12 @@ 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)
@@ -225,6 +239,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)

View File

@@ -140,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:
@@ -162,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:

View File

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

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):
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):
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=""):
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
@@ -238,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):
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),
@@ -712,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):
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
@@ -732,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),
@@ -750,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",
@@ -764,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
@@ -808,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 ─────────────────────
@@ -906,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
@@ -918,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
@@ -981,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
@@ -1115,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)
@@ -1128,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()

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

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
@@ -486,11 +189,13 @@ async def test_crossblock_without_embedding_advances(testdb, tmp_path, monkeypat
assert (await db.kanban_get_card(TOPIC, "artefacts", cid))["stage"] == ba.DONE
async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkeypatch):
"""Resume-Karte aus der alten Stage-Position (kein pattern im Payload) → zurück nach
question_pattern, KEIN Dedup — finalize würde den Fold sonst re-spiegeln."""
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, 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")
@@ -498,8 +203,8 @@ async def test_crossblock_nachzuegler_zurueck_zu_fragen(testdb, tmp_path, monkey
monkeypatch.setattr(ba, "embedding", _FakeEmb)
monkeypatch.setattr(ba, "run_single_slot", kein_agent)
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", "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"
@@ -558,47 +263,3 @@ async def test_crossblock_chunking_faltet_global(testdb, tmp_path, monkeypatch):
assert len(fake.calls) == 4 # 2 Chunks × j1/j2
rows = {r["sub_norm"]: r["status"] for r in await db.list_subblocks(TOPIC, "beta")}
assert set(rows.values()) == {"variant"} # beide Dubletten global gefaltet
async def test_facts_nachfass_holt_nur_fehlende(testdb, tmp_path, monkeypatch):
"""Nachfass ruft den slim-Facts-Lauf NUR mit den facts-losen Subs und merged die Funde;
Vorhandenes bleibt unberührt, Subs werden nie verworfen."""
gesehen = {}
async def fake_facts_block(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl="",
sources=None, slim=False):
gesehen["raw"] = raw
gesehen["slim"] = slim
return ({"Alpha": {"ohne beleg": {"key_points": ["kp neu"]},
"mit beleg": {"key_points": ["DARF NICHT GEWINNEN"]}}},
{"Alpha": {"ohne beleg"}}) # discard-Urteil wird ignoriert
monkeypatch.setattr(blocks, "_facts_block", fake_facts_block)
raw = {"Alpha": ["Mit Beleg", "Ohne Beleg"]}
facts_map = {"Alpha": {"mit beleg": {"key_points": ["kp alt"]}}}
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
n = await blocks._facts_nachfass(ctx, {"arbeit": tmp_path}, raw, facts_map, {}, None)
assert n == 1
assert gesehen["slim"] and gesehen["raw"] == {"Alpha": ["Ohne Beleg"]}
assert facts_map["Alpha"]["ohne beleg"]["key_points"] == ["kp neu"]
assert facts_map["Alpha"]["mit beleg"]["key_points"] == ["kp alt"]
assert raw["Alpha"] == ["Mit Beleg", "Ohne Beleg"] # kein Verwurf
async def test_levels_merge_fuzzy_match(testdb, tmp_path, monkeypatch):
"""Levels-Agent paraphrasiert den Sub-Titel → facts hängen trotzdem am Sidecar-Eintrag
(eindeutiger Präfix-Match statt stillem Grounding-Verlust)."""
db = testdb
payload = {"title": "Alpha", "raw": {"Alpha": ["Marker Regel: Details dazu"]},
"facts": {"Alpha": {blocks._norm_title("Marker Regel: Details dazu"):
{"key_points": ["kp"]}}}}
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels", payload)
cards = [{"card_id": "alpha", "payload": payload}]
async def fake_levels_block(ctx, set_p, files, raw, instructions, ns="", lbl=""):
return {"Alpha": [{"title": "Marker Regel", "level": "beginner"}]} # gekürzter Titel
monkeypatch.setattr(ba, "_levels_block", fake_levels_block)
await ba._proc_levels(_ctx(), Flow(TOPIC, work_dir=tmp_path), {"arbeit": tmp_path}, "", cards)
card = await db.kanban_get_card(TOPIC, "artefacts", "alpha")
assert card["payload"]["sidecar"]["Alpha"][0]["facts"] == {"key_points": ["kp"]}

View File

@@ -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,157 +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, late=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: # Finder antworten als TEXT (Marker-Format), kein out_path mehr
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
outs.append(slot["payload"]((0, text, "")))
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
text = "<!-- block: Alpha -->\n- Vertiefung der Konzepte"
return [slot["payload"]((0, text, "")) 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():
@@ -238,52 +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):
text = "<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!"
return [slot["payload"]((0, text, "")) for slot in slots[:2]]
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]:
prompts.append((slot["key"], slot["prompt"]))
outs.append(slot["payload"]((0, f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig", "")))
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"
@@ -348,51 +151,6 @@ 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 UND Finder bekommen Auszüge inline und laufen ohne Tools
(Text-Antwort); die Dateien schreibt die Engine. Der Finder verlor vorher 313
Tool-Runden pro Call mit der Material-Suche via glob/grep/bash."""
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"] == "none" for s in finders)
assert "── Skript.txt" in finders[0]["prompt"] # Auszüge inline statt Dateisystem-Suche
assert "web search" not in finders[0]["prompt"]
assert list(tmp_path.glob("subblock-final-*-j*.md")) # Engine persistiert die Judge-Antwort
async def test_thema_nutzt_research_material_inline(sub_env, monkeypatch, tmp_path):
"""thema mit Research-Fundstellen (arbeit/material/*.txt): Finder bekommt sie inline
und läuft ohne Tools — vorher eigene Websuche pro Call (Reasoning-Schleifen, Retries)."""
db, ctx, files = sub_env
monkeypatch.setattr(blx, "source_folder", lambda t: None)
md = tmp_path / "arbeit" / "material"
md.mkdir(parents=True)
(md / "research-1.txt").write_text(
"https://example.org/alpha\nAlpha Grundlagen: der Kernbegriff, gut belegt.\n",
encoding="utf-8")
monkeypatch.setattr(blx, "arbeit_dir", lambda t: tmp_path / "arbeit")
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-")
assert raw == {"Alpha": ["Alpha Grundlagen"]}
finders = [s for s in fake.slots_seen if "-r1-" in s["key"]]
assert finders and all(s["capabilities"] == "none" for s in finders)
assert "── research-1.txt" in finders[0]["prompt"] # Fundstellen inline
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")
@@ -407,115 +165,6 @@ def test_material_folder_fallbacks(monkeypatch, tmp_path):
assert blx.material_folder("t") == md
async def test_panel_2of3_kehrt_bei_einigkeit_zurueck(tmp_path):
"""Zwei übereinstimmende Verdicts → Rückkehr ohne den langsamen Dritten; sein
Ergebnis wird detached nachpersistiert (Resume)."""
import asyncio as aio
gesunken = {}
async def judge(j, delay, antwort):
await aio.sleep(delay)
return (0, antwort, "")
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
aio.create_task(judge(2, 0.02, "a")): 2,
aio.create_task(judge(3, 5.0, "b")): 3}
def sink(j, r):
gesunken[j] = r[1]
import time
t0 = time.monotonic()
await blx._panel_2of3(tasks, sink, lambda: list(gesunken.values()), lambda s: s)
assert time.monotonic() - t0 < 1.0 # nicht auf j3 gewartet
assert gesunken == {1: "a", 2: "a"}
async def test_panel_2of3_dissens_wartet_auf_dritten():
"""Uneinige erste zwei → der dritte wird abgewartet (Mehrheit braucht ihn)."""
import asyncio as aio
gesunken = {}
async def judge(j, delay, antwort):
await aio.sleep(delay)
return (0, antwort, "")
tasks = {aio.create_task(judge(1, 0.01, "a")): 1,
aio.create_task(judge(2, 0.02, "b")): 2,
aio.create_task(judge(3, 0.1, "a")): 3}
await blx._panel_2of3(tasks, lambda j, r: gesunken.__setitem__(j, r[1]),
lambda: list(gesunken.values()), lambda s: s)
assert gesunken == {1: "a", 2: "b", 3: "a"}
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)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
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
async def test_facts_find_inline_evidence_no_tools(sub_env, monkeypatch, tmp_path):
"""Facts find/erg mit Korpus: Auszüge inline, Agent ohne Tools — Tool-Agenten
verloren sich in Reasoning-Schleifen und endeten mit leerem Turn (Retry-Wellen)."""
db, ctx, files = sub_env
d = _corpus(tmp_path)
monkeypatch.setattr(blx, "source_folder", lambda t: d)
seen = []
facts = {"facts": [{"block": "Alpha", "subblock": "Sub Eins", "key_points": ["k"],
"prerequisites": "", "hurdles": "",
"cited_facts": [{"text": "T", "source": "Skript.txt, Z.2"}],
"example_idea": ""}]}
async def fake_slot(ctx2, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
seen.append((key, capabilities, prompt))
return blx.OK, payload((0, json.dumps(facts), ""))
async def fake_agent(key, prompt, timeout, **kw): # Check-Panel
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
finder = [s for s in seen if "-facts-c0" in s[0] or "-facts-erg-" in s[0]]
assert finder and all(caps == "none" for _, caps, _ in finder)
assert all("── Skript.txt" in prompt for _, _, prompt in finder) # Auszüge inline
assert all("ls/find" not in prompt for _, _, prompt in finder)
def test_sub_key_resolves_short_titles():
"""Artefakt-Agenten echoen den Kurztitel; der Sub-Key heißt 'kurztitel: beschreibung'.
Eindeutiger Präfix wird aufgelöst, Mehrdeutiges und Fehlendes bleibt unverändert."""

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

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

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

@@ -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,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,27 +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}
{material}
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.
- {backing}
- Subblock titles in GERMAN (code identifiers stay original), max. ~10 words, one statement.
Reply with ONLY the subblock lists in this exact marker format — one block marker per block (title EXACTLY from the assignment), the subblocks as a list below it. No code fences, no text outside the blocks:
<!-- block: Exact block title -->
- First subblock
- Second subblock
{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}