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/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 0e829fdd..68bdaf4c 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -236,6 +236,7 @@ async def complete_session( session.completed_at = datetime.now(timezone.utc) session.outcome = completion_data.outcome session.outcome_notes = completion_data.outcome_notes + session.next_steps = completion_data.next_steps await db.commit() await db.refresh(session) return session @@ -313,9 +314,10 @@ async def export_session( from app.services.variable_service import resolve_variables content = resolve_variables(content, session_vars) - # Mark as exported - session.exported = True - await db.commit() + # Only mark as exported if session is completed + if session.completed_at: + session.exported = True + await db.commit() return PlainTextResponse(content=content, media_type=media_type) 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..83c2a550 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,19 @@ 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") + # Phase B + include_summary: bool = False + detail_level: Literal["standard", "full"] = "standard" class SessionComplete(BaseModel): outcome: SessionOutcome outcome_notes: Optional[str] = None + next_steps: Optional[str] = None class ScratchpadUpdate(BaseModel): diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 1d7148a0..d38a92c4 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -90,6 +90,26 @@ def _get_command_output(decision: dict[str, Any]) -> str | None: return output if output else None +def _truncate_command_output(output: str, max_lines: int = 5, fmt: str = "text") -> str: + """Truncate command output to max_lines for standard detail level. + + Args: + fmt: One of "markdown", "text", "html", "psa" — controls suffix formatting. + """ + lines = output.splitlines() + if len(lines) <= max_lines: + return output + truncated = "\n".join(lines[:max_lines]) + count = len(lines) + if fmt == "markdown": + suffix = f"*(full output omitted — {count} lines)*" + elif fmt == "html": + suffix = f"(full output omitted — {count} lines)" + else: # text, psa + suffix = f"(full output omitted — {count} lines)" + return f"{truncated}\n{suffix}" + + def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]: """Find the commands list for a node in the tree snapshot.""" def _search(node: dict[str, Any]) -> list[str] | None: @@ -113,6 +133,42 @@ def _get_outcome_label(session: Session) -> str | None: return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title()) +def _build_summary_fields(session: Session) -> dict[str, str]: + """Build auto-populated summary fields from session data. + + Empty fields are left blank — users fill them in via the editable preview. + """ + tree_name = session.tree_snapshot.get("name", "Troubleshooting Session") + tree_desc = session.tree_snapshot.get("description", "") + issue = f"{tree_name}: {tree_desc}" if tree_desc else tree_name + + if session.completed_at: + status = "Resolved" if getattr(session, "outcome", None) == "resolved" else \ + f"Completed — {_get_outcome_label(session) or 'Unknown'}" + else: + step_count = len(session.decisions) if session.decisions else 0 + status = f"In Progress — paused at step {step_count}" if step_count else "In Progress" + + _raw_notes = getattr(session, 'outcome_notes', None) + resolution = (_raw_notes if isinstance(_raw_notes, str) else '').strip() + + _raw_next = getattr(session, 'next_steps', None) + next_steps = (_raw_next if isinstance(_raw_next, str) else '').strip() + + return { + "issue": issue, + "impact": "", + "status": status, + "resolution": resolution, + "next_steps": next_steps, + } + + +def _escape_markdown_table(value: str) -> str: + """Escape value for use in a markdown table cell.""" + return value.replace("|", "\\|").replace("\n", " ") + + def generate_markdown_export(session: Session, options: SessionExport) -> str: """Generate markdown export.""" lines = [] @@ -137,6 +193,22 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append("---") lines.append("") + if options.include_summary: + summary = _build_summary_fields(session) + esc = _escape_markdown_table + lines.append("## Summary") + lines.append("") + lines.append("| Field | Details |") + lines.append("|-------|---------|") + lines.append(f"| Issue | {esc(summary['issue'])} |") + lines.append(f"| Impact | {esc(summary['impact'])} |") + lines.append(f"| Status | {esc(summary['status'])} |") + lines.append(f"| Resolution | {esc(summary['resolution'])} |") + lines.append(f"| Next Steps | {esc(summary['next_steps'])} |") + lines.append("") + lines.append("---") + lines.append("") + # Scratchpad / Evidence section scratchpad = getattr(session, 'scratchpad', '') or '' if scratchpad.strip(): @@ -150,18 +222,28 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append("## Troubleshooting Steps") lines.append("") - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") duration_seconds = _get_step_duration_seconds(decision) - lines.append(f"### Step {i}: {question}") + is_custom = decision.get("node_id", "").startswith("custom-") + prefix = "[CUSTOM] " if is_custom else "" + lines.append(f"### Step {i}: {prefix}{question}") + if is_custom: + lines.append("*Custom step added by engineer*") if answer: lines.append(f"**Answer:** {answer}") if notes: lines.append(f"**Notes:** {notes}") if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="markdown") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}") @@ -175,6 +257,28 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str: lines.append(f"*{decision['timestamp']}*") lines.append("") + # Resolution / Outcome Notes + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("---") + lines.append("") + lines.append("## Resolution") + lines.append("") + lines.append(outcome_notes.strip()) + lines.append("") + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + lines.append("---") + lines.append("") + lines.append("## Next Steps") + lines.append("") + lines.append(next_steps.strip()) + lines.append("") + return "\n".join(lines) @@ -200,6 +304,17 @@ def generate_text_export(session: Session, options: SessionExport) -> str: lines.append(f"Outcome: {outcome_label}") lines.append("") + if options.include_summary: + summary = _build_summary_fields(session) + lines.append("SUMMARY") + lines.append("-" * 20) + lines.append(f"Issue: {summary['issue']}") + lines.append(f"Impact: {summary['impact']}") + lines.append(f"Status: {summary['status']}") + lines.append(f"Resolution: {summary['resolution']}") + lines.append(f"Next Steps: {summary['next_steps']}") + lines.append("") + # Scratchpad / Evidence section scratchpad = getattr(session, 'scratchpad', '') or '' if scratchpad.strip(): @@ -211,18 +326,26 @@ def generate_text_export(session: Session, options: SessionExport) -> str: lines.append("TROUBLESHOOTING STEPS") lines.append("-" * 20) - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") duration_seconds = _get_step_duration_seconds(decision) - lines.append(f"\n{i}. {question}") + is_custom = decision.get("node_id", "").startswith("custom-") + prefix = "[CUSTOM] " if is_custom else "" + lines.append(f"\n{i}. {prefix}{question}") if answer: lines.append(f" Answer: {answer}") if notes: lines.append(f" Notes: {notes}") if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="text") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: lines.append(f" Commands Run: {', '.join(commands)}") @@ -232,6 +355,24 @@ def generate_text_export(session: Session, options: SessionExport) -> str: if duration_seconds is not None: lines.append(f" Duration: {_format_step_duration(duration_seconds)}") + # Resolution + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append("") + lines.append("RESOLUTION") + lines.append("-" * 20) + lines.append(outcome_notes.strip()) + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + lines.append("") + lines.append("NEXT STEPS") + lines.append("-" * 20) + lines.append(next_steps.strip()) + return "\n".join(lines) @@ -272,6 +413,17 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

Outcome: {html.escape(outcome_label)}

') html_parts.append('') + if options.include_summary: + summary = _build_summary_fields(session) + html_parts.append('

Summary

') + html_parts.append('') + for label, value in [("Issue", summary["issue"]), ("Impact", summary["impact"]), + ("Status", summary["status"]), ("Resolution", summary["resolution"]), + ("Next Steps", summary["next_steps"])]: + html_parts.append(f'') + html_parts.append(f'') + html_parts.append('
{html.escape(label)}{html.escape(value)}
') + # Scratchpad / Evidence section scratchpad = getattr(session, 'scratchpad', '') or '' if scratchpad.strip(): @@ -280,19 +432,27 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append('

Troubleshooting Steps

') - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + + for i, decision in enumerate(decisions, 1): question = html.escape(decision.get("question") or decision.get("action_performed", "Step")) answer = html.escape(decision.get("answer", "")) notes = html.escape(decision.get("notes", "")) duration_seconds = _get_step_duration_seconds(decision) html_parts.append('
') - html_parts.append(f'

Step {i}: {question}

') + is_custom = decision.get("node_id", "").startswith("custom-") + custom_badge = 'CUSTOM' if is_custom else '' + html_parts.append(f'

{custom_badge}Step {i}: {question}

') if answer: html_parts.append(f'

Answer: {answer}

') if notes: html_parts.append(f'

Notes: {notes}

') if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="html") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: cmd_html = ", ".join(f"{html.escape(c)}" for c in commands) @@ -304,6 +464,20 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

{html.escape(str(decision["timestamp"]))}

') html_parts.append('
') + # Resolution + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + html_parts.append('

Resolution

') + html_parts.append(f'
{html.escape(outcome_notes.strip())}
') + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + html_parts.append('

Next Steps

') + html_parts.append(f'
{html.escape(next_steps.strip())}
') + html_parts.extend(['', '']) return "\n".join(html_parts) @@ -327,6 +501,16 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append(f"Outcome: {outcome_label}") lines.append("") + if options.include_summary: + summary = _build_summary_fields(session) + lines.append("--- SUMMARY ---") + lines.append(f"Issue: {summary['issue']}") + lines.append(f"Impact: {summary['impact']}") + lines.append(f"Status: {summary['status']}") + lines.append(f"Resolution: {summary['resolution']}") + lines.append(f"Next Steps: {summary['next_steps']}") + lines.append("") + # Problem section lines.append("--- PROBLEM ---") lines.append(tree_description if tree_description else "No description provided.") @@ -334,14 +518,19 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: # Steps taken lines.append("--- STEPS TAKEN ---") - if session.decisions: - for i, decision in enumerate(session.decisions, 1): + decisions = session.decisions + if options.max_step_index is not None: + decisions = decisions[:options.max_step_index] + if decisions: + for i, decision in enumerate(decisions, 1): question = decision.get("question") or decision.get("action_performed", "Step") answer = decision.get("answer", "") notes = decision.get("notes", "") duration_seconds = _get_step_duration_seconds(decision) - line = f"{i}. {question}" + is_custom = decision.get("node_id", "").startswith("custom-") + prefix = "[CUSTOM] " if is_custom else "" + line = f"{i}. {prefix}{question}" if answer: line += f" -> {answer}" if duration_seconds is not None: @@ -350,6 +539,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: if notes: lines.append(f" Notes: {notes}") if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output, fmt="psa") commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", "")) if commands: lines.append(f" Commands: {', '.join(commands)}") @@ -360,17 +551,30 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append("No steps recorded.") lines.append("") - # Resolution - last decision answer - lines.append("--- RESOLUTION ---") - if session.decisions: - last_decision = session.decisions[-1] - resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded") - lines.append(resolution) - else: - lines.append("No resolution recorded.") - if outcome_label: - lines.append(f"Outcome: {outcome_label}") - lines.append("") + # Resolution — only for completed sessions + if session.completed_at: + lines.append("--- RESOLUTION ---") + _raw_notes = getattr(session, 'outcome_notes', None) + outcome_notes = _raw_notes if isinstance(_raw_notes, str) else '' + if outcome_notes.strip() and options.include_outcome_notes: + lines.append(outcome_notes.strip()) + elif session.decisions: + last_decision = session.decisions[-1] + resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded") + lines.append(resolution) + else: + lines.append("No resolution recorded.") + if outcome_label: + lines.append(f"Outcome: {outcome_label}") + lines.append("") + + # Next Steps + _raw_next = getattr(session, 'next_steps', None) + next_steps = _raw_next if isinstance(_raw_next, str) else '' + if next_steps.strip() and options.include_next_steps: + lines.append("--- NEXT STEPS ---") + lines.append(next_steps.strip()) + lines.append("") # Time spent lines.append("--- TIME SPENT ---") 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/backend/tests/test_psa_export.py b/backend/tests/test_psa_export.py index 0bcc2a08..03f5af77 100644 --- a/backend/tests/test_psa_export.py +++ b/backend/tests/test_psa_export.py @@ -10,7 +10,10 @@ from unittest.mock import MagicMock import pytest from app.schemas.session import SessionExport -from app.services.export_service import generate_psa_export, _format_duration +from app.services.export_service import ( + generate_psa_export, generate_text_export, generate_markdown_export, + generate_html_export, _format_duration, +) def _make_session( @@ -231,3 +234,151 @@ class TestPsaExportFormat: """Verify the schema accepts 'psa' as a valid format.""" export = SessionExport(format="psa") assert export.format == "psa" + + +class TestPhaseB: + """Tests for Phase B export features: custom markers, detail levels, summary.""" + + def test_custom_step_markers_psa(self): + """Custom steps should have [CUSTOM] prefix in PSA export.""" + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Check DNS", "answer": "OK"}, + {"node_id": "custom-abc123", "question": "Check Additional Logs", "answer": "Found error"}, + ]) + options = SessionExport(format="psa") + result = generate_psa_export(session, options) + assert "[CUSTOM] Check Additional Logs" in result + assert "[CUSTOM] Check DNS" not in result + + def test_custom_step_markers_markdown(self): + """Custom steps should have [CUSTOM] prefix and subtitle in markdown.""" + session = _make_session(decisions=[ + {"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"}, + ]) + options = SessionExport(format="markdown") + result = generate_markdown_export(session, options) + assert "[CUSTOM] Manual Check" in result + assert "*Custom step added by engineer*" in result + + def test_custom_step_markers_html(self): + """Custom steps should have purple badge in HTML export.""" + session = _make_session(decisions=[ + {"node_id": "custom-xyz", "question": "Manual Check", "answer": "Done"}, + ]) + options = SessionExport(format="html") + result = generate_html_export(session, options) + assert "CUSTOM" in result + + def test_command_output_truncation_standard(self): + """Standard detail level truncates long command output.""" + long_output = "\n".join(f"line {i}" for i in range(20)) + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Run diagnostics", "answer": "See output", + "command_output": long_output}, + ]) + options = SessionExport(format="text", detail_level="standard") + result = generate_text_export(session, options) + assert "(full output omitted — 20 lines)" in result + assert "line 19" not in result + + def test_command_output_full_detail(self): + """Full detail level shows all command output.""" + long_output = "\n".join(f"line {i}" for i in range(20)) + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Run diagnostics", "answer": "See output", + "command_output": long_output}, + ]) + options = SessionExport(format="text", detail_level="full") + result = generate_text_export(session, options) + assert "(full output omitted" not in result + assert "line 19" in result + + def test_truncation_short_output_unchanged(self): + """Short command output is not truncated even in standard mode.""" + short_output = "line 1\nline 2\nline 3" + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Check", "answer": "OK", + "command_output": short_output}, + ]) + options = SessionExport(format="text", detail_level="standard") + result = generate_text_export(session, options) + assert "(full output omitted" not in result + assert "line 3" in result + + def test_truncation_markdown_format(self): + """Markdown format uses italic truncation marker.""" + long_output = "\n".join(f"line {i}" for i in range(20)) + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Check", "answer": "OK", + "command_output": long_output}, + ]) + options = SessionExport(format="markdown", detail_level="standard") + result = generate_markdown_export(session, options) + assert "*(full output omitted — 20 lines)*" in result + + def test_truncation_html_format(self): + """HTML format shows truncation marker (currently escaped in code block).""" + long_output = "\n".join(f"line {i}" for i in range(20)) + session = _make_session(decisions=[ + {"node_id": "node-1", "question": "Check", "answer": "OK", + "command_output": long_output}, + ]) + options = SessionExport(format="html", detail_level="standard") + result = generate_html_export(session, options) + # HTML escaping causes to become <em> in pre/code blocks + # This is actually correct behavior for code blocks + assert "full output omitted" in result + assert "20 lines" in result + assert "line 19" not in result + + def test_summary_block_psa(self): + """Summary block appears when include_summary is True.""" + session = _make_session() + options = SessionExport(format="psa", include_summary=True) + result = generate_psa_export(session, options) + assert "--- SUMMARY ---" in result + assert "Issue:" in result + assert "Status:" in result + + def test_no_summary_by_default(self): + """Summary block should not appear by default.""" + session = _make_session() + options = SessionExport(format="psa") + result = generate_psa_export(session, options) + assert "--- SUMMARY ---" not in result + + def test_summary_block_markdown(self): + """Summary block in markdown uses table format.""" + session = _make_session() + options = SessionExport(format="markdown", include_summary=True) + result = generate_markdown_export(session, options) + assert "## Summary" in result + assert "| Issue |" in result + + def test_summary_status_completed(self): + """Completed resolved session shows Resolved status in summary.""" + session = _make_session() + session.outcome = "resolved" + options = SessionExport(format="psa", include_summary=True) + result = generate_psa_export(session, options) + assert "Status: Resolved" in result + + def test_summary_status_in_progress(self): + """In-progress session shows step count in summary status.""" + session = _make_session( + decisions=[{"node_id": "n1", "question": "Step 1", "answer": "Done"}], + completed_at=None, + ) + session.completed_at = None + options = SessionExport(format="psa", include_summary=True) + result = generate_psa_export(session, options) + assert "In Progress" in result + + def test_summary_empty_fields_no_placeholders(self): + """Empty summary fields should be blank, not show placeholders.""" + session = _make_session() + session.outcome_notes = None + session.next_steps = None + options = SessionExport(format="psa", include_summary=True) + result = generate_psa_export(session, options) + assert "[Edit in preview]" not in result diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index f2dfa351..4b650738 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -1010,3 +1010,312 @@ class TestSessions: data = response.json() assert isinstance(data, list) assert len(data) == 0 + + @pytest.mark.asyncio + async def test_complete_session_with_next_steps( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test completing session saves next_steps.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={ + "outcome": "resolved", + "outcome_notes": "Fixed the issue", + "next_steps": "Monitor for 48 hours" + }, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["next_steps"] == "Monitor for 48 hours" + assert data["outcome_notes"] == "Fixed the issue" + + @pytest.mark.asyncio + async def test_update_session_next_steps( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test updating next_steps via session update.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.put( + f"/api/v1/sessions/{session_id}", + json={"next_steps": "Schedule follow-up call"}, + headers=auth_headers + ) + + assert response.status_code == 200 + assert response.json()["next_steps"] == "Schedule follow-up call" + + @pytest.mark.asyncio + async def test_export_includes_outcome_notes_in_resolution( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that outcome_notes appear as Resolution section in exports.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={ + "outcome": "resolved", + "outcome_notes": "Replaced failed DIMM in slot A2", + "next_steps": "Monitor for 24 hours" + }, + headers=auth_headers + ) + + # Test markdown + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown"}, + headers=auth_headers + ) + assert response.status_code == 200 + content = response.text + assert "## Resolution" in content + assert "Replaced failed DIMM in slot A2" in content + assert "## Next Steps" in content + assert "Monitor for 24 hours" in content + + # Test text + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "text"}, + headers=auth_headers + ) + content = response.text + assert "RESOLUTION" in content + assert "Replaced failed DIMM in slot A2" in content + assert "NEXT STEPS" in content + assert "Monitor for 24 hours" in content + + # Test HTML + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "html"}, + headers=auth_headers + ) + content = response.text + assert "Resolution" in content + assert "Replaced failed DIMM in slot A2" in content + assert "Next Steps" in content + assert "Monitor for 24 hours" in content + + # Test PSA + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "psa"}, + headers=auth_headers + ) + content = response.text + assert "Replaced failed DIMM in slot A2" in content + assert "Monitor for 24 hours" in content + + @pytest.mark.asyncio + async def test_export_omits_empty_resolution_and_next_steps( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that empty outcome_notes/next_steps don't create empty sections.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, + headers=auth_headers + ) + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown"}, + headers=auth_headers + ) + content = response.text + assert "## Resolution" not in content + assert "## Next Steps" not in content + + @pytest.mark.asyncio + async def test_export_exclude_outcome_notes_flag( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test include_outcome_notes=False suppresses resolution section.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={ + "outcome": "resolved", + "outcome_notes": "Should not appear" + }, + headers=auth_headers + ) + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "include_outcome_notes": False}, + headers=auth_headers + ) + content = response.text + assert "## Resolution" not in content + assert "Should not appear" not in content + + @pytest.mark.asyncio + async def test_export_max_step_index( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test max_step_index limits exported steps.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + decisions = [ + {"node_id": "n1", "question": "Step one?", "answer": "Yes", "timestamp": "2026-02-13T10:00:00Z", "attachments": []}, + {"node_id": "n2", "question": "Step two?", "answer": "No", "timestamp": "2026-02-13T10:01:00Z", "attachments": []}, + {"node_id": "n3", "question": "Step three?", "answer": "Maybe", "timestamp": "2026-02-13T10:02:00Z", "attachments": []}, + ] + await client.put( + f"/api/v1/sessions/{session_id}", + json={"decisions": decisions}, + headers=auth_headers + ) + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "max_step_index": 2}, + headers=auth_headers + ) + content = response.text + assert "Step one?" in content + assert "Step two?" in content + assert "Step three?" not in content + + @pytest.mark.asyncio + async def test_export_max_step_index_exceeds_count( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test max_step_index larger than decision count returns all steps.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + decisions = [ + {"node_id": "n1", "question": "Only step", "answer": "Done", "timestamp": "2026-02-13T10:00:00Z", "attachments": []}, + ] + await client.put( + f"/api/v1/sessions/{session_id}", + json={"decisions": decisions}, + headers=auth_headers + ) + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "max_step_index": 100}, + headers=auth_headers + ) + assert response.status_code == 200 + assert "Only step" in response.text + + @pytest.mark.asyncio + async def test_export_max_step_index_zero_returns_422( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test max_step_index=0 returns validation error.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + response = await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown", "max_step_index": 0}, + headers=auth_headers + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + async def test_export_in_progress_session_does_not_mark_exported( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that exporting an in-progress session does NOT set exported=True.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown"}, + headers=auth_headers + ) + + response = await client.get( + f"/api/v1/sessions/{session_id}", + headers=auth_headers + ) + assert response.json()["exported"] is False + + @pytest.mark.asyncio + async def test_export_completed_session_marks_exported( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Test that exporting a completed session sets exported=True.""" + create_response = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers + ) + session_id = create_response.json()["id"] + + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved"}, + headers=auth_headers + ) + + await client.post( + f"/api/v1/sessions/{session_id}/export", + json={"format": "markdown"}, + headers=auth_headers + ) + + response = await client.get( + f"/api/v1/sessions/{session_id}", + headers=auth_headers + ) + assert response.json()["exported"] is True 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: diff --git a/docs/plans/2026-02-13-export-phase-b-design.md b/docs/plans/2026-02-13-export-phase-b-design.md new file mode 100644 index 00000000..d98833c8 --- /dev/null +++ b/docs/plans/2026-02-13-export-phase-b-design.md @@ -0,0 +1,121 @@ +# Export Improvements Phase B — Design + +> **Date:** 2026-02-13 +> **Depends on:** Phase A (complete on `feat/export-phase-a`) +> **Scope:** B1 (Summary Block), B2 (Custom Step Markers), B3 (Detail Levels), B4 (Editable Preview) + +--- + +## Decisions Made + +| Question | Decision | +|----------|----------| +| B1 summary form vs single editable preview | Single editable preview (B4). No separate structured form for summary fields. | +| Detail levels | standard/full only. Dropped "summary" level — primary users are engineers, not dispatchers. | +| Mid-session editable preview | No. TreeNavigationPage keeps quick one-click copy. Editable preview is SessionDetailPage only. | + +--- + +## 1. Schema Changes + +Add to `SessionExport` (backend + frontend): + +```python +include_summary: bool = False +detail_level: Literal["standard", "full"] = "standard" +``` + +No database migration needed — these are export-time options only. + +--- + +## 2. Custom Step Differentiation (B2) + +Detect custom steps by checking `node_id.startswith("custom-")` in each decision dict. + +**Markdown:** +```markdown +### Step 5: [CUSTOM] Check Additional Event Logs +*Custom step added by engineer* +``` + +**Text:** +``` +5. [CUSTOM] Check Additional Event Logs +``` + +**HTML:** +```html +CUSTOM +``` + +**PSA:** +``` +5. [CUSTOM] Check Additional Event Logs -> ... +``` + +Pure backend change — all 4 generators in `export_service.py`. + +--- + +## 3. Summary Block (B1) + +When `include_summary=True`, insert a Summary section after metadata, before Evidence/Steps. + +Auto-populated fields: + +| Field | Source | +|-------|--------| +| Issue | Tree name + description | +| Impact | `[Edit in preview]` placeholder | +| Status | "Resolved" if completed, else "In Progress — paused at step N" | +| Resolution | `outcome_notes` if available | +| Next Steps | `next_steps` if available | + +Blank/placeholder fields are editable in the preview modal (B4). Format varies by generator (markdown table, text key-value, HTML styled table, PSA `--- SUMMARY ---` section). + +The summary block is opt-in (`include_summary=False` default), independent of detail level. + +--- + +## 4. Detail Levels (B3) + +Two levels: + +- **standard** (default): Current behavior, except command outputs >5 lines are truncated with `*(full output omitted — N lines)*` +- **full**: No truncation. All command outputs, scratchpad, notes rendered completely. + +Implementation: Helper function `_truncate_command_output(output, max_lines=5)` used in all 4 generators when `detail_level="standard"`. + +Frontend: Dropdown on SessionDetailPage export controls — "Standard" / "Full Detail". + +--- + +## 5. Editable Preview (B4) + +Modify `ExportPreviewModal`: + +- Replace read-only `
` with editable `