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

View File

@@ -5,3 +5,10 @@ CLAUDE_CODE_OAUTH_TOKEN=
# MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan). # MiniMax-Provider: API-Key aus der MiniMax-Console (Coding-Plan).
MINIMAX_API_KEY= MINIMAX_API_KEY=
# Optional — Rollen-Mixing über Anbieter-Grenzen (Standard: die UI-Auswahl gilt für alles).
# Wert: Provider-Name ("claude"/"minimax"/"lokal") oder "provider:modell".
#ROLE_QUICK=
#ROLE_JUDGE=
#ROLE_GUIDE=
#ROLE_FAST=

View File

@@ -29,13 +29,14 @@ log = logging.getLogger("creator.agents")
_active_processes: dict[str, asyncio.subprocess.Process] = {} _active_processes: dict[str, asyncio.subprocess.Process] = {}
_active_started: dict[str, float] = {} # agent_key → wall-clock start (for the live runtime display) _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]: def active_agents(scope_prefix: str | None = None) -> list[dict]:
"""Currently running agents and how long they've been running. Filter by key prefix """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() 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()) for k, t in list(_active_started.items())
if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))] if k in _active_processes and (not scope_prefix or k.startswith(scope_prefix))]
return sorted(out, key=lambda a: -a["runtime"]) 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. # per-topic queue can't undo the global priority when one topic is the only load.
_topic_sems: dict[str, _PrioritySemaphore] = {} _topic_sems: dict[str, _PrioritySemaphore] = {}
# Earlier kanban columns get the scarce global slot first (smaller = higher priority). # Smaller index = higher priority. Board 1 (inventory) first — it feeds everything.
_STAGE_PRIORITY = ("research", "ingest", "cluster", "pair", "clarify", "naming", "filter", "grouping") # 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: def _agent_priority(key: str) -> int:
for i, tag in enumerate(_STAGE_PRIORITY): for i, tag in enumerate(_STAGE_PRIORITY):
if f"-{tag}-" in key or key.endswith(f"-{tag}"): if f"-{tag}-" in key or key.endswith(f"-{tag}"):
return i 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 @asynccontextmanager
@@ -145,6 +150,8 @@ async def _opencode_slot() -> None:
_opencode_next_start = start_at + _OPENCODE_START_DELAY _opencode_next_start = start_at + _OPENCODE_START_DELAY
await asyncio.sleep(max(0.0, start_at - now)) await asyncio.sleep(max(0.0, start_at - now))
_SLIM_CONFIG = Path(__file__).resolve().parent.parent / "dev-ops" / "opencode-slim.json"
# Capability → Claude --allowedTools # Capability → Claude --allowedTools
_CLAUDE_TOOLS = { _CLAUDE_TOOLS = {
"full": "Write,Bash,Read,WebSearch,WebFetch", "full": "Write,Bash,Read,WebSearch,WebFetch",
@@ -220,6 +227,11 @@ def kill_process(agent_key_prefix: str) -> None:
_kill(process) _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( async def run_agent(
agent_key: str, agent_key: str,
prompt: str, prompt: str,
@@ -230,6 +242,7 @@ async def run_agent(
lane: str = "batch", lane: str = "batch",
scope: str | None = None, scope: str | None = None,
on_line=None, on_line=None,
label: str = "",
) -> tuple[int, str, str]: ) -> tuple[int, str, str]:
if _scope_cancelled(agent_key): # before queueing: don't even enter the queue if _scope_cancelled(agent_key): # before queueing: don't even enter the queue
return 1, "", "cancelled" return 1, "", "cancelled"
@@ -243,17 +256,47 @@ async def run_agent(
return 1, "", f"No model for role '{role}' (provider: {provider})" return 1, "", f"No model for role '{role}' (provider: {provider})"
if shutil.which(PROVIDERS[provider]["cli"]) is None: if shutil.which(PROVIDERS[provider]["cli"]) is None:
return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})" return 1, "", f"CLI '{PROVIDERS[provider]['cli']}' not installed (provider: {provider})"
queued = time.monotonic()
gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key)) gate = _interactive_sem if lane == "interactive" else _batch_gate(scope, _agent_priority(agent_key))
async with gate: async with gate:
if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn if _scope_cancelled(agent_key): # after the acquire: cancelled in the queue → no spawn
return 1, "", "cancelled" return 1, "", "cancelled"
log.info("agent %s: %s %s (role %s)", agent_key, provider, model, role) wait_ms = int((time.monotonic() - queued) * 1000)
if PROVIDERS[provider]["cli"] == "opencode": start = time.monotonic()
return await _run_opencode(agent_key, prompt, timeout, provider, model, capabilities, on_line=on_line) status = "error"
return await _run_claude_cli(agent_key, prompt, timeout, model, capabilities) 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() start = time.monotonic()
async def spawn(): async def spawn():
@@ -263,6 +306,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
start_new_session=True, # own process group → killpg also kills child processes start_new_session=True, # own process group → killpg also kills child processes
env=env,
) )
if stagger: if stagger:
@@ -277,6 +321,7 @@ async def _communicate(agent_key: str, cmd: list[str], stdin_data: bytes | None,
n += 1 n += 1
_active_processes[track_key] = process _active_processes[track_key] = process
_active_started[track_key] = time.time() _active_started[track_key] = time.time()
_active_labels[track_key] = label
try: try:
try: try:
if on_line is not None: 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: if _active_processes.get(track_key) is process:
del _active_processes[track_key] del _active_processes[track_key]
_active_started.pop(track_key, None) _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"] cfg = PROVIDERS["claude"]
cmd = [cfg["cli"], "-p", "--model", model] cmd = [cfg["cli"], "-p", "--model", model]
tools = _CLAUDE_TOOLS.get(capabilities) tools = _CLAUDE_TOOLS.get(capabilities)
if tools: if tools:
cmd += ["--allowedTools", tools] cmd += ["--allowedTools", tools]
cmd += ["--dangerously-skip-permissions"] 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] cfg = PROVIDERS[provider]
# Prompt via temp file instead of argv (ARG_MAX protection for large project prompts) # Prompt via temp file instead of argv (ARG_MAX protection for large project prompts)
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f: with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8", dir=tempfile.gettempdir()) as f:
@@ -349,8 +395,14 @@ async def _run_opencode(agent_key: str, prompt: str, timeout: int, provider: str
] ]
if on_line is not None: if on_line is not None:
cmd += ["--format", "json"] # raw JSON events → parsed live by on_line 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: 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 return rc, (stdout if on_line is not None else _clean_opencode_output(stdout)), stderr
finally: finally:
prompt_path.unlink(missing_ok=True) prompt_path.unlink(missing_ok=True)

View File

@@ -24,7 +24,7 @@ from pathlib import Path
import database as db import database as db
import embedding import embedding
from agents import kill_process, cancel_scope, clear_scope, run_agent from agents import kill_process, cancel_scope, clear_scope, run_agent
from config import CONSENSUS_GRACE, 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 fsutil import atomic_write_text, atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder from paths import arbeit_dir, blocks_path, question_pattern_path, project_dir, subblocks_path, source_path, source_crawl_dir, safe_folder
@@ -53,6 +53,10 @@ RESEARCH_THEMA_AGENTS = 5 # web mode (source "thema", no crawl folder)
RESEARCH_SECTION_CHARS = 12000 RESEARCH_SECTION_CHARS = 12000
# Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*). # Triage (content/noise) is now a deterministic rule filter (config.CRAWL_*).
SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min) SUBBLOCK_CAP = 900 # subblock find loop per chunk (15 min)
SUBBLOCK_MIN = 5 # below this consensus count a block gets focused catch-up rounds
SUBBLOCK_EXTRA_ROUNDS = 2 # max catch-up rounds (saturation stop still applies — thin stays thin)
SUBBLOCK_MAX_ROUNDS = 5 # hard round cap: measured runs hit 59 rounds purely on paraphrases
# before the variant-robust `new` count converges — never search longer
CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only CONSOLIDATION_CHUNK = 600 # up to here ONE global judge (dedups everything); above that chunked + merge pass — fallback path only
DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining) DEDUP_PAIR_FLOOR = 0.6 # min cosine for a candidate pair (complete-link aggregates → no chaining)
DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer) DEDUP_PAIRS_CHUNK = 40 # pairs per judge package (pairwise verification instead of a block mixer)
@@ -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. # Balance question-pattern chunks by sub load via LPT (makespan), not by block count.
QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk QUESTION_CHUNK_SUBS = 50 # target sum of relevant subs per chunk
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % 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) 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) 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) SUBBLOCK_PANEL = 3 # source judges in the subblock clarification (majority instead of a single judge)
@@ -556,10 +563,51 @@ def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]:
_NEG_TOKENS = {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie"}
def _neg_set(title: str) -> frozenset:
"""Negation tokens of a title — antonym statements measure cos 0.910.95 (above any usable
variant threshold), so equal negation sets are a hard merge precondition."""
return frozenset(t for t in re.findall(r"\w+", _norm_title(title)) if t in _NEG_TOKENS)
def _sub_tokens(title: str) -> set:
return set(re.findall(r"\w+", _norm_title(title)))
def _variant_clusters(titles: list[str], mentions: list[int], sims) -> list[dict]:
"""Fold phrasing VARIANTS of one concept BEFORE the consensus count: finders rephrase per
round, so exact-norm counting starves real concepts. Union-find over cos ≥ SUB_VARIANT_COS
with the negation guard. → [{"rep": idx, "members": [idx…], "mentions": sum}]."""
n = len(titles)
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
negs = [_neg_set(t) for t in titles]
for i in range(n):
for j in range(i + 1, n):
if float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]:
parent[find(i)] = find(j)
groups: dict[int, list[int]] = {}
for i in range(n):
groups.setdefault(find(i), []).append(i)
return [{"rep": max(g, key=lambda k: (len(titles[k]), -k)), "members": g,
"mentions": sum(mentions[k] for k in g)} for g in groups.values()]
async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, instructions: str, 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), """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`. {block title: [subblock, …]} (consensus) or None. Fills DB table `subblocks`.
wipe=False (kanban board: one call per block) keeps the other blocks' rows.""" wipe=False (kanban board: one call per block) keeps the other blocks' rows."""
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled 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) n = len(chunks)
title_by_num = {num: _title(entries[num]) for num in nums} 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} 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: if wipe:
await db.delete_subblocks(topic) # fresh start of the block (idempotent counter) 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): async def _known_block(chunk):
known = [] 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. # 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)) return ("\n\nBEREITS ERFASST — liste diese NICHT erneut. Finde nur, was FEHLT:\n" + "\n".join(known))
# ONE finder round (3 slots, quorum 2) → count of NEW sub norms; None = no result/cancel.
async def _one_round(label, subset, assignment, paths, keys, known, extra_instr):
chunk_idx = _title_index({num: title_by_num[num] for num in subset})
for p in paths:
p.unlink(missing_ok=True)
slots = [{
"key": k,
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=known, out_path=p, extra=_extra(extra_instr)),
"role": "quick", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
} for k, p in zip(keys, paths)]
agent_texts = await _race(topic, label, slots, 2, _timeout("subblock", len(subset)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled() or not agent_texts:
return None
rows_before = {num: await db.list_subblocks(topic, norm_by_num[num]) for num in subset}
existing = {num: {s["sub_norm"] for s in rows_before[num]} for num in subset}
fresh: dict[int, list[str]] = {}
for d in agent_texts:
for marker, subs in d.items():
num = _resolve_title(chunk_idx, marker)
if num is None:
continue
seen_set = set()
for sub in subs:
sn = _norm_title(sub)
if not sn or sn in seen_set:
continue
seen_set.add(sn)
if sn not in existing[num]:
existing[num].add(sn)
fresh.setdefault(num, []).append(sub)
await db.upsert_subblock(topic, norm_by_num[num], sn, title_by_num[num], sub)
# "New" is variant-robust: a paraphrase of an existing sub (or of another fresh find)
# still gets stored above (its mention feeds the cluster consensus), but it must not
# keep the saturation loop spinning — finders rephrase every round (measured: 59
# rounds without this fold). Model off → exact counting (status quo).
new = 0
for num, cands in fresh.items():
sims = None
base = [s["sub_title"] for s in rows_before[num]]
if emb_on and base + cands:
sims = await asyncio.to_thread(embedding.embed_sims, base + cands)
if sims is None:
new += len(cands)
continue
negs = [_neg_set(t) for t in base + cands]
nb = len(base)
kept: list[int] = []
for i in range(nb, nb + len(cands)):
dup = any(float(sims[i][j]) >= SUB_VARIANT_COS and negs[i] == negs[j]
for j in [*range(nb), *kept])
if not dup:
kept.append(i)
new += len(kept)
return new
# Phase "Subblocks find": per package loop until 0 new subs / time cap. # Phase "Subblocks find": per package loop until 0 new subs / time cap.
async def _find(c, chunk): async def _find(c, chunk):
assignment = "\n".join(f"- {entries[num]}" for num in 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() start = time.monotonic()
round_n = 0 round_n = 0
while not is_cancelled(): while not is_cancelled():
round_n += 1 round_n += 1
bekannt = await _known_block(chunk) if round_n > 1 else "" 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)] paths = [work_dir / f"subblock-c{c}-r{round_n}-{i}.md" for i in (1, 2, 3)]
for p in paths: keys = [f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}" for i in (1, 2, 3)]
p.unlink(missing_ok=True) new = await _one_round(f"{lbl}Subblocks package {c} R{round_n}", chunk, assignment, paths, keys, bekannt, instructions)
slots = [{ if new is None:
"key": f"blocks-{topic}-{ns}subblock-c{c}-r{round_n}-{i}", if is_cancelled():
"prompt": _prompt("Subblock-Research", topic=topic, assignment=assignment, known=bekannt, out_path=p, extra=_extra(instructions)), return False
"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:
return round_n > 1 # round 1 without result = error; later = simply the end 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: if new == 0:
break 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: if time.monotonic() - start > SUBBLOCK_CAP:
_log(topic, f"Subblocks package {c}: time cap reached (round {round_n})") _log(topic, f"Subblocks package {c}: time cap reached (round {round_n})")
break break
@@ -643,49 +729,117 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
_blocks_errors[topic] = "Subblocks failed (research)" _blocks_errors[topic] = "Subblocks failed (research)"
return None 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")) set_p(f"Subblocks select ({n} packages)…", step=_step_idx(topic, "Subblocks select"))
for num in nums: await _select(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"], # Judge formulation → shown candidate (best cos ≥ SUB_VARIANT_COS, negation-guarded).
status=("consensus" if s["mentions"] >= 2 else "discarded")) # 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×) # 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. # against the source; code majority per sub. External, multi-voice gate against single-judge bias + echo.
async def _clarify(c, chunk): async def _clarify(c, chunk, tag=""):
fp = work_dir / f"subblock-final-c{c}.md" fp = work_dir / f"subblock-final-c{c}{tag}.md"
if _parse_subblocks(_read(fp)): if _parse_subblocks(_read(fp)):
return return
block_texts, has_any = [], False block_texts, has_any = [], False
consensus_by_num: dict[int, list[str]] = {} consensus_by_num: dict[int, list[str]] = {}
shown_by_num: dict[int, list[str]] = {}
for num in chunk: for num in chunk:
rows = await db.list_subblocks(topic, norm_by_num[num]) rows = await db.list_subblocks(topic, norm_by_num[num])
consensus_subs = [s["sub_title"] for s in rows if s["status"] == "consensus"] 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 consensus_by_num[num] = consensus_subs
shown_by_num[num] = consensus_subs + uncertain
if not consensus_subs and not uncertain: if not consensus_subs and not uncertain:
continue continue
has_any = True has_any = True
k_lines = "\n".join(f"- {s}" for s in consensus_subs) if consensus_subs else "- (keiner)" 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)" 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: if not has_any:
return return
chunk_idx = _title_index({num: title_by_num[num] for num in chunk}) 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)] paths = [work_dir / f"subblock-final-c{c}{tag}-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] # 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: for _, p in pending:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
if pending: if pending:
slots = [{ 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)), "prompt": _prompt("Subblock-Mapping", topic=topic, source=source, blocks="\n\n".join(block_texts), out_path=p, extra=_extra(instructions)),
"role": "judge", "capabilities": caps, "role": "judge", "capabilities": caps,
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None), "payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
} for j, p in pending] } for j, p in pending]
existing = SUBBLOCK_PANEL - len(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) _timeout("subblock_check", len(chunk)), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
if is_cancelled(): if is_cancelled():
return return
@@ -698,22 +852,34 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
return return
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep). # 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 = [] block_texts_out = []
for num in chunk: 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] = {} votes: dict[str, int] = {}
form: dict[str, str] = {} form: dict[str, str] = {}
for d in outs: for subs_of_num in raw_votes:
seen = set() seen = set()
for marker, subs in d.items(): for sub in subs_of_num:
if _resolve_title(chunk_idx, marker) != num: sn = _norm_title(sub)
if not sn:
continue continue
for sub in subs: if sn in canon:
sn = _norm_title(sub) sn, sub = canon[sn]
if not sn or sn in seen: if sn in seen:
continue continue
seen.add(sn) seen.add(sn)
form.setdefault(sn, sub) form.setdefault(sn, sub)
votes[sn] = votes.get(sn, 0) + 1 votes[sn] = votes.get(sn, 0) + 1
kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)] kept = [form[sn] for sn in form if votes[sn] * 2 >= len(outs)]
if kept: if kept:
block_texts_out.append(f"<!-- block: {title_by_num[num]} -->\n" + "\n".join(f"- {s}" for s in 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. # Final list per block: judge output, otherwise consensus fallback. Reconcile DB + build raw.
raw: dict[str, list[str]] = {} 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}) 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} 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: 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} 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])} 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]): 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"], if s["sub_norm"] in final_norms:
status=("consensus" if s["sub_norm"] in final_norms else "discarded")) 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: for s in subs:
sn = _norm_title(s) sn = _norm_title(s)
if sn and sn not in have: if sn and sn not in have:
await db.upsert_subblock(topic, norm_by_num[num], sn, title, s) 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") 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) 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: if not raw:
# Finders ran but nothing survived the consensus/evidence gates: a legitimately # Finders ran but nothing survived the consensus/evidence gates: a legitimately
# thin block (e.g. a bare named reduction). {} = done-without-subs — the guide # 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 return
keepers: list[int] = [] keepers: list[int] = []
discarded: 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 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) discarded.append(i)
else: else:
keepers.append(i) 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. """Block C: three phases with a barrier — find (classify), select (vote), clarify.
Local IDs 1..n per package, mapped to global gid afterwards. Local IDs 1..n per package, mapped to global gid afterwards.
{block title: [{title, level}, …]} or None.""" {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", "role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)), "payload": (lambda result, p=p, ids=local_set: _levels_schema(_json_file(p), ids)),
} for i, p in pending] } 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 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")) 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: if decision is None:
disputed_block = _disputed_lines(items, item_idxs, strittig) disputed_block = _disputed_lines(items, item_idxs, strittig)
status, decision = await run_single_slot( 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}", 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)), prompt=_prompt("Levels-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="files",
@@ -1008,7 +1245,7 @@ def _facts_complete(files: dict) -> bool:
return isinstance(d, dict) and bool(d) 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). """Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
Extract-once grounding: the result feeds level/relevance/questions/guide. Extract-once grounding: the result feeds level/relevance/questions/guide.
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map → (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 return True
subs_total = sum(len(blocks[i][1]) for i in idxs) subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _r = await run_single_slot( 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)), prompt=_prompt("Facts-Research", topic=topic, source=source, blocks=block_text(idxs), out_path=fp, extra=_extra(instructions)),
role="quick", capabilities=caps, role="quick", capabilities=caps,
payload=lambda result, p=fp: _facts_schema(_json_file(p)), 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()) for bt, fm in per.items())
subs_total = sum(len(blocks[i][1]) for i in idxs) subs_total = sum(len(blocks[i][1]) for i in idxs)
await run_single_slot( 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)), prompt=_prompt("Facts-Supplement", topic=topic, source=source, blocks=block, out_path=ep, extra=_extra(instructions)),
role="quick", capabilities=caps, role="quick", capabilities=caps,
payload=lambda result, p=ep: _facts_schema(_json_file(p)), 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(*[ await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}", 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)), _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) 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] 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] = {} 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: if not goal:
return return
await run_single_slot( 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)), 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, role="quick", capabilities=caps,
payload=lambda result, p=fix_path(ci): _facts_schema(_json_file(p)), 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 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. """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. 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'.""" {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", "role": "quick", "capabilities": "files",
"payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)), "payload": (lambda result, p=p, ids=local_set: _relevance_schema(_json_file(p), ids)),
} for i, p in pending] } 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 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")) 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: if decision is None:
disputed_block = _disputed_lines(items, item_idxs, strittig) disputed_block = _disputed_lines(items, item_idxs, strittig)
status, decision = await run_single_slot( 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}", 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)), prompt=_prompt("Relevance-Mapping", topic=topic, disputed=disputed_block, out_path=judge_path, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="files",
@@ -1321,7 +1559,7 @@ def _match_sub(agent_sub: str, rel: list[str]) -> str:
return agent_sub 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: """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). 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). 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) subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _ = await run_single_slot( 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}", key=f"blocks-{topic}-{ns}question-pattern-c{ci}",
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block, prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=block,
out_path=fp, extra=_extra(instructions)), 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 return # nothing to clarify in this chunk
subs_total = sum(len(blocks[i][1]) for i in idxs) subs_total = sum(len(blocks[i][1]) for i in idxs)
status, _ = await run_single_slot( 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}", 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)), prompt=_prompt("Question-Pattern-Critique", topic=topic, table="\n\n".join(block_texts), out_path=fp, extra=_extra(instructions)),
role="judge", capabilities="files", role="judge", capabilities="files",
@@ -1505,7 +1743,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
return # resume return # resume
subs_total = sum(len(s) for _, s in items) subs_total = sum(len(s) for _, s in items)
await run_single_slot( 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}", key=f"blocks-{topic}-{ns}question-pattern-nach{round_n}-c{pi}",
prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items), prompt=_prompt("Question-Pattern-Research", topic=topic, blocks=_followup_block(items),
out_path=fp, extra=_extra(instructions)), 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")) 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]): def _outline_schema(data, valid: set[int]):
"""{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) · """{"chapters":[{title,numbers}]} → cleaned (valid numbers, each exactly once) ·
None at <80 % coverage (agent/judge omitted too much).""" 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))) timeout=_timeout("plan_judge", len(entries)))
plan = _outline_schema(_json_file(files["outline"]), valid) or proposals[0] 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). # Completeness: every block appears — missing in "Other" (against omitting agents/judge).
included = {n for ch in plan["chapters"] for n in ch["numbers"]} included = {n for ch in plan["chapters"] for n in ch["numbers"]}
missing = [n for n in entries if n not in included] 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) 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 """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); 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).""" 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) atomic_write_json(files["artefakte"], empty_map, indent=1)
return empty_map 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): def block_text(idxs):
return "\n\n".join(f"BLOCK: {blocks[i][0]}\nSUBBAUSTEINE:\n" + "\n".join(blocks[i][1]) for i in 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(*[ await asyncio.gather(*[
run_agent(f"blocks-{topic}-{ns}artifact-example-check-c{ci}-j{j}", 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)), _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) 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] 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: 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: if schema(_json_file(p)) is not None:
return True return True
await run_single_slot( 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)), prompt=_prompt(_ARTEFACT_PROMPT[typ], topic=topic, blocks=block_text(idxs), out_path=p, extra=_extra(instructions)),
role="guide", capabilities="files", role="guide", capabilities="files",
payload=lambda result, p=p: schema(_json_file(p)), payload=lambda result, p=p: schema(_json_file(p)),

View File

@@ -42,11 +42,20 @@ def _safe(norm: str) -> str:
def _card_set_p(flow: Flow, norm: str): def _card_set_p(flow: Flow, norm: str):
"""Per-card progress: the inner step messages land in-memory on the flow — """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", {}) info = flow.state.setdefault("card_info", {})
def set_p(msg: str, step: int | None = None) -> None: 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 return set_p
@@ -94,28 +103,73 @@ def _fail_or_cancel(ctx: GenContext, what: str):
# ── Stage processors (one call per card, all parallel) ───────────────────────────── # ── 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): async def _proc_subblocks(ctx: GenContext, flow: Flow, files: dict, instructions: str, cards):
topic = flow.topic topic = flow.topic
# Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks. # Fix 4: fragments demoted to a parent become seed candidates of the parent's subblocks.
seeds: dict[str, list[str]] = {} seeds = await _seed_map(topic)
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", ""))
async def one(c): async def one(c):
p = c["payload"] p = c["payload"]
norm = c["card_id"] norm = c["card_id"]
instr = instructions 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 " instr = (instructions + "\n\nBereits identifizierte Unterpunkt-Kandidaten dieses "
"Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\n" "Blocks (unbedingt prüfen und, wenn belegt, aufnehmen):\n"
+ "\n".join(f"- {s}" for s in sd)) + "\n".join(f"- {s}" for s in sd))
raw = await _subblocks_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), 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: if raw is None:
return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Subblocks {p.get('title', norm)}")
p["raw"] = raw 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_set_payload(topic, BOARD, norm, p)
await db.kanban_advance(topic, BOARD, norm, "facts") 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"] norm = c["card_id"]
raw = p.get("raw") or {} raw = p.get("raw") or {}
res = await _facts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), raw, q, 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: if res is None:
return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Facts {p.get('title', norm)}")
facts_map, discarded = res facts_map, discarded = res
@@ -154,7 +209,8 @@ async def _proc_levels(ctx: GenContext, flow: Flow, files: dict, instructions: s
p = c["payload"] p = c["payload"]
norm = c["card_id"] norm = c["card_id"]
sidecar = await _levels_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), 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: if sidecar is None:
return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Levels {p.get('title', norm)}")
facts_map = p.get("facts") or {} 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"] norm = c["card_id"]
sidecar = p.get("sidecar") or {} sidecar = p.get("sidecar") or {}
rel = await _relevance_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), 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: if rel is None:
return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Relevance {p.get('title', norm)}")
gid = 0 gid = 0
@@ -201,7 +258,7 @@ async def _proc_question_pattern(ctx: GenContext, flow: Flow, files: dict, instr
norm = c["card_id"] norm = c["card_id"]
pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), pattern = await _question_pattern_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions, p.get("sidecar") or {}, instructions,
ns=f"{_safe(norm)}-") ns=f"{_safe(norm)}-", lbl=f"{p.get('title', norm)} · ")
if pattern is None: if pattern is None:
return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}") return _fail_or_cancel(ctx, f"Fragen {p.get('title', norm)}")
p["pattern"] = pattern p["pattern"] = pattern
@@ -219,7 +276,7 @@ async def _proc_artefacts(ctx: GenContext, flow: Flow, files: dict, instructions
norm = c["card_id"] norm = c["card_id"]
artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm), artefacts = await _artefacts_block(ctx, _card_set_p(flow, norm), _pfiles(files, norm),
p.get("sidecar") or {}, instructions, 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(): if artefacts is None and ctx.is_cancelled():
return None return None
p["artefacts"] = artefacts or {} # artefacts are optional — never fatal 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) entries = {i: _entry_line(c["payload"]) for i, c in enumerate(done, 1)
if c["payload"].get("title")} if c["payload"].get("title")}
if entries: 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(): if ctx.is_cancelled():
return return
if isinstance(plan, dict) and plan.get("chapters"): if isinstance(plan, dict) and plan.get("chapters"):

View File

@@ -46,8 +46,8 @@ from blocks import (
) )
from config import ( from config import (
BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP, BLOCKS_GRUPPIERUNG_AKTIV, EMBEDDING_AKTIV, EMBEDDING_BLOCK_CAP,
EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, GROUP_MIN_COS_FLOOR, EMBEDDING_SIBLING_CAP, EMBEDDING_SIBLING_FLOOR, FRAGMENT_MIN_COS,
GROUP_RECONCILE_FLOOR, GROUP_MIN_COS_FLOOR, GROUP_RECONCILE_FLOOR,
) )
from fsutil import atomic_write_json, atomic_write_text from fsutil import atomic_write_json, atomic_write_text
from jsonio import read_json_file as _json_file 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] raise errs[0]
if ctx.is_cancelled(): if ctx.is_cancelled():
return return
fragments: dict[int, int] = {} proposals: dict[int, int] = {} # judge demotes are PROPOSALS — panel/containment confirm
drops: set[int] = set() drops: set[int] = set()
for ci, numbers in enumerate(chunks): for ci, numbers in enumerate(chunks):
raw = _json_file(work_dir / f"filter-{h}-c{ci}.json") 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) nset = set(numbers)
for nr, parent in verdict.items(): for nr, parent in verdict.items():
if 1 <= parent <= n_all and nr in nset: 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 []): for x in (raw.get("drop", []) if isinstance(raw, dict) else []):
try: try:
dnr = int(x) dnr = int(x)
@@ -735,34 +735,43 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
# hard-drop double gate # hard-drop double gate
honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])} honored = {nr for nr in drops if _is_artifact(allrows[nr - 1]["title"])}
for nr in honored: for nr in honored:
fragments.pop(nr, None) proposals.pop(nr, None)
# containment demote + parentless noise (deterministic) # 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)] 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): 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 continue
parent = _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) 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: if parent is not None and parent != i and parent not in honored:
fragments[i] = parent fragments[i] = parent
contained.add(i)
proposals.pop(i, None)
for i in range(1, n_dem + 1): 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 continue
if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None: if _containment_parent(allrows[i - 1]["title_norm"], [(nr, t) for nr, t in norms if nr != i]) is None:
honored.add(i) honored.add(i)
# recheck panel over still-⚠ survivors (rare-positive recall, majority ≥2). # recheck panel = second opinion: unconfirmed judge proposals (WITHOUT the suggested
# ONE wave over ALL (chunk, judge) slots; a single failed judge is tolerated # parent — no anchoring) plus still-⚠ survivors. Majority ≥2 demotes/drops; a proposal
# (panel votes over whatever answered — legacy semantics). Voting afterwards. # 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) 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: 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)] rchunks = [survivors[k:k + FILTER_CHUNK] for k in range(0, len(survivors), FILTER_CHUNK)]
async def _recheck_judge(ci, nums, j): 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: if _filter_schema(_json_file(path)) is not None:
return # resume return # resume
await run_single_slot( 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, prompt=_prompt("Blocks-Filter-Recheck", topic=topic,
survivors="\n".join(_fline(i) for i in nums), survivors="\n".join(_fline(i) for i in nums),
list=full_list, out_path=path), 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]] = {} dem: dict[int, list[int]] = {}
drp: dict[int, int] = {} drp: dict[int, int] = {}
nset = set(nums) nset = set(nums)
valid = 0
for j in range(1, FILTER_RECHECK_PANEL + 1): 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) v = _filter_schema(raw)
if v is None: if v is None:
continue continue
valid += 1
for nr, parent in v.items(): for nr, parent in v.items():
if nr in nset and 1 <= parent <= n_all and nr != parent: if nr in nset and 1 <= parent <= n_all and nr != parent:
dem.setdefault(nr, []).append(parent) dem.setdefault(nr, []).append(parent)
@@ -795,6 +806,8 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
continue continue
if dnr in nset: if dnr in nset:
drp[dnr] = drp.get(dnr, 0) + 1 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: for nr in nums:
if nr in fragments or nr in honored: if nr in fragments or nr in honored:
continue continue
@@ -803,6 +816,18 @@ async def _proc_fragment_filter(ctx: GenContext, flow: Flow, cards):
honored.add(nr) honored.add(nr)
elif len(dem.get(nr, [])) >= 2: elif len(dem.get(nr, [])) >= 2:
fragments[nr] = max(set(dem[nr]), key=dem[nr].count) 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) # statement-gate rescue (final override)
def _protected(nr): def _protected(nr):
return _is_named_statement(allrows[nr - 1]["title"], allrows[nr - 1]["description"]) 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"]) await db.kanban_set_payload(topic, BOARD, r["card_id"], r["payload"])
moves.append((r["card_id"], "rejected")) moves.append((r["card_id"], "rejected"))
journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"}) journal.append({"fragment": r["title"], "eltern": None, "grund": "drop"})
atomic_write_json(work_dir / "inventar-filter.json", # one journal file per pass (h) — the supplement feedback pass must not overwrite
{"vorher": n_dem, "degradiert": len(journal), "fragments": journal}, indent=1) # 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)})") _log(topic, f"Fragment-Filter: {n_dem}{n_dem - len(journal)} ({len(journal)})")
demoted = {cid for cid, _ in moves} demoted = {cid for cid, _ in moves}
moves += [(r["card_id"], "grouping") for r in rows if r["card_id"] not in demoted] 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: if status != OK:
_log(topic, "Supplement fehlgeschlagen — übersprungen (optional)") _log(topic, "Supplement fehlgeschlagen — übersprungen (optional)")
supplements = [] supplements = []
known_norms = set() # Dead lineage: blocks demoted by the fragment filter (and their cluster + title cards)
known_keys = set() # must NOT dedup a supplement proposal — their content is gone. A hit on a dead title
for t in await db.kanban_cards(topic, board=BOARD): # REOPENS the lineage instead: the title card rejoins its cluster (live re-cluster) and
tt = t["payload"].get("title", "") # the respawned block gets a fresh fragment_filter pass. failed-quorum/pre-reject stay
if tt: # in the dedup: those were rejected as non-blocks, not lost as content.
known_norms.add(_norm_title(tt)) dead_reasons = {"fragment", "drop-collateral", "drop"}
if (k := _canonical_key(tt)): cards = await db.kanban_cards(topic, board=BOARD)
known_keys.add(k) dead_clusters = {c["payload"].get("cluster") for c in cards
new = 0 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 []): for t, d in (supplements or []):
norm = _norm_title(t) norm = _norm_title(t)
key = _canonical_key(t) key = _canonical_key(t)
if not norm or norm in known_norms or (key and key in known_keys): if not norm or norm in known_norms or (key and key in known_keys):
continue continue
known_norms.add(norm) 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() desc = f"{d} [Supplement]".strip()
async with _ingest_lock: async with _ingest_lock:
await db.kanban_add_title(topic, BOARD, norm, t, desc, "supplement", "supplement") 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 card["payload"]["supplement"] = True
await db.kanban_set_payload(topic, BOARD, norm, card["payload"]) await db.kanban_set_payload(topic, BOARD, norm, card["payload"])
new += 1 new += 1
if new: if new or reopened:
_log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest") _log(topic, f"Supplement: {new} Block-Kandidat(en) → ingest, {reopened} wiedereröffnet")
flow.wake.set() 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) await board_artefacts.ensure_outline_card(topic)
stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions) stages += board_artefacts.artefact_stages(ctx, flow, files, q, folder, instructions)
stages = chain_stages(stages) 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 [] producers = _build_producers(ctx, flow, q, folder, instructions) if research else []
async def _as_producer(coro): async def _as_producer(coro):
@@ -1260,6 +1334,12 @@ _VERDICT_KEYS = ("reason", "votes", "judges", "merged_into", "parent_norm", "mir
DONE_ART = "done_artefact" 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: def _card_view(r: dict, active: set[str], live_info: dict) -> dict:
p = r["payload"] p = r["payload"]
key = f"{r['board']}:{r['card_id']}" 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']}" info = f"{p['merged_into']}"
elif p.get("parent_norm"): elif p.get("parent_norm"):
info = f"Fragment von: {p['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 out = {"title": p.get("title") or r["card_id"], "retries": r["retries"], "card_id": r["card_id"],
info = live_info[key] "kind": r.get("kind", ""), "board": r.get("board", ""),
status = "error" if r["retries"] else ("active" if is_active else "open") "status": "error" if r["retries"] else ("active" if is_active else "open")}
return {"title": p.get("title") or r["card_id"], "status": status, live = live_info.get(key)
"info": info, "retries": r["retries"]} 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: 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_subblocks(topic)
await db.delete_question_pattern(topic) await db.delete_question_pattern(topic)
await db.delete_sub_artefakte(topic) await db.delete_sub_artefakte(topic)
await db.add_event(topic, "reset", key=f"{board}:from-{stage}", status=str(moved))
return 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: async def requeue_dead(topic: str) -> int:
"""Dead-letter → restart stage by card kind (fresh retries). → requeued count.""" """Dead-letter → restart stage by card kind (fresh retries). → requeued count."""
n = 0 n = 0

View File

@@ -11,8 +11,10 @@ UNI_DIR = PROJECT_ROOT / "uni"
def _load_env(path: Path) -> None: def _load_env(path: Path) -> None:
"""Mini .env loader (no dependency): KEY=VALUE lines; existing env always wins """Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
(`make dev` already exports .env — this covers bare `uvicorn`/pytest starts).""" 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: try:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
except OSError: except OSError:
@@ -23,7 +25,7 @@ def _load_env(path: Path) -> None:
continue continue
key, _, value = line.partition("=") key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'") key, value = key.strip(), value.strip().strip('"').strip("'")
if key and key not in os.environ: if key:
os.environ[key] = value 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 # 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. # without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
EMBEDDING_SUB_DUP = 0.90 EMBEDDING_SUB_DUP = 0.90
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.910.95 → negation guard required.
SUB_VARIANT_COS = 0.90
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
# subs are statements: true covers measure 0.270.38 while a wrong hit measured 0.76. The
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
SEED_COVER_COS = 0.80
# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter): # Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model: # 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 # 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. # chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
GROUP_MIN_COS_FLOOR = 0.15 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: # 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 # 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). # Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken. # Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = { 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 "research_mapping": (600, 3), # n = pre-merged entries
"selection_mapping": (600, 2), # n = remaining entries (block inventory) "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": (300, 5),
"plan_judge": (600, 5), # judge reads up to 5 outlines, n = sections "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 "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 "subblock_check": (300, 15), # judge decides contested subblocks in the chunk
"level": (300, 10), # classify subblocks per chunk "level": (300, 10), # classify subblocks per chunk
"level_check": (300, 10), # judge decides contested levels in the 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 "relevance_check": (300, 10), # judge decides contested relevance in the chunk
"question_pattern": (300, 15), # question patterns per block (subblocks × types) "question_pattern": (300, 15), # question patterns per block (subblocks × types)
"question_pattern_check": (300, 10), # critic cleans up the pattern table per block "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 "lese_check": (300, 10), # per section in the package
# guide board (per card = one block) # guide board (per card = one block)
"lernziele": (300, 5), # backward-design objectives per 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) # Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
# may run on different providers within ONE run — judge model ≠ generator model # the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
# (research-backed: cross-model judging avoids self-preference bias). # Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
# Value: "" = provider of the run; "minimax" = that stack's role model; # to the claude stack regardless of the UI choice ("provider:model" pins a model).
# "provider:model" = explicit model override.
ROLE_ROUTING = { ROLE_ROUTING = {
"quick": os.getenv("ROLE_QUICK", "minimax"), "quick": os.getenv("ROLE_QUICK", ""),
"judge": os.getenv("ROLE_JUDGE", "claude"), "judge": os.getenv("ROLE_JUDGE", ""),
"guide": os.getenv("ROLE_GUIDE", "minimax"), "guide": os.getenv("ROLE_GUIDE", ""),
"fast": os.getenv("ROLE_FAST", ""), "fast": os.getenv("ROLE_FAST", ""),
} }

View File

@@ -34,19 +34,6 @@ CREATE TABLE IF NOT EXISTS topics (
) )
""" """
CREATE_ELEMENTS = """
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
examples TEXT NOT NULL DEFAULT '[]',
hints TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
CREATE_BLOCK_TEXTE = """ CREATE_BLOCK_TEXTE = """
CREATE TABLE IF NOT EXISTS block_texte ( CREATE TABLE IF NOT EXISTS block_texte (
topic TEXT NOT NULL, 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_KANBAN_PULL_INDEX = """
CREATE INDEX IF NOT EXISTS idx_kanban_pull ON kanban_cards(topic, board, stage, not_before, updated_at) 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_GUIDES)
await db.execute(CREATE_PROGRESS) await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS) await db.execute(CREATE_TOPICS)
await db.execute(CREATE_ELEMENTS)
await db.execute(CREATE_BLOCK_TEXTE) await db.execute(CREATE_BLOCK_TEXTE)
await db.execute(CREATE_BLOCK_PROGRESS) await db.execute(CREATE_BLOCK_PROGRESS)
await db.execute(CREATE_BLOCKS) await db.execute(CREATE_BLOCKS)
@@ -300,6 +308,8 @@ async def init_db():
await db.execute(CREATE_GUIDE_OUTLINE) await db.execute(CREATE_GUIDE_OUTLINE)
await db.execute(CREATE_SUB_ARTEFAKTE) await db.execute(CREATE_SUB_ARTEFAKTE)
await db.execute(CREATE_KANBAN_CARDS) 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_PULL_INDEX)
await db.execute(CREATE_KANBAN_MEMBERS) await db.execute(CREATE_KANBAN_MEMBERS)
await db.execute(CREATE_GUIDE_CARDS) 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" "SELECT topic, block, 'deepdive', md, created_at, updated_at FROM vertiefungen"
) )
await db.execute("DROP TABLE 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( await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' " "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server restart' "
"WHERE status IN ('queued', 'generating')" "WHERE status IN ('queued', 'generating')"
@@ -446,59 +458,6 @@ async def delete_topic(name: str) -> None:
await db.commit() 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 --- # --- Chapter progress ---
async def list_progress_all() -> dict[str, set[str]]: 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() 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): # 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 # 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). # 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]: 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() db = await get_db()
cursor = await db.execute( cursor = await db.execute(
"""SELECT * FROM kanban_cards WHERE topic = ? AND board = ? AND stage = ? AND not_before <= ? """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)) (topic, board, stage, _now(), limit))
return [_card(row, cursor) for row in await cursor.fetchall()] 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)]) 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: 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).""" """Batch stage moves in ONE commit (the flow advances whole packages)."""
if not moves: 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 = ? """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND card_id = ?""", WHERE topic = ? AND board = ? AND card_id = ?""",
[(stage, now, topic, board, cid) for cid, stage in moves]) [(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() 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 = ?""", WHERE topic = ? AND board = ? AND card_id = ?""",
(retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(), (retries, _now_plus(backoff_base * (2 ** (retries - 1))), error[:500], _now(),
topic, board, card_id)) 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() await db.commit()
return dead 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 = ? """UPDATE kanban_cards SET stage = ?, retries = 0, not_before = '', last_error = NULL, updated_at = ?
WHERE topic = ? AND board = ? AND stage = 'dead'""", WHERE topic = ? AND board = ? AND stage = 'dead'""",
(stage, _now(), topic, board)) (stage, _now(), topic, board))
await _add_events_many(db, topic, [("reset", f"{board}:requeue-dead", "", stage)])
await db.commit() await db.commit()
return cursor.rowcount return cursor.rowcount
@@ -1032,6 +1003,11 @@ async def set_guide_card(topic: str, format: str, block_norm: str, **fields) ->
await db.execute( await db.execute(
f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?", f"UPDATE guide_cards SET {cols}, updated_at = ? WHERE topic = ? AND format = ? AND block_norm = ?",
(*fields.values(), _now(), topic, format, 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() 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}) 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() 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() 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] 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() 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() 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] 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() 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() 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).""" NOT the topic config `source` — that is managed separately (delete_source)."""
db = await get_db() db = await get_db()
for tab in ("blocks", "subblocks", "question_pattern", "research_coverage", 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.execute(f"DELETE FROM {tab} WHERE topic = ?", (topic,))
await db.commit() await db.commit()

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
"""Board 3 „Guide": one card per block, linear stages with gates between them. """Board 3 „Guide": one card per block, linear stages with gates between them.
lernziele judge Backward Design — objectives BEFORE writing lernziele judge-Rolle Backward Design — objectives BEFORE writing
zuweisung code chapter/order from the outline artefact + facts grounding zuweisung code chapter/order from the outline artefact + facts grounding
writer guide ONE coherent per-block text, only from VERIFIED FACTS writer guide-Rolle ONE coherent per-block text, only from VERIFIED FACTS
fakten_gate judge CoVe: atomic claims, each binary against the facts → minimal fix fakten_gate judge-Rolle CoVe: atomic claims, each binary against the facts → minimal fix
coverage judge objective↔section mapping; gap → back to writer (max 2 rounds) coverage judge-Rolle objective↔section mapping; gap → back to writer (max 2 rounds)
lesbarkeit judge Lese-Check + deterministic readability gate → fix → done 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 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 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 database as db
import readability 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 fsutil import atomic_write_json
from jsonio import read_json_file as _json_file from jsonio import read_json_file as _json_file
from pipeline import (CANCELLED, FAILED, OK, GenContext, _extra, _log, _prompt, 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", "fakten_gate": "Fakten-Gate", "coverage": "Coverage",
"lesbarkeit": "Lesbarkeit", "done": "Fertig"} "lesbarkeit": "Lesbarkeit", "done": "Fertig"}
MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 12) MAX_WRITER_ROUNDS = 2 # coverage → writer feedback loop cap (gains die after round 12)
CARD_CONCURRENCY = 10 # simultaneous cards (the per-topic agent semaphore is the hard cap) # Simultaneous cards = the per-topic agent cap: every card busies exactly ONE agent at a
# time (its stages run serially), so a lower number just idles slots (was hardcoded 10
# from the old 10-slot era while the .env already allowed 24).
CARD_CONCURRENCY = MAX_CONCURRENT_AGENTS_PER_TOPIC
def _safe(norm: str) -> str: def _safe(norm: str) -> str:
@@ -142,6 +145,15 @@ def _card_assignment(env: _Env, card: dict) -> str:
return "\n".join(lines) 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): async def _set(env: _Env, card: dict, **fields):
card.update(fields) card.update(fields)
await db.set_guide_card(env.topic, env.format, card["block_norm"], **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 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: async def _stage_writer(env: _Env, card: dict) -> bool:
norm = card["block_norm"] norm = card["block_norm"]
ziele = await db.list_lernziele(env.topic, 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 " gaps = ("\nREVISION ROUND — a previous version exists. Revise it: close exactly the gaps "
"below, cut the listed ballast, keep everything else as-is.\n" "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") 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 = env.slot(f"card-{_safe(norm)}-r{card['writer_rounds']}.md")
path.unlink(missing_ok=True) 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"], prompt=_prompt("Guide-Fakten-Fix", topic=env.topic, block=card["block"],
section=card["md"], claims=claims_text, facts=facts, section=card["md"], claims=claims_text, facts=facts,
out_path=fixp, extra=_extra(env.instructions)), 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)) timeout=_timeout("fakten_gate", 1))
if fstatus == CANCELLED: if fstatus == CANCELLED:
return False 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 def _run_card(env: _Env, card: dict, sem: asyncio.Semaphore) -> None:
async with sem: async with sem:
while card["stage"] != "done": try:
if is_guide_cancelled(env.guide_id): await _run_card_inner(env, card)
await _set(env, card, status="open") # no longer being worked finally:
return _live_info.pop((env.topic, env.format, card["block_norm"]), None)
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']}") async def _run_card_inner(env: _Env, card: dict) -> None:
return while card["stage"] != "done":
if card["status"] != "active": if is_guide_cancelled(env.guide_id):
await _set(env, card, status="active") # live board: this card is being worked await _set(env, card, status="open") # no longer being worked
try: return
if not await fn(env, card): fn = _STAGE_FN.get(card["stage"])
return if fn is None: # unknown stage → park as error
except Exception as e: await _set(env, card, status="error", gate_info=f"Unbekannte Stage {card['stage']}")
log.exception("[%s] guide card %s failed", env.topic, card["block"]) return
await _set(env, card, status="error", gate_info=f"{type(e).__name__}: {e}"[:300]) 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 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 ────────────────────────────────────────────────────────────────── # ── Orchestration ──────────────────────────────────────────────────────────────────
@@ -498,16 +605,35 @@ async def board_snapshot(topic: str, format_name: str, limit: int = 20) -> dict:
views = [] views = []
for c in in_stage[:limit]: for c in in_stage[:limit]:
zc = ziele.get(c["block_norm"]) 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", "status": c["status"] if c["status"] in ("error", "active") else "open",
"rounds": c["writer_rounds"], "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 ""}) "ziele": f"{zc[0]}/{zc[1]}" if zc else ""})
columns.append({"key": stage, "label": STAGE_LABELS[stage], columns.append({"key": stage, "label": STAGE_LABELS[stage],
"total": len(in_stage), "cards": views}) "total": len(in_stage), "cards": views})
return {"columns": columns} 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: 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].""" """Cards in stages ≥ ab_stage (incl. done) back to GUIDE_STAGES[ab_stage]."""
ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1)) ab_stage = max(0, min(ab_stage, len(GUIDE_STAGES) - 1))

View File

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

View File

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

View File

@@ -39,6 +39,18 @@ class BlocksCreateRequest(BaseModel):
research: bool = True # False = Continue: drain the existing kanban queue, no new search 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): class BlocksResetStageRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100) topic: str = Field(min_length=1, max_length=100)
board: Literal["inventory", "artefacts"] board: Literal["inventory", "artefacts"]
@@ -134,76 +146,6 @@ class GuideChatResponse(BaseModel):
reply: str 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): class ProgressUpdate(BaseModel):
chapter: str = Field(min_length=1, max_length=100) chapter: str = Field(min_length=1, max_length=100)
done: bool done: bool

View File

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

View File

@@ -13,28 +13,24 @@ from database import (
create_guide, delete_guide, get_guide, list_guides, create_guide, delete_guide, get_guide, list_guides,
create_topic, list_topics as db_list_topics, delete_topic, create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress, 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, 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_block_data, delete_block_progress, subs_per_level, subs_per_level_raw,
delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content, delete_topic_pipeline, delete_source, get_guide_content, delete_guide_content,
get_sub_artefakte, kanban_reset, delete_guide_board, 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 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 board_inventory import add_research_agent, board_snapshot, requeue_dead, reset_board_from_stage, restart_artefact_card
from elements import generate_element, chat_with_guide, chat_with_element, check_element, style_element, refine_suggestion 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 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 chat_with_guide, generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from guide import generate_guide, guide_slot_files, block_pruefen, block_adopt, content_fuer_level
from pipeline import cancel_guide from pipeline import cancel_guide
from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed from rules import FORMATE, formats_stats, guide_lock, ist_completed, load_learnstate, topic_completed
from models import ( from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest, TopicCreateRequest,
BlocksCreateRequest, BlocksResetStageRequest, BlocksStatusResponse, BlocksCreateRequest, BlocksResetStageRequest, BlocksCardRestartRequest, BlocksStatusResponse,
GuideCardResetRequest,
GuideBoardResetRequest, GuideChatRequest, GuideChatResponse, GuideBoardResetRequest, GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ElementRefineRequest, ElementRefineResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview, FolderResponse, BlocksSourceUpdate, BlocksSourceResponse, BlockOverview,
BlockChatRequest, BlockChatResponse, BlockChatRequest, BlockChatResponse,
@@ -177,14 +173,14 @@ async def get_blocks_board(topic: str):
snap["generating"] = status["generating"] snap["generating"] = status["generating"]
snap["progress"] = status["progress"] snap["progress"] = status["progress"]
snap["error"] = status["error"] 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}-")] for a in active_agents(f"blocks-{topic}-")]
return snap return snap
@router.get("/blocks/agents") @router.get("/blocks/agents")
async def get_blocks_agents(topic: str): 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}-")] 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)} 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") @router.post("/blocks/cancel")
async def cancel_blocks_route(topic: str): async def cancel_blocks_route(topic: str):
if not cancel_blocks(topic): 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: 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 → """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 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.""" Re-rating of the same question uses the open streak anchor → idempotent."""
async with _check_lock(req.topic, req.block): async with _check_lock(req.topic, req.block):
state = await get_block_progress(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) basis, re_rating = _basis(state, question)
streak_basis = state["offene_streak"] if re_rating else state["streak"] streak_basis = state["offene_streak"] if re_rating else state["streak"]
if not re_rating: 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) score = compute_score(basis, d, floor, ca, cf)
points = score - basis points = score - basis
good, streak = await set_block_score_and_streak(req.topic, req.block, score, new_streak) 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} 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["progress"] = guide.get("progress") if guide else None
snap["error"] = guide.get("error_msg") if guide else None snap["error"] = guide.get("error_msg") if guide else None
prefix = f"{guide['id']}-" if guide else "-" 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)] for a in active_agents(prefix)]
return snap return snap
@@ -658,82 +671,6 @@ async def block_adopt_route(guide_id: str, req: BlockUebernehmenRequest):
return res 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") @router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str): async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id) cancelled = await cancel_guide(guide_id)

View File

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

View File

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

View File

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

View File

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

113
dev-ops/opencode-slim.json Normal file
View File

@@ -0,0 +1,113 @@
// Auto-Ableitung von opencode.json OHNE mcp-Server: Batch-Agenten (files/readonly/text)
// brauchen keine Web-MCPs — jeder opencode-Prozess startet sonst ~3 MCP-Prozesse (~300 MB).
// Bei Änderungen an opencode.json hier nachziehen (nur der mcp-Block fehlt).
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"minimax": {
"options": {
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3"
}
}
},
"minimax-kalt": {
"npm": "@ai-sdk/anthropic",
"name": "MiniMax (kalt — niedrige Temperature, ohne Thinking)",
"options": {
"baseURL": "https://api.minimax.io/anthropic/v1",
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3 (kalt)",
"options": {
"temperature": 0.2,
"thinking": {
"type": "disabled"
}
}
},
"MiniMax-M2.7-highspeed": {
"name": "MiniMax M2.7 highspeed (kalt)",
"options": {
"temperature": 0.3
}
}
}
},
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama (lokal)",
"options": {
"baseURL": "http://localhost:11434/v1"
},
"models": {
"qwen3.6:27b": {
"name": "Qwen3.6 27B"
},
"qwen3.5:9b": {
"name": "Qwen3.5 9B"
}
}
}
},
"agent": {
"full": {
"description": "Alle Tools: Dateien, Bash, Websuche",
"permission": {
"edit": "allow",
"bash": "allow",
"webfetch": "allow"
}
},
"files": {
"description": "Dateien lesen/schreiben + Bash, keine Websuche",
"permission": {
"edit": "allow",
"bash": "allow",
"webfetch": "deny"
},
"tools": {
"minimax-search*": false,
"searxng*": false
}
},
"readonly": {
"description": "Nur Dateien lesen",
"permission": {
"edit": "deny",
"bash": "deny",
"webfetch": "deny"
},
"tools": {
"write": false,
"edit": false,
"bash": false,
"minimax-search*": false,
"searxng*": false
}
},
"text": {
"description": "Reine Textantwort, keine Tools",
"permission": {
"edit": "deny",
"bash": "deny",
"webfetch": "deny"
},
"tools": {
"write": false,
"edit": false,
"bash": false,
"read": false,
"glob": false,
"grep": false,
"minimax-search*": false,
"searxng*": false
}
}
}
}

View File

@@ -1,13 +1,11 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, fetchFolders, updateSource as apiUpdateQuelle } from './api.js' import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js' import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import BlocksOverview from './components/BlocksOverview.vue' import BlocksOverview from './components/BlocksOverview.vue'
import GuideBoard from './components/GuideBoard.vue' import GenerationView from './components/GenerationView.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue' import GeneralExamPanel from './components/GeneralExamPanel.vue'
const guides = ref([]) const guides = ref([])
@@ -28,19 +26,14 @@ const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude') const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([]) const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | guideboard | elements | general | detail — exclusive main-area view const mainView = ref('blocks') // blocks | generation | general | detail — exclusive main-area view
const guideBoardFormat = ref('Guide') const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view) const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
const stats = ref(null) const stats = ref(null)
const progress = ref({}) const progress = ref({})
const locks = ref({}) // lock reasons per format (backend = single rule source) const locks = ref({}) // lock reasons per format (backend = single rule source)
const guideStepsDone = ref({}) // highest finished step index per format (artifact-based)
const uiError = ref(null) // surface rejected actions (409/400) const uiError = ref(null) // surface rejected actions (409/400)
const elementsOpen = ref(false) // right sidebar
const elementsVersion = ref(0) // increment = reload overview
const elementOpenId = ref(null) // open element from overview in sidebar
const elementOpenTick = ref(0)
// Run a loader, log + swallow its error (a failed background load must not break the UI). // Run a loader, log + swallow its error (a failed background load must not break the UI).
async function guard(label, fn) { async function guard(label, fn) {
@@ -172,12 +165,10 @@ async function loadBlocks() {
blocks.value = await fetchBlocksStatus(selectedTopic.value) blocks.value = await fetchBlocksStatus(selectedTopic.value)
progress.value = await fetchTopicProgress(selectedTopic.value) progress.value = await fetchTopicProgress(selectedTopic.value)
locks.value = await fetchGuideLocks(selectedTopic.value) locks.value = await fetchGuideLocks(selectedTopic.value)
guideStepsDone.value = await fetchGuideSteps(selectedTopic.value)
} else { } else {
blocks.value = { ...EMPTY_BLOCKS } blocks.value = { ...EMPTY_BLOCKS }
progress.value = {} progress.value = {}
locks.value = {} locks.value = {}
guideStepsDone.value = {}
} }
if (activeBlocks.value.length && !polling.running()) startPolling() if (activeBlocks.value.length && !polling.running()) startPolling()
} catch (e) { } catch (e) {
@@ -189,9 +180,7 @@ function selectTopic(topic) {
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
sidebarSticky.value = false sidebarSticky.value = false
elementsOpen.value = false
mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click) mainView.value = 'blocks' // topic click → blocks overview (guide only on pill click)
elementOpenId.value = null
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact' viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
loadBlocks() loadBlocks()
@@ -273,6 +262,7 @@ async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt })
} }
await loadTopics() await loadTopics()
selectTopic(topic) selectTopic(topic)
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
startPolling() startPolling()
} }
@@ -326,10 +316,35 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
function handleOpenGuideBoard(format = 'Guide') { function handleOpenGuideBoard(format = 'Guide') {
if (!selectedTopic.value) return if (!selectedTopic.value) return
guideBoardFormat.value = format guideBoardFormat.value = format
mainView.value = 'guideboard' mainView.value = 'generation'
previewGuide.value = null previewGuide.value = null
} }
function handleOpenGeneration() {
if (!selectedTopic.value) return
mainView.value = 'generation'
previewGuide.value = null
}
async function handleRestartCard(cardId) {
uiError.value = null
try {
await apiRestartBlocksCard(selectedTopic.value, cardId)
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
} catch (e) {
uiError.value = e.message
}
}
async function handleResetGuideCard({ format, blockNorm, abStage }) {
uiError.value = null
try {
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
} catch (e) {
uiError.value = e.message
}
}
async function handleGuideBoardReset({ format, abStage }) { async function handleGuideBoardReset({ format, abStage }) {
uiError.value = null uiError.value = null
try { try {
@@ -355,18 +370,6 @@ function handleGeneralExam() {
previewGuide.value = null previewGuide.value = null
} }
function handleOpenElements() {
if (!selectedTopic.value) return
mainView.value = 'elements'
// Right sidebar stays closed — it opens only when an element is clicked.
}
function handleOpenElementDetail(el) {
elementOpenId.value = el.id
elementOpenTick.value++
elementsOpen.value = true
}
async function handleDeleteGuide(guideId, slots = false) { async function handleDeleteGuide(guideId, slots = false) {
await deleteGuide(guideId, slots) await deleteGuide(guideId, slots)
if (previewGuide.value?.id === guideId) { if (previewGuide.value?.id === guideId) {
@@ -422,7 +425,6 @@ onMounted(async () => {
:stats="stats" :stats="stats"
:fortschritt="progress" :fortschritt="progress"
:locks="locks" :locks="locks"
:guideStepsDone="guideStepsDone"
:uiError="uiError" :uiError="uiError"
:doneByFormat="doneByFormat" :doneByFormat="doneByFormat"
:latestByFormat="latestByFormat" :latestByFormat="latestByFormat"
@@ -447,17 +449,12 @@ onMounted(async () => {
@updateSource="handleUpdateSource" @updateSource="handleUpdateSource"
@openBausteineView="handleOpenBlocksView" @openBausteineView="handleOpenBlocksView"
@openGuideBoard="handleOpenGuideBoard" @openGuideBoard="handleOpenGuideBoard"
@formatClick="handleFormatClick"
@bausteineClick="handleBlocksClick" @bausteineClick="handleBlocksClick"
@cancelBlocks="handleCancelBlocks"
@resetBausteine="handleResetBlocks"
@deleteTopic="handleDeleteTopic" @deleteTopic="handleDeleteTopic"
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError" @dismissError="handleDismissError"
@dismissUiError="uiError = null" @dismissUiError="uiError = null"
@preview="handlePreview" @preview="handlePreview"
@openElements="handleOpenElements" @openGeneration="handleOpenGeneration"
@togglePin="toggleSidebarPin" @togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave" @sidebarLeave="onSidebarLeave"
/> />
@@ -469,6 +466,17 @@ onMounted(async () => {
:ready="blocks.ready" :ready="blocks.ready"
:partial="blocks.partial" :partial="blocks.partial"
@close="mainView = 'detail'" @close="mainView = 'detail'"
@openGeneration="handleOpenGeneration"
/>
<GenerationView
v-else-if="selectedTopic && mainView === 'generation'"
:topic="selectedTopic"
:generating="blocks.generating"
:progress="blocks.progress"
:ready="blocks.ready"
:partial="blocks.partial"
:guideFormat="guideBoardFormat"
@close="mainView = 'blocks'"
@resetStage="handleResetStage" @resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })" @restartAll="() => handleBlocksClick({ research: true })"
@continueAll="() => handleBlocksClick({ research: false })" @continueAll="() => handleBlocksClick({ research: false })"
@@ -476,22 +484,13 @@ onMounted(async () => {
@requeueDead="handleRequeueDead" @requeueDead="handleRequeueDead"
@removeAll="handleResetBlocks" @removeAll="handleResetBlocks"
@cancel="handleCancelBlocks" @cancel="handleCancelBlocks"
/>
<GuideBoard
v-else-if="selectedTopic && mainView === 'guideboard'"
:topic="selectedTopic"
:format="guideBoardFormat"
@close="mainView = 'blocks'"
@cancelGuide="handleCancel" @cancelGuide="handleCancel"
@startGuide="handleFormatClick" @startGuide="handleFormatClick"
@resetStage="handleGuideBoardReset" @resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview" @preview="handleGuideBoardPreview"
/> @deleteGuide="handleDeleteGuide"
<ElementsOverview @restartCard="handleRestartCard"
v-else-if="selectedTopic && mainView === 'elements'" @resetGuideCard="handleResetGuideCard"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
/> />
<GeneralExamPanel <GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'" v-else-if="selectedTopic && mainView === 'general'"
@@ -505,7 +504,6 @@ onMounted(async () => {
:previewGuide="previewGuide" :previewGuide="previewGuide"
:dark="darkMode" :dark="darkMode"
:provider="provider" :provider="provider"
:elementsOpen="elementsOpen"
:doneByFormat="doneByFormat" :doneByFormat="doneByFormat"
:themaAbgeschlossen="!!progress.completed" :themaAbgeschlossen="!!progress.completed"
:ansichtModus="viewMode" :ansichtModus="viewMode"
@@ -518,20 +516,6 @@ onMounted(async () => {
<div v-else class="empty-main"> <div v-else class="empty-main">
<p>Create or select a topic in the sidebar.</p> <p>Create or select a topic in the sidebar.</p>
</div> </div>
<div
v-if="elementsOpen && selectedTopic"
class="elements-backdrop"
@click="elementsOpen = false"
></div>
<ElementsSidebar
v-if="elementsOpen && selectedTopic"
:topic="selectedTopic"
:provider="provider"
:openId="elementOpenId"
:openTick="elementOpenTick"
@close="elementsOpen = false"
@changed="elementsVersion++"
/>
</div> </div>
</template> </template>
@@ -685,19 +669,4 @@ textarea::placeholder {
font-size: 1rem; font-size: 1rem;
} }
/* Only visible when the elements sidebar sits as an overlay on mobile.
A tap next to it closes it. */
.elements-backdrop {
display: none;
}
@media (max-width: 768px) {
.elements-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 29;
background: var(--shadow);
}
}
</style> </style>

View File

@@ -78,6 +78,24 @@ export async function addBlocksResearch(topic, provider = 'claude') {
return jsonOrThrow(res) return jsonOrThrow(res)
} }
export async function restartBlocksCard(topic, cardId) {
const res = await fetch(`${BASE}/blocks/card-restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, card_id: cardId }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) {
const res = await fetch(`${BASE}/guides/board/card-reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
})
return jsonOrThrow(res)
}
export async function requeueBlocksDead(topic) { export async function requeueBlocksDead(topic) {
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' }) const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
return jsonOrThrow(res) return jsonOrThrow(res)
@@ -253,68 +271,3 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
return res.json() return res.json()
} }
export async function fetchElements(topic) {
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createElement(topic, hint = '', provider = 'claude') {
const res = await fetch(`${BASE}/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, hint, provider }),
})
return res.json()
}
export async function chatElement(id, messages, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, provider }),
})
return res.json()
}
export async function deleteElement(id) {
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
}
export async function updateElement(id, fields) {
const res = await fetch(`${BASE}/elements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
return res.json()
}
export async function styleElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/style`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Stil-Exam fehlgeschlagen (${res.status})`)
return res.json()
}
export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/refine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestion, instruction, provider }),
})
if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`)
return res.json()
}
export async function checkElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Exam fehlgeschlagen (${res.status})`)
return res.json()
}

View File

@@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview, fetchBlocksBoard } from '../api.js' import { fetchBlocksOverview } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
const props = defineProps({ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
@@ -10,71 +9,21 @@ const props = defineProps({
ready: { type: Boolean, default: false }, ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false }, partial: { type: Boolean, default: false },
}) })
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', 'requeueDead', 'removeAll', 'cancel']) const emit = defineEmits(['close', 'openGeneration'])
// ── Live-Kanban-Board (Poll 1.2s solange generiert) ────────────────────────────
// State ZUERST deklarieren: die immediate-Watches unten rufen load() synchron beim
// Setup — spätere const-Deklarationen wären dort noch TDZ (ReferenceError).
const board = ref(null)
const items = ref([]) const items = ref([])
const loading = ref(true) const loading = ref(true)
const error = ref(null) const error = ref(null)
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View).
let timer = null let timer = null
let lastDone = -1 function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
async function pollBoard() { watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
try { watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
board.value = await fetchBlocksBoard(props.topic)
if (board.value.done !== lastDone) { // neue fertige Blöcke → Grid live nachladen
lastDone = board.value.done
load()
}
} catch { /* Board noch leer */ }
}
function startPoll() {
stopPoll()
timer = setInterval(pollBoard, 1200)
}
function stopPoll() {
if (timer) { clearInterval(timer); timer = null }
}
watch(() => props.topic, () => { board.value = null; lastDone = -1; pollBoard(); load() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard(); load() } // Endstand + fertige Blöcke nachladen
}, { immediate: true })
onUnmounted(stopPoll) onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
const dead = computed(() => board.value?.dead || [])
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
}
// ── Fertige Blöcke (Grid) ────────────────────────────────────────────────────── // ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [ const LEVELS = [
{ key: 'beginner', label: 'Beginner' }, { key: 'beginner', label: 'Beginner' },
@@ -120,59 +69,13 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<button class="bk-close" title="Close" @click="emit('close')"></button> <button class="bk-close" title="Close" @click="emit('close')"></button>
</header> </header>
<section class="bk-board"> <button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
<div class="bk-steps-top"> <span class="bk-progress-dot"></span>
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div> Generierung läuft{{ progress ? ' · ' + progress : '' }} Board öffnen
<div v-if="!generating" class="bk-global-actions"> </button>
<button class="bk-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button> <button v-else-if="!ready && !partial && !items.length && !loading" class="bk-banner idle" @click="emit('openGeneration')">
<button v-if="partial" class="bk-act" @click="emit('continueAll')">Continue</button> Noch keine Bausteine zur Generierung
<button </button>
v-if="dead.length"
class="bk-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
> {{ dead.length }} dead</button>
<button
v-if="ready || partial"
class="bk-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="bk-global-actions">
<button class="bk-act" @click="emit('addResearch')">+ Research</button>
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
</div>
</div>
<div v-if="boardEmpty && !generating" class="bk-board-empty">No board yet generation streams live cards through the columns here.</div>
<template v-else>
<div class="bk-board-label">Inventar</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="bk-board-label">Artefakte</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
/>
</template>
<div v-if="sel && !generating" class="bk-step-actions">
<span class="bk-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="bk-act play" @click="resetHere(true)"> neu generieren</button>
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="bk-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<div v-if="loading" class="bk-empty-state">Loading</div> <div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div> <div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
@@ -242,29 +145,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
} }
.bk-close:hover { border-color: var(--accent); } .bk-close:hover { border-color: var(--accent); }
/* Live board above the blocks */
.bk-board {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.bk-board-label {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
}
.bk-board-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
.bk-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.bk-progress-dot { .bk-progress-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
@@ -274,35 +154,27 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
} }
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } @keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.bk-step-actions {
.bk-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.75rem; margin: 0.85rem 2rem 0;
padding-top: 0.7rem; padding: 0.55rem 0.9rem;
border-top: 1px dashed var(--border-strong); border: 1px solid var(--accent);
} border-radius: 8px;
.bk-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.bk-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel); background: var(--panel);
color: var(--text); color: var(--accent);
font-size: 0.8rem; font-size: 0.84rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600; font-weight: 600;
cursor: pointer;
text-align: left;
} }
.bk-act:hover { border-color: var(--accent); } .bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
.bk-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } .bk-banner:hover { background: var(--panel-soft); }
.bk-act.play:hover { background: var(--accent-hover); }
.bk-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.bk-act.danger.armed { background: var(--danger); color: #fff; }
.bk-act.ghost { color: var(--text-muted); }
.bk-empty-state { .bk-empty-state {
flex: 1; flex: 1;

View File

@@ -1,167 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements } from '../api.js'
import { renderMarkdown, plainText } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
version: { type: Number, default: 0 }, // increment = reload elements
})
const emit = defineEmits(['open'])
const elements = ref([])
watch([() => props.topic, () => props.version], load, { immediate: true })
async function load() {
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Failed to load elements:', e)
}
}
</script>
<template>
<div class="elements-overview">
<div class="overview-scroll">
<div class="overview-content">
<header class="overview-head">
<h1>{{ topic }}</h1>
<span class="overview-format">Elements</span>
</header>
<p v-if="!elements.length" class="overview-empty">
No elements yet. Enter a keyword in the sidebar on the right and click +.
</p>
<div class="element-grid">
<article
v-for="el in elements"
:key="el.id"
class="element-card"
@click="emit('open', el)"
>
<h3>{{ plainText(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block">
<h4>Hints</h4>
<ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.elements-overview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.overview-scroll {
flex: 1;
overflow-y: auto;
}
.overview-content {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 2rem 4rem;
}
.overview-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.overview-head h1 {
margin: 0;
font-size: 2.2rem;
color: var(--text);
}
.overview-format {
font-size: 1rem;
font-weight: 600;
color: var(--text-faint);
}
.overview-empty {
color: var(--text-muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
align-items: start;
}
.element-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
}
.element-card:hover {
box-shadow: 0 4px 16px var(--shadow);
transform: translateY(-1px);
}
.element-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--text);
}
.el-example {
margin-top: 0.5rem;
}
.el-hints-block {
margin-top: 0.7rem;
}
.el-hints-block h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-hints-block ul {
margin: 0;
padding-left: 1.1rem;
}
.el-hints-block li {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.2rem;
}
/* Markdown: base styles global (assets/markdown.css), here only the card base font */
.markdown {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
</style>

View File

@@ -0,0 +1,265 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue'
const props = defineProps({
topic: { type: String, required: true },
generating: { type: Boolean, default: false },
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
guideFormat: { type: String, default: 'Guide' },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'deleteGuide', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
const board = ref(null)
let timer = null
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ }
}
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen
}, { immediate: true })
onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
const dead = computed(() => board.value?.dead || [])
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
confirm.value = null
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
function restartCard() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('restartCard', k.card_id)
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
}
</script>
<template>
<div class="gen-view">
<header class="gen-head">
<h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span>
<button class="gen-close" title="Close" @click="emit('close')"></button>
</header>
<section class="gen-section">
<div class="gen-steps-top">
<span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="gen-actions">
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
<button
v-if="ready || partial"
class="gen-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
</div>
<button
v-if="dead.length"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
> {{ dead.length }} dead</button>
</div>
<div v-if="boardEmpty && !generating" class="gen-empty">No board yet generation streams live cards through the columns here.</div>
<template v-else>
<div class="gen-board-label">Inventar</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="gen-board-label">Artefakte</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
</template>
<div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<section class="gen-section">
<GuideBoardSection
:topic="topic"
:format="guideFormat"
@cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')"
@deleteGuide="(id) => emit('deleteGuide', id)"
@resetCard="(p) => emit('resetGuideCard', p)"
/>
</section>
</div>
</template>
<style scoped>
.gen-view {
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--bg-preview);
}
.gen-head {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.gen-head h1 { font-size: 1.5rem; }
.gen-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.gen-spacer { flex: 1; }
.gen-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.gen-close:hover { border-color: var(--accent); }
.gen-section {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
}
.gen-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
.gen-progress {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
}
.gen-progress-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gen-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gen-step-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.gen-act {
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.8rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
.gen-act.ghost { color: var(--text-muted); }
</style>

View File

@@ -7,7 +7,7 @@ const props = defineProps({
topic: { type: String, required: true }, topic: { type: String, required: true },
format: { type: String, default: 'Guide' }, format: { type: String, default: 'Guide' },
}) })
const emit = defineEmits(['close', 'cancelGuide', 'startGuide', 'resetStage', 'preview']) const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'deleteGuide', 'resetCard'])
const board = ref(null) const board = ref(null)
let timer = null let timer = null
@@ -32,13 +32,31 @@ const done = computed(() => columns.value.find((c) => c.key === 'done')?.total |
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done"). // Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit'] const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
const sel = ref(null) const sel = ref(null)
const selCard = ref(null)
const confirm = ref(null) const confirm = ref(null)
function stageClick(c) { function stageClick(c) {
if (generating.value || !STAGES.includes(c.key)) return if (generating.value || !STAGES.includes(c.key)) return
confirm.value = null confirm.value = null
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) } sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
} }
function cardClick(k) {
if (generating.value || !k.card_id) return
confirm.value = null
sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
}
function resetCardHere() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400)
}
function arm(action, fn) { function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() } if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action else confirm.value = action
@@ -58,86 +76,56 @@ function resetHere() {
</script> </script>
<template> <template>
<div class="gb-view"> <section class="gb-board">
<header class="gb-head"> <div class="gb-top">
<h1>{{ topic }}</h1> <span class="gb-title">Guide · {{ format }}</span>
<span class="gb-sub">Guide-Board · {{ format }}</span>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span> <span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span class="gb-spacer"></span> <div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
<button class="gb-close" title="Close" @click="emit('close')"></button> <div v-if="board?.error" class="gb-error">{{ board.error }}</div>
</header> <div class="gb-actions">
<template v-if="generating">
<section class="gb-board"> <button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
<div class="gb-top"> </template>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div> <template v-else>
<div v-if="board?.error" class="gb-error">{{ board.error }}</div> <button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<div class="gb-actions"> <button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<template v-if="generating"> <button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button> </template>
</template>
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
</template>
</div>
</div> </div>
</div>
<KanbanBoard <KanbanBoard
:columns="columns" :columns="columns"
:agents="board?.agents || []" :agents="board?.agents || []"
:generating="generating" :generating="generating"
:selectable="!generating" :selectable="!generating"
:selectedKey="sel?.key || null" :cardSelectable="!generating"
@stageClick="stageClick" :selectedKey="sel?.key || null"
/> @stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="sel && !generating" class="gb-stage-actions"> <div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span> <span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button> <button class="gb-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button> <button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button> </div>
</div> <div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div> <div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section> </section>
</div>
</template> </template>
<style scoped> <style scoped>
.gb-view { .gb-board { padding: 0.85rem 0 0; }
flex: 1;
min-width: 0;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.gb-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.gb-head h1 { font-size: 1.5rem; }
.gb-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-spacer { flex: 1; }
.gb-close {
align-self: center;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
width: 2rem;
height: 2rem;
cursor: pointer;
}
.gb-close:hover { border-color: var(--accent); }
.gb-board { padding: 0.85rem 2rem; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; } .gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
.gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-progress { .gb-progress {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -6,10 +6,11 @@ const props = defineProps({
agents: { type: Array, default: () => [] }, // [{label, runtime}] agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false }, generating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte) selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
selectedKey: { type: String, default: null }, selectedKey: { type: String, default: null },
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten) hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
}) })
const emit = defineEmits(['stageClick']) const emit = defineEmits(['stageClick', 'cardClick'])
function visible(c) { function visible(c) {
return !props.hideEmpty || c.total > 0 return !props.hideEmpty || c.total > 0
@@ -24,14 +25,14 @@ function fmtRuntime(s) {
<div class="kb"> <div class="kb">
<div v-if="agents.length" class="kb-agents"> <div v-if="agents.length" class="kb-agents">
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span> <span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
<span v-for="a in agents" :key="a.label" class="kb-agent">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span> <span v-for="(a, i) in agents" :key="a.key || i" class="kb-agent" :title="a.key">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
</div> </div>
<div class="kb-cols"> <div class="kb-cols">
<div <div
v-for="c in columns.filter(visible)" v-for="c in columns.filter(visible)"
:key="(c.board || '') + c.key" :key="(c.board || '') + c.key"
class="kb-col" class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key }" :class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
> >
<button <button
class="kb-col-head" class="kb-col-head"
@@ -43,7 +44,12 @@ function fmtRuntime(s) {
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span> <span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</button> </button>
<ul v-if="c.cards && c.cards.length" class="kb-cards"> <ul v-if="c.cards && c.cards.length" class="kb-cards">
<li v-for="(k, i) in c.cards" :key="i" class="kb-card" :class="k.status" :title="k.info || k.title"> <li
v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="[k.status, { klickbar: cardSelectable && k.card_id }]"
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)"
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
>
<div class="kb-card-row"> <div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span> <span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
<span class="kb-card-title">{{ k.title }}</span> <span class="kb-card-title">{{ k.title }}</span>
@@ -51,6 +57,13 @@ function fmtRuntime(s) {
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span> <span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
</div> </div>
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div> <div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
<div v-if="k.step_n && k.status === 'active'" class="kb-steps" :title="k.steps ? k.steps.join(' → ') : ''">
<span
v-for="s in k.step_n" :key="s" class="kb-step"
:class="{ done: s < k.step_i, act: s === k.step_i }"
:title="k.steps ? k.steps[s - 1] : ''"
></span>
</div>
</li> </li>
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li> <li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
</ul> </ul>
@@ -81,12 +94,15 @@ function fmtRuntime(s) {
.kb-cols { .kb-cols {
display: flex; display: flex;
gap: 0.45rem; gap: 0.45rem;
overflow-x: auto; overflow-x: auto; /* Fallback (schmale Screens) — am Desktop passt alles dank Kollaps */
align-items: flex-start; align-items: stretch;
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
} }
.kb-col { .kb-col {
flex: 0 0 150px; flex: 1 1 150px;
min-width: 140px;
max-width: 230px;
align-self: flex-start;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: var(--panel);
@@ -95,6 +111,36 @@ function fmtRuntime(s) {
.kb-col.active { opacity: 1; border-color: var(--border-strong); } .kb-col.active { opacity: 1; border-color: var(--border-strong); }
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } .kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
/* Leere Spalten kollabieren zu schmalen Säulen (Jira-Muster) — der Kopf bleibt
klickbar (Reset ab Spalte), das Label läuft vertikal. */
.kb-col.collapsed {
flex: 0 0 auto;
min-width: 0;
width: 32px;
align-self: stretch;
opacity: 0.5;
}
.kb-col.collapsed:hover { opacity: 0.85; }
.kb-col.collapsed .kb-col-head {
flex-direction: column-reverse;
justify-content: flex-end;
align-items: center;
gap: 0.4rem;
height: 100%;
min-height: 130px;
border-bottom: none;
border-radius: 8px;
padding: 0.4rem 0;
}
.kb-col.collapsed .kb-col-label {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.6rem;
overflow: hidden;
text-overflow: ellipsis;
max-height: 150px;
}
.kb-col-head { .kb-col-head {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -151,6 +197,9 @@ function fmtRuntime(s) {
.kb-card.error { border-color: var(--danger); } .kb-card.error { border-color: var(--danger); }
.kb-card.active { border-color: var(--accent-border); } .kb-card.active { border-color: var(--accent-border); }
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; } .kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
.kb-card.klickbar { cursor: pointer; }
.kb-card.klickbar:hover { border-color: var(--accent); }
.kb-card-info { .kb-card-info {
margin: 0.15rem 0 0 1rem; margin: 0.15rem 0 0 1rem;
font-size: 0.66rem; font-size: 0.66rem;
@@ -159,6 +208,29 @@ function fmtRuntime(s) {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* Phase stepper: one segment per fine step of the card's stage */
.kb-steps {
display: flex;
gap: 3px;
margin: 0.25rem 0 0.05rem 1rem;
}
.kb-step {
flex: 1;
max-width: 26px;
height: 3px;
border-radius: 2px;
background: var(--border, #444);
}
.kb-step.done { background: var(--accent, #7aa2f7); opacity: 0.55; }
.kb-step.act {
background: var(--accent, #7aa2f7);
animation: kbStepPulse 1.2s ease-in-out infinite;
}
@keyframes kbStepPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.kb-card-title { .kb-card-title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -22,7 +22,6 @@ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
dark: { type: Boolean, default: false }, dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
elementsOpen: { type: Boolean, default: false }, // element sidebar open → chat to the left
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related) doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
themaAbgeschlossen: { type: Boolean, default: false }, themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
@@ -209,12 +208,6 @@ function closeChat() {
chat.reset() chat.reset()
} }
// On mobile, chat and element sidebar are mutually exclusive —
// there is no room side by side, the sidebar would cover the chat.
watch(() => props.elementsOpen, (open) => {
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
})
function onDocMouseDown(e) { function onDocMouseDown(e) {
if (!chatOpen.value) return if (!chatOpen.value) return
if (panelEl.value && panelEl.value.contains(e.target)) return if (panelEl.value && panelEl.value.contains(e.target)) return
@@ -354,9 +347,9 @@ function extractContext() {
@section-updated="onSectionUpdated" @section-updated="onSectionUpdated"
/> />
<button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Questions about the guide" @click="openChat">💬</button> <button v-if="previewGuide && !chatOpen && focusIndex === null" class="chat-fab" title="Questions about the guide" @click="openChat">💬</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }"> <div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
<header class="chat-header"> <header class="chat-header">
<span>Questions about the guide</span> <span>Questions about the guide</span>
<button class="chat-close" title="Close chat" @click="closeChat">×</button> <button class="chat-close" title="Close chat" @click="closeChat">×</button>
@@ -640,23 +633,6 @@ function extractContext() {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* Element sidebar (320px) open → show chat to its left */
.chat-fab.shifted {
right: calc(1.5rem + 320px);
}
.chat-panel.shifted {
right: calc(1.5rem + 320px);
}
/* On mobile the element sidebar overlays the chat — hide FAB/panel */
@media (max-width: 768px) {
.chat-fab.shifted,
.chat-panel.shifted {
display: none;
}
}
.chat-panel { .chat-panel {
position: fixed; position: fixed;
right: 1.5rem; right: 1.5rem;

View File

@@ -9,7 +9,6 @@ const props = defineProps({
stats: { type: Object, default: null }, stats: { type: Object, default: null },
fortschritt: { type: Object, default: () => ({}) }, fortschritt: { type: Object, default: () => ({}) },
locks: { type: Object, default: () => ({}) }, locks: { type: Object, default: () => ({}) },
guideStepsDone: { type: Object, default: () => ({}) }, // highest finished step per format (-1 = none)
uiError: { type: String, default: null }, uiError: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) }, doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) }, latestByFormat: { type: Object, default: () => ({}) },
@@ -26,7 +25,7 @@ const props = defineProps({
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
}) })
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider']) const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'. // Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null) const openPanel = ref(null)
@@ -85,14 +84,6 @@ const activeGenerations = computed(() => {
const { pending: pendingConfirm, armOrRun } = useConfirm() const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBlocks() {
armOrRun('blocks', () => emit('cancelBlocks'))
}
function confirmResetBlocks() {
armOrRun('blocks', () => emit('resetBausteine'))
}
// Name click = open the block overview (generate/resume/remove all live there now). // Name click = open the block overview (generate/resume/remove all live there now).
function onBlocksName() { function onBlocksName() {
emit('openBausteineView') emit('openBausteineView')
@@ -108,26 +99,6 @@ function guideStatus(format) {
return latest.status return latest.status
} }
// Stage dots of the guide board (display only — restart/reset lives on the board)
const GUIDE_STEPS = ['Lernziele', 'Zuweisung', 'Writer', 'Fakten', 'Coverage', 'Lesbarkeit']
// Dots from the card-based "done" marker: ≤ done = done. Running → done+1 active.
function guideSteps(format) {
const labels = GUIDE_STEPS
const done = props.guideStepsDone[format] ?? -1
const st = guideStatus(format)
const active = st === 'generating' || st === 'queued' ? done + 1 : -1
return labels.map((label, i) => ({
label,
state: i <= done ? 'done' : i === active ? 'active' : 'pending',
}))
}
// Dot click → open the live guide board (the board hosts restart/reset per column).
function guideStepClick(format) {
emit('openGuideBoard', format)
}
function errorMsg(format) { function errorMsg(format) {
const latest = props.latestByFormat[format] const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return '' if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
@@ -141,23 +112,11 @@ function aborted(format) {
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled') return latest?.status === 'error' && (latest.error_msg || '').startsWith('Cancelled')
} }
// Name click: finished guide → preview, otherwise toggle action panel. // Name click: finished guide → preview, otherwise the generation view (start/cancel live there).
function handleFormatClick(format) { function handleFormatClick(format) {
const guide = props.doneByFormat[format] const guide = props.doneByFormat[format]
if (guide) emit('preview', guide) if (guide) emit('preview', guide)
else togglePanel('fmt-' + format) else emit('openGuideBoard', format)
}
// Lock reasons come from the backend (GET /guides/locks) — the rules only
// exist there now. While locks are not yet loaded: button enabled, the
// backend rejects invalid starts anyway (visible via uiError).
function playLock(format) {
return props.locks?.[format] ?? null
}
function handlePlay(format) {
if (playLock(format)) return
emit('formatClick', { format, instructions: '', abStep: null }) // Restart-ab-Stage lebt auf dem Board
} }
// Flash-message behavior: × only hides, nothing is deleted // Flash-message behavior: × only hides, nothing is deleted
@@ -166,25 +125,6 @@ function dismissError(format) {
if (latest?.status === 'error') emit('dismissError', latest.id) if (latest?.status === 'error') emit('dismissError', latest.id)
} }
function handleDelete(format) {
if (!props.latestByFormat[format]) return
armOrRun('fmt-' + format, () => {
// Cancel all running generations of the format (also covers duplicates)
const running = props.allGuides.filter(
(g) => g.topic === props.selectedTopic && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running.length) {
for (const g of running) emit('cancelGuide', g.id)
} else if (aborted(format)) {
// Paused run: delete partial progress incl. step files (reset)
emit('deleteGuide', props.latestByFormat[format].id, true)
} else {
emit('deleteGuide', props.latestByFormat[format].id)
}
})
}
// Create area: inline expandable (name + more info + source type). // Create area: inline expandable (name + more info + source type).
const dlg = ref(false) const dlg = ref(false)
const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }) const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' })
@@ -347,7 +287,7 @@ function saveSource() {
<div class="ord-blocks"> <div class="ord-blocks">
<div <div
class="format-row blocks-row" class="format-row blocks-row"
:class="{ 'is-active': blocksState === 'generating' || blocks.partial, 'row-open': isOpen('blocks') }" :class="{ 'is-active': blocksState === 'generating' || blocks.partial }"
> >
<button class="format-name blocks-name" @click="onBlocksName"> <button class="format-name blocks-name" @click="onBlocksName">
<span class="format-label">Blocks</span> <span class="format-label">Blocks</span>
@@ -357,20 +297,6 @@ function saveSource() {
title="Aborted — can be resumed" title="Aborted — can be resumed"
>Paused</span> >Paused</span>
</button> </button>
<button v-if="blocksState === 'generating' || blocks.ready || blocks.partial" class="panel-toggle" :class="{ open: isOpen('blocks') }" title="Actions" @click.stop="togglePanel('blocks')"></button>
</div>
<div v-if="isOpen('blocks')" class="action-panel">
<template v-if="blocksState === 'generating'">
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'blocks' }" @click="confirmCancelBlocks">{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Cancel' }}</button>
</template>
<template v-else>
<button
v-if="blocks.ready || blocks.partial"
class="panel-btn danger"
:class="{ armed: pendingConfirm === 'blocks' }"
@click="confirmResetBlocks"
>{{ pendingConfirm === 'blocks' ? 'Sure?' : 'Remove' }}</button>
</template>
</div> </div>
<div v-if="blocksState === 'generating'" class="format-progress"> <div v-if="blocksState === 'generating'" class="format-progress">
{{ blocks.progress || 'Waiting' }} {{ blocks.progress || 'Waiting' }}
@@ -378,10 +304,16 @@ function saveSource() {
<div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error"> <div v-if="blocks.error && !blocks.error.startsWith('Cancelled')" class="format-error">
<span class="format-error-text">{{ blocks.error }}</span> <span class="format-error-text">{{ blocks.error }}</span>
</div> </div>
<div class="format-row">
<button class="format-name" @click="emit('openGeneration')">
<span class="format-label gen-label">Generierung</span>
<span v-if="blocksState === 'generating'" class="gen-live-dot" title="Läuft"></span>
</button>
</div>
</div> </div>
<!-- Formats come after the blocks row via CSS order (order 2) --> <!-- Formats come after the blocks row via CSS order (order 2) -->
<div v-for="f in formats" :key="f.key" :style="{ order: 3 }"> <div v-for="f in formats" :key="f.key" :style="{ order: 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key), 'row-open': isOpen('fmt-' + f.key) }]"> <div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': aborted(f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)"> <button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span> <span class="format-label">{{ f.label }}</span>
<span <span
@@ -389,37 +321,7 @@ function saveSource() {
class="resume-badge" class="resume-badge"
title="Aborted — can be resumed" title="Aborted — can be resumed"
>Paused</span> >Paused</span>
<span class="step-dots" v-if="guideSteps(f.key).length">
<span
v-for="(s, i) in guideSteps(f.key)"
:key="s.label"
class="step-pill klickbar"
:class="[s.state]"
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + ' — Klick: Live-Board öffnen'"
@click.stop="guideStepClick(f.key)"
>{{ i + 1 }}</span>
</span>
</button> </button>
<button class="panel-toggle" :class="{ open: isOpen('fmt-' + f.key) }" title="Actions" @click.stop="togglePanel('fmt-' + f.key)"></button>
</div>
<div v-if="isOpen('fmt-' + f.key)" class="action-panel">
<template v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'">
<button class="panel-btn danger" :class="{ armed: pendingConfirm === 'fmt-' + f.key }" @click="handleDelete(f.key)">{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : 'Cancel' }}</button>
</template>
<template v-else>
<button
class="panel-btn play"
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
:disabled="!!playLock(f.key)"
@click="handlePlay(f.key)"
>{{ aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
<button
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
class="panel-btn danger"
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
@click="handleDelete(f.key)"
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sure?' : aborted(f.key) ? 'Delete progress' : 'Remove' }}</button>
</template>
</div> </div>
<div <div
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'" v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
@@ -435,11 +337,6 @@ function saveSource() {
<span class="format-label">General Exam</span> <span class="format-label">General Exam</span>
</button> </button>
</div> </div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elements</span>
</button>
</div>
</div> </div>
<ul class="topic-list"> <ul class="topic-list">
@@ -723,46 +620,12 @@ function saveSource() {
cursor: pointer; cursor: pointer;
} }
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
/* Coarse phases as numbered pills (15) — display + clickable for re-run from here. */ /* Coarse phases as numbered pills (15) — display + clickable for re-run from here. */
.step-pill {
display: inline-flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 50%;
background: var(--border-strong);
color: var(--bg);
font-size: 0.62rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
border: 1.5px solid transparent;
}
.step-pill.done {
background: var(--success-border);
}
.step-pill.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
.step-pill.klickbar {
cursor: pointer;
}
.step-pill.sel {
border-color: var(--text);
box-shadow: 0 0 0 1px var(--text);
}
@keyframes dot-pulse { @keyframes dot-pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
@@ -784,6 +647,16 @@ function saveSource() {
flex-direction: column; flex-direction: column;
} }
.gen-label { color: var(--text-muted); font-size: 0.86em; }
.gen-live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
animation: gen-side-pulse 1.2s ease-in-out infinite;
}
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.ord-blocks { .ord-blocks {
order: 2; order: 2;
} }
@@ -792,10 +665,6 @@ function saveSource() {
order: 4; order: 4;
} }
.ord-elemente {
order: 5;
}
.elements-btn { .elements-btn {
cursor: pointer; cursor: pointer;
color: var(--text); color: var(--text);
@@ -889,34 +758,7 @@ function saveSource() {
.format-row.row-open, .format-row.row-open,
.topic-list li.li-open .topic-row { background: var(--panel-soft); } .topic-list li.li-open .topic-row { background: var(--panel-soft); }
.action-panel {
display: flex;
gap: 0.5rem;
padding: 0.15rem 0.75rem 0.55rem calc(0.75rem + 8px);
}
.panel-btn {
flex: 1;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.panel-btn:hover { border-color: var(--accent); }
.panel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.panel-btn:disabled:hover { border-color: var(--border-strong); }
.panel-btn.play {
color: var(--success);
background: var(--success-soft);
border-color: var(--success-border);
}
.panel-btn.play:hover { background: var(--success-soft-hover); }
.panel-btn.danger { color: var(--danger); }
.panel-btn.danger:hover { border-color: var(--danger); } .panel-btn.danger:hover { border-color: var(--danger); }
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); } .panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }

View File

@@ -1,147 +0,0 @@
<script setup>
import { watch } from 'vue'
import { chatElement } from '../../api.js'
import { useChat } from '../../composables/useChat.js'
const props = defineProps({
element: { type: Object, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['changes'])
const chat = useChat((msgs) => chatElement(props.element.id, msgs, props.provider))
const { messages, input, loading, messagesEl, inputEl, onScroll } = chat
// Different element selected → discard history
watch(() => props.element.id, () => chat.reset())
async function send() {
const res = await chat.send()
if (res?.changes?.length) emit('changes', res.changes)
}
</script>
<template>
<div class="el-chat">
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
<p v-if="!messages.length" class="chat-hint">Write what should be changed on the element.</p>
<template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Adjusting</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Adjust element…"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Cancel' : 'Send'"
@click="send"
>{{ loading ? '✕' : '➤' }}</button>
</div>
</div>
</template>
<style scoped>
.el-chat {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.78rem;
text-align: center;
margin-top: 0.5rem;
}
.chat-msg {
max-width: 85%;
padding: 6px 10px;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
height: 72px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-input button.cancel {
background: var(--danger);
}
</style>

View File

@@ -1,455 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { updateElement, checkElement, styleElement, refineSuggestion } from '../../api.js'
import { renderMarkdown, plainText } from '../../markdown.js'
import ElementSuggestion from './ElementSuggestion.vue'
import ElementChatTab from './ElementChatTab.vue'
import ElementEditTab from './ElementEditTab.vue'
const props = defineProps({
element: { type: Object, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['back', 'close', 'updated', 'changed'])
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
const savingEdit = ref(false)
// Strip Markdown characters from the header title
// Different element selected → reset exam state and tab
watch(() => props.element.id, () => {
tab.value = 'overview'
resetCheck()
})
// --- AI exam for missing info (results land as inline suggestions) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // identify the running exam; cancellation ignores its result
async function runCheck() {
if (checking.value) { // second click = cancel
checkRun++
checking.value = false
return
}
const run = ++checkRun
checking.value = true
statusMsg.value = null
try {
const res = await checkElement(props.element.id, props.provider)
if (run !== checkRun) return // cancelled or a new exam started
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'add', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'No important gaps found.'
} catch (e) {
if (run !== checkRun) return
console.error('Exam failed:', e)
statusMsg.value = 'Exam failed — please try again.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Style exam: AI proposes changes, user confirms ---
const styleChanges = ref(null) // null = not yet examined
const styling = ref(false)
const applyingStyle = ref(false)
const refiningIdx = ref(null)
let styleRun = 0
function resetStyle() {
styleChanges.value = null
styling.value = false
applyingStyle.value = false
refiningIdx.value = null
}
function suggBusy(i) {
return applyingStyle.value || refiningIdx.value === i
}
// Refine a single suggestion via instruction (pencil icon)
async function refineChange(i, instruction) {
if (refiningIdx.value !== null || applyingStyle.value) return
refiningIdx.value = i
try {
const res = await refineSuggestion(props.element.id, styleChanges.value[i], instruction, props.provider)
const next = [...styleChanges.value]
next[i] = res.change
styleChanges.value = next
} catch (e) {
console.error('Refinement failed:', e)
statusMsg.value = 'Refinement failed — please try again.'
} finally {
refiningIdx.value = null
}
}
async function runStyle() {
if (styling.value) { // second click = cancel
styleRun++
styling.value = false
return
}
const run = ++styleRun
styling.value = true
statusMsg.value = null
try {
const res = await styleElement(props.element.id, props.provider)
if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Style already fits.'
} catch (e) {
if (run !== styleRun) return
console.error('Style exam failed:', e)
statusMsg.value = 'Style exam failed — please try again.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Chat suggestions also land as inline suggestions in the overview
function onChatChanges(changes) {
styleChanges.value = [...(styleChanges.value || []), ...changes]
}
// Show suggestions at the target location: adjust/remove at the affected entry …
function styleAt(target, index = null) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'add')
}
// … additions at the end of the respective section
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'add')
}
function dismissStyleChange(i) {
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
}
async function applyStyleChange(i) {
if (applyingStyle.value) return
const c = styleChanges.value[i]
applyingStyle.value = true
try {
const STRING_TARGETS = ['title', 'description']
const fields = {
title: props.element.title,
description: props.element.description,
examples: [...props.element.examples],
hints: [...props.element.hints],
}
if (c.action === 'remove') fields[c.target].splice(c.index, 1)
else if (c.action === 'add') {
if (c.target === 'title') fields.title = c.content
else if (c.target === 'description')
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
else fields[c.target].push(c.content)
} else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
else fields[c.target][c.index] = c.content
const updated = await updateElement(props.element.id, fields)
emit('updated', updated)
// Keep remaining suggestions; indices after a removal shift up
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'remove') {
for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
}
}
styleChanges.value = rest
} catch (e) {
console.error('Apply failed:', e)
} finally {
applyingStyle.value = false
}
}
// --- Edit tab: save fields directly ---
async function saveEdit(fields) {
if (savingEdit.value) return
savingEdit.value = true
try {
const updated = await updateElement(props.element.id, fields)
emit('updated', updated)
tab.value = 'overview'
} catch (e) {
console.error('Save failed:', e)
} finally {
savingEdit.value = false
}
}
</script>
<template>
<header class="el-header">
<button class="el-back" title="Back to list" @click="emit('back')"></button>
<span class="el-title">{{ plainText(element.title) }}</span>
<button
class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Cancel exam' : 'Check for missing info'" @click="runCheck"
>🔍</button>
<button
class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Cancel exam' : 'Check & adjust style'" @click="runStyle"
></button>
<button class="el-close" title="Close" @click="emit('close')">×</button>
</header>
<nav class="el-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Overview</button>
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
<button :class="{ active: tab === 'edit' }" @click="tab = 'edit'">Edit</button>
</nav>
<!-- Overview: inseparably intertwined with styleChanges/apply stays here -->
<div v-show="tab === 'overview'" class="el-detail">
<div v-if="element.description" class="el-desc markdown" v-html="renderMarkdown(element.description)"></div>
<ElementSuggestion
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
<template v-for="(ex, i) in element.examples" :key="i">
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
<ElementSuggestion
v-for="[ci, c] in styleAt('examples', i)"
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</template>
<ElementSuggestion
v-for="[ci, c] in styleAdds('examples')"
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
<div v-if="element.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hints</h4>
<ul class="el-hints">
<li v-for="(h, i) in element.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span>
<ElementSuggestion
v-for="[ci, c] in styleAt('hints', i)"
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</li>
</ul>
<ElementSuggestion
v-for="[ci, c] in styleAdds('hints')"
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</div>
<div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Checking for missing info</p>
<p v-if="styling" class="check-empty busy-text">Checking the style</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<!-- v-show preserves the chat history when switching tabs -->
<ElementChatTab
v-show="tab === 'chat'"
:element="element"
:provider="provider"
@changes="onChatChanges"
/>
<!-- v-if loads the edit fields fresh on every open -->
<ElementEditTab
v-if="tab === 'edit'"
:element="element"
:saving="savingEdit"
@save="saveEdit"
/>
</template>
<style scoped>
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-back,
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-back:hover,
.el-close:hover {
color: var(--text);
}
.el-tool {
border: none;
background: none;
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 2px 3px;
border-radius: 6px;
filter: grayscale(0.4);
}
.el-tool:hover {
background: var(--panel-soft);
filter: none;
}
.el-tool.busy {
filter: none;
animation: pulse 1.5s ease-in-out infinite;
}
.busy-text {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.el-tabs button {
flex: 1;
padding: 0.5rem 0.25rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.el-tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.el-tabs button:hover:not(.active) {
color: var(--text);
}
.el-detail {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
}
.el-desc {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.el-hints-block {
margin-top: 0.9rem;
}
.el-hints-block h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-entry {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.4rem;
}
.el-entry:last-child {
margin-bottom: 0;
}
.el-hints {
margin: 0;
padding-left: 1.1rem;
}
.el-hints li {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.25rem;
}
/* Keep hint text inline next to the bullet (p is block otherwise) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* Markdown: base is global (assets/markdown.css); narrow sidebar → more compact code blocks */
.markdown :deep(pre) {
padding: 8px 10px;
}
/* --- AI exam --- */
.el-check {
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border-strong);
}
.check-empty {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--text-faint);
text-align: center;
}
</style>

View File

@@ -1,164 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
element: { type: Object, required: true },
saving: { type: Boolean, default: false },
})
const emit = defineEmits(['save'])
const edit = ref({ title: '', description: '', examples: [], hints: [] })
watch(() => props.element, load, { immediate: true })
function load() {
edit.value = {
title: props.element.title,
description: props.element.description,
examples: [...props.element.examples],
hints: [...props.element.hints],
}
}
function save() {
if (props.saving) return
emit('save', {
title: edit.value.title,
description: edit.value.description,
examples: edit.value.examples.filter((s) => s.trim()),
hints: edit.value.hints.filter((s) => s.trim()),
})
}
</script>
<template>
<div class="el-edit">
<button class="edit-save" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
<label>Title</label>
<input v-model="edit.title" placeholder="Title" />
<label>Description</label>
<textarea v-model="edit.description" placeholder="Description"></textarea>
<label>Examples</label>
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
<textarea v-model="edit.examples[i]" placeholder="Example"></textarea>
<button class="edit-del" title="Remove" @click="edit.examples.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.examples.push('')">+ Example</button>
<label>Hints</label>
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
<textarea v-model="edit.hints[i]" placeholder="Hint"></textarea>
<button class="edit-del" title="Remove" @click="edit.hints.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.hints.push('')">+ Hint</button>
</div>
</template>
<style scoped>
.el-edit {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.el-edit label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin-top: 0.5rem;
}
.el-edit input,
.el-edit textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-edit textarea {
resize: vertical;
min-height: 120px;
overflow: auto;
line-height: 1.4;
}
.el-edit input:focus,
.el-edit textarea:focus {
border-color: var(--accent);
}
.edit-row {
display: flex;
gap: 6px;
align-items: flex-start;
}
.edit-row textarea {
flex: 1;
}
.edit-del {
flex-shrink: 0;
width: 30px;
align-self: stretch;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--danger);
font-size: 1rem;
cursor: pointer;
}
.edit-add {
align-self: flex-start;
padding: 5px 10px;
border: 1px dashed var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.78rem;
cursor: pointer;
}
.edit-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.edit-save {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 0.3rem;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.edit-save:disabled {
opacity: 0.5;
cursor: wait;
}
</style>

View File

@@ -1,196 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useConfirm } from '../../composables/useConfirm.js'
import { plainText } from '../../markdown.js'
const props = defineProps({
elements: { type: Array, required: true },
creating: { type: Boolean, default: false },
})
const emit = defineEmits(['select', 'create', 'remove'])
const query = ref('')
const { isArmed, armOrRun } = useConfirm()
// Strip Markdown characters for title and list preview
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.elements
return props.elements.filter(
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
)
})
function add() {
if (props.creating) return
emit('create', query.value.trim())
query.value = ''
}
// Inline confirmation: first click "Sure?", second deletes
function confirmDelete(el) {
armOrRun('el-' + el.id, () => emit('remove', el))
}
</script>
<template>
<div class="el-new">
<input
v-model="query"
placeholder="Search or keyword…"
:disabled="creating"
@keyup.enter="add"
/>
<button :disabled="creating" title="Create element via AI" @click="add">+</button>
</div>
<div v-if="creating" class="el-creating">AI is creating element</div>
<ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="emit('select', el)">
<div class="el-item-main">
<span class="el-item-title">{{ plainText(el.title) }}</span>
<span class="el-item-desc">{{ plainText(el.description) }}</span>
</div>
<button
class="el-delete"
:class="{ armed: isArmed('el-' + el.id) }"
title="Delete element"
@click.stop="confirmDelete(el)"
>{{ isArmed('el-' + el.id) ? 'Sure?' : '×' }}</button>
</li>
<li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'No matches.' : 'No elements yet. Enter a keyword and click +.' }}
</li>
</ul>
</template>
<style scoped>
.el-new {
display: flex;
gap: 6px;
padding: 0.6rem 0.75rem;
}
.el-new input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-new input:focus {
border-color: var(--accent);
}
.el-new button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1.1rem;
cursor: pointer;
}
.el-new button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.el-creating {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--warning);
background: var(--warning-soft);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.el-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.el-list li:hover {
background: var(--panel-soft);
}
.el-item-main {
flex: 1;
min-width: 0;
}
.el-item-title {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-item-desc {
display: block;
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-delete {
border: none;
background: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
visibility: hidden;
}
.el-list li:hover .el-delete {
visibility: visible;
}
.el-delete.armed {
visibility: visible;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.el-empty {
cursor: default !important;
color: var(--text-faint);
font-size: 0.8rem;
}
.el-empty:hover {
background: none !important;
}
</style>

View File

@@ -1,184 +0,0 @@
<script setup>
import { ref, nextTick } from 'vue'
import { renderMarkdown } from '../../markdown.js'
const props = defineProps({
change: { type: Object, required: true },
busy: { type: Boolean, default: false },
})
const emit = defineEmits(['apply', 'dismiss', 'refine'])
const ACTION_LABELS = { remove: 'Remove:', adjust: 'Adjust:', add: 'Add:' }
const editing = ref(false)
const instruction = ref('')
const inputEl = ref(null)
function toggleEdit() {
editing.value = !editing.value
if (editing.value) nextTick(() => inputEl.value?.focus())
}
function submit() {
const text = instruction.value.trim()
if (!text || props.busy) return
emit('refine', text)
instruction.value = ''
editing.value = false
}
</script>
<template>
<div class="style-sugg" :class="{ busy }">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Confirm</button>
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Reject</button>
<button class="sugg-edit" :disabled="busy" title="Adjust suggestion via instruction" @click="toggleEdit"></button>
</div>
<div v-if="editing" class="sugg-edit-row">
<input
ref="inputEl"
v-model="instruction"
placeholder="Instruction for the suggestion…"
@keyup.enter="submit"
/>
<button :disabled="!instruction.trim() || busy" @click="submit"></button>
</div>
</div>
</template>
<style scoped>
.style-sugg {
margin: 0.3rem 0 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--panel-soft);
}
.style-sugg.busy {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.45; }
}
.style-sugg-text {
font-size: 0.76rem;
line-height: 1.4;
color: var(--text);
}
.style-sugg-text strong {
color: var(--accent);
}
.style-sugg-preview {
margin-top: 0.35rem;
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-muted);
}
.style-sugg-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-ok,
.sugg-no {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.sugg-ok {
border: none;
background: var(--accent);
color: var(--on-accent);
}
.sugg-no {
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
}
.sugg-no:hover {
border-color: var(--danger);
color: var(--danger);
}
.sugg-edit {
border: none;
background: none;
font-size: 0.8rem;
cursor: pointer;
padding: 2px 4px;
border-radius: 6px;
filter: grayscale(0.4);
}
.sugg-edit:hover {
background: var(--border);
filter: none;
}
.sugg-ok:disabled,
.sugg-no:disabled,
.sugg-edit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sugg-edit-row {
display: flex;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-edit-row input {
flex: 1;
padding: 5px 8px;
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 0.76rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.sugg-edit-row input:focus {
border-color: var(--accent);
}
.sugg-edit-row button {
width: 30px;
border: none;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.8rem;
cursor: pointer;
}
.sugg-edit-row button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Markdown: base is global (assets/markdown.css); compact preview code blocks */
.markdown :deep(pre) {
padding: 6px 8px;
border-radius: 6px;
margin: 0.3em 0;
}
</style>

View File

@@ -1,156 +0,0 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements, createElement, deleteElement } from '../../api.js'
import ElementList from './ElementList.vue'
import ElementDetail from './ElementDetail.vue'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element ID that should be opened
openTick: { type: Number, default: 0 }, // increment = (re)open openId
})
const emit = defineEmits(['close', 'changed'])
const elements = ref([])
const creating = ref(false)
const selected = ref(null)
watch(() => props.topic, load, { immediate: true })
async function load() {
selected.value = null
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Failed to load elements:', e)
}
openFromProp()
}
// Open the element clicked in the overview of the main area
watch(() => props.openTick, openFromProp)
function openFromProp() {
if (!props.openId) return
const el = elements.value.find((e) => e.id === props.openId)
if (el) selected.value = el
}
async function create(hint) {
if (creating.value) return
creating.value = true
try {
const el = await createElement(props.topic, hint, props.provider)
elements.value.unshift(el)
emit('changed')
} catch (e) {
console.error('Failed to create element:', e)
} finally {
creating.value = false
}
}
async function remove(el) {
await deleteElement(el.id)
elements.value = elements.value.filter((e) => e.id !== el.id)
if (selected.value?.id === el.id) selected.value = null
emit('changed')
}
// Keep the edited element in sync within the list and selection
function onUpdated(el) {
selected.value = el
const idx = elements.value.findIndex((e) => e.id === el.id)
if (idx !== -1) elements.value.splice(idx, 1, el)
emit('changed')
}
</script>
<template>
<aside class="elements-sidebar">
<ElementDetail
v-if="selected"
:element="selected"
:provider="provider"
@back="selected = null"
@close="emit('close')"
@updated="onUpdated"
@changed="emit('changed')"
/>
<template v-else>
<header class="el-header">
<span class="el-title">Elements</span>
<button class="el-close" title="Close" @click="emit('close')">×</button>
</header>
<ElementList
:elements="elements"
:creating="creating"
@select="(el) => (selected = el)"
@create="create"
@remove="remove"
/>
</template>
</aside>
</template>
<style scoped>
.elements-sidebar {
width: 320px;
min-width: 320px;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
/* Above the guide chat (FAB/panel: z-index 20) */
position: relative;
z-index: 30;
}
/* Mobile/narrow: lay it as an overlay over the main content instead of
squeezing it into the flex flow. */
@media (max-width: 768px) {
.elements-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(100vw, 380px);
min-width: 0;
box-shadow: -4px 0 16px var(--shadow);
}
}
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-close:hover {
color: var(--text);
}
</style>

View File

@@ -1,4 +1,4 @@
Topic "{topic}". A first pass kept the blocks below, but each was FLAGGED as a likely **fragment** a property, proof step, remark, bound, or notation that belongs to another block, not its own learning unit. Re-judge each one carefully. This is a focused second opinion; a good learning list has roughly 7582 top-level blocks, so real fragments here SHOULD be demoted or dropped but never sacrifice a genuine concept to hit a number. Topic "{topic}". You are the SECOND OPINION of a filter pass. Each entry below was either FLAGGED as a likely **fragment** (a property, sub-form, detail, or notation that belongs to another block) or PROPOSED for demotion by a first judge. Re-judge each one independently and carefully — real fragments SHOULD be demoted or dropped, but never sacrifice a genuine standalone concept.
RE-JUDGE THESE (by their number): RE-JUDGE THESE (by their number):
{survivors} {survivors}
@@ -6,17 +6,18 @@ RE-JUDGE THESE (by their number):
FULL BLOCK LIST (context — to find a parent number): FULL BLOCK LIST (context — to find a parent number):
{list} {list}
## Decide each survivor → one of three ## Decide each entry → one of three
- **demote (→ parent number):** it presupposes another block as its subject — a property/status („X ist NP-vollständig", „X ∈ NP"), a **lower bound** of X, a **bare theorem/remark** about X („Bemerkung: HK auch für gerichtete Graphen" → Hamiltonkreis; „Satz: F erfüllbar ⇔ … 3-dim Matching" → 3-dim Matching), an **approximation-güte facet** („Schärfe der 3/2 Rate" → Christofides), a **proof-example/gadget** („MST in Gegenbeispiel" → the approximation proof), a proof-variable. Put `{{"&lt;nr&gt;": &lt;parent-nr&gt;}}` in `fragments`. - **demote (→ parent number):** it presupposes another block as its subject — a property/status („X ist NP-vollständig", „X ist optional"), a **sub-form/variant** of a base entry („ATX-Überschrift" → Überschriften), a **bound/güte/runtime facet**, a **bare theorem/remark** about X, a **proof-example/gadget**, a proof-variable. Put `{{"<nr>": <parent-nr>}}` in `fragments`.
- **drop:** pure exercise/reference scaffolding with NO real content and NO parent — a bare label („Remark 7.28", „Satz D*"), a one-off notation assignment („r = n + m"). Put its number in `drop`. - **drop:** pure exercise/reference scaffolding with NO real content and NO parent — a bare label („Remark 7.28", „Satz D*"), a one-off notation assignment („r = n + m"). Put its number in `drop`.
- **keep:** it IS a self-contained concept. Do NOT touch it. (Just omit it.) - **keep:** it IS a self-contained concept. Do NOT touch it. (Just omit it.)
## KEEP-guards — these are real blocks, never demote/drop them ## KEEP-guards — these are real blocks, never demote/drop them
- A **named theorem WITH its own statement or an author**: „Satz 6.24 Cook/Levin — SAT ist NP-vollständig", „Satz von ImmermanSzelepcsényi". KEEP. - **Similarity is NOT containment:** an element with its own syntax/definition and its own purpose is a SIBLING of its neighbours, not their part — even with similar syntax, the same category, or shared context (a blockquote is not part of a code block; a footnote is not part of a task list). KEEP.
- A **complexity-class (in)equality / open question**: „P = NP?", „NL = coNL". KEEP. - A **named theorem WITH its own statement or an author**: „Satz 6.24 Cook/Levin — SAT ist NP-vollständig". KEEP.
- Anything headed „**Definition**", a **problem**, an **algorithm**, a **reduction** („3-SAT ≤ Clique"). KEEP. - A **fundamental (in)equality / open question** of the field: „P = NP?", „NL = coNL". KEEP.
- Anything headed „**Definition**", a **problem**, an **algorithm/method**, a **relation between two named things** („3-SAT ≤ Clique"). KEEP.
Judge by the CONTENT (after „—"), not the label. When unsure whether something is a fragment or a concept: if it has an obvious parent in the list → demote; otherwise → keep (never drop on doubt). Judge by the CONTENT (after „—"), not the label. When unsure whether something is a fragment or a concept: demote only if the entry clearly makes a statement ABOUT its parent or is a form OF it; otherwise → keep (never drop on doubt).
Write ONLY the JSON file to: {out_path} Write ONLY the JSON file to: {out_path}

View File

@@ -8,28 +8,26 @@ JUDGE the numbers **{from_n} to {to_n}** — go through them **ONE BY ONE**, one
## Procedure per entry (mandatory for EACH one) ## Procedure per entry (mandatory for EACH one)
For each entry {from_n}{to_n}: For each entry {from_n}{to_n}:
1. What is the **subject**? (What is being talked about?) 1. What is the **subject**? (What is being talked about?)
2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PROOF PART, a NOTATION, or a RUNTIME DETAIL about it? 2. Is this subject itself another entry in the list — and does the entry only state a PROPERTY, a PART, a SUB-FORM, or a DETAIL of it?
- **Yes → fragment**, parent = the number of that subject. - **Yes → fragment**, parent = the number of that subject.
- No, it stands on its own → block (keep). - No, it stands on its own → block (keep).
Lines marked with **⚠** are suspected cases (property/runtime/notation) — check them especially carefully. Decide by the content, not by the marking. Lines marked with **⚠** are suspected cases (property/detail/notation) — check them especially carefully. Decide by the content, not by the marking.
## What is a BLOCK (standalone learning unit — keep)? ## What is a BLOCK (standalone learning unit — keep)?
A block is self-contained: you can explain it WITHOUT presupposing another block as its subject. A block is self-contained: you can explain it WITHOUT presupposing another block as its subject. A distinct element, concept, method, problem, or named theorem with its own statement stands on its own.
- A **problem**: „3-SAT", „Clique", „Knapsack", „Dominating Set". - **CRITICAL — similarity is NOT containment.** Two entries with similar syntax, related purpose, or the same category are SIBLINGS, not parent and part. A blockquote is not part of a code block just because both mark lines with a prefix; a footnote is not part of a task list just because both are extensions of the same standard. Demote ONLY when the entry makes a statement ABOUT the parent or is a form OF the parent — never because the two are alike or usually taught together.
- A **method/algorithm**: „LPT Scheduling", „Christofides", „FPTAS". - An element with its own syntax/definition and its own purpose is its own block, even if a bigger neighbour exists.
- A **definition/concept**: „NP", „Reduktion", „Verifizierer", „KNF". - Examples across domains: a **problem** („3-SAT", „Knapsack"), a **method/algorithm** („Christofides", „Quicksort"), a **definition/concept** („NP", „Reduktion", „Blockquote", „Directive"), a **named theorem WITH its own statement** („Cook-Levin: SAT ist NP-vollständig").
- A **named theorem WITH its own statement**: „Cook-Levin: SAT ist NP-vollständig".
## What is a FRAGMENT (belongs to another block → demote)? ## What is a FRAGMENT (belongs to another block → demote)?
Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block. Self-containment test: does the entry presuppose ANOTHER concept in the list as its subject? Then it is that concept's property/part, not its own block.
- **Property/status** of a problem X (that is itself in the list): „X ist NP-vollständig", „X ∈ NP", „NP-Schwere von X", „Approximationsgüte von X". → parent = X. - **Property/status of X** (X itself in the list): „X ist NP-vollständig", „X ∈ NP", „X ist optional", „Standard-Verhalten von X". → parent = X.
- **Lower bound / ETH bound** of a problem X: „Lower Bound bzgl. Knoten für VERTEX COVER", „ETH untere Schranke HITTING SET (|U|)", „2^Ω(√|E|) …". → parent = X (the problem the bound is about). - **Parent named in the entry's OWN title:** if the title itself contains another block's name as its subject („Lower Bound für **VERTEX COVER**", „**List Scheduling** Güte", „Anker für **Überschriften**"), that named block IS the parent — demote to it. Do not keep such an entry just because you would scan the whole list; the parent is right there in the title.
- **Bare theorem / proof reference**: „Satz 6.12: P ⊆ NP", „Beweis Satz 6.16 (⇒)", „Beweis ⊃ von Satz 6.21", „Satz 7.20 (Sahni)", „Pm||Cmax NP-vollständig (Satz 7.23)" — a restated inclusion/membership or a bare „Satz N"/„Beweis …" is a proof detail. → parent = the problem/algorithm/class it is about (P/NP, Sahni's algorithm, Pm||Cmax …). - **Sub-form/variant of a base entry** that is itself in the list: „ATX-Überschrift" and „Setext-Überschrift" are forms of „Überschriften"; „Even-Knapsack" is an exercise-tweaked variant of „Rucksackproblem". → parent = the base entry. (A genuinely different concept with its own rules stays its own block — see the sibling rule above.)
- **Proof/reduction gadget or variable**: „αEnde", „A-Komponente", „Dummy Items", „αu-Variablen", „Variablenungleichungen im ILP", „Austausch-Argument". → parent = the theorem/reduction in whose proof it appears. - The following patterns are typical for THEORY-SCRIPT topics (use them when they fit, ignore them otherwise):
- **Approximation-guarantee facet**: „Güte 2", „Güte 2 1/m", „Approximative Güte 2", „List Scheduling Güte (2 1/m)". → parent = the algorithm it bounds (List Scheduling, LPT, …). - **Bare theorem / proof reference**: „Satz 6.12: P ⊆ NP", „Beweis Satz 6.16 (⇒)", „Satz 7.20 (Sahni)" — a restated inclusion/membership or a bare „Satz N"/„Beweis …" is a proof detail. → parent = the object it is about.
- **Runtime/size detail**: „O(|V|⁴) Verifizierer-Laufzeit", „|V'| = |V| bei Reduktion", „Reduktion in O(|E|)". → parent = the algorithm/reduction. - **Proof/reduction gadget or variable**: „αEnde", „A-Komponente", „Dummy Items", „Austausch-Argument". → parent = the theorem/reduction in whose proof it appears.
- **Parent named in the entry's OWN title:** if the title itself contains another block's name („Lower Bound … für **VERTEX COVER**", „**List Scheduling** Güte …", „**Pm||Cmax** NP-vollständig"), that named block IS the parent — demote to it. Do not keep such an entry just because you would scan the whole list; the parent is right there in the title. - **Bound/guarantee/runtime facet**: „ETH untere Schranke HITTING SET", „Güte 2 1/m", „O(|V|⁴) Verifizierer-Laufzeit", „Reduktion in O(|E|)". → parent = the problem/algorithm it bounds.
- **Over-specific variant** of a base problem that is itself in the list: „Even-Knapsack", „Subset Sum Cardinality", „Partition (3·Summe)", „SAT3" are exercise-tweaked variants of „Rucksackproblem"/„Subset Sum"/„Partition"/„SAT". → parent = the base problem. (A genuinely different problem with its own theory stays its own block.)
## What is an EXERCISE ARTEFACT (no concept at all → hard-drop)? ## What is an EXERCISE ARTEFACT (no concept at all → hard-drop)?
Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffolding that is neither a learnable concept nor a fragment of one AND has no parent in the list. These forms all count, no matter where the marker sits: Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffolding that is neither a learnable concept nor a fragment of one AND has no parent in the list. These forms all count, no matter where the marker sits:
@@ -37,13 +35,13 @@ Rare, and applied cautiously. ONLY clear exercise-sheet / cross-reference scaffo
- a bare sheet/task reference: „Blatt 10", „Aufgabe 3", „Übung 7.31"; - a bare sheet/task reference: „Blatt 10", „Aufgabe 3", „Übung 7.31";
- a worked-example / table / figure reference: „Scheduling Beispiel Tab. 7.1", „Beispiel 3.2", „Abbildung 4.5"; - a worked-example / table / figure reference: „Scheduling Beispiel Tab. 7.1", „Beispiel 3.2", „Abbildung 4.5";
- a one-off framing with no standalone content. - a one-off framing with no standalone content.
Put its number in `drop`. NEVER drop anything that names a real problem/method/definition/theorem/reduction — if there is any doubt, keep it (or demote it as a fragment with a parent). A **named theorem WITH its own statement** („Satz 6.24 Cook/Levin — SAT ist NP-vollständig") is a real block, never an artefact. If it has a parent in the list, prefer demoting (fragment) over dropping. Put its number in `drop`. NEVER drop anything that names a real concept/element/method/definition/theorem — if there is any doubt, keep it (or demote it as a fragment with a parent). A **named theorem WITH its own statement** is a real block, never an artefact. If it has a parent in the list, prefer demoting (fragment) over dropping.
## Rules ## Rules
- A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it). - A fragment is demoted ONLY if its **parent block is in the list** (give its number). If you find no parent → keep it (don't list it).
- The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/notation/proof part WITH a parent in the list IS a fragment — don't keep it out of caution. - The doubt concerns STANDALONE-NESS: if it's unclear whether an entry stands on its own → keep it. But a clear property/sub-form/detail WITH a parent in the list IS a fragment — don't keep it out of caution.
- A standalone **reduction between two problems** is a block, NOT a fragment („3-SAT ≤ Clique"). - A standalone **relation between two named things** is a block, NOT a fragment („3-SAT ≤ Clique").
- A **named theorem WITH its own relational statement** — a biconditional/implication/reduction between two named objects („Satz 6.37: 3-SAT ≤ 3-Färbung … ⇔ …") — is a block; keep it even if it references other blocks. Only a BARE label with no statement („Satz 7.18", „Remark 7.28"), a unary status („X ist NP-vollständig", „X ∈ NP"), or a güte/bound/proof-size facet is a fragment. - A **named theorem WITH its own relational statement** — a biconditional/implication/reduction between two named objects — is a block; keep it even if it references other blocks. Only a BARE label with no statement, a unary status („X ist NP-vollständig"), or a bound/proof facet is a fragment.
- Judge by the CONTENT (after the „—"), not the title. - Judge by the CONTENT (after the „—"), not the title.
Write ONLY the JSON file to: {out_path} Write ONLY the JSON file to: {out_path}

View File

@@ -0,0 +1,16 @@
Topic "{topic}". Below is the final chapter outline of a learning guide. Your ONLY job: find blocks that sit in the WRONG chapter and name the chapter where they belong. This is a placement check, not a redesign.
OUTLINE (chapters are numbered, blocks carry their block number):
{chapters}
## Rules
- Go through every block: does its subject match the chapter's theme better than any other chapter's?
- Report ONLY clear misplacements. A defensible placement is NOT a misplacement — leave it.
- Do NOT rename chapters, do NOT create chapters, do NOT reorder within a chapter.
- If everything fits, report no moves.
{extra}
Write ONLY the JSON file to: {out_path}
Format — `moves` maps a misplaced BLOCK number to the target CHAPTER number (may be empty):
{{"moves": {{"7": 2, "15": 4}}}}