@@ -70,6 +70,11 @@ class GraphConfig(BaseModel):
7070 le = 20 ,
7171 description = "Maximum concurrent LLM calls during graph expansion (helps avoid rate limits)" ,
7272 )
73+ max_tokens : int = Field (
74+ default = DEFAULT_MAX_TOKENS ,
75+ ge = 1 ,
76+ description = "Maximum tokens for topic generation LLM calls" ,
77+ )
7378 base_url : str | None = Field (
7479 default = None ,
7580 description = "Base URL for API endpoint (e.g., custom OpenAI-compatible servers)" ,
@@ -156,6 +161,7 @@ def __init__(self, **kwargs):
156161 self .degree = self .config .degree
157162 self .depth = self .config .depth
158163 self .max_concurrent = self .config .max_concurrent
164+ self .max_tokens = self .config .max_tokens
159165 self .prompt_style = self .config .prompt_style
160166
161167 # Initialize LLM client
@@ -211,6 +217,139 @@ def add_edge(self, parent_id: int, child_id: int) -> None:
211217 if parent_node not in child_node .parents :
212218 child_node .parents .append (parent_node )
213219
220+ def find_node_by_uuid (self , uuid : str ) -> Node | None :
221+ """Find a node by its UUID.
222+
223+ Args:
224+ uuid: The UUID string to search for.
225+
226+ Returns:
227+ The Node if found, None otherwise.
228+ """
229+ for node in self .nodes .values ():
230+ if node .metadata .get ("uuid" ) == uuid :
231+ return node
232+ return None
233+
234+ def remove_node (self , node_id : int ) -> None :
235+ """Remove a single node from the graph, cleaning up bidirectional references.
236+
237+ Does not remove children — use remove_subtree() for cascading removal.
238+
239+ Args:
240+ node_id: The ID of the node to remove.
241+
242+ Raises:
243+ ValueError: If node_id is the root node or does not exist.
244+ """
245+ if node_id == self .root .id :
246+ raise ValueError ("Cannot remove the root node" ) # noqa: TRY003
247+ node = self .nodes .get (node_id )
248+ if node is None :
249+ raise ValueError (f"Node { node_id } not found in graph" ) # noqa: TRY003
250+
251+ for parent in node .parents :
252+ if node in parent .children :
253+ parent .children .remove (node )
254+
255+ for child in node .children :
256+ if node in child .parents :
257+ child .parents .remove (node )
258+
259+ del self .nodes [node_id ]
260+
261+ def remove_subtree (self , node_id : int ) -> list [int ]:
262+ """Remove a node and all its descendants from the graph.
263+
264+ Args:
265+ node_id: The ID of the node to remove (along with all descendants).
266+
267+ Returns:
268+ List of removed node IDs.
269+
270+ Raises:
271+ ValueError: If node_id is the root node or does not exist.
272+ """
273+ if node_id == self .root .id :
274+ raise ValueError ("Cannot remove the root node" ) # noqa: TRY003
275+ node = self .nodes .get (node_id )
276+ if node is None :
277+ raise ValueError (f"Node { node_id } not found in graph" ) # noqa: TRY003
278+
279+ # BFS to collect all descendant node IDs
280+ to_remove : list [int ] = []
281+ queue = [node ]
282+ visited : set [int ] = set ()
283+ while queue :
284+ current = queue .pop (0 )
285+ if current .id in visited :
286+ continue
287+ visited .add (current .id )
288+ to_remove .append (current .id )
289+ for child in current .children :
290+ if child .id not in visited :
291+ queue .append (child )
292+
293+ # Remove in reverse order (leaves first)
294+ for nid in reversed (to_remove ):
295+ self .remove_node (nid )
296+
297+ return to_remove
298+
299+ def prune_at_level (self , max_depth : int ) -> list [int ]:
300+ """Remove all nodes below the given depth level.
301+
302+ Nodes at exactly max_depth become leaf nodes. Root is depth 0.
303+
304+ Args:
305+ max_depth: Maximum depth to keep (inclusive).
306+ 0 = keep only root, 1 = root and its children, etc.
307+
308+ Returns:
309+ List of removed node IDs.
310+
311+ Raises:
312+ ValueError: If max_depth is negative.
313+ """
314+ if max_depth < 0 :
315+ raise ValueError ("max_depth must be non-negative" ) # noqa: TRY003
316+
317+ # BFS from root to compute node depths
318+ node_depths : dict [int , int ] = {}
319+ queue : list [tuple [Node , int ]] = [(self .root , 0 )]
320+ visited : set [int ] = set ()
321+ while queue :
322+ current , depth = queue .pop (0 )
323+ if current .id in visited :
324+ continue
325+ visited .add (current .id )
326+ node_depths [current .id ] = depth
327+ for child in current .children :
328+ if child .id not in visited :
329+ queue .append ((child , depth + 1 ))
330+
331+ to_remove_set = {nid for nid , d in node_depths .items () if d > max_depth }
332+
333+ # Sever children links from boundary nodes
334+ for nid , d in node_depths .items ():
335+ if d == max_depth :
336+ self .nodes [nid ].children = [
337+ c for c in self .nodes [nid ].children if c .id not in to_remove_set
338+ ]
339+
340+ # Remove deeper nodes
341+ for nid in to_remove_set :
342+ node = self .nodes [nid ]
343+ for parent in node .parents :
344+ if node in parent .children :
345+ parent .children .remove (node )
346+ for child in node .children :
347+ if node in child .parents :
348+ child .parents .remove (node )
349+ del self .nodes [nid ]
350+
351+ return list (to_remove_set )
352+
214353 def to_pydantic (self ) -> GraphModel :
215354 """Converts the runtime graph to its Pydantic model representation."""
216355 return GraphModel (
@@ -237,6 +376,13 @@ def save(self, save_path: str) -> None:
237376 with open (save_path , "w" ) as f :
238377 f .write (self .to_json ())
239378
379+ # Save failed generations if any
380+ if self .failed_generations :
381+ failed_path = save_path .replace (".json" , "_failed.jsonl" )
382+ with open (failed_path , "w" ) as f :
383+ for failed in self .failed_generations :
384+ f .write (json .dumps ({"failed_generation" : failed }) + "\n " )
385+
240386 @classmethod
241387 def from_json (cls , json_path : str , params : dict ) -> "Graph" :
242388 """Load a topic graph from a JSON file."""
@@ -268,6 +414,36 @@ def from_json(cls, json_path: str, params: dict) -> "Graph":
268414 graph ._next_node_id = max (graph .nodes .keys ()) + 1
269415 return graph
270416
417+ @classmethod
418+ def load (cls , json_path : str ) -> "Graph" :
419+ """Load a graph from JSON without initializing LLM client.
420+
421+ Intended for inspection and manipulation operations that don't
422+ require LLM generation capabilities. Restores provider, model,
423+ and temperature from the file metadata so saves preserve them.
424+ """
425+ params = {
426+ "topic_prompt" : "loaded" ,
427+ "model_name" : "placeholder/model" ,
428+ "degree" : 3 ,
429+ "depth" : 2 ,
430+ "temperature" : 0.7 ,
431+ }
432+ graph = cls .from_json (json_path , params )
433+
434+ # Restore original metadata so save() preserves provenance
435+ with open (json_path ) as f :
436+ raw = json .load (f )
437+ file_meta = raw .get ("metadata" ) or {}
438+ if file_meta .get ("provider" ):
439+ graph .provider = file_meta ["provider" ]
440+ if file_meta .get ("model" ):
441+ graph .model_name = file_meta ["model" ]
442+ if file_meta .get ("temperature" ) is not None :
443+ graph .temperature = file_meta ["temperature" ]
444+
445+ return graph
446+
271447 def visualize (self , save_path : str ) -> None :
272448 """Visualize the graph and save it to a file."""
273449 try :
@@ -454,7 +630,7 @@ async def _generate_subtopics_with_retry(
454630 prompt = prompt ,
455631 schema = GraphSubtopics ,
456632 max_retries = 1 , # Don't retry inside - we handle it here
457- max_tokens = DEFAULT_MAX_TOKENS ,
633+ max_tokens = self . max_tokens ,
458634 temperature = self .temperature ,
459635 )
460636
0 commit comments