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

View File

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