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