Skip to content

Commit 868048c

Browse files
anhnh2002claude
andcommitted
Partition huge clustering inputs by repo structure; survive empty LLM responses
The clustering response must re-emit every leaf-node ID verbatim, so repos with many leaf nodes (electron: 1085) blow past max_tokens; the proxy then returns content: null and cluster_modules crashed on the None (twice: the tag check and the except handler's response[:200]) instead of falling back. - cluster_modules now partitions leaf nodes along directory structure into batches capped by max_leaf_nodes_per_cluster (default 600) and an output token budget (max_tokens // 2), clusters each batch separately, and merges the results into one flat top level for super_group_modules to organize. A failed batch degrades to a directory-named fallback module. - call_llm logs finish_reason=length truncation and may return None; all consumers (clustering, super-grouping, overview generation) now treat an empty response as a graceful fallback instead of a TypeError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d94cacf commit 868048c

6 files changed

Lines changed: 580 additions & 44 deletions

File tree

‎codewiki/cli/adapters/doc_generator.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ def generate(self) -> DocumentationJob:
145145
max_tokens=self.config.get('max_tokens', 32768),
146146
max_token_per_module=self.config.get('max_token_per_module', 36369),
147147
max_token_per_leaf_module=self.config.get('max_token_per_leaf_module', 16000),
148+
max_leaf_nodes_per_cluster=self.config.get('max_leaf_nodes_per_cluster', 600),
148149
max_depth=self.config.get('max_depth', 2),
149150
agent_instructions=self.config.get('agent_instructions'),
150151
use_gitignore=self.config.get('use_gitignore', True),

‎codewiki/src/be/cluster_modules.py‎

Lines changed: 262 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77

88
from codewiki.src.be.dependency_analyzer.models.core import Node
99
from codewiki.src.be.llm_services import call_llm
10+
from codewiki.src.be.module_naming import resolve_unique_name, sanitize_module_name
1011
from 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+
)
1217
from 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+
61258
def 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):

‎codewiki/src/be/documentation_generator.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ async def generate_parent_module_docs(self, module_path: List[str],
287287

288288
try:
289289
parent_docs = self.backend.complete(prompt)
290+
if not parent_docs:
291+
raise RuntimeError(
292+
f"LLM returned empty content for {module_name} overview "
293+
f"(possible output truncation at max_tokens)"
294+
)
290295

291296
# Parse and save parent documentation. Subscription-CLI backends
292297
# (claude-code / codex) sometimes ignore the <OVERVIEW> wrapper and

0 commit comments

Comments
 (0)