From ec5c91c8e4679a3115c9d3a89ee63cfb93f34d7d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 13 Feb 2026 08:32:20 -0500 Subject: [PATCH] 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 --- .../034_add_next_steps_to_sessions.py | 27 +++ backend/app/models/session.py | 3 + backend/app/schemas/session.py | 11 +- backend/scripts/seed_trees_v2.py | 186 +++++++++++++----- docs/GITHUB-ISSUES-MIGRATION.md | 31 +++ 5 files changed, 204 insertions(+), 54 deletions(-) create mode 100644 backend/alembic/versions/034_add_next_steps_to_sessions.py diff --git a/backend/alembic/versions/034_add_next_steps_to_sessions.py b/backend/alembic/versions/034_add_next_steps_to_sessions.py new file mode 100644 index 00000000..3cc487ed --- /dev/null +++ b/backend/alembic/versions/034_add_next_steps_to_sessions.py @@ -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') diff --git a/backend/app/models/session.py b/backend/app/models/session.py index a8984d8a..91e40a50 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -56,6 +56,9 @@ class Session(Base): scratchpad: Mapped[Optional[str]] = mapped_column( Text, nullable=True, server_default=sa.text("''") ) + next_steps: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, server_default=sa.text("''") + ) # Relationships tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions") diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index f546b99b..76a2399a 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -50,6 +50,7 @@ class SessionUpdate(BaseModel): ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) scratchpad: Optional[str] = None + next_steps: Optional[str] = None session_variables: Optional[dict[str, str]] = None @@ -65,14 +66,15 @@ class SessionResponse(BaseModel): completed_at: Optional[datetime] = None outcome: Optional[SessionOutcome] = None outcome_notes: Optional[str] = None + next_steps: str = "" ticket_number: Optional[str] = None client_name: Optional[str] = None exported: bool scratchpad: str = "" session_variables: dict[str, str] = Field(default_factory=dict) - @validator('scratchpad', pre=True, always=True) - def normalize_scratchpad(cls, v): + @validator('scratchpad', 'next_steps', pre=True, always=True) + def normalize_text_fields(cls, v): return v or "" class Config: @@ -83,11 +85,16 @@ class SessionExport(BaseModel): format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$") include_timestamps: 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): outcome: SessionOutcome outcome_notes: Optional[str] = None + next_steps: Optional[str] = None class ScratchpadUpdate(BaseModel): diff --git a/backend/scripts/seed_trees_v2.py b/backend/scripts/seed_trees_v2.py index d5728520..0045619e 100644 --- a/backend/scripts/seed_trees_v2.py +++ b/backend/scripts/seed_trees_v2.py @@ -54,6 +54,116 @@ ADMIN_EMAIL = 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 # ============================================================================= @@ -957,50 +1067,6 @@ def get_site_to_site_vpn_tree() -> dict[str, Any]: # 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: """Authenticate with admin credentials.""" 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"] -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.""" headers = {"Authorization": f"Bearer {token}"} tree_data["is_default"] = True tree_data["is_public"] = True - # Fix missing action/solution fields in tree structure nodes - if "tree_structure" in tree_data: - _fix_node_fields(tree_data["tree_structure"]) + # Normalize description -> action/solution fields + normalize_tree_structure(tree_data) + + # 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) if list_response.status_code == 200: @@ -1084,7 +1153,17 @@ async def seed_database(): print(f" [ERROR] {e}") 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 = [ ("Networking", get_dns_resolution_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("[3/3] Creating decision trees...") + print("[4/5] Creating decision trees...") created_count = 0 skipped_count = 0 current_category = None @@ -1121,7 +1200,8 @@ async def seed_database(): print(f"\n {category}:") current_category = category 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: created_count += 1 else: @@ -1129,9 +1209,11 @@ async def seed_database(): except Exception as e: print(f" [FAIL] '{tree_data['name']}': {e}") - print("\n" + "=" * 60) + print("\n[5/5] Summary") + print("=" * 60) print(" SEEDING COMPLETE") print("=" * 60) + print(f" Global categories: {len(category_map)}") print(f" Trees created: {created_count}") print(f" Trees skipped: {skipped_count}") print(f" Total: {created_count + skipped_count}") diff --git a/docs/GITHUB-ISSUES-MIGRATION.md b/docs/GITHUB-ISSUES-MIGRATION.md index 198b8c5f..a42dda58 100644 --- a/docs/GITHUB-ISSUES-MIGRATION.md +++ b/docs/GITHUB-ISSUES-MIGRATION.md @@ -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 Create the following labels in the repository: