77
88from codewiki .src .be .dependency_analyzer .models .core import Node
99from codewiki .src .be .llm_services import call_llm
10+ from codewiki .src .be .module_naming import resolve_unique_name , sanitize_module_name
1011from codewiki .src .be .utils import count_tokens
11- from codewiki .src .config import Config , DEFAULT_MIN_MODULES_FOR_SUPER_GROUPING
12+ from codewiki .src .config import (
13+ Config ,
14+ DEFAULT_MAX_LEAF_NODES_PER_CLUSTER ,
15+ DEFAULT_MIN_MODULES_FOR_SUPER_GROUPING ,
16+ )
1217from codewiki .src .be .prompt_template import format_cluster_prompt , format_super_group_prompt
1318
14- Completer = Callable [[str ], str ]
19+ Completer = Callable [[str ], Optional [ str ] ]
1520
1621# When whole-repo mode is chosen but leaf entry points touch fewer than this
1722# fraction of parsed files, warn that coverage depends on agent exploration.
@@ -58,6 +63,198 @@ def get_clustering_input_token_count(
5863 return count_tokens (potential_core_components_with_code )
5964
6065
66+ def _cluster_batch_fits (node_ids : List [str ], config : Config ) -> bool :
67+ """Whether a single LLM clustering call can handle these nodes.
68+
69+ The clustering response must re-emit every component ID verbatim, so the
70+ joined ID list is a direct proxy for output size; keep 2x headroom under
71+ max_tokens for dict syntax, module names/paths, and preamble.
72+ """
73+ max_nodes = getattr (
74+ config , "max_leaf_nodes_per_cluster" , DEFAULT_MAX_LEAF_NODES_PER_CLUSTER
75+ )
76+ if len (node_ids ) > max_nodes :
77+ return False
78+ output_budget = max (2048 , config .max_tokens // 2 )
79+ return count_tokens ("\n " .join (node_ids )) <= output_budget
80+
81+
82+ def partition_leaf_nodes_by_structure (
83+ leaf_nodes : List [str ],
84+ components : Dict [str , Node ],
85+ fits : Callable [[List [str ]], bool ],
86+ ) -> List [List [str ]]:
87+ """Partition leaf nodes into batches that each satisfy ``fits``.
88+
89+ Splits along the directory structure of the nodes' relative paths, then
90+ greedily coalesces path-adjacent small groups so root-level files and tiny
91+ directories don't become their own batches. Deterministic: nodes are
92+ processed in (relative_path, id) order. Returns the input as a single
93+ batch when it already fits.
94+ """
95+ valid = []
96+ for leaf_node in leaf_nodes :
97+ if leaf_node in components :
98+ valid .append (leaf_node )
99+ else :
100+ logger .warning (f"Skipping invalid leaf node '{ leaf_node } ' - not found in components" )
101+ valid .sort (key = lambda node : (components [node ].relative_path , node ))
102+ if not valid or fits (valid ):
103+ return [valid ]
104+
105+ def path_parts (node : str ) -> List [str ]:
106+ return components [node ].relative_path .strip ("/" ).split ("/" )
107+
108+ def chunk (nodes : List [str ]) -> List [List [str ]]:
109+ # Nodes that share one directory/file and still don't fit can only be
110+ # cut into fixed-size slices.
111+ size = len (nodes )
112+ while size > 1 and not fits (nodes [:size ]):
113+ size = (size + 1 ) // 2
114+ logger .warning (
115+ "Splitting %d leaf nodes that share one path into slices of at "
116+ "most %d; directory structure can't divide them further." ,
117+ len (nodes ),
118+ size ,
119+ )
120+ return [nodes [i :i + size ] for i in range (0 , len (nodes ), size )]
121+
122+ def split (nodes : List [str ], depth : int ) -> List [List [str ]]:
123+ by_prefix = defaultdict (list )
124+ for node in nodes :
125+ by_prefix ["/" .join (path_parts (node )[:depth ])].append (node )
126+ groups : List [List [str ]] = []
127+ for prefix in sorted (by_prefix ):
128+ sub = by_prefix [prefix ]
129+ if fits (sub ):
130+ groups .append (sub )
131+ elif any (len (path_parts (node )) > depth for node in sub ):
132+ groups .extend (split (sub , depth + 1 ))
133+ else :
134+ groups .extend (chunk (sub ))
135+ return groups
136+
137+ batches : List [List [str ]] = []
138+ current : List [str ] = []
139+ for group in split (valid , 1 ):
140+ if not current :
141+ current = group
142+ elif fits (current + group ):
143+ current .extend (group )
144+ else :
145+ batches .append (current )
146+ current = group
147+ if current :
148+ batches .append (current )
149+ return batches
150+
151+
152+ def _cluster_via_llm (
153+ leaf_nodes : List [str ],
154+ components : Dict [str , Node ],
155+ config : Config ,
156+ current_module_tree : Dict [str , Any ],
157+ current_module_name : Optional [str ],
158+ module_label : str ,
159+ completer : Optional [Completer ],
160+ ) -> Dict [str , Any ]:
161+ """Run one clustering LLM call over these nodes.
162+
163+ Returns {} for any empty, malformed, or non-dict response instead of
164+ raising, so callers can fall back gracefully.
165+ """
166+ potential_core_components , _ = format_potential_core_components (leaf_nodes , components )
167+ prompt = format_cluster_prompt (potential_core_components , current_module_tree , current_module_name )
168+ if completer is not None :
169+ response = completer (prompt )
170+ else :
171+ response = call_llm (prompt , config , model = config .cluster_model )
172+
173+ if not response :
174+ logger .warning (
175+ "Empty LLM clustering response for %s (provider returned no "
176+ "content, likely output truncation); falling back." ,
177+ module_label ,
178+ )
179+ return {}
180+
181+ try :
182+ if "<GROUPED_COMPONENTS>" not in response or "</GROUPED_COMPONENTS>" not in response :
183+ logger .warning (
184+ "Invalid LLM clustering response for %s: missing <GROUPED_COMPONENTS> "
185+ "tags; falling back. Response preview: %s..." ,
186+ module_label ,
187+ str (response )[:200 ],
188+ )
189+ return {}
190+
191+ response_content = response .split ("<GROUPED_COMPONENTS>" )[1 ].split ("</GROUPED_COMPONENTS>" )[0 ]
192+ module_tree = eval (response_content )
193+
194+ if not isinstance (module_tree , dict ):
195+ logger .error (f"Invalid module tree format - expected dict, got { type (module_tree )} " )
196+ return {}
197+
198+ except Exception as e :
199+ logger .warning (
200+ "Failed to parse LLM clustering response for %s; falling back. "
201+ "Error: %s. Response preview: %s..." ,
202+ module_label ,
203+ e ,
204+ str (response )[:200 ],
205+ )
206+ logger .error (f"Traceback: { traceback .format_exc ()} " )
207+ return {}
208+
209+ return module_tree
210+
211+
212+ def _merge_module_trees (target : Dict [str , Any ], addition : Dict [str , Any ]) -> None :
213+ """Merge one batch's module dict into the accumulated tree in place.
214+
215+ Batches cluster independently, so two of them may propose the same module
216+ name for the same concept; merge their components rather than renaming
217+ (dedupe_module_tree_names still guards anything unexpected later).
218+ """
219+ for name , info in addition .items ():
220+ if name not in target :
221+ target [name ] = info
222+ continue
223+ existing = target [name ]
224+ if not isinstance (existing , dict ) or not isinstance (info , dict ):
225+ logger .warning (
226+ "Cannot merge module '%s' from another batch; keeping the first version." ,
227+ name ,
228+ )
229+ continue
230+ seen = set (existing .get ("components" , []))
231+ merged = list (existing .get ("components" , []))
232+ for component in info .get ("components" , []):
233+ if component not in seen :
234+ seen .add (component )
235+ merged .append (component )
236+ existing ["components" ] = merged
237+ paths = [existing .get ("path" , "" ), info .get ("path" , "" )]
238+ existing ["path" ] = _common_path_prefix (paths ) if all (paths ) else ""
239+ logger .info (
240+ "Module '%s' was produced by multiple clustering batches; merged "
241+ "into %d components." ,
242+ name ,
243+ len (merged ),
244+ )
245+
246+
247+ def _batch_fallback_name (
248+ batch : List [str ], components : Dict [str , Node ], existing : Dict [str , Any ]
249+ ) -> str :
250+ """Directory-derived module name for a batch whose LLM clustering failed."""
251+ prefix = _common_path_prefix (
252+ [components [node ].relative_path for node in batch if node in components ]
253+ )
254+ name = sanitize_module_name (prefix .replace ("/" , "_" )) if prefix else "root"
255+ return resolve_unique_name (name , None , set (existing ))
256+
257+
61258def cluster_modules (
62259 leaf_nodes : List [str ],
63260 components : Dict [str , Node ],
@@ -77,7 +274,7 @@ def cluster_modules(
77274 subscription-mode (caw) routing. If ``None``, falls back to
78275 ``call_llm`` for backward compatibility with direct callers.
79276 """
80- potential_core_components , potential_core_components_with_code = (
277+ _ , potential_core_components_with_code = (
81278 format_potential_core_components (leaf_nodes , components )
82279 )
83280 input_tokens = count_tokens (potential_core_components_with_code )
@@ -118,46 +315,68 @@ def cluster_modules(
118315 )
119316 return {}
120317
121- prompt = format_cluster_prompt (potential_core_components , current_module_tree , current_module_name )
122318 logger .info (
123319 "Requesting LLM module clustering for %s because %d tokens exceed the %d-token threshold." ,
124320 module_label ,
125321 input_tokens ,
126322 threshold ,
127323 )
128- if completer is not None :
129- response = completer (prompt )
130- else :
131- response = call_llm (prompt , config , model = config .cluster_model )
132324
133- #parse the response
134- try :
135- if "<GROUPED_COMPONENTS>" not in response or "</GROUPED_COMPONENTS>" not in response :
136- logger .warning (
137- "Invalid LLM clustering response for %s: missing <GROUPED_COMPONENTS> "
138- "tags; falling back to whole-module documentation. Response preview: %s..." ,
139- module_label ,
140- response [:200 ],
141- )
142- return {}
143-
144- response_content = response .split ("<GROUPED_COMPONENTS>" )[1 ].split ("</GROUPED_COMPONENTS>" )[0 ]
145- module_tree = eval (response_content )
146-
147- if not isinstance (module_tree , dict ):
148- logger .error (f"Invalid module tree format - expected dict, got { type (module_tree )} " )
325+ batches = partition_leaf_nodes_by_structure (
326+ leaf_nodes , components , lambda ids : _cluster_batch_fits (ids , config )
327+ )
328+
329+ if len (batches ) == 1 :
330+ module_tree = _cluster_via_llm (
331+ batches [0 ],
332+ components ,
333+ config ,
334+ current_module_tree ,
335+ current_module_name ,
336+ module_label ,
337+ completer ,
338+ )
339+ if not module_tree :
149340 return {}
150-
151- except Exception as e :
152- logger .warning (
153- "Failed to parse LLM clustering response for %s; falling back to "
154- "whole-module documentation. Error: %s. Response preview: %s..." ,
341+ else :
342+ logger .info (
343+ "Partitioned %d leaf nodes for %s into %d structure-based batches for clustering." ,
344+ len (leaf_nodes ),
155345 module_label ,
156- e ,
157- response [:200 ],
346+ len (batches ),
158347 )
159- logger .error (f"Traceback: { traceback .format_exc ()} " )
160- return {}
348+ module_tree = {}
349+ for i , batch in enumerate (batches , 1 ):
350+ batch_label = f"{ module_label } (batch { i } /{ len (batches )} )"
351+ # Each batch gets the *unmodified* current_module_tree: passing the
352+ # accumulating merge would flip format_cluster_prompt into its
353+ # module-level variant mid-partition.
354+ partial = _cluster_via_llm (
355+ batch ,
356+ components ,
357+ config ,
358+ current_module_tree ,
359+ current_module_name ,
360+ batch_label ,
361+ completer ,
362+ )
363+ if not partial :
364+ name = _batch_fallback_name (batch , components , module_tree )
365+ partial = {
366+ name : {
367+ "path" : _common_path_prefix (
368+ [components [n ].relative_path for n in batch if n in components ]
369+ ),
370+ "components" : list (batch ),
371+ }
372+ }
373+ logger .warning (
374+ "Clustering failed for %s; keeping its %d components as fallback module '%s'." ,
375+ batch_label ,
376+ len (batch ),
377+ name ,
378+ )
379+ _merge_module_trees (module_tree , partial )
161380
162381 # check if the module tree is valid
163382 if len (module_tree ) <= 1 :
@@ -182,7 +401,7 @@ def cluster_modules(
182401 for key in current_module_path :
183402 value = value [key ]["children" ]
184403 for module_name , module_info in module_tree .items ():
185- del module_info [ "path" ]
404+ module_info . pop ( "path" , None )
186405 value [module_name ] = module_info
187406
188407 for module_name , module_info in module_tree .items ():
@@ -226,12 +445,18 @@ def _common_path_prefix(paths: List[str]) -> str:
226445 return "/" .join (common )
227446
228447
229- def _parse_super_group_response (response : str ) -> Optional [Dict [str , Any ]]:
448+ def _parse_super_group_response (response : Optional [str ]) -> Optional [Dict [str , Any ]]:
449+ if not response :
450+ logger .warning (
451+ "Empty super-grouping response (provider returned no content, "
452+ "likely output truncation); keeping the flat module tree."
453+ )
454+ return None
230455 if "<GROUPED_MODULES>" not in response or "</GROUPED_MODULES>" not in response :
231456 logger .warning (
232457 "Invalid super-grouping response: missing <GROUPED_MODULES> tags; "
233458 "keeping the flat module tree. Response preview: %s..." ,
234- response [:200 ],
459+ str ( response ) [:200 ],
235460 )
236461 return None
237462 try :
@@ -243,7 +468,7 @@ def _parse_super_group_response(response: str) -> Optional[Dict[str, Any]]:
243468 "Failed to parse super-grouping response; keeping the flat module "
244469 "tree. Error: %s. Response preview: %s..." ,
245470 e ,
246- response [:200 ],
471+ str ( response ) [:200 ],
247472 )
248473 return None
249474 if not isinstance (grouping , dict ):
0 commit comments