44//! Hierarchical ToC-based node locator.
55//!
66//! Replaces the monolithic `build_toc_for_llm` with a two-phase approach:
7- //! - Phase A: BM25 scoring on level-0 ( top-level) nodes for fast filtering
7+ //! - Phase A: BM25 scoring on top-level nodes for fast filtering
88//! - Phase B: Optional LLM refinement when top scores are below a threshold
99
1010use std:: sync:: Arc ;
1111
1212use serde:: Deserialize ;
1313use tracing:: { debug, info, warn} ;
1414
15- use crate :: document:: { DocumentTree , NodeId , TocView } ;
15+ use crate :: document:: DocumentTree ;
16+ use crate :: document:: NodeId ;
1617use crate :: llm:: LlmClient ;
1718use crate :: memo:: MemoStore ;
1819use crate :: retrieval:: search:: scorer:: NodeScorer ;
@@ -82,9 +83,9 @@ impl ToCNavigator {
8283 & self ,
8384 query : & str ,
8485 tree : & DocumentTree ,
85- level_0_nodes : & [ NodeId ] ,
86+ top_level_nodes : & [ NodeId ] ,
8687 ) -> Vec < SearchCue > {
87- if level_0_nodes . is_empty ( ) {
88+ if top_level_nodes . is_empty ( ) {
8889 return vec ! [ SearchCue {
8990 root: tree. root( ) ,
9091 confidence: 0.5 ,
@@ -93,7 +94,7 @@ impl ToCNavigator {
9394
9495 // Phase A: BM25 scoring
9596 let scorer = NodeScorer :: for_query ( query) ;
96- let scored: Vec < ( NodeId , f32 ) > = level_0_nodes
97+ let scored: Vec < ( NodeId , f32 ) > = top_level_nodes
9798 . iter ( )
9899 . map ( |& id| ( id, scorer. score ( tree, id) ) )
99100 . filter ( |( _, s) | * s > 0.05 )
@@ -102,8 +103,8 @@ impl ToCNavigator {
102103 let top_branches = take_top_n ( scored, self . max_branches ) ;
103104
104105 debug ! (
105- "ToCNavigator Phase A: {} level-0 nodes scored, top {} kept" ,
106- level_0_nodes . len( ) ,
106+ "ToCNavigator Phase A: {} top-level nodes scored, {} kept after filter " ,
107+ top_level_nodes . len( ) ,
107108 top_branches. len( )
108109 ) ;
109110
@@ -116,11 +117,20 @@ impl ToCNavigator {
116117 best_score, self . llm_threshold
117118 ) ;
118119 return self
119- . llm_refine ( query, tree, & top_branches , client)
120+ . llm_refine ( query, tree, top_level_nodes , client)
120121 . await ;
121122 }
122123 }
123124
125+ // Fallback: if no branches passed the filter, search from root
126+ if top_branches. is_empty ( ) {
127+ debug ! ( "ToCNavigator: no branches above threshold, falling back to root" ) ;
128+ return vec ! [ SearchCue {
129+ root: tree. root( ) ,
130+ confidence: 0.5 ,
131+ } ] ;
132+ }
133+
124134 // Return BM25 results as cues
125135 top_branches
126136 . into_iter ( )
@@ -131,32 +141,33 @@ impl ToCNavigator {
131141 . collect ( )
132142 }
133143
134- /// Phase B: Ask the LLM to refine branch selection.
144+ /// Phase B: Ask the LLM to pick the most relevant subtrees.
145+ ///
146+ /// Presents the full top-level TOC to the LLM and lets it select the
147+ /// most relevant entries. Uses direct tree traversal so that we can
148+ /// correctly map LLM-selected indices back to real NodeIds.
135149 async fn llm_refine (
136150 & self ,
137151 query : & str ,
138152 tree : & DocumentTree ,
139- top_branches : & [ ( NodeId , f32 ) ] ,
153+ top_level_nodes : & [ NodeId ] ,
140154 client : & LlmClient ,
141155 ) -> Vec < SearchCue > {
142- let toc_view = TocView :: new ( ) ;
143- let mut toc_entries = Vec :: new ( ) ;
144- let mut node_ids = Vec :: new ( ) ;
156+ // Collect (title, summary) and the corresponding NodeId directly
157+ // from the tree, maintaining index correspondence for LLM response mapping.
158+ let mut toc_entries: Vec < ( String , Option < String > ) > = Vec :: new ( ) ;
159+ let mut node_ids: Vec < NodeId > = Vec :: new ( ) ;
145160
146- for & ( node_id, _) in top_branches {
147- let sub_toc = toc_view. generate_from ( tree, node_id) ;
148- collect_toc_flat ( & sub_toc, & mut toc_entries, & mut node_ids) ;
161+ for & node_id in top_level_nodes {
162+ collect_tree_entries ( tree, node_id, & mut toc_entries, & mut node_ids, 0 , 2 ) ;
149163 }
150164
151165 if node_ids. is_empty ( ) {
152- warn ! ( "LLM refinement: no nodes collected from top branches" ) ;
153- return top_branches
154- . iter ( )
155- . map ( |& ( node_id, score) | SearchCue {
156- root : node_id,
157- confidence : score,
158- } )
159- . collect ( ) ;
166+ warn ! ( "LLM refinement: no nodes collected from top-level branches" ) ;
167+ return vec ! [ SearchCue {
168+ root: tree. root( ) ,
169+ confidence: 0.5 ,
170+ } ] ;
160171 }
161172
162173 let toc_str = toc_entries
@@ -221,13 +232,10 @@ Rules:
221232
222233 if cues. is_empty ( ) {
223234 warn ! ( "LLM refinement returned no valid candidates, falling back to BM25" ) ;
224- return top_branches
225- . iter ( )
226- . map ( |& ( node_id, score) | SearchCue {
227- root : node_id,
228- confidence : score,
229- } )
230- . collect ( ) ;
235+ return vec ! [ SearchCue {
236+ root: tree. root( ) ,
237+ confidence: 0.5 ,
238+ } ] ;
231239 }
232240
233241 info ! (
@@ -238,14 +246,11 @@ Rules:
238246 cues
239247 }
240248 Err ( e) => {
241- warn ! ( "LLM refinement failed: {}, falling back to BM25" , e) ;
242- top_branches
243- . iter ( )
244- . map ( |& ( node_id, score) | SearchCue {
245- root : node_id,
246- confidence : score,
247- } )
248- . collect ( )
249+ warn ! ( "LLM refinement failed: {}, falling back to root" , e) ;
250+ vec ! [ SearchCue {
251+ root: tree. root( ) ,
252+ confidence: 0.5 ,
253+ } ]
249254 }
250255 }
251256 }
@@ -259,20 +264,34 @@ fn take_top_n(scored: Vec<(NodeId, f32)>, n: usize) -> Vec<(NodeId, f32)> {
259264 sorted
260265}
261266
262- /// Recursively collect ToC entries into flat lists for LLM consumption.
263- fn collect_toc_flat (
264- toc : & crate :: document:: TocNode ,
267+ /// Collect tree entries (title, summary) alongside their NodeIds.
268+ ///
269+ /// Walks the subtree rooted at `node_id` up to `max_depth` levels deep.
270+ /// The `toc_entries[i]` ↔ `node_ids[i]` correspondence is guaranteed,
271+ /// so LLM response indices can be mapped back to real NodeIds.
272+ fn collect_tree_entries (
273+ tree : & DocumentTree ,
274+ node_id : NodeId ,
265275 entries : & mut Vec < ( String , Option < String > ) > ,
266276 node_ids : & mut Vec < NodeId > ,
277+ depth : usize ,
278+ max_depth : usize ,
267279) {
268- // Parse node_id string back to NodeId — but TocNode stores it as Option<String>.
269- // Since we only need the original NodeId from the tree, we store a placeholder.
270- // The actual mapping is handled by the caller (top_branches -> node_ids).
271- // For LLM refinement, we index into node_ids, so we just push in order.
272- entries. push ( ( toc. title . clone ( ) , toc. summary . clone ( ) ) ) ;
273- // Note: node_ids are populated separately by the caller using tree traversal
274- for child in & toc. children {
275- collect_toc_flat ( child, entries, node_ids) ;
280+ if depth > max_depth {
281+ return ;
282+ }
283+ if let Some ( node) = tree. get ( node_id) {
284+ let summary = if node. summary . is_empty ( ) {
285+ None
286+ } else {
287+ Some ( node. summary . clone ( ) )
288+ } ;
289+ entries. push ( ( node. title . clone ( ) , summary) ) ;
290+ node_ids. push ( node_id) ;
291+
292+ for child_id in tree. children ( node_id) {
293+ collect_tree_entries ( tree, child_id, entries, node_ids, depth + 1 , max_depth) ;
294+ }
276295 }
277296}
278297
@@ -296,7 +315,6 @@ struct LocateCandidate {
296315mod tests {
297316 #[ test]
298317 fn test_take_top_n_logic ( ) {
299- // take_top_n is a trivial sort+truncate — verify the ordering contract.
300318 let mut scored: Vec < ( u32 , f32 ) > = vec ! [ ( 0 , 0.1 ) , ( 1 , 0.9 ) , ( 2 , 0.5 ) , ( 3 , 0.3 ) ] ;
301319 scored. sort_by ( |a, b| b. 1 . partial_cmp ( & a. 1 ) . unwrap_or ( std:: cmp:: Ordering :: Equal ) ) ;
302320 scored. truncate ( 2 ) ;
0 commit comments