update
This commit is contained in:
@@ -29,13 +29,14 @@ log = logging.getLogger("creator.agents")
|
||||
|
||||
_active_processes: dict[str, asyncio.subprocess.Process] = {}
|
||||
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display)
|
||||
_active_labels: dict[str, str] = {} # agent_key → human-readable label (for display + events)
|
||||
|
||||
|
||||
def active_agents(scope_prefix: str | None = None) -> list[dict]:
|
||||
"""Currently running agents and how long they've been running. Filter by key prefix
|
||||
(e.g. f"blocks-{topic}-") for one topic. → [{key, runtime}] sorted longest-first."""
|
||||
(e.g. f"blocks-{topic}-") for one topic. → [{key, label, runtime}] sorted longest-first."""
|
||||
now = time.time()
|
||||
out = [{"key": k, "runtime": round(now - t, 1)}
|
||||
out = [{"key": k, "label": _active_labels.get(k, ""), "runtime": round(now - t, 1)}
|
||||
for k, t in list(_active_started.items())
|
||||
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
|
||||
return sorted(out, key=lambda a: -a["runtime"])
|
||||
@@ -101,15 +102,19 @@ _interactive_sem = asyncio.Semaphore(MAX_CONCURRENT_INTERACTIVE)
|
||||
# per-topic queue can't undo the global priority when one topic is the only load.
|
||||
_topic_sems: dict[str, _PrioritySemaphore] = {}
|
||||
|
||||
# Earlier kanban columns get the scarce global slot first (smaller = higher priority).
|
||||
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter", "grouping")
|
||||
# Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
|
||||
# Within board 2 the LATE stages win (outline → artefacts → … → subblocks): finish cards
|
||||
# instead of opening new WIP, so the makespan tail block gets slots before fresh work.
|
||||
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter",
|
||||
"grouping", "supplement", "outline", "artifact", "question", "relevance",
|
||||
"level", "facts", "subblock")
|
||||
|
||||
|
||||
def _agent_priority(key: str) -> int:
|
||||
for i, tag in enumerate(_STAGE_PRIORITY):
|
||||
if f"-{tag}-" in key or key.endswith(f"-{tag}"):
|
||||
return i
|
||||
return len(_STAGE_PRIORITY) # downstream agents (subblocks/facts/…) after the inventory columns
|
||||
return len(_STAGE_PRIORITY) # unmatched keys (guide board, …) after everything
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -145,6 +150,8 @@ async def _opencode_slot() -> None:
|
||||
_opencode_next_start = start_at + _OPENCODE_START_DELAY
|
||||
await asyncio.sleep(max(0.0, start_at - now))
|
||||
|
||||
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
|
||||
|
||||
# Capability → Claude --allowedTools
|
||||
_CLAUDE_TOOLS = {
|
||||
"full": "Write,Bash,Read,WebSearch,WebFetch",
|
||||
@@ -220,6 +227,11 @@ def kill_process(agent_key_prefix: str) -> None:
|
||||
_kill(process)
|
||||
|
||||
|
||||
# Event sink for the pipeline history (injected by main.py lifespan as database.add_event —
|
||||
# agents.py stays DB-free). Called fire-and-forget for every finished BATCH agent.
|
||||
on_event = None
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_key: str,
|
||||
prompt: str,
|
||||
@@ -230,6 +242,7 @@ async def run_agent(
|
||||
lane: str = "batch",
|
||||
scope: str | None = None,
|
||||
on_line=None,
|
||||
label: str = "",
|
||||
) -> tuple[int, str, str]:
|
||||
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
|
||||
return 1, "", "cancelled"
|
||||
@@ -243,17 +256,47 @@ async def run_agent(
|
||||
return 1, "", f"No model for role '{role}' (provider: {provider})"
|
||||
if shutil.which(PROVIDERS[provider]["cli"]) is None:
|
||||
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
|
||||
queued = time.monotonic()
|
||||
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
|
||||
async with gate:
|
||||
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
|
||||
return 1, "", "cancelled"
|
||||
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
return await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line)
|
||||
return await _run_claude_cli(agent_key, prompt, timeout, model, capabilities)
|
||||
wait_ms = int((time.monotonic() - queued) * 1000)
|
||||
start = time.monotonic()
|
||||
status = "error"
|
||||
rc = None
|
||||
err_tail = ""
|
||||
try:
|
||||
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role)
|
||||
if PROVIDERS[provider]["cli"] == "opencode":
|
||||
res = await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line, label=label)
|
||||
else:
|
||||
res = await _run_claude_cli(agent_key, prompt, timeout, model, capabilities, label=label)
|
||||
rc = res[0]
|
||||
status = "ok" if rc == 0 else ("killed" if rc is not None and rc < 0 else "error")
|
||||
if rc not in (0, None) and rc >= 0:
|
||||
err_tail = (res[2] or res[1] or "").strip()[-300:] # diagnosis: rc=1 without stderr is opaque
|
||||
return res
|
||||
except asyncio.TimeoutError:
|
||||
status = "timeout"
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
status = "cancelled"
|
||||
raise
|
||||
finally:
|
||||
if on_event is not None and scope is not None: # batch pipeline only, never fatal
|
||||
try:
|
||||
meta = {"provider": provider, "model": model, "role": role, "rc": rc}
|
||||
if err_tail:
|
||||
meta["stderr"] = err_tail
|
||||
await on_event(topic=scope, kind="agent", key=agent_key, label=label,
|
||||
status=status, dur_ms=int((time.monotonic() - start) * 1000),
|
||||
wait_ms=wait_ms, meta=meta)
|
||||
except Exception:
|
||||
log.debug("on_event failed", exc_info=True)
|
||||
|
||||
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None) -> tuple[int, str, str]:
|
||||
async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None, timeout: int, stagger: bool = False, on_line=None, label: str = "", env: dict | None = None) -> tuple[int, str, str]:
|
||||
start = time.monotonic()
|
||||
|
||||
async def spawn():
|
||||
@@ -263,6 +306,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True, # own process group → killpg also kills child processes
|
||||
env=env,
|
||||
)
|
||||
|
||||
if stagger:
|
||||
@@ -277,6 +321,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
n += 1
|
||||
_active_processes[track_key] = process
|
||||
_active_started[track_key] = time.time()
|
||||
_active_labels[track_key] = label
|
||||
try:
|
||||
try:
|
||||
if on_line is not None:
|
||||
@@ -319,19 +364,20 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
|
||||
if _active_processes.get(track_key) is process:
|
||||
del _active_processes[track_key]
|
||||
_active_started.pop(track_key, None)
|
||||
_active_labels.pop(track_key, None)
|
||||
|
||||
|
||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str) -> tuple[int, str, str]:
|
||||
async def _run_claude_cli(agent_key: str, prompt: str, timeout: int, model: str, capabilities: str, label: str = "") -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS["claude"]
|
||||
cmd = [cfg["cli"], "-p", "--model", model]
|
||||
tools = _CLAUDE_TOOLS.get(capabilities)
|
||||
if tools:
|
||||
cmd += ["--allowedTools", tools]
|
||||
cmd += ["--dangerously-skip-permissions"]
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout)
|
||||
return await _communicate(agent_key, cmd, prompt.encode("utf-8"), timeout, label=label)
|
||||
|
||||
|
||||
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None) -> tuple[int, str, str]:
|
||||
async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str, model: str, capabilities: str, on_line=None, label: str = "") -> tuple[int, str, str]:
|
||||
cfg = PROVIDERS[provider]
|
||||
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
|
||||
@@ -349,8 +395,14 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
|
||||
]
|
||||
if on_line is not None:
|
||||
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line
|
||||
env = None
|
||||
if capabilities != "full":
|
||||
# Batch agents (files/readonly/text) never use the web MCPs, but opencode starts
|
||||
# every configured MCP server PER PROCESS (~3 procs / ~300 MB each). Point them
|
||||
# at the mcp-free config copy; only `full` (research/supplement) keeps the servers.
|
||||
env = {**os.environ, "OPENCODE_CONFIG": str(_SLIM_CONFIG)}
|
||||
try:
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line)
|
||||
rc, stdout, stderr = await _communicate(agent_key, cmd, None, timeout, stagger=True, on_line=on_line, label=label, env=env)
|
||||
return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
|
||||
finally:
|
||||
prompt_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
import database as db
|
||||
import embedding
|
||||
from agents import kill_process, cancel_scope, clear_scope, run_agent
|
||||
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR
|
||||
from config import CONSENSUS_GRACE, CONSENSUS_MAX_ROUNDS, DEFAULT_PROVIDER, CRAWL_KEEP_PATTERNS, CRAWL_NOISE_PATTERNS, CRAWL_MIN_CHARS, QUELLE_RELEVANZ_CHUNK, QUELLE_RELEVANZ_SNIPPET, EMBEDDING_AKTIV, EMBEDDING_SUB_DUP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_SIBLING_FLOOR, EMBEDDING_SIBLING_CAP, GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, SUB_VARIANT_COS, SEED_COVER_COS
|
||||
from fsutil import atomic_write_text, atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
|
||||
@@ -53,6 +53,10 @@ RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema", no crawl folder)
|
||||
RESEARCH_SECTION_CHARS = 12000
|
||||
# Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*).
|
||||
SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
|
||||
SUBBLOCK_MIN = 5 # below this consensus count a block gets focused catch-up rounds
|
||||
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds (saturation stop still applies — thin stays thin)
|
||||
SUBBLOCK_MAX_ROUNDS = 5 # hard round cap: measured runs hit 5–9 rounds purely on paraphrases
|
||||
# before the variant-robust `new` count converges — never search longer
|
||||
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only
|
||||
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
|
||||
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
|
||||
@@ -61,7 +65,10 @@ FILTER_CHUNK = 35 # blocks to assess per judge in the degrade pass (fu
|
||||
# Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
|
||||
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
|
||||
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % per chunk)
|
||||
FACTS_CHUNK_SUBS = 25 # facts extraction: smaller chunks (facts are bulkier than patterns)
|
||||
FACTS_CHUNK_SUBS = 10 # facts extraction: small chunks — the 4 phases (find/erg/check/fix) are
|
||||
# serial PER CHUNK, so chunk count = parallelism; the makespan tail of a
|
||||
# late block is bounded by ONE chunk's phase chain, not the whole block
|
||||
ARTEFACT_CHUNK_SUBS = 25 # flashcards/examples: bulk generation, phases are cheap → bigger packages
|
||||
FACTS_CHECK_PANEL = 3 # judges per chunk in the facts check (majority objects)
|
||||
CONSOLIDATION_PANEL = 3 # mapping judges per chunk (panel → reconcile instead of a single judge)
|
||||
SUBBLOCK_PANEL = 3 # source judges in the subblock clarification (majority instead of a single judge)
|
||||
@@ -556,10 +563,51 @@ def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]:
|
||||
|
||||
|
||||
|
||||
_NEG_TOKENS = {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie"}
|
||||
|
||||
|
||||
def _neg_set(title: str) -> frozenset:
|
||||
"""Negation tokens of a title — antonym statements measure cos 0.91–0.95 (above any usable
|
||||
variant threshold), so equal negation sets are a hard merge precondition."""
|
||||
return frozenset(t for t in re.findall(r"\w+", _norm_title(title)) if t in _NEG_TOKENS)
|
||||
|
||||
|
||||
def _sub_tokens(title: str) -> set:
|
||||
return set(re.findall(r"\w+", _norm_title(title)))
|
||||
|
||||
|
||||
def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict]:
|
||||
"""Fold phrasing VARIANTS of one concept BEFORE the consensus count: finders rephrase per
|
||||
round, so exact-norm counting starves real concepts. Union-find over cos ≥ SUB_VARIANT_COS
|
||||
with the negation guard. → [{"rep": idx, "members": [idx…], "mentions": sum}]."""
|
||||
n = len(titles)
|
||||
parent = list(range(n))
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
negs = [_neg_set(t) for t in titles]
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
if float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]:
|
||||
parent[find(i)] = find(j)
|
||||
groups: dict[int, list[int]] = {}
|
||||
for i in range(n):
|
||||
groups.setdefault(find(i), []).append(i)
|
||||
return [{"rep": max(g, key=lambda k: (len(titles[k]), -k)), "members": g,
|
||||
"mentions": sum(mentions[k] for k in g)} for g in groups.values()]
|
||||
|
||||
|
||||
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str,
|
||||
wipe: bool = True, ns: str = "") -> dict | None:
|
||||
wipe: bool = True, ns: str = "", seeds: list[str] | None = None,
|
||||
lbl: str = "") -> dict | None:
|
||||
"""Block B (DB + loop): per package, find subblocks in rounds (3 finders, until 0 new/cap),
|
||||
collect in the DB (≥2 mentions = consensus, 1× discarded), a judge cleans up per package.
|
||||
collect in the DB (variant-clustered mentions ≥2 = consensus), a judge panel cleans up per
|
||||
package; blocks below SUBBLOCK_MIN get focused catch-up rounds; `seeds` (demoted fragment
|
||||
titles, single-block kanban calls) are guaranteed to reach the facts evidence gate.
|
||||
→ {block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`.
|
||||
wipe=False (kanban board: one call per block) keeps the other blocks' rows."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
@@ -574,8 +622,12 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
n = len(chunks)
|
||||
title_by_num = {num: _title(entries[num]) for num in nums}
|
||||
norm_by_num = {num: _norm_title(title_by_num[num]) for num in nums}
|
||||
emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available)
|
||||
if wipe:
|
||||
await db.delete_subblocks(topic) # fresh start of the block (idempotent counter)
|
||||
else:
|
||||
for num in nums: # per-block wipe: a re-spawned card must not accumulate mentions
|
||||
await db.delete_subblocks(topic, norm_by_num[num])
|
||||
|
||||
async def _known_block(chunk):
|
||||
known = []
|
||||
@@ -589,48 +641,82 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
# self-bias/echo) — only add what's missing. This keeps the counter an honest consensus signal.
|
||||
return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known))
|
||||
|
||||
# ONE finder round (3 slots, quorum 2) → count of NEW sub norms; None = no result/cancel.
|
||||
async def _one_round(label, subset, assignment, paths, keys, known, extra_instr):
|
||||
chunk_idx = _title_index({num: title_by_num[num] for num in subset})
|
||||
for p in paths:
|
||||
p.unlink(missing_ok=True)
|
||||
slots = [{
|
||||
"key": k,
|
||||
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=known, out_path=p, extra=_extra(extra_instr)),
|
||||
"role": "quick", "capabilities": caps,
|
||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||
} for k, p in zip(keys, paths)]
|
||||
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled() or not agent_texts:
|
||||
return None
|
||||
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
|
||||
existing = {num: {s["sub_norm"] for s in rows_before[num]} for num in subset}
|
||||
fresh: dict[int, list[str]] = {}
|
||||
for d in agent_texts:
|
||||
for marker, subs in d.items():
|
||||
num = _resolve_title(chunk_idx, marker)
|
||||
if num is None:
|
||||
continue
|
||||
seen_set = set()
|
||||
for sub in subs:
|
||||
sn = _norm_title(sub)
|
||||
if not sn or sn in seen_set:
|
||||
continue
|
||||
seen_set.add(sn)
|
||||
if sn not in existing[num]:
|
||||
existing[num].add(sn)
|
||||
fresh.setdefault(num, []).append(sub)
|
||||
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
|
||||
# "New" is variant-robust: a paraphrase of an existing sub (or of another fresh find)
|
||||
# still gets stored above (its mention feeds the cluster consensus), but it must not
|
||||
# keep the saturation loop spinning — finders rephrase every round (measured: 5–9
|
||||
# rounds without this fold). Model off → exact counting (status quo).
|
||||
new = 0
|
||||
for num, cands in fresh.items():
|
||||
sims = None
|
||||
base = [s["sub_title"] for s in rows_before[num]]
|
||||
if emb_on and base + cands:
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, base + cands)
|
||||
if sims is None:
|
||||
new += len(cands)
|
||||
continue
|
||||
negs = [_neg_set(t) for t in base + cands]
|
||||
nb = len(base)
|
||||
kept: list[int] = []
|
||||
for i in range(nb, nb + len(cands)):
|
||||
dup = any(float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]
|
||||
for j in [*range(nb), *kept])
|
||||
if not dup:
|
||||
kept.append(i)
|
||||
new += len(kept)
|
||||
return new
|
||||
|
||||
# Phase "Subblocks find": per package loop until 0 new subs / time cap.
|
||||
async def _find(c, chunk):
|
||||
assignment = "\n".join(f"- {entries[num]}" for num in chunk)
|
||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
||||
start = time.monotonic()
|
||||
round_n = 0
|
||||
while not is_cancelled():
|
||||
round_n += 1
|
||||
bekannt = await _known_block(chunk) if round_n > 1 else ""
|
||||
paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)]
|
||||
for p in paths:
|
||||
p.unlink(missing_ok=True)
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}",
|
||||
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=bekannt, out_path=p, extra=_extra(instructions)),
|
||||
"role": "quick", "capabilities": caps,
|
||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||
} for i, p in enumerate(paths, 1)]
|
||||
agent_texts = await _race(topic, f"Subblocks package {c} R{round_n}", slots, 2, _timeout("subblock", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled():
|
||||
return False
|
||||
if not agent_texts:
|
||||
keys = [f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}" for i in (1, 2, 3)]
|
||||
new = await _one_round(f"{lbl}Subblocks package {c} R{round_n}", chunk, assignment, paths, keys, bekannt, instructions)
|
||||
if new is None:
|
||||
if is_cancelled():
|
||||
return False
|
||||
return round_n > 1 # round 1 without result = error; later = simply the end
|
||||
existing = {num: {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])} for num in chunk}
|
||||
new = 0
|
||||
for d in agent_texts:
|
||||
for marker, subs in d.items():
|
||||
num = _resolve_title(chunk_idx, marker)
|
||||
if num is None:
|
||||
continue
|
||||
seen_set = set()
|
||||
for sub in subs:
|
||||
sn = _norm_title(sub)
|
||||
if not sn or sn in seen_set:
|
||||
continue
|
||||
seen_set.add(sn)
|
||||
if sn not in existing[num]:
|
||||
new += 1
|
||||
existing[num].add(sn)
|
||||
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
|
||||
if new == 0:
|
||||
break
|
||||
if round_n >= SUBBLOCK_MAX_ROUNDS:
|
||||
_log(topic, f"Subblocks package {c}: round cap reached ({round_n})")
|
||||
break
|
||||
if time.monotonic() - start > SUBBLOCK_CAP:
|
||||
_log(topic, f"Subblocks package {c}: time cap reached (round {round_n})")
|
||||
break
|
||||
@@ -643,49 +729,117 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
_blocks_errors[topic] = "Subblocks failed (research)"
|
||||
return None
|
||||
|
||||
# Phase "Subblocks select": ≥2 mentions = consensus, 1× discarded (code).
|
||||
# Phase "Subblocks select": variant-clustered mentions ≥2 = consensus — the cluster
|
||||
# representative carries the status, folded members become `variant` (NOT discarded:
|
||||
# the clarify panel's uncertain group must not re-list them). Model off → exact counter.
|
||||
async def _select(subset, keep_consensus=False):
|
||||
for num in subset:
|
||||
rows = await db.list_subblocks(topic, norm_by_num[num])
|
||||
clusters = None
|
||||
if emb_on and len(rows) >= 2:
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [r["sub_title"] for r in rows])
|
||||
if sims is not None:
|
||||
clusters = _variant_clusters([r["sub_title"] for r in rows],
|
||||
[r["mentions"] for r in rows], sims)
|
||||
if clusters is None:
|
||||
for r in rows:
|
||||
if keep_consensus and r["status"] == "consensus":
|
||||
continue
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], r["sub_norm"],
|
||||
status=("consensus" if r["mentions"] >= 2 else "discarded"))
|
||||
continue
|
||||
for cl in clusters:
|
||||
# a re-select (catch-up) never demotes panel-confirmed subs — an existing
|
||||
# consensus member stays the representative, new variants fold under it.
|
||||
kept = [k for k in cl["members"] if keep_consensus and rows[k]["status"] == "consensus"]
|
||||
for k in cl["members"]:
|
||||
if kept:
|
||||
st = "consensus" if k in kept else "variant"
|
||||
elif cl["mentions"] >= 2:
|
||||
st = "consensus" if k == cl["rep"] else "variant"
|
||||
else:
|
||||
st = "discarded"
|
||||
if keep_consensus and rows[k]["status"] == "consensus" and st != "consensus":
|
||||
continue
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], rows[k]["sub_norm"], status=st)
|
||||
|
||||
set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select"))
|
||||
for num in nums:
|
||||
for s in await db.list_subblocks(topic, norm_by_num[num]):
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
|
||||
status=("consensus" if s["mentions"] >= 2 else "discarded"))
|
||||
await _select(nums)
|
||||
|
||||
# Judge formulation → shown candidate (best cos ≥ SUB_VARIANT_COS, negation-guarded).
|
||||
# Judges demonstrably paraphrase; without canonicalizing, the exact-norm majority vote
|
||||
# splinters across formulations (measured: 0.993-duplicates in a final list).
|
||||
async def _canon_map(shown: list[str], judge_titles: list[str]) -> dict[str, tuple[str, str]]:
|
||||
if not emb_on or not shown or not judge_titles:
|
||||
return {}
|
||||
texts = shown + list(judge_titles)
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, texts)
|
||||
if sims is None:
|
||||
return {}
|
||||
negs = [_neg_set(t) for t in texts]
|
||||
m: dict[str, tuple[str, str]] = {}
|
||||
for a in range(len(shown), len(texts)):
|
||||
best, bv = None, 0.0
|
||||
for b in range(len(shown)):
|
||||
v = float(sims[a][b])
|
||||
if v >= SUB_VARIANT_COS and v > bv and negs[a] == negs[b]:
|
||||
best, bv = b, v
|
||||
if best is not None:
|
||||
m[_norm_title(texts[a])] = (_norm_title(shown[best]), shown[best])
|
||||
return m
|
||||
|
||||
# Phase "Subblocks clarify": source panel (SUBBAUSTEIN_PANEL judges) checks consensus + uncertain (1×)
|
||||
# against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo.
|
||||
async def _clarify(c, chunk):
|
||||
fp = work_dir / f"subblock-final-c{c}.md"
|
||||
async def _clarify(c, chunk, tag=""):
|
||||
fp = work_dir / f"subblock-final-c{c}{tag}.md"
|
||||
if _parse_subblocks(_read(fp)):
|
||||
return
|
||||
block_texts, has_any = [], False
|
||||
consensus_by_num: dict[int, list[str]] = {}
|
||||
shown_by_num: dict[int, list[str]] = {}
|
||||
for num in chunk:
|
||||
rows = await db.list_subblocks(topic, norm_by_num[num])
|
||||
consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"]
|
||||
uncertain = [s["sub_title"] for s in rows if s["status"] != "consensus" and s["mentions"] == 1]
|
||||
# folded variants (status `variant`) are already counted — only true singles are uncertain
|
||||
uncertain = [s["sub_title"] for s in rows if s["status"] == "discarded" and s["mentions"] == 1]
|
||||
consensus_by_num[num] = consensus_subs
|
||||
shown_by_num[num] = consensus_subs + uncertain
|
||||
if not consensus_subs and not uncertain:
|
||||
continue
|
||||
has_any = True
|
||||
k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)"
|
||||
u_lines = "\n".join(f"- {s}" for s in uncertain) if uncertain else "- (keiner)"
|
||||
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}")
|
||||
band = ""
|
||||
shown = shown_by_num[num]
|
||||
if emb_on and len(shown) >= 2: # near-dup pairs BELOW the fold threshold → explicit panel hint
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, shown)
|
||||
if sims is not None:
|
||||
pairs = [f"- „{shown[i]}“ ↔ „{shown[j]}“"
|
||||
for i in range(len(shown)) for j in range(i + 1, len(shown))
|
||||
if 0.75 <= float(sims[i][j]) < SUB_VARIANT_COS]
|
||||
if pairs:
|
||||
band = ("\nMögliche Duplikate — prüfen und ggf. zu EINEM Eintrag zusammenführen:\n"
|
||||
+ "\n".join(pairs[:12]))
|
||||
block_texts.append(f"BLOCK: {title_by_num[num]}\nKonsens (≥2 finders):\n{k_lines}\nUnsicher (1× — streng gegen Source check):\n{u_lines}{band}")
|
||||
if not has_any:
|
||||
return
|
||||
|
||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
||||
paths = [work_dir / f"subblock-final-c{c}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
|
||||
pending = [(j, p) for j, p in enumerate(paths, 1) if _parse_subblocks(_read(p)) is None]
|
||||
paths = [work_dir / f"subblock-final-c{c}{tag}-j{j}.md" for j in range(1, SUBBLOCK_PANEL + 1)]
|
||||
# truthiness, NOT `is None`: a missing file parses to {} — with `is None` the whole
|
||||
# panel silently never ran (fallback adopted the raw consensus unchecked).
|
||||
pending = [(j, p) for j, p in enumerate(paths, 1) if not _parse_subblocks(_read(p))]
|
||||
for _, p in pending:
|
||||
p.unlink(missing_ok=True)
|
||||
if pending:
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-{ns}subblock-final-c{c}-j{j}",
|
||||
"key": f"blocks-{topic}-{ns}subblock-final-c{c}{tag}-j{j}",
|
||||
"prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
|
||||
"role": "judge", "capabilities": caps,
|
||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||
} for j, p in pending]
|
||||
existing = SUBBLOCK_PANEL - len(pending)
|
||||
await _race(topic, f"Subblock-Clarification {c}", slots, max(1, 2 - existing),
|
||||
await _race(topic, f"{lbl}Subblock-Clarification {c}", slots, max(1, 2 - existing),
|
||||
_timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled():
|
||||
return
|
||||
@@ -698,22 +852,34 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
return
|
||||
|
||||
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep).
|
||||
# Votes are canonicalized onto the shown candidates first (paraphrase-robust).
|
||||
block_texts_out = []
|
||||
for num in chunk:
|
||||
raw_votes: list[list[str]] = []
|
||||
for d in outs:
|
||||
subs_of_num: list[str] = []
|
||||
for marker, subs in d.items():
|
||||
if _resolve_title(chunk_idx, marker) == num:
|
||||
subs_of_num.extend(subs)
|
||||
raw_votes.append(subs_of_num)
|
||||
judge_titles = list(dict.fromkeys(s for subs in raw_votes for s in subs
|
||||
if _norm_title(s) not in {_norm_title(t) for t in shown_by_num[num]}))
|
||||
canon = await _canon_map(shown_by_num[num], judge_titles)
|
||||
votes: dict[str, int] = {}
|
||||
form: dict[str, str] = {}
|
||||
for d in outs:
|
||||
for subs_of_num in raw_votes:
|
||||
seen = set()
|
||||
for marker, subs in d.items():
|
||||
if _resolve_title(chunk_idx, marker) != num:
|
||||
for sub in subs_of_num:
|
||||
sn = _norm_title(sub)
|
||||
if not sn:
|
||||
continue
|
||||
for sub in subs:
|
||||
sn = _norm_title(sub)
|
||||
if not sn or sn in seen:
|
||||
continue
|
||||
seen.add(sn)
|
||||
form.setdefault(sn, sub)
|
||||
votes[sn] = votes.get(sn, 0) + 1
|
||||
if sn in canon:
|
||||
sn, sub = canon[sn]
|
||||
if sn in seen:
|
||||
continue
|
||||
seen.add(sn)
|
||||
form.setdefault(sn, sub)
|
||||
votes[sn] = votes.get(sn, 0) + 1
|
||||
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
|
||||
if kept:
|
||||
block_texts_out.append(f"<!-- block: {title_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in kept))
|
||||
@@ -725,8 +891,9 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
|
||||
# Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw.
|
||||
raw: dict[str, list[str]] = {}
|
||||
for c, chunk in enumerate(chunks, 1):
|
||||
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}.md")) or {}
|
||||
|
||||
async def _align(c, chunk, tag=""):
|
||||
final = _parse_subblocks(_read(work_dir / f"subblock-final-c{c}{tag}.md")) or {}
|
||||
chunk_idx = _title_index({num: title_by_num[num] for num in chunk})
|
||||
final_by_num = {_resolve_title(chunk_idx, m): subs for m, subs in final.items() if _resolve_title(chunk_idx, m) is not None}
|
||||
for num in chunk:
|
||||
@@ -740,14 +907,83 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
final_norms = {_norm_title(s) for s in subs}
|
||||
have = {s["sub_norm"] for s in await db.list_subblocks(topic, norm_by_num[num])}
|
||||
for s in await db.list_subblocks(topic, norm_by_num[num]):
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"],
|
||||
status=("consensus" if s["sub_norm"] in final_norms else "discarded"))
|
||||
if s["sub_norm"] in final_norms:
|
||||
st = "consensus"
|
||||
elif s["status"] == "variant":
|
||||
st = "variant" # folded members stay marked — a catch-up clarify must not
|
||||
else: # re-list them as "uncertain singles"
|
||||
st = "discarded"
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], s["sub_norm"], status=st)
|
||||
for s in subs:
|
||||
sn = _norm_title(s)
|
||||
if sn and sn not in have:
|
||||
await db.upsert_subblock(topic, norm_by_num[num], sn, title, s)
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], sn, status="consensus")
|
||||
|
||||
for c, chunk in enumerate(chunks, 1):
|
||||
await _align(c, chunk)
|
||||
|
||||
# Minimum catch-up: a block below SUBBLOCK_MIN gets up to SUBBLOCK_EXTRA_ROUNDS focused
|
||||
# finder rounds. Saturation stop stays — a thin block REMAINS thin if nothing verifiable.
|
||||
async def _catchup(c, chunk):
|
||||
for k in range(1, SUBBLOCK_EXTRA_ROUNDS + 1):
|
||||
lacking = [num for num in chunk if len(raw.get(title_by_num[num]) or []) < SUBBLOCK_MIN]
|
||||
if not lacking or is_cancelled():
|
||||
return
|
||||
assignment = "\n".join(f"- {entries[num]}" for num in lacking)
|
||||
known = await _known_block(lacking) # ALL rows of the block, incl. variants/discarded
|
||||
focus = (instructions + "\n\nDieser Block hat bisher nur sehr wenige belegte "
|
||||
"Subbausteine. Suche gezielt nach WEITEREN belegbaren Kernaspekten, die "
|
||||
"oben fehlen. Nimm NUR auf, was die Quellen wirklich hergeben — nicht aufblähen.")
|
||||
paths = [work_dir / f"subblock-x{k}-c{c}-{i}.md" for i in (1, 2, 3)]
|
||||
keys = [f"blocks-{topic}-{ns}subblock-x{k}-c{c}-{i}" for i in (1, 2, 3)]
|
||||
new = await _one_round(f"{lbl}Subblocks catch-up {c} X{k}", lacking, assignment, paths, keys, known, focus)
|
||||
if not new:
|
||||
return
|
||||
await _select(lacking, keep_consensus=True)
|
||||
await _clarify(c, chunk, tag=f"-x{k}")
|
||||
if is_cancelled():
|
||||
return
|
||||
await _align(c, chunk, tag=f"-x{k}")
|
||||
|
||||
# progress reuses the clarify step label — catch-up has no own registry entry
|
||||
await _gather_progress([_catchup(c, chunk) for c, chunk in enumerate(chunks, 1)], n,
|
||||
_report_p(set_p, topic, "Subblocks clarify"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM)
|
||||
|
||||
# Seed guarantee (single-block kanban calls): every demoted-fragment seed must reach the
|
||||
# facts evidence gate — covered by a consensus sub, promoted from a single find, or
|
||||
# inserted as its own sub. Unverifiable seeds die at the facts discard, not silently here.
|
||||
for num in (nums if seeds else []):
|
||||
title = title_by_num[num]
|
||||
for seed in dict.fromkeys(s for s in seeds if s):
|
||||
st = _sub_tokens(seed)
|
||||
have = raw.get(title) or []
|
||||
if not st or any(st <= _sub_tokens(s) for s in have):
|
||||
continue
|
||||
if emb_on and have: # embedding backup for rephrased covers (lexical is primary)
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + have)
|
||||
if sims is not None and max(float(sims[0][j]) for j in range(1, len(have) + 1)) >= SEED_COVER_COS:
|
||||
continue
|
||||
rows = [r for r in await db.list_subblocks(topic, norm_by_num[num]) if r["status"] != "consensus"]
|
||||
cand = next((r for r in rows if st <= _sub_tokens(r["sub_title"])), None)
|
||||
if cand is None and emb_on and rows:
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [seed] + [r["sub_title"] for r in rows])
|
||||
if sims is not None:
|
||||
j = max(range(1, len(rows) + 1), key=lambda x: float(sims[0][x]))
|
||||
if float(sims[0][j]) >= SEED_COVER_COS and _neg_set(seed) == _neg_set(rows[j - 1]["sub_title"]):
|
||||
cand = rows[j - 1]
|
||||
if cand is not None:
|
||||
await db.set_subblock_fields(topic, norm_by_num[num], cand["sub_norm"], status="consensus")
|
||||
raw.setdefault(title, []).append(cand["sub_title"])
|
||||
_log(topic, f"Seed „{seed}“: Einzelfund „{cand['sub_title']}“ übernommen ({title})")
|
||||
elif (sn := _norm_title(seed)):
|
||||
await db.put_subblock(topic, norm_by_num[num], sn, title, seed, status="consensus")
|
||||
raw.setdefault(title, []).append(seed)
|
||||
_log(topic, f"Seed „{seed}“ als Subbaustein eingefügt ({title}) — Facts-Gate prüft")
|
||||
|
||||
if not raw:
|
||||
# Finders ran but nothing survived the consensus/evidence gates: a legitimately
|
||||
# thin block (e.g. a bare named reduction). {} = done-without-subs — the guide
|
||||
@@ -772,8 +1008,9 @@ async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None:
|
||||
return
|
||||
keepers: list[int] = []
|
||||
discarded: list[int] = []
|
||||
negs = [_neg_set(s) for s in subs]
|
||||
for i in sorted(range(len(subs)), key=lambda x: (-len(subs[x]), x)): # most informative first
|
||||
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP for j in keepers):
|
||||
if any(float(sims[i][j]) >= EMBEDDING_SUB_DUP and negs[i] == negs[j] for j in keepers):
|
||||
discarded.append(i)
|
||||
else:
|
||||
keepers.append(i)
|
||||
@@ -812,7 +1049,7 @@ def _disputed_lines(items, item_idxs, disputed: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "") -> dict | None:
|
||||
async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||
"""Block C: three phases with a barrier — find (classify), select (vote), clarify.
|
||||
Local IDs 1..n per package, mapped to global gid afterwards.
|
||||
→ {block title: [{title, level}, …]} or None."""
|
||||
@@ -868,7 +1105,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
||||
"role": "quick", "capabilities": "files",
|
||||
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
|
||||
} for i, p in pending]
|
||||
new = await _race(topic, f"Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
new = await _race(topic, f"{lbl}Levels package {c}", slots, 2 - existing, _timeout("level", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
return not is_cancelled() and new is not None
|
||||
|
||||
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Levels find"))
|
||||
@@ -895,7 +1132,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
||||
if decision is None:
|
||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||
status, decision = await run_single_slot(
|
||||
ctx, f"Levels-Clarification {c}",
|
||||
ctx, f"{lbl}Levels-Clarification {c}",
|
||||
key=f"blocks-{topic}-{ns}level-final-c{c}",
|
||||
prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
||||
role="judge", capabilities="files",
|
||||
@@ -1008,7 +1245,7 @@ def _facts_complete(files: dict) -> bool:
|
||||
return isinstance(d, dict) and bool(d)
|
||||
|
||||
|
||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "") -> tuple | None:
|
||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "") -> tuple | None:
|
||||
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
||||
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
||||
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
|
||||
@@ -1081,7 +1318,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
return True
|
||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||
status, _r = await run_single_slot(
|
||||
ctx, f"Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
||||
ctx, f"{lbl}Facts {ci}", key=f"blocks-{topic}-{ns}facts-c{ci}",
|
||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
|
||||
role="quick", capabilities=caps,
|
||||
payload=lambda result, p=fp: _facts_schema(_json_file(p)),
|
||||
@@ -1112,7 +1349,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
for bt, fm in per.items())
|
||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||
await run_single_slot(
|
||||
ctx, f"Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
||||
ctx, f"{lbl}Facts supplement {ci}", key=f"blocks-{topic}-{ns}facts-erg-c{ci}",
|
||||
prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
|
||||
role="quick", capabilities=caps,
|
||||
payload=lambda result, p=ep: _facts_schema(_json_file(p)),
|
||||
@@ -1134,7 +1371,8 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
await asyncio.gather(*[
|
||||
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
||||
_prompt("Facts-Check", topic=topic, source=source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
||||
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps)
|
||||
_timeout("content_check", len(per)), provider=provider, role="judge", capabilities=caps,
|
||||
scope=topic, label=f"{lbl}Facts check {ci}/{j}")
|
||||
for j in pending], return_exceptions=True)
|
||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
||||
bvotes: dict[str, int] = {}
|
||||
@@ -1183,7 +1421,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
if not goal:
|
||||
return
|
||||
await run_single_slot(
|
||||
ctx, f"Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
||||
ctx, f"{lbl}Facts-Fix {ci}", key=f"blocks-{topic}-{ns}facts-fix-c{ci}",
|
||||
prompt=_prompt("Facts-Research", topic=topic, source=source, blocks="\n\n".join(goal), out_path=fix_path(ci), extra=_extra(instructions)),
|
||||
role="quick", capabilities=caps,
|
||||
payload=lambda result, p=fix_path(ci): _facts_schema(_json_file(p)),
|
||||
@@ -1211,7 +1449,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
return outcome, discarded_map
|
||||
|
||||
|
||||
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
||||
async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||
"""Block D: three phases with a barrier — find (relevant/peripheral), select (vote), clarify.
|
||||
Items from the sidecar; local IDs 1..n per package → global gid.
|
||||
→ {gid: relevance} or None on cancel/research error. Default on gap/dispute: 'relevant'."""
|
||||
@@ -1249,7 +1487,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
"role": "quick", "capabilities": "files",
|
||||
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
|
||||
} for i, p in pending]
|
||||
new = await _race(topic, f"Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
new = await _race(topic, f"{lbl}Relevance package {c}", slots, 2 - existing, _timeout("relevance", len(item_idxs)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
return not is_cancelled() and new is not None
|
||||
|
||||
oks = await _gather_progress([_rate(c, idxs) for c, idxs in enumerate(chunks, 1)], len(chunks), _report_p(set_p, topic, "Relevance find"))
|
||||
@@ -1276,7 +1514,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
if decision is None:
|
||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||
status, decision = await run_single_slot(
|
||||
ctx, f"Relevance-Clarification {c}",
|
||||
ctx, f"{lbl}Relevance-Clarification {c}",
|
||||
key=f"blocks-{topic}-{ns}relevance-final-c{c}",
|
||||
prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
|
||||
role="judge", capabilities="files",
|
||||
@@ -1321,7 +1559,7 @@ def _match_sub(agent_sub: str, rel: list[str]) -> str:
|
||||
return agent_sub
|
||||
|
||||
|
||||
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
||||
async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||
"""Block E (chunks of 10): find (1 generator per ~10 blocks, parallel), select (code:
|
||||
group per block + dedup), clarify (1 critic per chunk), check (catch-up round).
|
||||
Assignment per entry via the `block` field (a chunk file carries several blocks).
|
||||
@@ -1371,7 +1609,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
)
|
||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||
status, _ = await run_single_slot(
|
||||
ctx, f"Question-Pattern {ci}",
|
||||
ctx, f"{lbl}Question-Pattern {ci}",
|
||||
key=f"blocks-{topic}-{ns}question-pattern-c{ci}",
|
||||
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block,
|
||||
out_path=fp, extra=_extra(instructions)),
|
||||
@@ -1436,7 +1674,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
return # nothing to clarify in this chunk
|
||||
subs_total = sum(len(blocks[i][1]) for i in idxs)
|
||||
status, _ = await run_single_slot(
|
||||
ctx, f"Question-Pattern-Clarification {ci}",
|
||||
ctx, f"{lbl}Question-Pattern-Clarification {ci}",
|
||||
key=f"blocks-{topic}-{ns}question-pattern-final-c{ci}",
|
||||
prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
|
||||
role="judge", capabilities="files",
|
||||
@@ -1505,7 +1743,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
return # resume
|
||||
subs_total = sum(len(s) for _, s in items)
|
||||
await run_single_slot(
|
||||
ctx, f"Question pattern catch-up R{round_n}/{pi}",
|
||||
ctx, f"{lbl}Question pattern catch-up R{round_n}/{pi}",
|
||||
key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}",
|
||||
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items),
|
||||
out_path=fp, extra=_extra(instructions)),
|
||||
@@ -2144,6 +2382,26 @@ def _outline_complete(files: dict) -> bool:
|
||||
return isinstance(d, dict) and isinstance(d.get("chapters"), list) and bool(d.get("chapters"))
|
||||
|
||||
|
||||
def _outline_review_schema(data, valid: set[int], n_chapters: int, n_blocks: int):
|
||||
"""{"moves": {"<blocknr>": <chapter-idx>}} → {nr: idx} (may be {}) · None if broken/invalid.
|
||||
A mass rewrite (more than a third of all blocks) is rejected — the reviewer's job is
|
||||
spotting misplacements, not re-designing the outline."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("moves"), dict):
|
||||
return None
|
||||
out: dict[int, int] = {}
|
||||
for k, v in data["moves"].items():
|
||||
try:
|
||||
nr, ch = int(k), int(v)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if nr not in valid or not (1 <= ch <= n_chapters):
|
||||
return None
|
||||
out[nr] = ch
|
||||
if len(out) * 3 > n_blocks:
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def _outline_schema(data, valid: set[int]):
|
||||
"""{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) ·
|
||||
None at <80 % coverage (agent/judge omitted too much)."""
|
||||
@@ -2302,6 +2560,34 @@ async def _outline_block(ctx: GenContext, set_p, files: dict, entries: dict, ins
|
||||
timeout=_timeout("plan_judge", len(entries)))
|
||||
plan = _outline_schema(_json_file(files["outline"]), valid) or proposals[0]
|
||||
|
||||
# Placement review (best-effort): ONE judge checks every block→chapter assignment and
|
||||
# reports ONLY misplacements as moves. Invalid/mass output → plan unchanged.
|
||||
if proposals and len(plan["chapters"]) >= 2 and not is_cancelled():
|
||||
rp = files["arbeit"] / "outline-review.json"
|
||||
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
|
||||
if moves is None:
|
||||
chapter_text = "\n\n".join(
|
||||
f"KAPITEL {k}: {ch['title']}\n" + "\n".join(f" {n}. {_title(entries[n])}" for n in ch["numbers"])
|
||||
for k, ch in enumerate(plan["chapters"], 1))
|
||||
set_p("Outline review…", step=step)
|
||||
await run_single_slot(
|
||||
ctx, "Outline-Review", key=f"blocks-{topic}-outline-review",
|
||||
prompt=_prompt("Guide-Outline-Review", topic=topic, chapters=chapter_text,
|
||||
out_path=rp, extra=_extra(instructions)),
|
||||
role="judge", capabilities="files",
|
||||
payload=lambda result: _outline_review_schema(
|
||||
_json_file(rp), valid, len(plan["chapters"]), len(entries)),
|
||||
timeout=_timeout("plan_judge", len(entries)))
|
||||
moves = _outline_review_schema(_json_file(rp), valid, len(plan["chapters"]), len(entries))
|
||||
for nr, target in (moves or {}).items():
|
||||
for ch in plan["chapters"]:
|
||||
if nr in ch["numbers"]:
|
||||
ch["numbers"].remove(nr)
|
||||
plan["chapters"][target - 1]["numbers"].append(nr)
|
||||
if moves:
|
||||
plan["chapters"] = [ch for ch in plan["chapters"] if ch["numbers"]]
|
||||
_log(topic, f"Outline-Review: {len(moves)} Block/Blöcke umsortiert")
|
||||
|
||||
# Completeness: every block appears — missing in "Other" (against omitting agents/judge).
|
||||
included = {n for ch in plan["chapters"] for n in ch["numbers"]}
|
||||
missing = [n for n in entries if n not in included]
|
||||
@@ -2372,7 +2658,7 @@ def _artefacts_complete(files: dict) -> bool:
|
||||
return isinstance(d, dict) and all(t in d for t in ARTEFACT_TYPES)
|
||||
|
||||
|
||||
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "") -> dict | None:
|
||||
async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, instructions: str, ns: str = "", lbl: str = "") -> dict | None:
|
||||
"""Generate learning artefacts per type from the stored facts — one generation pass
|
||||
per type over chunks. Worked examples are verified against the facts (wrong ones discarded);
|
||||
flashcards are low-risk and stay unchecked. → {type: [entries]} (also in files)."""
|
||||
@@ -2400,7 +2686,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
atomic_write_json(files["artefakte"], empty_map, indent=1)
|
||||
return empty_map
|
||||
|
||||
chunks = _lpt_chunks([len(z) for _, z in blocks], FACTS_CHUNK_SUBS)
|
||||
chunks = _lpt_chunks([len(z) for _, z in blocks], ARTEFACT_CHUNK_SUBS)
|
||||
def block_text(idxs):
|
||||
return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in idxs)
|
||||
|
||||
@@ -2419,7 +2705,8 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
await asyncio.gather(*[
|
||||
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}",
|
||||
_prompt("Artifact-Example-Check", topic=topic, facts=block_text(idxs), examples=examples_txt, out_path=cpath(j), extra=_extra(instructions)),
|
||||
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps)
|
||||
_timeout("content_check", len(items)), provider=provider, role="judge", capabilities=caps,
|
||||
scope=topic, label=f"{lbl}Beispiel-Check {ci}/{j}")
|
||||
for j in pending], return_exceptions=True)
|
||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _example_check_schema(_json_file(cpath(j)))) is not None]
|
||||
if not outs:
|
||||
@@ -2446,7 +2733,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
if schema(_json_file(p)) is not None:
|
||||
return True
|
||||
await run_single_slot(
|
||||
ctx, f"{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
|
||||
ctx, f"{lbl}{_ARTEFACT_STEP[typ]} {ci}", key=f"blocks-{topic}-{ns}artifact-{typ}-c{ci}",
|
||||
prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)),
|
||||
role="guide", capabilities="files",
|
||||
payload=lambda result, p=p: schema(_json_file(p)),
|
||||
|
||||
@@ -42,11 +42,20 @@ def _safe(norm: str) -> str:
|
||||
|
||||
def _card_set_p(flow: Flow, norm: str):
|
||||
"""Per-card progress: the inner step messages land in-memory on the flow —
|
||||
board_snapshot shows them as the card's info line while it is active."""
|
||||
board_snapshot shows them as the card's info line + phase stepper while active.
|
||||
The step INDEX is resolved to its NAME at write time (indices shift with the
|
||||
source type, names are stable)."""
|
||||
info = flow.state.setdefault("card_info", {})
|
||||
|
||||
def set_p(msg: str, step: int | None = None) -> None:
|
||||
info[f"{BOARD}:{norm}"] = msg
|
||||
name = ""
|
||||
if step is not None:
|
||||
steps = flow.state.get("blocks_steps")
|
||||
if steps is None:
|
||||
steps = flow.state["blocks_steps"] = blocks._blocks_steps(flow.topic)
|
||||
if 0 <= step < len(steps):
|
||||
name = steps[step]
|
||||
info[f"{BOARD}:{norm}"] = {"msg": msg, "step": name}
|
||||
return set_p
|
||||
|
||||
|
||||
@@ -94,28 +103,73 @@ def _fail_or_cancel(ctx: GenContext, what: str):
|
||||
|
||||
|
||||
# ── Stage processors (one call per card, all parallel) ─────────────────────────────
|
||||
async def _seed_map(topic: str) -> dict[str, list[str]]:
|
||||
"""Demoted fragments become seed candidates of their SURVIVING parent block.
|
||||
parent_norm may point at a block that itself got grouped/merged/renamed — follow the
|
||||
redirect chain (grouped → merged_into, rejected → parent_norm, done → mirrored_norm)
|
||||
to the living board-2 card id (= mirrored_norm). A dead end drops the seed (as before)."""
|
||||
alive: set[str] = set()
|
||||
redirect: dict[str, str] = {}
|
||||
rejected: list[dict] = []
|
||||
for r in await db.kanban_cards(topic, board="inventory", kind="block"):
|
||||
p = r["payload"]
|
||||
tn = _norm_title(p.get("title", ""))
|
||||
if not tn:
|
||||
continue
|
||||
if r["stage"] in ("done", "done_block"):
|
||||
mn = p.get("mirrored_norm") or tn
|
||||
alive.add(mn)
|
||||
if tn != mn:
|
||||
redirect.setdefault(tn, mn)
|
||||
elif r["stage"] == "grouped" and p.get("merged_into"):
|
||||
redirect.setdefault(tn, _norm_title(p["merged_into"]))
|
||||
# umbrella members are absorbed WHOLE topics ("Aufgabenlisten" → "Listen") —
|
||||
# without a seed the umbrella's finders may simply miss them (measured).
|
||||
rejected.append({"title": p.get("title", ""), "parent_norm": _norm_title(p["merged_into"])})
|
||||
elif r["stage"] == "rejected":
|
||||
if p.get("parent_norm"):
|
||||
redirect.setdefault(tn, p["parent_norm"])
|
||||
rejected.append(p)
|
||||
|
||||
def _resolve(norm: str) -> str | None:
|
||||
seen: set[str] = set()
|
||||
cur = norm
|
||||
while cur and cur not in seen:
|
||||
if cur in alive: # alive check BEFORE following (self-edges like „Listen"→„Listen")
|
||||
return cur
|
||||
seen.add(cur)
|
||||
cur = redirect.get(cur, "")
|
||||
return None
|
||||
|
||||
seeds: dict[str, list[str]] = {}
|
||||
for p in rejected:
|
||||
pn = p.get("parent_norm")
|
||||
if pn and (target := _resolve(pn)):
|
||||
seeds.setdefault(target, []).append(p.get("title", ""))
|
||||
return seeds
|
||||
|
||||
|
||||
async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
|
||||
topic = flow.topic
|
||||
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
|
||||
seeds: dict[str, list[str]] = {}
|
||||
for r in await db.kanban_cards(topic, board="inventory", stage="rejected"):
|
||||
pn = r["payload"].get("parent_norm")
|
||||
if pn:
|
||||
seeds.setdefault(pn, []).append(r["payload"].get("title", ""))
|
||||
seeds = await _seed_map(topic)
|
||||
|
||||
async def one(c):
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
instr = instructions
|
||||
if (sd := [s for s in seeds.get(norm, []) if s]):
|
||||
sd = [s for s in seeds.get(norm, []) if s]
|
||||
if sd:
|
||||
instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
|
||||
"Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\n"
|
||||
+ "\n".join(f"- {s}" for s in sd))
|
||||
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-")
|
||||
{1: _entry_line(p)}, instr, wipe=False, ns=f"{_safe(norm)}-",
|
||||
seeds=sd or None, lbl=f"{p.get('title', norm)} · ")
|
||||
if raw is None:
|
||||
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
|
||||
p["raw"] = raw
|
||||
p["subs_n"] = sum(len(v) for v in raw.values()) # LPT: bigger blocks pull first
|
||||
await db.kanban_set_payload(topic, BOARD, norm, p)
|
||||
await db.kanban_advance(topic, BOARD, norm, "facts")
|
||||
|
||||
@@ -131,7 +185,8 @@ async def _proc_facts(ctx: GenContext, flow: Flow, files: dict, q: dict, folder,
|
||||
norm = c["card_id"]
|
||||
raw = p.get("raw") or {}
|
||||
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q,
|
||||
folder, instructions, ns=f"{_safe(norm)}-")
|
||||
folder, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if res is None:
|
||||
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
|
||||
facts_map, discarded = res
|
||||
@@ -154,7 +209,8 @@ async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: s
|
||||
p = c["payload"]
|
||||
norm = c["card_id"]
|
||||
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-")
|
||||
p.get("raw") or {}, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if sidecar is None:
|
||||
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
|
||||
facts_map = p.get("facts") or {}
|
||||
@@ -178,7 +234,8 @@ async def _proc_relevance(ctx: GenContext, flow: Flow, files: dict, instructions
|
||||
norm = c["card_id"]
|
||||
sidecar = p.get("sidecar") or {}
|
||||
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
sidecar, instructions, ns=f"{_safe(norm)}-")
|
||||
sidecar, instructions, ns=f"{_safe(norm)}-",
|
||||
lbl=f"{p.get('title', norm)} · ")
|
||||
if rel is None:
|
||||
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
|
||||
gid = 0
|
||||
@@ -201,7 +258,7 @@ async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instr
|
||||
norm = c["card_id"]
|
||||
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("sidecar") or {}, instructions,
|
||||
ns=f"{_safe(norm)}-")
|
||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||
if pattern is None:
|
||||
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
|
||||
p["pattern"] = pattern
|
||||
@@ -219,7 +276,7 @@ async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions
|
||||
norm = c["card_id"]
|
||||
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
|
||||
p.get("sidecar") or {}, instructions,
|
||||
ns=f"{_safe(norm)}-")
|
||||
ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
|
||||
if artefacts is None and ctx.is_cancelled():
|
||||
return None
|
||||
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal
|
||||
@@ -301,7 +358,15 @@ async def _proc_outline(ctx: GenContext, flow: Flow, files: dict, instructions:
|
||||
entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
|
||||
if c["payload"].get("title")}
|
||||
if entries:
|
||||
plan = await _outline_block(ctx, _nset, files, entries, instructions)
|
||||
# The outline may run BEFORE finalize has merged the global facts.json — feed the
|
||||
# prereq hints of _learning_order from the card payloads instead (complete as soon
|
||||
# as every block passed the facts stage, which the trimmed barrier guarantees).
|
||||
facts_map: dict = {}
|
||||
for bc in await db.kanban_cards(topic, board=BOARD, kind="ablock"):
|
||||
facts_map.update(bc["payload"].get("facts") or {})
|
||||
fp = flow.work_dir / "outline-facts.json"
|
||||
atomic_write_json(fp, facts_map, indent=1)
|
||||
plan = await _outline_block(ctx, _nset, {**files, "facts": fp}, entries, instructions)
|
||||
if ctx.is_cancelled():
|
||||
return
|
||||
if isinstance(plan, dict) and plan.get("chapters"):
|
||||
|
||||
@@ -46,8 +46,8 @@ from blocks import (
|
||||
)
|
||||
from config import (
|
||||
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, GROUP_MIN_COS_FLOOR,
|
||||
GROUP_RECONCILE_FLOOR,
|
||||
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
|
||||
GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
|
||||
)
|
||||
from fsutil import atomic_write_json, atomic_write_text
|
||||
from jsonio import read_json_file as _json_file
|
||||
@@ -716,7 +716,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
raise errs[0]
|
||||
if ctx.is_cancelled():
|
||||
return
|
||||
fragments: dict[int, int] = {}
|
||||
proposals: dict[int, int] = {} # judge demotes are PROPOSALS — panel/containment confirm
|
||||
drops: set[int] = set()
|
||||
for ci, numbers in enumerate(chunks):
|
||||
raw = _json_file(work_dir / f"filter-{h}-c{ci}.json")
|
||||
@@ -724,7 +724,7 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
nset = set(numbers)
|
||||
for nr, parent in verdict.items():
|
||||
if 1 <= parent <= n_all and nr in nset:
|
||||
fragments[nr] = parent
|
||||
proposals[nr] = parent
|
||||
for x in (raw.get("drop", []) if isinstance(raw, dict) else []):
|
||||
try:
|
||||
dnr = int(x)
|
||||
@@ -735,34 +735,43 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
# hard-drop double gate
|
||||
honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])}
|
||||
for nr in honored:
|
||||
fragments.pop(nr, None)
|
||||
# containment demote + parentless noise (deterministic)
|
||||
proposals.pop(nr, None)
|
||||
# containment demote (deterministic — auto-confirms proposals whose parent name is
|
||||
# literally contained in the title, and keeps the legacy ⚠-only pass) + parentless noise
|
||||
norms = [(i, allrows[i - 1]["title_norm"]) for i in range(1, n_all + 1)]
|
||||
fragments: dict[int, int] = {}
|
||||
contained: set[int] = set()
|
||||
for i in range(1, n_dem + 1):
|
||||
if i in fragments or i in honored or not _filter_suspect(allrows[i - 1]):
|
||||
if i in honored or (i not in proposals and not _filter_suspect(allrows[i - 1])):
|
||||
continue
|
||||
parent = _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i])
|
||||
if parent is not None and parent != i and parent not in honored:
|
||||
fragments[i] = parent
|
||||
contained.add(i)
|
||||
proposals.pop(i, None)
|
||||
for i in range(1, n_dem + 1):
|
||||
if i in fragments or i in honored or not _is_parentless_noise(allrows[i - 1]["title"]):
|
||||
if i in fragments or i in honored or i in proposals or not _is_parentless_noise(allrows[i - 1]["title"]):
|
||||
continue
|
||||
if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None:
|
||||
honored.add(i)
|
||||
# recheck panel over still-⚠ survivors (rare-positive recall, majority ≥2).
|
||||
# ONE wave over ALL (chunk, judge) slots; a single failed judge is tolerated
|
||||
# (panel votes over whatever answered — legacy semantics). Voting afterwards.
|
||||
# recheck panel = second opinion: unconfirmed judge proposals (WITHOUT the suggested
|
||||
# parent — no anchoring) plus still-⚠ survivors. Majority ≥2 demotes/drops; a proposal
|
||||
# the panel does not confirm survives. The panel is load-bearing now: fewer than 2
|
||||
# valid judge files per chunk is an error (backoff), not a silent keep.
|
||||
survivors = [i for i in range(1, n_dem + 1)
|
||||
if i not in fragments and i not in honored and _filter_suspect(allrows[i - 1])]
|
||||
if i not in fragments and i not in honored
|
||||
and (i in proposals or _filter_suspect(allrows[i - 1]))]
|
||||
overruled: list[int] = []
|
||||
if survivors:
|
||||
ph = _h(",".join(map(str, survivors))) # panel-input hash: stale pre-change files never match
|
||||
rchunks = [survivors[k:k + FILTER_CHUNK] for k in range(0, len(survivors), FILTER_CHUNK)]
|
||||
|
||||
async def _recheck_judge(ci, nums, j):
|
||||
path = work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json"
|
||||
path = work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json"
|
||||
if _filter_schema(_json_file(path)) is not None:
|
||||
return # resume
|
||||
await run_single_slot(
|
||||
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-c{ci}-j{j}",
|
||||
ctx, f"Filter-Recheck {ci}/{j}", key=f"blocks-{topic}-filter-recheck-{h}-{ph}-c{ci}-j{j}",
|
||||
prompt=_prompt("Blocks-Filter-Recheck", topic=topic,
|
||||
survivors="\n".join(_fline(i) for i in nums),
|
||||
list=full_list, out_path=path),
|
||||
@@ -780,11 +789,13 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
dem: dict[int, list[int]] = {}
|
||||
drp: dict[int, int] = {}
|
||||
nset = set(nums)
|
||||
valid = 0
|
||||
for j in range(1, FILTER_RECHECK_PANEL + 1):
|
||||
raw = _json_file(work_dir / f"filter-recheck-{h}-c{ci}-j{j}.json")
|
||||
raw = _json_file(work_dir / f"filter-recheck-{h}-{ph}-c{ci}-j{j}.json")
|
||||
v = _filter_schema(raw)
|
||||
if v is None:
|
||||
continue
|
||||
valid += 1
|
||||
for nr, parent in v.items():
|
||||
if nr in nset and 1 <= parent <= n_all and nr != parent:
|
||||
dem.setdefault(nr, []).append(parent)
|
||||
@@ -795,6 +806,8 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
continue
|
||||
if dnr in nset:
|
||||
drp[dnr] = drp.get(dnr, 0) + 1
|
||||
if valid < 2:
|
||||
raise RuntimeError(f"Filter-Recheck chunk {ci}: nur {valid} Judge(s) mit Ergebnis")
|
||||
for nr in nums:
|
||||
if nr in fragments or nr in honored:
|
||||
continue
|
||||
@@ -803,6 +816,18 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
honored.add(nr)
|
||||
elif len(dem.get(nr, [])) >= 2:
|
||||
fragments[nr] = max(set(dem[nr]), key=dem[nr].count)
|
||||
elif nr in proposals:
|
||||
overruled.append(nr)
|
||||
# embedding backstop: veto confirmed non-containment demotes whose direct title pair is
|
||||
# literally structureless (see FRAGMENT_MIN_COS) — applied BEFORE _root resolution.
|
||||
floor_veto: list[int] = []
|
||||
if (cand := [nr for nr in fragments if nr not in contained]):
|
||||
va = await _vec_rows(flow, [r["title"] for r in allrows])
|
||||
if va is not None:
|
||||
for nr in cand:
|
||||
if float(va[nr - 1] @ va[fragments[nr] - 1]) < FRAGMENT_MIN_COS:
|
||||
fragments.pop(nr)
|
||||
floor_veto.append(nr)
|
||||
# statement-gate rescue (final override)
|
||||
def _protected(nr):
|
||||
return _is_named_statement(allrows[nr - 1]["title"], allrows[nr - 1]["description"])
|
||||
@@ -831,8 +856,12 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
|
||||
await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
|
||||
moves.append((r["card_id"], "rejected"))
|
||||
journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"})
|
||||
atomic_write_json(work_dir / "inventar-filter.json",
|
||||
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal}, indent=1)
|
||||
# one journal file per pass (h) — the supplement feedback pass must not overwrite
|
||||
# the main pass's journal (it is the evaluation instrument).
|
||||
atomic_write_json(work_dir / f"inventar-filter-{h}.json",
|
||||
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal,
|
||||
"ueberstimmt": [allrows[nr - 1]["title"] for nr in overruled],
|
||||
"floor_veto": [allrows[nr - 1]["title"] for nr in floor_veto]}, indent=1)
|
||||
_log(topic, f"Fragment-Filter: {n_dem} → {n_dem - len(journal)} (−{len(journal)})")
|
||||
demoted = {cid for cid, _ in moves}
|
||||
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted]
|
||||
@@ -1057,21 +1086,59 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
if status != OK:
|
||||
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
|
||||
supplements = []
|
||||
known_norms = set()
|
||||
known_keys = set()
|
||||
for t in await db.kanban_cards(topic, board=BOARD):
|
||||
tt = t["payload"].get("title", "")
|
||||
if tt:
|
||||
known_norms.add(_norm_title(tt))
|
||||
if (k := _canonical_key(tt)):
|
||||
known_keys.add(k)
|
||||
new = 0
|
||||
# Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
|
||||
# must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
|
||||
# REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
|
||||
# the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
|
||||
# in the dedup: those were rejected as non-blocks, not lost as content.
|
||||
dead_reasons = {"fragment", "drop-collateral", "drop"}
|
||||
cards = await db.kanban_cards(topic, board=BOARD)
|
||||
dead_clusters = {c["payload"].get("cluster") for c in cards
|
||||
if c["kind"] == "block" and c["stage"] == "rejected"
|
||||
and c["payload"].get("reason") in dead_reasons}
|
||||
dead_clusters.discard(None)
|
||||
membership = await db.kanban_membership(topic)
|
||||
dead_titles = {nm for nm, cid in membership.items() if cid in dead_clusters}
|
||||
|
||||
def _is_dead(c) -> bool:
|
||||
if c["kind"] == "title":
|
||||
return c["card_id"] in dead_titles
|
||||
if c["kind"] == "cluster":
|
||||
return c["card_id"] in dead_clusters
|
||||
return c["stage"] == "rejected" and c["payload"].get("reason") in dead_reasons
|
||||
|
||||
known_norms, known_keys = set(), set()
|
||||
dead_by_norm: dict[str, str] = {} # title norm/key → requeue-able title card_id
|
||||
dead_by_key: dict[str, str] = {}
|
||||
for c in cards:
|
||||
tt = c["payload"].get("title", "")
|
||||
if not tt:
|
||||
continue
|
||||
if _is_dead(c):
|
||||
if c["kind"] == "title":
|
||||
dead_by_norm.setdefault(c["card_id"], c["card_id"])
|
||||
if (k := _canonical_key(tt)):
|
||||
dead_by_key.setdefault(k, c["card_id"])
|
||||
continue
|
||||
known_norms.add(_norm_title(tt))
|
||||
if (k := _canonical_key(tt)):
|
||||
known_keys.add(k)
|
||||
new = reopened = 0
|
||||
for t, d in (supplements or []):
|
||||
norm = _norm_title(t)
|
||||
key = _canonical_key(t)
|
||||
if not norm or norm in known_norms or (key and key in known_keys):
|
||||
continue
|
||||
known_norms.add(norm)
|
||||
dead_id = dead_by_norm.get(norm) or (dead_by_key.get(key) if key else None)
|
||||
if dead_id:
|
||||
card = await db.kanban_get_card(topic, BOARD, dead_id)
|
||||
if card:
|
||||
card["payload"]["supplement"] = True
|
||||
await db.kanban_set_payload(topic, BOARD, dead_id, card["payload"])
|
||||
await db.kanban_advance(topic, BOARD, dead_id, "cluster")
|
||||
reopened += 1
|
||||
continue
|
||||
desc = f"{d} [Supplement]".strip()
|
||||
async with _ingest_lock:
|
||||
await db.kanban_add_title(topic, BOARD, norm, t, desc, "supplement", "supplement")
|
||||
@@ -1080,8 +1147,8 @@ async def _supplement_producer(ctx: GenContext, flow: Flow, titles: list[str]):
|
||||
card["payload"]["supplement"] = True
|
||||
await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
|
||||
new += 1
|
||||
if new:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest")
|
||||
if new or reopened:
|
||||
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
|
||||
flow.wake.set()
|
||||
|
||||
|
||||
@@ -1175,6 +1242,13 @@ async def run_boards(ctx: GenContext, set_p, files: dict, q: dict, folder, instr
|
||||
await board_artefacts.ensure_outline_card(topic)
|
||||
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
|
||||
stages = chain_stages(stages)
|
||||
if artefacts:
|
||||
# Outline needs every block's TITLE + FACTS, nothing later: cut the post-facts
|
||||
# artefact stages from its barrier so it runs parallel to levels…finalize of the
|
||||
# slowest block (makespan tail). Inventory stages all stay — no late blocks.
|
||||
outline = next(s for s in stages if s.stage == "outline")
|
||||
outline.upstream = [u for u in outline.upstream if u not in
|
||||
("levels", "relevance", "question_pattern", "artefacts", "finalize")]
|
||||
producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
|
||||
|
||||
async def _as_producer(coro):
|
||||
@@ -1260,6 +1334,12 @@ _VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mir
|
||||
DONE_ART = "done_artefact"
|
||||
|
||||
|
||||
# Board-2 stage → its fine-step group in blocks.PHASEN (drives the per-card stepper).
|
||||
_STAGE_PHASE = {"subblocks": "Subblocks", "facts": "Facts", "levels": "Levels",
|
||||
"relevance": "Relevance", "question_pattern": "Questions", "artefacts": "Artefacts"}
|
||||
_PHASE_STEPS = {name: steps for name, steps in blocks.PHASEN}
|
||||
|
||||
|
||||
def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
|
||||
p = r["payload"]
|
||||
key = f"{r['board']}:{r['card_id']}"
|
||||
@@ -1269,11 +1349,18 @@ def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
|
||||
info = f"→ {p['merged_into']}"
|
||||
elif p.get("parent_norm"):
|
||||
info = f"Fragment von: {p['parent_norm']}"
|
||||
if is_active and live_info.get(key): # live step message wins while the card is worked
|
||||
info = live_info[key]
|
||||
status = "error" if r["retries"] else ("active" if is_active else "open")
|
||||
return {"title": p.get("title") or r["card_id"], "status": status,
|
||||
"info": info, "retries": r["retries"]}
|
||||
out = {"title": p.get("title") or r["card_id"], "retries": r["retries"], "card_id": r["card_id"],
|
||||
"kind": r.get("kind", ""), "board": r.get("board", ""),
|
||||
"status": "error" if r["retries"] else ("active" if is_active else "open")}
|
||||
live = live_info.get(key)
|
||||
if is_active and live: # live step message wins while the card is worked
|
||||
info = live["msg"] if isinstance(live, dict) else live # dict since the stepper, str before
|
||||
step = live.get("step") if isinstance(live, dict) else ""
|
||||
steps = _PHASE_STEPS.get(_STAGE_PHASE.get(r.get("stage", ""), ""), ())
|
||||
if step in steps: # phase stepper: which fine step of the card's stage runs (1-based)
|
||||
out.update(step_i=steps.index(step) + 1, step_n=len(steps), steps=list(steps))
|
||||
out["info"] = info
|
||||
return out
|
||||
|
||||
|
||||
async def board_snapshot(topic: str, limit: int = 20) -> dict:
|
||||
@@ -1361,9 +1448,27 @@ async def reset_board_from_stage(topic: str, board: str, stage: str, files: dict
|
||||
await db.delete_subblocks(topic)
|
||||
await db.delete_question_pattern(topic)
|
||||
await db.delete_sub_artefakte(topic)
|
||||
await db.add_event(topic, "reset", key=f"{board}:from-{stage}", status=str(moved))
|
||||
return moved
|
||||
|
||||
|
||||
async def restart_artefact_card(topic: str, card_id: str) -> bool:
|
||||
"""Restart ONE artefacts card from `subblocks` — wipes only ITS derived DB rows
|
||||
(per-block work-dir slots overwrite themselves; finalize re-upserts later).
|
||||
Only call while nothing is generating (the route guards). → False if unknown."""
|
||||
card = await db.kanban_get_card(topic, "artefacts", card_id)
|
||||
if card is None or card["kind"] != "ablock":
|
||||
return False
|
||||
await db.delete_subblocks(topic, card_id)
|
||||
await db.delete_question_pattern(topic, card_id)
|
||||
await db.delete_sub_artefakte(topic, card_id)
|
||||
p = {k: v for k, v in card["payload"].items() if k in ("title", "description")}
|
||||
await db.kanban_set_payload(topic, "artefacts", card_id, p)
|
||||
await db.kanban_advance(topic, "artefacts", card_id, "subblocks")
|
||||
await db.add_event(topic, "reset", key=f"artefacts:{card_id}", status="card-restart")
|
||||
return True
|
||||
|
||||
|
||||
async def requeue_dead(topic: str) -> int:
|
||||
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
|
||||
n = 0
|
||||
|
||||
@@ -11,8 +11,10 @@ UNI_DIR = PROJECT_ROOT / "uni"
|
||||
|
||||
|
||||
def _load_env(path: Path) -> None:
|
||||
"""Mini .env loader (no dependency): KEY=VALUE lines; existing env always wins
|
||||
(`make dev` already exports .env — this covers bare `uvicorn`/pytest starts)."""
|
||||
"""Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
|
||||
env: a --reload master keeps its startup environment forever, so "existing env wins"
|
||||
silently pinned stale values across .env edits (measured: file said 24, workers
|
||||
inherited 15 for hours). Trade-off: ad-hoc shell overrides lose against the file."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
@@ -23,7 +25,7 @@ def _load_env(path: Path) -> None:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key, value = key.strip(), value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
if key:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@@ -59,6 +61,15 @@ EMBEDDING_BLOCK_CAP = 25 # max. titles per block (keep the LLM list short/s
|
||||
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
|
||||
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
|
||||
EMBEDDING_SUB_DUP = 0.90
|
||||
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
|
||||
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
|
||||
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
|
||||
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.91–0.95 → negation guard required.
|
||||
SUB_VARIANT_COS = 0.90
|
||||
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
|
||||
# subs are statements: true covers measure 0.27–0.38 while a wrong hit measured 0.76. The
|
||||
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
|
||||
SEED_COVER_COS = 0.80
|
||||
|
||||
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
|
||||
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
|
||||
@@ -83,6 +94,12 @@ GROUP_RECONCILE_FLOOR = 0.75
|
||||
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
|
||||
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
|
||||
GROUP_MIN_COS_FLOOR = 0.15
|
||||
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
|
||||
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
|
||||
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
|
||||
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
|
||||
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
|
||||
FRAGMENT_MIN_COS = 0.15
|
||||
|
||||
# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
|
||||
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
|
||||
@@ -125,15 +142,15 @@ QUELLE_RELEVANZ_SNIPPET = 800 # body characters per page in the prompt (URL i
|
||||
# Timeouts per agent step: (base seconds, seconds per block/section).
|
||||
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
|
||||
TIMEOUTS = {
|
||||
"research": (1800, 0), # fixed 30 min
|
||||
"research": (900, 0), # p95 measured 125 s (web mode); uni/link sections need headroom
|
||||
"research_mapping": (600, 3), # n = pre-merged entries
|
||||
"selection_mapping": (600, 2), # n = remaining entries (block inventory)
|
||||
"ergaenzung": (900, 0), # subject-field extension for projects (web research)
|
||||
"ergaenzung": (600, 0), # subject-field extension for projects (web research)
|
||||
"plan": (300, 5),
|
||||
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections
|
||||
"content": (600, 90), # identify content per block in the chunk (web search)
|
||||
"content": (450, 30), # facts find/erg/fix — p95 measured 241 s (was 600+90n)
|
||||
"content_check": (300, 10), # content exam per block in the package
|
||||
"subblock": (900, 45), # find subblocks per block in the chunk (web search)
|
||||
"subblock": (400, 15), # finder round — p95 measured 124 s (was 900+45n)
|
||||
"subblock_check": (300, 15), # judge decides contested subblocks in the chunk
|
||||
"level": (300, 10), # classify subblocks per chunk
|
||||
"level_check": (300, 10), # judge decides contested levels in the chunk
|
||||
@@ -141,7 +158,7 @@ TIMEOUTS = {
|
||||
"relevance_check": (300, 10), # judge decides contested relevance in the chunk
|
||||
"question_pattern": (300, 15), # question patterns per block (subblocks × types)
|
||||
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block
|
||||
"writer": (600, 120), # per section in the chunk
|
||||
"writer": (450, 60), # per section — split keeps sections ≤30 subs
|
||||
"lese_check": (300, 10), # per section in the package
|
||||
# guide board (per card = one block)
|
||||
"lernziele": (300, 5), # backward-design objectives per block
|
||||
@@ -194,15 +211,14 @@ PROVIDERS = {
|
||||
},
|
||||
}
|
||||
|
||||
# Role routing ACROSS provider stacks: generation (quick/guide) and judging (judge)
|
||||
# may run on different providers within ONE run — judge model ≠ generator model
|
||||
# (research-backed: cross-model judging avoids self-preference bias).
|
||||
# Value: "" = provider of the run; "minimax" = that stack's role model;
|
||||
# "provider:model" = explicit model override.
|
||||
# Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
|
||||
# the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
|
||||
# Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
|
||||
# to the claude stack regardless of the UI choice ("provider:model" pins a model).
|
||||
ROLE_ROUTING = {
|
||||
"quick": os.getenv("ROLE_QUICK", "minimax"),
|
||||
"judge": os.getenv("ROLE_JUDGE", "claude"),
|
||||
"guide": os.getenv("ROLE_GUIDE", "minimax"),
|
||||
"quick": os.getenv("ROLE_QUICK", ""),
|
||||
"judge": os.getenv("ROLE_JUDGE", ""),
|
||||
"guide": os.getenv("ROLE_GUIDE", ""),
|
||||
"fast": os.getenv("ROLE_FAST", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,6 @@ CREATE TABLE IF NOT EXISTS topics (
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_ELEMENTS = """
|
||||
CREATE TABLE IF NOT EXISTS elements (
|
||||
id TEXT PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
examples TEXT NOT NULL DEFAULT '[]',
|
||||
hints TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_BLOCK_TEXTE = """
|
||||
CREATE TABLE IF NOT EXISTS block_texte (
|
||||
topic TEXT NOT NULL,
|
||||
@@ -219,6 +206,28 @@ CREATE TABLE IF NOT EXISTS kanban_cards (
|
||||
)
|
||||
"""
|
||||
|
||||
# Pipeline history (agents, stage moves, failures) — the raw data for quality/perf
|
||||
# analysis. Written fire-and-forget from the hooks in kanban_advance_many / kanban_fail_card /
|
||||
# set_guide_card and agents.run_agent (injected via agents.on_event); never load-bearing.
|
||||
CREATE_EVENTS = """
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
topic TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
dur_ms INTEGER,
|
||||
wait_ms INTEGER,
|
||||
meta TEXT NOT NULL DEFAULT '{}'
|
||||
)
|
||||
"""
|
||||
|
||||
CREATE_EVENTS_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_events ON events(topic, ts)
|
||||
"""
|
||||
|
||||
CREATE_KANBAN_PULL_INDEX = """
|
||||
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at)
|
||||
"""
|
||||
@@ -287,7 +296,6 @@ async def init_db():
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(CREATE_TOPICS)
|
||||
await db.execute(CREATE_ELEMENTS)
|
||||
await db.execute(CREATE_BLOCK_TEXTE)
|
||||
await db.execute(CREATE_BLOCK_PROGRESS)
|
||||
await db.execute(CREATE_BLOCKS)
|
||||
@@ -300,6 +308,8 @@ async def init_db():
|
||||
await db.execute(CREATE_GUIDE_OUTLINE)
|
||||
await db.execute(CREATE_SUB_ARTEFAKTE)
|
||||
await db.execute(CREATE_KANBAN_CARDS)
|
||||
await db.execute(CREATE_EVENTS)
|
||||
await db.execute(CREATE_EVENTS_INDEX)
|
||||
await db.execute(CREATE_KANBAN_PULL_INDEX)
|
||||
await db.execute(CREATE_KANBAN_MEMBERS)
|
||||
await db.execute(CREATE_GUIDE_CARDS)
|
||||
@@ -353,6 +363,8 @@ async def init_db():
|
||||
"SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
|
||||
)
|
||||
await db.execute("DROP TABLE vertiefungen")
|
||||
# Migration: the elements feature was removed entirely — drop its orphaned table.
|
||||
await db.execute("DROP TABLE IF EXISTS elements")
|
||||
await db.execute(
|
||||
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
|
||||
"WHERE status IN ('queued', 'generating')"
|
||||
@@ -446,59 +458,6 @@ async def delete_topic(name: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- Elements ---
|
||||
|
||||
def _element_row(row, cursor) -> dict:
|
||||
el = _row_to_dict(row, cursor)
|
||||
el["examples"] = json.loads(el["examples"] or "[]")
|
||||
el["hints"] = json.loads(el["hints"] or "[]")
|
||||
return el
|
||||
|
||||
|
||||
async def create_element(element: dict) -> dict:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
|
||||
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
|
||||
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
|
||||
"hints": json.dumps(element["hints"], ensure_ascii=False)},
|
||||
)
|
||||
await db.commit()
|
||||
return element
|
||||
|
||||
|
||||
async def list_elements(topic: str) -> list[dict]:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_element_row(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def get_element(element_id: str) -> dict | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _element_row(row, cursor)
|
||||
|
||||
|
||||
async def update_element(element_id: str, **fields) -> None:
|
||||
for key in ("examples", "hints"):
|
||||
if key in fields:
|
||||
fields[key] = json.dumps(fields[key], ensure_ascii=False)
|
||||
await _update("elements", fields, {"id": element_id})
|
||||
|
||||
|
||||
async def delete_element(element_id: str) -> bool:
|
||||
db = await get_db()
|
||||
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# --- Chapter progress ---
|
||||
|
||||
async def list_progress_all() -> dict[str, set[str]]:
|
||||
@@ -613,23 +572,6 @@ async def delete_block_progress(topic: str, block: str) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def set_block_completed(topic: str, block: str) -> bool:
|
||||
"""Marks completed; True only the first time (drives the element task)."""
|
||||
db = await get_db()
|
||||
now = _now()
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO block_progress (topic, block, good_answers, updated_at) VALUES (?, ?, 0, ?)",
|
||||
(topic, block, now),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"UPDATE block_progress SET completed = ?, updated_at = ? "
|
||||
"WHERE topic = ? AND block = ? AND completed IS NULL",
|
||||
(now, now, topic, block),
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
# Sub-level from the two orthogonal columns: peripheral → 4 (V), otherwise level (learning-path position):
|
||||
# beginner/NULL → 1, advanced → 2, expert → 3. Old values (einfach/mittel/schwer) are
|
||||
# mapped in backward-compatibly. Drives the guide view A/F/E/V + cap (unlocked subs × 25).
|
||||
@@ -797,11 +739,13 @@ def _card(row, cursor) -> dict:
|
||||
|
||||
|
||||
async def kanban_pull(topic: str, board: str, stage: str, limit: int) -> list[dict]:
|
||||
"""Oldest `limit` cards sitting in `stage` whose backoff has expired (FIFO via updated_at)."""
|
||||
"""`limit` ready cards of `stage` (backoff expired). LPT: cards carrying a `subs_n`
|
||||
payload field (board 2, set after subblocks) are pulled BIGGEST first — the longest
|
||||
block starts earliest and stops dominating the makespan tail. Others stay FIFO."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ?
|
||||
ORDER BY updated_at LIMIT ?""",
|
||||
ORDER BY COALESCE(json_extract(payload, '$.subs_n'), 0) DESC, updated_at LIMIT ?""",
|
||||
(topic, board, stage, _now(), limit))
|
||||
return [_card(row, cursor) for row in await cursor.fetchall()]
|
||||
|
||||
@@ -829,6 +773,27 @@ async def kanban_advance(topic: str, board: str, card_id: str, stage: str) -> No
|
||||
await kanban_advance_many(topic, board, [(card_id, stage)])
|
||||
|
||||
|
||||
async def add_event(topic: str, kind: str, key: str = "", label: str = "", status: str = "",
|
||||
dur_ms: int | None = None, wait_ms: int | None = None, meta: dict | None = None) -> None:
|
||||
"""One pipeline-history row, own commit. Callers treat this as fire-and-forget."""
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(topic, _now(), kind, key, label, status, dur_ms, wait_ms,
|
||||
json.dumps(meta or {}, ensure_ascii=False)))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _add_events_many(db, topic: str, rows: list[tuple]) -> None:
|
||||
"""Batch insert WITHOUT commit — must run inside the caller's transaction
|
||||
(kanban_advance_many) so the event batch stays atomic with the moves."""
|
||||
now = _now()
|
||||
await db.executemany(
|
||||
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
[(topic, now, kind, key, label, status, None, None, "{}")
|
||||
for kind, key, label, status in rows])
|
||||
|
||||
|
||||
async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str]]) -> None:
|
||||
"""Batch stage moves in ONE commit (the flow advances whole packages)."""
|
||||
if not moves:
|
||||
@@ -839,6 +804,7 @@ async def kanban_advance_many(topic: str, board: str, moves: list[tuple[str, str
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
[(stage, now, topic, board, cid) for cid, stage in moves])
|
||||
await _add_events_many(db, topic, [("stage", f"{board}:{cid}", "", stage) for cid, stage in moves])
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -912,6 +878,10 @@ async def kanban_fail_card(topic: str, board: str, card_id: str, error: str,
|
||||
WHERE topic = ? AND board = ? AND card_id = ?""",
|
||||
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
|
||||
topic, board, card_id))
|
||||
await db.execute(
|
||||
"INSERT INTO events (topic, ts, kind, key, label, status, dur_ms, wait_ms, meta) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(topic, _now(), "fail", f"{board}:{card_id}", "", "dead" if dead else f"retry{retries}",
|
||||
None, None, json.dumps({"error": error[:200]}, ensure_ascii=False)))
|
||||
await db.commit()
|
||||
return dead
|
||||
|
||||
@@ -928,6 +898,7 @@ async def kanban_requeue_dead(topic: str, board: str, stage: str) -> int:
|
||||
"""UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
|
||||
WHERE topic = ? AND board = ? AND stage = 'dead'""",
|
||||
(stage, _now(), topic, board))
|
||||
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
|
||||
await db.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
@@ -1032,6 +1003,11 @@ async def set_guide_card(topic: str, format: str, block_norm: str, **fields) ->
|
||||
await db.execute(
|
||||
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
|
||||
(*fields.values(), _now(), topic, format, block_norm))
|
||||
if "stage" in fields: # the guide board moves stages here, not via kanban_advance_many
|
||||
await _add_events_many(db, topic, [("stage", f"guide:{format}:{block_norm}", "", fields["stage"])])
|
||||
elif fields.get("status") == "error": # guide cards fail here, not via kanban_fail_card
|
||||
await _add_events_many(db, topic, [("fail", f"guide:{format}:{block_norm}", "",
|
||||
str(fields.get("gate_info", ""))[:200])])
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1202,9 +1178,12 @@ async def set_subblock_fields(topic: str, block_norm: str, sub_norm: str, **fiel
|
||||
await _update("subblocks", fields, {"topic": topic, "block_norm": block_norm, "sub_norm": sub_norm})
|
||||
|
||||
|
||||
async def delete_subblocks(topic: str) -> None:
|
||||
async def delete_subblocks(topic: str, block_norm: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
|
||||
if block_norm is None:
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM subblocks WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1232,9 +1211,12 @@ async def list_question_pattern(topic: str, block_norm: str | None = None) -> li
|
||||
return [_row_to_dict(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def delete_question_pattern(topic: str) -> None:
|
||||
async def delete_question_pattern(topic: str, block_norm: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
|
||||
if block_norm is None:
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM question_pattern WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1408,9 +1390,12 @@ async def get_sub_artefakte(topic: str, type: str | None = None) -> list[dict]:
|
||||
return [_row_to_dict(row, cursor) for row in rows]
|
||||
|
||||
|
||||
async def delete_sub_artefakte(topic: str) -> None:
|
||||
async def delete_sub_artefakte(topic: str, block_norm: str | None = None) -> None:
|
||||
db = await get_db()
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
||||
if block_norm is None:
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ?", (topic,))
|
||||
else:
|
||||
await db.execute("DELETE FROM sub_artefakte WHERE topic = ? AND block_norm = ?", (topic, block_norm))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1441,6 +1426,6 @@ async def delete_topic_pipeline(topic: str) -> None:
|
||||
NOT the topic config `source` — that is managed separately (delete_source)."""
|
||||
db = await get_db()
|
||||
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage",
|
||||
"pipeline_state", "guide_outline", "sub_artefakte"):
|
||||
"pipeline_state", "guide_outline", "sub_artefakte", "events"):
|
||||
await db.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
|
||||
await db.commit()
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Elements (personal summary) and tutor chat for the guide."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from jsonio import parse_json_text as _parse_json_text, read_json_file as _read_json_file
|
||||
from paths import blocks_path, guide_content_path
|
||||
from pipeline import _prompt
|
||||
|
||||
log = logging.getLogger("creator.elements")
|
||||
|
||||
|
||||
# --- Tutor chat ---
|
||||
|
||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||
transcript = "\n".join(
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return _prompt(
|
||||
"Chat",
|
||||
topic=topic, format_name=format_name,
|
||||
outline_block=outline.strip() or "(none)",
|
||||
section_block=section.strip() or "(no section detected)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
|
||||
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||
returncode, stdout, stderr = await run_agent(
|
||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
reply = stdout.strip()
|
||||
return reply or "Sorry, I didn't get a response."
|
||||
except Exception:
|
||||
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
|
||||
|
||||
# --- Elements ---
|
||||
|
||||
def _element_fields(data: dict) -> dict | None:
|
||||
"""Validate AI element JSON and normalize it onto the DB fields."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
title = str(data.get("title", "")).strip()
|
||||
if not title:
|
||||
return None
|
||||
lists = {}
|
||||
for key in ("examples", "hints"):
|
||||
raw = data.get(key, [])
|
||||
lists[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
|
||||
return {
|
||||
"title": title[:200],
|
||||
"description": str(data.get("description", "")).strip(),
|
||||
"examples": lists["examples"],
|
||||
"hints": lists["hints"],
|
||||
}
|
||||
|
||||
|
||||
def _topic_context(topic: str, limit: int = 12000) -> str:
|
||||
"""Blocks + guide content of the topic as context text (truncated)."""
|
||||
parts: list[str] = []
|
||||
bp = blocks_path(topic)
|
||||
if bp.exists():
|
||||
parts.append(bp.read_text(encoding="utf-8"))
|
||||
for fmt in ("Guide", "FullGuide"): # best available prose guide as chat context
|
||||
content = _read_json_file(guide_content_path(topic, fmt))
|
||||
if content:
|
||||
for ch in content.get("chapters", []):
|
||||
for sec in ch.get("sections", []):
|
||||
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
|
||||
break # the best available guide is enough
|
||||
text = "\n\n".join(parts).strip()
|
||||
return text[:limit] if text else "(no material available)"
|
||||
|
||||
|
||||
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER, extra_context: str = "") -> dict:
|
||||
"""Create element fields via AI. Fallback: only the title from the keyword."""
|
||||
fallback = {"title": hint.strip() or "New element", "description": "", "examples": [], "hints": []}
|
||||
try:
|
||||
context = _topic_context(topic)
|
||||
if extra_context.strip():
|
||||
context = (extra_context.strip() + "\n\n" + context)[:12000]
|
||||
prompt = _prompt(
|
||||
"Element-Create",
|
||||
topic=topic, hint=hint.strip() or "(none — pick a core concept yourself)",
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return fallback
|
||||
return _element_fields(_parse_json_text(stdout)) or fallback
|
||||
except Exception:
|
||||
log.warning("[%s] Element creation failed", topic, exc_info=True)
|
||||
return fallback
|
||||
|
||||
|
||||
def _parse_suggestions(stdout: str) -> list[dict] | None:
|
||||
"""Validate suggestion JSON from AI output. None on invalid JSON."""
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
suggestions = []
|
||||
for s in data.get("suggestions", []):
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
text = str(s.get("text", "")).strip()
|
||||
target = s.get("target")
|
||||
content = str(s.get("content", "")).strip()
|
||||
if text and content and target in ("description", "examples", "hints"):
|
||||
suggestions.append({"text": text, "target": target, "content": content})
|
||||
return suggestions
|
||||
|
||||
|
||||
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Two-step check for missing info: research → verify. None on error."""
|
||||
try:
|
||||
element_json = json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
context = _topic_context(element["topic"])
|
||||
|
||||
# Step 1: research — collect candidates broadly
|
||||
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
candidates = _parse_suggestions(stdout)
|
||||
if candidates is None:
|
||||
return None
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Step 2: verify — only let important, non-redundant items through
|
||||
prompt = _prompt(
|
||||
"Element-Verify",
|
||||
topic=element["topic"], element_json=element_json,
|
||||
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
|
||||
context=context,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
return _parse_suggestions(stdout)
|
||||
except Exception:
|
||||
log.warning("[%s] Element check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _element_json(element: dict) -> str:
|
||||
return json.dumps(
|
||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||
ensure_ascii=False, indent=1,
|
||||
)
|
||||
|
||||
|
||||
def _validate_change(c, element: dict) -> dict | None:
|
||||
"""Validate a change suggestion from AI output against the element."""
|
||||
if not isinstance(c, dict):
|
||||
return None
|
||||
text = str(c.get("text", "")).strip()
|
||||
action = c.get("action")
|
||||
target = c.get("target")
|
||||
index = c.get("index")
|
||||
content = str(c.get("content", "")).strip()
|
||||
if not text or action not in ("remove", "adjust", "add"):
|
||||
return None
|
||||
if target not in ("title", "description", "examples", "hints"):
|
||||
return None
|
||||
if action in ("adjust", "add") and not content:
|
||||
return None
|
||||
if action == "remove" and target not in ("examples", "hints"):
|
||||
return None
|
||||
# Index only for adjust/remove on list fields; must exist
|
||||
if target in ("examples", "hints") and action in ("adjust", "remove"):
|
||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
||||
return None
|
||||
else:
|
||||
index = None
|
||||
return {"text": text, "action": action, "target": target, "index": index, "content": content}
|
||||
|
||||
|
||||
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
|
||||
"""Chat about the element. Returns (reply, change suggestions) — changes nothing directly."""
|
||||
error = "Sorry, that didn't work. Please try again."
|
||||
try:
|
||||
transcript = "\n".join(
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return error, []
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return error, []
|
||||
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
reply = str(data.get("reply", "")).strip() or ("Suggestions created." if changes else error)
|
||||
return reply, changes
|
||||
except Exception:
|
||||
log.warning("[%s] Element chat failed", element.get("topic", "?"), exc_info=True)
|
||||
return error, []
|
||||
|
||||
|
||||
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
|
||||
"""Check an element against the style rules and suggest changes. None on error."""
|
||||
try:
|
||||
prompt = _prompt("Element-Style", topic=element["topic"], element_json=_element_json(element))
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||
except Exception:
|
||||
log.warning("[%s] Style check failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
||||
"""Revise a single suggestion per user instruction. None on error."""
|
||||
try:
|
||||
prompt = _prompt(
|
||||
"Element-Refine",
|
||||
topic=element["topic"], element_json=_element_json(element),
|
||||
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
|
||||
instruction=instruction,
|
||||
)
|
||||
returncode, stdout, _ = await run_agent(
|
||||
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return None
|
||||
data = _parse_json_text(stdout)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return _validate_change(data.get("change"), element)
|
||||
except Exception:
|
||||
log.warning("[%s] Suggestion revision failed", element.get("topic", "?"), exc_info=True)
|
||||
return None
|
||||
@@ -396,3 +396,34 @@ async def block_adopt(topic: str, format_name: str, block: str, spot: str, old:
|
||||
await set_guide_content(topic, format_name, js)
|
||||
atomic_write_json(guide_content_path(topic, format_name), content, indent=1)
|
||||
return {"compact": sec.get("compact", ""), "md": sec.get("md", ""), "found": found}
|
||||
|
||||
|
||||
# --- Tutor chat (moved from the removed elements module) ---
|
||||
|
||||
def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str:
|
||||
transcript = "\n".join(
|
||||
f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
|
||||
for m in messages
|
||||
)
|
||||
return _prompt(
|
||||
"Chat",
|
||||
topic=topic, format_name=format_name,
|
||||
outline_block=outline.strip() or "(none)",
|
||||
section_block=section.strip() or "(no section detected)",
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
|
||||
async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> str:
|
||||
try:
|
||||
prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages)
|
||||
returncode, stdout, stderr = await run_agent(
|
||||
"chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none", lane="interactive"
|
||||
)
|
||||
if returncode != 0:
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
reply = stdout.strip()
|
||||
return reply or "Sorry, I didn't get a response."
|
||||
except Exception:
|
||||
log.warning("[%s] Guide chat failed", topic, exc_info=True)
|
||||
return "Sorry, that didn't work. Please try again."
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Board 3 „Guide": one card per block, linear stages with gates between them.
|
||||
|
||||
lernziele judge Backward Design — objectives BEFORE writing
|
||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||
writer guide ONE coherent per-block text, only from VERIFIED FACTS
|
||||
fakten_gate judge CoVe: atomic claims, each binary against the facts → minimal fix
|
||||
coverage judge objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||
lesbarkeit judge Lese-Check + deterministic readability gate → fix → done
|
||||
lernziele judge-Rolle Backward Design — objectives BEFORE writing
|
||||
zuweisung code chapter/order from the outline artefact + facts grounding
|
||||
writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
|
||||
fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
|
||||
coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
|
||||
lesbarkeit judge-Rolle Lese-Check + deterministic readability gate → fix → done
|
||||
|
||||
Runner: one asyncio task per card (cards are fixed from the start — no queue engine
|
||||
needed); stage transitions are persisted in guide_cards, so the board is live and
|
||||
@@ -20,7 +20,7 @@ import re
|
||||
|
||||
import database as db
|
||||
import readability
|
||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR
|
||||
from config import FORMAT_PURPOSE, READABILITY_ACTIVE, TEMPLATES_DIR, MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
from fsutil import atomic_write_json
|
||||
from jsonio import read_json_file as _json_file
|
||||
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt,
|
||||
@@ -34,7 +34,10 @@ STAGE_LABELS = {"lernziele": "Lernziele", "zuweisung": "Zuweisung", "writer": "W
|
||||
"fakten_gate": "Fakten-Gate", "coverage": "Coverage",
|
||||
"lesbarkeit": "Lesbarkeit", "done": "Fertig"}
|
||||
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 1–2)
|
||||
CARD_CONCURRENCY = 10 # simultaneous cards (the per-topic agent semaphore is the hard cap)
|
||||
# Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
|
||||
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
|
||||
# from the old 10-slot era while the .env already allowed 24).
|
||||
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
|
||||
|
||||
|
||||
def _safe(norm: str) -> str:
|
||||
@@ -142,6 +145,15 @@ def _card_assignment(env: _Env, card: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Live info per active card (in-memory): what the card is doing RIGHT NOW —
|
||||
# board_snapshot shows it as the info line while status == active.
|
||||
_live_info: dict[tuple[str, str, str], str] = {}
|
||||
|
||||
|
||||
def _live(env: _Env, card: dict, msg: str) -> None:
|
||||
_live_info[(env.topic, env.format, card["block_norm"])] = msg
|
||||
|
||||
|
||||
async def _set(env: _Env, card: dict, **fields):
|
||||
card.update(fields)
|
||||
await db.set_guide_card(env.topic, env.format, card["block_norm"], **fields)
|
||||
@@ -179,6 +191,82 @@ async def _stage_zuweisung(env: _Env, card: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# A single section over ~45 subs measurably breaks the writer/coverage (Front Matter:
|
||||
# 4/6 objectives open after 2 rounds). First drafts of oversized cards are written in two
|
||||
# halves and merged back into ONE canonical section (all gates/assembly read one section).
|
||||
WRITER_SPLIT_SUBS = 30
|
||||
|
||||
|
||||
def _merge_split_sections(sec_a: dict, sec_b: dict) -> str:
|
||||
"""Rebuild ONE canonical fragment from two half-sections: header + anchor from part A,
|
||||
sub blocks of both parts in order, both layers. Part B's framing is dropped — its
|
||||
prompt forbids an intro; keeping it would inject a second lead-in mid-section."""
|
||||
lines = []
|
||||
if sec_a.get("chapters"):
|
||||
lines.append(f"<!-- kapitel: {sec_a['chapters']} -->")
|
||||
lines.append(f"<!-- section: {sec_a['title']} -->")
|
||||
lines.append("<!-- compact -->")
|
||||
if sec_a.get("anker_compact"):
|
||||
lines.append(sec_a["anker_compact"])
|
||||
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||
if sub.get("compact"):
|
||||
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||
lines.append(sub["compact"])
|
||||
lines.append("<!-- ausführlich -->")
|
||||
if sec_a.get("anchor"):
|
||||
lines.append(sec_a["anchor"])
|
||||
for sub in [*sec_a["subs"], *sec_b["subs"]]:
|
||||
if sub.get("md"):
|
||||
lines.append(f"<!-- sub: {sub['level']} | {sub['title']} -->")
|
||||
lines.append(sub["md"])
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
async def _write_split(env: _Env, card: dict, ziele_text: str):
|
||||
"""First draft in two halves (parallel), merged into one section.
|
||||
→ merged text | None (failed) | False (cancelled)."""
|
||||
from guide import _level_label
|
||||
norm = card["block_norm"]
|
||||
subs = env.subs_by_title.get(card["block"], [])
|
||||
half = (len(subs) + 1) // 2
|
||||
parts = (subs[:half], subs[half:])
|
||||
hints = (
|
||||
"TEIL 1/2: Schreibe den Abschnitts-EINSTIEG und die folgenden Unterpunkte. "
|
||||
"Weitere Unterpunkte folgen in Teil 2 — KEIN Fazit, KEIN Ausblick am Ende.",
|
||||
"TEIL 2/2: FORTSETZUNG desselben Abschnitts. KEIN neuer Einstieg, KEINE "
|
||||
"Wiederholung von Teil 1 — direkt mit den Unterpunkten weitermachen.",
|
||||
)
|
||||
|
||||
async def _one(i):
|
||||
assignment = "\n".join([f"- {card['block']}"]
|
||||
+ [f" [{_level_label(s)}] {s['title']}" for s in parts[i]])
|
||||
path = env.slot(f"card-{_safe(norm)}-r0-{'ab'[i]}.md")
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _payload(result, p=path):
|
||||
t = p.read_text(encoding="utf-8") if p.exists() else ""
|
||||
sec = _first_section(t)
|
||||
return t if sec and sec.get("md", "").strip() else None
|
||||
|
||||
return await run_single_slot(
|
||||
env.ctx, f"Writer {card['block']} ({i + 1}/2)",
|
||||
key=f"{env.guide_id}-w-{_safe(norm)}-r0-{'ab'[i]}",
|
||||
prompt=_prompt("Guide-Writer-Board", topic=env.topic, format_name=env.format,
|
||||
chapter=card.get("chapter") or "Inhalte",
|
||||
assignment=assignment, ziele=ziele_text,
|
||||
facts=_card_facts(env, card["block"]), gaps="\n" + hints[i] + "\n",
|
||||
spec=env.spec, out_path=path, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_payload,
|
||||
timeout=_timeout("writer", 1))
|
||||
|
||||
results = await asyncio.gather(_one(0), _one(1))
|
||||
if any(s == CANCELLED for s, _ in results):
|
||||
return False
|
||||
if any(s == FAILED for s, _ in results):
|
||||
return None
|
||||
return _merge_split_sections(_first_section(results[0][1]), _first_section(results[1][1]))
|
||||
|
||||
|
||||
async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
norm = card["block_norm"]
|
||||
ziele = await db.list_lernziele(env.topic, norm)
|
||||
@@ -188,6 +276,17 @@ async def _stage_writer(env: _Env, card: dict) -> bool:
|
||||
gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
|
||||
"below, cut the listed ballast, keep everything else as-is.\n"
|
||||
f"PREVIOUS VERSION:\n{card.get('md', '')}\n\nGAPS/BALLAST:\n{card['gate_info']}\n")
|
||||
# oversized first drafts: two halves, merged into one canonical section
|
||||
if card["writer_rounds"] == 0 and len(env.subs_by_title.get(card["block"], [])) > WRITER_SPLIT_SUBS:
|
||||
text = await _write_split(env, card, ziele_text)
|
||||
if text is False:
|
||||
return False
|
||||
if text is None:
|
||||
await _set(env, card, status="error", gate_info="Writer (Split) ohne Ergebnis")
|
||||
return False
|
||||
await _set(env, card, md=text, stage="fakten_gate", status="open")
|
||||
return True
|
||||
|
||||
path = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@@ -251,7 +350,7 @@ async def _stage_fakten_gate(env: _Env, card: dict) -> bool:
|
||||
prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
|
||||
section=card["md"], claims=claims_text, facts=facts,
|
||||
out_path=fixp, extra=_extra(env.instructions)),
|
||||
role="guide", capabilities="files", payload=_fixload, # Fix ≠ Gate-Modell (kein Selbst-Check)
|
||||
role="guide", capabilities="files", payload=_fixload, # Fix auf der Schreib-Rolle, Gate auf der Judge-Rolle
|
||||
timeout=_timeout("fakten_gate", 1))
|
||||
if fstatus == CANCELLED:
|
||||
return False
|
||||
@@ -372,23 +471,31 @@ _STAGE_FN = {"lernziele": _stage_lernziele, "zuweisung": _stage_zuweisung,
|
||||
|
||||
async def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
|
||||
async with sem:
|
||||
while card["stage"] != "done":
|
||||
if is_guide_cancelled(env.guide_id):
|
||||
await _set(env, card, status="open") # no longer being worked
|
||||
return
|
||||
fn = _STAGE_FN.get(card["stage"])
|
||||
if fn is None: # unknown stage → park as error
|
||||
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
|
||||
return
|
||||
if card["status"] != "active":
|
||||
await _set(env, card, status="active") # live board: this card is being worked
|
||||
try:
|
||||
if not await fn(env, card):
|
||||
return
|
||||
except Exception as e:
|
||||
log.exception("[%s] guide card %s failed", env.topic, card["block"])
|
||||
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
|
||||
try:
|
||||
await _run_card_inner(env, card)
|
||||
finally:
|
||||
_live_info.pop((env.topic, env.format, card["block_norm"]), None)
|
||||
|
||||
|
||||
async def _run_card_inner(env: _Env, card: dict) -> None:
|
||||
while card["stage"] != "done":
|
||||
if is_guide_cancelled(env.guide_id):
|
||||
await _set(env, card, status="open") # no longer being worked
|
||||
return
|
||||
fn = _STAGE_FN.get(card["stage"])
|
||||
if fn is None: # unknown stage → park as error
|
||||
await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
|
||||
return
|
||||
if card["status"] != "active":
|
||||
await _set(env, card, status="active") # live board: this card is being worked
|
||||
_live(env, card, STAGE_LABELS.get(card["stage"], card["stage"]) + "…")
|
||||
try:
|
||||
if not await fn(env, card):
|
||||
return
|
||||
except Exception as e:
|
||||
log.exception("[%s] guide card %s failed", env.topic, card["block"])
|
||||
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300])
|
||||
return
|
||||
|
||||
|
||||
# ── Orchestration ──────────────────────────────────────────────────────────────────
|
||||
@@ -498,16 +605,35 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
|
||||
views = []
|
||||
for c in in_stage[:limit]:
|
||||
zc = ziele.get(c["block_norm"])
|
||||
views.append({"title": c["block"],
|
||||
info = c["gate_info"][:200] if c["status"] == "error" else ""
|
||||
if c["status"] == "active":
|
||||
info = _live_info.get((topic, format_name, c["block_norm"]), "") or info
|
||||
views.append({"title": c["block"], "card_id": c["block_norm"],
|
||||
"status": c["status"] if c["status"] in ("error", "active") else "open",
|
||||
"rounds": c["writer_rounds"],
|
||||
"info": c["gate_info"][:200] if c["status"] == "error" else "",
|
||||
"info": info,
|
||||
"ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
|
||||
columns.append({"key": stage, "label": STAGE_LABELS[stage],
|
||||
"total": len(in_stage), "cards": views})
|
||||
return {"columns": columns}
|
||||
|
||||
|
||||
async def reset_card(topic: str, format_name: str, block_norm: str, ab_stage: int) -> bool:
|
||||
"""Reset ONE guide card to a stage (single-card variant of reset_from_stage):
|
||||
fields re-zeroed, md only wiped for writer(2) and earlier, lernziele only for 0."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(topic, format_name)}
|
||||
if block_norm not in cards:
|
||||
return False
|
||||
fields = dict(stage=GUIDE_STAGES[ab_stage], status="open", writer_rounds=0, gate_info="")
|
||||
if ab_stage <= 2:
|
||||
fields["md"] = ""
|
||||
if ab_stage == 0:
|
||||
await db.delete_lernziele(topic, block_norm)
|
||||
await db.set_guide_card(topic, format_name, block_norm, **fields)
|
||||
return True
|
||||
|
||||
|
||||
async def reset_from_stage(topic: str, format_name: str, ab_stage: int) -> int:
|
||||
"""Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
|
||||
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))
|
||||
|
||||
@@ -13,8 +13,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from agents import run_agent
|
||||
from config import DEFAULT_PROVIDER
|
||||
from database import create_element, list_elements, get_block_hurdles
|
||||
from elements import generate_element
|
||||
from database import get_block_hurdles
|
||||
from jsonio import parse_json_text as _parse_json_text
|
||||
from pipeline import _prompt, _problems_schema
|
||||
from textkit import _norm_title
|
||||
@@ -637,21 +636,3 @@ async def block_discussion(
|
||||
return None
|
||||
|
||||
|
||||
async def create_block_element(topic: str, block: str, section: str, provider: str = DEFAULT_PROVIDER) -> None:
|
||||
"""Background task after completion: register the block as an element.
|
||||
|
||||
Dedup via normalized title — if an element for the block already exists,
|
||||
nothing happens. Must never raise an exception to the outside.
|
||||
"""
|
||||
try:
|
||||
existing = {_norm_title(e["title"]) for e in await list_elements(topic)}
|
||||
if _norm_title(block) in existing:
|
||||
return
|
||||
fields = await generate_element(topic, hint=block, provider=provider, extra_context=section)
|
||||
if _norm_title(fields["title"]) in existing:
|
||||
return
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await create_element({"id": str(uuid.uuid4()), "topic": topic, **fields, "created_at": now, "updated_at": now})
|
||||
log.info("[%s] Block registered as element: %s", topic, fields["title"])
|
||||
except Exception:
|
||||
log.warning("[%s] Element registration after exam failed (%s)", topic, block, exc_info=True)
|
||||
|
||||
@@ -9,6 +9,8 @@ from logsetup import setup_logging
|
||||
setup_logging()
|
||||
|
||||
from config import FRONTEND_DIST, STORAGE_DIR
|
||||
import agents
|
||||
import database
|
||||
from database import init_db, close_db
|
||||
from guide import reconcile_guides
|
||||
from routes import router
|
||||
@@ -18,6 +20,7 @@ from routes import router
|
||||
async def lifespan(app: FastAPI):
|
||||
(STORAGE_DIR / "topics").mkdir(parents=True, exist_ok=True)
|
||||
await init_db()
|
||||
agents.on_event = database.add_event # pipeline history sink (agents.py stays DB-free)
|
||||
await reconcile_guides()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
@@ -39,6 +39,18 @@ class BlocksCreateRequest(BaseModel):
|
||||
research: bool = True # False = Continue: drain the existing kanban queue, no new search
|
||||
|
||||
|
||||
class BlocksCardRestartRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
card_id: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class GuideCardResetRequest(BaseModel):
|
||||
topic: str = Field(min_length=1)
|
||||
format: str = Field(min_length=1)
|
||||
block_norm: str = Field(min_length=1, max_length=200)
|
||||
ab_stage: int = Field(ge=0, le=5)
|
||||
|
||||
|
||||
class BlocksResetStageRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
board: Literal["inventory", "artefacts"]
|
||||
@@ -134,76 +146,6 @@ class GuideChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
class ElementResponse(BaseModel):
|
||||
id: str
|
||||
topic: str
|
||||
title: str
|
||||
description: str = ""
|
||||
examples: list[str] = []
|
||||
hints: list[str] = []
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ElementCreateRequest(BaseModel):
|
||||
topic: str = Field(min_length=1, max_length=100)
|
||||
hint: str = Field(default="", max_length=500)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, max_length=200)
|
||||
description: str | None = None
|
||||
examples: list[str] | None = None
|
||||
hints: list[str] | None = None
|
||||
|
||||
|
||||
class ElementCheckRequest(BaseModel):
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementSuggestion(BaseModel):
|
||||
text: str
|
||||
target: Literal["description", "examples", "hints"]
|
||||
content: str
|
||||
|
||||
|
||||
class ElementCheckResponse(BaseModel):
|
||||
suggestions: list[ElementSuggestion]
|
||||
|
||||
|
||||
class ElementStyleChange(BaseModel):
|
||||
text: str
|
||||
action: Literal["remove", "adjust", "add"]
|
||||
target: Literal["title", "description", "examples", "hints"]
|
||||
index: int | None = None
|
||||
content: str = ""
|
||||
|
||||
|
||||
class ElementStyleResponse(BaseModel):
|
||||
changes: list[ElementStyleChange]
|
||||
|
||||
|
||||
class ElementChatRequest(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementChatResponse(BaseModel):
|
||||
reply: str
|
||||
changes: list[ElementStyleChange] = []
|
||||
|
||||
|
||||
class ElementRefineRequest(BaseModel):
|
||||
suggestion: ElementStyleChange
|
||||
instruction: str = Field(min_length=1, max_length=2000)
|
||||
provider: ProviderType = "claude"
|
||||
|
||||
|
||||
class ElementRefineResponse(BaseModel):
|
||||
change: ElementStyleChange
|
||||
|
||||
|
||||
class ProgressUpdate(BaseModel):
|
||||
chapter: str = Field(min_length=1, max_length=100)
|
||||
done: bool
|
||||
|
||||
@@ -196,10 +196,11 @@ async def _race(topic: str, label: str, slots: list[dict], quorum: int, timeout:
|
||||
|
||||
def spawn(i: int) -> None:
|
||||
slot = slots[i]
|
||||
lbl = slot.get("label") or (label if len(slots) == 1 else f"{label} {i + 1}")
|
||||
task = asyncio.create_task(run_agent(
|
||||
slot["key"], slot["prompt"], timeout,
|
||||
provider=provider, role=slot["role"], capabilities=slot["capabilities"],
|
||||
scope=topic, on_line=slot.get("on_line"),
|
||||
scope=topic, on_line=slot.get("on_line"), label=lbl,
|
||||
))
|
||||
tasks[task] = i
|
||||
|
||||
|
||||
@@ -13,28 +13,24 @@ from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
list_block_progress, get_block_progress, set_open_question,
|
||||
set_block_score_and_streak, set_block_completed,
|
||||
set_block_score_and_streak,
|
||||
delete_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
|
||||
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
|
||||
get_sub_artefakte, kanban_reset, delete_guide_board,
|
||||
)
|
||||
from blocks import generate_blocks, cancel_blocks, blocks_status, active_blocks, reset_blocks, load_source, load_overview, subblocks_title, subblocks_frei, load_question_pattern, load_question_pattern_free, _blocks_files
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage
|
||||
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion
|
||||
from learning import block_chat, block_discussion, create_block_element, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
|
||||
from guide import generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||
from board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
|
||||
from learning import block_chat, block_discussion, exam_rating, exam_rating_fast, exam_question, exam_question_variant, generate_quiz, generate_gapchoice, generate_gaptext, check_gaptext, hurdles_distractor_block, compute_score, floor_from_score, level_from_score, cap_final, cap_aktuell, freie_level, thresholds, points_delta, cap_followup
|
||||
from guide import chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
|
||||
from pipeline import cancel_guide
|
||||
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksStatusResponse,
|
||||
BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
|
||||
GuideCardResetRequest,
|
||||
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
|
||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ElementRefineRequest, ElementRefineResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
|
||||
BlockChatRequest, BlockChatResponse,
|
||||
@@ -177,14 +173,14 @@ async def get_blocks_board(topic: str):
|
||||
snap["generating"] = status["generating"]
|
||||
snap["progress"] = status["progress"]
|
||||
snap["error"] = status["error"]
|
||||
snap["agents"] = [{"label": a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
for a in active_agents(f"blocks-{topic}-")]
|
||||
return snap
|
||||
|
||||
|
||||
@router.get("/blocks/agents")
|
||||
async def get_blocks_agents(topic: str):
|
||||
return [{"label": a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
return [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(f"blocks-{topic}-"), "runtime": a["runtime"]}
|
||||
for a in active_agents(f"blocks-{topic}-")]
|
||||
|
||||
|
||||
@@ -214,6 +210,28 @@ async def requeue_blocks_dead(topic: str):
|
||||
return {"ok": True, "requeued": await requeue_dead(topic)}
|
||||
|
||||
|
||||
@router.post("/blocks/card-restart")
|
||||
async def blocks_card_restart(req: BlocksCardRestartRequest):
|
||||
if (await blocks_status(req.topic))["generating"]:
|
||||
return {"ok": True, "status": "generating"}
|
||||
if not await restart_artefact_card(req.topic, req.card_id):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/board/card-reset")
|
||||
async def guide_card_reset(req: GuideCardResetRequest):
|
||||
running = [g for g in await list_guides()
|
||||
if g["topic"] == req.topic and g["format"] == req.format
|
||||
and g["status"] in ("queued", "generating")]
|
||||
if running:
|
||||
return {"ok": True, "status": "generating"}
|
||||
from guide_board import reset_card
|
||||
if not await reset_card(req.topic, req.format, req.block_norm, req.ab_stage):
|
||||
raise HTTPException(404, "Card not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/blocks/cancel")
|
||||
async def cancel_blocks_route(topic: str):
|
||||
if not cancel_blocks(topic):
|
||||
@@ -361,11 +379,10 @@ def _color(points: int) -> str:
|
||||
async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int]) -> dict:
|
||||
"""Book score+streak drift-free (lock + open-question/open-streak anchor). Tier →
|
||||
points delta (streak-modulated) or progressive malus on error. cap_aktuell is derived
|
||||
from the base (delayed unlock at the level threshold); element once from beginner level.
|
||||
from the base (delayed unlock at the level threshold).
|
||||
Re-rating of the same question uses the open streak anchor → idempotent."""
|
||||
async with _check_lock(req.topic, req.block):
|
||||
state = await get_block_progress(req.topic, req.block)
|
||||
was_level = state["completed"] is not None # element guard: ever created already?
|
||||
basis, re_rating = _basis(state, question)
|
||||
streak_basis = state["offene_streak"] if re_rating else state["streak"]
|
||||
if not re_rating:
|
||||
@@ -378,10 +395,6 @@ async def _book_score(req, question: str, tier: str, n_je_level: dict[int, int])
|
||||
score = compute_score(basis, d, floor, ca, cf)
|
||||
points = score - basis
|
||||
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak)
|
||||
# Create the learning element once, as soon as the first level (beginner) is reached.
|
||||
if not was_level and level_from_score(score, cf) is not None:
|
||||
if await set_block_completed(req.topic, req.block):
|
||||
asyncio.create_task(create_block_element(req.topic, req.block, req.section, req.provider))
|
||||
return {"points": points, "rating": _color(points), "good_answers": good, "streak": streak, "cap": cf}
|
||||
|
||||
|
||||
@@ -571,7 +584,7 @@ async def get_guide_board(topic: str, format: str = "Guide"):
|
||||
snap["progress"] = guide.get("progress") if guide else None
|
||||
snap["error"] = guide.get("error_msg") if guide else None
|
||||
prefix = f"{guide['id']}-" if guide else "-"
|
||||
snap["agents"] = [{"label": a["key"].removeprefix(prefix), "runtime": a["runtime"]}
|
||||
snap["agents"] = [{"key": a["key"], "label": a["label"] or a["key"].removeprefix(prefix), "runtime": a["runtime"]}
|
||||
for a in active_agents(prefix)]
|
||||
return snap
|
||||
|
||||
@@ -658,82 +671,6 @@ async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
|
||||
return res
|
||||
|
||||
|
||||
# --- Elements (personal summary) ---
|
||||
|
||||
@router.get("/elements", response_model=list[ElementResponse])
|
||||
async def get_elements(topic: str):
|
||||
return await list_elements(topic)
|
||||
|
||||
|
||||
@router.post("/elements", response_model=ElementResponse)
|
||||
async def post_element(req: ElementCreateRequest):
|
||||
fields = await generate_element(req.topic, req.hint, provider=req.provider)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
|
||||
await create_element(element)
|
||||
return element
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
|
||||
async def element_chat(element_id: str, req: ElementChatRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element not found")
|
||||
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||||
return {"reply": reply, "changes": changes}
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
|
||||
async def element_refine(element_id: str, req: ElementRefineRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element not found")
|
||||
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||||
if change is None:
|
||||
raise HTTPException(502, "Revision failed — please try again")
|
||||
return {"change": change}
|
||||
|
||||
|
||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||||
async def put_element(element_id: str, req: ElementUpdateRequest):
|
||||
if await get_element(element_id) is None:
|
||||
raise HTTPException(404, "Element not found")
|
||||
fields = req.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if fields:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await update_element(element_id, **fields, updated_at=now)
|
||||
return await get_element(element_id)
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
|
||||
async def element_style(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element not found")
|
||||
changes = await style_element(element, provider=req.provider)
|
||||
if changes is None:
|
||||
raise HTTPException(502, "Style check failed — please try again")
|
||||
return {"changes": changes}
|
||||
|
||||
|
||||
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
|
||||
async def element_check(element_id: str, req: ElementCheckRequest):
|
||||
element = await get_element(element_id)
|
||||
if element is None:
|
||||
raise HTTPException(404, "Element not found")
|
||||
suggestions = await check_element(element, provider=req.provider)
|
||||
if suggestions is None:
|
||||
raise HTTPException(502, "Check failed — please try again")
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@router.delete("/elements/{element_id}")
|
||||
async def remove_element(element_id: str):
|
||||
if not await delete_element(element_id):
|
||||
raise HTTPException(404, "Element not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/guides/{guide_id}/cancel")
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
|
||||
@@ -57,26 +57,26 @@ async def board_env(testdb, tmp_path, monkeypatch):
|
||||
return False
|
||||
monkeypatch.setattr(bi, "_emb_ok", no_emb)
|
||||
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
||||
async def fake_subblocks(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||
title = list(entries.values())[0].split(" — ")[0]
|
||||
return {title: ["Sub Eins", "Sub Zwei"]}
|
||||
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns=""):
|
||||
async def fake_facts(ctx, set_p, files, raw, q, folder, instructions, ns="", lbl=""):
|
||||
facts = {t: {_norm_title(s): {"key_points": [f"Fakt zu {s}"], "cited_facts": []}
|
||||
for s in subs} for t, subs in raw.items()}
|
||||
return facts, {}
|
||||
|
||||
async def fake_levels(ctx, set_p, files, raw, instructions, ns=""):
|
||||
async def fake_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
return {t: [{"title": s, "level": "beginner"} for s in subs] for t, subs in raw.items()}
|
||||
|
||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns=""):
|
||||
async def fake_relevance(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {1: "relevant", 2: "peripheral"}
|
||||
|
||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns=""):
|
||||
async def fake_pattern(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {t: [{"subblock": subs[0]["title"], "question": f"Was ist {t}?"}]
|
||||
for t, subs in sidecar.items()}
|
||||
|
||||
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns=""):
|
||||
async def fake_artefacts(ctx, set_p, files, sidecar, instructions, ns="", lbl=""):
|
||||
return {"flashcard": [{"block": t, "subblock": subs[0]["title"], "front": "F", "back": "B"}
|
||||
for t, subs in sidecar.items()], "example": []}
|
||||
|
||||
@@ -185,7 +185,7 @@ async def test_empty_subblocks_completes_without_deadletter(board_env, monkeypat
|
||||
import blocks as blx
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns=""):
|
||||
async def empty_subs(ctx, set_p, files, entries, instructions, wipe=True, ns="", seeds=None, lbl=""):
|
||||
return {}
|
||||
monkeypatch.setattr(ba, "_subblocks_block", empty_subs)
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "leer", "ablock", "subblocks",
|
||||
@@ -208,3 +208,304 @@ async def test_reader_union_folds_exact_dupes(testdb):
|
||||
assert set(card["payload"]["readers"]) == {"r1", "r2"}
|
||||
assert set(card["payload"]["sources"]) == {"s1", "s2"}
|
||||
assert card["payload"]["description"] == "d länger"
|
||||
|
||||
|
||||
# ── Fragment-Filter: Zweitmeinung, Containment, Floor, Supplement-Reopen ────────────
|
||||
|
||||
def _slot_router(handlers, counter=None):
|
||||
"""Fully scripted judge: first matching key-substring wins, its JSON lands at out_path."""
|
||||
async def fake(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
m = _PATH_RE.search(prompt)
|
||||
out = None
|
||||
for pat, h in handlers:
|
||||
if pat in key:
|
||||
if counter is not None:
|
||||
counter[pat] = counter.get(pat, 0) + 1
|
||||
out = h(key) if callable(h) else h
|
||||
break
|
||||
if m and out is not None:
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f)
|
||||
return "ok", payload(None)
|
||||
return fake
|
||||
|
||||
|
||||
def _mk_flow(tmp_path):
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(topic=TOPIC, work_dir=tmp_path, state={}, wake=asyncio.Event())
|
||||
|
||||
|
||||
async def _run_filter(db, ctx, tmp_path, cards):
|
||||
"""Seed block cards into fragment_filter and run ONE barrier pass over them."""
|
||||
for cid, p in cards:
|
||||
await db.kanban_upsert_card(TOPIC, B, cid, "block", "fragment_filter", p)
|
||||
rows = [{"card_id": cid, "payload": dict(p)} for cid, p in cards]
|
||||
await bi._proc_fragment_filter(ctx, _mk_flow(tmp_path), rows)
|
||||
|
||||
|
||||
def _confirm_votes(votes, verdict):
|
||||
"""Recheck judge j∈votes returns `verdict`, the rest keep everything."""
|
||||
return lambda key: verdict if key.rsplit("-j", 1)[1] in votes else {"fragments": {}, "drop": []}
|
||||
|
||||
|
||||
async def test_panel_confirms_demote(board_env, tmp_path, monkeypatch):
|
||||
"""Judge-Demote ist nur Vorschlag — 2 Panel-Stimmen bestätigen → rejected."""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
]))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
|
||||
assert c1["stage"] == "rejected"
|
||||
assert c1["payload"]["reason"] == "fragment"
|
||||
assert c1["payload"]["parent_norm"] == "codeblock"
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-2"))["stage"] == "grouping"
|
||||
|
||||
|
||||
async def test_panel_overrules_single_vote(board_env, tmp_path, monkeypatch):
|
||||
"""Nur 1 von 3 Panel-Stimmen bestätigt den Judge-Demote → Karte überlebt (Journal)."""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
]))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
])
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping"
|
||||
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||
assert journal["ueberstimmt"] == ["Blockzitat"]
|
||||
assert journal["degradiert"] == 0
|
||||
|
||||
|
||||
async def test_containment_autoconfirm_skips_panel(board_env, tmp_path, monkeypatch):
|
||||
"""Proposal mit Namens-Containment wird deterministisch committet — ohne Recheck-Call."""
|
||||
db, ctx, files = board_env
|
||||
counter = {}
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", {"fragments": {}, "drop": []}),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
], counter))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
|
||||
("b-2", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
|
||||
])
|
||||
c1 = await db.kanban_get_card(TOPIC, B, "b-1")
|
||||
assert c1["stage"] == "rejected"
|
||||
assert c1["payload"]["parent_norm"] == "aufgabenlisten"
|
||||
assert "-filter-recheck-" not in counter # no panel needed
|
||||
|
||||
|
||||
async def test_floor_vetoes_structureless_demote(board_env, tmp_path, monkeypatch):
|
||||
"""Orthogonale Titel-Vektoren: bestätigter Judge-Demote ohne Containment wird vetot,
|
||||
der Containment-Demote nicht."""
|
||||
import numpy as np
|
||||
db, ctx, files = board_env
|
||||
|
||||
async def emb_on(flow):
|
||||
return True
|
||||
|
||||
async def ortho_vecs(flow, texts):
|
||||
uniq = list(dict.fromkeys(texts))
|
||||
eye = np.eye(max(2, len(uniq)))
|
||||
pos = {t: eye[i] for i, t in enumerate(uniq)}
|
||||
return np.vstack([pos[t] for t in texts])
|
||||
|
||||
monkeypatch.setattr(bi, "_emb_ok", emb_on)
|
||||
monkeypatch.setattr(bi, "_vec_rows", ortho_vecs)
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2, "3": 4}, "drop": []}),
|
||||
]))
|
||||
await _run_filter(db, ctx, tmp_path, [
|
||||
("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"}),
|
||||
("b-3", {"title": "Aufgabenlisten (Task Lists)", "description": "Checkboxen"}),
|
||||
("b-4", {"title": "Aufgabenlisten", "description": "GFM-Listen mit Checkbox"}),
|
||||
])
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-1"))["stage"] == "grouping" # floor veto
|
||||
assert (await db.kanban_get_card(TOPIC, B, "b-3"))["stage"] == "rejected" # containment holds
|
||||
journal = json.loads(next(tmp_path.glob("inventar-filter-*.json")).read_text(encoding="utf-8"))
|
||||
assert journal["floor_veto"] == ["Blockzitat"]
|
||||
|
||||
|
||||
async def test_filter_resume_no_new_calls(board_env, tmp_path, monkeypatch):
|
||||
"""Zweiter Lauf über identischem Zustand resumed alle Judge-Dateien: 0 neue Calls."""
|
||||
db, ctx, files = board_env
|
||||
counter = {}
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-filter-recheck-", _confirm_votes({"1", "2"}, {"fragments": {"1": 2}, "drop": []})),
|
||||
("-filter-", {"fragments": {"1": 2}, "drop": []}),
|
||||
], counter))
|
||||
cards = [("b-1", {"title": "Blockzitat", "description": "Zitat mit >"}),
|
||||
("b-2", {"title": "Codeblock", "description": "Code mit Einrückung"})]
|
||||
await _run_filter(db, ctx, tmp_path, cards)
|
||||
first = dict(counter)
|
||||
assert first["-filter-"] == 1 and first["-filter-recheck-"] == 3
|
||||
await _run_filter(db, ctx, tmp_path, cards)
|
||||
assert counter == first
|
||||
|
||||
|
||||
async def test_supplement_reopens_dead_lineage(board_env, tmp_path, monkeypatch):
|
||||
"""Vorschlag trifft einen wegdegradierten Titel → Lineage wird wiedereröffnet;
|
||||
failed-quorum bleibt dedupt; frische Titel landen normal im ingest."""
|
||||
db, ctx, files = board_env
|
||||
monkeypatch.setattr(bi, "run_single_slot", _slot_router([
|
||||
("-supplement", {"blocks": [
|
||||
{"title": "Blockzitate", "description": "Zitat-Syntax"},
|
||||
{"title": "Leerzeilen", "description": "Trenner"},
|
||||
{"title": "Neu-Konzept", "description": "fehlt kanonisch"},
|
||||
]}),
|
||||
]))
|
||||
# dead lineage: title → cluster cl-1 → block b-cl-1 demoted as fragment
|
||||
await db.kanban_add_title(TOPIC, B, "blockzitate", "Blockzitate", "d", "s1", "r1")
|
||||
await db.kanban_advance(TOPIC, B, "blockzitate", "clustered")
|
||||
await db.kanban_set_member(TOPIC, "blockzitate", "cl-1")
|
||||
await db.kanban_upsert_card(TOPIC, B, "cl-1", "cluster", "done_cluster", {"title": "Blockzitate"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-cl-1", "block", "rejected",
|
||||
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
|
||||
"cluster": "cl-1", "parent_norm": "codeblöcke"})
|
||||
# failed-quorum lineage: deliberately rejected as non-block → must stay deduped
|
||||
await db.kanban_add_title(TOPIC, B, "leerzeilen", "Leerzeilen", "d", "s1", "r1")
|
||||
await db.kanban_advance(TOPIC, B, "leerzeilen", "clustered")
|
||||
await db.kanban_set_member(TOPIC, "leerzeilen", "cl-2")
|
||||
await db.kanban_upsert_card(TOPIC, B, "cl-2", "cluster", "rejected",
|
||||
{"title": "Leerzeilen", "reason": "failed-quorum"})
|
||||
flow = _mk_flow(tmp_path)
|
||||
flow.state["instructions"] = ""
|
||||
await bi._supplement_producer(ctx, flow, ["Codeblöcke"])
|
||||
reopened = await db.kanban_get_card(TOPIC, B, "blockzitate")
|
||||
assert reopened["stage"] == "cluster"
|
||||
assert reopened["payload"]["supplement"] is True
|
||||
assert (await db.kanban_get_card(TOPIC, B, "leerzeilen"))["stage"] == "clustered"
|
||||
fresh = await db.kanban_get_card(TOPIC, B, "neu-konzept")
|
||||
assert fresh and fresh["stage"] == "ingest" and fresh["payload"]["supplement"] is True
|
||||
|
||||
|
||||
# ── Makespan: Slot-Priorität, vorgezogene Gliederung ────────────────────────────────
|
||||
|
||||
def test_agent_priority_order():
|
||||
"""Board 1 zuerst; in Board 2 gewinnen späte Stages (Restarbeit vor Nachschub)."""
|
||||
from agents import _agent_priority as p
|
||||
t = "blocks-Markdown"
|
||||
assert p(f"{t}-research-1") < p(f"{t}-filter-abc-c0") < p(f"{t}-supplement")
|
||||
assert (p(f"{t}-outline-judge") < p(f"{t}-ns-artifact-example-c0")
|
||||
< p(f"{t}-ns-question-pattern-c0") < p(f"{t}-ns-relevance-final-c0")
|
||||
< p(f"{t}-ns-level-final-c0") < p(f"{t}-ns-facts-erg-c0")
|
||||
< p(f"{t}-ns-subblock-c1-r2-1"))
|
||||
assert p(f"{t}-supplement") < p(f"{t}-outline-1")
|
||||
assert p("guide-t-writer-k1") == 16 # unmatched → after everything
|
||||
|
||||
|
||||
async def test_outline_runs_before_artefacts_finish(board_env, monkeypatch):
|
||||
"""Gliederung startet, sobald alle Karten die facts-Stage passiert haben —
|
||||
parallel zu den restlichen Artefakt-Stages des langsamsten Blocks."""
|
||||
import asyncio
|
||||
import board_artefacts as ba
|
||||
db, ctx, files = board_env
|
||||
await _seed(db)
|
||||
base_levels = ba._levels_block
|
||||
snapshot = {}
|
||||
|
||||
async def slow_levels(ctx, set_p, files, raw, instructions, ns="", lbl=""):
|
||||
await asyncio.sleep(0.8) # keeps one card in `levels` while the outline fires
|
||||
return await base_levels(ctx, set_p, files, raw, instructions, ns=ns, lbl=lbl)
|
||||
|
||||
base_outline = ba._outline_block
|
||||
|
||||
async def spy_outline(ctx, set_p, files, entries, instructions):
|
||||
cards = await db.kanban_cards(TOPIC, board="artefacts", kind="ablock")
|
||||
snapshot["unfinished"] = sum(1 for c in cards if c["stage"] != "done_artefact")
|
||||
return await base_outline(ctx, set_p, files, entries, instructions)
|
||||
|
||||
monkeypatch.setattr(ba, "_levels_block", slow_levels)
|
||||
monkeypatch.setattr(ba, "_outline_block", spy_outline)
|
||||
ok = await asyncio.wait_for(
|
||||
bi.run_boards(ctx, lambda *a, **k: None, files, {"type": "thema"}, None, "", research=False),
|
||||
timeout=30)
|
||||
assert ok
|
||||
assert snapshot["unfinished"] > 0 # outline ran while blocks were still in levels+
|
||||
outline = await db.get_outline(TOPIC)
|
||||
assert outline and "Kapitel 1" in outline
|
||||
|
||||
|
||||
async def test_outline_facts_from_payloads(board_env, tmp_path):
|
||||
"""_proc_outline speist die Prereq-Hints aus den Karten-Payloads —
|
||||
unabhängig vom globalen facts.json (das erst finalize schreibt)."""
|
||||
import board_artefacts as ba
|
||||
db, ctx, files = board_env
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "done_block",
|
||||
{"title": "Alpha", "description": "d"})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "alpha", "ablock", "levels",
|
||||
{"title": "Alpha", "facts": {"Alpha": {"sub eins": {
|
||||
"sub": "Sub Eins", "prerequisites": "Beta zuerst"}}}})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "outline", "outline", "outline",
|
||||
{"title": "Gliederung"})
|
||||
flow = _mk_flow(tmp_path)
|
||||
await ba._proc_outline(ctx, flow, files, "", [{"card_id": "outline", "payload": {}}])
|
||||
merged = json.loads((tmp_path / "outline-facts.json").read_text(encoding="utf-8"))
|
||||
assert merged["Alpha"]["sub eins"]["prerequisites"] == "Beta zuerst"
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "outline"))["stage"] == "done_artefact"
|
||||
|
||||
|
||||
async def test_card_view_stepper(testdb):
|
||||
"""Aktive artefacts-Karte mit Step-Name → step_i/step_n; Alt-String bleibt tolerierbar."""
|
||||
r = {"board": "artefacts", "card_id": "alpha", "stage": "facts", "retries": 0,
|
||||
"payload": {"title": "Alpha"}}
|
||||
v = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "Facts check 1/2…", "step": "Facts check"}})
|
||||
assert v["status"] == "active" and v["info"] == "Facts check 1/2…"
|
||||
assert v["step_i"] == 2 and v["step_n"] == 3 and v["steps"][0] == "Facts find"
|
||||
# legacy plain-string live info → no stepper, no crash
|
||||
v2 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": "Facts find 0/1…"})
|
||||
assert v2["info"] == "Facts find 0/1…" and "step_n" not in v2
|
||||
# step outside the card's stage group (e.g. supplement note) → no stepper
|
||||
v3 = bi._card_view(r, {"artefacts:alpha"}, {"artefacts:alpha": {"msg": "x", "step": "Subblocks find"}})
|
||||
assert "step_n" not in v3
|
||||
|
||||
|
||||
async def test_seed_map_resolves_cascade(testdb):
|
||||
"""Seeds folgen der Redirect-Kette bis zum lebenden Block; Zyklen/Dead-Ends verfallen."""
|
||||
import board_artefacts as ba
|
||||
db = testdb
|
||||
# chain: fragment → grouped member → living umbrella (with self-edge Listen→Listen)
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-1", "block", "rejected",
|
||||
{"title": "Blockzitate (Blockquotes)", "reason": "fragment",
|
||||
"parent_norm": "eingerückte codeblöcke"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-2", "block", "grouped",
|
||||
{"title": "Eingerückte Codeblöcke", "merged_into": "Codeblöcke"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-3", "block", "done_block",
|
||||
{"title": "Codeblöcke", "mirrored_norm": "codeblöcke"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-4", "block", "grouped",
|
||||
{"title": "Listen", "merged_into": "Listen"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-5", "block", "done_block",
|
||||
{"title": "Listen", "mirrored_norm": "listen"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-6", "block", "rejected",
|
||||
{"title": "Aufgabenlisten", "reason": "fragment", "parent_norm": "listen"})
|
||||
# cycle: a → b → a, neither alive
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-7", "block", "rejected",
|
||||
{"title": "A-Ding", "reason": "fragment", "parent_norm": "b-ding"})
|
||||
await db.kanban_upsert_card(TOPIC, B, "b-8", "block", "rejected",
|
||||
{"title": "B-Ding", "reason": "fragment", "parent_norm": "a-ding"})
|
||||
seeds = await ba._seed_map(TOPIC)
|
||||
# grouped umbrella members become seeds of their living target too (whole absorbed topics)
|
||||
assert {k: sorted(v) for k, v in seeds.items()} == {
|
||||
"codeblöcke": ["Blockzitate (Blockquotes)", "Eingerückte Codeblöcke"],
|
||||
"listen": ["Aufgabenlisten", "Listen"]}
|
||||
|
||||
|
||||
def test_per_block_functions_accept_wrapper_kwargs():
|
||||
"""Die board_artefacts-Wrapper übergeben ns/lbl (subblocks auch seeds) — ein fehlender
|
||||
Parameter stirbt sonst erst im Echt-Lauf als TypeError (Fakes verdecken die Signatur)."""
|
||||
import inspect
|
||||
import blocks as blx
|
||||
for fn in ("_subblocks_block", "_facts_block", "_levels_block", "_relevance_block",
|
||||
"_question_pattern_block", "_artefacts_block"):
|
||||
params = inspect.signature(getattr(blx, fn)).parameters
|
||||
assert "ns" in params and "lbl" in params, fn
|
||||
assert "seeds" in inspect.signature(blx._subblocks_block).parameters
|
||||
|
||||
174
backend/tests/test_events.py
Normal file
174
backend/tests/test_events.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Event-Tracking (events-Tabelle) + Agenten-Labels."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import agents
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
|
||||
|
||||
async def _events(db, kind=None):
|
||||
conn = await db.get_db()
|
||||
q = "SELECT topic, kind, key, label, status, dur_ms, wait_ms FROM events WHERE topic = ?"
|
||||
args = [TOPIC]
|
||||
if kind:
|
||||
q += " AND kind = ?"
|
||||
args.append(kind)
|
||||
cur = await conn.execute(q, args)
|
||||
return [dict(zip(("topic", "kind", "key", "label", "status", "dur_ms", "wait_ms"), r))
|
||||
for r in await cur.fetchall()]
|
||||
|
||||
|
||||
async def test_advance_many_writes_stage_events(testdb):
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||
await db.kanban_advance_many(TOPIC, "inventory", [("a", "s2"), ("b", "s2")])
|
||||
evs = await _events(db, "stage")
|
||||
assert {(e["key"], e["status"]) for e in evs} == {("inventory:a", "s2"), ("inventory:b", "s2")}
|
||||
|
||||
|
||||
async def test_fail_card_events_retry_then_dead(testdb):
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is False
|
||||
assert await db.kanban_fail_card(TOPIC, "inventory", "a", "boom", max_retries=2) is True
|
||||
evs = await _events(db, "fail")
|
||||
assert [e["status"] for e in evs] == ["retry1", "dead"]
|
||||
|
||||
|
||||
async def test_guide_stage_event(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.set_guide_card(TOPIC, "Guide", "alpha", stage="writer")
|
||||
evs = await _events(db, "stage")
|
||||
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and evs[-1]["status"] == "writer"
|
||||
|
||||
|
||||
async def test_run_agent_emits_event_and_survives_broken_sink(testdb, monkeypatch):
|
||||
recorded = []
|
||||
|
||||
async def sink(**kw):
|
||||
recorded.append(kw)
|
||||
|
||||
async def fake_cli(agent_key, prompt, timeout, model, capabilities, label=""):
|
||||
return 0, "out", ""
|
||||
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
monkeypatch.setattr(agents, "_run_claude_cli", fake_cli)
|
||||
monkeypatch.setattr(agents.shutil, "which", lambda c: "/bin/true")
|
||||
monkeypatch.setattr(agents, "resolve_role", lambda p, r: ("claude", "test-model"))
|
||||
rc, out, err = await agents.run_agent("blocks-t-x", "p", 5, provider="claude",
|
||||
role="judge", scope=TOPIC, label="Alpha · Judge")
|
||||
assert rc == 0
|
||||
assert recorded and recorded[0]["kind"] == "agent"
|
||||
assert recorded[0]["label"] == "Alpha · Judge" and recorded[0]["status"] == "ok"
|
||||
assert isinstance(recorded[0]["wait_ms"], int) and isinstance(recorded[0]["dur_ms"], int)
|
||||
|
||||
# broken sink never breaks the call; interactive/scope-less calls don't log
|
||||
async def broken(**kw):
|
||||
raise RuntimeError("sink down")
|
||||
monkeypatch.setattr(agents, "on_event", broken)
|
||||
rc, _, _ = await agents.run_agent("blocks-t-y", "p", 5, provider="claude", scope=TOPIC)
|
||||
assert rc == 0
|
||||
monkeypatch.setattr(agents, "on_event", sink)
|
||||
recorded.clear()
|
||||
await agents.run_agent("chat-1", "p", 5, provider="claude", lane="interactive")
|
||||
assert recorded == []
|
||||
|
||||
|
||||
async def test_active_agents_carry_labels():
|
||||
async def run(key, label):
|
||||
return await agents._communicate(key, ["sleep", "0.4"], None, 5, label=label)
|
||||
|
||||
t1 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 1"))
|
||||
t2 = asyncio.create_task(run("blocks-t-x", "Alpha · Facts 2")) # key collision → ~2
|
||||
await asyncio.sleep(0.15)
|
||||
agents_now = agents.active_agents("blocks-t-")
|
||||
assert sorted(a["label"] for a in agents_now) == ["Alpha · Facts 1", "Alpha · Facts 2"]
|
||||
assert {a["key"] for a in agents_now} == {"blocks-t-x", "blocks-t-x~2"}
|
||||
await asyncio.gather(t1, t2)
|
||||
assert agents.active_agents("blocks-t-") == []
|
||||
|
||||
async def test_pull_prefers_bigger_blocks(testdb):
|
||||
"""LPT: Karten mit größerem subs_n werden zuerst gezogen; ohne Feld bleibt FIFO."""
|
||||
db = testdb
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "klein", "ablock", "facts", {"subs_n": 5})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "gross", "ablock", "facts", {"subs_n": 40})
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", "mittel", "ablock", "facts", {"subs_n": 15})
|
||||
pulled = await db.kanban_pull(TOPIC, "artefacts", "facts", 10)
|
||||
assert [c["card_id"] for c in pulled] == ["gross", "mittel", "klein"]
|
||||
# ohne subs_n: FIFO nach updated_at
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "a", "block", "s1")
|
||||
await db.kanban_upsert_card(TOPIC, "inventory", "b", "block", "s1")
|
||||
pulled = await db.kanban_pull(TOPIC, "inventory", "s1", 10)
|
||||
assert [c["card_id"] for c in pulled] == ["a", "b"]
|
||||
|
||||
|
||||
async def test_learnstate_smoke(testdb):
|
||||
"""Regression: P5-Ausbau hatte die _LEVEL_CASE-Konstante mitgerissen —
|
||||
load_learnstate (Guide-Start-Pfad) muss ohne NameError laufen."""
|
||||
from rules import load_learnstate
|
||||
guides, progress, levels = await load_learnstate()
|
||||
assert isinstance(levels, dict)
|
||||
|
||||
|
||||
async def test_guide_error_event(testdb):
|
||||
db = testdb
|
||||
await db.upsert_guide_card(TOPIC, "Guide", "alpha", "Alpha")
|
||||
await db.set_guide_card(TOPIC, "Guide", "alpha", status="error", gate_info="Writer ohne Ergebnis")
|
||||
evs = await _events(db, "fail")
|
||||
assert evs and evs[-1]["key"] == "guide:Guide:alpha" and "Writer" in evs[-1]["status"]
|
||||
|
||||
|
||||
def test_timeout_calibration_smoke():
|
||||
from pipeline import _timeout
|
||||
assert _timeout("subblock", 10) == 400 + 150
|
||||
assert _timeout("content", 10) == 450 + 300
|
||||
|
||||
|
||||
def test_env_file_wins(tmp_path, monkeypatch):
|
||||
"""Regression: geerbte (veraltete) Env-Werte dürfen die .env nicht mehr überstimmen."""
|
||||
import config
|
||||
monkeypatch.setenv("X_CREATOR_TESTKEY", "alt")
|
||||
p = tmp_path / ".env"
|
||||
p.write_text("X_CREATOR_TESTKEY=neu\n", encoding="utf-8")
|
||||
config._load_env(p)
|
||||
import os
|
||||
assert os.environ["X_CREATOR_TESTKEY"] == "neu"
|
||||
|
||||
|
||||
async def test_restart_artefact_card_wipes_only_that_block(testdb):
|
||||
import board_inventory as bi
|
||||
db = testdb
|
||||
for norm in ("alpha", "beta"):
|
||||
await db.kanban_upsert_card(TOPIC, "artefacts", norm, "ablock", "done_artefact",
|
||||
{"title": norm.title(), "raw": {norm: ["S"]}, "facts": {}})
|
||||
await db.upsert_subblock(TOPIC, norm, "s1", norm.title(), "Sub Eins")
|
||||
await db.upsert_question_pattern(TOPIC, norm, "s1", norm.title(), "Sub Eins", "Frage?")
|
||||
await db.put_sub_artifact(TOPIC, norm, "s1", "flashcard", norm.title(), "Sub Eins", "{}")
|
||||
assert await bi.restart_artefact_card(TOPIC, "alpha") is True
|
||||
assert (await db.kanban_get_card(TOPIC, "artefacts", "alpha"))["stage"] == "subblocks"
|
||||
assert await db.list_subblocks(TOPIC, "alpha") == []
|
||||
assert len(await db.list_subblocks(TOPIC, "beta")) == 1 # untouched
|
||||
assert await bi.restart_artefact_card(TOPIC, "gibtsnicht") is False
|
||||
|
||||
|
||||
async def test_guide_reset_card_single(testdb):
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
for n in ("alpha", "beta"):
|
||||
await db.upsert_guide_card(TOPIC, "Guide", n, n.title())
|
||||
await db.set_guide_card(TOPIC, "Guide", n, stage="done", status="ok",
|
||||
writer_rounds=2, md="# SECTION Text", gate_info="x")
|
||||
await db.put_lernziel(TOPIC, n, "z1", "Ziel eins")
|
||||
assert await gb.reset_card(TOPIC, "Guide", "alpha", 0) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["alpha"]["stage"] == "lernziele" and cards["alpha"]["md"] == "" and cards["alpha"]["writer_rounds"] == 0
|
||||
assert cards["beta"]["stage"] == "done" and cards["beta"]["md"] # untouched
|
||||
assert await db.list_lernziele(TOPIC) and all(z["block_norm"] != "alpha" for z in await db.list_lernziele(TOPIC))
|
||||
# ab_stage 3 (fakten_gate) behält md
|
||||
assert await gb.reset_card(TOPIC, "Guide", "beta", 3) is True
|
||||
cards = {c["block_norm"]: c for c in await db.list_guide_cards(TOPIC, "Guide")}
|
||||
assert cards["beta"]["stage"] == "fakten_gate" and cards["beta"]["md"]
|
||||
@@ -66,3 +66,96 @@ async def test_done_step(testdb):
|
||||
assert await gb.done_step(TOPIC, FMT) == 3 # bis fakten_gate fertig
|
||||
await db.set_guide_card(TOPIC, FMT, "a", stage="done")
|
||||
assert await gb.done_step(TOPIC, FMT) == len(gb.GUIDE_STAGES)
|
||||
|
||||
|
||||
async def test_run_card_sets_and_clears_live_info(testdb, monkeypatch):
|
||||
"""Regression: _live nutzte env.format_name (existiert nicht) → AttributeError beim
|
||||
ersten Stage-Start. Treibt eine Karte durch _run_card mit Fake-Stage."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import guide_board as gb
|
||||
db = testdb
|
||||
await db.upsert_guide_card("t", "Guide", "alpha", "Alpha")
|
||||
env = SimpleNamespace(ctx=None, guide_id="g-live", topic="t", format="Guide")
|
||||
card = {"block_norm": "alpha", "block": "Alpha", "stage": "lernziele", "status": "open"}
|
||||
seen = {}
|
||||
|
||||
async def fake_stage(env2, card2):
|
||||
seen.update(dict(gb._live_info))
|
||||
card2["stage"] = "done"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(gb, "_STAGE_FN", {"lernziele": fake_stage})
|
||||
await gb._run_card(env, card, asyncio.Semaphore(1))
|
||||
assert card["stage"] == "done"
|
||||
assert ("t", "Guide", "alpha") in seen # live info stand während der Stage
|
||||
assert ("t", "Guide", "alpha") not in gb._live_info # und wurde aufgeräumt
|
||||
|
||||
|
||||
def test_merge_split_sections_one_section_all_markers():
|
||||
import guide_board as gb
|
||||
from textkit import _parse_fragment
|
||||
a = _parse_fragment("""<!-- section: Front Matter -->
|
||||
<!-- compact -->
|
||||
Kurzer Einstieg kompakt.
|
||||
<!-- sub: beginner | YAML-Basics -->
|
||||
YAML kompakt.
|
||||
<!-- ausführlich -->
|
||||
Einstieg ausführlich.
|
||||
<!-- sub: beginner | YAML-Basics -->
|
||||
YAML ausführlich.""")[0]
|
||||
b = _parse_fragment("""<!-- section: Front Matter (Teil 2) -->
|
||||
<!-- compact -->
|
||||
<!-- sub: advanced | TOML-Sektionen -->
|
||||
TOML kompakt.
|
||||
<!-- ausführlich -->
|
||||
Unerwünschter zweiter Einstieg.
|
||||
<!-- sub: advanced | TOML-Sektionen -->
|
||||
TOML ausführlich.""")[0]
|
||||
merged = gb._merge_split_sections(a, b)
|
||||
secs = _parse_fragment(merged)
|
||||
assert len(secs) == 1
|
||||
sec = secs[0]
|
||||
assert sec["title"] == "Front Matter"
|
||||
assert [s["title"] for s in sec["subs"]] == ["YAML-Basics", "TOML-Sektionen"]
|
||||
assert sec["anchor"] == "Einstieg ausführlich." # Teil-B-Einstieg verworfen
|
||||
assert "TOML ausführlich." in sec["md"] and "YAML kompakt." in sec["compact"]
|
||||
|
||||
|
||||
async def test_writer_splits_oversized_first_draft(testdb, monkeypatch, tmp_path):
|
||||
import guide_board as gb
|
||||
from types import SimpleNamespace
|
||||
db = testdb
|
||||
await db.upsert_guide_card("t", "Guide", "gross", "Gross")
|
||||
calls = []
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
calls.append(label)
|
||||
part = "2" if key.endswith("-b") else "1"
|
||||
p = tmp_path / f"out-{key[-1]}.md"
|
||||
p.write_text(f"<!-- section: Gross -->\n<!-- ausführlich -->\n"
|
||||
+ ("Einstieg.\n" if part == "1" else "")
|
||||
+ f"<!-- sub: beginner | Sub {part} -->\nText {part}.", encoding="utf-8")
|
||||
# payload liest die ECHTE Slot-Datei — wir schreiben direkt an deren Pfad
|
||||
import re as _re
|
||||
m = _re.search(r"(/\S+\.md)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(p.read_text(encoding="utf-8"))
|
||||
return "ok", payload(None)
|
||||
|
||||
monkeypatch.setattr(gb, "run_single_slot", fake_slot)
|
||||
subs = [{"title": f"Sub {i}", "level": "beginner", "relevance": "relevant"} for i in range(31)]
|
||||
env = SimpleNamespace(ctx=SimpleNamespace(topic="t", provider="p", is_cancelled=lambda: False),
|
||||
guide_id="g", topic="t", format="Guide", instructions="",
|
||||
subs_by_title={"Gross": subs}, spec="",
|
||||
slot=lambda name: tmp_path / name)
|
||||
monkeypatch.setattr(gb, "_card_facts", lambda e, b: "")
|
||||
card = {"block_norm": "gross", "block": "Gross", "stage": "writer", "status": "open",
|
||||
"writer_rounds": 0, "gate_info": "", "md": "", "chapter": "K1"}
|
||||
ok = await gb._stage_writer(env, card)
|
||||
assert ok is True
|
||||
assert [c for c in calls if "(1/2)" in c] and [c for c in calls if "(2/2)" in c]
|
||||
from textkit import _parse_fragment
|
||||
secs = _parse_fragment(card["md"])
|
||||
assert len(secs) == 1 and [s["title"] for s in secs[0]["subs"]] == ["Sub 1", "Sub 2"]
|
||||
assert card["stage"] == "fakten_gate"
|
||||
|
||||
295
backend/tests/test_subblocks.py
Normal file
295
backend/tests/test_subblocks.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Subbaustein-Qualität: Varianten-Konsens, Seed-Garantie, Nachfass, Outline-Review."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import blocks as blx
|
||||
from pipeline import GenContext
|
||||
|
||||
TOPIC = "t"
|
||||
_MD_PATH = re.compile(r"(/\S+\.md)")
|
||||
|
||||
|
||||
# ── _variant_clusters (pure) ─────────────────────────────────────────────────────────
|
||||
|
||||
def _sims(pairs, n):
|
||||
m = np.eye(n)
|
||||
for i, j, v in pairs:
|
||||
m[i][j] = m[j][i] = v
|
||||
return m
|
||||
|
||||
|
||||
def test_variant_clusters_folds_paraphrases():
|
||||
titles = ["Harte Umbrüche brauchen Marker", "Harte Umbrüche erfordern explizite Marker!",
|
||||
"Tabs werden expandiert"]
|
||||
cl = blx._variant_clusters(titles, [1, 1, 1], _sims([(0, 1, 0.95)], 3))
|
||||
by_rep = {c["rep"]: c for c in cl}
|
||||
assert by_rep[1]["mentions"] == 2 and sorted(by_rep[1]["members"]) == [0, 1] # longest wins
|
||||
assert by_rep[2]["mentions"] == 1
|
||||
|
||||
|
||||
def test_variant_clusters_negation_guard():
|
||||
titles = ["Fenced können Absätze unterbrechen", "Fenced können Absätze nicht unterbrechen"]
|
||||
cl = blx._variant_clusters(titles, [1, 1], _sims([(0, 1, 0.95)], 2))
|
||||
assert len(cl) == 2 # antonyms never merge, no matter the cosine
|
||||
|
||||
|
||||
# ── _subblocks_block integration (fake race + fake embeddings) ──────────────────────
|
||||
|
||||
def _fake_sims(texts):
|
||||
"""Markertoken matrix: same first word → 0.95, else 0."""
|
||||
n = len(texts)
|
||||
m = np.eye(n)
|
||||
key = lambda t: t.split()[0].casefold()
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and key(texts[i]) == key(texts[j]):
|
||||
m[i][j] = 0.95
|
||||
return m
|
||||
|
||||
|
||||
def _mk_race(finder_by_agent):
|
||||
"""Key-routed _race fake. Finder round 1 → scripted per-agent subs; later finder and
|
||||
catch-up rounds → nothing; clarify judges echo the consensus lines from their prompt."""
|
||||
prompts = []
|
||||
|
||||
async def fake_race(topic, label, slots, quorum, timeout, provider, on_update=None,
|
||||
cancelled=None, *, grace=None, min_runtime=None, max_runtime=None):
|
||||
outs = []
|
||||
for slot in slots:
|
||||
key, prompt = slot["key"], slot["prompt"]
|
||||
prompts.append((key, prompt))
|
||||
text = None
|
||||
if "-subblock-final-" in key:
|
||||
kons = re.search(r"Konsens \(≥2 finders\):\n(.*?)\nUnsicher", prompt, re.S)
|
||||
subs = [l[2:] for l in (kons.group(1).splitlines() if kons else [])
|
||||
if l.startswith("- ") and l != "- (keiner)"]
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
elif "-r1-" in key:
|
||||
agent = int(key.rsplit("-", 1)[1])
|
||||
subs = finder_by_agent.get(agent) or []
|
||||
if subs:
|
||||
text = "<!-- block: Alpha -->\n" + "\n".join(f"- {s}" for s in subs)
|
||||
if text is not None and (m := _MD_PATH.search(prompt)):
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
outs.append(slot["payload"](None))
|
||||
outs = [o for o in outs if o]
|
||||
return outs or None
|
||||
return fake_race, prompts
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sub_env(testdb, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(blx, "EMBEDDING_AKTIV", True)
|
||||
monkeypatch.setattr(blx.embedding, "available", lambda: True)
|
||||
monkeypatch.setattr(blx.embedding, "embed_sims", _fake_sims)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
files = {"arbeit": tmp_path}
|
||||
return testdb, ctx, files
|
||||
|
||||
|
||||
async def _run(ctx, files, monkeypatch, finder_by_agent, seeds=None):
|
||||
fake, prompts = _mk_race(finder_by_agent)
|
||||
monkeypatch.setattr(blx, "_race", fake)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-", seeds=seeds)
|
||||
return raw, prompts
|
||||
|
||||
|
||||
async def test_variant_consensus_end_to_end(sub_env, monkeypatch):
|
||||
"""3 Einzelfunde in 3 Formulierungen → EIN consensus-Repräsentant; Varianten gehen
|
||||
nicht als „Unsicher" ins Panel."""
|
||||
db, ctx, files = sub_env
|
||||
raw, prompts = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Umbruch braucht Marker"],
|
||||
2: ["Umbruch erfordert explizite Marker!"],
|
||||
3: ["Umbruch verlangt zwei Leerzeichen als Marker"],
|
||||
})
|
||||
assert raw == {"Alpha": ["Umbruch verlangt zwei Leerzeichen als Marker"]} # longest = rep
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
status = sorted(r["status"] for r in rows)
|
||||
assert status == ["consensus", "variant", "variant"]
|
||||
clarify_prompts = [p for k, p in prompts if "-subblock-final-" in k]
|
||||
assert clarify_prompts and "Umbruch braucht Marker" not in clarify_prompts[0]
|
||||
|
||||
|
||||
async def test_seed_promotes_single_find(sub_env, monkeypatch):
|
||||
"""Seed deckt einen verworfenen Einzelfund lexikalisch → Promotion zu consensus."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen", "Zeilenumbruch Regeln im Detail"],
|
||||
2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Zeilenumbruch Regeln"])
|
||||
assert "Zeilenumbruch Regeln im Detail" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_norm"] == "zeilenumbruch regeln im detail")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_inserted_when_nothing_found(sub_env, monkeypatch):
|
||||
"""Seed ohne jeden Fund wird als eigener consensus-Sub eingefügt (Facts-Gate prüft später)."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"],
|
||||
}, seeds=["Fußnoten Syntax"])
|
||||
assert "Fußnoten Syntax" in raw["Alpha"]
|
||||
row = next(r for r in await db.list_subblocks(TOPIC, "alpha")
|
||||
if r["sub_title"] == "Fußnoten Syntax")
|
||||
assert row["status"] == "consensus"
|
||||
|
||||
|
||||
async def test_seed_covered_no_duplicate(sub_env, monkeypatch):
|
||||
"""Seed lexikalisch von einem consensus-Sub abgedeckt → nichts eingefügt."""
|
||||
db, ctx, files = sub_env
|
||||
raw, _ = await _run(ctx, files, monkeypatch, {
|
||||
1: ["Tabs werden zu Leerzeichen expandiert"], 2: ["Tabs werden zu Leerzeichen expandiert"],
|
||||
}, seeds=["Tabs"])
|
||||
assert raw == {"Alpha": ["Tabs werden zu Leerzeichen expandiert"]}
|
||||
|
||||
|
||||
async def test_wipe_false_is_idempotent(sub_env, monkeypatch):
|
||||
"""Zweiter Karten-Lauf kumuliert keine Mentions (per-Block-Wipe)."""
|
||||
db, ctx, files = sub_env
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
first = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
await _run(ctx, files, monkeypatch, {1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
second = {r["sub_norm"]: r["mentions"] for r in await db.list_subblocks(TOPIC, "alpha")}
|
||||
assert first == second
|
||||
|
||||
|
||||
async def test_catchup_adds_and_stops(sub_env, monkeypatch, tmp_path):
|
||||
"""Block unter SUBBLOCK_MIN: Nachfass-Runde findet Neues → eigenes Final-File,
|
||||
Konsens wächst; zweite Runde ohne Neues → Ende."""
|
||||
db, ctx, files = sub_env
|
||||
fake, prompts = _mk_race({1: ["Alpha Grundlagen"], 2: ["Alpha Grundlagen"]})
|
||||
base = fake
|
||||
hit = {"n": 0}
|
||||
|
||||
async def with_catchup(topic, label, slots, *a, **k):
|
||||
if any("-subblock-x" in s["key"] for s in slots):
|
||||
hit["n"] += 1
|
||||
if hit["n"] == 1: # first catch-up round: both agents agree on one new sub
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Vertiefung der Konzepte")
|
||||
return [slot["payload"](None) for slot in slots[:2]]
|
||||
return None
|
||||
return await base(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_catchup)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert set(raw["Alpha"]) == {"Alpha Grundlagen", "Vertiefung der Konzepte"}
|
||||
assert (tmp_path / "subblock-final-c1-x1.md").exists()
|
||||
assert hit["n"] == 2 # round 2 ran, found nothing, loop ended
|
||||
|
||||
|
||||
# ── Outline-Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_outline_review_schema():
|
||||
valid = {1, 2, 3, 4, 5, 6}
|
||||
ok = blx._outline_review_schema({"moves": {"3": 2}}, valid, 2, 6)
|
||||
assert ok == {3: 2}
|
||||
assert blx._outline_review_schema({"moves": {}}, valid, 2, 6) == {}
|
||||
assert blx._outline_review_schema({"moves": {"9": 1}}, valid, 2, 6) is None # unknown block
|
||||
assert blx._outline_review_schema({"moves": {"1": 5}}, valid, 2, 6) is None # chapter range
|
||||
assert blx._outline_review_schema({"moves": {"1": 2, "2": 2, "3": 2}}, valid, 2, 6) is None # mass move
|
||||
assert blx._outline_review_schema({"chapters": []}, valid, 2, 6) is None
|
||||
|
||||
|
||||
async def test_outline_review_moves_block(testdb, tmp_path, monkeypatch):
|
||||
"""Review verschiebt einen fehlplatzierten Block; kaputtes Review lässt den Plan unverändert."""
|
||||
entries = {i: f"Block {i} — d" for i in range(1, 7)}
|
||||
slots = [tmp_path / f"outline-{i}.json" for i in (1, 2, 3)]
|
||||
plan_a = {"chapters": [{"title": "K1", "numbers": [1, 2, 6]}, {"title": "K2", "numbers": [3, 4, 5]}]}
|
||||
for p in slots[:2]:
|
||||
p.write_text(json.dumps(plan_a), encoding="utf-8")
|
||||
files = {"arbeit": tmp_path, "outline": tmp_path / "outline.json", "outline_slots": slots,
|
||||
"facts": tmp_path / "facts.json"}
|
||||
|
||||
review_out = {"val": {"moves": {"6": 2}}}
|
||||
|
||||
async def fake_slot(ctx, label, *, key, prompt, role, capabilities, payload, timeout, on_line=None):
|
||||
out = None
|
||||
if key.endswith("outline-prereqs"):
|
||||
out = {"prereqs": {}}
|
||||
elif key.endswith("outline-judge"):
|
||||
out = plan_a
|
||||
elif key.endswith("outline-review"):
|
||||
out = review_out["val"]
|
||||
if out is not None:
|
||||
m = re.search(r"(/\S+\.json)", prompt)
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
json.dump(out, f)
|
||||
return "ok", payload(None)
|
||||
|
||||
monkeypatch.setattr(blx, "run_single_slot", fake_slot)
|
||||
ctx = GenContext(topic=TOPIC, provider="claude", is_cancelled=lambda: False)
|
||||
plan = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan["chapters"][0]["numbers"] == [1, 2]
|
||||
assert plan["chapters"][1]["numbers"] == [3, 4, 5, 6]
|
||||
|
||||
# broken review (mass move) → schema rejects, plan unchanged
|
||||
review_out["val"] = {"moves": {"1": 2, "2": 2, "3": 1}}
|
||||
(tmp_path / "outline-review.json").unlink()
|
||||
(tmp_path / "outline.json").unlink()
|
||||
plan2 = await blx._outline_block(ctx, lambda *a, **k: None, files, entries, "")
|
||||
assert plan2["chapters"][0]["numbers"] == [1, 2, 6]
|
||||
|
||||
async def test_paraphrase_saturation_stops_early(sub_env, monkeypatch):
|
||||
"""Runde 2 liefert nur eine Paraphrase → zählt nicht als neu, Schleife endet ohne r3.
|
||||
Die Paraphrase liegt trotzdem in der DB (Mention fürs Cluster-Voting)."""
|
||||
db, ctx, files = sub_env
|
||||
base_fake, prompts = _mk_race({1: ["Umbruch braucht Marker"], 2: ["Umbruch braucht Marker"]})
|
||||
|
||||
async def with_r2(topic, label, slots, *a, **k):
|
||||
if any("-r2-" in s["key"] for s in slots):
|
||||
outs = []
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write("<!-- block: Alpha -->\n- Umbruch erfordert explizite Marker!")
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
return await base_fake(topic, label, slots, *a, **k)
|
||||
|
||||
monkeypatch.setattr(blx, "_race", with_r2)
|
||||
raw = await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
assert raw["Alpha"] # Konsens steht
|
||||
assert not any("-r3-" in k for k, _ in prompts) # Paraphrase hielt die Schleife NICHT am Leben
|
||||
rows = await db.list_subblocks(TOPIC, "alpha")
|
||||
assert any(r["sub_title"] == "Umbruch erfordert explizite Marker!" for r in rows)
|
||||
|
||||
|
||||
async def test_round_cap_stops_endless_finders(sub_env, monkeypatch):
|
||||
"""Jede Runde ein echt neues Konzept → hartes Cap stoppt bei SUBBLOCK_MAX_ROUNDS."""
|
||||
db, ctx, files = sub_env
|
||||
_, prompts = _mk_race({})
|
||||
|
||||
async def endless(topic, label, slots, *a, **k):
|
||||
if "-subblock-final-" in slots[0]["key"]:
|
||||
return None # panel fails → consensus fallback
|
||||
outs = []
|
||||
import re as _re
|
||||
rn = _re.search(r"-r(\d+)-", slots[0]["key"])
|
||||
n = rn.group(1) if rn else "x"
|
||||
for slot in slots[:2]:
|
||||
m = _MD_PATH.search(slot["prompt"])
|
||||
with open(m.group(1), "w", encoding="utf-8") as f:
|
||||
f.write(f"<!-- block: Alpha -->\n- Konzept{n} ist eigenständig")
|
||||
prompts.append((slot["key"], slot["prompt"]))
|
||||
outs.append(slot["payload"](None))
|
||||
return outs
|
||||
|
||||
monkeypatch.setattr(blx, "_race", endless)
|
||||
await blx._subblocks_block(ctx, lambda *a, **k: None, files, {1: "Alpha — Grundkonzept"},
|
||||
"", wipe=False, ns="x-")
|
||||
max_round = max(int(k.split("-r")[1].split("-")[0]) for k, _ in prompts if "-r" in k and "-subblock-c" in k)
|
||||
assert max_round == blx.SUBBLOCK_MAX_ROUNDS
|
||||
Reference in New Issue
Block a user