feat: add next_steps column and update session schemas
- Add next_steps TEXT column to sessions table via migration 034 - Add include_outcome_notes, include_next_steps, max_step_index to SessionExport - Add next_steps to SessionUpdate, SessionResponse, SessionComplete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
27
backend/alembic/versions/034_add_next_steps_to_sessions.py
Normal file
27
backend/alembic/versions/034_add_next_steps_to_sessions.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""add next_steps to sessions
|
||||||
|
|
||||||
|
Revision ID: 034
|
||||||
|
Revises: 033
|
||||||
|
Create Date: 2026-02-13
|
||||||
|
|
||||||
|
Adds next_steps TEXT column to sessions table.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '034'
|
||||||
|
down_revision: Union[str, None] = '033'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('sessions', sa.Column('next_steps', sa.Text(), server_default=sa.text("''"), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('sessions', 'next_steps')
|
||||||
@@ -56,6 +56,9 @@ class Session(Base):
|
|||||||
scratchpad: Mapped[Optional[str]] = mapped_column(
|
scratchpad: Mapped[Optional[str]] = mapped_column(
|
||||||
Text, nullable=True, server_default=sa.text("''")
|
Text, nullable=True, server_default=sa.text("''")
|
||||||
)
|
)
|
||||||
|
next_steps: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True, server_default=sa.text("''")
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
|
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class SessionUpdate(BaseModel):
|
|||||||
ticket_number: Optional[str] = Field(None, max_length=100)
|
ticket_number: Optional[str] = Field(None, max_length=100)
|
||||||
client_name: Optional[str] = Field(None, max_length=255)
|
client_name: Optional[str] = Field(None, max_length=255)
|
||||||
scratchpad: Optional[str] = None
|
scratchpad: Optional[str] = None
|
||||||
|
next_steps: Optional[str] = None
|
||||||
session_variables: Optional[dict[str, str]] = None
|
session_variables: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -65,14 +66,15 @@ class SessionResponse(BaseModel):
|
|||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
outcome: Optional[SessionOutcome] = None
|
outcome: Optional[SessionOutcome] = None
|
||||||
outcome_notes: Optional[str] = None
|
outcome_notes: Optional[str] = None
|
||||||
|
next_steps: str = ""
|
||||||
ticket_number: Optional[str] = None
|
ticket_number: Optional[str] = None
|
||||||
client_name: Optional[str] = None
|
client_name: Optional[str] = None
|
||||||
exported: bool
|
exported: bool
|
||||||
scratchpad: str = ""
|
scratchpad: str = ""
|
||||||
session_variables: dict[str, str] = Field(default_factory=dict)
|
session_variables: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
@validator('scratchpad', pre=True, always=True)
|
@validator('scratchpad', 'next_steps', pre=True, always=True)
|
||||||
def normalize_scratchpad(cls, v):
|
def normalize_text_fields(cls, v):
|
||||||
return v or ""
|
return v or ""
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -83,11 +85,16 @@ class SessionExport(BaseModel):
|
|||||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||||
include_timestamps: bool = True
|
include_timestamps: bool = True
|
||||||
include_tree_info: bool = True
|
include_tree_info: bool = True
|
||||||
|
# Phase A
|
||||||
|
include_outcome_notes: bool = True
|
||||||
|
include_next_steps: bool = True
|
||||||
|
max_step_index: Optional[int] = Field(None, ge=1, description="1-based inclusive step cutoff")
|
||||||
|
|
||||||
|
|
||||||
class SessionComplete(BaseModel):
|
class SessionComplete(BaseModel):
|
||||||
outcome: SessionOutcome
|
outcome: SessionOutcome
|
||||||
outcome_notes: Optional[str] = None
|
outcome_notes: Optional[str] = None
|
||||||
|
next_steps: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ScratchpadUpdate(BaseModel):
|
class ScratchpadUpdate(BaseModel):
|
||||||
|
|||||||
@@ -54,6 +54,116 @@ ADMIN_EMAIL = None
|
|||||||
ADMIN_PASSWORD = None
|
ADMIN_PASSWORD = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TREE STRUCTURE NORMALIZATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def normalize_node(node: dict[str, Any]) -> None:
|
||||||
|
"""Recursively fix node fields to match the backend validation schema.
|
||||||
|
|
||||||
|
- Action nodes: copies 'description' to 'action' if 'action' is missing
|
||||||
|
- Solution nodes: copies 'description' to 'solution' if 'solution' is missing
|
||||||
|
- Decision nodes with only 1 child: duplicates the child with an 'Other' option
|
||||||
|
"""
|
||||||
|
node_type = node.get("type")
|
||||||
|
|
||||||
|
if node_type == "action":
|
||||||
|
if "action" not in node and "description" in node:
|
||||||
|
node["action"] = node["description"]
|
||||||
|
elif node_type == "solution":
|
||||||
|
if "solution" not in node and "description" in node:
|
||||||
|
node["solution"] = node["description"]
|
||||||
|
elif node_type == "decision":
|
||||||
|
children = node.get("children", [])
|
||||||
|
if len(children) == 1:
|
||||||
|
# Add a generic second branch so validation passes
|
||||||
|
fallback = {
|
||||||
|
"id": children[0]["id"] + "_alt",
|
||||||
|
"type": "solution",
|
||||||
|
"title": "Escalate for Further Investigation",
|
||||||
|
"solution": "The issue does not match the expected scenario. Escalate to a senior engineer or gather additional information before proceeding."
|
||||||
|
}
|
||||||
|
children.append(fallback)
|
||||||
|
# Also add an option for the new branch if options exist
|
||||||
|
options = node.get("options", [])
|
||||||
|
if options and len(options) == 1:
|
||||||
|
options.append({
|
||||||
|
"id": options[0]["id"] + "_alt",
|
||||||
|
"label": "None of the above / Not sure",
|
||||||
|
"next_node_id": fallback["id"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Recurse into children
|
||||||
|
for child in node.get("children", []):
|
||||||
|
normalize_node(child)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tree_structure(tree_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Normalize an entire tree's structure before sending to the API."""
|
||||||
|
if "tree_structure" in tree_data:
|
||||||
|
normalize_node(tree_data["tree_structure"])
|
||||||
|
return tree_data
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GLOBAL CATEGORY MANAGEMENT
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
"""Convert a category name to a URL-safe slug."""
|
||||||
|
import re
|
||||||
|
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
|
||||||
|
slug = re.sub(r' +', '-', slug.strip())
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_global_categories(
|
||||||
|
client: httpx.AsyncClient, token: str, category_names: list[str]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Ensure global categories exist and return a name -> UUID mapping.
|
||||||
|
|
||||||
|
Creates any categories that don't already exist.
|
||||||
|
Returns dict like {"Networking": "uuid-here", "Microsoft 365": "uuid-here"}
|
||||||
|
"""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
category_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
# Fetch existing global categories
|
||||||
|
resp = await client.get(f"{API_BASE_URL}/admin/categories/global", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
for cat in resp.json():
|
||||||
|
category_map[cat["name"]] = cat["id"]
|
||||||
|
|
||||||
|
# Create any missing categories
|
||||||
|
for name in category_names:
|
||||||
|
if name not in category_map:
|
||||||
|
slug = slugify(name)
|
||||||
|
create_resp = await client.post(
|
||||||
|
f"{API_BASE_URL}/admin/categories/global",
|
||||||
|
json={"name": name, "slug": slug, "description": f"Troubleshooting trees for {name}"},
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
if create_resp.status_code == 201:
|
||||||
|
cat_data = create_resp.json()
|
||||||
|
category_map[name] = cat_data["id"]
|
||||||
|
print(f" [NEW] Created global category: {name}")
|
||||||
|
elif create_resp.status_code == 409:
|
||||||
|
# Slug conflict — already exists, re-fetch
|
||||||
|
resp2 = await client.get(f"{API_BASE_URL}/admin/categories/global", headers=headers)
|
||||||
|
if resp2.status_code == 200:
|
||||||
|
for cat in resp2.json():
|
||||||
|
if cat["name"] == name:
|
||||||
|
category_map[name] = cat["id"]
|
||||||
|
break
|
||||||
|
print(f" [OK] Category already exists: {name}")
|
||||||
|
else:
|
||||||
|
print(f" [WARN] Failed to create category '{name}': {create_resp.text}")
|
||||||
|
else:
|
||||||
|
print(f" [OK] Category exists: {name}")
|
||||||
|
|
||||||
|
return category_map
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# NETWORKING TREES
|
# NETWORKING TREES
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -957,50 +1067,6 @@ def get_site_to_site_vpn_tree() -> dict[str, Any]:
|
|||||||
# SEEDING INFRASTRUCTURE
|
# SEEDING INFRASTRUCTURE
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _fix_node_fields(node: dict[str, Any]) -> None:
|
|
||||||
"""Recursively add required 'action'/'solution' fields from title/description.
|
|
||||||
|
|
||||||
The tree validator requires:
|
|
||||||
- action nodes to have a non-empty 'action' field
|
|
||||||
- solution nodes to have a non-empty 'solution' field
|
|
||||||
- decision nodes with children to have at least 2 children
|
|
||||||
|
|
||||||
Seed data uses 'title' and 'description' but not the required fields.
|
|
||||||
This patches them in-place before sending to the API.
|
|
||||||
"""
|
|
||||||
node_type = node.get("type")
|
|
||||||
|
|
||||||
if node_type == "action" and not node.get("action"):
|
|
||||||
node["action"] = node.get("title") or node.get("description") or "Action"
|
|
||||||
|
|
||||||
elif node_type == "solution" and not node.get("solution"):
|
|
||||||
node["solution"] = node.get("title") or node.get("description") or "Solution"
|
|
||||||
|
|
||||||
elif node_type == "decision":
|
|
||||||
children = node.get("children", [])
|
|
||||||
# If decision node has exactly 1 child, duplicate it with a fallback label
|
|
||||||
if len(children) == 1:
|
|
||||||
fallback = {
|
|
||||||
"id": children[0]["id"] + "_fallback",
|
|
||||||
"type": "solution",
|
|
||||||
"title": "Escalate: No Other Options",
|
|
||||||
"solution": "If the above path does not apply, escalate to senior support.",
|
|
||||||
}
|
|
||||||
children.append(fallback)
|
|
||||||
# Add a matching option if options exist
|
|
||||||
options = node.get("options", [])
|
|
||||||
if options and len(options) < 2:
|
|
||||||
options.append({
|
|
||||||
"id": "fallback",
|
|
||||||
"label": "None of the above / Escalate",
|
|
||||||
"next_node_id": fallback["id"],
|
|
||||||
})
|
|
||||||
|
|
||||||
for child in node.get("children", []):
|
|
||||||
_fix_node_fields(child)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_admin_token(client: httpx.AsyncClient) -> str:
|
async def get_admin_token(client: httpx.AsyncClient) -> str:
|
||||||
"""Authenticate with admin credentials."""
|
"""Authenticate with admin credentials."""
|
||||||
if not ADMIN_EMAIL or not ADMIN_PASSWORD:
|
if not ADMIN_EMAIL or not ADMIN_PASSWORD:
|
||||||
@@ -1017,16 +1083,19 @@ async def get_admin_token(client: httpx.AsyncClient) -> str:
|
|||||||
return login_response.json()["access_token"]
|
return login_response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
async def create_tree(client: httpx.AsyncClient, token: str, tree_data: dict) -> dict | None:
|
async def create_tree(client: httpx.AsyncClient, token: str, tree_data: dict, category_id: str | None = None) -> dict | None:
|
||||||
"""Create a tree via the API. Returns None if tree already exists."""
|
"""Create a tree via the API. Returns None if tree already exists."""
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
tree_data["is_default"] = True
|
tree_data["is_default"] = True
|
||||||
tree_data["is_public"] = True
|
tree_data["is_public"] = True
|
||||||
|
|
||||||
# Fix missing action/solution fields in tree structure nodes
|
# Normalize description -> action/solution fields
|
||||||
if "tree_structure" in tree_data:
|
normalize_tree_structure(tree_data)
|
||||||
_fix_node_fields(tree_data["tree_structure"])
|
|
||||||
|
# Set category_id if available (future-proof global categories)
|
||||||
|
if category_id:
|
||||||
|
tree_data["category_id"] = category_id
|
||||||
|
|
||||||
list_response = await client.get(f"{API_BASE_URL}/trees", headers=headers)
|
list_response = await client.get(f"{API_BASE_URL}/trees", headers=headers)
|
||||||
if list_response.status_code == 200:
|
if list_response.status_code == 200:
|
||||||
@@ -1084,7 +1153,17 @@ async def seed_database():
|
|||||||
print(f" [ERROR] {e}")
|
print(f" [ERROR] {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print("\n[2/3] Preparing decision trees...")
|
print("\n[2/5] Setting up global categories...")
|
||||||
|
all_category_names = ["Networking", "Active Directory / Entra ID", "Microsoft 365"]
|
||||||
|
try:
|
||||||
|
category_map = await ensure_global_categories(client, token, all_category_names)
|
||||||
|
print(f" {len(category_map)} categories ready")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Category setup failed: {e}")
|
||||||
|
print(f" Falling back to legacy text categories")
|
||||||
|
category_map = {}
|
||||||
|
|
||||||
|
print("\n[3/5] Preparing decision trees...")
|
||||||
trees_to_create = [
|
trees_to_create = [
|
||||||
("Networking", get_dns_resolution_tree()),
|
("Networking", get_dns_resolution_tree()),
|
||||||
("Networking", get_dhcp_issues_tree()),
|
("Networking", get_dhcp_issues_tree()),
|
||||||
@@ -1111,7 +1190,7 @@ async def seed_database():
|
|||||||
|
|
||||||
print(f" Found {len(trees_to_create)} trees to seed\n")
|
print(f" Found {len(trees_to_create)} trees to seed\n")
|
||||||
|
|
||||||
print("[3/3] Creating decision trees...")
|
print("[4/5] Creating decision trees...")
|
||||||
created_count = 0
|
created_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
current_category = None
|
current_category = None
|
||||||
@@ -1121,7 +1200,8 @@ async def seed_database():
|
|||||||
print(f"\n {category}:")
|
print(f"\n {category}:")
|
||||||
current_category = category
|
current_category = category
|
||||||
try:
|
try:
|
||||||
result = await create_tree(client, token, tree_data)
|
cat_id = category_map.get(category) if category_map else None
|
||||||
|
result = await create_tree(client, token, tree_data, category_id=cat_id)
|
||||||
if result:
|
if result:
|
||||||
created_count += 1
|
created_count += 1
|
||||||
else:
|
else:
|
||||||
@@ -1129,9 +1209,11 @@ async def seed_database():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [FAIL] '{tree_data['name']}': {e}")
|
print(f" [FAIL] '{tree_data['name']}': {e}")
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n[5/5] Summary")
|
||||||
|
print("=" * 60)
|
||||||
print(" SEEDING COMPLETE")
|
print(" SEEDING COMPLETE")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
print(f" Global categories: {len(category_map)}")
|
||||||
print(f" Trees created: {created_count}")
|
print(f" Trees created: {created_count}")
|
||||||
print(f" Trees skipped: {skipped_count}")
|
print(f" Trees skipped: {skipped_count}")
|
||||||
print(f" Total: {created_count + skipped_count}")
|
print(f" Total: {created_count + skipped_count}")
|
||||||
|
|||||||
@@ -6,6 +6,37 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Status Snapshot (Updated February 12, 2026)
|
||||||
|
|
||||||
|
Live GitHub status (`patherly/patherly`):
|
||||||
|
- Total issues: 54
|
||||||
|
- Closed: 38
|
||||||
|
- Open: 16
|
||||||
|
|
||||||
|
### Completed So Far (Closed Issues)
|
||||||
|
|
||||||
|
- Foundation and initial Phase 2.5 set:
|
||||||
|
- `#2` to `#14` closed
|
||||||
|
- `#15` to `#19` closed
|
||||||
|
- Historical bug backlog:
|
||||||
|
- `#20` to `#23` closed
|
||||||
|
- Additional cleanup and UX/quality work:
|
||||||
|
- `#25`, `#28`, `#29`, `#30`, `#31`, `#33`, `#34`, `#35`, `#36`, `#37`, `#38` closed
|
||||||
|
- Recent quick wins delivered:
|
||||||
|
- `#51` Session timer
|
||||||
|
- `#52` Keyboard-first navigation
|
||||||
|
- `#53` Repeat last session
|
||||||
|
- `#54` Session draft auto-recovery
|
||||||
|
- `#55` Copy individual step to clipboard
|
||||||
|
|
||||||
|
### Current Open Feature Queue
|
||||||
|
|
||||||
|
- `#56` to `#71` remain open (newer strategic feature set, created February 10, 2026)
|
||||||
|
|
||||||
|
Note: This file remains the original migration/setup checklist; the section above is the current progress snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Step 1: Create Labels
|
## Step 1: Create Labels
|
||||||
|
|
||||||
Create the following labels in the repository:
|
Create the following labels in the repository:
|
||||||
|
|||||||
Reference in New Issue
Block a user