mcp-vector-search 0.12.6__py3-none-any.whl → 1.1.22__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +111 -0
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +74 -0
  7. mcp_vector_search/analysis/collectors/base.py +164 -0
  8. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  9. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  10. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  11. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  12. mcp_vector_search/analysis/collectors/smells.py +325 -0
  13. mcp_vector_search/analysis/debt.py +516 -0
  14. mcp_vector_search/analysis/interpretation.py +685 -0
  15. mcp_vector_search/analysis/metrics.py +414 -0
  16. mcp_vector_search/analysis/reporters/__init__.py +7 -0
  17. mcp_vector_search/analysis/reporters/console.py +646 -0
  18. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  19. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  20. mcp_vector_search/analysis/storage/__init__.py +93 -0
  21. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  22. mcp_vector_search/analysis/storage/schema.py +245 -0
  23. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  24. mcp_vector_search/analysis/trends.py +308 -0
  25. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  26. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  27. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  28. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  29. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  30. mcp_vector_search/cli/commands/analyze.py +1062 -0
  31. mcp_vector_search/cli/commands/chat.py +1455 -0
  32. mcp_vector_search/cli/commands/index.py +621 -5
  33. mcp_vector_search/cli/commands/index_background.py +467 -0
  34. mcp_vector_search/cli/commands/init.py +13 -0
  35. mcp_vector_search/cli/commands/install.py +597 -335
  36. mcp_vector_search/cli/commands/install_old.py +8 -4
  37. mcp_vector_search/cli/commands/mcp.py +78 -6
  38. mcp_vector_search/cli/commands/reset.py +68 -26
  39. mcp_vector_search/cli/commands/search.py +224 -8
  40. mcp_vector_search/cli/commands/setup.py +1184 -0
  41. mcp_vector_search/cli/commands/status.py +339 -5
  42. mcp_vector_search/cli/commands/uninstall.py +276 -357
  43. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  44. mcp_vector_search/cli/commands/visualize/cli.py +292 -0
  45. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  46. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  47. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
  48. mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
  49. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  50. mcp_vector_search/cli/commands/visualize/server.py +600 -0
  51. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  52. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  53. mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
  54. mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
  55. mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
  56. mcp_vector_search/cli/didyoumean.py +27 -2
  57. mcp_vector_search/cli/main.py +127 -160
  58. mcp_vector_search/cli/output.py +158 -13
  59. mcp_vector_search/config/__init__.py +4 -0
  60. mcp_vector_search/config/default_thresholds.yaml +52 -0
  61. mcp_vector_search/config/settings.py +12 -0
  62. mcp_vector_search/config/thresholds.py +273 -0
  63. mcp_vector_search/core/__init__.py +16 -0
  64. mcp_vector_search/core/auto_indexer.py +3 -3
  65. mcp_vector_search/core/boilerplate.py +186 -0
  66. mcp_vector_search/core/config_utils.py +394 -0
  67. mcp_vector_search/core/database.py +406 -94
  68. mcp_vector_search/core/embeddings.py +24 -0
  69. mcp_vector_search/core/exceptions.py +11 -0
  70. mcp_vector_search/core/git.py +380 -0
  71. mcp_vector_search/core/git_hooks.py +4 -4
  72. mcp_vector_search/core/indexer.py +632 -54
  73. mcp_vector_search/core/llm_client.py +756 -0
  74. mcp_vector_search/core/models.py +91 -1
  75. mcp_vector_search/core/project.py +17 -0
  76. mcp_vector_search/core/relationships.py +473 -0
  77. mcp_vector_search/core/scheduler.py +11 -11
  78. mcp_vector_search/core/search.py +179 -29
  79. mcp_vector_search/mcp/server.py +819 -9
  80. mcp_vector_search/parsers/python.py +285 -5
  81. mcp_vector_search/utils/__init__.py +2 -0
  82. mcp_vector_search/utils/gitignore.py +0 -3
  83. mcp_vector_search/utils/gitignore_updater.py +212 -0
  84. mcp_vector_search/utils/monorepo.py +66 -4
  85. mcp_vector_search/utils/timing.py +10 -6
  86. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
  87. mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
  88. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
  89. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
  90. mcp_vector_search/cli/commands/visualize.py +0 -1467
  91. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  92. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,469 @@
1
+ """Layout calculation algorithms for visualization V2.0.
2
+
3
+ This module implements layout algorithms for hierarchical list-based navigation:
4
+ - List Layout: Vertical alphabetical positioning of root nodes
5
+ - Fan Layout: Horizontal arc (180°) for expanded directory/file children
6
+
7
+ Design Principles:
8
+ - Deterministic: Same input → same output (no randomness)
9
+ - Adaptive: Radius and spacing adjust to child count
10
+ - Sorted: Alphabetical order (directories first) for predictability
11
+ - Performance: O(n) time complexity, <50ms for 100 nodes
12
+
13
+ Reference: docs/development/VISUALIZATION_ARCHITECTURE_V2.md
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Any
20
+
21
+ from loguru import logger
22
+
23
+
24
+ def calculate_list_layout(
25
+ nodes: list[dict[str, Any]], canvas_width: int, canvas_height: int
26
+ ) -> dict[str, tuple[float, float]]:
27
+ """Calculate vertical list positions for nodes.
28
+
29
+ Positions nodes in a vertical list with fixed spacing, sorted
30
+ alphabetically with directories appearing before files.
31
+
32
+ Design Decision: Vertical list for alphabetical browsing
33
+
34
+ Rationale: Users expect alphabetical file/folder listings (like Finder, Explorer).
35
+ Vertical layout mirrors familiar file manager interfaces.
36
+
37
+ Trade-offs:
38
+ - Familiarity: Matches OS file managers vs. novel layouts
39
+ - Vertical space: Requires scrolling for many files vs. compact layouts
40
+ - Readability: One item per line is clearest vs. dense grids
41
+
42
+ Args:
43
+ nodes: List of node dictionaries with 'id', 'name', and 'type' keys
44
+ canvas_width: SVG viewport width in pixels
45
+ canvas_height: SVG viewport height in pixels
46
+
47
+ Returns:
48
+ Dictionary mapping node_id -> (x, y) position
49
+
50
+ Time Complexity: O(n log n) where n = number of nodes (due to sorting)
51
+ Space Complexity: O(n) for position dictionary
52
+
53
+ Example:
54
+ >>> nodes = [
55
+ ... {'id': 'dir1', 'name': 'src', 'type': 'directory'},
56
+ ... {'id': 'file1', 'name': 'main.py', 'type': 'file'}
57
+ ... ]
58
+ >>> positions = calculate_list_layout(nodes, 1920, 1080)
59
+ >>> positions['dir1'] # (x, y) coordinates
60
+ (100, 490.0)
61
+ >>> positions['file1']
62
+ (100, 540.0)
63
+ """
64
+ if not nodes:
65
+ logger.debug("No nodes to layout")
66
+ return {}
67
+
68
+ # Sort alphabetically (directories first, then files)
69
+ sorted_nodes = sorted(
70
+ nodes,
71
+ key=lambda n: (
72
+ 0 if n.get("type") == "directory" else 1, # Directories first
73
+ (n.get("name") or "").lower(), # Then alphabetical
74
+ ),
75
+ )
76
+
77
+ # Layout parameters
78
+ node_height = 50 # Vertical space per node (icon + label)
79
+ x_position = 100 # Left margin
80
+ total_height = len(sorted_nodes) * node_height
81
+
82
+ # Center vertically in viewport
83
+ start_y = (canvas_height - total_height) / 2
84
+
85
+ # Calculate positions
86
+ positions = {}
87
+ for i, node in enumerate(sorted_nodes):
88
+ node_id = node.get("id")
89
+ if not node_id:
90
+ logger.warning(f"Node missing 'id': {node}")
91
+ continue
92
+
93
+ y_position = start_y + (i * node_height)
94
+ positions[node_id] = (x_position, y_position)
95
+
96
+ logger.debug(
97
+ f"List layout: {len(positions)} nodes, "
98
+ f"height={total_height}px, "
99
+ f"start_y={start_y:.1f}"
100
+ )
101
+
102
+ return positions
103
+
104
+
105
+ def calculate_fan_layout(
106
+ parent_pos: tuple[float, float],
107
+ children: list[dict[str, Any]],
108
+ canvas_width: int,
109
+ canvas_height: int,
110
+ fan_type: str = "horizontal",
111
+ ) -> dict[str, tuple[float, float]]:
112
+ """Calculate horizontal fan positions for child nodes.
113
+
114
+ Arranges children in a 180° arc (horizontal fan) emanating from parent node.
115
+ Radius adapts to child count (200-400px range). Children sorted alphabetically.
116
+
117
+ Design Decision: Horizontal fan for directory expansion
118
+
119
+ Rationale: Horizontal layout provides intuitive left-to-right navigation,
120
+ matching Western reading patterns. 180° arc provides clear parent-child
121
+ visual connection while maintaining adequate spacing between nodes.
122
+
123
+ Trade-offs:
124
+ - Horizontal space: Limited by viewport width vs. vertical fans
125
+ - Readability: Left-to-right natural vs. radial (360°) all directions
126
+ - Density: 180° arc provides more space per node vs. full circle
127
+ - Visual connection: Clear parent→child line vs. grid layout
128
+
129
+ Alternatives Considered:
130
+ 1. Full circle (360°): Rejected - confusing orientation, no clear "top"
131
+ 2. Vertical fan: Rejected - conflicts with list view scrolling
132
+ 3. Grid layout: Rejected - loses parent-child visual connection
133
+ 4. Tree layout: Rejected - doesn't support sibling switching gracefully
134
+
135
+ Extension Points: Arc angle parameterizable (currently 180°) for different
136
+ density preferences. Radius calculation can use different formulas.
137
+
138
+ Args:
139
+ parent_pos: (x, y) coordinates of parent node
140
+ children: List of child node dictionaries with 'id', 'name', 'type'
141
+ canvas_width: SVG viewport width in pixels
142
+ canvas_height: SVG viewport height in pixels
143
+ fan_type: Type of fan ("horizontal" for 180° arc)
144
+
145
+ Returns:
146
+ Dictionary mapping child_id -> (x, y) position
147
+
148
+ Time Complexity: O(n log n) where n = number of children (sorting)
149
+ Space Complexity: O(n) for position dictionary
150
+
151
+ Performance:
152
+ - Expected: <10ms for 50 children
153
+ - Tested: <50ms for 500 children
154
+
155
+ Example:
156
+ >>> parent = (500, 400)
157
+ >>> children = [
158
+ ... {'id': 'c1', 'name': 'utils', 'type': 'directory'},
159
+ ... {'id': 'c2', 'name': 'tests', 'type': 'directory'},
160
+ ... {'id': 'c3', 'name': 'main.py', 'type': 'file'}
161
+ ... ]
162
+ >>> positions = calculate_fan_layout(parent, children, 1920, 1080)
163
+ >>> # Children positioned in 180° arc from left to right
164
+ >>> len(positions)
165
+ 3
166
+ """
167
+ if not children:
168
+ logger.debug("No children to layout in fan")
169
+ return {}
170
+
171
+ parent_x, parent_y = parent_pos
172
+
173
+ # Calculate adaptive radius based on child count
174
+ # More children → larger radius to prevent overlap
175
+ base_radius = 200 # Minimum radius
176
+ max_radius = 400 # Maximum radius
177
+ spacing_per_child = 60 # Horizontal space needed per child
178
+
179
+ # Arc length = radius * angle (in radians)
180
+ # For 180° arc: arc_length = radius * π
181
+ # We want: arc_length >= num_children * spacing_per_child
182
+ # Therefore: radius >= (num_children * spacing_per_child) / π
183
+ calculated_radius = (len(children) * spacing_per_child) / math.pi
184
+ radius = max(base_radius, min(calculated_radius, max_radius))
185
+
186
+ # Horizontal fan: 180° arc from left to right
187
+ # Start at π radians (180°, left side)
188
+ # End at 0 radians (0°, right side)
189
+ start_angle = math.pi # Left (180°)
190
+ end_angle = 0 # Right (0°)
191
+ angle_range = start_angle - end_angle # π radians
192
+
193
+ # Sort children (directories first, then alphabetical)
194
+ sorted_children = sorted(
195
+ children,
196
+ key=lambda n: (
197
+ 0 if n.get("type") == "directory" else 1, # Directories first
198
+ (n.get("name") or "").lower(), # Then alphabetical
199
+ ),
200
+ )
201
+
202
+ # Calculate positions
203
+ positions = {}
204
+ num_children = len(sorted_children)
205
+
206
+ for i, child in enumerate(sorted_children):
207
+ child_id = child.get("id")
208
+ if not child_id:
209
+ logger.warning(f"Child node missing 'id': {child}")
210
+ continue
211
+
212
+ # Calculate angle for this child
213
+ if num_children == 1:
214
+ # Single child: center of arc (90°)
215
+ angle = math.pi / 2
216
+ else:
217
+ # Distribute evenly across arc
218
+ # angle = start_angle - (progress * angle_range)
219
+ # progress = i / (num_children - 1) goes from 0 to 1
220
+ progress = i / (num_children - 1)
221
+ angle = start_angle - (progress * angle_range)
222
+
223
+ # Convert polar coordinates (radius, angle) to cartesian (x, y)
224
+ x = parent_x + radius * math.cos(angle)
225
+ y = parent_y + radius * math.sin(angle)
226
+
227
+ positions[child_id] = (x, y)
228
+
229
+ logger.debug(
230
+ f"Fan layout: {len(positions)} children, "
231
+ f"radius={radius:.1f}px, "
232
+ f"arc={math.degrees(angle_range):.0f}°"
233
+ )
234
+
235
+ return positions
236
+
237
+
238
+ def calculate_compact_folder_layout(
239
+ parent_pos: tuple[float, float],
240
+ children: list[dict[str, Any]],
241
+ canvas_width: int,
242
+ canvas_height: int,
243
+ ) -> dict[str, tuple[float, float]]:
244
+ """Calculate compact horizontal folder layout for directory children.
245
+
246
+ Arranges children horizontally in a straight line to the right of parent,
247
+ with fixed 800px spacing. Designed for directory-only views where vertical
248
+ space is limited.
249
+
250
+ This layout is optimized for horizontal directory navigation without
251
+ the arc geometry of fan layout.
252
+
253
+ Args:
254
+ parent_pos: (x, y) coordinates of parent node
255
+ children: List of child node dictionaries with 'id', 'name', 'type'
256
+ canvas_width: SVG viewport width in pixels
257
+ canvas_height: SVG viewport height in pixels
258
+
259
+ Returns:
260
+ Dictionary mapping child_id -> (x, y) position
261
+
262
+ Time Complexity: O(n log n) where n = number of children (sorting)
263
+ Space Complexity: O(n) for position dictionary
264
+ """
265
+ if not children:
266
+ logger.debug("No children to layout in compact folder mode")
267
+ return {}
268
+
269
+ parent_x, parent_y = parent_pos
270
+
271
+ # Compact folder layout parameters
272
+ horizontal_offset = 800 # Fixed horizontal spacing from parent
273
+ vertical_spacing = 50 # Vertical spacing between children
274
+
275
+ # Sort children (directories first, then alphabetical)
276
+ sorted_children = sorted(
277
+ children,
278
+ key=lambda n: (
279
+ 0 if n.get("type") == "directory" else 1,
280
+ (n.get("name") or "").lower(),
281
+ ),
282
+ )
283
+
284
+ # Calculate vertical centering
285
+ total_height = len(sorted_children) * vertical_spacing
286
+ start_y = parent_y - (total_height / 2)
287
+
288
+ # Calculate positions
289
+ positions = {}
290
+ for i, child in enumerate(sorted_children):
291
+ child_id = child.get("id")
292
+ if not child_id:
293
+ logger.warning(f"Child node missing 'id': {child}")
294
+ continue
295
+
296
+ x = parent_x + horizontal_offset
297
+ y = start_y + (i * vertical_spacing)
298
+
299
+ positions[child_id] = (x, y)
300
+
301
+ logger.debug(
302
+ f"Compact folder layout: {len(positions)} children, "
303
+ f"offset={horizontal_offset}px, "
304
+ f"spacing={vertical_spacing}px"
305
+ )
306
+
307
+ return positions
308
+
309
+
310
+ def calculate_tree_layout(
311
+ nodes: list[dict[str, Any]],
312
+ expansion_path: list[str],
313
+ canvas_width: int,
314
+ canvas_height: int,
315
+ level_spacing: int = 300,
316
+ node_spacing: int = 50,
317
+ ) -> dict[str, tuple[float, float]]:
318
+ """Calculate positions for rightward tree layout.
319
+
320
+ Design Decision: Rightward Tree Layout
321
+
322
+ Rationale: Traditional file explorer tree layout matches user expectations
323
+ for hierarchical file system navigation. Nodes expand horizontally to the
324
+ right (like Finder/Explorer) rather than radially, providing clear depth
325
+ visualization and natural left-to-right reading flow.
326
+
327
+ Trade-offs:
328
+ - Familiarity: Matches OS file explorers vs. novel graph layouts
329
+ - Depth visibility: Clear level separation vs. radial compactness
330
+ - Horizontal space: Requires wider viewport vs. vertical layouts
331
+ - Simplicity: No angle calculations vs. fan layouts
332
+
333
+ Args:
334
+ nodes: All nodes in the graph
335
+ expansion_path: List of expanded node IDs (root → current)
336
+ canvas_width: Canvas width
337
+ canvas_height: Canvas height
338
+ level_spacing: Horizontal distance between tree levels (default 300px)
339
+ node_spacing: Vertical distance between sibling nodes (default 50px)
340
+
341
+ Returns:
342
+ Dict mapping node_id → (x, y) position
343
+
344
+ Layout Logic:
345
+ - Root nodes: x=100, y=vertical list (50px spacing)
346
+ - Level 1 (children of expanded root): x=400, y=vertical under parent
347
+ - Level 2: x=700, etc.
348
+ - Center view by shifting all x positions left as tree grows
349
+
350
+ Time Complexity: O(n log n) where n = number of nodes (sorting)
351
+ Space Complexity: O(n) for position dictionary and level grouping
352
+
353
+ Example:
354
+ >>> nodes = [
355
+ ... {'id': 'root1', 'name': 'src', 'type': 'directory', 'parent': None},
356
+ ... {'id': 'child1', 'name': 'main.py', 'type': 'file', 'parent': 'root1'}
357
+ ... ]
358
+ >>> expansion_path = ['root1']
359
+ >>> positions = calculate_tree_layout(nodes, expansion_path, 1920, 1080)
360
+ >>> # root1 at x=100, child1 at x=400
361
+ """
362
+ if not nodes:
363
+ logger.debug("No nodes to layout in tree")
364
+ return {}
365
+
366
+ positions = {}
367
+
368
+ # Group nodes by depth level based on expansion path
369
+ levels = _build_tree_levels(nodes, expansion_path)
370
+
371
+ if not levels:
372
+ logger.warning("No levels built from expansion path")
373
+ return {}
374
+
375
+ # Calculate positions level by level
376
+ for level_index, level_nodes in enumerate(levels):
377
+ x = 100 + (level_index * level_spacing)
378
+
379
+ # Sort alphabetically (directories first)
380
+ sorted_nodes = sorted(
381
+ level_nodes,
382
+ key=lambda n: (
383
+ 0 if n.get("type") == "directory" else 1,
384
+ (n.get("name") or "").lower(),
385
+ ),
386
+ )
387
+
388
+ # Position vertically
389
+ total_height = len(sorted_nodes) * node_spacing
390
+ start_y = (canvas_height - total_height) / 2
391
+
392
+ for i, node in enumerate(sorted_nodes):
393
+ node_id = node.get("id")
394
+ if not node_id:
395
+ logger.warning(f"Node missing 'id': {node}")
396
+ continue
397
+
398
+ y = start_y + (i * node_spacing)
399
+ positions[node_id] = (x, y)
400
+
401
+ # Center the view (shift left based on deepest level)
402
+ max_level = len(levels) - 1
403
+ shift_x = (max_level * level_spacing) // 2
404
+
405
+ for node_id in positions:
406
+ x, y = positions[node_id]
407
+ positions[node_id] = (x - shift_x, y)
408
+
409
+ logger.debug(
410
+ f"Tree layout: {len(positions)} nodes across {len(levels)} levels, "
411
+ f"shift_x={shift_x}px"
412
+ )
413
+
414
+ return positions
415
+
416
+
417
+ def _build_tree_levels(
418
+ nodes: list[dict[str, Any]], expansion_path: list[str]
419
+ ) -> list[list[dict[str, Any]]]:
420
+ """Build tree levels based on expansion path.
421
+
422
+ Returns:
423
+ List of levels, where each level is a list of nodes
424
+ Level 0: Root nodes (no parent)
425
+ Level 1: Children of expanded root node
426
+ Level 2: Children of expanded level-1 node
427
+ etc.
428
+
429
+ Example:
430
+ >>> nodes = [
431
+ ... {'id': 'a', 'parent': None},
432
+ ... {'id': 'b', 'parent': 'a'},
433
+ ... {'id': 'c', 'parent': 'a'}
434
+ ... ]
435
+ >>> expansion_path = ['a']
436
+ >>> levels = _build_tree_levels(nodes, expansion_path)
437
+ >>> len(levels)
438
+ 2
439
+ >>> len(levels[0]) # Root level
440
+ 1
441
+ >>> len(levels[1]) # Children of 'a'
442
+ 2
443
+ """
444
+ levels: list[list[dict[str, Any]]] = []
445
+
446
+ # Build node ID to node map for quick lookup
447
+ node_map = {node.get("id"): node for node in nodes if node.get("id")}
448
+
449
+ # Level 0: Root nodes (no parent or parent not in graph)
450
+ root_nodes = [
451
+ node
452
+ for node in nodes
453
+ if not node.get("parent") or node.get("parent") not in node_map
454
+ ]
455
+ levels.append(root_nodes)
456
+
457
+ # Subsequent levels: Children of expanded nodes in expansion path
458
+ for expanded_node_id in expansion_path:
459
+ # Find children of this expanded node
460
+ children = [node for node in nodes if node.get("parent") == expanded_node_id]
461
+
462
+ if children:
463
+ levels.append(children)
464
+
465
+ logger.debug(
466
+ f"Built {len(levels)} tree levels from expansion path: {expansion_path}"
467
+ )
468
+
469
+ return levels