@@ -23,7 +23,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 , 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
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 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
@@ -198,7 +198,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 " , " Dedup " , " Blocks-Filter" )
base = ( " Research " , " Consolidation " , " Clarification " , " Blocks-Filter " )
rest = (
" Subblocks find " , " Subblocks select " , " Subblocks clarify " ,
" Facts find " , " Facts check " , " Facts fix " ,
@@ -228,7 +228,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 " , " Dedup " , " Blocks-Filter" , " Supplement " ) ) ,
( " Inventory " , ( " Research " , " Consolidation " , " Clarification " , " Blocks-Filter " , " Supplement " ) ) ,
( " Subblocks " , ( " Subblocks find " , " Subblocks select " , " Subblocks clarify " ) ) ,
( " Facts " , ( " Facts find " , " Facts check " , " Facts fix " ) ) ,
( " Levels " , ( " Levels find " , " Levels select " , " Levels clarify " ) ) ,
@@ -317,7 +317,7 @@ 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 " , " Dedup " , " Blocks-Filter" ) :
for step in ( " Source prep " , " Research " , " Consolidation " , " Clarification " , " Blocks-Filter " ) :
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
@@ -451,15 +451,19 @@ def _reset_from_phase(topic: str, label: str) -> None:
files [ " final " ] . unlink ( missing_ok = True )
async def _reset_from_step ( topic : str , step_idx : int ) - > None :
""" Fine reset FROM a sub-step (0-based index in _blocks_steps). Resets
pipeline_state + artefacts + DB from here on; earlier steps stay. Inventory sub-steps
reconstruct the DB status from the artefacts (dedup/filter safe; clarification robustly falls back
to consolidation, because the clarification renames → a title mismatch would be fragile). """
async def _reset_from_step ( topic : str , step_idx : int , to_idx : int | None = None ) - > None :
""" Fine reset FROM a sub-step (0-based index in _blocks_steps). With `to_idx` set, ONLY the
span [step_idx, to_idx] is reset — later steps and the blocks.md aggregate stay (isolated
single-step / bounded-range regenerate). Without it the reset cascades to the end (full re-run).
Earlier steps always stay. Inventory sub-steps reconstruct the DB status from the artefacts
(filter safe; clarification robustly falls back to consolidation, because the clarification
renames → a title mismatch would be fragile). """
fine_steps = list ( _blocks_steps ( topic ) )
if not ( 0 < = step_idx < len ( fine_steps ) ) :
return
affected = set ( fine_steps [ step_idx : ] )
last = len ( fine_steps ) - 1 if to_idx is None else max ( step_idx , min ( to_idx , len ( fine_steps ) - 1 ) )
span = fine_steps [ step_idx : last + 1 ]
affected = set ( span )
files = _blocks_files ( topic )
work_dir = files [ " arbeit " ]
@@ -468,7 +472,7 @@ async def _reset_from_step(topic: str, step_idx: int) -> None:
for p in work_dir . glob ( pat ) :
p . unlink ( missing_ok = True )
await db . delete_pipeline_state ( topic , list ( fine_steps [ step_idx : ] ) )
await db . delete_pipeline_state ( topic , span )
# Later artefacts/DB cumulatively from the affected step (back to front).
if { " Examples " , " Flashcards " } & affected :
files [ " artefakte " ] . unlink ( missing_ok = True ) ; gd ( " artifact-* " ) ; await db . delete_sub_artefakte ( topic )
@@ -501,21 +505,12 @@ async def _reset_from_step(topic: str, step_idx: int) -> None:
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 " , " Dedup " } & affected ) :
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 " Dedup " in affected and not ( { " Clarification " , " Consolidation " , " Research " } & affected ) :
# Dedup (+filter) rebuilt: all blocks discarded in dedup/filter back to consensus.
for kind in ( " dedup-runde-1.json " , " inventar-filter.json " ) :
d = _json_file ( work_dir / kind )
title = ( [ t for g in d . get ( " groups " , [ ] ) for t in g ] if isinstance ( d , dict ) and " groups " in d
else [ f . get ( " fragment " , " " ) for f in d . get ( " fragments " , [ ] ) ] if isinstance ( d , dict ) else [ ] )
for t in title :
await db . set_block_status ( topic , _norm_title ( t ) , " consensus " )
gd ( " dedup-* " ) ; 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.
@@ -527,8 +522,9 @@ async def _reset_from_step(topic: str, step_idx: int) -> None:
await db . delete_blocks ( 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).
if { " Research " , " Consolidation " , " Clarification " , " Dedup " , " Blocks-Filter " } & affected :
# 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 :
files [ " final " ] . unlink ( missing_ok = True )
@@ -867,7 +863,7 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
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 ]
pending = [ ( j , p ) for j , p in enumerate ( paths , 1 ) if not _parse_subblocks ( _read ( p ) ) ] # {} (empty/missing) also pending — _parse_subblocks never returns None
for _ , p in pending :
p . unlink ( missing_ok = True )
if pending :
@@ -890,12 +886,13 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
atomic_write_text ( fp , text )
return
# code majority per block/sub-norm: keep if a majority of judges list it (tie → keep).
# Semantic majority per block: cluster the judges' subblocks by meaning, keep a cluster a
# majority of judges contributed to. Exact-string voting discarded paraphrased core facts
# (each judge wording → 1 vote) while verbatim side-notes won.
block_texts_out = [ ]
for num in chunk :
votes : dic t[ str , int ] = { }
form : dict [ str , str ] = { }
for d in outs :
cand : lis t[ tuple [ int , str ] ] = [ ] # (judge index, subblock) — exact-dedup per judge
for ji , d in enumerate ( outs ) :
seen = set ( )
for marker , subs in d . items ( ) :
if _resolve_title ( chunk_idx , marker ) != num :
@@ -905,9 +902,10 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
if not sn or 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 ) ]
cand . append ( ( ji , sub ) )
if not cand :
continue
kept = await _cluster_vote ( cand , len ( outs ) )
if kept :
block_texts_out . append ( f " <!-- block: { title_by_num [ num ] } --> \n " + " \n " . join ( f " - { s } " for s in kept ) )
atomic_write_text ( fp , " \n \n " . join ( block_texts_out ) )
@@ -947,6 +945,42 @@ async def _subblocks_block(ctx: GenContext, set_p, files: dict, entries: dict, i
return raw
async def _cluster_vote ( cand : list [ tuple [ int , str ] ] , n_judges : int ) - > list [ str ] :
""" Semantic majority over the judges ' subblocks. Cluster by cosine ≥ EMBEDDING_SUB_SAME (same
point), keep a cluster a majority of distinct judges contributed to, pick the longest (most
informative) phrasing. Replaces exact-string voting, which split paraphrases of the same fact.
Model missing → norm-union fallback (keep everything ≥1 judge, exact-dedup — never lose content). """
texts = [ s for _ , s in cand ]
sims = ( await asyncio . to_thread ( embedding . embed_sims , texts )
if EMBEDDING_AKTIV and await asyncio . to_thread ( embedding . available ) else None )
if sims is None :
out , seen = [ ] , set ( )
for _ , s in cand :
sn = _norm_title ( s )
if sn and sn not in seen :
seen . add ( sn )
out . append ( s )
return out
parent = list ( range ( len ( texts ) ) )
def find ( x : int ) - > int :
while parent [ x ] != x :
parent [ x ] = parent [ parent [ x ] ]
x = parent [ x ]
return x
for i in range ( len ( texts ) ) :
for k in range ( i + 1 , len ( texts ) ) :
if float ( sims [ i ] [ k ] ) > = EMBEDDING_SUB_SAME :
parent [ find ( i ) ] = find ( k )
clusters : dict [ int , list [ int ] ] = { }
for i in range ( len ( texts ) ) :
clusters . setdefault ( find ( i ) , [ ] ) . append ( i )
kept = [ ]
for idxs in clusters . values ( ) :
if len ( { cand [ i ] [ 0 ] for i in idxs } ) * 2 > = n_judges : # majority of judges
kept . append ( max ( ( texts [ i ] for i in idxs ) , key = len ) ) # longest phrasing
return kept
async def _dedup_subblocks ( topic : str , raw : dict [ str , list [ str ] ] ) - > None :
""" Deterministic near-duplicate filter per block: subblocks with cosine ≥
EMBEDDING_SUB_DUP are the same statement (reliable in the narrow block context — no LLM
@@ -1324,7 +1358,7 @@ async def _facts_block(ctx, set_p, files: dict, raw: dict, q: dict, folder, inst
await asyncio . gather ( * [
run_agent ( f " blocks- { topic } -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 )
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 ] = { }
@@ -1920,6 +1954,37 @@ async def _prepare_source(ctx: GenContext, set_p, files: dict, q: dict, folder,
return True
async def _ingest_research ( topic : str , reader_id : str , text : str ) - > None :
""" Parse one reader ' s research file (lines `title — desc — source`) into block candidates and
upsert them with the reader id (one reader = one vote per concept). Shared by the research step
and the candidate re-ingest below. """
seen_set = set ( )
for record in _parse_selection ( text ) . values ( ) :
title = _title ( record )
norm = _norm_title ( title )
if not norm or norm in seen_set :
continue
seen_set . add ( norm ) # one reader = one vote per concept
split_parts = [ t . strip ( ) for t in record . split ( " — " ) ]
desc = split_parts [ 1 ] if len ( split_parts ) > = 2 else " "
source = [ split_parts [ 2 ] ] if len ( split_parts ) > = 3 and split_parts [ 2 ] else [ ]
await db . upsert_block ( topic , norm , title , desc , source , reader = reader_id )
async def _reingest_research_files ( topic : str , work_dir : Path ) - > None :
""" Rebuild the research candidates in the blocks DB from the saved research-*.md reader files
(no agents, no web calls). Consolidation CONSUMES the candidates (overwrites them with
consensus/rest), so a consolidation re-run / reset needs them restored. Reader id = file suffix
(pure number N → " tN " for the web readers; " a… " / " b… " section/batch readers stay as-is). """
for p in sorted ( work_dir . glob ( " research-*.md " ) ) :
suffix = p . stem [ len ( " research- " ) : ]
if not suffix :
continue
rid = f " t { suffix } " if suffix . isdigit ( ) else suffix
if text := _file_payload ( p ) :
await _ingest_research ( topic , rid , text )
async def _research_batch ( ctx : GenContext , set_p , files : dict , q : dict , folder , instructions : str ) - > bool :
""" Fills DB table `blocks` with candidates (+ mention counter). FIXED file batches:
each crawl page is assigned to exactly one batch and read by RESEARCH_READERS agents
@@ -1933,17 +1998,7 @@ async def _research_batch(ctx: GenContext, set_p, files: dict, q: dict, folder,
await db . set_step_status ( topic , " Research " , " running " )
async def _ingest ( reader_id : str , text : str ) - > None :
seen_set = set ( )
for record in _parse_selection ( text ) . values ( ) :
title = _title ( record )
norm = _norm_title ( title )
if not norm or norm in seen_set :
continue
seen_set . add ( norm ) # one reader = one vote per concept
split_parts = [ t . strip ( ) for t in record . split ( " — " ) ]
desc = split_parts [ 1 ] if len ( split_parts ) > = 2 else " "
source = [ split_parts [ 2 ] ] if len ( split_parts ) > = 3 and split_parts [ 2 ] else [ ]
await db . upsert_block ( topic , norm , title , desc , source , reader = reader_id )
await _ingest_research ( topic , reader_id , text )
pages = await db . list_content ( topic ) # pages marked as content by the triage
if not pages and folder :
@@ -2068,31 +2123,6 @@ async def _research_batch(ctx: GenContext, set_p, files: dict, q: dict, folder,
return True
def _grp_schema ( data , ids : set [ int ] ) :
""" { " groups " : [[1,3],[2], …]} → partition of `ids` as a list of index groups.
Tolerant: ignores foreign/duplicate numbers; forgotten candidates are added standalone
(singleton group). None only on structurally broken JSON. """
if not isinstance ( data , dict ) or not isinstance ( data . get ( " groups " ) , list ) :
return None
groups , seen_set = [ ] , set ( )
for g in data [ " groups " ] :
if not isinstance ( g , list ) :
return None
grp = [ ]
for x in g :
try :
num = int ( x )
except ( ValueError , TypeError ) :
continue
if num in ids and num not in seen_set :
seen_set . add ( num )
grp . append ( num )
if grp :
groups . append ( grp )
groups + = [ [ r ] for r in sorted ( ids - seen_set ) ] # forgotten candidates stay standalone
return groups or None
_ASPECT_MARKER = ( " ∈ np " , " ∈np " , " in np " , " np-schwer " , " np-vollständig " , " verifizierer " ,
" zertifikat " , " ndtm " , " nicht-determ " , " lower bound " , " untere schranke " ,
" bzgl " , " als sprache " )
@@ -2146,47 +2176,58 @@ def _canonical(candidates: list[dict], idxs: list[int], seen_norm: set[str]) ->
return { " title " : title , " description " : candidates [ k ] [ " description " ] }
async def _group_block s ( ctx : GenContext , set_p , work_dir : Path , candidates : list [ dict ] ,
blocks : list [ list [ int ] ] , prefix : str = " consolidation " ,
step : str = " Consolidation " ) - > list [ list [ int ] ] :
""" Per similarity block, a judge groups the titles into the real blocks (merge
paraphrases, split over-merges). Singletons directly. Error/timeout → conservatively each
candidate alone (avoids false over-merging). → final groups (global indices).
`praefix`/`step` separate consolidation and dedup (artefacts, race key, progress) . """
async def _pairwise_group s ( 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
multi = [ ( bi , b ) for bi , b in enumerate ( blocks ) if len ( b ) > 1 ]
outcome : list [ list [ int ] ] = [ list ( b ) for b in blocks if len ( b ) == 1 ] # singletons directly
n = l en( 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 _line ( k : int , g : int ) - > str :
b = candidates [ g ]
return f " { k } . { b [ ' title ' ] } " + ( f " — { b [ ' description ' ] } " if b [ " description " ] else " " )
def pair_path ( pi ) : return work_dir / f " consolidation-paar-c { pi } .json "
async def _grp ( b i: int , block : list [ int ] ) - > None :
ids = set ( range ( 1 , len ( block ) + 1 ) )
p = work_dir / f " { prefix } -block-c { bi } .json "
part = _grp_schema ( _json_file ( p ) , ids )
if part is None : # resume: don't recompute a valid file
p . unlink ( missing_ok = True )
if is_cancelled ( ) :
return
lines = [ _line ( k , block [ k - 1 ] ) for k in range ( 1 , len ( block ) + 1 ) ]
status , part = await run_single_slot (
ctx , f " B lock grouping { b i} " ,
key = f " b locks-{ topic } - { prefix } -block-c { bi } " ,
prompt = _prompt ( " Blocks-Block-Grouping " , topic = topic , entries = " \n " . join ( lines ) , out_path = p ) ,
role = " judge " , capabilities = " files " ,
payload = ( lambda result , p = p , ids = ids : _grp_schema ( _json_fi le ( p) , ids ) ) ,
timeout = _timeout ( " research_mapping " , len ( block ) ) ,
)
part = part if status == OK else None
if part is None : # judge failed → individually (no over-merge)
outcome . extend ( [ idx ] for idx in block )
else : # local numbers → global candidate indices
outcome . extend ( [ block [ k - 1 ] for k in g ] for g in part )
async def _filt ( p i, paare ) :
fp = pair_path ( pi )
if _pairs_schema ( _json_file ( fp ) ) :
return # resume
lines = " \n \n " . join (
f " { j + 1 } . \n A: { candidates [ a ] [ ' title ' ] } — { candidates [ a ] [ ' description ' ] } "
f " \n B: { candidates [ b ] [ ' title ' ] } — { candidates [ b ] [ ' description ' ] } "
for j , ( a , b ) in enumerate ( paare ) )
await run_single_slot (
ctx , f " Consolidation pairs { pi } " ,
key = f " b locks- { topic } -consolidation-paar-c { p i} " ,
prompt = _prompt ( " B locks-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 ( [ _grp ( b i, b ) for b i, b in multi ] ,
len ( multi ) , _report_p ( set_p , topic , step ) )
return outcome
await _gather_progress ( [ _filt ( p i, p ) for p i, p in enumerate ( packages ) ] ,
len ( packages ) , _report_p ( set_p , topic , " Consolidation " ) )
if is_cancelled ( ) :
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 ]
async def _consolidate_embedding ( ctx : GenContext , set_p , files : dict , candidates : list [ dict ] ) - > bool :
@@ -2198,11 +2239,12 @@ async def _consolidate_embedding(ctx: GenContext, set_p, files: dict, 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).
# 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: one judge groups EACH multi-block into the real blocks.
groups = await _group_blocks ( ctx , set_p , work_dir , candidates , blocks )
if is_cancelled ( ) :
# 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)
@@ -2230,7 +2272,7 @@ async def _consolidate_embedding(ctx: GenContext, set_p, files: dict, candidates
" 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 (embedding ): { len ( blocks ) } blocks ( { multi_blocks } grouped via LLM ) "
_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 " )
@@ -2251,6 +2293,11 @@ async def _consolidate(ctx: GenContext, set_p, files: dict) -> bool:
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.
await _reingest_research_files ( topic , files [ " arbeit " ] )
candidates = await db . list_blocks ( topic )
if not candidates :
_blocks_errors [ topic ] = " Consolidation: no candidates "
return False
@@ -2434,7 +2481,7 @@ async def _clarify_inventory(ctx: GenContext, set_p, files: dict) -> bool:
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 , status = " consensus " ) }
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 :
@@ -2494,83 +2541,6 @@ def _cliques(n: int, edge_list: list[tuple[int, int]]) -> list[list[int]]:
return groups
async def _dedup_inventory ( ctx : GenContext , set_p , files : dict ) - > bool :
""" Final dedup pass over the finished consensus list: pairwise verification (entity
resolution). Embedding yields candidate PAIRS (cosine ≥ DEDUP_PAAR_FLOOR), a judge
confirms EACH pair individually (ja = the same duplicate). ONLY confirmed pairs become
merge edges (union-find) — no chaining, no aspect over-merging like the block mixer.
Per group ONE representative (main concept) stays, the rest is discarded. """
topic , is_cancelled = ctx . topic , ctx . is_cancelled
if await db . get_step_status ( topic , " Dedup " ) == " done " :
return True
if not ( EMBEDDING_AKTIV and await asyncio . to_thread ( embedding . available ) ) :
await db . set_step_status ( topic , " Dedup " , " done " ) # without a model: silently skip
return True
set_p ( " Dedup… " , step = _step_idx ( topic , " Dedup " ) )
work_dir = files [ " arbeit " ]
consensus = await db . list_blocks ( topic , status = " consensus " )
if len ( consensus ) > = 2 :
import numpy as np
texts = [ f " { b [ ' title ' ] } — { b [ ' description ' ] } " if b [ " description " ] else b [ " title " ] for b in consensus ]
sims = await asyncio . to_thread ( embedding . embed_sims , texts )
if sims is not None :
n = len ( consensus )
iu = np . triu_indices ( n , k = 1 )
cands = [ ( int ( iu [ 0 ] [ m ] ) , int ( iu [ 1 ] [ m ] ) ) for m in np . where ( sims [ iu ] > = DEDUP_PAIR_FLOOR ) [ 0 ] ]
_log ( topic , f " Dedup: { len ( cands ) } candidate pairs (cosine ≥ { DEDUP_PAIR_FLOOR } ) → pairwise filter " )
packages = [ cands [ i : i + DEDUP_PAIRS_CHUNK ] for i in range ( 0 , len ( cands ) , DEDUP_PAIRS_CHUNK ) ]
def pair_path ( pi ) : return work_dir / f " dedup-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 } . \n A: { consensus [ a ] [ ' title ' ] } — { consensus [ a ] [ ' description ' ] } "
f " \n B: { consensus [ b ] [ ' title ' ] } — { consensus [ b ] [ ' description ' ] } "
for j , ( a , b ) in enumerate ( paare ) )
await run_single_slot (
ctx , f " Dedup pairs { pi } " ,
key = f " blocks- { topic } -dedup-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 , " Dedup " ) )
if is_cancelled ( ) :
return False
# Collect confirmed "ja" edges, then COMPLETE-LINK (greedy cliques) instead of single-link
# union-find — prevents chaining (A=B + B=C does NOT merge A,C without a direct A=C).
edge_list , ja = [ ] , 0
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 ) )
ja + = 1
groups = _cliques ( n , edge_list )
removed = 0
for idxs in groups :
# representative = main concept (fewest property markers), then shortest title.
rep = min ( idxs , key = lambda k : ( _aspect_marker ( consensus [ k ] [ " title " ] ) , len ( consensus [ k ] [ " title " ] ) , k ) )
for k in idxs :
if k != rep :
await db . set_block_status ( topic , consensus [ k ] [ " title_norm " ] , " discarded " )
removed + = 1
from collections import Counter
atomic_write_json ( work_dir / " dedup-runde-1.json " ,
{ " vorher " : n , " entfernt " : removed , " paare_geprueft " : len ( cands ) , " paare_ja " : ja ,
" clique_groessen " : dict ( sorted ( Counter ( len ( g ) for g in groups ) . items ( ) ) ) ,
" groups " : [ [ consensus [ k ] [ " title " ] for k in g ] for g in groups ] } , indent = 1 )
_log ( topic , f " Dedup (pairwise): { n } → { n - removed } (− { removed } ); { ja } / { len ( cands ) } pairs confirmed " )
await db . set_step_status ( topic , " Dedup " , " done " )
return True
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. """
@@ -2963,7 +2933,7 @@ async def _artefacts_block(ctx: GenContext, set_p, files: dict, sidecar: dict, i
await asyncio . gather ( * [
run_agent ( f " blocks- { topic } -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 )
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 :
@@ -3073,7 +3043,7 @@ 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 " , " Dedup " , " Blocks-Filter" ] )
await db . delete_pipeline_state ( topic , [ " Research " , " Consolidation " , " Clarification " , " Blocks-Filter " ] )
if idx < = 0 : # Source: redo triage (coverage/content + step)
await db . delete_coverage ( topic )
await db . delete_pipeline_state ( topic , [ " Source prep " ] )
@@ -3114,7 +3084,7 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
# Re-run from the chosen phase: delete artefacts from there; the fresh-start block
# below is skipped (with a preserved sidecar it would otherwise wipe everything).
if ab_step is not None : # fine sub-step re-run (takes precedence over ab_phase)
await _reset_from_step ( topic , ab_step )
await _reset_from_step ( topic , ab_step , to_step )
elif ab_phase is not None :
phasen = _phases ( topic )
label = phasen [ ab_phase - 1 ] [ 0 ] if 1 < = ab_phase < = len ( phasen ) else " Inventory "
@@ -3156,9 +3126,6 @@ async def generate_blocks(topic: str, instructions: str = "", provider: str = DE
if _past_limit ( " Clarification " ) : return
if not await _stage ( _clarify_inventory ( ctx , set_p , files ) ) :
return
if _past_limit ( " Dedup " ) : return
if not await _stage ( _dedup_inventory ( ctx , set_p , files ) ) :
return
if _past_limit ( " Blocks-Filter " ) : return
if not await _stage ( _filter_inventory ( ctx , set_p , files ) ) :
return