This commit is contained in:
team3
2026-07-02 22:48:57 +02:00
parent 41c9f29a37
commit 285317927d
38 changed files with 2548 additions and 2812 deletions

View File

@@ -29,13 +29,14 @@ log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
def active_agents(scope_prefix: str | None = None) -> list[dict]:
"""Currently running agents and how long they've been running. Filter by key prefix
(e.g. f"blocks-{topic}-") for one topic. → [{key, runtime}] sorted longest-first."""
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
now = time.time()
out = [{"key": k, "runtime": round(now - t, 1)}
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
for k, t in list(_active_started.items())
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
@@ -101,15 +102,19 @@ _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# per-topic queue can't undo the global priority when one topic is the only load.
_topic_sems: dict[str, _PrioritySemaphore] = {}
# Earlier kanban columns get the scarce global slot first (smaller = higher priority).
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter", "grouping")
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
"grouping", "supplement", "outline", "artifact", "question", "relevance",
"level", "facts", "subblock")
def _agent_priority(key: str) -> int:
for i, tag in enumerate(_STAGE_PRIORITY):
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
return i
return len(_STAGE_PRIORITY) # downstream agents (subblocks/facts/…) after the inventory columns
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
@asynccontextmanager
@@ -145,6 +150,8 @@ async def _opencode_slot() -> None:
_opencode_next_start = start_at + _OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, start_at - now))
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools
_CLAUDE_TOOLS = {
"full": "Write,Bash,Read,WebSearch,WebFetch",
@@ -220,6 +227,11 @@ def kill_process(agent_key_prefix: str) -> None:
_kill(process)
# Event sink for the pipeline history (injected by main.py lifespan as database.add_event —
# agents.py stays DB-free). Called fire-and-forget for every finished BATCH agent.
on_event = None
async def run_agent(
agent_key: str,
prompt: str,
@@ -230,6 +242,7 @@ async def run_agent(
lane: str = "batch",
scope: str | None = None,
on_line=None,
label: str = "",
) -> tuple[int, str, str]:
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled"
@@ -243,17 +256,47 @@ async def run_agent(
return 1, "", f"No model for role '{role}' (provider: {provider})"
if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
queued = time.monotonic()
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
async with gate:
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled"
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line)
return await _run_claude_cli(agent_key, prompt, timeout, model, capabilities)
wait_ms = int((time.monotonic() - queued) * 1000)
start = time.monotonic()
status = "error"
rc = None
err_tail = ""
try:
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
if PROVIDERS[provider]["cli"] == "opencode":
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
else:
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
rc = res[0]
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
if rc not in (0, None) and rc >= 0:
err_tail = (res[2] or res[1] or "").strip()[-300:] # diagnosis: rc=1 without stderr is opaque
return res
except asyncio.TimeoutError:
status = "timeout"
raise
except asyncio.CancelledError:
status = "cancelled"
raise
finally:
if on_event is not None and scope is not None: # batch pipeline only, never fatal
try:
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
if err_tail:
meta["stderr"] = err_tail
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
status=status, dur_ms=int((time.monotonic() - start) * 1000),
wait_ms=wait_ms, meta=meta)
except Exception:
log.debug("on_event failed", exc_info=True)
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None) -> tuple[int, str, str]:
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None) -> tuple[int, str, str]:
start = time.monotonic()
async def spawn():
@@ -263,6 +306,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True, # own process group → killpg also kills child processes
env=env,
)
if stagger:
@@ -277,6 +321,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
n += 1
_active_processes[track_key] = process
_active_started[track_key] = time.time()
_active_labels[track_key] = label
try:
try:
if on_line is not None:
@@ -319,19 +364,20 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
if _active_processes.get(track_key) is process:
del _active_processes[track_key]
_active_started.pop(track_key, None)
_active_labels.pop(track_key, None)
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str) -> tuple[int, str, str]:
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str, label: str = "") -> tuple[int, str, str]:
cfg = PROVIDERS["claude"]
cmd = [cfg["cli"], "-p", "--model", model]
tools = _CLAUDE_TOOLS.get(capabilities)
if tools:
cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"]
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None) -> tuple[int, str, str]:
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
cfg = PROVIDERS[provider]
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
@@ -349,8 +395,14 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
]
if on_line is not None:
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
env = None
if capabilities != "full":
# Batch agents (files/readonly/text) never use the web MCPs, but opencode starts
# every configured MCP server PER PROCESS (~3 procs / ~300 MB each). Point them
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line)
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally:
prompt_path.unlink(missing_ok=True)

View File

@@ -24,7 +24,7 @@ from pathlib import Path
import database as db
import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS
from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_file
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
@@ -53,6 +53,10 @@ RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema", no crawl folder)
RESEARCH_SECTION_CHARS = 12000
# Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*).
SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
SUBBLOCK_MIN = 5 # below this consensus count a block gets focused catch-up rounds
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds (saturation stop still applies — thin stays thin)
SUBBLOCK_MAX_ROUNDS = 5 # hard round cap: measured runs hit 59 rounds purely on paraphrases
# before the variant-robust `new` count converges — never search longer
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
@@ -61,7 +65,10 @@ FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (fu
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % per chunk)
FACTS_CHUNK_SUBS = 25 # facts extraction: smaller chunks (facts are bulkier than patterns)
FACTS_CHUNK_SUBS = 10 # facts extraction: small chunks — the 4 phases (find/erg/check/fix) are
# serial PER CHUNK, so chunk count = parallelism; the makespan tail of a
# late block is bounded by ONE chunk's phase chain, not the whole block
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples: bulk generation, phases are cheap → bigger packages
FACTS_CHECK_PANEL = 3 # judges per chunk in the facts check (majority objects)
CONSOLIDATION_PANEL = 3 # mapping judges per chunk (panel → reconcile instead of a single judge)
SUBBLOCK_PANEL = 3 # source judges in the subblock clarification (majority instead of a single judge)
@@ -556,10 +563,51 @@ def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]:
_NEG_TOKENS = {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie"}
def _neg_set(title: str) -> frozenset:
"""Negation tokens of a title — antonym statements measure cos 0.910.95 (above any usable
variant threshold), so equal negation sets are a hard merge precondition."""
return frozenset(t for t in re.findall(r"\w+", _norm_title(title)) if t in _NEG_TOKENS)
def _sub_tokens(title: str) -> set:
return set(re.findall(r"\w+", _norm_title(title)))
def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict]:
"""Fold phrasing VARIANTS of one concept BEFORE the consensus count: finders rephrase per
round, so exact-norm counting starves real concepts. Union-find over cos ≥ SUB_VARIANT_COS
with the negation guard. → [{"rep": idx, "members": [idx…], "mentions": sum}]."""
n = len(titles)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
negs = [_neg_set(t) for t in titles]
for i in range(n):
for j in range(i + 1, n):
if float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]:
parent[find(i)] = find(j)
groups: dict[int, list[int]] = {}
for i in range(n):
groups.setdefault(find(i), []).append(i)
return [{"rep": max(g, key=lambda k: (len(titles[k]), -k)), "members": g,
"mentions": sum(mentions[k] for k in g)} for g in groups.values()]
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
wipe: bool = True, ns: str = "") -> dict | None:
wipe: bool = True, ns: str = "", seeds: list[str] | None = None,
lbl: str = "") -> dict | None:
"""Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap),
collect in the DB (≥2 mentions = consensus, 1× discarded), a judge cleans up per package.
collect in the DB (variant-clustered mentions ≥2 = consensus), a judge panel cleans up per
package; blocks below SUBBLOCK_MIN get focused catch-up rounds; `seeds` (demoted fragment
titles, single-block kanban calls) are guaranteed to reach the facts evidence gate.
{block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`.
wipe=False (kanban board: one call per block) keeps the other blocks' rows."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
@@ -574,8 +622,12 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
n = len(chunks)
title_by_num = {num: _title(entries[num]) for num in nums}
norm_by_num = {num: _norm_title(title_by_num[num]) for num in nums}
emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available)
if wipe:
await db.delete_subblocks(topic) # fresh start of the block (idempotent counter)
else:
for num in nums: # per-block wipe: a re-spawned card must not accumulate mentions
await db.delete_subblocks(topic, norm_by_num[num])
async def _known_block(chunk):
known = []
@@ -589,48 +641,82 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
# self-bias/echo) — only add what's missing. This keeps the counter an honest consensus signal.
return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known))
# ONE finder round (3 slots, quorum 2) → count of NEW sub norms; None = no result/cancel.
async def _one_round(label, subset, assignment, paths, keys, known, extra_instr):
chunk_idx = _title_index({num: title_by_num[num] for num in subset})
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": k,
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=known, out_path=p, extra=_extra(extra_instr)),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
} for k, p in zip(keys, paths)]
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled() or not agent_texts:
return None
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
existing = {num: {s["sub_norm"] for s in rows_before[num]} for num in subset}
fresh: dict[int, list[str]] = {}
for d in agent_texts:
for marker, subs in d.items():
num = _resolve_title(chunk_idx, marker)
if num is None:
continue
seen_set = set()
for sub in subs:
sn = _norm_title(sub)
if not sn or sn in seen_set:
continue
seen_set.add(sn)
if sn not in existing[num]:
existing[num].add(sn)
fresh.setdefault(num, []).append(sub)
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
# "New" is variant-robust: a paraphrase of an existing sub (or of another fresh find)
# still gets stored above (its mention feeds the cluster consensus), but it must not
# keep the saturation loop spinning — finders rephrase every round (measured: 59
# rounds without this fold). Model off → exact counting (status quo).
new = 0
for num, cands in fresh.items():
sims = None
base = [s["sub_title"] for s in rows_before[num]]
if emb_on and base + cands:
sims = await asyncio.to_thread(embedding.embed_sims, base + cands)
if sims is None:
new += len(cands)
continue
negs = [_neg_set(t) for t in base + cands]
nb = len(base)
kept: list[int] = []
for i in range(nb, nb + len(cands)):
dup = any(float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]
for j in [*range(nb), *kept])
if not dup:
kept.append(i)
new += len(kept)
return new
# Phase "Subblocks find": per package loop until 0 new subs / time cap.
async def _find(c, chunk):
assignment = "\n".join(f"- {entries[num]}" for num in chunk)
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
start = time.monotonic()
round_n = 0
while not is_cancelled():
round_n += 1
bekannt = await _known_block(chunk) if round_n > 1 else ""
paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)]
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}",
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=bekannt, out_path=p, extra=_extra(instructions)),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
} for i, p in enumerate(paths, 1)]
agent_texts = await _race(topic, f"Subblocks package {c} R{round_n}", slots, 2, _timeout("subblock", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled():
return False
if not agent_texts:
keys = [f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}" for i in (1, 2, 3)]
new = await _one_round(f"{lbl}Subblocks package {c} R{round_n}", chunk, assignment, paths, keys, bekannt, instructions)
if new is None:
if is_cancelled():
return False
return round_n > 1 # round 1 without result = error; later = simply the end
existing = {num: {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])} for num in chunk}
new = 0
for d in agent_texts:
for marker, subs in d.items():
num = _resolve_title(chunk_idx, marker)
if num is None:
continue
seen_set = set()
for sub in subs:
sn = _norm_title(sub)
if not sn or sn in seen_set:
continue
seen_set.add(sn)
if sn not in existing[num]:
new += 1
existing[num].add(sn)
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
if new == 0:
break
if round_n >= SUBBLOCK_MAX_ROUNDS:
_log(topic, f"Subblocks package {c}: round cap reached ({round_n})")
break
if time.monotonic() - start > SUBBLOCK_CAP:
_log(topic, f"Subblocks package {c}: time cap reached (round {round_n})")
break
@@ -643,49 +729,117 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
_blocks_errors[topic] = "Subblocks failed (research)"
return None
# Phase "Subblocks select": ≥2 mentions = consensus, 1× discarded (code).
# Phase "Subblocks select": variant-clustered mentions ≥2 = consensus — the cluster
# representative carries the status, folded members become `variant` (NOT discarded:
# the clarify panel's uncertain group must not re-list them). Model off → exact counter.
async def _select(subset, keep_consensus=False):
for num in subset:
rows = await db.list_subblocks(topic, norm_by_num[num])
clusters = None
if emb_on and len(rows) >= 2:
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
if sims is not None:
clusters = _variant_clusters([r["sub_title"] for r in rows],
[r["mentions"] for r in rows], sims)
if clusters is None:
for r in rows:
if keep_consensus and r["status"] == "consensus":
continue
await db.set_subblock_fields(topic, norm_by_num[num], r["sub_norm"],
status=("consensus" if r["mentions"] >= 2 else "discarded"))
continue
for cl in clusters:
# a re-select (catch-up) never demotes panel-confirmed subs — an existing
# consensus member stays the representative, new variants fold under it.
kept = [k for k in cl["members"] if keep_consensus and rows[k]["status"] == "consensus"]
for k in cl["members"]:
if kept:
st = "consensus" if k in kept else "variant"
elif cl["mentions"] >= 2:
st = "consensus" if k == cl["rep"] else "variant"
else:
st = "discarded"
if keep_consensus and rows[k]["status"] == "consensus" and st != "consensus":
continue
await db.set_subblock_fields(topic, norm_by_num[num], rows[k]["sub_norm"], status=st)
set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select"))
for num in nums:
for s in await db.list_subblocks(topic, norm_by_num[num]):
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
status=("consensus" if s["mentions"] >= 2 else "discarded"))
await _select(nums)
# Judge formulation → shown candidate (best cos ≥ SUB_VARIANT_COS, negation-guarded).
# Judges demonstrably paraphrase; without canonicalizing, the exact-norm majority vote
# splinters across formulations (measured: 0.993-duplicates in a final list).
async def _canon_map(shown: list[str], judge_titles: list[str]) -> dict[str, tuple[str, str]]:
if not emb_on or not shown or not judge_titles:
return {}
texts = shown + list(judge_titles)
sims = await asyncio.to_thread(embedding.embed_sims, texts)
if sims is None:
return {}
negs = [_neg_set(t) for t in texts]
m: dict[str, tuple[str, str]] = {}
for a in range(len(shown), len(texts)):
best, bv = None, 0.0
for b in range(len(shown)):
v = float(sims[a][b])
if v >= SUB_VARIANT_COS and v > bv and negs[a] == negs[b]:
best, bv = b, v
if best is not None:
m[_norm_title(texts[a])] = (_norm_title(shown[best]), shown[best])
return m
# Phase "Subblocks clarify": source panel (SUBBAUSTEIN_PANEL judges) checks consensus + uncertain (1×)
# against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo.
async def _clarify(c, chunk):
fp = work_dir / f"subblock-final-c{c}.md"
async def _clarify(c, chunk, tag=""):
fp = work_dir / f"subblock-final-c{c}{tag}.md"
if _parse_subblocks(_read(fp)):
return
block_texts, has_any = [], False
consensus_by_num: dict[int, list[str]] = {}
shown_by_num: dict[int, list[str]] = {}
for num in chunk:
rows = await db.list_subblocks(topic, norm_by_num[num])
consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"]
uncertain = [s["sub_title"] for s in rows if s["status"] != "consensus" and s["mentions"] == 1]
# folded variants (status `variant`) are already counted — only true singles are uncertain
uncertain = [s["sub_title"] for s in rows if s["status"] == "discarded" and s["mentions"] == 1]
consensus_by_num[num] = consensus_subs
shown_by_num[num] = consensus_subs + uncertain
if not consensus_subs and not uncertain:
continue
has_any = True
k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)"
u_lines = "\n".join(f"- {s}" for s in uncertain) if uncertain else "- (keiner)"
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}")
band = ""
shown = shown_by_num[num]
if emb_on and len(shown) >= 2: # near-dup pairs BELOW the fold threshold → explicit panel hint
sims = await asyncio.to_thread(embedding.embed_sims, shown)
if sims is not None:
pairs = [f"- „{shown[i]}“ ↔ „{shown[j]}"
for i in range(len(shown)) for j in range(i + 1, len(shown))
if 0.75 <= float(sims[i][j]) < SUB_VARIANT_COS]
if pairs:
band = ("\nMögliche Duplikate — prüfen und ggf. zu EINEM Eintrag zusammenführen:\n"
+ "\n".join(pairs[:12]))
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}{band}")
if not has_any:
return
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
paths = [work_dir / f"subblock-final-c{c}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
pending = [(j, p) for j, p in enumerate(paths, 1) if _parse_subblocks(_read(p)) is None]
paths = [work_dir / f"subblock-final-c{c}{tag}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
# truthiness, NOT `is None`: a missing file parses to {} — with `is None` the whole
# panel silently never ran (fallback adopted the raw consensus unchecked).
pending = [(j, p) for j, p in enumerate(paths, 1) if not _parse_subblocks(_read(p))]
for _, p in pending:
p.unlink(missing_ok=True)
if pending:
slots = [{
"key": f"blocks-{topic}-{ns}subblock-final-c{c}-j{j}",
"key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}",
"prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
} for j, p in pending]
existing = SUBBLOCK_PANEL - len(pending)
await _race(topic, f"Subblock-Clarification {c}", slots, max(1, 2 - existing),
await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing),
_timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled():
return
@@ -698,22 +852,34 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
return
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep).
# Votes are canonicalized onto the shown candidates first (paraphrase-robust).
block_texts_out = []
for num in chunk:
raw_votes: list[list[str]] = []
for d in outs:
subs_of_num: list[str] = []
for marker, subs in d.items():
if _resolve_title(chunk_idx, marker) == num:
subs_of_num.extend(subs)
raw_votes.append(subs_of_num)
judge_titles = list(dict.fromkeys(s for subs in raw_votes for s in subs
if _norm_title(s) not in {_norm_title(t) for t in shown_by_num[num]}))
canon = await _canon_map(shown_by_num[num], judge_titles)
votes: dict[str, int] = {}
form: dict[str, str] = {}
for d in outs:
for subs_of_num in raw_votes:
seen = set()
for marker, subs in d.items():
if _resolve_title(chunk_idx, marker) != num:
for sub in subs_of_num:
sn = _norm_title(sub)
if not sn:
continue
for sub in subs:
sn = _norm_title(sub)
if not sn or sn in seen:
continue
seen.add(sn)
form.setdefault(sn, sub)
votes[sn] = votes.get(sn, 0) + 1
if sn in canon:
sn, sub = canon[sn]
if sn in seen:
continue
seen.add(sn)
form.setdefault(sn, sub)
votes[sn] = votes.get(sn, 0) + 1
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
if kept:
block_texts_out.append(f"<!-- block: {title_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in kept))
@@ -725,8 +891,9 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
# Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw.
raw: dict[str, list[str]] = {}
for c, chunk in enumerate(chunks, 1):
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}.md")) or {}
async def _align(c, chunk, tag=""):
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}{tag}.md")) or {}
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
final_by_num = {_resolve_title(chunk_idx, m): subs for m, subs in final.items() if _resolve_title(chunk_idx, m) is not None}
for num in chunk:
@@ -740,14 +907,83 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
final_norms = {_norm_title(s) for s in subs}
have = {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])}
for s in await db.list_subblocks(topic, norm_by_num[num]):
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
status=("consensus" if s["sub_norm"] in final_norms else "discarded"))
if s["sub_norm"] in final_norms:
st = "consensus"
elif s["status"] == "variant":
st = "variant" # folded members stay marked — a catch-up clarify must not
else: # re-list them as "uncertain singles"
st = "discarded"
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"], status=st)
for s in subs:
sn = _norm_title(s)
if sn and sn not in have:
await db.upsert_subblock(topic, norm_by_num[num], sn, title, s)
await db.set_subblock_fields(topic, norm_by_num[num], sn, status="consensus")
for c, chunk in enumerate(chunks, 1):
await _align(c, chunk)
# Minimum catch-up: a block below SUBBLOCK_MIN gets up to SUBBLOCK_EXTRA_ROUNDS focused
# finder rounds. Saturation stop stays — a thin block REMAINS thin if nothing verifiable.
async def _catchup(c, chunk):
for k in range(1, SUBBLOCK_EXTRA_ROUNDS + 1):
lacking = [num for num in chunk if len(raw.get(title_by_num[num]) or []) < SUBBLOCK_MIN]
if not lacking or is_cancelled():
return
assignment = "\n".join(f"- {entries[num]}" for num in lacking)
known = await _known_block(lacking) # ALL rows of the block, incl. variants/discarded
focus = (instructions + "\n\nDieser Block hat bisher nur sehr wenige belegte "
"Subbausteine. Suche gezielt nach WEITEREN belegbaren Kernaspekten, die "
"oben fehlen. Nimm NUR auf, was die Quellen wirklich hergeben — nicht aufblähen.")
paths = [work_dir / f"subblock-x{k}-c{c}-{i}.md" for i in (1, 2, 3)]
keys = [f"blocks-{topic}-{ns}subblock-x{k}-c{c}-{i}" for i in (1, 2, 3)]
new = await _one_round(f"{lbl}Subblocks catch-up {c} X{k}", lacking, assignment, paths, keys, known, focus)
if not new:
return
await _select(lacking, keep_consensus=True)
await _clarify(c, chunk, tag=f"-x{k}")
if is_cancelled():
return
await _align(c, chunk, tag=f"-x{k}")
# progress reuses the clarify step label — catch-up has no own registry entry
await _gather_progress([_catchup(c, chunk) for c, chunk in enumerate(chunks, 1)], n,
_report_p(set_p, topic, "Subblocks clarify"))
if is_cancelled():
return None
await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM)
# Seed guarantee (single-block kanban calls): every demoted-fragment seed must reach the
# facts evidence gate — covered by a consensus sub, promoted from a single find, or
# inserted as its own sub. Unverifiable seeds die at the facts discard, not silently here.
for num in (nums if seeds else []):
title = title_by_num[num]
for seed in dict.fromkeys(s for s in seeds if s):
st = _sub_tokens(seed)
have = raw.get(title) or []
if not st or any(st <= _sub_tokens(s) for s in have):
continue
if emb_on and have: # embedding backup for rephrased covers (lexical is primary)
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + have)
if sims is not None and max(float(sims[0][j]) for j in range(1, len(have) + 1)) >= SEED_COVER_COS:
continue
rows = [r for r in await db.list_subblocks(topic, norm_by_num[num]) if r["status"] != "consensus"]
cand = next((r for r in rows if st <= _sub_tokens(r["sub_title"])), None)
if cand is None and emb_on and rows:
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + [r["sub_title"] for r in rows])
if sims is not None:
j = max(range(1, len(rows) + 1), key=lambda x: float(sims[0][x]))
if float(sims[0][j]) >= SEED_COVER_COS and _neg_set(seed) == _neg_set(rows[j - 1]["sub_title"]):
cand = rows[j - 1]
if cand is not None:
await db.set_subblock_fields(topic, norm_by_num[num], cand["sub_norm"], status="consensus")
raw.setdefault(title, []).append(cand["sub_title"])
_log(topic, f"Seed „{seed}“: Einzelfund „{cand['sub_title']}“ übernommen ({title})")
elif (sn := _norm_title(seed)):
await db.put_subblock(topic, norm_by_num[num], sn, title, seed, status="consensus")
raw.setdefault(title, []).append(seed)
_log(topic, f"Seed „{seed}“ als Subbaustein eingefügt ({title}) — Facts-Gate prüft")
if not raw:
# Finders ran but nothing survived the consensus/evidence gates: a legitimately
# thin block (e.g. a bare named reduction). {} = done-without-subs — the guide
@@ -772,8 +1008,9 @@ async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None:
return
keepers: list[int] = []
discarded: list[int] = []
negs = [_neg_set(s) for s in subs]
for i in sorted(range(len(subs)), key=lambda x: (-len(subs[x]), x)): # most informative first
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP for j in keepers):
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP and negs[i] == negs[j] for j in keepers):
discarded.append(i)
else:
keepers.append(i)
@@ -812,7 +1049,7 @@ def _disputed_lines(items, item_idxs, disputed: dict) -> str:
)
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "") -> dict | None:
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
"""Block C: three phases with a barrier — find (classify), select (vote), clarify.
Local IDs 1..n per package, mapped to global gid afterwards.
{block title: [{title, level}, …]} or None."""
@@ -868,7 +1105,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
"role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
} for i, p in pending]
new = await _race(topic, f"Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
new = await _race(topic, f"{lbl}Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
return not is_cancelled() and new is not None
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels find"))
@@ -895,7 +1132,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
if decision is None:
disputed_block = _disputed_lines(items, item_idxs, strittig)
status, decision = await run_single_slot(
ctx, f"Levels-Clarification {c}",
ctx, f"{lbl}Levels-Clarification {c}",
key=f"blocks-{topic}-{ns}level-final-c{c}",
prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files",
@@ -1008,7 +1245,7 @@ def _facts_complete(files: dict) -> bool:
return isinstance(d, dict) and bool(d)
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "") -> tuple | None:
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "") -> tuple | None:
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
Extract-once grounding: the result feeds level/relevance/questions/guide.
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
@@ -1081,7 +1318,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
return True
subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _r = await run_single_slot(
ctx, f"Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
role="quick", capabilities=caps,
payload=lambda result, p=fp: _facts_schema(_json_file(p)),
@@ -1112,7 +1349,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
for bt, fm in per.items())
subs_total = sum(len(blocks[i][1]) for i in idxs)
await run_single_slot(
ctx, f"Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
role="quick", capabilities=caps,
payload=lambda result, p=ep: _facts_schema(_json_file(p)),
@@ -1134,7 +1371,8 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
_prompt("Facts-Check", topic=topic, source=source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps)
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps,
scope=topic, label=f"{lbl}Facts check {ci}/{j}")
for j in pending], return_exceptions=True)
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
bvotes: dict[str, int] = {}
@@ -1183,7 +1421,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
if not goal:
return
await run_single_slot(
ctx, f"Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
role="quick", capabilities=caps,
payload=lambda result, p=fix_path(ci): _facts_schema(_json_file(p)),
@@ -1211,7 +1449,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
return outcome, discarded_map
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
"""Block D: three phases with a barrier — find (relevant/peripheral), select (vote), clarify.
Items from the sidecar; local IDs 1..n per package → global gid.
{gid: relevance} or None on cancel/research error. Default on gap/dispute: 'relevant'."""
@@ -1249,7 +1487,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
"role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
} for i, p in pending]
new = await _race(topic, f"Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
new = await _race(topic, f"{lbl}Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
return not is_cancelled() and new is not None
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance find"))
@@ -1276,7 +1514,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
if decision is None:
disputed_block = _disputed_lines(items, item_idxs, strittig)
status, decision = await run_single_slot(
ctx, f"Relevance-Clarification {c}",
ctx, f"{lbl}Relevance-Clarification {c}",
key=f"blocks-{topic}-{ns}relevance-final-c{c}",
prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files",
@@ -1321,7 +1559,7 @@ def _match_sub(agent_sub: str, rel: list[str]) -> str:
return agent_sub
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
"""Block E (chunks of 10): find (1 generator per ~10 blocks, parallel), select (code:
group per block + dedup), clarify (1 critic per chunk), check (catch-up round).
Assignment per entry via the `block` field (a chunk file carries several blocks).
@@ -1371,7 +1609,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
)
subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _ = await run_single_slot(
ctx, f"Question-Pattern {ci}",
ctx, f"{lbl}Question-Pattern {ci}",
key=f"blocks-{topic}-{ns}question-pattern-c{ci}",
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block,
out_path=fp, extra=_extra(instructions)),
@@ -1436,7 +1674,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
return # nothing to clarify in this chunk
subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _ = await run_single_slot(
ctx, f"Question-Pattern-Clarification {ci}",
ctx, f"{lbl}Question-Pattern-Clarification {ci}",
key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}",
prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files",
@@ -1505,7 +1743,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
return # resume
subs_total = sum(len(s) for _, s in items)
await run_single_slot(
ctx, f"Question pattern catch-up R{round_n}/{pi}",
ctx, f"{lbl}Question pattern catch-up R{round_n}/{pi}",
key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}",
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items),
out_path=fp, extra=_extra(instructions)),
@@ -2144,6 +2382,26 @@ def _outline_complete(files: dict) -> bool:
return isinstance(d, dict) and isinstance(d.get("chapters"), list) and bool(d.get("chapters"))
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
"""{"moves": {"<blocknr>": <chapter-idx>}} → {nr: idx} (may be {}) · None if broken/invalid.
A mass rewrite (more than a third of all blocks) is rejected — the reviewer's job is
spotting misplacements, not re-designing the outline."""
if not isinstance(data, dict) or not isinstance(data.get("moves"), dict):
return None
out: dict[int, int] = {}
for k, v in data["moves"].items():
try:
nr, ch = int(k), int(v)
except (ValueError, TypeError):
return None
if nr not in valid or not (1 <= ch <= n_chapters):
return None
out[nr] = ch
if len(out) * 3 > n_blocks:
return None
return out
def _outline_schema(data, valid: set[int]):
"""{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) ·
None at <80 % coverage (agent/judge omitted too much)."""
@@ -2302,6 +2560,34 @@ async def _outline_block(ctx: GenContext, set_p, files: dict, entries: dict, ins
timeout=_timeout("plan_judge", len(entries)))
plan = _outline_schema(_json_file(files["outline"]), valid) or proposals[0]
# Placement review (best-effort): ONE judge checks every block→chapter assignment and
# reports ONLY misplacements as moves. Invalid/mass output → plan unchanged.
if proposals and len(plan["chapters"]) >= 2 and not is_cancelled():
rp = files["arbeit"] / "outline-review.json"
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
if moves is None:
chapter_text = "\n\n".join(
f"KAPITEL {k}: {ch['title']}\n" + "\n".join(f" {n}. {_title(entries[n])}" for n in ch["numbers"])
for k, ch in enumerate(plan["chapters"], 1))
set_p("Outline review…", step=step)
await run_single_slot(
ctx, "Outline-Review", key=f"blocks-{topic}-outline-review",
prompt=_prompt("Guide-Outline-Review", topic=topic, chapters=chapter_text,
out_path=rp, extra=_extra(instructions)),
role="judge", capabilities="files",
payload=lambda result: _outline_review_schema(
_json_file(rp), valid, len(plan["chapters"]), len(entries)),
timeout=_timeout("plan_judge", len(entries)))
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
for nr, target in (moves or {}).items():
for ch in plan["chapters"]:
if nr in ch["numbers"]:
ch["numbers"].remove(nr)
plan["chapters"][target - 1]["numbers"].append(nr)
if moves:
plan["chapters"] = [ch for ch in plan["chapters"] if ch["numbers"]]
_log(topic, f"Outline-Review: {len(moves)} Block/Blöcke umsortiert")
# Completeness: every block appears — missing in "Other" (against omitting agents/judge).
included = {n for ch in plan["chapters"] for n in ch["numbers"]}
missing = [n for n in entries if n not in included]
@@ -2372,7 +2658,7 @@ def _artefacts_complete(files: dict) -> bool:
return isinstance(d, dict) and all(t in d for t in ARTEFACT_TYPES)
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
"""Generate learning artefacts per type from the stored facts — one generation pass
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
flashcards are low-risk and stay unchecked. → {type: [entries]} (also in files)."""
@@ -2400,7 +2686,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
atomic_write_json(files["artefakte"], empty_map, indent=1)
return empty_map
chunks = _lpt_chunks([len(z) for _, z in blocks], FACTS_CHUNK_SUBS)
chunks = _lpt_chunks([len(z) for _, z in blocks], ARTEFACT_CHUNK_SUBS)
def block_text(idxs):
return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in idxs)
@@ -2419,7 +2705,8 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps)
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps,
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
for j in pending], return_exceptions=True)
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
if not outs:
@@ -2446,7 +2733,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
if schema(_json_file(p)) is not None:
return True
await run_single_slot(
ctx, f"{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
ctx, f"{lbl}{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)),
role="guide", capabilities="files",
payload=lambda result, p=p: schema(_json_file(p)),

View File

@@ -42,11 +42,20 @@ def _safe(norm: str) -> str:
def _card_set_p(flow: Flow, norm: str):
"""Per-card progress: the inner step messages land in-memory on the flow —
board_snapshot shows them as the card's info line while it is active."""
board_snapshot shows them as the card's info line + phase stepper while active.
The step INDEX is resolved to its NAME at write time (indices shift with the
source type, names are stable)."""
info = flow.state.setdefault("card_info", {})
def set_p(msg: str, step: int | None = None) -> None:
info[f"{BOARD}:{norm}"] = msg
name = ""
if step is not None:
steps = flow.state.get("blocks_steps")
if steps is None:
steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
if 0 <= step < len(steps):
name = steps[step]
info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
return set_p
@@ -94,28 +103,73 @@ def _fail_or_cancel(ctx: GenContext, what: str):
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
async def _seed_map(topic: str) -> dict[str, list[str]]:
"""Demoted fragments become seed candidates of their SURVIVING parent block.
parent_norm may point at a block that itself got grouped/merged/renamed — follow the
redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
alive: set[str] = set()
redirect: dict[str, str] = {}
rejected: list[dict] = []
for r in await db.kanban_cards(topic, board="inventory", kind="block"):
p = r["payload"]
tn = _norm_title(p.get("title", ""))
if not tn:
continue
if r["stage"] in ("done", "done_block"):
mn = p.get("mirrored_norm") or tn
alive.add(mn)
if tn != mn:
redirect.setdefault(tn, mn)
elif r["stage"] == "grouped" and p.get("merged_into"):
redirect.setdefault(tn, _norm_title(p["merged_into"]))
# umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
# without a seed the umbrella's finders may simply miss them (measured).
rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
elif r["stage"] == "rejected":
if p.get("parent_norm"):
redirect.setdefault(tn, p["parent_norm"])
rejected.append(p)
def _resolve(norm: str) -> str | None:
seen: set[str] = set()
cur = norm
while cur and cur not in seen:
if cur in alive: # alive check BEFORE following (self-edges like „Listen"→„Listen")
return cur
seen.add(cur)
cur = redirect.get(cur, "")
return None
seeds: dict[str, list[str]] = {}
for p in rejected:
pn = p.get("parent_norm")
if pn and (target := _resolve(pn)):
seeds.setdefault(target, []).append(p.get("title", ""))
return seeds
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
seeds: dict[str, list[str]] = {}
for r in await db.kanban_cards(topic, board="inventory", stage="rejected"):
pn = r["payload"].get("parent_norm")
if pn:
seeds.setdefault(pn, []).append(r["payload"].get("title", ""))
seeds = await _seed_map(topic)
async def one(c):
p = c["payload"]
norm = c["card_id"]
instr = instructions
if (sd := [s for s in seeds.get(norm, []) if s]):
sd = [s for s in seeds.get(norm, []) if s]
if sd:
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
"Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\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)}-")
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
seeds=sd or None, lbl=f"{p.get('title', norm)} · ")
if raw is None:
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
p["raw"] = raw
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
await db.kanban_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "facts")
@@ -131,7 +185,8 @@ async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
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)}-")
folder, instructions, ns=f"{_safe(norm)}-",
lbl=f"{p.get('title', norm)} · ")
if res is None:
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
facts_map, discarded = res
@@ -154,7 +209,8 @@ async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: s
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)}-")
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 {}
@@ -178,7 +234,8 @@ async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions
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)}-")
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
@@ -201,7 +258,7 @@ async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instr
norm = c["card_id"]
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-")
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
@@ -219,7 +276,7 @@ async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions
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)}-")
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
@@ -301,7 +358,15 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
if c["payload"].get("title")}
if entries:
plan = await _outline_block(ctx, _nset, files, entries, instructions)
# The outline may run BEFORE finalize has merged the global facts.json — feed the
# prereq hints of _learning_order from the card payloads instead (complete as soon
# as every block passed the facts stage, which the trimmed barrier guarantees).
facts_map: dict = {}
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
facts_map.update(bc["payload"].get("facts") or {})
fp = flow.work_dir / "outline-facts.json"
atomic_write_json(fp, facts_map, indent=1)
plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
if ctx.is_cancelled():
return
if isinstance(plan, dict) and plan.get("chapters"):

View File

@@ -46,8 +46,8 @@ from blocks import (
)
from config import (
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, GROUP_MIN_COS_FLOOR,
GROUP_RECONCILE_FLOOR,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
)
from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file
@@ -716,7 +716,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
raise errs[0]
if ctx.is_cancelled():
return
fragments: dict[int, int] = {}
proposals: dict[int, int] = {} # judge demotes are PROPOSALS — panel/containment confirm
drops: set[int] = set()
for ci, numbers in enumerate(chunks):
raw = _json_file(work_dir / f"filter-{h}-c{ci}.json")
@@ -724,7 +724,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
nset = set(numbers)
for nr, parent in verdict.items():
if 1 <= parent <= n_all and nr in nset:
fragments[nr] = parent
proposals[nr] = parent
for x in (raw.get("drop", []) if isinstance(raw, dict) else []):
try:
dnr = int(x)
@@ -735,34 +735,43 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
# hard-drop double gate
honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])}
for nr in honored:
fragments.pop(nr, None)
# containment demote + parentless noise (deterministic)
proposals.pop(nr, None)
# containment demote (deterministic — auto-confirms proposals whose parent name is
# literally contained in the title, and keeps the legacy ⚠-only pass) + parentless noise
norms = [(i, allrows[i - 1]["title_norm"]) for i in range(1, n_all + 1)]
fragments: dict[int, int] = {}
contained: set[int] = set()
for i in range(1, n_dem + 1):
if i in fragments or i in honored or not _filter_suspect(allrows[i - 1]):
if i in honored or (i not in proposals and not _filter_suspect(allrows[i - 1])):
continue
parent = _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i])
if parent is not None and parent != i and parent not in honored:
fragments[i] = parent
contained.add(i)
proposals.pop(i, None)
for i in range(1, n_dem + 1):
if i in fragments or i in honored or not _is_parentless_noise(allrows[i - 1]["title"]):
if i in fragments or i in honored or i in proposals or not _is_parentless_noise(allrows[i - 1]["title"]):
continue
if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None:
honored.add(i)
# recheck panel over still-⚠ survivors (rare-positive recall, majority ≥2).
# ONE wave over ALL (chunk, judge) slots; a single failed judge is tolerated
# (panel votes over whatever answered — legacy semantics). Voting afterwards.
# recheck panel = second opinion: unconfirmed judge proposals (WITHOUT the suggested
# parent — no anchoring) plus still-⚠ survivors. Majority ≥2 demotes/drops; a proposal
# the panel does not confirm survives. The panel is load-bearing now: fewer than 2
# valid judge files per chunk is an error (backoff), not a silent keep.
survivors = [i for i in range(1, n_dem + 1)
if i not in fragments and i not in honored and _filter_suspect(allrows[i - 1])]
if i not in fragments and i not in honored
and (i in proposals or _filter_suspect(allrows[i - 1]))]
overruled: list[int] = []
if survivors:
ph = _h(",".join(map(str, survivors))) # panel-input hash: stale pre-change files never match
rchunks = [survivors[k:k + FILTER_CHUNK] for k in range(0, len(survivors), FILTER_CHUNK)]
async def _recheck_judge(ci, nums, j):
path = work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json"
path = work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json"
if _filter_schema(_json_file(path)) is not None:
return # resume
await run_single_slot(
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-c{ci}-j{j}",
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-{ph}-c{ci}-j{j}",
prompt=_prompt("Blocks-Filter-Recheck", topic=topic,
survivors="\n".join(_fline(i) for i in nums),
list=full_list, out_path=path),
@@ -780,11 +789,13 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
dem: dict[int, list[int]] = {}
drp: dict[int, int] = {}
nset = set(nums)
valid = 0
for j in range(1, FILTER_RECHECK_PANEL + 1):
raw = _json_file(work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json")
raw = _json_file(work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json")
v = _filter_schema(raw)
if v is None:
continue
valid += 1
for nr, parent in v.items():
if nr in nset and 1 <= parent <= n_all and nr != parent:
dem.setdefault(nr, []).append(parent)
@@ -795,6 +806,8 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
continue
if dnr in nset:
drp[dnr] = drp.get(dnr, 0) + 1
if valid < 2:
raise RuntimeError(f"Filter-Recheck chunk {ci}: nur {valid} Judge(s) mit Ergebnis")
for nr in nums:
if nr in fragments or nr in honored:
continue
@@ -803,6 +816,18 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
honored.add(nr)
elif len(dem.get(nr, [])) >= 2:
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
elif nr in proposals:
overruled.append(nr)
# embedding backstop: veto confirmed non-containment demotes whose direct title pair is
# literally structureless (see FRAGMENT_MIN_COS) — applied BEFORE _root resolution.
floor_veto: list[int] = []
if (cand := [nr for nr in fragments if nr not in contained]):
va = await _vec_rows(flow, [r["title"] for r in allrows])
if va is not None:
for nr in cand:
if float(va[nr - 1] @ va[fragments[nr] - 1]) < FRAGMENT_MIN_COS:
fragments.pop(nr)
floor_veto.append(nr)
# statement-gate rescue (final override)
def _protected(nr):
return _is_named_statement(allrows[nr - 1]["title"], allrows[nr - 1]["description"])
@@ -831,8 +856,12 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
moves.append((r["card_id"], "rejected"))
journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"})
atomic_write_json(work_dir / "inventar-filter.json",
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal}, indent=1)
# one journal file per pass (h) — the supplement feedback pass must not overwrite
# the main pass's journal (it is the evaluation instrument).
atomic_write_json(work_dir / f"inventar-filter-{h}.json",
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal,
"ueberstimmt": [allrows[nr - 1]["title"] for nr in overruled],
"floor_veto": [allrows[nr - 1]["title"] for nr in floor_veto]}, indent=1)
_log(topic, f"Fragment-Filter: {n_dem}{n_dem - len(journal)} ({len(journal)})")
demoted = {cid for cid, _ in moves}
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted]
@@ -1057,21 +1086,59 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
if status != OK:
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
supplements = []
known_norms = set()
known_keys = set()
for t in await db.kanban_cards(topic, board=BOARD):
tt = t["payload"].get("title", "")
if tt:
known_norms.add(_norm_title(tt))
if (k := _canonical_key(tt)):
known_keys.add(k)
new = 0
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
# in the dedup: those were rejected as non-blocks, not lost as content.
dead_reasons = {"fragment", "drop-collateral", "drop"}
cards = await db.kanban_cards(topic, board=BOARD)
dead_clusters = {c["payload"].get("cluster") for c in cards
if c["kind"] == "block" and c["stage"] == "rejected"
and c["payload"].get("reason") in dead_reasons}
dead_clusters.discard(None)
membership = await db.kanban_membership(topic)
dead_titles = {nm for nm, cid in membership.items() if cid in dead_clusters}
def _is_dead(c) -> bool:
if c["kind"] == "title":
return c["card_id"] in dead_titles
if c["kind"] == "cluster":
return c["card_id"] in dead_clusters
return c["stage"] == "rejected" and c["payload"].get("reason") in dead_reasons
known_norms, known_keys = set(), set()
dead_by_norm: dict[str, str] = {} # title norm/key → requeue-able title card_id
dead_by_key: dict[str, str] = {}
for c in cards:
tt = c["payload"].get("title", "")
if not tt:
continue
if _is_dead(c):
if c["kind"] == "title":
dead_by_norm.setdefault(c["card_id"], c["card_id"])
if (k := _canonical_key(tt)):
dead_by_key.setdefault(k, c["card_id"])
continue
known_norms.add(_norm_title(tt))
if (k := _canonical_key(tt)):
known_keys.add(k)
new = reopened = 0
for t, d in (supplements or []):
norm = _norm_title(t)
key = _canonical_key(t)
if not norm or norm in known_norms or (key and key in known_keys):
continue
known_norms.add(norm)
dead_id = dead_by_norm.get(norm) or (dead_by_key.get(key) if key else None)
if dead_id:
card = await db.kanban_get_card(topic, BOARD, dead_id)
if card:
card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, dead_id, card["payload"])
await db.kanban_advance(topic, BOARD, dead_id, "cluster")
reopened += 1
continue
desc = f"{d} [Supplement]".strip()
async with _ingest_lock:
await db.kanban_add_title(topic, BOARD, norm, t, desc, "supplement", "supplement")
@@ -1080,8 +1147,8 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
new += 1
if new:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest")
if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set()
@@ -1175,6 +1242,13 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
await board_artefacts.ensure_outline_card(topic)
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
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 = next(s for s in stages if s.stage == "outline")
outline.upstream = [u for u in outline.upstream if u not in
("levels", "relevance", "question_pattern", "artefacts", "finalize")]
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
async def _as_producer(coro):
@@ -1260,6 +1334,12 @@ _VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mir
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"}
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
p = r["payload"]
key = f"{r['board']}:{r['card_id']}"
@@ -1269,11 +1349,18 @@ def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
info = f"{p['merged_into']}"
elif p.get("parent_norm"):
info = f"Fragment von: {p['parent_norm']}"
if is_active and live_info.get(key): # live step message wins while the card is worked
info = live_info[key]
status = "error" if r["retries"] else ("active" if is_active else "open")
return {"title": p.get("title") or r["card_id"], "status": status,
"info": info, "retries": r["retries"]}
out = {"title": p.get("title") or r["card_id"], "retries": r["retries"], "card_id": r["card_id"],
"kind": r.get("kind", ""), "board": r.get("board", ""),
"status": "error" if r["retries"] else ("active" if is_active else "open")}
live = live_info.get(key)
if is_active and live: # live step message wins while the card is worked
info = live["msg"] if isinstance(live, dict) else live # dict since the stepper, str before
step = live.get("step") if isinstance(live, dict) else ""
steps = _PHASE_STEPS.get(_STAGE_PHASE.get(r.get("stage", ""), ""), ())
if step in steps: # phase stepper: which fine step of the card's stage runs (1-based)
out.update(step_i=steps.index(step) + 1, step_n=len(steps), steps=list(steps))
out["info"] = info
return out
async def board_snapshot(topic: str, limit: int = 20) -> dict:
@@ -1361,9 +1448,27 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
await db.delete_subblocks(topic)
await db.delete_question_pattern(topic)
await db.delete_sub_artefakte(topic)
await db.add_event(topic, "reset", key=f"{board}:from-{stage}", status=str(moved))
return moved
async def restart_artefact_card(topic: str, card_id: str) -> bool:
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
(per-block work-dir slots overwrite themselves; finalize re-upserts later).
Only call while nothing is generating (the route guards). → False if unknown."""
card = await db.kanban_get_card(topic, "artefacts", card_id)
if card is None or card["kind"] != "ablock":
return False
await db.delete_subblocks(topic, card_id)
await db.delete_question_pattern(topic, card_id)
await db.delete_sub_artefakte(topic, card_id)
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
await db.kanban_set_payload(topic, "artefacts", card_id, p)
await db.kanban_advance(topic, "artefacts", card_id, "subblocks")
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
return True
async def requeue_dead(topic: str) -> int:
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
n = 0

View File

@@ -11,8 +11,10 @@ UNI_DIR = PROJECT_ROOT / "uni"
def _load_env(path: Path) -> None:
"""Mini .env loader (no dependency): KEY=VALUE lines; existing env always wins
(`make dev` already exports .env — this covers bare `uvicorn`/pytest starts)."""
"""Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
env: a --reload master keeps its startup environment forever, so "existing env wins"
silently pinned stale values across .env edits (measured: file said 24, workers
inherited 15 for hours). Trade-off: ad-hoc shell overrides lose against the file."""
try:
text = path.read_text(encoding="utf-8")
except OSError:
@@ -23,7 +25,7 @@ def _load_env(path: Path) -> None:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
if key and key not in os.environ:
if key:
os.environ[key] = value
@@ -59,6 +61,15 @@ EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/s
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
EMBEDDING_SUB_DUP = 0.90
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.910.95 → negation guard required.
SUB_VARIANT_COS = 0.90
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
# subs are statements: true covers measure 0.270.38 while a wrong hit measured 0.76. The
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
SEED_COVER_COS = 0.80
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
@@ -83,6 +94,12 @@ GROUP_RECONCILE_FLOOR = 0.75
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
GROUP_MIN_COS_FLOOR = 0.15
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
FRAGMENT_MIN_COS = 0.15
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
@@ -125,15 +142,15 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i
# Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = {
"research": (1800, 0), # fixed 30 min
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
"research_mapping": (600, 3), # n = pre-merged entries
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
"plan": (300, 5),
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
"content": (600, 90), # identify content per block in the chunk (web search)
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
"content_check": (300, 10), # content exam per block in the package
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
"level": (300, 10), # classify subblocks per chunk
"level_check": (300, 10), # judge decides contested levels in the chunk
@@ -141,7 +158,7 @@ TIMEOUTS = {
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
"writer": (600, 120), # per section in the chunk
"writer": (450, 60), # per section — split keeps sections ≤30 subs
"lese_check": (300, 10), # per section in the package
# guide board (per card = one block)
"lernziele": (300, 5), # backward-design objectives per block
@@ -194,15 +211,14 @@ PROVIDERS = {
},
}
# Role routing ACROSS provider stacks: generation (quick/guide) and judging (judge)
# may run on different providers within ONE run — judge model ≠ generator model
# (research-backed: cross-model judging avoids self-preference bias).
# Value: "" = provider of the run; "minimax" = that stack's role model;
# "provider:model" = explicit model override.
# Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
# the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
# Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
# to the claude stack regardless of the UI choice ("provider:model" pins a model).
ROLE_ROUTING = {
"quick": os.getenv("ROLE_QUICK", "minimax"),
"judge": os.getenv("ROLE_JUDGE", "claude"),
"guide": os.getenv("ROLE_GUIDE", "minimax"),
"quick": os.getenv("ROLE_QUICK", ""),
"judge": os.getenv("ROLE_JUDGE", ""),
"guide": os.getenv("ROLE_GUIDE", ""),
"fast": os.getenv("ROLE_FAST", ""),
}

View File

@@ -34,19 +34,6 @@ CREATE TABLE IF NOT EXISTS topics (
)
"""
CREATE_ELEMENTS = """
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
examples TEXT NOT NULL DEFAULT '[]',
hints TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
CREATE_BLOCK_TEXTE = """
CREATE TABLE IF NOT EXISTS block_texte (
topic TEXT NOT NULL,
@@ -219,6 +206,28 @@ CREATE TABLE IF NOT EXISTS kanban_cards (
)
"""
# Pipeline history (agents, stage moves, failures) — the raw data for quality/perf
# analysis. Written fire-and-forget from the hooks in kanban_advance_many / kanban_fail_card /
# set_guide_card and agents.run_agent (injected via agents.on_event); never load-bearing.
CREATE_EVENTS = """
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
topic TEXT NOT NULL,
ts TEXT NOT NULL,
kind TEXT NOT NULL,
key TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
dur_ms INTEGER,
wait_ms INTEGER,
meta TEXT NOT NULL DEFAULT '{}'
)
"""
CREATE_EVENTS_INDEX = """
CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts)
"""
CREATE_KANBAN_PULL_INDEX = """
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
"""
@@ -287,7 +296,6 @@ async def init_db():
await db.execute(CREATE_GUIDES)
await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS)
await db.execute(CREATE_ELEMENTS)
await db.execute(CREATE_BLOCK_TEXTE)
await db.execute(CREATE_BLOCK_PROGRESS)
await db.execute(CREATE_BLOCKS)
@@ -300,6 +308,8 @@ async def init_db():
await db.execute(CREATE_GUIDE_OUTLINE)
await db.execute(CREATE_SUB_ARTEFAKTE)
await db.execute(CREATE_KANBAN_CARDS)
await db.execute(CREATE_EVENTS)
await db.execute(CREATE_EVENTS_INDEX)
await db.execute(CREATE_KANBAN_PULL_INDEX)
await db.execute(CREATE_KANBAN_MEMBERS)
await db.execute(CREATE_GUIDE_CARDS)
@@ -353,6 +363,8 @@ async def init_db():
"SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
)
await db.execute("DROP TABLE vertiefungen")
# Migration: the elements feature was removed entirely — drop its orphaned table.
await db.execute("DROP TABLE IF EXISTS elements")
await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
"WHERE status IN ('queued', 'generating')"
@@ -446,59 +458,6 @@ async def delete_topic(name: str) -> None:
await db.commit()
# --- Elements ---
def _element_row(row, cursor) -> dict:
el = _row_to_dict(row, cursor)
el["examples"] = json.loads(el["examples"] or "[]")
el["hints"] = json.loads(el["hints"] or "[]")
return el
async def create_element(element: dict) -> dict:
db = await get_db()
await db.execute(
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
"hints": json.dumps(element["hints"], ensure_ascii=False)},
)
await db.commit()
return element
async def list_elements(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
)
rows = await cursor.fetchall()
return [_element_row(row, cursor) for row in rows]
async def get_element(element_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
row = await cursor.fetchone()
if row is None:
return None
return _element_row(row, cursor)
async def update_element(element_id: str, **fields) -> None:
for key in ("examples", "hints"):
if key in fields:
fields[key] = json.dumps(fields[key], ensure_ascii=False)
await _update("elements", fields, {"id": element_id})
async def delete_element(element_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
await db.commit()
return cursor.rowcount > 0
# --- Chapter progress ---
async def list_progress_all() -> dict[str, set[str]]:
@@ -613,23 +572,6 @@ async def delete_block_progress(topic: str, block: str) -> None:
await db.commit()
async def set_block_completed(topic: str, block: str) -> bool:
"""Marks completed; True only the first time (drives the element task)."""
db = await get_db()
now = _now()
await db.execute(
"INSERT OR IGNORE INTO block_progress (topic, block, good_answers, updated_at) VALUES (?, ?, 0, ?)",
(topic, block, now),
)
cursor = await db.execute(
"UPDATE block_progress SET completed = ?, updated_at = ? "
"WHERE topic = ? AND block = ? AND completed IS NULL",
(now, now, topic, block),
)
await db.commit()
return cursor.rowcount > 0
# Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position):
# beginner/NULL → 1, advanced → 2, expert → 3. Old values (einfach/mittel/schwer) are
# mapped in backward-compatibly. Drives the guide view A/F/E/V + cap (unlocked subs × 25).
@@ -797,11 +739,13 @@ def _card(row, cursor) -> dict:
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
"""Oldest `limit` cards sitting in `stage` whose backoff has expired (FIFO via updated_at)."""
"""`limit` ready cards of `stage` (backoff expired). LPT: cards carrying a `subs_n`
payload field (board 2, set after subblocks) are pulled BIGGEST first — the longest
block starts earliest and stops dominating the makespan tail. Others stay FIFO."""
db = await get_db()
cursor = await db.execute(
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
ORDER BY updated_at LIMIT ?""",
ORDER BY COALESCE(json_extract(payload, '$.subs_n'), 0) DESC, updated_at LIMIT ?""",
(topic, board, stage, _now(), limit))
return [_card(row, cursor) for row in await cursor.fetchall()]
@@ -829,6 +773,27 @@ async def kanban_advance(topic: str, board: str, card_id: str, stage: str) -> No
await kanban_advance_many(topic, board, [(card_id, stage)])
async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "",
dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None:
"""One pipeline-history row, own commit. Callers treat this as fire-and-forget."""
db = await get_db()
await db.execute(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
(topic, _now(), kind, key, label, status, dur_ms, wait_ms,
json.dumps(meta or {}, ensure_ascii=False)))
await db.commit()
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
"""Batch insert WITHOUT commit — must run inside the caller's transaction
(kanban_advance_many) so the event batch stays atomic with the moves."""
now = _now()
await db.executemany(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
[(topic, now, kind, key, label, status, None, None, "{}")
for kind, key, label, status in rows])
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
"""Batch stage moves in ONE commit (the flow advances whole packages)."""
if not moves:
@@ -839,6 +804,7 @@ async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND card_id = ?""",
[(stage, now, topic, board, cid) for cid, stage in moves])
await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
await db.commit()
@@ -912,6 +878,10 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
WHERE topic = ? AND board = ? AND card_id = ?""",
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
topic, board, card_id))
await db.execute(
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
(topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}",
None, None, json.dumps({"error": error[:200]}, ensure_ascii=False)))
await db.commit()
return dead
@@ -928,6 +898,7 @@ async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND stage = 'dead'""",
(stage, _now(), topic, board))
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
await db.commit()
return cursor.rowcount
@@ -1032,6 +1003,11 @@ async def set_guide_card(topic: str, format: str, block_norm: str, **fields) ->
await db.execute(
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
(*fields.values(), _now(), topic, format, block_norm))
if "stage" in fields: # the guide board moves stages here, not via kanban_advance_many
await _add_events_many(db, topic, [("stage", f"guide:{format}:{block_norm}", "", fields["stage"])])
elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card
await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "",
str(fields.get("gate_info", ""))[:200])])
await db.commit()
@@ -1202,9 +1178,12 @@ async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fiel
await _update("subblocks", fields, {"topic": topic, "block_norm": block_norm, "sub_norm": sub_norm})
async def delete_subblocks(topic: str) -> None:
async def delete_subblocks(topic: str, block_norm: str | None = None) -> None:
db = await get_db()
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
if block_norm is None:
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
else:
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
@@ -1232,9 +1211,12 @@ async def list_question_pattern(topic: str, block_norm: str | None = None) -> li
return [_row_to_dict(row, cursor) for row in rows]
async def delete_question_pattern(topic: str) -> None:
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
db = await get_db()
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
if block_norm is None:
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
else:
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
@@ -1408,9 +1390,12 @@ async def get_sub_artefakte(topic: str, type: str | None = None) -> list[dict]:
return [_row_to_dict(row, cursor) for row in rows]
async def delete_sub_artefakte(topic: str) -> None:
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
db = await get_db()
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
if block_norm is None:
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
else:
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm))
await db.commit()
@@ -1441,6 +1426,6 @@ async def delete_topic_pipeline(topic: str) -> None:
NOT the topic config `source` — that is managed separately (delete_source)."""
db = await get_db()
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
"pipeline_state", "guide_outline", "sub_artefakte"):
"pipeline_state", "guide_outline", "sub_artefakte", "events"):
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit()

View File

@@ -1,261 +0,0 @@
"""Elements (personal summary) and tutor chat for the guide."""
import json
import logging
import uuid
from agents import run_agent
from config import DEFAULT_PROVIDER
from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
from paths import blocks_path, guide_content_path
from pipeline import _prompt
log = logging.getLogger("creator.elements")
# --- Tutor chat ---
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
transcript = "\n".join(
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
for m in messages
)
return _prompt(
"Chat",
topic=topic, format_name=format_name,
outline_block=outline.strip() or "(none)",
section_block=section.strip() or "(no section detected)",
transcript=transcript,
)
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
returncode, stdout, stderr = await run_agent(
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return "Sorry, that didn't work. Please try again."
reply = stdout.strip()
return reply or "Sorry, I didn't get a response."
except Exception:
log.warning("[%s] Guide chat failed", topic, exc_info=True)
return "Sorry, that didn't work. Please try again."
# --- Elements ---
def _element_fields(data: dict) -> dict | None:
"""Validate AI element JSON and normalize it onto the DB fields."""
if not isinstance(data, dict):
return None
title = str(data.get("title", "")).strip()
if not title:
return None
lists = {}
for key in ("examples", "hints"):
raw = data.get(key, [])
lists[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
return {
"title": title[:200],
"description": str(data.get("description", "")).strip(),
"examples": lists["examples"],
"hints": lists["hints"],
}
def _topic_context(topic: str, limit: int = 12000) -> str:
"""Blocks + guide content of the topic as context text (truncated)."""
parts: list[str] = []
bp = blocks_path(topic)
if bp.exists():
parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
content = _read_json_file(guide_content_path(topic, fmt))
if content:
for ch in content.get("chapters", []):
for sec in ch.get("sections", []):
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
break # the best available guide is enough
text = "\n\n".join(parts).strip()
return text[:limit] if text else "(no material available)"
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
"""Create element fields via AI. Fallback: only the title from the keyword."""
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
try:
context = _topic_context(topic)
if extra_context.strip():
context = (extra_context.strip() + "\n\n" + context)[:12000]
prompt = _prompt(
"Element-Create",
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
context=context,
)
returncode, stdout, _ = await run_agent(
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return fallback
return _element_fields(_parse_json_text(stdout)) or fallback
except Exception:
log.warning("[%s] Element creation failed", topic, exc_info=True)
return fallback
def _parse_suggestions(stdout: str) -> list[dict] | None:
"""Validate suggestion JSON from AI output. None on invalid JSON."""
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
suggestions = []
for s in data.get("suggestions", []):
if not isinstance(s, dict):
continue
text = str(s.get("text", "")).strip()
target = s.get("target")
content = str(s.get("content", "")).strip()
if text and content and target in ("description", "examples", "hints"):
suggestions.append({"text": text, "target": target, "content": content})
return suggestions
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Two-step check for missing info: research → verify. None on error."""
try:
element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
context = _topic_context(element["topic"])
# Step 1: research — collect candidates broadly
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
returncode, stdout, _ = await run_agent(
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
candidates = _parse_suggestions(stdout)
if candidates is None:
return None
if not candidates:
return []
# Step 2: verify — only let important, non-redundant items through
prompt = _prompt(
"Element-Verify",
topic=element["topic"], element_json=element_json,
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
context=context,
)
returncode, stdout, _ = await run_agent(
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
return _parse_suggestions(stdout)
except Exception:
log.warning("[%s] Element check failed", element.get("topic", "?"), exc_info=True)
return None
def _element_json(element: dict) -> str:
return json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
def _validate_change(c, element: dict) -> dict | None:
"""Validate a change suggestion from AI output against the element."""
if not isinstance(c, dict):
return None
text = str(c.get("text", "")).strip()
action = c.get("action")
target = c.get("target")
index = c.get("index")
content = str(c.get("content", "")).strip()
if not text or action not in ("remove", "adjust", "add"):
return None
if target not in ("title", "description", "examples", "hints"):
return None
if action in ("adjust", "add") and not content:
return None
if action == "remove" and target not in ("examples", "hints"):
return None
# Index only for adjust/remove on list fields; must exist
if target in ("examples", "hints") and action in ("adjust", "remove"):
if not isinstance(index, int) or not (0 <= index < len(element[target])):
return None
else:
index = None
return {"text": text, "action": action, "target": target, "index": index, "content": content}
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
"""Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
error = "Sorry, that didn't work. Please try again."
try:
transcript = "\n".join(
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
for m in messages
)
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
returncode, stdout, _ = await run_agent(
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return error, []
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return error, []
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
return reply, changes
except Exception:
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
return error, []
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Check an element against the style rules and suggest changes. None on error."""
try:
prompt = _prompt("Element-Style", topic=element["topic"], element_json=_element_json(element))
returncode, stdout, _ = await run_agent(
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
except Exception:
log.warning("[%s] Style check failed", element.get("topic", "?"), exc_info=True)
return None
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
"""Revise a single suggestion per user instruction. None on error."""
try:
prompt = _prompt(
"Element-Refine",
topic=element["topic"], element_json=_element_json(element),
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
instruction=instruction,
)
returncode, stdout, _ = await run_agent(
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
return _validate_change(data.get("change"), element)
except Exception:
log.warning("[%s] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
return None

View File

@@ -396,3 +396,34 @@ async def block_adopt(topic: str, format_name: str, block: str, spot: str, old:
await set_guide_content(topic, format_name, js)
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}
# --- Tutor chat (moved from the removed elements module) ---
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
transcript = "\n".join(
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
for m in messages
)
return _prompt(
"Chat",
topic=topic, format_name=format_name,
outline_block=outline.strip() or "(none)",
section_block=section.strip() or "(no section detected)",
transcript=transcript,
)
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
try:
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
returncode, stdout, stderr = await run_agent(
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
)
if returncode != 0:
return "Sorry, that didn't work. Please try again."
reply = stdout.strip()
return reply or "Sorry, I didn't get a response."
except Exception:
log.warning("[%s] Guide chat failed", topic, exc_info=True)
return "Sorry, that didn't work. Please try again."

View File

@@ -1,11 +1,11 @@
"""Board 3 „Guide": one card per block, linear stages with gates between them.
lernziele judge Backward Design — objectives BEFORE writing
zuweisung code chapter/order from the outline artefact + facts grounding
writer guide ONE coherent per-block text, only from VERIFIED FACTS
fakten_gate judge CoVe: atomic claims, each binary against the facts → minimal fix
coverage judge objective↔section mapping; gap → back to writer (max 2 rounds)
lesbarkeit judge Lese-Check + deterministic readability gate → fix → done
lernziele judge-Rolle Backward Design — objectives BEFORE writing
zuweisung code chapter/order from the outline artefact + facts grounding
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
lesbarkeit judge-Rolle Lese-Check + deterministic readability gate → fix → done
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
needed); stage transitions are persisted in guide_cards, so the board is live and
@@ -20,7 +20,7 @@ import re
import database as db
import readability
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC
from fsutil import atomic_write_json
from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
@@ -34,7 +34,10 @@ STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "W
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 12)
CARD_CONCURRENCY = 10 # simultaneous cards (the per-topic agent semaphore is the hard cap)
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
def _safe(norm: str) -> str:
@@ -142,6 +145,15 @@ def _card_assignment(env: _Env, card: dict) -> str:
return "\n".join(lines)
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
# board_snapshot shows it as the info line while status == active.
_live_info: dict[tuple[str, str, str], str] = {}
def _live(env: _Env, card: dict, msg: str) -> None:
_live_info[(env.topic, env.format, card["block_norm"])] = msg
async def _set(env: _Env, card: dict, **fields):
card.update(fields)
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
@@ -179,6 +191,82 @@ async def _stage_zuweisung(env: _Env, card: dict) -> bool:
return True
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
# halves and merged back into ONE canonical section (all gates/assembly read one section).
WRITER_SPLIT_SUBS = 30
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
lines = []
if sec_a.get("chapters"):
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
lines.append(f"<!-- section: {sec_a['title']} -->")
lines.append("<!-- compact -->")
if sec_a.get("anker_compact"):
lines.append(sec_a["anker_compact"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("compact"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["compact"])
lines.append("<!-- ausführlich -->")
if sec_a.get("anchor"):
lines.append(sec_a["anchor"])
for sub in [*sec_a["subs"], *sec_b["subs"]]:
if sub.get("md"):
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
lines.append(sub["md"])
return "\n\n".join(lines)
async def _write_split(env: _Env, card: dict, ziele_text: str):
"""First draft in two halves (parallel), merged into one section.
→ merged text | None (failed) | False (cancelled)."""
from guide import _level_label
norm = card["block_norm"]
subs = env.subs_by_title.get(card["block"], [])
half = (len(subs) + 1) // 2
parts = (subs[:half], subs[half:])
hints = (
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
)
async def _one(i):
assignment = "\n".join([f"- {card['block']}"]
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
path.unlink(missing_ok=True)
def _payload(result, p=path):
t = p.read_text(encoding="utf-8") if p.exists() else ""
sec = _first_section(t)
return t if sec and sec.get("md", "").strip() else None
return await run_single_slot(
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
chapter=card.get("chapter") or "Inhalte",
assignment=assignment, ziele=ziele_text,
facts=_card_facts(env, card["block"]), gaps="\n" + hints[i] + "\n",
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_payload,
timeout=_timeout("writer", 1))
results = await asyncio.gather(_one(0), _one(1))
if any(s == CANCELLED for s, _ in results):
return False
if any(s == FAILED for s, _ in results):
return None
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, norm)
@@ -188,6 +276,17 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
"below, cut the listed ballast, keep everything else as-is.\n"
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
# oversized first drafts: two halves, merged into one canonical section
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
text = await _write_split(env, card, ziele_text)
if text is False:
return False
if text is None:
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
return False
await _set(env, card, md=text, stage="fakten_gate", status="open")
return True
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
path.unlink(missing_ok=True)
@@ -251,7 +350,7 @@ async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
section=card["md"], claims=claims_text, facts=facts,
out_path=fixp, extra=_extra(env.instructions)),
role="guide", capabilities="files", payload=_fixload, # Fix ≠ Gate-Modell (kein Selbst-Check)
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
timeout=_timeout("fakten_gate", 1))
if fstatus == CANCELLED:
return False
@@ -372,23 +471,31 @@ _STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
async with sem:
while card["stage"] != "done":
if is_guide_cancelled(env.guide_id):
await _set(env, card, status="open") # no longer being worked
return
fn = _STAGE_FN.get(card["stage"])
if fn is None: # unknown stage → park as error
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
return
if card["status"] != "active":
await _set(env, card, status="active") # live board: this card is being worked
try:
if not await fn(env, card):
return
except Exception as e:
log.exception("[%s] guide card %s failed", env.topic, card["block"])
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
try:
await _run_card_inner(env, card)
finally:
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
async def _run_card_inner(env: _Env, card: dict) -> None:
while card["stage"] != "done":
if is_guide_cancelled(env.guide_id):
await _set(env, card, status="open") # no longer being worked
return
fn = _STAGE_FN.get(card["stage"])
if fn is None: # unknown stage → park as error
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
return
if card["status"] != "active":
await _set(env, card, status="active") # live board: this card is being worked
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "")
try:
if not await fn(env, card):
return
except Exception as e:
log.exception("[%s] guide card %s failed", env.topic, card["block"])
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
return
# ── Orchestration ──────────────────────────────────────────────────────────────────
@@ -498,16 +605,35 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
views = []
for c in in_stage[:limit]:
zc = ziele.get(c["block_norm"])
views.append({"title": c["block"],
info = c["gate_info"][:200] if c["status"] == "error" else ""
if c["status"] == "active":
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
views.append({"title": c["block"], "card_id": c["block_norm"],
"status": c["status"] if c["status"] in ("error", "active") else "open",
"rounds": c["writer_rounds"],
"info": c["gate_info"][:200] if c["status"] == "error" else "",
"info": info,
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
columns.append({"key": stage, "label": STAGE_LABELS[stage],
"total": len(in_stage), "cards": views})
return {"columns": columns}
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
if block_norm not in cards:
return False
fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="")
if ab_stage <= 2:
fields["md"] = ""
if ab_stage == 0:
await db.delete_lernziele(topic, block_norm)
await db.set_guide_card(topic, format_name, block_norm, **fields)
return True
async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))

View File

@@ -13,8 +13,7 @@ from datetime import datetime, timezone
from agents import run_agent
from config import DEFAULT_PROVIDER
from database import create_element, list_elements, get_block_hurdles
from elements import generate_element
from database import get_block_hurdles
from jsonio import parse_json_text as _parse_json_text
from pipeline import _prompt, _problems_schema
from textkit import _norm_title
@@ -637,21 +636,3 @@ async def block_discussion(
return None
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
"""Background task after completion: register the block as an element.
Dedup via normalized title — if an element for the block already exists,
nothing happens. Must never raise an exception to the outside.
"""
try:
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
if _norm_title(block) in existing:
return
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
if _norm_title(fields["title"]) in existing:
return
now = datetime.now(timezone.utc).isoformat()
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
log.info("[%s] Block registered as element: %s", topic, fields["title"])
except Exception:
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)

View File

@@ -9,6 +9,8 @@ from logsetup import setup_logging
setup_logging()
from config import FRONTEND_DIST, STORAGE_DIR
import agents
import database
from database import init_db, close_db
from guide import reconcile_guides
from routes import router
@@ -18,6 +20,7 @@ from routes import router
async def lifespan(app: FastAPI):
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
await init_db()
agents.on_event = database.add_event # pipeline history sink (agents.py stays DB-free)
await reconcile_guides()
yield
await close_db()

View File

@@ -39,6 +39,18 @@ class BlocksCreateRequest(BaseModel):
research: bool = True # False = Continue: drain the existing kanban queue, no new search
class BlocksCardRestartRequest(BaseModel):
topic: str = Field(min_length=1)
card_id: str = Field(min_length=1, max_length=200)
class GuideCardResetRequest(BaseModel):
topic: str = Field(min_length=1)
format: str = Field(min_length=1)
block_norm: str = Field(min_length=1, max_length=200)
ab_stage: int = Field(ge=0, le=5)
class BlocksResetStageRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
board: Literal["inventory", "artefacts"]
@@ -134,76 +146,6 @@ class GuideChatResponse(BaseModel):
reply: str
class ElementResponse(BaseModel):
id: str
topic: str
title: str
description: str = ""
examples: list[str] = []
hints: list[str] = []
created_at: str
updated_at: str
class ElementCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
hint: str = Field(default="", max_length=500)
provider: ProviderType = "claude"
class ElementUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=200)
description: str | None = None
examples: list[str] | None = None
hints: list[str] | None = None
class ElementCheckRequest(BaseModel):
provider: ProviderType = "claude"
class ElementSuggestion(BaseModel):
text: str
target: Literal["description", "examples", "hints"]
content: str
class ElementCheckResponse(BaseModel):
suggestions: list[ElementSuggestion]
class ElementStyleChange(BaseModel):
text: str
action: Literal["remove", "adjust", "add"]
target: Literal["title", "description", "examples", "hints"]
index: int | None = None
content: str = ""
class ElementStyleResponse(BaseModel):
changes: list[ElementStyleChange]
class ElementChatRequest(BaseModel):
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
class ElementChatResponse(BaseModel):
reply: str
changes: list[ElementStyleChange] = []
class ElementRefineRequest(BaseModel):
suggestion: ElementStyleChange
instruction: str = Field(min_length=1, max_length=2000)
provider: ProviderType = "claude"
class ElementRefineResponse(BaseModel):
change: ElementStyleChange
class ProgressUpdate(BaseModel):
chapter: str = Field(min_length=1, max_length=100)
done: bool

View File

@@ -196,10 +196,11 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
def spawn(i: int) -> None:
slot = slots[i]
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, on_line=slot.get("on_line"),
scope=topic, on_line=slot.get("on_line"), label=lbl,
))
tasks[task] = i

View File

@@ -13,28 +13,24 @@ from database import (
create_guide, delete_guide, get_guide, list_guides,
create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element,
list_block_progress, get_block_progress, set_open_question,
set_block_score_and_streak, set_block_completed,
set_block_score_and_streak,
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board,
)
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
from learning import block_chat, block_discussion, create_block_element, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
from guide import generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
from models import (
GuideCreateRequest, GuideResponse,
TopicCreateRequest,
BlocksCreateRequest, BlocksResetStageRequest, BlocksStatusResponse,
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
GuideCardResetRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse,
@@ -177,14 +173,14 @@ async def get_blocks_board(topic: str):
snap["generating"] = status["generating"]
snap["progress"] = status["progress"]
snap["error"] = status["error"]
snap["agents"] = [{"label": a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
for a in active_agents(f"blocks-{topic}-")]
return snap
@router.get("/blocks/agents")
async def get_blocks_agents(topic: str):
return [{"label": a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
return [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
for a in active_agents(f"blocks-{topic}-")]
@@ -214,6 +210,28 @@ async def requeue_blocks_dead(topic: str):
return {"ok": True, "requeued": await requeue_dead(topic)}
@router.post("/blocks/card-restart")
async def blocks_card_restart(req: BlocksCardRestartRequest):
if (await blocks_status(req.topic))["generating"]:
return {"ok": True, "status": "generating"}
if not await restart_artefact_card(req.topic, req.card_id):
raise HTTPException(404, "Card not found")
return {"ok": True}
@router.post("/guides/board/card-reset")
async def guide_card_reset(req: GuideCardResetRequest):
running = [g for g in await list_guides()
if g["topic"] == req.topic and g["format"] == req.format
and g["status"] in ("queued", "generating")]
if running:
return {"ok": True, "status": "generating"}
from guide_board import reset_card
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
raise HTTPException(404, "Card not found")
return {"ok": True}
@router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str):
if not cancel_blocks(topic):
@@ -361,11 +379,10 @@ def _color(points: int) -> str:
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
from the base (delayed unlock at the level threshold); element once from beginner level.
from the base (delayed unlock at the level threshold).
Re-rating of the same question uses the open streak anchor → idempotent."""
async with _check_lock(req.topic, req.block):
state = await get_block_progress(req.topic, req.block)
was_level = state["completed"] is not None # element guard: ever created already?
basis, re_rating = _basis(state, question)
streak_basis = state["offene_streak"] if re_rating else state["streak"]
if not re_rating:
@@ -378,10 +395,6 @@ async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int])
score = compute_score(basis, d, floor, ca, cf)
points = score - basis
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
# Create the learning element once, as soon as the first level (beginner) is reached.
if not was_level and level_from_score(score, cf) is not None:
if await set_block_completed(req.topic, req.block):
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
@@ -571,7 +584,7 @@ async def get_guide_board(topic: str, format: str = "Guide"):
snap["progress"] = guide.get("progress") if guide else None
snap["error"] = guide.get("error_msg") if guide else None
prefix = f"{guide['id']}-" if guide else "-"
snap["agents"] = [{"label": a["key"].removeprefix(prefix), "runtime": a["runtime"]}
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
for a in active_agents(prefix)]
return snap
@@ -658,82 +671,6 @@ async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
return res
# --- Elements (personal summary) ---
@router.get("/elements", response_model=list[ElementResponse])
async def get_elements(topic: str):
return await list_elements(topic)
@router.post("/elements", response_model=ElementResponse)
async def post_element(req: ElementCreateRequest):
fields = await generate_element(req.topic, req.hint, provider=req.provider)
now = datetime.now(timezone.utc).isoformat()
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
await create_element(element)
return element
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
async def element_chat(element_id: str, req: ElementChatRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element not found")
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
return {"reply": reply, "changes": changes}
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
async def element_refine(element_id: str, req: ElementRefineRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element not found")
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
if change is None:
raise HTTPException(502, "Revision failed — please try again")
return {"change": change}
@router.put("/elements/{element_id}", response_model=ElementResponse)
async def put_element(element_id: str, req: ElementUpdateRequest):
if await get_element(element_id) is None:
raise HTTPException(404, "Element not found")
fields = req.model_dump(exclude_unset=True, exclude_none=True)
if fields:
now = datetime.now(timezone.utc).isoformat()
await update_element(element_id, **fields, updated_at=now)
return await get_element(element_id)
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
async def element_style(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element not found")
changes = await style_element(element, provider=req.provider)
if changes is None:
raise HTTPException(502, "Style check failed — please try again")
return {"changes": changes}
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
async def element_check(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element not found")
suggestions = await check_element(element, provider=req.provider)
if suggestions is None:
raise HTTPException(502, "Check failed — please try again")
return {"suggestions": suggestions}
@router.delete("/elements/{element_id}")
async def remove_element(element_id: str):
if not await delete_element(element_id):
raise HTTPException(404, "Element not found")
return {"ok": True}
@router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id)

View File

@@ -57,26 +57,26 @@ 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=""):
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
title = list(entries.values())[0].split("")[0]
return {title: ["Sub Eins", "Sub Zwei"]}
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns=""):
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""):
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_levels(ctx, set_p, files, raw, instructions, ns=""):
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=""):
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=""):
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=""):
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": []}
@@ -185,7 +185,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
import blocks as blx
db, ctx, files = board_env
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
return {}
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
@@ -208,3 +208,304 @@ async def test_reader_union_folds_exact_dupes(testdb):
assert set(card["payload"]["readers"]) == {"r1", "r2"}
assert set(card["payload"]["sources"]) == {"s1", "s2"}
assert card["payload"]["description"] == "d länger"
# ── Fragment-Filter: Zweitmeinung, Containment, Floor, Supplement-Reopen ────────────
def _slot_router(handlers, counter=None):
"""Fully scripted judge: first matching key-substring wins, its JSON lands at out_path."""
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
m = _PATH_RE.search(prompt)
out = None
for pat, h in handlers:
if pat in key:
if counter is not None:
counter[pat] = counter.get(pat, 0) + 1
out = h(key) if callable(h) else h
break
if m and out is not None:
with open(m.group(1), "w", encoding="utf-8") as f:
json.dump(out, f)
return "ok", payload(None)
return fake
def _mk_flow(tmp_path):
import asyncio
from types import SimpleNamespace
return SimpleNamespace(topic=TOPIC, work_dir=tmp_path, state={}, wake=asyncio.Event())
async def _run_filter(db, ctx, tmp_path, cards):
"""Seed block cards into fragment_filter and run ONE barrier pass over them."""
for cid, p in cards:
await db.kanban_upsert_card(TOPIC, B, cid, "block", "fragment_filter", p)
rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
await bi._proc_fragment_filter(ctx, _mk_flow(tmp_path), rows)
def _confirm_votes(votes, verdict):
"""Recheck judge j∈votes returns `verdict`, the rest keep everything."""
return lambda key: verdict if key.rsplit("-j", 1)[1] in votes else {"fragments": {}, "drop": []}
async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
"""Judge-Demote ist nur Vorschlag — 2 Panel-Stimmen bestätigen → rejected."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
]))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
assert c1["stage"] == "rejected"
assert c1["payload"]["reason"] == "fragment"
assert c1["payload"]["parent_norm"] == "codeblock"
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
]))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["ueberstimmt"] == ["Blockzitat"]
assert journal["degradiert"] == 0
async def test_containment_autoconfirm_skips_panel(board_env, tmp_path, monkeypatch):
"""Proposal mit Namens-Containment wird deterministisch committet — ohne Recheck-Call."""
db, ctx, files = board_env
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", {"fragments": {}, "drop": []}),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
], counter))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
("b-2", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
])
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
assert c1["stage"] == "rejected"
assert c1["payload"]["parent_norm"] == "aufgabenlisten"
assert "-filter-recheck-" not in counter # no panel needed
async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatch):
"""Orthogonale Titel-Vektoren: bestätigter Judge-Demote ohne Containment wird vetot,
der Containment-Demote nicht."""
import numpy as np
db, ctx, files = board_env
async def emb_on(flow):
return True
async def ortho_vecs(flow, texts):
uniq = list(dict.fromkeys(texts))
eye = np.eye(max(2, len(uniq)))
pos = {t: eye[i] for i, t in enumerate(uniq)}
return np.vstack([pos[t] for t in texts])
monkeypatch.setattr(bi, "_emb_ok", emb_on)
monkeypatch.setattr(bi, "_vec_rows", ortho_vecs)
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2, "3": 4}, "drop": []}),
]))
await _run_filter(db, ctx, tmp_path, [
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
])
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" # floor veto
assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
assert journal["floor_veto"] == ["Blockzitat"]
async def test_filter_resume_no_new_calls(board_env, tmp_path, monkeypatch):
"""Zweiter Lauf über identischem Zustand resumed alle Judge-Dateien: 0 neue Calls."""
db, ctx, files = board_env
counter = {}
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
("-filter-", {"fragments": {"1": 2}, "drop": []}),
], counter))
cards = [("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"})]
await _run_filter(db, ctx, tmp_path, cards)
first = dict(counter)
assert first["-filter-"] == 1 and first["-filter-recheck-"] == 3
await _run_filter(db, ctx, tmp_path, cards)
assert counter == first
async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch):
"""Vorschlag trifft einen wegdegradierten Titel → Lineage wird wiedereröffnet;
failed-quorum bleibt dedupt; frische Titel landen normal im ingest."""
db, ctx, files = board_env
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
("-supplement", {"blocks": [
{"title": "Blockzitate", "description": "Zitat-Syntax"},
{"title": "Leerzeilen", "description": "Trenner"},
{"title": "Neu-Konzept", "description": "fehlt kanonisch"},
]}),
]))
# dead lineage: title → cluster cl-1 → block b-cl-1 demoted as fragment
await db.kanban_add_title(TOPIC, B, "blockzitate", "Blockzitate", "d", "s1", "r1")
await db.kanban_advance(TOPIC, B, "blockzitate", "clustered")
await db.kanban_set_member(TOPIC, "blockzitate", "cl-1")
await db.kanban_upsert_card(TOPIC, B, "cl-1", "cluster", "done_cluster", {"title": "Blockzitate"})
await db.kanban_upsert_card(TOPIC, B, "b-cl-1", "block", "rejected",
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
"cluster": "cl-1", "parent_norm": "codeblöcke"})
# failed-quorum lineage: deliberately rejected as non-block → must stay deduped
await db.kanban_add_title(TOPIC, B, "leerzeilen", "Leerzeilen", "d", "s1", "r1")
await db.kanban_advance(TOPIC, B, "leerzeilen", "clustered")
await db.kanban_set_member(TOPIC, "leerzeilen", "cl-2")
await db.kanban_upsert_card(TOPIC, B, "cl-2", "cluster", "rejected",
{"title": "Leerzeilen", "reason": "failed-quorum"})
flow = _mk_flow(tmp_path)
flow.state["instructions"] = ""
await bi._supplement_producer(ctx, flow, ["Codeblöcke"])
reopened = await db.kanban_get_card(TOPIC, B, "blockzitate")
assert reopened["stage"] == "cluster"
assert reopened["payload"]["supplement"] is True
assert (await db.kanban_get_card(TOPIC, B, "leerzeilen"))["stage"] == "clustered"
fresh = await db.kanban_get_card(TOPIC, B, "neu-konzept")
assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
def test_agent_priority_order():
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
from agents import _agent_priority as p
t = "blocks-Markdown"
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-supplement")
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
< p(f"{t}-ns-subblock-c1-r2-1"))
assert p(f"{t}-supplement") < p(f"{t}-outline-1")
assert p("guide-t-writer-k1") == 16 # unmatched → after everything
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
"""Gliederung startet, sobald alle Karten die facts-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
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)
base_outline = ba._outline_block
async def spy_outline(ctx, set_p, files, entries, instructions):
cards = await db.kanban_cards(TOPIC, board="artefacts", kind="ablock")
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, "_outline_block", spy_outline)
ok = await asyncio.wait_for(
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
timeout=30)
assert ok
assert snapshot["unfinished"] > 0 # outline ran while blocks were still in levels+
outline = await db.get_outline(TOPIC)
assert outline and "Kapitel 1" in outline
async def test_outline_facts_from_payloads(board_env, tmp_path):
"""_proc_outline speist die Prereq-Hints aus den Karten-Payloads —
unabhängig vom globalen facts.json (das erst finalize schreibt)."""
import board_artefacts as ba
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",
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
{"title": "Gliederung"})
flow = _mk_flow(tmp_path)
await ba._proc_outline(ctx, flow, files, "", [{"card_id": "outline", "payload": {}}])
merged = json.loads((tmp_path / "outline-facts.json").read_text(encoding="utf-8"))
assert merged["Alpha"]["sub eins"]["prerequisites"] == "Beta zuerst"
assert (await db.kanban_get_card(TOPIC, "artefacts", "outline"))["stage"] == "done_artefact"
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,
"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"
# 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
# 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"}})
assert "step_n" not in v3
async def test_seed_map_resolves_cascade(testdb):
"""Seeds folgen der Redirect-Kette bis zum lebenden Block; Zyklen/Dead-Ends verfallen."""
import board_artefacts as ba
db = testdb
# chain: fragment → grouped member → living umbrella (with self-edge Listen→Listen)
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "rejected",
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
"parent_norm": "eingerückte codeblöcke"})
await db.kanban_upsert_card(TOPIC, B, "b-2", "block", "grouped",
{"title": "Eingerückte Codeblöcke", "merged_into": "Codeblöcke"})
await db.kanban_upsert_card(TOPIC, B, "b-3", "block", "done_block",
{"title": "Codeblöcke", "mirrored_norm": "codeblöcke"})
await db.kanban_upsert_card(TOPIC, B, "b-4", "block", "grouped",
{"title": "Listen", "merged_into": "Listen"})
await db.kanban_upsert_card(TOPIC, B, "b-5", "block", "done_block",
{"title": "Listen", "mirrored_norm": "listen"})
await db.kanban_upsert_card(TOPIC, B, "b-6", "block", "rejected",
{"title": "Aufgabenlisten", "reason": "fragment", "parent_norm": "listen"})
# cycle: a → b → a, neither alive
await db.kanban_upsert_card(TOPIC, B, "b-7", "block", "rejected",
{"title": "A-Ding", "reason": "fragment", "parent_norm": "b-ding"})
await db.kanban_upsert_card(TOPIC, B, "b-8", "block", "rejected",
{"title": "B-Ding", "reason": "fragment", "parent_norm": "a-ding"})
seeds = await ba._seed_map(TOPIC)
# grouped umbrella members become seeds of their living target too (whole absorbed topics)
assert {k: sorted(v) for k, v in seeds.items()} == {
"codeblöcke": ["Blockzitate (Blockquotes)", "Eingerückte Codeblöcke"],
"listen": ["Aufgabenlisten", "Listen"]}
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)."""
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
assert "ns" in params and "lbl" in params, fn
assert "seeds" in inspect.signature(blx._subblocks_block).parameters

View File

@@ -0,0 +1,174 @@
"""Event-Tracking (events-Tabelle) + Agenten-Labels."""
import asyncio
import agents
from pipeline import GenContext
TOPIC = "t"
async def _events(db, kind=None):
conn = await db.get_db()
q = "SELECT topic, kind, key, label, status, dur_ms, wait_ms FROM events WHERE topic = ?"
args = [TOPIC]
if kind:
q += " AND kind = ?"
args.append(kind)
cur = await conn.execute(q, args)
return [dict(zip(("topic", "kind", "key", "label", "status", "dur_ms", "wait_ms"), r))
for r in await cur.fetchall()]
async def test_advance_many_writes_stage_events(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
await db.kanban_advance_many(TOPIC, "inventory", [("a", "s2"), ("b", "s2")])
evs = await _events(db, "stage")
assert {(e["key"], e["status"]) for e in evs} == {("inventory:a", "s2"), ("inventory:b", "s2")}
async def test_fail_card_events_retry_then_dead(testdb):
db = testdb
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is False
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is True
evs = await _events(db, "fail")
assert [e["status"] for e in evs] == ["retry1", "dead"]
async def test_guide_stage_event(testdb):
db = testdb
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.set_guide_card(TOPIC, "Guide", "alpha", stage="writer")
evs = await _events(db, "stage")
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and evs[-1]["status"] == "writer"
async def test_run_agent_emits_event_and_survives_broken_sink(testdb, monkeypatch):
recorded = []
async def sink(**kw):
recorded.append(kw)
async def fake_cli(agent_key, prompt, timeout, model, capabilities, label=""):
return 0, "out", ""
monkeypatch.setattr(agents, "on_event", sink)
monkeypatch.setattr(agents, "_run_claude_cli", fake_cli)
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("claude", "test-model"))
rc, out, err = await agents.run_agent("blocks-t-x", "p", 5, provider="claude",
role="judge", scope=TOPIC, label="Alpha · Judge")
assert rc == 0
assert recorded and recorded[0]["kind"] == "agent"
assert recorded[0]["label"] == "Alpha · Judge" and recorded[0]["status"] == "ok"
assert isinstance(recorded[0]["wait_ms"], int) and isinstance(recorded[0]["dur_ms"], int)
# broken sink never breaks the call; interactive/scope-less calls don't log
async def broken(**kw):
raise RuntimeError("sink down")
monkeypatch.setattr(agents, "on_event", broken)
rc, _, _ = await agents.run_agent("blocks-t-y", "p", 5, provider="claude", scope=TOPIC)
assert rc == 0
monkeypatch.setattr(agents, "on_event", sink)
recorded.clear()
await agents.run_agent("chat-1", "p", 5, provider="claude", lane="interactive")
assert recorded == []
async def test_active_agents_carry_labels():
async def run(key, label):
return await agents._communicate(key, ["sleep", "0.4"], None, 5, label=label)
t1 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 1"))
t2 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 2")) # key collision → ~2
await asyncio.sleep(0.15)
agents_now = agents.active_agents("blocks-t-")
assert sorted(a["label"] for a in agents_now) == ["Alpha · Facts 1", "Alpha · Facts 2"]
assert {a["key"] for a in agents_now} == {"blocks-t-x", "blocks-t-x~2"}
await asyncio.gather(t1, t2)
assert agents.active_agents("blocks-t-") == []
async def test_pull_prefers_bigger_blocks(testdb):
"""LPT: Karten mit größerem subs_n werden zuerst gezogen; ohne Feld bleibt FIFO."""
db = testdb
await db.kanban_upsert_card(TOPIC, "artefacts", "klein", "ablock", "facts", {"subs_n": 5})
await db.kanban_upsert_card(TOPIC, "artefacts", "gross", "ablock", "facts", {"subs_n": 40})
await db.kanban_upsert_card(TOPIC, "artefacts", "mittel", "ablock", "facts", {"subs_n": 15})
pulled = await db.kanban_pull(TOPIC, "artefacts", "facts", 10)
assert [c["card_id"] for c in pulled] == ["gross", "mittel", "klein"]
# ohne subs_n: FIFO nach updated_at
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
assert [c["card_id"] for c in pulled] == ["a", "b"]
async def test_learnstate_smoke(testdb):
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
from rules import load_learnstate
guides, progress, levels = await load_learnstate()
assert isinstance(levels, dict)
async def test_guide_error_event(testdb):
db = testdb
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
await db.set_guide_card(TOPIC, "Guide", "alpha", status="error", gate_info="Writer ohne Ergebnis")
evs = await _events(db, "fail")
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and "Writer" in evs[-1]["status"]
def test_timeout_calibration_smoke():
from pipeline import _timeout
assert _timeout("subblock", 10) == 400 + 150
assert _timeout("content", 10) == 450 + 300
def test_env_file_wins(tmp_path, monkeypatch):
"""Regression: geerbte (veraltete) Env-Werte dürfen die .env nicht mehr überstimmen."""
import config
monkeypatch.setenv("X_CREATOR_TESTKEY", "alt")
p = tmp_path / ".env"
p.write_text("X_CREATOR_TESTKEY=neu\n", encoding="utf-8")
config._load_env(p)
import os
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
async def test_restart_artefact_card_wipes_only_that_block(testdb):
import board_inventory as bi
db = testdb
for norm in ("alpha", "beta"):
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
assert await db.list_subblocks(TOPIC, "alpha") == []
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
async def test_guide_reset_card_single(testdb):
import guide_board as gb
db = testdb
for n in ("alpha", "beta"):
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
writer_rounds=2, md="# SECTION Text", gate_info="x")
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
# ab_stage 3 (fakten_gate) behält md
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]

View File

@@ -66,3 +66,96 @@ async def test_done_step(testdb):
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch):
"""Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim
ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage."""
import asyncio
from types import SimpleNamespace
import guide_board as gb
db = testdb
await db.upsert_guide_card("t", "Guide", "alpha", "Alpha")
env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide")
card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"}
seen = {}
async def fake_stage(env2, card2):
seen.update(dict(gb._live_info))
card2["stage"] = "done"
return True
monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage})
await gb._run_card(env, card, asyncio.Semaphore(1))
assert card["stage"] == "done"
assert ("t", "Guide", "alpha") in seen # live info stand während der Stage
assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt
def test_merge_split_sections_one_section_all_markers():
import guide_board as gb
from textkit import _parse_fragment
a = _parse_fragment("""<!-- section: Front Matter -->
<!-- compact -->
Kurzer Einstieg kompakt.
<!-- sub: beginner | YAML-Basics -->
YAML kompakt.
<!-- ausführlich -->
Einstieg ausführlich.
<!-- sub: beginner | YAML-Basics -->
YAML ausführlich.""")[0]
b = _parse_fragment("""<!-- section: Front Matter (Teil 2) -->
<!-- compact -->
<!-- sub: advanced | TOML-Sektionen -->
TOML kompakt.
<!-- ausführlich -->
Unerwünschter zweiter Einstieg.
<!-- sub: advanced | TOML-Sektionen -->
TOML ausführlich.""")[0]
merged = gb._merge_split_sections(a, b)
secs = _parse_fragment(merged)
assert len(secs) == 1
sec = secs[0]
assert sec["title"] == "Front Matter"
assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"]
assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
import guide_board as gb
from types import SimpleNamespace
db = testdb
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
calls = []
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
calls.append(label)
part = "2" if key.endswith("-b") else "1"
p = tmp_path / f"out-{key[-1]}.md"
p.write_text(f"<!-- section: Gross -->\n<!-- ausführlich -->\n"
+ ("Einstieg.\n" if part == "1" else "")
+ f"<!-- sub: beginner | Sub {part} -->\nText {part}.", encoding="utf-8")
# payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad
import re as _re
m = _re.search(r"(/\S+\.md)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(p.read_text(encoding="utf-8"))
return "ok", payload(None)
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)]
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
guide_id="g", topic="t", format="Guide", instructions="",
subs_by_title={"Gross": subs}, spec="",
slot=lambda name: tmp_path / name)
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "")
card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open",
"writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"}
ok = await gb._stage_writer(env, card)
assert ok is True
assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c]
from textkit import _parse_fragment
secs = _parse_fragment(card["md"])
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
assert card["stage"] == "fakten_gate"

View File

@@ -0,0 +1,295 @@
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
import json
import re
import numpy as np
import pytest
import blocks as blx
from pipeline import GenContext
TOPIC = "t"
_MD_PATH = re.compile(r"(/\S+\.md)")
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
def _sims(pairs, n):
m = np.eye(n)
for i, j, v in pairs:
m[i][j] = m[j][i] = v
return m
def test_variant_clusters_folds_paraphrases():
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
"Tabs werden expandiert"]
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
by_rep = {c["rep"]: c for c in cl}
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
assert by_rep[2]["mentions"] == 1
def test_variant_clusters_negation_guard():
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
assert len(cl) == 2 # antonyms never merge, no matter the cosine
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
def _fake_sims(texts):
"""Markertoken matrix: same first word → 0.95, else 0."""
n = len(texts)
m = np.eye(n)
key = lambda t: t.split()[0].casefold()
for i in range(n):
for j in range(n):
if i != j and key(texts[i]) == key(texts[j]):
m[i][j] = 0.95
return m
def _mk_race(finder_by_agent):
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
prompts = []
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
outs = []
for slot in slots:
key, prompt = slot["key"], slot["prompt"]
prompts.append((key, prompt))
text = None
if "-subblock-final-" in key:
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)
elif "-r1-" in key:
agent = int(key.rsplit("-", 1)[1])
subs = finder_by_agent.get(agent) or []
if subs:
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
if text is not None and (m := _MD_PATH.search(prompt)):
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(text)
outs.append(slot["payload"](None))
outs = [o for o in outs if o]
return outs or None
return fake_race, prompts
@pytest.fixture
def sub_env(testdb, tmp_path, monkeypatch):
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
monkeypatch.setattr(blx.embedding, "available", lambda: True)
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
files = {"arbeit": tmp_path}
return testdb, ctx, files
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
fake, prompts = _mk_race(finder_by_agent)
monkeypatch.setattr(blx, "_race", fake)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-", seeds=seeds)
return raw, prompts
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
nicht als „Unsicher" ins Panel."""
db, ctx, files = sub_env
raw, prompts = await _run(ctx, files, monkeypatch, {
1: ["Umbruch braucht Marker"],
2: ["Umbruch erfordert explizite Marker!"],
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
})
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
rows = await db.list_subblocks(TOPIC, "alpha")
status = sorted(r["status"] for r in rows)
assert status == ["consensus", "variant", "variant"]
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
async def test_seed_promotes_single_find(sub_env, monkeypatch):
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
2: ["Alpha Grundlagen"],
}, seeds=["Zeilenumbruch Regeln"])
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
if r["sub_norm"] == "zeilenumbruch regeln im detail")
assert row["status"] == "consensus"
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
}, seeds=["Fußnoten Syntax"])
assert "Fußnoten Syntax" in raw["Alpha"]
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
if r["sub_title"] == "Fußnoten Syntax")
assert row["status"] == "consensus"
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
db, ctx, files = sub_env
raw, _ = await _run(ctx, files, monkeypatch, {
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
}, seeds=["Tabs"])
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
db, ctx, files = sub_env
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
assert first == second
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
Konsens wächst; zweite Runde ohne Neues → Ende."""
db, ctx, files = sub_env
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
base = fake
hit = {"n": 0}
async def with_catchup(topic, label, slots, *a, **k):
if any("-subblock-x" in s["key"] for s in slots):
hit["n"] += 1
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
return [slot["payload"](None) for slot in slots[:2]]
return None
return await base(topic, label, slots, *a, **k)
monkeypatch.setattr(blx, "_race", with_catchup)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
assert (tmp_path / "subblock-final-c1-x1.md").exists()
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
# ── Outline-Review ───────────────────────────────────────────────────────────────────
def test_outline_review_schema():
valid = {1, 2, 3, 4, 5, 6}
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
assert ok == {3: 2}
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
entries = {i: f"Block {i} — d" for i in range(1, 7)}
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
for p in slots[:2]:
p.write_text(json.dumps(plan_a), encoding="utf-8")
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
"facts": tmp_path / "facts.json"}
review_out = {"val": {"moves": {"6": 2}}}
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
out = None
if key.endswith("outline-prereqs"):
out = {"prereqs": {}}
elif key.endswith("outline-judge"):
out = plan_a
elif key.endswith("outline-review"):
out = review_out["val"]
if out is not None:
m = re.search(r"(/\S+\.json)", prompt)
with open(m.group(1), "w", encoding="utf-8") as f:
json.dump(out, f)
return "ok", payload(None)
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
assert plan["chapters"][0]["numbers"] == [1, 2]
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
# broken review (mass move) → schema rejects, plan unchanged
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
(tmp_path / "outline-review.json").unlink()
(tmp_path / "outline.json").unlink()
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
db, ctx, files = sub_env
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
async def with_r2(topic, label, slots, *a, **k):
if any("-r2-" in s["key"] for s in slots):
outs = []
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
outs.append(slot["payload"](None))
return outs
return await base_fake(topic, label, slots, *a, **k)
monkeypatch.setattr(blx, "_race", with_r2)
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
assert raw["Alpha"] # Konsens steht
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
rows = await db.list_subblocks(TOPIC, "alpha")
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
db, ctx, files = sub_env
_, prompts = _mk_race({})
async def endless(topic, label, slots, *a, **k):
if "-subblock-final-" in slots[0]["key"]:
return None # panel fails → consensus fallback
outs = []
import re as _re
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
n = rn.group(1) if rn else "x"
for slot in slots[:2]:
m = _MD_PATH.search(slot["prompt"])
with open(m.group(1), "w", encoding="utf-8") as f:
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
prompts.append((slot["key"], slot["prompt"]))
outs.append(slot["payload"](None))
return outs
monkeypatch.setattr(blx, "_race", endless)
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
"", wipe=False, ns="x-")
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
assert max_round == blx.SUBBLOCK_MAX_ROUNDS