This commit is contained in:
team3
2026-07-01 22:01:32 +02:00
parent fa718b7d6c
commit b5398f73d2
17 changed files with 1580 additions and 522 deletions

View File

@@ -5,6 +5,7 @@ respective provider fails — the other keeps running unchanged.
""" """
import asyncio import asyncio
import heapq
import logging import logging
import os import os
import re import re
@@ -22,6 +23,17 @@ from config import (PROVIDERS, DEFAULT_PROVIDER, MAX_CONCURRENT_AGENTS,
log = logging.getLogger("creator.agents") log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {} _active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
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."""
now = time.time()
out = [{"key": k, "runtime": round(now - t, 1)}
for k, t in list(_active_started.items())
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"])
# Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose # Cancelled scopes (key prefixes, symmetric to kill_process). An agent whose
# key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING # key starts with one of these prefixes aborts BEFORE the spawn — so agents WAITING
@@ -43,26 +55,71 @@ def _scope_cancelled(agent_key: str) -> bool:
# Caps the real CLI processes — independent of the pipeline semaphore in # Caps the real CLI processes — independent of the pipeline semaphore in
# generator.py. The acquire happens BEFORE the spawn so that queue wait time # generator.py. The acquire happens BEFORE the spawn so that queue wait time
# does not count against the agent timeout. # does not count against the agent timeout.
_batch_sem = asyncio.Semaphore(MAX_CONCURRENT_AGENTS) class _PrioritySemaphore:
"""asyncio.Semaphore variant: when slots are scarce, the LOWEST priority number is served first
(FIFO within the same priority). Lets earlier pipeline columns grab agents before later ones."""
def __init__(self, value: int):
self._value = value
self._waiters: list = [] # heap of [priority, seq, future]
self._seq = 0
async def acquire(self, priority: int = 100):
if self._value > 0:
self._value -= 1
return
fut = asyncio.get_event_loop().create_future()
entry = [priority, self._seq, fut]
self._seq += 1
heapq.heappush(self._waiters, entry)
try:
await fut # release() hands us the slot directly (no value change)
except BaseException:
entry[2] = None # tombstone so release() skips this dead waiter
if fut.done() and not fut.cancelled():
self.release() # granted just before we were cancelled → pass it on
raise
def release(self):
while self._waiters:
entry = heapq.heappop(self._waiters)
if entry[2] is not None and not entry[2].done():
entry[2].set_result(None) # hand the slot straight to the highest-priority waiter
return
self._value += 1
_batch_sem = _PrioritySemaphore(MAX_CONCURRENT_AGENTS)
_interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE) _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
# Per-topic caps (lazily created): each topic gets its own batch semaphore of size # Per-topic caps (lazily created): each topic gets its own priority semaphore of size
# MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. # MAX_CONCURRENT_AGENTS_PER_TOPIC, nested INSIDE the global _batch_sem. Priority-based too, so the
_topic_sems: dict[str, asyncio.Semaphore] = {} # 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", "verify", "naming", "small", "dep")
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
@asynccontextmanager @asynccontextmanager
async def _batch_gate(scope: str | None): async def _batch_gate(scope: str | None, priority: int):
"""Acquire a batch slot: per-topic semaphore FIRST, then the global one. The order matters — """Per-topic slot FIRST (fair), then the GLOBAL slot by priority (earlier columns win when
a waiter holds only its (per-topic) slot while queueing for the global cap, so a saturated topic agents are scarce). Order matters — a waiter holds only its per-topic slot while queueing globally."""
never blocks other topics on the global semaphore. scope=None → global cap only.""" topic_sem = _topic_sems.setdefault(scope, _PrioritySemaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None
topic_sem = _topic_sems.setdefault(scope, asyncio.Semaphore(MAX_CONCURRENT_AGENTS_PER_TOPIC)) if scope else None if topic_sem is not None:
if topic_sem is None: await topic_sem.acquire(priority)
async with _batch_sem: await _batch_sem.acquire(priority)
yield try:
else: yield
async with topic_sem: finally:
async with _batch_sem: _batch_sem.release()
yield if topic_sem is not None:
topic_sem.release()
# Serialize OpenCode starts: processes starting simultaneously collide on the # Serialize OpenCode starts: processes starting simultaneously collide on the
# internal session DB ("database is locked", exit after <1s). The short # internal session DB ("database is locked", exit after <1s). The short
@@ -122,6 +179,7 @@ def kill_process(agent_key_prefix: str) -> None:
for key, process in list(_active_processes.items()): for key, process in list(_active_processes.items()):
if process.returncode is not None: # clean up dead entries while iterating if process.returncode is not None: # clean up dead entries while iterating
_active_processes.pop(key, None) _active_processes.pop(key, None)
_active_started.pop(key, None)
continue continue
if key.startswith(agent_key_prefix): if key.startswith(agent_key_prefix):
log.debug("kill agent %s", key) log.debug("kill agent %s", key)
@@ -137,6 +195,7 @@ async def run_agent(
capabilities: str = "none", capabilities: str = "none",
lane: str = "batch", lane: str = "batch",
scope: str | None = None, scope: str | None = None,
on_line=None,
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled" return 1, "", "cancelled"
@@ -144,16 +203,16 @@ async def run_agent(
return 1, "", f"Unknown provider: {provider}" return 1, "", f"Unknown provider: {provider}"
if shutil.which(PROVIDERS[provider]["cli"]) is None: if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})" return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope) gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
async with gate: async with gate:
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled" return 1, "", "cancelled"
if PROVIDERS[provider]["cli"] == "opencode": if PROVIDERS[provider]["cli"] == "opencode":
return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities) return await _run_opencode(agent_key, prompt, timeout, provider, role, capabilities, on_line=on_line)
return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities) return await _run_claude_cli(agent_key, prompt, timeout, role, capabilities)
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False) -> tuple[int, str, str]: async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None) -> tuple[int, str, str]:
start = time.monotonic() start = time.monotonic()
async def spawn(): async def spawn():
@@ -172,12 +231,29 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
else: else:
process = await spawn() process = await spawn()
_active_processes[agent_key] = process _active_processes[agent_key] = process
_active_started[agent_key] = time.time()
try: try:
try: try:
stdout, stderr = await asyncio.wait_for( if on_line is not None:
process.communicate(input=stdin_data), # Streaming path: read stdout line by line, hand each raw line to on_line LIVE.
timeout=timeout, out_chunks: list[str] = []
) async def _pump():
async for raw in process.stdout:
s = raw.decode("utf-8", errors="replace")
out_chunks.append(s)
try:
on_line(s)
except Exception:
log.debug("on_line callback failed", exc_info=True)
await asyncio.wait_for(_pump(), timeout=timeout)
await process.wait()
stderr_b = await process.stderr.read()
stdout, stderr = "".join(out_chunks).encode("utf-8"), stderr_b
else:
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data),
timeout=timeout,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
_kill(process) _kill(process)
try: try:
@@ -196,6 +272,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
# the NEW process from tracking. # the NEW process from tracking.
if _active_processes.get(agent_key) is process: if _active_processes.get(agent_key) is process:
del _active_processes[agent_key] del _active_processes[agent_key]
_active_started.pop(agent_key, None)
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]: async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str, capabilities: str) -> tuple[int, str, str]:
@@ -208,7 +285,7 @@ async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, role: str,
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout) return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str) -> tuple[int, str, str]: async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, role: str, capabilities: str, on_line=None) -> tuple[int, str, str]:
cfg = PROVIDERS[provider] cfg = PROVIDERS[provider]
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts) # 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: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
@@ -224,9 +301,11 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
"--dangerously-skip-permissions", "--dangerously-skip-permissions",
"-f", str(prompt_path), "-f", str(prompt_path),
] ]
if on_line is not None:
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
try: try:
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True) rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line)
return rc, _clean_opencode_output(stdout), stderr return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally: finally:
prompt_path.unlink(missing_ok=True) prompt_path.unlink(missing_ok=True)

View File

@@ -23,14 +23,14 @@ from pathlib import Path
import database as db import database as db
import embedding import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import CONSENSUS_GRACE, RESEARCH_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, EMBEDDING_SUB_SAME from config import CONSENSUS_GRACE, RESEARCH_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, EMBEDDING_SUB_SAME, KANBAN_INVENTORY
from fsutil import atomic_write_text, atomic_write_json from fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_file 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 from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
from crawl import crawl from crawl import crawl
from pipeline import ( from pipeline import (
CANCELLED, FAILED, OK, GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race, CANCELLED, FAILED, OK, GenContext, _extra, _gather_progress, _yesno_schema, _log, _prompt, _race,
_relevance_schema, _runde_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot, _relevance_schema, _semaphore, _str_list, _levels_schema, _timeout, run_single_slot,
) )
from textkit import ( from textkit import (
_unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title, _unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title,
@@ -55,6 +55,13 @@ SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only 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_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) DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
RESEARCH_MIN_RUNTIME = 300 # research: do not finish before 5 min (let agents search thoroughly)
RESEARCH_MAX_RUNTIME = 1800 # research: hard wall-clock cap at 30 min
# Inventory columns (kanban streaming dataflow) — also the fine-step labels shown as pills.
INVENTORY_STEPS = ("Research", "Merge", "Chain", "Chain-Verify", "Naming", "Naming-Verify",
"Chain-Filter", "Filter-Verify", "Block", "Small-Blocks", "Small-Verify",
"Dependency", "Dependency-Verify", "Main-Block")
FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (full list as context) FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (full list as context)
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count. # 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_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
@@ -198,7 +205,7 @@ def _blocks_steps(topic: str) -> tuple:
all packages run in parallel; the step remains until the last package is done. all packages run in parallel; the step remains until the last package is done.
""" """
q = load_source(topic) q = load_source(topic)
base = ("Research", "Consolidation", "Clarification", "Blocks-Filter") base = INVENTORY_STEPS
rest = ( rest = (
"Subblocks find", "Subblocks select", "Subblocks clarify", "Subblocks find", "Subblocks select", "Subblocks clarify",
"Facts find", "Facts check", "Facts fix", "Facts find", "Facts check", "Facts fix",
@@ -228,7 +235,7 @@ def _report_p(set_p, topic: str, step: str):
# Special steps (Source laden, Supplement) belong to the "Inventory" phase. # Special steps (Source laden, Supplement) belong to the "Inventory" phase.
PHASEN = ( PHASEN = (
("Source", ("Source prep",)), ("Source", ("Source prep",)),
("Inventory", ("Research", "Consolidation", "Clarification", "Blocks-Filter", "Supplement")), ("Inventory", INVENTORY_STEPS + ("Supplement",)),
("Subblocks", ("Subblocks find", "Subblocks select", "Subblocks clarify")), ("Subblocks", ("Subblocks find", "Subblocks select", "Subblocks clarify")),
("Facts", ("Facts find", "Facts check", "Facts fix")), ("Facts", ("Facts find", "Facts check", "Facts fix")),
("Levels", ("Levels find", "Levels select", "Levels clarify")), ("Levels", ("Levels find", "Levels select", "Levels clarify")),
@@ -288,9 +295,11 @@ def _all_slot_files(files: dict) -> list[Path]:
# Subblock/levels slots are dynamic per chunk — collect via glob. # Subblock/levels slots are dynamic per chunk — collect via glob.
dyn = (list(work_dir.glob("subblock-*")) + list(work_dir.glob("facts-*")) + list(work_dir.glob("level-*")) + list(work_dir.glob("relevance-*")) dyn = (list(work_dir.glob("subblock-*")) + list(work_dir.glob("facts-*")) + list(work_dir.glob("level-*")) + list(work_dir.glob("relevance-*"))
+ list(work_dir.glob("question-pattern-*")) + list(work_dir.glob("outline-*")) + list(work_dir.glob("artifact-*")) + list(work_dir.glob("question-pattern-*")) + list(work_dir.glob("outline-*")) + list(work_dir.glob("artifact-*"))
+ list(work_dir.glob("research-*")) + list(work_dir.glob("consolidation-*")) + list(work_dir.glob("research-*"))
+ list(work_dir.glob("clarification*")) + list(work_dir.glob("dedup-*")) + list(work_dir.glob("combine-*")) + list(work_dir.glob("verify-*")) + list(work_dir.glob("naming*"))
+ list(work_dir.glob("inventar-filter*"))) if work_dir.is_dir() else [] # legacy artefacts of the old consolidation/clarification/filter steps (cleaned on reset)
+ list(work_dir.glob("consolidation-*")) + list(work_dir.glob("clarification*"))
+ list(work_dir.glob("dedup-*")) + list(work_dir.glob("inventar-filter*"))) if work_dir.is_dir() else []
return [ return [
*files["research"], files["research_mapping"], *files["research"], files["research_mapping"],
*(p for slots in files["selection"].values() for p in slots), *(p for slots in files["selection"].values() for p in slots),
@@ -317,10 +326,10 @@ async def _resume_step(topic: str) -> int:
files = _blocks_files(topic) files = _blocks_files(topic)
steps_all = _blocks_steps(topic) steps_all = _blocks_steps(topic)
if not files["final"].exists(): if not files["final"].exists():
for step in ("Source prep", "Research", "Consolidation", "Clarification", "Blocks-Filter"): for step in ("Source prep",) + INVENTORY_STEPS:
if step in steps_all and await db.get_step_status(topic, step) != "done": if step in steps_all and await db.get_step_status(topic, step) != "done":
return _step_idx(topic, step) return _step_idx(topic, step)
return _step_idx(topic, "Blocks-Filter") # statuses done but artefact gone → rewrite return _step_idx(topic, INVENTORY_STEPS[-1]) # statuses done but artefact gone → rewrite
q = load_source(topic) q = load_source(topic)
if q["type"] == "projekt" and not files["ergaenzung"].exists(): if q["type"] == "projekt" and not files["ergaenzung"].exists():
return _step_idx(topic, "Supplement") return _step_idx(topic, "Supplement")
@@ -504,27 +513,18 @@ async def _reset_from_step(topic: str, step_idx: int, to_idx: int | None = None)
atomic_write_json(files["sidecar"], sc, indent=1) atomic_write_json(files["sidecar"], sc, indent=1)
if any(s.startswith("Subblock") for s in affected): if any(s.startswith("Subblock") for s in affected):
files["sub_roh"].unlink(missing_ok=True); gd("subblock-*"); await db.delete_subblocks(topic) files["sub_roh"].unlink(missing_ok=True); gd("subblock-*"); await db.delete_subblocks(topic)
# --- Inventory (DB status cascades) --- # --- Inventory (kanban streaming dataflow) ---
if "Blocks-Filter" in affected and not ({"Clarification", "Consolidation", "Research"} & affected): # The kanban flow is a single streaming run (cards, not discrete steps) → any inventory-step reset
# Only filter rebuilt: degraded blocks back to consensus. # rewinds the WHOLE inventory: clear the kanban tables + the mirrored blocks, research rebuilds.
d = _json_file(work_dir / "inventar-filter.json") inv = set(INVENTORY_STEPS) - {"Research"}
for f in (d.get("fragments", []) if isinstance(d, dict) else []): if ({"Research"} | inv) & affected:
await db.set_block_status(topic, _norm_title(f.get("fragment", "")), "consensus")
gd("inventar-filter*")
if {"Clarification", "Consolidation"} & affected and not ({"Research"} & affected):
# Clarification/consolidation rebuilt: clear inventory DB (research readers stay). Clarification rollback
# would be fragile due to renaming → cleanly rebuild from consolidation.
await db.delete_blocks(topic)
gd("clarification*"); gd("consolidation-*"); gd("dedup-*"); gd("inventar-filter*")
if "Research" in affected: # whole inventory like a phase reset
for p_old in _all_slot_files(files): for p_old in _all_slot_files(files):
p_old.unlink(missing_ok=True) p_old.unlink(missing_ok=True)
await db.delete_blocks(topic) await db.delete_blocks(topic)
await db.kanban_reset(topic)
# blocks.md is the inventory aggregate — stale once any inventory sub-step is reset. Delete it so the # blocks.md is the inventory aggregate — stale once any inventory sub-step is reset. Delete it so the
# status/resume see the inventory as open from the reset step (the pipeline rewrites it; the DB step # status/resume see the inventory as open. On a BOUNDED reset (to_idx set) the later steps are kept.
# statuses of the kept earlier steps let those skip). On a BOUNDED reset (to_idx set) the later steps if to_idx is None and ({"Research"} | inv) & affected:
# are kept on purpose → keep their aggregate too.
if to_idx is None and {"Research", "Consolidation", "Clarification", "Blocks-Filter"} & affected:
files["final"].unlink(missing_ok=True) files["final"].unlink(missing_ok=True)
@@ -1800,18 +1800,6 @@ def _crawl_index(folder) -> dict[str, str]:
async def _set_inventory(topic: str, record: str, status: str) -> None:
"""Write an inventory entry ('title — description') with status to the DB."""
title = _title(record)
norm = _norm_title(title)
if not norm:
return
split_parts = [t.strip() for t in record.split("")]
desc = split_parts[1] if len(split_parts) >= 2 else ""
await db.upsert_block(topic, norm, title, desc)
await db.set_block_status(topic, norm, status)
def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]: def _triage_rules(folder, pages: list[str]) -> tuple[list[str], list[str]]:
"""Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against """Deterministic content/noise filter (config.CRAWL_*). Substring match (lowercase) against
URL + filename. Order: keep > noise > min_chars > keep. → (content, noise).""" URL + filename. Order: keep > noise > min_chars > keep. → (content, noise)."""
@@ -2018,7 +2006,8 @@ async def _research_batch(ctx: GenContext, set_p, files: dict, q: dict, folder,
"payload": (lambda result, p=p, rid=f"t{i}": ((rid, t) if (t := _file_payload(p)) else None)), "payload": (lambda result, p=p, rid=f"t{i}": ((rid, t) if (t := _file_payload(p)) else None)),
} for i, p in enumerate(paths, 1)] } for i, p in enumerate(paths, 1)]
agent_texts = await _race(topic, "Research", slots, 3, _timeout("research"), provider, agent_texts = await _race(topic, "Research", slots, 3, _timeout("research"), provider,
cancelled=is_cancelled, grace=RESEARCH_GRACE) cancelled=is_cancelled, grace=RESEARCH_GRACE,
min_runtime=RESEARCH_MIN_RUNTIME, max_runtime=RESEARCH_MAX_RUNTIME)
if is_cancelled(): if is_cancelled():
return False return False
if not agent_texts: if not agent_texts:
@@ -2176,331 +2165,274 @@ def _canonical(candidates: list[dict], idxs: list[int], seen_norm: set[str]) ->
return {"title": title, "description": candidates[k]["description"]} return {"title": title, "description": candidates[k]["description"]}
async def _pairwise_groups(ctx: GenContext, set_p, work_dir: Path, candidates: list[dict], def _naming_schema(data, count: int) -> int | None:
blocks: list[list[int]], sims) -> list[list[int]] | None: """{"best": N} → 1-based member index in [1, count] · otherwise None."""
"""Verify candidate PAIRS individually (ja/nein) inside each similarity block, then form if not isinstance(data, dict):
COMPLETE-LINK cliques — same entity-resolution mechanism as the dedup pass: no chaining
(A=B + B=C without A=C does NOT merge), no aspect over-merging like the old N→groups judge.
Only block-internal pairs with cosine ≥ DEDUP_PAIR_FLOOR are checked; members without a
confirmed edge stay singletons. → final groups (global candidate indices) · None on cancel."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
n = len(candidates)
pairs: list[tuple[int, int]] = [] # block-internal candidate pairs above the pair floor
for b in blocks:
for x in range(len(b)):
for y in range(x + 1, len(b)):
i, j = b[x], b[y]
if float(sims[i][j]) >= DEDUP_PAIR_FLOOR:
pairs.append((i, j))
if not pairs:
return [[i] for i in range(n)]
packages = [pairs[k:k + DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), DEDUP_PAIRS_CHUNK)]
def pair_path(pi): return work_dir / f"consolidation-paar-c{pi}.json"
async def _filt(pi, paare):
fp = pair_path(pi)
if _pairs_schema(_json_file(fp)):
return # resume
lines = "\n\n".join(
f"{j + 1}.\nA: {candidates[a]['title']}{candidates[a]['description']}"
f"\nB: {candidates[b]['title']}{candidates[b]['description']}"
for j, (a, b) in enumerate(paare))
await run_single_slot(
ctx, f"Consolidation pairs {pi}",
key=f"blocks-{topic}-consolidation-paar-c{pi}",
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp: _pairs_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(paare)),
)
await _gather_progress([_filt(pi, p) for pi, p in enumerate(packages)],
len(packages), _report_p(set_p, topic, "Consolidation"))
if is_cancelled():
return None return None
edge_list: list[tuple[int, int]] = [] try:
for pi, paare in enumerate(packages): n = int(data.get("best"))
verdict = _pairs_schema(_json_file(pair_path(pi))) or {} except (ValueError, TypeError):
for j, (a, b) in enumerate(paare): return None
if verdict.get(j + 1): return n if 1 <= n <= count else None
edge_list.append((a, b))
cliques = _cliques(n, edge_list)
covered = {i for g in cliques for i in g}
return cliques + [[i] for i in range(n) if i not in covered]
async def _consolidate_embedding(ctx: GenContext, set_p, files: dict, candidates: list[dict]) -> bool: async def _merge(ctx: GenContext, set_p, files: dict) -> bool:
"""Two-stage: embeddings → coarse capped blocks (high recall) → one judge per multi-block, """Merge (exact dedup): already folded at ingest (upsert by title_norm + reader-union). This
grouping the titles into the real blocks → reader union (≥2 = consensus).""" step only restores the candidates after a reset (re-ingest from research-*.md) and marks done."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
work_dir = files["arbeit"]
texts = [f"{b['title']}{b['description']}" if b["description"] else b["title"] for b in candidates]
sims = await asyncio.to_thread(embedding.embed_sims, texts)
if sims is None: # model not available after all → fallback
return await _consolidate_llm(ctx, set_p, files, candidates)
# Level 1: coarse similarity blocks (capped, no giant component) — pure blocking for recall.
blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None)
# Level 2: verify candidate PAIRS individually + complete-link cliques (no chaining, no aspect
# over-merging) instead of an N→groups judge that fused whole topics into one block.
groups = await _pairwise_groups(ctx, set_p, work_dir, candidates, blocks, sims)
if groups is None or is_cancelled():
return False
def _min_cos(idxs): # internal coherence as a check (chains would be ~0.3)
if len(idxs) < 2:
return 1.0
return round(min(float(sims[i][j]) for n, i in enumerate(idxs) for j in idxs[n + 1:]), 3)
# Consensus = ≥2 distinct readers per cluster. Legacy DBs without reader tracking (research ran
# before the migration, no re-ingest) have empty reader sets → fall back to a title heuristic
# (otherwise EVERYTHING would land in the rest).
hat_reader = any(b["reader"] for b in candidates)
consensus, rest, debug, seen_norm = [], [], [], set()
for idxs in groups:
reader = set().union(*[set(candidates[k]["reader"]) for k in idxs]) if idxs else set()
if hat_reader:
score = len(reader)
else: # without reader data: max(mentions, number of distinct title variants in the cluster)
score = max(max(candidates[k]["mentions"] for k in idxs),
len({candidates[k]["title_norm"] for k in idxs}))
rep = _canonical(candidates, idxs, seen_norm)
record = f"{rep['title']}{rep['description']}" if rep["description"] else rep["title"]
(consensus if score >= 2 else rest).append(record)
debug.append({"title": rep["title"], "reader": sorted(reader), "score": score,
"consensus": score >= 2, "min_cos": _min_cos(idxs),
"mitglieder": [candidates[k]["title"] for k in idxs]})
atomic_write_json(work_dir / "consolidation-cluster.json", debug, indent=1)
multi_blocks = sum(1 for b in blocks if len(b) > 1)
_log(topic, f"Consolidation (pairwise): {len(blocks)} blocks ({multi_blocks} multi) "
f"{len(groups)} clusters from {len(candidates)} candidates "
f"{len(consensus)} consensus / {len(rest)} rest")
await db.delete_blocks(topic)
for t in consensus:
await _set_inventory(topic, t, "consensus")
for t in rest:
await _set_inventory(topic, t, "rest")
await db.set_step_status(topic, "Consolidation", "done")
return True
async def _consolidate(ctx: GenContext, set_p, files: dict) -> bool:
"""Merges raw candidates into consensus (≥2 readers)/rest. Deterministic via embedding clustering;
if the model is missing → fall back to the LLM panel (`_consolidate_llm`). Status in DB."""
topic = ctx.topic topic = ctx.topic
if await db.get_step_status(topic, "Consolidation") == "done": if await db.get_step_status(topic, "Merge") == "done":
return True return True
set_p("Consolidating research", step=_step_idx(topic, "Consolidation")) set_p("Merge", step=_step_idx(topic, "Merge"))
candidates = await db.list_blocks(topic) if not await db.list_blocks(topic):
if not candidates:
# Candidates were consumed by an earlier consolidation (overwritten with consensus/rest) or
# wiped by a reset → rebuild them from the saved research files so this step can re-run.
await _reingest_research_files(topic, files["arbeit"]) await _reingest_research_files(topic, files["arbeit"])
candidates = await db.list_blocks(topic) if not await db.list_blocks(topic):
if not candidates: _blocks_errors[topic] = "Merge: no candidates"
_blocks_errors[topic] = "Consolidation: no candidates"
return False return False
if EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available): await db.set_step_status(topic, "Merge", "done")
return await _consolidate_embedding(ctx, set_p, files, candidates)
return await _consolidate_llm(ctx, set_p, files, candidates)
async def _consolidate_llm(ctx: GenContext, set_p, files: dict, candidates: list[dict]) -> bool:
"""Fallback (only without an embedding model): a panel (KONSOLIDIERUNG_PANEL judges) merges
candidates semantically; a reconcile judge combines the panel outputs into the final
consensus (≥2)/rest (1×) list. Panel instead of a single judge: a single judge is bias-prone and unstable."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
work_dir = files["arbeit"]
chunks = _chunk_nums(candidates, max(1, math.ceil(len(candidates) / CONSOLIDATION_CHUNK)))
async def _map_panel(c: int, eintraege: str, amount: int):
"""3 mapping judges over `eintraege` → reconcile judge → (consensus, rest). None on cancel/error."""
paths = [work_dir / f"consolidation-c{c}-j{j}.json" for j in range(1, CONSOLIDATION_PANEL + 1)]
pending = [(j, p) for j, p in enumerate(paths, 1) if _mapping_schema(_json_file(p)) is None]
for _, p in pending:
p.unlink(missing_ok=True)
if pending:
slots = [{
"key": f"blocks-{topic}-consolidation-c{c}-j{j}",
"prompt": _prompt("Blocks-Research-Mapping", topic=topic, n=RESEARCH_READERS, entries=eintraege, out_path=p),
"role": "judge", "capabilities": "files",
"payload": (lambda result, p=p: _mapping_schema(_json_file(p))),
} for j, p in pending]
existing = CONSOLIDATION_PANEL - len(pending)
await _race(topic, f"Consolidation {c}", slots, max(1, 2 - existing),
_timeout("research_mapping", amount), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled():
return None
outs = [m for p in paths if (m := _mapping_schema(_json_file(p)))]
if not outs:
return None
# union of panel titles; per title count how many judges list it as consensus.
kvotes: dict[str, int] = {}
form: dict[str, str] = {} # norm → display title (first occurrence)
order: list[str] = []
for kk, rr in outs:
for t in kk + rr:
nt = _norm_title(_title(t))
if not nt:
continue
if nt not in form:
form[nt] = t
order.append(nt)
kvotes.setdefault(nt, 0)
for t in kk:
nt = _norm_title(_title(t))
if nt:
kvotes[nt] = kvotes.get(nt, 0) + 1
# Reconcile: one merge judge over the union, annotated with judge votes ("k× genannt").
rp = work_dir / f"consolidation-c{c}-reconcile.json"
recon = _mapping_schema(_json_file(rp))
if recon is None:
rp.unlink(missing_ok=True)
entries_r = "\n".join(f"{i}. {form[nt]} ({max(1, kvotes[nt])}× genannt)" for i, nt in enumerate(order, 1))
status, recon = await run_single_slot(
ctx, f"Consolidation Reconcile {c}",
key=f"blocks-{topic}-consolidation-c{c}-reconcile",
prompt=_prompt("Blocks-Research-Mapping", topic=topic, n=CONSOLIDATION_PANEL, entries=entries_r, out_path=rp),
role="judge", capabilities="files",
payload=lambda result, p=rp: _mapping_schema(_json_file(p)),
timeout=_timeout("research_mapping", len(order)),
)
if status == CANCELLED:
return None
recon = recon if status != FAILED else None
if recon:
return recon
# Fallback (reconcile failed): code majority — consensus if a majority of judges say consensus.
consensus = [form[nt] for nt in order if kvotes[nt] * 2 >= len(outs) and kvotes[nt] > 0]
kset = {_norm_title(_title(t)) for t in consensus}
return consensus, [form[nt] for nt in order if nt not in kset]
consensus, rest = [], []
for c, chunk in enumerate(chunks, 1):
eintraege = "\n".join(
f"{i}. {b['title']}{b['description']} ({b['mentions']}× genannt)" for i, b in enumerate(chunk, 1)
)
res = await _map_panel(c, eintraege, len(chunk))
if res is None:
if is_cancelled():
return False
_blocks_errors[topic] = "Research mapping failed"
return False
k, r = res
consensus += k
rest += r
# With multiple chunks: a global merge pass over the combined consensus entries,
# so duplicates across chunk boundaries (DAL×4, PHPUnit×5 …) merge.
if len(chunks) > 1 and consensus:
fp = work_dir / "consolidation-merge.json"
fp.unlink(missing_ok=True)
eintraege = "\n".join(f"{i}. {t} (2× genannt)" for i, t in enumerate(consensus, 1))
status, mapping = await run_single_slot(
ctx, "Consolidation Merge",
key=f"blocks-{topic}-consolidation-merge",
prompt=_prompt("Blocks-Research-Mapping", topic=topic, n=RESEARCH_READERS, entries=eintraege, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp: _mapping_schema(_json_file(p)),
timeout=_timeout("research_mapping", len(consensus)),
)
if status == CANCELLED:
return False
if status != FAILED and mapping:
consensus, r2 = mapping
rest += r2 # entries downgraded by the merge into the rest
# Judge output is authoritative → re-set the inventory in the DB.
await db.delete_blocks(topic)
for t in consensus:
await _set_inventory(topic, t, "consensus")
for t in rest:
await _set_inventory(topic, t, "rest")
await db.set_step_status(topic, "Consolidation", "done")
return True return True
async def _clarify_inventory(ctx: GenContext, set_p, files: dict) -> bool: async def _combine(ctx: GenContext, set_p, files: dict) -> bool:
"""A panel (KONSOLIDIERUNG_PANEL judges) decides on the rest (1×-mentioned): majority `aufnehmen` """Combine (blocking): embed all candidates, build capped similarity blocks, emit the block-
→ consensus, otherwise discarded. Panel instead of a single judge — the rest cut is the sharpest internal candidate PAIRS (cosine ≥ DEDUP_PAIR_FLOOR) for the Verify judge. Pure embedding, no
intervention; a single judge is too unstable here. Conservative tie → keep (never lose a concept).""" LLM. Pairs stored by title_norm. No embedding model → no pairs (every candidate stays its own)."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled topic = ctx.topic
if await db.get_step_status(topic, "Clarification") == "done": if await db.get_step_status(topic, "Combine") == "done":
return True return True
set_p("Clarification running", step=_step_idx(topic, "Clarification")) set_p("Combine", step=_step_idx(topic, "Combine"))
rest_rows = await db.list_blocks(topic, status="rest") work_dir = files["arbeit"]
# Continuous gate (EDC "Define"): also check consensus blocks with reference/placeholder titles if not await db.list_blocks(topic):
# ("Satz 7.18", "Korollar 6.18", "Bedingung (**)") — otherwise they bypass every exam. await _reingest_research_files(topic, work_dir)
suspicious = [b for b in await db.list_blocks(topic, status="consensus") if _is_reference(b["title"])] rows = await db.list_blocks(topic)
check_rows = rest_rows + suspicious if not rows:
if check_rows: _blocks_errors[topic] = "Combine: no candidates"
work_dir = files["arbeit"] return False
paths = [work_dir / f"clarification-j{j}.json" for j in range(1, CONSOLIDATION_PANEL + 1)] by_norm = {r["title_norm"]: r for r in rows}
# final=False: a judge with an accidentally non-empty `rest` must not fail entirely order = sorted(by_norm)
# (otherwise the panel collapses to 1 judge). Its `aufnehmen` counts; rest entries count as pairs_norm: list[list[str]] = []
# not-accepted. The "rest empty" requirement still stands in the prompt. if EMBEDDING_AKTIV and len(order) >= 2 and await asyncio.to_thread(embedding.available):
pending = [(j, p) for j, p in enumerate(paths, 1) if _runde_schema(_json_file(p)) is None] texts = [f"{by_norm[nm]['title']}{by_norm[nm]['description']}" if by_norm[nm]["description"]
for _, p in pending: else by_norm[nm]["title"] for nm in order]
p.unlink(missing_ok=True) sims = await asyncio.to_thread(embedding.embed_sims, texts)
if pending: if sims is not None:
slots = [{ blocks = await asyncio.to_thread(embedding.capped_blocks, sims, None, None)
"key": f"blocks-{topic}-clarification-j{j}", for b in blocks:
"prompt": _prompt( for x in range(len(b)):
"Blocks-Klaerung", topic=topic, for y in range(x + 1, len(b)):
rest="\n".join(f"- {b['title']}{b['description']}" if b['description'] else f"- {b['title']}" i, j = b[x], b[y]
for b in check_rows), if float(sims[i][j]) >= DEDUP_PAIR_FLOOR:
final="\n- Entscheide JEDEN Eintrag. `rest` MUSS leer sein.", pairs_norm.append([order[i], order[j]])
out_path=p, atomic_write_json(work_dir / "combine-pairs.json", {"pairs": pairs_norm}, indent=1)
), _log(topic, f"Combine: {len(order)} candidates → {len(pairs_norm)} candidate pairs (embedding blocking)")
"role": "judge", "capabilities": "files", await db.set_step_status(topic, "Combine", "done")
"payload": (lambda result, p=p: _runde_schema(_json_file(p))), return True
} for j, p in pending]
existing = CONSOLIDATION_PANEL - len(pending)
await _race(topic, "Clarification", slots, max(1, 2 - existing), async def _verify_chains(ctx: GenContext, set_p, files: dict) -> bool:
_timeout("selection_mapping", len(check_rows)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE) """Verify (matching): a judge decides each candidate pair (ja/nein), packages run in parallel.
Confirmed pairs become complete-link cliques (no chaining → no aspect over-merge). Output =
chains (groups of title_norms, a partition of all candidates) → verify-chains.json."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
if await db.get_step_status(topic, "Verify") == "done":
return True
set_p("Verify…", step=_step_idx(topic, "Verify"))
work_dir = files["arbeit"]
rows = await db.list_blocks(topic)
by_norm = {r["title_norm"]: r for r in rows}
order = sorted(by_norm)
idx_of = {nm: i for i, nm in enumerate(order)}
data = _json_file(work_dir / "combine-pairs.json")
raw_pairs = data.get("pairs", []) if isinstance(data, dict) else []
pairs = [(a, b) for a, b in raw_pairs if a in idx_of and b in idx_of] # robust against re-ingest
edge_norm: list[tuple[str, str]] = []
if pairs:
packages = [pairs[k:k + DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), DEDUP_PAIRS_CHUNK)]
def pair_path(pi): return work_dir / f"verify-paar-c{pi}.json"
async def _filt(pi, paare):
fp = pair_path(pi)
if _pairs_schema(_json_file(fp)):
return # resume
lines = "\n\n".join(
f"{k + 1}.\nA: {by_norm[a]['title']}{by_norm[a]['description']}"
f"\nB: {by_norm[b]['title']}{by_norm[b]['description']}"
for k, (a, b) in enumerate(paare))
await run_single_slot(
ctx, f"Verify pairs {pi}",
key=f"blocks-{topic}-verify-paar-c{pi}",
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp: _pairs_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(paare)),
)
await _gather_progress([_filt(pi, p) for pi, p in enumerate(packages)],
len(packages), _report_p(set_p, topic, "Verify"))
if is_cancelled(): if is_cancelled():
return False return False
outs = [r for p in paths if (r := _runde_schema(_json_file(p)))] for pi, paare in enumerate(packages):
if not outs: verdict = _pairs_schema(_json_file(pair_path(pi))) or {}
_blocks_errors[topic] = "Clarification failed" for k, (a, b) in enumerate(paare):
return False if verdict.get(k + 1):
# Majority per rest entry (by norm title). Tie → keep (votes*2 >= n). edge_norm.append((a, b))
votes: dict[str, int] = {} edges = [(idx_of[a], idx_of[b]) for a, b in edge_norm]
for accepted, _ in outs: cliques = _cliques(len(order), edges)
for nt in {_norm_title(_title(t)) for t in accepted}: covered = {i for g in cliques for i in g}
votes[nt] = votes.get(nt, 0) + 1 groups = cliques + [[i] for i in range(len(order)) if i not in covered]
# Rename suggestions (additive from the raw JSON — _runde_schema doesn't know the field): chains = [[order[i] for i in g] for g in groups]
# kept reference/placeholder titles → meaningful name from the content. Old title norm atomic_write_json(work_dir / "verify-chains.json", {"chains": chains}, indent=1)
# stays stable (doesn't break the votes match); per old title the most frequent suggestion. multi = sum(1 for c in chains if len(c) > 1)
renames: dict[str, dict[str, int]] = {} _log(topic, f"Verify: {len(edge_norm)} confirmed pairs → {len(chains)} chains ({multi} multi)")
for p in paths: await db.set_step_status(topic, "Verify", "done")
d = _json_file(p) return True
rename_raw = d.get("rename") if isinstance(d, dict) else None
if isinstance(rename_raw, dict):
for old, new in rename_raw.items(): async def _naming(ctx: GenContext, set_p, files: dict) -> bool:
new = str(new).strip() """Naming (canonicalization): per multi-member chain a judge picks the best, most concrete
if new: EXISTING member title (no invented umbrella term). Singletons keep their title. Output = winner
renames.setdefault(_norm_title(str(old)), {}).setdefault(new, 0) title_norm per chain → naming.json. Fallback without a model: the _canonical heuristic."""
renames[_norm_title(str(old))][new] += 1 topic, is_cancelled = ctx.topic, ctx.is_cancelled
seen_norm = {b["title_norm"] for b in await db.list_blocks(topic)} # all stati: UNIQUE(topic,title_norm) spans every status, not just consensus if await db.get_step_status(topic, "Naming") == "done":
for b in check_rows: return True
accept = votes.get(b["title_norm"], 0) * 2 >= len(outs) set_p("Naming…", step=_step_idx(topic, "Naming"))
if not accept: work_dir = files["arbeit"]
await db.set_block_status(topic, b["title_norm"], "discarded") by_norm = {r["title_norm"]: r for r in await db.list_blocks(topic)}
continue data = _json_file(work_dir / "verify-chains.json")
new_title = None chains = [[nm for nm in c if nm in by_norm] for c in (data.get("chains", []) if isinstance(data, dict) else [])]
if _is_reference(b["title"]) and (suggestions := renames.get(b["title_norm"])): chains = [c for c in chains if c]
cands = max(suggestions, key=lambda k: (suggestions[k], len(k))) if not chains:
if not _is_reference(cands): _blocks_errors[topic] = "Naming: no chains"
new_title = cands return False
if new_title: multi = [(ci, c) for ci, c in enumerate(chains) if len(c) > 1]
nn, t, n = _norm_title(new_title), new_title, 2
while nn in seen_norm: def name_path(ci): return work_dir / f"naming-c{ci}.json"
t, nn, n = f"{new_title} ({n})", _norm_title(f"{new_title} ({n})"), n + 1
seen_norm.add(nn) async def _name(ci, members):
await db.set_block_status(topic, b["title_norm"], "consensus", title=t, neu_norm=nn) fp = name_path(ci)
else: if _naming_schema(_json_file(fp), len(members)) is not None:
await db.set_block_status(topic, b["title_norm"], "consensus") return # resume
await db.set_step_status(topic, "Clarification", "done") lines = "\n".join(f"{k + 1}. {by_norm[nm]['title']}{by_norm[nm]['description']}"
for k, nm in enumerate(members))
await run_single_slot(
ctx, f"Naming {ci}",
key=f"blocks-{topic}-naming-c{ci}",
prompt=_prompt("Blocks-Naming", topic=topic, members=lines, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp, n=len(members): _naming_schema(_json_file(p), n),
timeout=_timeout("selection_mapping", len(members)),
)
await _gather_progress([_name(ci, c) for ci, c in multi], len(multi), _report_p(set_p, topic, "Naming"))
if is_cancelled():
return False
result = []
for ci, members in enumerate(chains):
if len(members) == 1:
winner = members[0]
else:
best = _naming_schema(_json_file(name_path(ci)), len(members))
if best is not None:
winner = members[best - 1]
else: # judge failed → deterministic _canonical heuristic over the members
rep = _canonical([by_norm[nm] for nm in members], list(range(len(members))), set())
winner = _norm_title(rep["title"])
if winner not in members:
winner = members[0]
result.append({"members": members, "winner": winner})
atomic_write_json(work_dir / "naming.json", {"chains": result}, indent=1)
_log(topic, f"Naming: {len(result)} chains named ({len(multi)} via judge)")
await db.set_step_status(topic, "Naming", "done")
return True
async def _verify_naming(ctx: GenContext, set_p, files: dict) -> bool:
"""Verify-Naming: a second judge checks each chain's chosen title and corrects it if another
member fits better. Updates naming.json. Singletons skipped."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
if await db.get_step_status(topic, "Verify-Naming") == "done":
return True
set_p("Verify-Naming…", step=_step_idx(topic, "Verify-Naming"))
work_dir = files["arbeit"]
by_norm = {r["title_norm"]: r for r in await db.list_blocks(topic)}
data = _json_file(work_dir / "naming.json")
chains = data.get("chains", []) if isinstance(data, dict) else []
multi = [(ci, [nm for nm in c.get("members", []) if nm in by_norm])
for ci, c in enumerate(chains) if len([nm for nm in c.get("members", []) if nm in by_norm]) > 1]
if not multi:
await db.set_step_status(topic, "Verify-Naming", "done")
return True
def check_path(ci): return work_dir / f"naming-check-c{ci}.json"
async def _check(ci, members, current):
fp = check_path(ci)
if _naming_schema(_json_file(fp), len(members)) is not None:
return # resume
lines = "\n".join(f"{k + 1}. {by_norm[nm]['title']}{by_norm[nm]['description']}"
for k, nm in enumerate(members))
await run_single_slot(
ctx, f"Verify-Naming {ci}",
key=f"blocks-{topic}-naming-check-c{ci}",
prompt=_prompt("Blocks-Naming-Check", topic=topic, members=lines, current=current, out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp, n=len(members): _naming_schema(_json_file(p), n),
timeout=_timeout("selection_mapping", len(members)),
)
coros = []
for ci, members in multi:
cur = chains[ci].get("winner")
current = members.index(cur) + 1 if cur in members else 1
coros.append(_check(ci, members, current))
await _gather_progress(coros, len(multi), _report_p(set_p, topic, "Verify-Naming"))
if is_cancelled():
return False
changed = 0
for ci, members in multi:
best = _naming_schema(_json_file(check_path(ci)), len(members))
if best is not None and members[best - 1] != chains[ci].get("winner"):
chains[ci]["winner"] = members[best - 1]
changed += 1
atomic_write_json(work_dir / "naming-final.json", {"chains": chains}, indent=1)
_log(topic, f"Verify-Naming: {changed} titles corrected")
await db.set_step_status(topic, "Verify-Naming", "done")
return True
async def _filter(ctx: GenContext, set_p, files: dict) -> bool:
"""Filter (reduce): each chain collapses to its winner. Winner → status consensus (the final
block, keeping its own title/description/source), all other members → discarded. Candidates not
covered by any chain stay consensus (no concept loss). Pure Python, no LLM."""
topic = ctx.topic
if await db.get_step_status(topic, "Filter") == "done":
return True
set_p("Filter…", step=_step_idx(topic, "Filter"))
work_dir = files["arbeit"]
by_norm = {r["title_norm"]: r for r in await db.list_blocks(topic)}
data = _json_file(work_dir / "naming-final.json") or _json_file(work_dir / "naming.json")
chains = data.get("chains", []) if isinstance(data, dict) else []
if not chains:
_blocks_errors[topic] = "Filter: no chains"
return False
kept, seen = 0, set()
for chain in chains:
members = [nm for nm in chain.get("members", []) if nm in by_norm]
if not members:
continue
winner = chain.get("winner") if chain.get("winner") in members else members[0]
await db.set_block_status(topic, winner, "consensus")
seen.add(winner); kept += 1
for nm in members:
if nm != winner:
await db.set_block_status(topic, nm, "discarded")
seen.add(nm)
for nm in by_norm: # safety: any uncovered candidate survives
if nm not in seen:
await db.set_block_status(topic, nm, "consensus")
kept += 1
_log(topic, f"Filter: {kept} final blocks (consensus)")
await db.set_step_status(topic, "Filter", "done")
return True return True
@@ -2541,117 +2473,6 @@ def _cliques(n: int, edge_list: list[tuple[int, int]]) -> list[list[int]]:
return groups return groups
def _filter_schema(data) -> dict[int, int] | None:
"""{"fragments": {"3": 7, "12": 8}} → {block_nr: parent_nr} · None on invalid structure.
Empty dict = valid (nothing to degrade). Parent ≠ itself."""
if not isinstance(data, dict) or not isinstance(data.get("fragments"), dict):
return None
out: dict[int, int] = {}
for k, v in data["fragments"].items():
try:
nr, parent = int(k), int(v)
except (ValueError, TypeError):
continue
if nr != parent:
out[nr] = parent
return out
# Pure notation/symbols without a standalone concept — kept narrow (FP~0, checked against aak;
# "KNF"/"MST"/"NP" do NOT match). These are discarded autonomously (need no parent).
_FILTER_NOTATION = re.compile(r'^\s*\|.{1,6}\|\s*$|^Güte\s+\d+\s*$')
# Property/runtime suspicion — marks lines for the judge's verdict (NO auto-drop, FP too high:
# "NP-Schwere", reductions with "∈NP" are real blocks). Complements _aspekt_marker.
_FILTER_PREDICATE = re.compile(
r'ist NP-(vollständig|schwer)|NP-(Vollständigkeit|Schwere) von|ETH (Konsequenz|Lower Bound)'
r'|Approximationsschema nach|Laufzeit O\(|∈ ?NP', re.I)
def _filter_suspect(b: dict) -> bool:
"""Heuristic flag: could be a property/detail of another block."""
return _aspect_marker(b["title"]) > 0 or bool(_FILTER_PREDICATE.search(f"{b['title']} {b['description'] or ''}"))
async def _filter_inventory(ctx: GenContext, set_p, files: dict) -> bool:
"""Degrade pass (granularity): separates real blocks from fragments (properties,
proof gadgets, notation, runtime details). Each judge sees the FULL block list
(self-containment is relational) and marks fragments WITH a parent block from the list.
Fragment + parent-in-list → discarded (content comes back as a subblock of the parent).
No parent or in doubt → keep (no concept loss)."""
topic, is_cancelled = ctx.topic, ctx.is_cancelled
if await db.get_step_status(topic, "Blocks-Filter") == "done":
return True
set_p("Blocks-Filter…", step=_step_idx(topic, "Blocks-Filter"))
work_dir = files["arbeit"]
consensus_all = await db.list_blocks(topic, status="consensus")
# Safety net: discard pure notation autonomously (FP~0, no parent needed). The judge
# reliably overlooks such symbols (recall problem), hence deterministically beforehand.
consensus, notation_dropped = [], []
for b in consensus_all:
if _FILTER_NOTATION.search(b["title"]):
await db.set_block_status(topic, b["title_norm"], "discarded")
notation_dropped.append(b["title"])
else:
consensus.append(b)
if notation_dropped:
_log(topic, f"Blocks-Filter: {len(notation_dropped)} pure notation discarded: {notation_dropped[:6]}")
if len(consensus) < 2:
await db.set_step_status(topic, "Blocks-Filter", "done")
return True
n = len(consensus)
# ⚠ marks suspicious lines (property/runtime) — the judge MUST check them per entry.
def _line(i, b):
mark = "" if _filter_suspect(b) else ""
return f"{i}. {mark}{b['title']}{b['description']}" if b["description"] else f"{i}. {mark}{b['title']}"
full_list = "\n".join(_line(i, b) for i, b in enumerate(consensus, 1))
chunks = [list(range(i, min(i + FILTER_CHUNK, n + 1))) for i in range(1, n + 1, FILTER_CHUNK)]
def filt_path(ci): return work_dir / f"inventar-filter-c{ci}.json"
async def _assess(ci, numbers):
fp = filt_path(ci)
if _filter_schema(_json_file(fp)) is not None:
return # resume
await run_single_slot(
ctx, f"Blocks-Filter {ci}",
key=f"blocks-{topic}-inventar-filter-c{ci}",
prompt=_prompt("Blocks-Filter", topic=topic, list=full_list,
from_n=numbers[0], to_n=numbers[-1], out_path=fp),
role="judge", capabilities="files",
payload=lambda result, p=fp: _filter_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(numbers)),
)
await _gather_progress([_assess(ci, nm) for ci, nm in enumerate(chunks)],
len(chunks), _report_p(set_p, topic, "Blocks-Filter"))
if is_cancelled():
return False
fragments: dict[int, int] = {}
for ci, numbers in enumerate(chunks):
verdict = _filter_schema(_json_file(filt_path(ci))) or {}
nset = set(numbers)
for nr, parent in verdict.items():
if 1 <= parent <= n and nr in nset:
fragments[nr] = parent
# Chain protection: a block that is itself the parent of a fragment stays (its child needs the anchor).
parent_set = set(fragments.values())
removed, debug = 0, []
for nr, parent in fragments.items():
if nr in parent_set:
continue
b = consensus[nr - 1]
await db.set_block_status(topic, b["title_norm"], "discarded")
removed += 1
debug.append({"fragment": b["title"], "eltern": consensus[parent - 1]["title"]})
atomic_write_json(work_dir / "inventar-filter.json",
{"vorher": n, "degradiert": removed, "fragments": debug}, indent=1)
_log(topic, f"Blocks-Filter: {n}{n - removed} ({removed} fragments → subblocks)")
await db.set_step_status(topic, "Blocks-Filter", "done")
return True
# --- Outline (blocks artifact: chapter structure, only read by the guide) ---
def _outline_complete(files: dict) -> bool: def _outline_complete(files: dict) -> bool:
"""Is the outline present (chapter list exists)?""" """Is the outline present (chapter list exists)?"""
d = _json_file(files["outline"]) d = _json_file(files["outline"])
@@ -3043,13 +2864,14 @@ async def _reset_db_from_phase(topic: str, label: str) -> None:
await db.delete_subblocks(topic) await db.delete_subblocks(topic)
if idx <= 1: # Inventory: inventory + research steps — triage stays if idx <= 1: # Inventory: inventory + research steps — triage stays
await db.delete_blocks(topic) await db.delete_blocks(topic)
await db.delete_pipeline_state(topic, ["Research", "Consolidation", "Clarification", "Blocks-Filter"]) await db.kanban_reset(topic)
await db.delete_pipeline_state(topic, list(INVENTORY_STEPS))
if idx <= 0: # Source: redo triage (coverage/content + step) if idx <= 0: # Source: redo triage (coverage/content + step)
await db.delete_coverage(topic) await db.delete_coverage(topic)
await db.delete_pipeline_state(topic, ["Source prep"]) await db.delete_pipeline_state(topic, ["Source prep"])
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None, ab_step: int | None = None, to_step: int | None = None) -> None: async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER, ab_phase: int | None = None, ab_step: int | None = None, to_step: int | None = None, research: bool = True) -> None:
if topic in _blocks_progress: if topic in _blocks_progress:
return return
_blocks_progress[topic] = "Waiting…" _blocks_progress[topic] = "Waiting…"
@@ -3104,31 +2926,27 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
# sidecar) → complete fresh start. If blocks.md exists without the sidecar, # sidecar) → complete fresh start. If blocks.md exists without the sidecar,
# it's a partial state (block B/C open) → resume, don't wipe. # it's a partial state (block B/C open) → resume, don't wipe.
# On an explicit re-run (ab_phase) _reset_ab_phase already handled that. # On an explicit re-run (ab_phase) _reset_ab_phase already handled that.
done = ab_phase is None and ab_step is None and final_path.exists() and _sidecar_schema(_json_file(files["sidecar"])) is not None # "Continue" (research=False) is non-destructive: never wipe a finished topic.
done = research and ab_phase is None and ab_step is None and final_path.exists() and _sidecar_schema(_json_file(files["sidecar"])) is not None
if done: if done:
for p_old in _all_slot_files(files): for p_old in _all_slot_files(files):
p_old.unlink(missing_ok=True) p_old.unlink(missing_ok=True)
await db.delete_pipeline_state(topic) await db.delete_pipeline_state(topic)
await db.delete_blocks(topic) await db.delete_blocks(topic)
await db.kanban_reset(topic)
await db.delete_subblocks(topic) await db.delete_subblocks(topic)
await db.delete_question_pattern(topic) await db.delete_question_pattern(topic)
await db.delete_coverage(topic) await db.delete_coverage(topic)
await db.delete_outline(topic) await db.delete_outline(topic)
await db.delete_sub_artefakte(topic) await db.delete_sub_artefakte(topic)
# Inventory (DB): research loop → consolidation → clarification. # Inventory: streaming kanban dataflow — its columns ARE the inventory steps (pills).
if _past_limit("Research"): return # One streaming run (cards, not discrete steps); per-column regenerate is not meaningful.
if not await _stage(_research_batch(ctx, set_p, files, q, folder, instructions)): import kanban # lazy import: avoids a module-level cycle (kanban imports blocks)
return if not await _stage(kanban.run_kanban(ctx, set_p, files, q, folder, instructions, research=research)):
if _past_limit("Consolidation"): return
if not await _stage(_consolidate(ctx, set_p, files)):
return
if _past_limit("Clarification"): return
if not await _stage(_clarify_inventory(ctx, set_p, files)):
return
if _past_limit("Blocks-Filter"): return
if not await _stage(_filter_inventory(ctx, set_p, files)):
return return
for _s in INVENTORY_STEPS: # mark all columns done so the step pills show complete
await db.set_step_status(topic, _s, "done")
consensus_rows = await db.list_blocks(topic, status="consensus") consensus_rows = await db.list_blocks(topic, status="consensus")
entries = { entries = {
i: (f"{b['title']}{b['description']}" if b["description"] else b["title"]) i: (f"{b['title']}{b['description']}" if b["description"] else b["title"])

View File

@@ -55,6 +55,9 @@ MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "10"))
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "10")) # per topic MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "10")) # per topic
MAX_CONCURRENT_INTERACTIVE = 8 MAX_CONCURRENT_INTERACTIVE = 8
# Inventory engine: streaming kanban dataflow (kanban.py) is the default; set "0" for the legacy ER pipeline.
KANBAN_INVENTORY = os.getenv("KANBAN_INVENTORY", "1") != "0"
# Grace window of the consensus races (blocks, guide, OnePager): after the first # Grace window of the consensus races (blocks, guide, OnePager): after the first
# valid result the remaining agents may still become done for this many seconds # valid result the remaining agents may still become done for this many seconds
# (kill only once the minimum is already in). # (kill only once the minimum is already in).

View File

@@ -199,6 +199,59 @@ CREATE TABLE IF NOT EXISTS sub_artefakte (
) )
""" """
# Kanban streaming dataflow for the inventory phase. Cards (titles → chains → blocks) flow through
# columns; `stage` is the current/next column (the queue of a worker = WHERE stage = <predecessor>).
# `stage` is used instead of the reserved word `column`. chain_id/block_id are stable → upsert, not dup.
CREATE_KANBAN_TITLES = """
CREATE TABLE IF NOT EXISTS kanban_titles (
topic TEXT NOT NULL,
title_norm TEXT NOT NULL,
title TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT 'merge',
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, title_norm)
)
"""
CREATE_KANBAN_CHAINS = """
CREATE TABLE IF NOT EXISTS kanban_chains (
topic TEXT NOT NULL,
chain_id TEXT NOT NULL,
stage TEXT NOT NULL DEFAULT 'chain_verify',
main_title_norm TEXT,
dirty INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, chain_id)
)
"""
CREATE_KANBAN_CHAIN_MEMBERS = """
CREATE TABLE IF NOT EXISTS kanban_chain_members (
topic TEXT NOT NULL,
chain_id TEXT NOT NULL,
title_norm TEXT NOT NULL,
PRIMARY KEY (topic, title_norm)
)
"""
CREATE_KANBAN_BLOCKS = """
CREATE TABLE IF NOT EXISTS kanban_blocks (
topic TEXT NOT NULL,
block_id TEXT NOT NULL,
chain_id TEXT,
title TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT 'small_blocks',
is_small INTEGER NOT NULL DEFAULT 0,
parent_block_id TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (topic, block_id)
)
"""
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
@@ -230,6 +283,10 @@ async def init_db():
await db.execute(CREATE_SOURCE) await db.execute(CREATE_SOURCE)
await db.execute(CREATE_GUIDE_OUTLINE) await db.execute(CREATE_GUIDE_OUTLINE)
await db.execute(CREATE_SUB_ARTEFAKTE) await db.execute(CREATE_SUB_ARTEFAKTE)
await db.execute(CREATE_KANBAN_TITLES)
await db.execute(CREATE_KANBAN_CHAINS)
await db.execute(CREATE_KANBAN_CHAIN_MEMBERS)
await db.execute(CREATE_KANBAN_BLOCKS)
try: # migration for existing DBs without the step column try: # migration for existing DBs without the step column
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
@@ -706,6 +763,144 @@ async def delete_blocks(topic: str) -> None:
await db.commit() await db.commit()
# ── Kanban streaming dataflow (inventory) ───────────────────────────────────────
# Generic stage helpers. `stage` is the queue key: a worker pulls WHERE stage = <its input stage>.
_KANBAN_ID = {"kanban_titles": "title_norm", "kanban_chains": "chain_id", "kanban_blocks": "block_id"}
async def kanban_pull(topic: str, table: str, stage: str, limit: int) -> list[dict]:
"""Oldest `limit` cards sitting in `stage` (FIFO via updated_at)."""
idc = _KANBAN_ID[table] # validates table name
db = await get_db()
cursor = await db.execute(
f"SELECT * FROM {table} WHERE topic = ? AND stage = ? ORDER BY updated_at LIMIT ?", (topic, stage, limit))
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def kanban_count(topic: str, table: str, stages) -> int:
"""How many cards sit in any of `stages` (str or list) — for queue length / quiescence."""
_ = _KANBAN_ID[table]
if isinstance(stages, str):
stages = [stages]
if not stages:
return 0
db = await get_db()
ph = ",".join("?" * len(stages))
cursor = await db.execute(f"SELECT count(*) FROM {table} WHERE topic = ? AND stage IN ({ph})", (topic, *stages))
return (await cursor.fetchone())[0]
async def kanban_advance(topic: str, table: str, id_val: str, stage: str) -> None:
"""Move a card to `stage` (advance to next column, or back for rework/retraction)."""
idc = _KANBAN_ID[table]
db = await get_db()
await db.execute(f"UPDATE {table} SET stage = ?, updated_at = ? WHERE topic = ? AND {idc} = ?",
(stage, _now(), topic, id_val))
await db.commit()
async def kanban_add_title(topic: str, title_norm: str, title: str, source: str = "", content: str = "") -> bool:
"""Research → titles queue (stage 'merge'). Exact dupes are dropped (PK conflict). → True if new."""
db = await get_db()
cursor = await db.execute(
"""INSERT INTO kanban_titles (topic, title_norm, title, source, content, stage, updated_at)
VALUES (?, ?, ?, ?, ?, 'merge', ?) ON CONFLICT(topic, title_norm) DO NOTHING""",
(topic, title_norm, title, source, content, _now()))
await db.commit()
return cursor.rowcount > 0
async def kanban_upsert_chain(topic: str, chain_id: str, stage: str, main_title_norm: str | None = None,
dirty: int = 0) -> None:
db = await get_db()
await db.execute(
"""INSERT INTO kanban_chains (topic, chain_id, stage, main_title_norm, dirty, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, chain_id) DO UPDATE SET
stage = excluded.stage, main_title_norm = COALESCE(excluded.main_title_norm, kanban_chains.main_title_norm),
dirty = excluded.dirty, updated_at = excluded.updated_at""",
(topic, chain_id, stage, main_title_norm, dirty, _now()))
await db.commit()
async def kanban_set_chain_members(topic: str, chain_id: str, members: list[str]) -> None:
"""Replace the member set of a chain (one title belongs to exactly one chain)."""
db = await get_db()
await db.execute("DELETE FROM kanban_chain_members WHERE topic = ? AND chain_id = ?", (topic, chain_id))
for nm in members:
await db.execute(
"""INSERT INTO kanban_chain_members (topic, chain_id, title_norm) VALUES (?, ?, ?)
ON CONFLICT(topic, title_norm) DO UPDATE SET chain_id = excluded.chain_id""",
(topic, chain_id, nm))
await db.commit()
async def kanban_chain_members(topic: str, chain_id: str) -> list[str]:
db = await get_db()
cursor = await db.execute(
"SELECT title_norm FROM kanban_chain_members WHERE topic = ? AND chain_id = ?", (topic, chain_id))
return [r[0] for r in await cursor.fetchall()]
async def kanban_member_chain(topic: str, title_norm: str) -> str | None:
"""Which chain a title currently belongs to (or None)."""
db = await get_db()
cursor = await db.execute(
"SELECT chain_id FROM kanban_chain_members WHERE topic = ? AND title_norm = ?", (topic, title_norm))
row = await cursor.fetchone()
return row[0] if row else None
async def kanban_upsert_block(topic: str, block_id: str, chain_id: str | None, title: str, source: str = "",
content: str = "", stage: str = "small_blocks", is_small: int = 0,
parent_block_id: str | None = None) -> None:
"""Chain-id-stable block (Filter/Block). Upsert → growing chains overwrite, never duplicate."""
db = await get_db()
await db.execute(
"""INSERT INTO kanban_blocks (topic, block_id, chain_id, title, source, content, stage, is_small, parent_block_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(topic, block_id) DO UPDATE SET
chain_id = excluded.chain_id, title = excluded.title, source = excluded.source,
content = excluded.content, stage = excluded.stage, is_small = excluded.is_small,
parent_block_id = excluded.parent_block_id, updated_at = excluded.updated_at""",
(topic, block_id, chain_id, title, source, content, stage, is_small, parent_block_id, _now()))
await db.commit()
async def kanban_titles_by_norm(topic: str) -> dict[str, dict]:
"""All titles of a topic keyed by title_norm (the candidate universe for chaining)."""
db = await get_db()
cursor = await db.execute("SELECT * FROM kanban_titles WHERE topic = ?", (topic,))
rows = await cursor.fetchall()
return {(d := _row_to_dict(row, cursor))["title_norm"]: d for row in rows}
async def kanban_all_blocks(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute("SELECT * FROM kanban_blocks WHERE topic = ?", (topic,))
rows = await cursor.fetchall()
return [_row_to_dict(row, cursor) for row in rows]
async def kanban_stage_counts(topic: str) -> dict[str, int]:
"""{stage: count} across all kanban tables — for the live board / quiescence."""
db = await get_db()
out: dict[str, int] = {}
for table in ("kanban_titles", "kanban_chains", "kanban_blocks"):
cursor = await db.execute(f"SELECT stage, count(*) FROM {table} WHERE topic = ? GROUP BY stage", (topic,))
for stage, n in await cursor.fetchall():
out[stage] = out.get(stage, 0) + n
return out
async def kanban_reset(topic: str) -> None:
db = await get_db()
for table in ("kanban_titles", "kanban_chains", "kanban_chain_members", "kanban_blocks"):
await db.execute(f"DELETE FROM {table} WHERE topic = ?", (topic,))
await db.commit()
async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str) -> None: async def upsert_subblock(topic: str, block_norm: str, sub_norm: str, block: str, sub_title: str) -> None:
db = await get_db() db = await get_db()
await db.execute( await db.execute(

748
backend/kanban.py Normal file
View File

@@ -0,0 +1,748 @@
"""Streaming kanban dataflow for the inventory phase.
Each column is a worker that pulls cards from its input `stage` (the queue), processes up to
KANBAN_BATCH at a time, and advances them to the next stage. Cards: titles → chains → blocks.
Streaming columns run continuously; barrier columns start only at QUIESCENCE of everything before
them (no active worker + empty queues). Verify columns push failures back (rework). The Chain column
re-clusters live: a chain that gains a member is marked dirty and flows back to chain_verify.
Reused from blocks.py (imported lazily-safe — kanban is only imported after blocks is loaded):
embedding clustering, `_pairs_schema`/`_cliques`, `_canonical`, research prompt + file payload.
"""
import asyncio
import json
import uuid
import database as db
import embedding
import blocks
from config import RESEARCH_GRACE, MAX_CONCURRENT_AGENTS_PER_TOPIC
from pipeline import GenContext, run_single_slot, _prompt, _timeout, _log, OK
from textkit import _norm_title, _title, _parse_selection
from jsonio import read_json_file as _json_file
KANBAN_BATCH = 5 # cards a worker pulls per package (micro-batching)
# How many packages ONE worker keeps in flight at once. A worker no longer blocks on a single
# package — it keeps pulling and dispatching until this many run concurrently, so a busy column
# fills the agent slots (the per-topic semaphore is the real cap; over-dispatch just queues cheaply).
WORKER_INFLIGHT = MAX_CONCURRENT_AGENTS_PER_TOPIC
# Stages whose processor mutates shared cross-card state and MUST run one package at a time.
# Online chain-clustering reads the whole universe + membership; parallel packages would race.
_SERIAL_STAGES = {"chain"}
_ID_COL = {"kanban_titles": "title_norm", "kanban_chains": "chain_id", "kanban_blocks": "block_id"}
CHAIN_CAP = 12 # max members per chain — caps the O(n²) pair-verification blow-up
_POLL = 0.3 # seconds between empty-queue polls
# Stage order. A card's `stage` = the column it waits in (its worker's input).
TITLE_STAGES = ["merge", "chain", "chained"] # 'chained' = consumed into a chain
CHAIN_STAGES = ["chain_verify", "naming", "naming_verify", "chain_filter", "filter_verify", "block_assemble"]
BLOCK_STAGES = ["small_blocks", "small_verify", "dependency", "dependency_verify", "main"]
DONE_CHAIN = "done_chain"
DONE_BLOCK = "done_block"
REJECTED = "rejected" # block dropped by filter_verify (off-topic / noise) — terminal, never mirrored
# Predecessor stages for each barrier (must ALL be quiescent before the barrier worker runs).
_BEFORE_CHAIN_FILTER = ["merge", "chain", "chain_verify", "naming", "naming_verify"]
_BEFORE_BLOCK = _BEFORE_CHAIN_FILTER + ["chain_filter", "filter_verify"]
_BEFORE_MAIN = ["small_blocks", "small_verify", "dependency", "dependency_verify"]
class _Flow:
"""Shared runtime state: active-task counters per stage + a wakeup event. `producers` counts the
running research agents (initial + any added live via the generate button); research counts as
done only when ALL producers have finished, so the flow stays awake while extras still search."""
def __init__(self, topic: str, work_dir):
self.topic = topic
self.work_dir = work_dir
self.active: dict[str, int] = {}
self.producers = 1 # the initial research agent
self.research_tag = 0
self.stop = False
self.wake = asyncio.Event()
self.spawn_research = None # set by run_kanban: () → coroutine that adds one more research agent
@property
def research_done(self) -> bool:
return self.producers <= 0
def add_producer(self):
self.producers += 1
self.wake.set()
def done_producer(self):
self.producers -= 1
self.wake.set()
def next_tag(self) -> int:
self.research_tag += 1
return self.research_tag
def enter(self, stage: str):
self.active[stage] = self.active.get(stage, 0) + 1
def leave(self, stage: str):
self.active[stage] = max(0, self.active.get(stage, 0) - 1)
self.wake.set()
def active_in(self, stages) -> bool:
return any(self.active.get(s, 0) > 0 for s in stages)
async def queued_in(self, table: str, stages) -> bool:
return await db.kanban_count(self.topic, table, list(stages)) > 0
# ── Research producer ────────────────────────────────────────────────────────────
async def _ingest_titles(topic: str, text: str) -> int:
"""Parse a reader file into kanban_titles (stage 'merge'). Exact dupes drop on the PK. → new count."""
n, seen = 0, set()
for record in _parse_selection(text).values():
title = _title(record)
norm = _norm_title(title)
if not norm or norm in seen:
continue
seen.add(norm)
parts = [t.strip() for t in record.split("")]
source = parts[2] if len(parts) >= 3 else ""
desc = parts[1] if len(parts) >= 2 else ""
if await db.kanban_add_title(topic, norm, title, source, desc):
n += 1
return n
RESEARCH_RUNTIME = 900 # one research agent, one round, ~15 min hard cap — the tail ingests live while it writes
_POLL_RESEARCH = 3 # seconds between live reads of a running research file
# Live registry of running flows, so the "+ research" button can attach another agent to a live run.
_active_flows: dict[str, "_Flow"] = {}
def _extract_text(raw_line: str) -> str:
"""Best-effort: pull assistant/tool text out of ONE opencode `--format json` event line.
Recursively collects every `text`/`content` string — robust to the exact event schema."""
try:
obj = json.loads(raw_line)
except Exception:
return ""
parts: list[str] = []
def _walk(o):
if isinstance(o, dict):
for k, v in o.items():
if k in ("text", "content") and isinstance(v, str):
parts.append(v)
else:
_walk(v)
elif isinstance(o, list):
for v in o:
_walk(v)
_walk(obj)
return "".join(parts)
async def _research_once(ctx: GenContext, files: dict, q: dict, folder, instructions: str, tag: str, flow: "_Flow"):
"""ONE agent searches the topic; its titles go into the merge queue LIVE. Two sources feed the
ingest: the JSON event stream (on_line → text buffer) AND the file the agent writes — whichever
the agent uses, cards stream in immediately (not only after it finishes)."""
work_dir = files["arbeit"]
caps = "files" if folder else "full"
p = work_dir / f"research-{tag}.md"
p.unlink(missing_ok=True)
stop = asyncio.Event()
buf: list[str] = [] # assistant text streamed live from the JSON events
def _on_line(raw: str): # sync, called per stdout line by the agent runner
if (t := _extract_text(raw)):
buf.append(t)
async def _drain() -> bool: # ingest from BOTH event buffer and file (idempotent, dupes drop on PK)
text = "".join(buf)
if (ft := blocks._file_payload(p)):
text += "\n" + ft
return bool(text) and await _ingest_titles(ctx.topic, text)
async def _tail(): # live-ingest loop while the agent runs
while not stop.is_set():
try:
await asyncio.wait_for(stop.wait(), timeout=_POLL_RESEARCH)
except asyncio.TimeoutError:
pass
if await _drain():
flow.wake.set() # new cards → wake the workers
tail = asyncio.create_task(_tail())
try:
await run_single_slot(
ctx, f"research-{tag}", key=f"blocks-{ctx.topic}-research-{tag}",
prompt=blocks._build_research_prompt(ctx.topic, p, instructions, q["type"], folder),
role="quick", capabilities=caps,
payload=(lambda result, p=p: blocks._file_payload(p)),
timeout=RESEARCH_RUNTIME, on_line=_on_line,
)
finally:
stop.set()
await tail
if await _drain(): # final catch-up
flow.wake.set()
_log(ctx.topic, f"Research {tag}: titles → merge queue")
async def _research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
"""The initial research producer (already counted in flow.producers=1)."""
try:
await _research_once(ctx, files, q, folder, instructions, "1", flow)
finally:
flow.done_producer()
async def _extra_research(ctx: GenContext, files: dict, q: dict, folder, instructions: str, flow: _Flow):
"""One more research agent, added live via the generate button. Keeps the flow awake until done."""
flow.add_producer()
try:
await _research_once(ctx, files, q, folder, instructions, f"x{flow.next_tag()}", flow)
finally:
flow.done_producer()
def add_research_agent(topic: str) -> bool:
"""Attach one more research agent to a running flow. → True if a run was live to attach to."""
flow = _active_flows.get(topic)
if flow is None or flow.stop or flow.spawn_research is None:
return False
asyncio.create_task(flow.spawn_research())
return True
# ── Generic worker loop ────────────────────────────────────────────────────────────
async def _quiescent(flow: _Flow, stages) -> bool:
"""True iff no worker is active in `stages` AND no card is queued in any of them (all tables).
The barrier/exit condition — must include QUEUED cards, not just active workers, or a worker
could exit in a momentary lull while an upstream worker still has work to push down."""
if not stages:
return True
if flow.active_in(stages):
return False
for tb in ("kanban_titles", "kanban_chains", "kanban_blocks"):
if await db.kanban_count(flow.topic, tb, list(stages)):
return False
return True
async def _worker(flow: _Flow, table: str, in_stage: str, process, upstream, *, barrier=False, inflight=WORKER_INFLIGHT):
"""Pull cards from `in_stage`, run `process` — keeping up to `inflight` packages running CONCURRENTLY
so a busy column fills the agent slots instead of doing one package at a time. `upstream` = all stages
before this one. A barrier worker only pulls when `upstream` is fully quiescent. ANY worker exits only
when research is done, its own queue is empty, AND `upstream` is quiescent (nothing can still arrive).
Double-pull safety: each stage has exactly ONE worker, so an in-memory `claimed` set of card-ids (held
while a package runs) is enough to keep concurrent pulls from grabbing the same cards."""
topic = flow.topic
idc = _ID_COL[table]
claimed: set[str] = set()
tasks: set[asyncio.Task] = set()
async def _run(cards):
ids = [c[idc] for c in cards]
flow.enter(in_stage)
try:
await process(cards)
except Exception as e: # one bad package must not kill the worker
_log(topic, f"worker {in_stage}: {type(e).__name__}: {e}")
finally:
flow.leave(in_stage)
for i in ids:
claimed.discard(i)
flow.wake.set()
try:
while not flow.stop:
tasks = {t for t in tasks if not t.done()}
# Fill the pipeline: pull fresh cards and dispatch until `inflight` packages run.
if not barrier or await _quiescent(flow, upstream):
while len(tasks) < inflight:
rows = await db.kanban_pull(topic, table, in_stage, KANBAN_BATCH + len(claimed))
fresh = [r for r in rows if r[idc] not in claimed][:KANBAN_BATCH]
if not fresh:
break
for r in fresh:
claimed.add(r[idc])
tasks.add(asyncio.create_task(_run(list(fresh))))
if tasks: # busy → wait for a package to finish, then refill
await asyncio.wait(tasks, timeout=_POLL, return_when=asyncio.FIRST_COMPLETED)
continue
# idle: nothing in flight and nothing pulled
up_quiet = await _quiescent(flow, upstream)
if (flow.research_done and up_quiet and not flow.active_in([in_stage])
and await db.kanban_count(topic, table, in_stage) == 0):
return # nothing left and nothing upstream can produce
await _sleep_wake(flow)
finally:
for t in tasks:
t.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def _sleep_wake(flow: _Flow):
try:
await asyncio.wait_for(flow.wake.wait(), timeout=_POLL)
except asyncio.TimeoutError:
pass
flow.wake.clear()
# ── Column processors ──────────────────────────────────────────────────────────────
async def _proc_merge(flow: _Flow, cards):
"""Exact dedup happened at ingest (PK). Merge just advances titles to the chain column."""
for c in cards:
await db.kanban_advance(flow.topic, "kanban_titles", c["title_norm"], "chain")
flow.wake.set()
async def _proc_chain(flow: _Flow, cards):
"""Embedding blocking: for each new title, find the most similar existing title (cosine ≥ floor).
Join its chain (or open a new one), mark the chain dirty → chain_verify. Live-growing clusters."""
topic = flow.topic
by_norm = await db.kanban_titles_by_norm(topic)
# Universe = titles already chained + the new batch (for nearest-neighbour search).
universe = [nm for nm, r in by_norm.items() if r["stage"] in ("chain", "chained")]
if len(universe) < 1:
return
texts = [f"{by_norm[nm]['title']}{by_norm[nm]['content']}" if by_norm[nm]["content"] else by_norm[nm]["title"]
for nm in universe]
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
embedding and await asyncio.to_thread(embedding.available)) else None
idx = {nm: i for i, nm in enumerate(universe)}
# existing membership
member_chain = {}
for nm in universe:
cid = await _chain_of(topic, nm)
if cid:
member_chain[nm] = cid
touched = set()
for c in cards:
nm = c["title_norm"]
target = None
if sims is not None and nm in idx:
best, bestcos = None, blocks.DEDUP_PAIR_FLOOR
for other in universe:
if other == nm or other not in member_chain and other not in idx:
continue
cos = float(sims[idx[nm]][idx[other]]) if other in idx else -1
if cos >= bestcos and other != nm:
best, bestcos = other, cos
if best is not None:
target = member_chain.get(best)
cid = target or f"c-{uuid.uuid4().hex[:12]}"
members = set(await db.kanban_chain_members(topic, cid))
if target and len(members) >= CHAIN_CAP: # neighbour's chain is full → start a fresh chain
cid = f"c-{uuid.uuid4().hex[:12]}"
members = set()
members.add(nm)
await db.kanban_set_chain_members(topic, cid, sorted(members))
await db.kanban_upsert_chain(topic, cid, "chain_verify", dirty=1)
member_chain[nm] = cid
await db.kanban_advance(topic, "kanban_titles", nm, "chained")
touched.add(cid)
flow.wake.set()
async def _chain_of(topic: str, title_norm: str) -> str | None:
return await db.kanban_member_chain(topic, title_norm)
async def _members_dicts(topic: str, members: list[str]) -> list[dict]:
by = await db.kanban_titles_by_norm(topic)
return [by[m] for m in members if m in by]
async def _proc_chain_verify(ctx: GenContext, flow: _Flow, cards):
"""Pairwise-verify the batch's chains IN PARALLEL (one agent per chain). Failures split off."""
await asyncio.gather(*[_verify_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _verify_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
if len(dicts) <= 1:
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
return
# Only the embedding-NEAR candidate pairs (cosine ≥ floor) — NOT all O(n²) pairs. A 12-member
# chain shrinks from 66 pairs to a handful. Transitivity (connected components) does the rest.
nm = [d["title_norm"] for d in dicts]
texts = [f"{d['title']}{d['content']}" if d["content"] else d["title"] for d in dicts]
sims = await asyncio.to_thread(embedding.embed_sims, texts) if (
embedding and await asyncio.to_thread(embedding.available)) else None
if sims is not None:
pairs = [(nm[i], nm[j]) for i in range(len(nm)) for j in range(i + 1, len(nm))
if float(sims[i][j]) >= blocks.DEDUP_PAIR_FLOOR]
else:
pairs = [(a, b) for x, a in enumerate(members) for b in members[x + 1:]]
keep_edges = await _verify_pairs(ctx, flow.work_dir, topic, cid, dicts, pairs)
groups = _components(members, keep_edges) # transitive groups over confirmed near-pairs
groups.sort(key=len, reverse=True)
main = groups[0] if groups else members
await db.kanban_set_chain_members(topic, cid, sorted(main))
await db.kanban_upsert_chain(topic, cid, "naming", dirty=0)
for g in groups[1:]: # the rest split into fresh chains
ncid = f"c-{uuid.uuid4().hex[:12]}"
await db.kanban_set_chain_members(topic, ncid, sorted(g))
await db.kanban_upsert_chain(topic, ncid, "naming", dirty=0)
def _components(members: list[str], edges) -> list[list[str]]:
"""Connected components (union-find) over confirmed pairs. Members without an edge stay alone."""
parent = {m: m for m in members}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a, b in edges:
if a in parent and b in parent:
parent[find(a)] = find(b)
comp: dict[str, list[str]] = {}
for m in members:
comp.setdefault(find(m), []).append(m)
return list(comp.values())
async def _verify_pairs(ctx, work_dir, topic, cid, dicts, pairs):
"""Judge the candidate pairs in DEDUP_PAIRS_CHUNK packages, all packages IN PARALLEL → confirmed edges."""
by = {d["title_norm"]: d for d in dicts}
chunks = [pairs[k:k + blocks.DEDUP_PAIRS_CHUNK] for k in range(0, len(pairs), blocks.DEDUP_PAIRS_CHUNK)]
async def _chunk(ci, chunk):
path = work_dir / f"verify-{cid}-{ci}.json"
lines = "\n\n".join(
f"{j + 1}.\nA: {by[a]['title']}{by[a]['content']}\nB: {by[b]['title']}{by[b]['content']}"
for j, (a, b) in enumerate(chunk))
await run_single_slot(
ctx, f"Chain verify {cid}", key=f"blocks-{topic}-verify-{cid}-{ci}",
prompt=_prompt("Blocks-Paar-Filter", topic=topic, pairs=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: blocks._pairs_schema(_json_file(p)),
timeout=_timeout("selection_mapping", len(chunk)))
verdict = blocks._pairs_schema(_json_file(path)) or {}
return [(a, b) for j, (a, b) in enumerate(chunk) if verdict.get(j + 1)]
results = await asyncio.gather(*[_chunk(ci, ch) for ci, ch in enumerate(chunks)], return_exceptions=True)
return [e for r in results if isinstance(r, list) for e in r]
async def _proc_naming(ctx: GenContext, flow: _Flow, cards):
"""Pick the best member title per chain — batch runs IN PARALLEL (one agent per chain)."""
await asyncio.gather(*[_name_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _name_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
if len(dicts) <= 1:
await db.kanban_upsert_chain(topic, cid, "naming_verify",
main_title_norm=(members[0] if members else None), dirty=0)
return
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming")
await db.kanban_upsert_chain(topic, cid, "naming_verify", main_title_norm=winner, dirty=0)
async def _proc_naming_verify(ctx: GenContext, flow: _Flow, cards):
"""Second judge checks each title — batch runs IN PARALLEL."""
await asyncio.gather(*[_namecheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _namecheck_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
dicts = await _members_dicts(topic, members)
winner = c.get("main_title_norm") or (members[0] if members else None)
if len(dicts) > 1:
winner = await _choose_title(ctx, flow.work_dir, topic, cid, members, dicts, "Blocks-Naming-Check",
current=(members.index(winner) + 1 if winner in members else 1))
await db.kanban_upsert_chain(topic, cid, "chain_filter", main_title_norm=winner, dirty=0)
async def _choose_title(ctx, work_dir, topic, cid, members, dicts, template, current=None):
by = {d["title_norm"]: d for d in dicts}
path = work_dir / f"naming-{cid}.json"
lines = "\n".join(f"{k + 1}. {by[m]['title']}{by[m]['content']}" for k, m in enumerate(members) if m in by)
kw = dict(topic=topic, members=lines, out_path=path)
if current is not None:
kw["current"] = current
await run_single_slot(
ctx, f"Naming {cid}", key=f"blocks-{topic}-naming-{cid}",
prompt=_prompt(template, **kw), role="judge", capabilities="files",
payload=lambda result, p=path: blocks._naming_schema(_json_file(p), len(members)),
timeout=_timeout("selection_mapping", len(members)))
best = blocks._naming_schema(_json_file(path), len(members))
if best is None:
rep = blocks._canonical(dicts, list(range(len(dicts))), set())
w = _norm_title(rep["title"])
return w if w in members else members[0]
return members[best - 1]
async def _proc_chain_filter(flow: _Flow, cards):
"""BARRIER. Reduce each chain to its winner → upsert a block (chain_id stable). → filter_verify."""
topic = flow.topic
by = await db.kanban_titles_by_norm(topic)
for c in cards:
cid = c["chain_id"]
members = await db.kanban_chain_members(topic, cid)
winner = c.get("main_title_norm") if c.get("main_title_norm") in members else (members[0] if members else None)
if not winner or winner not in by:
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
continue
w = by[winner]
await db.kanban_upsert_block(topic, f"b-{cid}", cid, w["title"], w["source"], w["content"], stage="filter_verify_b")
await db.kanban_upsert_chain(topic, cid, "filter_verify", dirty=0)
flow.wake.set()
async def _proc_filter_verify(ctx: GenContext, flow: _Flow, cards):
"""An agent confirms each reduced block is a valid, on-topic, self-contained concept. Off-topic /
noise / empty blocks are dropped (→ REJECTED, chain done). IN PARALLEL (one agent per block)."""
await asyncio.gather(*[_filtercheck_one(ctx, flow, c) for c in cards], return_exceptions=True)
flow.wake.set()
async def _filtercheck_one(ctx: GenContext, flow: _Flow, c):
topic = flow.topic
cid = c["chain_id"]
bid = f"b-{cid}"
by = await db.kanban_titles_by_norm(topic)
members = await db.kanban_chain_members(topic, cid)
winner = c.get("main_title_norm") if c.get("main_title_norm") in by else (members[0] if members else None)
async def _drop():
await db.kanban_advance(topic, "kanban_blocks", bid, REJECTED)
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
async def _pass():
await db.kanban_advance(topic, "kanban_blocks", bid, "block_assemble_b")
await db.kanban_upsert_chain(topic, cid, "block_assemble", dirty=0)
if not winner or winner not in by: # nothing to verify → drop the empty chain
await _drop()
return
w = by[winner]
path = flow.work_dir / f"filtercheck-{cid}.json"
await run_single_slot(
ctx, f"Filter verify {cid}", key=f"blocks-{topic}-verify-filter-{cid}",
prompt=_prompt("Blocks-Filter-Check", topic=topic, title=w["title"], content=w["content"], out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _keep_schema(_json_file(p)),
timeout=_timeout("selection_mapping", 1))
keep = _keep_schema(_json_file(path))
await (_drop() if keep is False else _pass()) # None (parse fail) → keep, conservative
async def _proc_block(flow: _Flow, cards):
"""BARRIER. Assemble the final block row → small_blocks. (chain card consumed → done.)"""
topic = flow.topic
for c in cards:
cid = c["chain_id"]
await db.kanban_advance(topic, "kanban_blocks", f"b-{cid}", "small_blocks")
await db.kanban_upsert_chain(topic, cid, DONE_CHAIN, dirty=0)
flow.wake.set()
def _small_schema(data, count):
"""{"small": {"1": true, ...}} → {block_index: bool} · else None."""
if not isinstance(data, dict) or not isinstance(data.get("small"), dict):
return None
out = {}
for k, v in data["small"].items():
try:
n = int(k)
except (ValueError, TypeError):
continue
if 1 <= n <= count:
out[n] = str(v).strip().casefold() in ("true", "ja", "yes", "1")
return out or None
def _keep_schema(data):
"""{"keep": true/false} → bool · None when absent/unparseable (caller keeps on None, conservative)."""
if not isinstance(data, dict) or "keep" not in data:
return None
return str(data["keep"]).strip().casefold() in ("true", "ja", "yes", "1")
def _dep_schema(data, count):
"""{"parent": N} → 0..count (0 = standalone) · else None."""
if not isinstance(data, dict):
return None
try:
n = int(data.get("parent"))
except (ValueError, TypeError):
return None
return n if 0 <= n <= count else None
async def _proc_small(ctx: GenContext, flow: _Flow, cards):
"""Judge marks fragment-like blocks (batch). → small_verify."""
topic = flow.topic
path = flow.work_dir / f"small-{cards[0]['block_id']}.json"
lines = "\n".join(f"{i + 1}. {c['title']}{c['content']}" for i, c in enumerate(cards))
await run_single_slot(
ctx, "Small blocks", key=f"blocks-{topic}-small-{cards[0]['block_id']}",
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
timeout=_timeout("selection_mapping", len(cards)))
verdict = _small_schema(_json_file(path), len(cards)) or {}
for i, c in enumerate(cards):
is_small = 1 if verdict.get(i + 1) else 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="small_verify", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_small_verify(ctx: GenContext, flow: _Flow, cards):
"""Second judge re-checks the small flag (consensus): a block stays `small` only if it was marked
small AND this judge also calls it a fragment. Disagreement → keep as a main block (conservative).
→ dependency."""
topic = flow.topic
path = flow.work_dir / f"smallcheck-{cards[0]['block_id']}.json"
lines = "\n".join(f"{i + 1}. {c['title']}{c['content']}" for i, c in enumerate(cards))
await run_single_slot(
ctx, "Small verify", key=f"blocks-{topic}-verify-small-{cards[0]['block_id']}",
prompt=_prompt("Blocks-Small", topic=topic, blocks=lines, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _small_schema(_json_file(p), len(cards)),
timeout=_timeout("selection_mapping", len(cards)))
verdict = _small_schema(_json_file(path), len(cards)) or {}
for i, c in enumerate(cards):
is_small = 1 if (c["is_small"] and verdict.get(i + 1)) else 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="dependency", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_dependency(ctx: GenContext, flow: _Flow, cards):
"""For each SMALL block, a judge picks its parent from the full list — batch runs IN PARALLEL."""
topic = flow.topic
parents = [b for b in await db.kanban_all_blocks(topic) if not b["is_small"] and b["stage"] != REJECTED]
plist = "\n".join(f"{i + 1}. {b['title']}" for i, b in enumerate(parents))
await asyncio.gather(*[_dep_one(ctx, flow, c, parents, plist) for c in cards], return_exceptions=True)
flow.wake.set()
async def _dep_one(ctx: GenContext, flow: _Flow, c, parents, plist):
topic = flow.topic
if not c["is_small"] or not parents:
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], "dependency_verify")
return
path = flow.work_dir / f"dep-{c['block_id']}.json"
await run_single_slot(
ctx, "Dependency", key=f"blocks-{topic}-dep-{c['block_id']}",
prompt=_prompt("Blocks-Dependency", topic=topic,
small=f"{c['title']}{c['content']}", parents=plist, out_path=path),
role="judge", capabilities="files",
payload=lambda result, p=path: _dep_schema(_json_file(p), len(parents)),
timeout=_timeout("selection_mapping", len(parents)))
pick = _dep_schema(_json_file(path), len(parents))
parent_id = parents[pick - 1]["block_id"] if pick else None
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="dependency_verify", is_small=c["is_small"], parent_block_id=parent_id)
async def _proc_dependency_verify(flow: _Flow, cards):
"""A small block without a parent is demarked → becomes a main block. → main."""
topic = flow.topic
for c in cards:
is_small = c["is_small"]
if is_small and not c.get("parent_block_id"):
is_small = 0
await db.kanban_upsert_block(topic, c["block_id"], c["chain_id"], c["title"], c["source"], c["content"],
stage="main", is_small=is_small, parent_block_id=c.get("parent_block_id"))
flow.wake.set()
async def _proc_main(flow: _Flow, cards):
"""BARRIER. Finalize non-small blocks → mirror into the legacy `blocks` table as consensus."""
topic = flow.topic
for c in cards:
if not c["is_small"]:
norm = _norm_title(c["title"])
await db.upsert_block(topic, norm, c["title"], c["content"], [c["source"]] if c["source"] else [])
await db.set_block_status(topic, norm, "consensus")
await db.kanban_advance(topic, "kanban_blocks", c["block_id"], DONE_BLOCK)
flow.wake.set()
# ── Orchestration ──────────────────────────────────────────────────────────────────
async def run_kanban(ctx: GenContext, set_p, files: dict, q: dict, folder, instructions: str,
research: bool = True) -> bool:
"""Run the streaming inventory. Returns True when the whole flow reaches quiescence at 'main'.
research=False ("Continue"): process the EXISTING queue without searching new titles. No initial
research producer, producers=0 → research_done is true at once; workers drain the queue and exit.
The +Research button can still attach an agent later via flow.spawn_research."""
topic = ctx.topic
flow = _Flow(topic, files["arbeit"])
flow.spawn_research = lambda: _extra_research(ctx, files, q, folder, instructions, flow)
if not research:
flow.producers = 0 # continue the existing queue, search no new titles
_active_flows[topic] = flow
set_p("Kanban inventory…")
ORDER = ["merge", "chain", "chain_verify", "naming", "naming_verify", "chain_filter",
"filter_verify", "block_assemble", "small_blocks", "small_verify",
"dependency", "dependency_verify", "main"]
up = {s: ORDER[:i] for i, s in enumerate(ORDER)} # upstream = all stages before this one
barriers = {"chain_filter", "block_assemble", "main"}
specs = [
("kanban_titles", "merge", lambda cs: _proc_merge(flow, cs)),
("kanban_titles", "chain", lambda cs: _proc_chain(flow, cs)),
("kanban_chains", "chain_verify", lambda cs: _proc_chain_verify(ctx, flow, cs)),
("kanban_chains", "naming", lambda cs: _proc_naming(ctx, flow, cs)),
("kanban_chains", "naming_verify", lambda cs: _proc_naming_verify(ctx, flow, cs)),
("kanban_chains", "chain_filter", lambda cs: _proc_chain_filter(flow, cs)),
("kanban_chains", "filter_verify", lambda cs: _proc_filter_verify(ctx, flow, cs)),
("kanban_chains", "block_assemble", lambda cs: _proc_block(flow, cs)),
("kanban_blocks", "small_blocks", lambda cs: _proc_small(ctx, flow, cs)),
("kanban_blocks", "small_verify", lambda cs: _proc_small_verify(ctx, flow, cs)),
("kanban_blocks", "dependency", lambda cs: _proc_dependency(ctx, flow, cs)),
("kanban_blocks", "dependency_verify", lambda cs: _proc_dependency_verify(flow, cs)),
("kanban_blocks", "main", lambda cs: _proc_main(flow, cs)),
]
workers = [_research(ctx, files, q, folder, instructions, flow)] if research else []
for table, stage, proc in specs:
workers.append(_worker(flow, table, stage, proc, up[stage], barrier=(stage in barriers),
inflight=(1 if stage in _SERIAL_STAGES else WORKER_INFLIGHT)))
progress = asyncio.create_task(_progress(flow, set_p))
try:
await asyncio.gather(*workers, return_exceptions=True)
finally:
flow.stop = True
progress.cancel()
_active_flows.pop(topic, None)
if ctx.is_cancelled():
return False
n = await db.kanban_count(topic, "kanban_blocks", DONE_BLOCK)
_log(topic, f"Kanban: done — {n} blocks finalized")
return True
async def _progress(flow: _Flow, set_p):
while not flow.stop:
try:
counts = await db.kanban_stage_counts(flow.topic)
total = sum(counts.values())
set_p(f"Kanban: {total} cards in flow")
except Exception:
pass
await asyncio.sleep(1.0)

View File

@@ -33,6 +33,7 @@ class BlocksCreateRequest(BaseModel):
ab_phase: int | None = Field(default=None, ge=1, le=9) # re-run from a coarse phase (position in _phasen(topic), 1-based; up to 9: …outline/questions/artifacts); None = resume/continue without deleting ab_phase: int | None = Field(default=None, ge=1, le=9) # re-run from a coarse phase (position in _phasen(topic), 1-based; up to 9: …outline/questions/artifacts); None = resume/continue without deleting
ab_step: int | None = Field(default=None, ge=0) # re-run from a fine sub-step (0-based index into _blocks_steps); takes precedence over ab_phase ab_step: int | None = Field(default=None, ge=0) # re-run from a fine sub-step (0-based index into _blocks_steps); takes precedence over ab_phase
to_step: int | None = Field(default=None, ge=0) # stop AFTER this fine sub-step (0-based index into _blocks_steps); None = run to the end to_step: int | None = Field(default=None, ge=0) # stop AFTER this fine sub-step (0-based index into _blocks_steps); None = run to the end
research: bool = True # False = "Continue": process the existing queue, search no new titles
class BlocksResetStepRequest(BaseModel): class BlocksResetStepRequest(BaseModel):

View File

@@ -167,7 +167,7 @@ _yesno_schema = _enum_map_schema("relevant", _YESNO) # triage gate
_MAX_RESTARTS = 2 _MAX_RESTARTS = 2
async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None) -> list | None: async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout: int, provider: str, on_update=None, cancelled=None, *, grace: int | None = None, min_runtime: int | None = None, max_runtime: int | None = None) -> list | None:
"""Starts all slots in parallel and collects `quorum` valid results. """Starts all slots in parallel and collects `quorum` valid results.
Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)` Slot spec: {key, prompt, role, capabilities, payload}. `payload(result)`
@@ -180,10 +180,18 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
a timer of `grace` seconds. After it expires, running agents are only a timer of `grace` seconds. After it expires, running agents are only
killed if the minimum stands — otherwise the race, including restarts, killed if the minimum stands — otherwise the race, including restarts,
keeps running until it stands. Returns: `quorum` to `len(slots)` results. keeps running until it stands. Returns: `quorum` to `len(slots)` results.
`min_runtime` (wall-clock from start): the race does not return before it
elapses while agents are still running — gives them time to search thoroughly.
`max_runtime` (wall-clock from start): hard cap — returns whatever is collected
(or None if nothing), killing the rest. Both default off; only Research sets them.
""" """
attempts = {i: 0 for i in range(len(slots))} attempts = {i: 0 for i in range(len(slots))}
tasks: dict[asyncio.Task, int] = {} tasks: dict[asyncio.Task, int] = {}
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
start = loop.time()
min_deadline = start + min_runtime if min_runtime else None
max_deadline = start + max_runtime if max_runtime else None
deadline: float | None = None deadline: float | None = None
def spawn(i: int) -> None: def spawn(i: int) -> None:
@@ -191,7 +199,7 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
task = asyncio.create_task(run_agent( task = asyncio.create_task(run_agent(
slot["key"], slot["prompt"], timeout, slot["key"], slot["prompt"], timeout,
provider=provider, role=slot["role"], capabilities=slot["capabilities"], provider=provider, role=slot["role"], capabilities=slot["capabilities"],
scope=topic, scope=topic, on_line=slot.get("on_line"),
)) ))
tasks[task] = i tasks[task] = i
@@ -203,12 +211,22 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
while tasks: while tasks:
if cancelled and cancelled(): if cancelled and cancelled():
return None return None
if deadline is not None and len(results) >= quorum and loop.time() >= deadline: # Hard wall-clock cap: return whatever we have (None if empty), kill the rest.
if max_deadline is not None and loop.time() >= max_deadline:
_log(topic, f"{label}: max runtime {max_runtime}s reached ({len(results)} valid)")
return results or None
min_ok = min_deadline is None or loop.time() >= min_deadline
if deadline is not None and len(results) >= quorum and loop.time() >= deadline and min_ok:
return results return results
# Grace set and minimum reached → only wait for the remaining deadline # Wake up for the earliest relevant deadline (grace, min, or max).
wait_timeout = None waits = []
if deadline is not None and len(results) >= quorum: if deadline is not None and len(results) >= quorum:
wait_timeout = max(0.0, deadline - loop.time()) waits.append(deadline - loop.time())
if min_deadline is not None:
waits.append(min_deadline - loop.time())
if max_deadline is not None:
waits.append(max_deadline - loop.time())
wait_timeout = max(0.0, min(waits)) if waits else None
done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout) done, _ = await asyncio.wait(tasks.keys(), return_when=asyncio.FIRST_COMPLETED, timeout=wait_timeout)
if not done: if not done:
continue continue
@@ -235,7 +253,8 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
_log(topic, f"{label}: first result — grace {grace}s running") _log(topic, f"{label}: first result — grace {grace}s running")
if on_update: if on_update:
on_update(len(results)) on_update(len(results))
if len(results) >= quorum and (grace is None or loop.time() >= deadline): if (len(results) >= quorum and (grace is None or loop.time() >= deadline)
and (min_deadline is None or loop.time() >= min_deadline)):
return results return results
continue continue
@@ -273,13 +292,13 @@ OK, CANCELLED, FAILED = "ok", "cancelled", "failed"
async def run_single_slot( async def run_single_slot(
ctx: GenContext, label: str, *, ctx: GenContext, label: str, *,
key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, key: str, prompt: str, role: str, capabilities: str, payload, timeout: int, on_line=None,
) -> tuple[str, object]: ) -> tuple[str, object]:
"""One agent, one valid result (race with quorum 1). """One agent, one valid result (race with quorum 1).
→ (OK, value) | (CANCELLED, None) | (FAILED, None) → (OK, value) | (CANCELLED, None) | (FAILED, None)
""" """
slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload}] slots = [{"key": key, "prompt": prompt, "role": role, "capabilities": capabilities, "payload": payload, "on_line": on_line}]
res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled) res = await _race(ctx.topic, label, slots, 1, timeout, ctx.provider, cancelled=ctx.is_cancelled)
if ctx.is_cancelled(): if ctx.is_cancelled():
return CANCELLED, None return CANCELLED, None

View File

@@ -164,7 +164,7 @@ async def create_blocks(req: BlocksCreateRequest):
raise HTTPException(400, "Link must start with http:// or https://.") raise HTTPException(400, "Link must start with http:// or https://.")
qp.parent.mkdir(parents=True, exist_ok=True) qp.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()}) atomic_write_json(qp, {"type": type, "location": location, "spec": req.instructions.strip()})
asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step, to_step=req.to_step)) asyncio.create_task(generate_blocks(topic, req.instructions.strip(), req.provider, ab_phase=req.ab_phase, ab_step=req.ab_step, to_step=req.to_step, research=req.research))
return {"ok": True} return {"ok": True}
@@ -231,6 +231,28 @@ async def get_blocks_uebersicht(topic: str):
return await load_overview(topic) return await load_overview(topic)
@router.get("/blocks/kanban")
async def get_kanban_board(topic: str):
"""Live card counts per kanban column (empty dict when the streaming inventory is not in use)."""
import database as db
return await db.kanban_stage_counts(topic)
@router.get("/blocks/agents")
async def get_active_agents(topic: str):
"""Currently running agents for this topic + their runtime (seconds). Label = key minus prefix."""
from agents import active_agents
prefix = f"blocks-{topic}-"
return [{"label": a["key"][len(prefix):], "runtime": a["runtime"]} for a in active_agents(prefix)]
@router.post("/blocks/research")
async def add_research(topic: str):
"""Attach one more research agent to the running kanban flow (live breadth boost)."""
from kanban import add_research_agent
return {"started": add_research_agent(topic)}
@router.get("/blocks/question-pattern") @router.get("/blocks/question-pattern")
async def get_question_pattern(topic: str, block: str): async def get_question_pattern(topic: str, block: str):
"""Unlocked question patterns of a block (up to the current level; empty = live).""" """Unlocked question patterns of a block (up to the current level; empty = live)."""

View File

@@ -224,12 +224,12 @@ async function handleResetFromStep(step) {
await loadBlocks() await loadBlocks()
} }
async function handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null }) { async function handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null, research = true }) {
if (!selectedTopic.value) return if (!selectedTopic.value) return
uiError.value = null uiError.value = null
try { try {
// Source is already fixed here; abPhase/abStep set the start, toStep an optional end limit. // Source is already fixed here; abPhase/abStep set the start, toStep an optional end limit.
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep, toStep) await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep, toStep, research)
} catch (e) { } catch (e) {
uiError.value = e.message uiError.value = e.message
return return
@@ -424,7 +424,7 @@ onMounted(async () => {
@close="mainView = 'detail'" @close="mainView = 'detail'"
@restartFrom="(r) => handleBlocksClick({ instructions: '', abStep: r.from, toStep: r.to })" @restartFrom="(r) => handleBlocksClick({ instructions: '', abStep: r.from, toStep: r.to })"
@resetFrom="handleResetFromStep" @resetFrom="handleResetFromStep"
@restartAll="() => handleBlocksClick({ abPhase: blocks.ready ? 1 : null })" @restartAll="(o) => handleBlocksClick({ research: o?.research ?? false })"
@removeAll="handleResetBlocks" @removeAll="handleResetBlocks"
@cancel="handleCancelBlocks" @cancel="handleCancelBlocks"
/> />

View File

@@ -47,11 +47,11 @@ export async function fetchBlocksStatus(topic) {
return res.json() return res.json()
} }
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null) { export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null, research = true) {
const res = await fetch(`${BASE}/blocks`, { const res = await fetch(`${BASE}/blocks`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep, to_step: toStep }), body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep, to_step: toStep, research }),
}) })
return jsonOrThrow(res) return jsonOrThrow(res)
} }
@@ -147,6 +147,24 @@ export async function fetchBlocksOverview(topic) {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
// Live card counts per kanban column ({} when the streaming inventory is not in use).
export async function fetchKanban(topic) {
const res = await fetch(`${BASE}/blocks/kanban?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Currently running agents for a topic + their runtime in seconds.
export async function fetchAgents(topic) {
const res = await fetch(`${BASE}/blocks/agents?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Attach one more research agent to the running kanban flow.
export async function addResearch(topic) {
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function cancelGuide(id) { export async function cancelGuide(id) {
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' }) await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
} }

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview } from '../api.js' import { fetchBlocksOverview, fetchKanban, fetchAgents, addResearch } from '../api.js'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -12,6 +12,39 @@ const props = defineProps({
}) })
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel']) const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
// Live kanban board (streaming inventory). Ordered columns + their card counts.
const KANBAN_COLS = [
['merge', 'Merge'], ['chain', 'Chain'], ['chain_verify', 'Verify'], ['naming', 'Naming'],
['naming_verify', 'Name✓'], ['chain_filter', 'Filter'], ['filter_verify', 'Filter✓'],
['block_assemble', 'Block'], ['small_blocks', 'Small'], ['small_verify', 'Small✓'],
['dependency', 'Dep'], ['dependency_verify', 'Dep✓'], ['main', 'Main'], ['done_block', 'Done'],
]
const kanban = ref({})
const agents = ref([])
const kanbanCols = computed(() => KANBAN_COLS.map(([k, label]) => ({ key: k, label, n: kanban.value[k] || 0 })))
const kanbanActive = computed(() => Object.values(kanban.value).some((n) => n > 0))
function fmtRuntime(s) {
const m = Math.floor(s / 60), sec = Math.floor(s % 60)
return `${m}:${String(sec).padStart(2, '0')}`
}
const researchBusy = ref(false)
async function moreResearch() {
researchBusy.value = true
try { await addResearch(props.topic) } catch { /* ignore */ }
setTimeout(() => { researchBusy.value = false }, 800) // brief debounce against double-clicks
}
let kanbanTimer = null
async function pollKanban() {
try { kanban.value = await fetchKanban(props.topic) } catch { /* ignore */ }
try { agents.value = await fetchAgents(props.topic) } catch { /* ignore */ }
}
watch(() => [props.topic, props.generating], () => {
clearInterval(kanbanTimer)
pollKanban()
if (props.generating) kanbanTimer = setInterval(pollKanban, 1000)
}, { immediate: true })
onUnmounted(() => clearInterval(kanbanTimer))
// Group sub-steps by phase, carrying the global index for the re-run. // Group sub-steps by phase, carrying the global index for the re-run.
const phaseGroups = computed(() => { const phaseGroups = computed(() => {
const out = [] const out = []
@@ -113,7 +146,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<div class="bk-steps-top"> <div class="bk-steps-top">
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div> <div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="bk-global-actions"> <div v-if="!generating" class="bk-global-actions">
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</button> <button class="bk-act play" @click="emit('restartAll', { research: false })" title="Process the existing queue — search no new topics">Continue</button>
<button class="bk-act play" @click="emit('restartAll', { research: true })" title="Start one research agent and process the queue">+ Research</button>
<button <button
v-if="ready || partial" v-if="ready || partial"
class="bk-act danger" class="bk-act danger"
@@ -122,9 +156,22 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button> >{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div> </div>
<div v-else class="bk-global-actions"> <div v-else class="bk-global-actions">
<button class="bk-act play" :disabled="researchBusy" @click="moreResearch" title="Start one more research agent">+ Research</button>
<button class="bk-act danger" @click="emit('cancel')">Cancel</button> <button class="bk-act danger" @click="emit('cancel')">Cancel</button>
</div> </div>
</div> </div>
<div v-if="generating || kanbanActive" class="bk-kanban">
<div v-for="c in kanbanCols" :key="c.key" class="bk-kcol" :class="{ 'bk-kactive': c.n > 0 }">
<span class="bk-kcount">{{ c.n }}</span>
<span class="bk-klabel">{{ c.label }}</span>
</div>
</div>
<div v-if="agents.length" class="bk-agents">
<span class="bk-agents-label">{{ agents.length }} Agenten aktiv:</span>
<span v-for="a in agents" :key="a.label" class="bk-agent">
{{ a.label }} <span class="bk-agent-time">{{ fmtRuntime(a.runtime) }}</span>
</span>
</div>
<div class="bk-phasen"> <div class="bk-phasen">
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase"> <div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
<span class="bk-phase-label">{{ g.phase }}</span> <span class="bk-phase-label">{{ g.phase }}</span>
@@ -274,6 +321,28 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
/* Header: progress left, global buttons right */ /* Header: progress left, global buttons right */
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; } .bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
.bk-steps-top .bk-progress { margin-bottom: 0; } .bk-steps-top .bk-progress { margin-bottom: 0; }
/* Live kanban board (streaming inventory) */
.bk-kanban { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-bottom: 0.7rem; }
.bk-kcol {
display: flex; flex-direction: column; align-items: center; gap: 1px;
min-width: 3.1rem; padding: 0.3rem 0.4rem;
border: 1px solid var(--border-strong); border-radius: 6px; background: var(--panel);
}
.bk-kcol.bk-kactive { border-color: var(--accent); background: var(--accent-soft); }
.bk-kcount { font-size: 0.95rem; font-weight: 700; color: var(--text); }
.bk-kactive .bk-kcount { color: var(--accent); }
.bk-klabel { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-faint); }
/* Running agents + live runtime */
.bk-agents { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.5rem; margin-bottom: 0.7rem; font-size: 0.78rem; }
.bk-agents-label { color: var(--text-muted); font-weight: 600; }
.bk-agent {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.12rem 0.5rem; border: 1px solid var(--accent); border-radius: 10px;
background: var(--accent-soft); color: var(--text);
}
.bk-agent-time { font-variant-numeric: tabular-nums; font-weight: 700; color: var(--accent); }
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; } .bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
/* Action bar for the selected start point */ /* Action bar for the selected start point */

View File

@@ -0,0 +1,17 @@
A small block for the topic "{topic}" may be a sub-topic of a bigger block. Pick the ONE block it belongs under — or 0 if it stands on its own after all.
SMALL BLOCK:
{small}
CANDIDATE PARENT BLOCKS:
{parents}
Rules:
- Pick the number of the block the small block is a detail/sub-step/property of.
- 0 = it is NOT a sub-topic of any of them (it is actually standalone).
- Only pick a parent if the small block clearly belongs INSIDE it.
Write ONLY the JSON file to: {out_path}
Format (the chosen parent number, or 0):
{{"parent": 0}}

View File

@@ -0,0 +1,23 @@
You are filtering the block inventory of a learning guide for the topic "{topic}". Judge the ONE block below.
BLOCK:
{title} — {content}
## Question
Is this a **valid, self-contained learning block** that genuinely belongs to "{topic}"?
- **keep = true** → a real, teachable concept of this topic: a method, definition, syntax element, problem, rule, or feature. The default.
- **keep = false** → drop it, ONLY if it clearly is one of:
- **Off-topic**: not actually about "{topic}" (a stray crawl artifact, a different subject).
- **Noise / meta**: navigation, "Table of Contents", "Dos and Don'ts", a tool/website name, a page section — not a concept you would learn.
- **Empty / degenerate**: title says nothing teachable, or the content is a non-statement.
## Rules
- When in doubt → **keep** (true). Only drop a CLEAR off-topic / noise / empty case.
- Judge by the CONTENT (after the "—"), not only the title.
- Do NOT drop something just because it is narrow or overlaps another block — that is handled elsewhere. Drop only off-topic / noise / empty.
Write ONLY the JSON file to: {out_path}
Format:
{{"keep": true}}

View File

@@ -0,0 +1,15 @@
The numbered entries below all describe the SAME block for the topic "{topic}". Entry number {current} was chosen as the canonical title. Check whether that is the best choice — if another entry is a clearly better canonical name, pick it instead.
MEMBERS:
{members}
Rules:
- Pick an EXISTING entry number — do NOT invent a title.
- Best = most concrete, precise, self-explanatory, established term for the shared concept.
- If the current choice ({current}) is already the best, return it unchanged.
- When in doubt, keep the current choice.
Write ONLY the JSON file to: {out_path}
Format (the best member number, nothing else):
{{"best": {current}}}

View File

@@ -0,0 +1,15 @@
The numbered entries below all describe the SAME block (concept) for the topic "{topic}", just worded differently. Pick the ONE entry whose title is the best canonical name for this block.
MEMBERS:
{members}
Rules:
- Pick an EXISTING entry — do NOT invent a new title or umbrella term.
- Prefer the most CONCRETE, precise, self-explanatory title for the shared concept.
- Prefer the established/standard term (correct spelling, full form over cryptic abbreviation) — but stay concrete, never over-general.
- Avoid reference/placeholder titles ("Satz 7.18", "Punkt 3", "(**)") if a meaningful one exists.
Write ONLY the JSON file to: {out_path}
Format (the chosen member number, nothing else):
{{"best": 1}}

View File

@@ -17,7 +17,8 @@ Rules:
- Write title and description in GERMAN (technical terms/code identifiers stay original). - Write title and description in GERMAN (technical terms/code identifiers stay original).
- Description at most ~12 words. - Description at most ~12 words.
Write ONLY the Markdown file to: {blocks_path} Write the Markdown file to: {blocks_path}
**Stream INCREMENTALLY — your output is read LIVE while you work.** The MOMENT you find a block: (1) print its line in your reply, AND (2) re-write the file with all blocks so far. One line per block, immediately, do NOT wait until the end. Cards appear as soon as a line lands.
Format: EXACTLY one line per block: `N. Title — Kurzbeschreibung — Source` Format: EXACTLY one line per block: `N. Title — Kurzbeschreibung — Source`
The source (3rd segment) MUST be the exact file name or URL of the crawl page the block comes from — it drives the coverage check. The source (3rd segment) MUST be the exact file name or URL of the crawl page the block comes from — it drives the coverage check.

View File

@@ -0,0 +1,15 @@
Below are block candidates for the topic "{topic}". For EACH, decide whether it is a STANDALONE learning block or a SMALL fragment.
BLOCKS:
{blocks}
Rules:
- small = true → a property, detail, sub-step or notation that only makes sense inside another block
(e.g. "install() method" belongs to "Plugin lifecycle"; "Knapsack ∈ NP" belongs to "Knapsack").
- small = false → a self-contained learning unit (its own problem/method/concept).
- When in doubt → false (keep as a main block).
Write ONLY the JSON file to: {out_path}
Format (each block number → true/false):
{{"small": {{"1": true, "2": false}}}}