Skip to content

Commit 33a1a83

Browse files
authored
Merge pull request #598 from scp7/feature/topic-inspect
feat: add topic inspect and prune commands change: change default topic generation mode from tree to graph
2 parents f212901 + b2f1915 commit 33a1a83

22 files changed

Lines changed: 2357 additions & 43 deletions

deepfabric/cli.py

Lines changed: 541 additions & 6 deletions
Large diffs are not rendered by default.

deepfabric/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from .constants import (
1010
DEFAULT_MAX_RETRIES,
11+
DEFAULT_MAX_TOKENS,
1112
DEFAULT_MODEL,
1213
DEFAULT_PROVIDER,
1314
DEFAULT_SAMPLE_RETRIES,
@@ -86,7 +87,7 @@ class TopicsConfig(BaseModel):
8687
..., min_length=1, description="The initial prompt to start topic generation"
8788
)
8889
mode: Literal["tree", "graph"] = Field(
89-
default="tree", description="Topic generation mode: tree or graph"
90+
default="graph", description="Topic generation mode: tree or graph"
9091
)
9192
system_prompt: str = Field(
9293
default="", description="System prompt for topic exploration and generation"
@@ -109,6 +110,11 @@ class TopicsConfig(BaseModel):
109110
le=20,
110111
description="Maximum concurrent LLM calls during graph expansion (helps avoid rate limits)",
111112
)
113+
max_tokens: int = Field(
114+
default=DEFAULT_MAX_TOKENS,
115+
ge=1,
116+
description="Maximum tokens for topic generation LLM calls",
117+
)
112118
save_as: str | None = Field(default=None, description="Where to save the generated topics")
113119
prompt_style: Literal["default", "isolated", "anchored"] = Field(
114120
default="default",
@@ -589,6 +595,7 @@ def get_topics_params(self, **overrides) -> dict:
589595
"depth": self.topics.depth,
590596
"degree": self.topics.degree,
591597
"max_concurrent": self.topics.max_concurrent,
598+
"max_tokens": self.topics.max_tokens,
592599
"prompt_style": self.topics.prompt_style,
593600
}
594601

deepfabric/config_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def load_config( # noqa: PLR0913
3737
topics_save_as: str | None = None,
3838
output_save_as: str | None = None,
3939
include_system_message: bool | None = None,
40-
mode: str = "tree",
40+
mode: str | None = None,
4141
# Modular conversation configuration
4242
conversation_type: str | None = None,
4343
reasoning_style: str | None = None,
@@ -83,6 +83,8 @@ def load_config( # noqa: PLR0913
8383
except Exception as e:
8484
raise ConfigurationError(f"Error loading config file: {str(e)}") from e
8585
else:
86+
if mode is not None:
87+
config.topics.mode = mode
8688
return config
8789

8890
# No config file provided - create minimal configuration from CLI args
@@ -92,6 +94,9 @@ def load_config( # noqa: PLR0913
9294
tui = get_tui()
9395
tui.info("No config file provided - using CLI parameters")
9496

97+
# Default to graph mode when no config file and no explicit mode
98+
mode = mode or "graph"
99+
95100
# Create minimal config dict with new structure
96101
default_prompt = generation_system_prompt or "You are a helpful AI assistant."
97102

deepfabric/graph.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)