Files
resolutionflow/backend/app/core/session_to_tree.py
Michael Chihlas c7b2c59ef6 feat: implement tree sharing, draft trees, and session-to-tree conversion (Issues #16, #25, #17)
Backend features:
- Tree sharing via secure tokens with expiration (Issue #16)
- Draft tree status with conditional validation (Issue #25)
- Save session as custom tree with fork tracking (Issue #17)
- Tree validation system for publish requirements
- Session-to-tree conversion preserving custom steps

Database migrations:
- 024: Tree sharing (tree_shares table, visibility field)
- 025: Tree status field (draft/published)
- 25b: Merge migration for indexes

New endpoints:
- POST /api/v1/trees/{id}/share - Generate share token
- GET /api/v1/shared/{token} - Public tree access
- POST /api/v1/trees/{id}/can-publish - Validate tree
- POST /api/v1/sessions/{id}/save-as-tree - Convert session

Test coverage:
- 20 tests for draft trees functionality
- 14 tests for session-to-tree conversion
- 15 tests for tree sharing

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 23:06:13 -05:00

207 lines
6.1 KiB
Python

"""Helper module to convert sessions into tree structures."""
import uuid
from typing import Any
def convert_session_to_tree(
session_path: list[str],
tree_snapshot: dict[str, Any],
custom_steps: list[dict[str, Any]],
decisions: list[dict[str, Any]]
) -> dict[str, Any]:
"""Convert a session's path and custom steps into a linear tree structure.
Creates a linear decision tree that represents the path taken through the
original tree, including any custom steps inserted during the session.
Args:
session_path: List of node IDs representing the path taken
tree_snapshot: Original tree structure (for node details)
custom_steps: Custom steps inserted during session
decisions: Decision records with answers and notes
Returns:
Tree structure dict representing the linear path
"""
if not session_path:
# Return minimal valid tree if no path taken
return {
"id": str(uuid.uuid4()),
"type": "solution",
"solution": "Session had no recorded path",
"children": []
}
# Build a map of custom steps by their ID
custom_steps_map = {}
for step in custom_steps:
if "id" in step:
custom_steps_map[step["id"]] = step
# Build a map of decisions by node_id for quick lookup
decisions_map = {}
for decision in decisions:
if "node_id" in decision:
decisions_map[decision["node_id"]] = decision
# Build the linear tree structure
root_node = None
current_node = None
for i, node_id in enumerate(session_path):
# Check if this is a custom step
if node_id in custom_steps_map:
step = custom_steps_map[node_id]
new_node = _create_node_from_custom_step(step, node_id)
else:
# Find node in original tree
original_node = _find_node_in_tree(tree_snapshot, node_id)
if original_node:
new_node = _create_node_from_original(original_node, decisions_map.get(node_id))
else:
# Node not found, create a placeholder
new_node = {
"id": node_id,
"type": "action",
"action": f"Step from original tree (node {node_id})",
"children": []
}
# Add notes from decision if available
decision = decisions_map.get(node_id)
if decision and decision.get("notes"):
new_node["notes"] = decision["notes"]
# Build the chain
if root_node is None:
root_node = new_node
current_node = root_node
else:
current_node["children"] = [new_node]
current_node = new_node
return root_node
def _find_node_in_tree(tree: dict[str, Any], node_id: str) -> dict[str, Any] | None:
"""Recursively find a node in the tree structure by ID.
Args:
tree: Tree structure dict
node_id: Node ID to find
Returns:
Node dict if found, None otherwise
"""
if tree.get("id") == node_id:
return tree
for child in tree.get("children", []):
result = _find_node_in_tree(child, node_id)
if result:
return result
return None
def _create_node_from_original(
original_node: dict[str, Any],
decision: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Create a new node based on an original tree node.
Args:
original_node: Original node from tree
decision: Decision record for this node (optional)
Returns:
New node dict for the linear tree
"""
node_type = original_node.get("type", "action")
new_node = {
"id": str(uuid.uuid4()), # Generate new ID for the saved tree
"type": node_type,
"children": []
}
# Copy relevant content based on node type
if node_type == "decision":
new_node["question"] = original_node.get("question", "")
if decision and decision.get("answer"):
new_node["question"] += f"\n\nAnswer: {decision['answer']}"
elif node_type == "action":
new_node["action"] = original_node.get("action", "")
if decision and decision.get("action_performed"):
new_node["action"] = decision["action_performed"]
elif node_type == "solution":
new_node["solution"] = original_node.get("solution", "")
return new_node
def _create_node_from_custom_step(
custom_step: dict[str, Any],
step_id: str
) -> dict[str, Any]:
"""Create a node from a custom step.
Args:
custom_step: Custom step dict
step_id: ID of the custom step
Returns:
Node dict for the linear tree
"""
step_type = custom_step.get("type", "action")
content = custom_step.get("content", "")
new_node = {
"id": str(uuid.uuid4()),
"type": step_type,
"children": []
}
# Map content to appropriate field based on type
if step_type == "decision":
new_node["question"] = content
elif step_type == "action":
new_node["action"] = content
elif step_type == "solution":
new_node["solution"] = content
# Add notes if present
if custom_step.get("notes"):
if step_type == "decision":
new_node["question"] += f"\n\nNotes: {custom_step['notes']}"
elif step_type == "action":
new_node["action"] += f"\n\nNotes: {custom_step['notes']}"
elif step_type == "solution":
new_node["solution"] += f"\n\nNotes: {custom_step['notes']}"
return new_node
def generate_tree_name_from_session(
original_tree_name: str,
ticket_number: str | None = None,
client_name: str | None = None
) -> str:
"""Generate a descriptive name for the saved tree.
Args:
original_tree_name: Name of the original tree
ticket_number: Optional ticket number
client_name: Optional client name
Returns:
Generated tree name
"""
parts = [original_tree_name, "Session"]
if ticket_number:
parts.append(f"(Ticket {ticket_number})")
if client_name:
parts.append(f"- {client_name}")
return " ".join(parts)