update
This commit is contained in:
@@ -11,6 +11,7 @@ the full list.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -35,7 +36,7 @@ from pipeline import (
|
||||
)
|
||||
from textkit import (
|
||||
_unique_title, _load_blocks, _norm_title, _parse_selection, _parse_subblocks, _title,
|
||||
_resolve_title, _title_index,
|
||||
_resolve_title, _title_index, clean_title,
|
||||
)
|
||||
|
||||
# Chunk the subblocks (web search per block): 1 agent per ~10 blocks, capped.
|
||||
@@ -55,8 +56,9 @@ RESEARCH_SECTION_CHARS = 12000
|
||||
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
|
||||
SUBBLOCK_MAX_ROUNDS = 3 # hard round cap: measured, rounds 4–5 burned 29 % of the finder agents
|
||||
# for ~zero consensus gain (fringe ideas never saturate) — thin blocks
|
||||
# are caught by the SUBBLOCK_MIN catch-up plus the gap follow-up round
|
||||
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)
|
||||
@@ -65,7 +67,8 @@ DEDUP_GLOBAL_FLOOR = 0.65 # global post-naming dedup stage: candidate floor ab
|
||||
# same-domain noise band, below the sibling zone (~0.85) — the judge decides there
|
||||
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
|
||||
QUESTION_CHUNK_SUBS = 25 # target sum of relevant subs per chunk — at 50 the generator
|
||||
# skipped so many subs that 60 % of all question calls were catch-up
|
||||
QUESTION_MAX_ROUNDS = 3 # catch-up rounds for subs without a pattern (the LLM omits ~18 % per chunk)
|
||||
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
|
||||
@@ -695,13 +698,17 @@ def _lpt_chunks(weights: list[int], target: int) -> list[list[int]]:
|
||||
|
||||
|
||||
|
||||
_NEG_TOKENS = {"nicht", "kein", "keine", "keinen", "keiner", "ohne", "nie"}
|
||||
# lemmatized: 'kein Syntaxfehler' vs 'keine Syntax-Fehlermeldung' are the SAME statement —
|
||||
# unlemmatized token sets ({kein} ≠ {keine}) blocked that fold at cos 0.974 (measured).
|
||||
_NEG_LEMMA = {"nicht": "nicht", "ohne": "ohne", "nie": "nie", "niemals": "nie",
|
||||
"kein": "kein", "keine": "kein", "keinen": "kein", "keiner": "kein",
|
||||
"keinem": "kein", "keines": "kein"}
|
||||
|
||||
|
||||
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)
|
||||
"""Lemmatized 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(l for t in re.findall(r"\w+", _norm_title(title)) if (l := _NEG_LEMMA.get(t)))
|
||||
|
||||
|
||||
def _sub_tokens(title: str) -> set:
|
||||
@@ -1097,7 +1104,6 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
_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
|
||||
@@ -1130,6 +1136,9 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
|
||||
raw.setdefault(title, []).append(seed)
|
||||
_log(topic, f"Seed „{seed}“ als Subbaustein eingefügt ({title}) — Facts-Gate prüft")
|
||||
|
||||
# AFTER the seed guarantee: promoted/inserted seeds must not bypass the near-dup filter
|
||||
await _dedup_subblocks(topic, raw) # near-dup filter per block (deterministic, no LLM)
|
||||
|
||||
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
|
||||
@@ -1168,6 +1177,343 @@ async def _dedup_subblocks(topic: str, raw: dict[str, list[str]]) -> None:
|
||||
raw[title] = [subs[i] for i in sorted(keepers)] # original order of the kept ones
|
||||
|
||||
|
||||
_KONSOLIDIERUNG_PANEL = 2 # merge needs unanimity — single judges over-merge (blocks-dedup lesson)
|
||||
|
||||
|
||||
def _kons_id(x, n: int) -> int | None:
|
||||
"""Judge id → int in 1..n, else None (bools are not ids)."""
|
||||
if isinstance(x, bool):
|
||||
return None
|
||||
if isinstance(x, str) and x.isdigit():
|
||||
x = int(x)
|
||||
return x if isinstance(x, int) and 1 <= x <= n else None
|
||||
|
||||
|
||||
def _konsolidierung_schema(data, n: int) -> dict | None:
|
||||
"""Judge output → normalized dict, else None. gruppen accepts the {"haupt": 1,
|
||||
"weitere": [4]} form AND the legacy plain-list form [1, 4] (resume files of the
|
||||
first template version). kataloge/fremd/luecken are optional."""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("gruppen"), list):
|
||||
return None
|
||||
|
||||
def _ids(lst):
|
||||
return sorted({i for x in (lst or []) if (i := _kons_id(x, n)) is not None})
|
||||
|
||||
gruppen = []
|
||||
for g in data["gruppen"]:
|
||||
if isinstance(g, dict):
|
||||
haupt = _kons_id(g.get("haupt"), n)
|
||||
ids = _ids(([haupt] if haupt else []) + list(g.get("weitere") or []))
|
||||
elif isinstance(g, list):
|
||||
haupt, ids = None, _ids(g)
|
||||
else:
|
||||
return None
|
||||
if len(ids) >= 2:
|
||||
gruppen.append({"haupt": haupt if haupt in ids else None, "ids": ids})
|
||||
kataloge = []
|
||||
for k in data.get("kataloge") or []:
|
||||
if not isinstance(k, dict):
|
||||
continue
|
||||
ids = _ids(k.get("mitglieder"))
|
||||
titel = str(k.get("titel") or "").strip()
|
||||
if len(ids) >= 2 and titel:
|
||||
kataloge.append({"titel": titel, "ids": ids})
|
||||
return {"gruppen": gruppen, "kataloge": kataloge, "fremd": set(_ids(data.get("fremd"))),
|
||||
"luecken": [s.strip() for s in data.get("luecken") or [] if isinstance(s, str) and s.strip()]}
|
||||
|
||||
|
||||
def _facts_union(wf: dict, lf: dict) -> None:
|
||||
"""Merge a folded sub's facts into the winner's: key_points/cited_facts union
|
||||
(exact-duplicate-free), scalar fields only fill gaps."""
|
||||
for feld in ("key_points", "cited_facts"):
|
||||
have = wf.get(feld) or []
|
||||
seen = {json.dumps(e, sort_keys=True, ensure_ascii=False) for e in have}
|
||||
fresh = [e for e in (lf.get(feld) or [])
|
||||
if json.dumps(e, sort_keys=True, ensure_ascii=False) not in seen]
|
||||
if fresh:
|
||||
wf[feld] = have + fresh
|
||||
for feld in ("prerequisites", "hurdles", "example_idea"):
|
||||
if not wf.get(feld) and lf.get(feld):
|
||||
wf[feld] = lf[feld]
|
||||
|
||||
|
||||
def _agreed_cliques(pair_sets: list[set], negs: list, n: int) -> list[list[int]]:
|
||||
"""Union-find over the UNANIMOUS pairs (both judges grouped them), negation-guarded."""
|
||||
agreed = {(a, b) for a, b in pair_sets[0] & pair_sets[1] if negs[a - 1] == negs[b - 1]}
|
||||
parent = list(range(n + 1))
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
for a, b in agreed:
|
||||
parent[find(a)] = find(b)
|
||||
groups: dict[int, list[int]] = {}
|
||||
for k in range(1, n + 1):
|
||||
groups.setdefault(find(k), []).append(k)
|
||||
return [g for g in groups.values() if len(g) >= 2]
|
||||
|
||||
|
||||
def _pairs_of(groups) -> set:
|
||||
ps: set[tuple[int, int]] = set()
|
||||
for ids in groups:
|
||||
ps |= {(a, b) for x, a in enumerate(ids) for b in ids[x + 1:]}
|
||||
return ps
|
||||
|
||||
|
||||
_LUECKEN_CAP = 3 # the gap list feeds ONE finder round — an uncapped list doubled the decomposition
|
||||
|
||||
|
||||
def _luecken_schnitt(l1: list[str], l2: list[str], cap: int = _LUECKEN_CAP) -> list[str]:
|
||||
"""Gaps BOTH judges name — exact strings never match across paraphrases, so a gap
|
||||
survives when the other judge names one sharing a distinctive token (≥4 chars).
|
||||
j1's phrasing wins. The measured union produced 107 'gaps' on 216 subs."""
|
||||
def toks(s):
|
||||
return {t for t in _sub_tokens(s) if len(t) >= 4}
|
||||
toks2 = [toks(l) for l in l2]
|
||||
out = [l for l in l1 if toks(l) and any(toks(l) & t2 for t2 in toks2)]
|
||||
return out[:cap]
|
||||
|
||||
|
||||
async def _konsolidiere_subblocks(ctx: GenContext, files: dict, raw: dict, facts_map: dict,
|
||||
instructions: str = "", ns: str = "", lbl: str = "") -> dict:
|
||||
"""In-block consolidation AFTER the facts stage: a two-judge panel sees the subs WITH
|
||||
their key points and applies the 100%-decomposition test — the embedding paths only
|
||||
catch cos ≥ 0.90, real paraphrase duplicates measure down to 0.61, and only the facts
|
||||
reveal a subset. Every action needs UNANIMITY of both judges:
|
||||
gruppen — same-statement/subset entries fold into the judge-named `haupt` (base
|
||||
before detail; heuristic fallback), facts union, losers → `variant`
|
||||
kataloge — pure enumeration entries of one kind bundle into a NEW named sub row
|
||||
(members → `variant`); runs before levels/relevance, so the new row
|
||||
gets classified normally
|
||||
fremd — statements off-topic for the TOPIC → `discarded` (removal test)
|
||||
Questions/artefacts do not exist yet — no orphans. Gaps are returned per block so the
|
||||
caller can run the single follow-up finder round (`_luecken_runde`).
|
||||
Judge replies persist as j-files keyed by a subs-list hash (resume-safe).
|
||||
→ {block title: [luecken]}"""
|
||||
topic = ctx.topic
|
||||
work_dir = files["arbeit"]
|
||||
luecken_by_title: dict[str, list[str]] = {}
|
||||
for title, subs in list(raw.items()):
|
||||
if ctx.is_cancelled():
|
||||
return luecken_by_title
|
||||
n = len(subs)
|
||||
if n < 2:
|
||||
continue
|
||||
bnorm = _norm_title(title)
|
||||
bfacts = facts_map.setdefault(title, {})
|
||||
|
||||
def _kp(s):
|
||||
return (bfacts.get(_norm_title(s)) or {}).get("key_points") or []
|
||||
|
||||
# prompt shows max 3 key points per sub — full lists blew past the judge timeout
|
||||
# (measured: 15 % timeouts at 585 s); the facts UNION on merge stays complete
|
||||
lines = "\n".join(f"{k}. {s}" + "".join(f"\n - {p}" for p in _kp(s)[:3])
|
||||
for k, s in enumerate(subs, 1))
|
||||
h = hashlib.md5("\n".join(subs).encode()).hexdigest()[:8]
|
||||
paths = [work_dir / f"sub-konsolidierung-{ns}{h}-j{j}.json" for j in (1, 2)]
|
||||
|
||||
async def _judge(j, path):
|
||||
if _konsolidierung_schema(_json_file(path), n) is not None:
|
||||
return # resume
|
||||
status, _v = await run_single_slot(
|
||||
ctx, f"{lbl}Sub-Konsolidierung j{j}",
|
||||
key=f"blocks-{topic}-{ns}sub-konsolidierung-{h}-j{j}",
|
||||
prompt=_prompt("Subblock-Konsolidierung", topic=topic, block=title, subs=lines, extra=_extra(instructions)),
|
||||
role="judge", capabilities="none",
|
||||
payload=lambda result, p=path: _sink_json(result, p, lambda d: _konsolidierung_schema(d, n)),
|
||||
timeout=_timeout("konsolidierung", n))
|
||||
if status == FAILED:
|
||||
_log(topic, f"Sub-Konsolidierung {title} j{j} ohne Ergebnis — fail-open")
|
||||
|
||||
await asyncio.gather(*[_judge(j, p) for j, p in zip((1, 2), paths)])
|
||||
if ctx.is_cancelled():
|
||||
return luecken_by_title
|
||||
outs = [o for p in paths if (o := _konsolidierung_schema(_json_file(p), n)) is not None]
|
||||
if len(outs) == 1: # Ersatz-Richter: EIN Timeout darf die gute Stimme nicht entwerten
|
||||
ersatz = work_dir / f"sub-konsolidierung-{ns}{h}-j3.json"
|
||||
await _judge(3, ersatz)
|
||||
if ctx.is_cancelled():
|
||||
return luecken_by_title
|
||||
outs = [o for p in [*paths, ersatz]
|
||||
if (o := _konsolidierung_schema(_json_file(p), n)) is not None]
|
||||
# gaps need UNANIMITY (token-overlap match) — the union of both judges was uncalibrated
|
||||
luecken = (_luecken_schnitt(outs[0]["luecken"], outs[1]["luecken"])
|
||||
if len(outs) == _KONSOLIDIERUNG_PANEL else [])
|
||||
journal = {"block": title, "richter": len(outs), "vorher": n,
|
||||
"luecken_roh": [len(o["luecken"]) for o in outs],
|
||||
"gruppen": [], "kataloge": [], "fremd": [], "luecken": luecken}
|
||||
if len(outs) == _KONSOLIDIERUNG_PANEL:
|
||||
negs = [_neg_set(s) for s in subs]
|
||||
keep = list(subs)
|
||||
gone: set[int] = set()
|
||||
|
||||
async def _fold(k: int, wf: dict | None):
|
||||
lose_title = subs[k - 1]
|
||||
lf = bfacts.pop(_norm_title(lose_title), None) or {}
|
||||
if wf is not None:
|
||||
_facts_union(wf, lf)
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(lose_title), status="variant")
|
||||
keep.remove(lose_title)
|
||||
gone.add(k)
|
||||
|
||||
# 1. Fremd (removal test): off-topic for the TOPIC → discarded, no heir.
|
||||
for k in sorted(outs[0]["fremd"] & outs[1]["fremd"]):
|
||||
ft = subs[k - 1]
|
||||
bfacts.pop(_norm_title(ft), None)
|
||||
await db.set_subblock_fields(topic, bnorm, _norm_title(ft), status="discarded")
|
||||
keep.remove(ft)
|
||||
gone.add(k)
|
||||
journal["fremd"].append(ft)
|
||||
|
||||
# 2. Gruppen: winner = judge-named haupt (majority), else key_points/length heuristic.
|
||||
haupt_votes: dict[int, int] = {}
|
||||
for o in outs:
|
||||
for g in o["gruppen"]:
|
||||
if g["haupt"]:
|
||||
haupt_votes[g["haupt"]] = haupt_votes.get(g["haupt"], 0) + 1
|
||||
for g in _agreed_cliques([_pairs_of([x["ids"] for x in o["gruppen"]]) for o in outs], negs, n):
|
||||
g = [k for k in g if k not in gone]
|
||||
if len(g) < 2:
|
||||
continue
|
||||
win = max(g, key=lambda k: (haupt_votes.get(k, 0),
|
||||
len(_kp(subs[k - 1])), len(subs[k - 1]), -k))
|
||||
wf = bfacts.setdefault(_norm_title(subs[win - 1]), {})
|
||||
for k in g:
|
||||
if k != win:
|
||||
await _fold(k, wf)
|
||||
journal["gruppen"].append({"behalten": subs[win - 1],
|
||||
"gefaltet": [subs[k - 1] for k in g if k != win]})
|
||||
|
||||
# 3. Kataloge: bundle enumeration rows into ONE new named sub (facts union).
|
||||
for g in _agreed_cliques([_pairs_of([x["ids"] for x in o["kataloge"]]) for o in outs], negs, n):
|
||||
g = [k for k in g if k not in gone]
|
||||
if len(g) < 2:
|
||||
continue
|
||||
titel = next((clean_title(x["titel"]) for x in outs[0]["kataloge"] + outs[1]["kataloge"]
|
||||
if set(x["ids"]) & set(g) and clean_title(x["titel"])), "")
|
||||
kn = _norm_title(titel)
|
||||
if not kn or kn in {_norm_title(s) for s in keep}:
|
||||
continue # no usable/colliding title → members stay
|
||||
kf: dict = {}
|
||||
for k in g:
|
||||
await _fold(k, kf)
|
||||
bfacts[kn] = kf
|
||||
keep.append(titel)
|
||||
await db.put_subblock(topic, bnorm, kn, title, titel, status="consensus")
|
||||
journal["kataloge"].append({"titel": titel, "gefaltet": [subs[k - 1] for k in g]})
|
||||
|
||||
if len(keep) != n:
|
||||
raw[title] = keep
|
||||
_log(topic, f"Sub-Konsolidierung {title}: {n} → {len(keep)}")
|
||||
elif outs:
|
||||
_log(topic, f"Sub-Konsolidierung {title}: nur {len(outs)}/{_KONSOLIDIERUNG_PANEL} Richter — fail-open")
|
||||
if luecken:
|
||||
luecken_by_title[title] = luecken
|
||||
_log(topic, f"Sub-Konsolidierung {title}: mögliche Lücken: {', '.join(luecken[:5])}")
|
||||
atomic_write_json(work_dir / f"sub-konsolidierung-{ns}{h}.json", journal, indent=1)
|
||||
return luecken_by_title
|
||||
|
||||
|
||||
async def _luecken_runde(ctx: GenContext, files: dict, title: str, luecken: list[str],
|
||||
raw: dict, facts_map: dict, q: dict, folder, instructions: str = "",
|
||||
ns: str = "", lbl: str = "", sources: list[str] | None = None) -> int:
|
||||
"""ONE targeted finder round for the consolidation judges' reported gaps — no loop.
|
||||
Finds are deduped against the existing subs (token containment + embedding +
|
||||
negation guard, seed-guarantee pattern) and must pass the facts evidence gate
|
||||
(own work subdir `nf` — the block's facts resume files must not collide) before
|
||||
they join raw/facts_map as consensus rows. They then flow through levels/relevance/
|
||||
questions/artefacts like any other sub. → count of adopted subs."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
work_dir = files["arbeit"]
|
||||
have = list(raw.get(title) or [])
|
||||
focus = (instructions + "\n\nFinde NUR belegbare Subbausteine zu diesen bisher fehlenden "
|
||||
"Aspekten des Blocks — nichts anderes:\n" + "\n".join(f"- {l}" for l in luecken))
|
||||
known = ("\n\nBEREITS ERFASST — liste diese NICHT erneut:\n"
|
||||
+ "\n".join(f"- {s}" for s in have)) if have else ""
|
||||
paths = [work_dir / f"luecken-{ns}r1-{i}.md" for i in (1, 2, 3)]
|
||||
slots = [{
|
||||
"key": f"blocks-{topic}-{ns}luecken-r1-{i}",
|
||||
"prompt": _prompt("Subblock-Research", topic=topic, assignment=f"- {title}", known=known, out_path=p, extra=_extra(focus)),
|
||||
"role": "quick", "capabilities": "files" if folder else "full",
|
||||
"payload": (lambda result, p=p: _parse_subblocks(_read(p)) or None),
|
||||
} for i, p in zip((1, 2, 3), paths)]
|
||||
agent_texts = await _race(topic, f"{lbl}Lücken-Nachfass", slots, 2,
|
||||
_timeout("subblock", 1), provider, cancelled=is_cancelled, grace=CONSENSUS_GRACE)
|
||||
if is_cancelled() or not agent_texts:
|
||||
return 0
|
||||
cands: list[str] = []
|
||||
seen = {_norm_title(s) for s in have}
|
||||
for d in agent_texts:
|
||||
for subs in d.values(): # single-block call — every marker means this block
|
||||
for s in subs:
|
||||
sn = _norm_title(s)
|
||||
if sn and sn not in seen:
|
||||
seen.add(sn)
|
||||
cands.append(s)
|
||||
if not cands:
|
||||
return 0
|
||||
emb_on = EMBEDDING_AKTIV and await asyncio.to_thread(embedding.available)
|
||||
fresh: list[str] = []
|
||||
for s in cands:
|
||||
st = _sub_tokens(s)
|
||||
base = have + fresh
|
||||
if any(st <= _sub_tokens(b) or _sub_tokens(b) <= st for b in base):
|
||||
continue
|
||||
if emb_on and base:
|
||||
sims = await asyncio.to_thread(embedding.embed_sims, [s] + base)
|
||||
if sims is not None:
|
||||
negs = [_neg_set(t) for t in [s] + base]
|
||||
if any(float(sims[0][j]) >= SEED_COVER_COS and negs[0] == negs[j]
|
||||
for j in range(1, len(base) + 1)):
|
||||
continue
|
||||
fresh.append(s)
|
||||
if not fresh:
|
||||
return 0
|
||||
nf_dir = work_dir / "nf"
|
||||
nf_dir.mkdir(parents=True, exist_ok=True)
|
||||
res = await _facts_block(ctx, lambda *a, **k: None, {**files, "arbeit": nf_dir},
|
||||
{title: list(fresh)}, q, folder, instructions,
|
||||
ns=f"{ns}nf-", lbl=lbl, sources=sources, slim=True)
|
||||
if is_cancelled() or res is None:
|
||||
return 0
|
||||
nf_facts, discarded = res
|
||||
dropped = (discarded or {}).get(title) or set()
|
||||
nf_map = nf_facts.get(title) or {}
|
||||
|
||||
def _belegt(s: str) -> bool: # HARD gate: no facts entry = no evidence = no adoption
|
||||
fk = nf_map.get(_norm_title(s))
|
||||
return bool(fk and (fk.get("key_points") or fk.get("cited_facts")))
|
||||
|
||||
kept = [s for s in fresh if _norm_title(s) not in dropped and _belegt(s)]
|
||||
if not kept:
|
||||
return 0
|
||||
bnorm = _norm_title(title)
|
||||
bfacts = facts_map.setdefault(title, {})
|
||||
for s in kept:
|
||||
sn = _norm_title(s)
|
||||
await db.upsert_subblock(topic, bnorm, sn, title, s)
|
||||
await db.set_subblock_fields(topic, bnorm, sn, status="consensus")
|
||||
bfacts[sn] = nf_map[sn]
|
||||
raw.setdefault(title, []).extend(kept)
|
||||
_log(topic, f"Lücken-Nachfass {title}: {len(kept)}/{len(fresh)} Funde übernommen")
|
||||
return len(kept)
|
||||
|
||||
|
||||
def _subs_hash(sidecar_or_raw: dict) -> str:
|
||||
"""Sub-set identity for the resume files of the sub-CONSUMING stages (levels/relevance/
|
||||
questions/artefacts). Without it a re-run with a recut sub set adopted the stale stage
|
||||
results (measured: 626 orphans — artefacts of the old 425-sub set re-imported)."""
|
||||
parts: list[str] = []
|
||||
for title, subs in sidecar_or_raw.items():
|
||||
parts.append(str(title))
|
||||
for s in subs:
|
||||
parts.append(s["title"] if isinstance(s, dict) else str(s))
|
||||
return hashlib.md5("\n".join(parts).encode()).hexdigest()[:8]
|
||||
|
||||
|
||||
def _code_vote(rater: list[dict], n: int) -> tuple[dict, dict]:
|
||||
"""Majority vote over rater dicts on local ids 1..n → (outcome, disputed). A clear winner
|
||||
needs ≥2 votes and no tie; otherwise the id is disputed (kept with its vote list)."""
|
||||
@@ -1220,9 +1566,10 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
||||
if cur:
|
||||
chunks.append(cur)
|
||||
n = len(chunks)
|
||||
sh = _subs_hash(raw) # resume must invalidate when the sub set changed
|
||||
|
||||
def rater_paths(c):
|
||||
return [work_dir / f"level-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
return [work_dir / f"level-{sh}-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
|
||||
def lset(item_idxs):
|
||||
return set(range(1, len(item_idxs) + 1))
|
||||
@@ -1273,7 +1620,7 @@ async def _levels_block(ctx: GenContext, set_p, files: dict, raw: dict, instruct
|
||||
async def _clarify(c, item_idxs):
|
||||
outcome, strittig = vote_by_c[c]
|
||||
if strittig:
|
||||
judge_path = work_dir / f"level-final-c{c}.json"
|
||||
judge_path = work_dir / f"level-final-{sh}-c{c}.json"
|
||||
decision = _levels_schema(_json_file(judge_path), set(strittig))
|
||||
if decision is None:
|
||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||
@@ -1385,9 +1732,11 @@ def _facts_lines(fk: dict) -> str:
|
||||
return "\n".join(z)
|
||||
|
||||
|
||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None) -> tuple | None:
|
||||
async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, instructions: str, ns: str = "", lbl: str = "", sources: list[str] | None = None, slim: bool = False) -> tuple | None:
|
||||
"""Block: per sub extract source facts (find) → verify (check) → correct/discard (fix).
|
||||
Extract-once grounding: the result feeds level/relevance/questions/guide.
|
||||
slim=True (gap follow-up): no supplement pass, ONE check judge — the full program cost
|
||||
230 agent-minutes per run for a handful of finds; the hard adoption gate stays.
|
||||
→ (facts_map, discarded_map) — facts_map {block: {sub_norm: facts}}, discarded_map
|
||||
{block: {sub_norm}} (unsupportable subs to remove) — or None on cancel/error."""
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
@@ -1399,11 +1748,12 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
if not blocks:
|
||||
return {}, {}
|
||||
chunks = _lpt_chunks([len(subs) for _, subs in blocks], FACTS_CHUNK_SUBS)
|
||||
sh = _subs_hash(raw) # resume must invalidate when the sub set changed
|
||||
|
||||
def raw_path(ci): return work_dir / f"facts-c{ci}.json"
|
||||
def supp_path(ci): return work_dir / f"facts-erg-c{ci}.json"
|
||||
def chk_path(ci, j): return work_dir / f"facts-check-c{ci}-j{j}.json"
|
||||
def fix_path(ci): return work_dir / f"facts-fix-c{ci}.json"
|
||||
def raw_path(ci): return work_dir / f"facts-{sh}-c{ci}.json"
|
||||
def supp_path(ci): return work_dir / f"facts-erg-{sh}-c{ci}.json"
|
||||
def chk_path(ci, j): return work_dir / f"facts-check-{sh}-c{ci}-j{j}.json"
|
||||
def fix_path(ci): return work_dir / f"facts-fix-{sh}-c{ci}.json"
|
||||
def ctitle(idxs): return [blocks[i][0] for i in idxs]
|
||||
def block_text(idxs):
|
||||
return "\n\n".join(
|
||||
@@ -1495,10 +1845,13 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
payload=lambda result, p=ep: _facts_schema(_json_file(p)),
|
||||
timeout=_timeout("content", subs_total))
|
||||
|
||||
set_p("Facts supplement…", step=_step_idx(topic, "Facts find"))
|
||||
await _gather_progress([_supplement(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts find"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
if not slim:
|
||||
set_p("Facts supplement…", step=_step_idx(topic, "Facts find"))
|
||||
await _gather_progress([_supplement(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts find"))
|
||||
if is_cancelled():
|
||||
return None
|
||||
panel = (1,) if slim else (1, 2, 3)[:FACTS_CHECK_PANEL]
|
||||
min_discard = 1 if slim else 2
|
||||
|
||||
# Phase "Facts check": FACTS_CHECK_PANEL judges per chunk. Two majority sets:
|
||||
# flagged (fact inaccurate → correct) and discard (sub not supportable → remove).
|
||||
@@ -1514,7 +1867,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
fallback = [blocks[i][0] for i in idxs] + [fk["sub"] for fm in per.values() for fk in fm.values()]
|
||||
ev = _cited_evidence(folder, sources, cites, fallback) if folder else ""
|
||||
c_source = _prompt("Blocks-Source-Inline", excerpts=ev) if ev else source
|
||||
pending = [j for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if _facts_check_schema(_json_file(chk_path(ci, j))) is None]
|
||||
pending = [j for j in panel if _facts_check_schema(_json_file(chk_path(ci, j))) is None]
|
||||
rs = await asyncio.gather(*[
|
||||
run_agent(f"blocks-{topic}-{ns}facts-check-c{ci}-j{j}",
|
||||
_prompt("Facts-Check", topic=topic, source=c_source, facts=facts_text, out_path=chk_path(ci, j), extra=_extra(instructions)),
|
||||
@@ -1525,7 +1878,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
for j, r in zip(pending, rs):
|
||||
if isinstance(r, tuple):
|
||||
_sink_json(r, chk_path(ci, j), _facts_check_schema)
|
||||
outs = [s for j in (1, 2, 3)[:FACTS_CHECK_PANEL] if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
||||
outs = [s for j in panel if (s := _facts_check_schema(_json_file(chk_path(ci, j)))) is not None]
|
||||
bvotes: dict[str, int] = {}
|
||||
vvotes: dict[str, int] = {}
|
||||
for s in outs: # s = [(sub_norm, verwerfen)] of one judge
|
||||
@@ -1538,8 +1891,9 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
|
||||
threshold = len(outs) / 2 if outs else 99
|
||||
flagged = {sn for sn, v in bvotes.items() if v > threshold}
|
||||
# Discarding is irreversible → stricter than flagging: majority AND ≥2 agreeing judges
|
||||
# (prevents deletion by a single vote when the panel is degraded).
|
||||
to_discard = {sn for sn, v in vvotes.items() if v > threshold and v >= 2}
|
||||
# (prevents deletion by a single vote when the panel is degraded). slim runs ONE judge
|
||||
# by design — there its single vote must be allowed to discard.
|
||||
to_discard = {sn for sn, v in vvotes.items() if v > threshold and v >= min_discard}
|
||||
return ci, flagged, to_discard
|
||||
|
||||
check = await _gather_progress([_check(ci, idxs) for ci, idxs in enumerate(chunks)], len(chunks), _report_p(set_p, topic, "Facts check"))
|
||||
@@ -1611,9 +1965,10 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
return {}
|
||||
chunks = _chunk_nums(list(range(len(items))), _n_chunks(len(items), LEVEL_CHUNK))
|
||||
n = len(chunks)
|
||||
sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed
|
||||
|
||||
def rater_paths(c):
|
||||
return [work_dir / f"relevance-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
return [work_dir / f"relevance-{sh}-c{c}-{i}.json" for i in (1, 2, 3)]
|
||||
|
||||
def lset(item_idxs):
|
||||
return set(range(1, len(item_idxs) + 1))
|
||||
@@ -1660,7 +2015,7 @@ async def _relevance_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
async def _clarify(c, item_idxs):
|
||||
outcome, strittig = vote_by_c[c]
|
||||
if strittig:
|
||||
judge_path = work_dir / f"relevance-final-c{c}.json"
|
||||
judge_path = work_dir / f"relevance-final-{sh}-c{c}.json"
|
||||
decision = _relevance_schema(_json_file(judge_path), set(strittig))
|
||||
if decision is None:
|
||||
disputed_block = _disputed_lines(items, item_idxs, strittig)
|
||||
@@ -1733,12 +2088,13 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
if not blocks:
|
||||
return {}
|
||||
chunks = _lpt_chunks([len(rel) for _, rel in blocks], QUESTION_CHUNK_SUBS) # load-balanced by sub count
|
||||
sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed
|
||||
|
||||
def raw_path(ci):
|
||||
return work_dir / f"question-pattern-c{ci}.json"
|
||||
return work_dir / f"question-pattern-{sh}-c{ci}.json"
|
||||
|
||||
def final_path(ci):
|
||||
return work_dir / f"question-pattern-final-c{ci}.json"
|
||||
return work_dir / f"question-pattern-final-{sh}-c{ci}.json"
|
||||
|
||||
def _chunk_title(idxs):
|
||||
return [blocks[i][0] for i in idxs]
|
||||
@@ -1889,7 +2245,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
return "\n\n".join(block_texts)
|
||||
|
||||
async def _request_more(round_n, pi, items):
|
||||
fp = work_dir / f"question-pattern-nach{round_n}-c{pi}.json"
|
||||
fp = work_dir / f"question-pattern-nach{round_n}-{sh}-c{pi}.json"
|
||||
if _question_pattern_chunk_schema(_json_file(fp)):
|
||||
return # resume
|
||||
subs_total = sum(len(s) for _, s in items)
|
||||
@@ -1920,7 +2276,7 @@ async def _question_pattern_block(ctx: GenContext, set_p, files: dict, sidecar:
|
||||
for pi, items in enumerate(package_items):
|
||||
title_subs = {t: subs for t, subs in items}
|
||||
ctitle = list(title_subs.keys())
|
||||
for e in _question_pattern_chunk_schema(_json_file(work_dir / f"question-pattern-nach{round_n}-c{pi}.json")) or []:
|
||||
for e in _question_pattern_chunk_schema(_json_file(work_dir / f"question-pattern-nach{round_n}-{sh}-c{pi}.json")) or []:
|
||||
title = _match_sub(e["block"], ctitle)
|
||||
if title not in title_subs:
|
||||
continue
|
||||
@@ -2825,6 +3181,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled
|
||||
work_dir = files["arbeit"]
|
||||
caps = "files"
|
||||
sh = _subs_hash(sidecar) # resume must invalidate when the sub set changed
|
||||
# Blocks with subs + facts lines as input block (extract-once from the facts).
|
||||
blocks = []
|
||||
for btitle, subs in sidecar.items():
|
||||
@@ -2855,7 +3212,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
async def _check_examples(ci, idxs, items):
|
||||
if is_cancelled() or not items:
|
||||
return items
|
||||
def cpath(j): return work_dir / f"artifact-example-check-c{ci}-j{j}.json"
|
||||
def cpath(j): return work_dir / f"artifact-example-check-{sh}-c{ci}-j{j}.json"
|
||||
examples_txt = "\n\n".join(
|
||||
f"{k}. PROBLEM: {e['problem']}\n SCHRITTE: " + " | ".join(e.get("steps", []))
|
||||
+ (f"\n ERGEBNIS: {e['result']}" if e.get("result") else "")
|
||||
@@ -2890,7 +3247,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
|
||||
own files) and run in parallel."""
|
||||
schema = _ARTEFACT_SCHEMA[typ]
|
||||
|
||||
def apath(ci): return work_dir / f"artifact-{typ}-c{ci}.json"
|
||||
def apath(ci): return work_dir / f"artifact-{typ}-{sh}-c{ci}.json"
|
||||
|
||||
async def _gen(ci, idxs):
|
||||
p = apath(ci)
|
||||
@@ -2945,7 +3302,7 @@ async def _mirror_sidecar_db(topic: str, sidecar: dict) -> None:
|
||||
|
||||
|
||||
async def generate_blocks(topic: str, instructions: str = "", provider: str = DEFAULT_PROVIDER,
|
||||
research: bool = True) -> None:
|
||||
research: bool = True, qa_force: bool = False) -> None:
|
||||
"""Kanban entry point: source prep, then both boards (inventory + artefacts) until
|
||||
quiescence. research=False = Continue (drain the existing queue, no new search).
|
||||
A run on a finished topic ADDS research (live extension) — full rebuild = DELETE /blocks."""
|
||||
@@ -2978,7 +3335,7 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
|
||||
return
|
||||
import board_inventory # lazy: the boards import blocks
|
||||
ok = await board_inventory.run_boards(ctx, set_p, files, q, folder, instructions,
|
||||
research=research)
|
||||
research=research, qa_force=qa_force)
|
||||
if not ok and is_cancelled():
|
||||
_blocks_errors[topic] = "Cancelled — progress is preserved"
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user