update
This commit is contained in:
748
backend/kanban.py
Normal file
748
backend/kanban.py
Normal 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)
|
||||
Reference in New Issue
Block a user