feat: export improvements — Phase A + Phase B #75

Merged
chihlasm merged 19 commits from feat/export-phase-a into main 2026-02-13 23:30:32 +00:00
16 changed files with 2137 additions and 112 deletions

View 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')

View File

@@ -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)

View File

@@ -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")

View File

@@ -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):

View File

@@ -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"<em>(full output omitted — {count} lines)</em>"
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'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
html_parts.append('</div>')
if options.include_summary:
summary = _build_summary_fields(session)
html_parts.append('<h2>Summary</h2>')
html_parts.append('<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">')
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'<tr><td style="padding: 6px 12px; border: 1px solid #ddd; font-weight: bold; width: 120px;">{html.escape(label)}</td>')
html_parts.append(f'<td style="padding: 6px 12px; border: 1px solid #ddd;">{html.escape(value)}</td></tr>')
html_parts.append('</table>')
# 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('<h2>Troubleshooting Steps</h2>')
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('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
is_custom = decision.get("node_id", "").startswith("custom-")
custom_badge = '<span style="background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-right: 6px;">CUSTOM</span>' if is_custom else ''
html_parts.append(f'<h3>{custom_badge}Step {i}: {question}</h3>')
if answer:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
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"<code>{html.escape(c)}</code>" for c in commands)
@@ -304,6 +464,20 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
# 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('<h2>Resolution</h2>')
html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(outcome_notes.strip())}</div>')
# 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('<h2>Next Steps</h2>')
html_parts.append(f'<div style="white-space: pre-wrap; margin-bottom: 20px;">{html.escape(next_steps.strip())}</div>')
html_parts.extend(['</body>', '</html>'])
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 ---")

View File

@@ -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}")

View File

@@ -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</span>" 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 <em> to become &lt;em&gt; 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

View File

@@ -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

View File

@@ -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:

View File

@@ -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
<span style="background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-right: 6px;">CUSTOM</span>
```
**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 `<pre>` with editable `<textarea>`
- Local state `editedContent` initialized from `content` prop
- "Copy" copies `editedContent`
- "Download" downloads `editedContent`
- "Reset" button restores original content
- "Include Summary" checkbox re-fetches export with `include_summary=true`
- Edits are NOT saved back to the session
Only applies to SessionDetailPage. TreeNavigationPage keeps instant "Copy for Ticket" behavior.
---
## Files Affected
**Backend:**
- `backend/app/schemas/session.py` — Add `include_summary`, `detail_level` to `SessionExport`
- `backend/app/services/export_service.py` — All 4 generators: custom step markers, summary block, command output truncation
**Frontend:**
- `frontend/src/types/session.ts` — Add `include_summary`, `detail_level` to `SessionExport`
- `frontend/src/components/session/ExportPreviewModal.tsx` — Editable textarea, reset, summary toggle
- `frontend/src/pages/SessionDetailPage.tsx` — Detail level dropdown, pass new options to export calls
**No migration needed.**

View File

@@ -0,0 +1,898 @@
# Export Improvements Phase B — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add summary block, custom step markers, detail levels (standard/full), and editable preview modal to the export system.
**Architecture:** Backend changes to all 4 export generators (markdown, text, HTML, PSA) + schema additions. Frontend changes to ExportPreviewModal (editable textarea) and SessionDetailPage (detail level dropdown, summary toggle). No migration needed.
**Tech Stack:** Python FastAPI, Pydantic v2, React 19, TypeScript, Tailwind CSS
**Prerequisites:** Phase A frontend must be merged first (branch `feat/export-phase-a`). Task 7 depends on `maxStepIndex` state and `handleCopyForTicket` from Phase A.
---
## Task 1: Add Phase B Fields to Backend Schema
**Files:**
- Modify: `backend/app/schemas/session.py:84-91`
**Step 1: Add `include_summary` and `detail_level` to `SessionExport`**
```python
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"
```
Note: `Literal` is already imported at line 2.
**Step 2: Run tests to verify no regressions**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All existing export tests pass (new fields have defaults, so backward-compatible).
**Step 3: Commit**
```bash
git add backend/app/schemas/session.py
git commit -m "feat: add include_summary and detail_level to SessionExport schema"
```
---
## Task 2: Add Frontend Types for Phase B
**Files:**
- Modify: `frontend/src/types/session.ts:79-86`
**Step 1: Add `include_summary` and `detail_level` to `SessionExport` interface**
```typescript
export interface SessionExport {
format: 'text' | 'markdown' | 'html' | 'psa'
include_timestamps?: boolean
include_tree_info?: boolean
include_outcome_notes?: boolean
include_next_steps?: boolean
max_step_index?: number
include_summary?: boolean
detail_level?: 'standard' | 'full'
}
```
**Step 2: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Expected: No type errors.
**Step 3: Commit**
```bash
git add frontend/src/types/session.ts
git commit -m "feat(frontend): add include_summary and detail_level to SessionExport type"
```
---
## Task 3: Custom Step Markers (B2) in All 4 Generators
**Files:**
- Modify: `backend/app/services/export_service.py`
This is a pure backend change. Detect custom steps by `node_id.startswith("custom-")` and prefix the step title with `[CUSTOM]`.
**Step 1: Update `generate_markdown_export`** (line 163)
Change:
```python
lines.append(f"### Step {i}: {question}")
```
To:
```python
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*")
```
**Step 2: Update `generate_text_export`** (line 250)
Change:
```python
lines.append(f"\n{i}. {question}")
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
prefix = "[CUSTOM] " if is_custom else ""
lines.append(f"\n{i}. {prefix}{question}")
```
**Step 3: Update `generate_html_export`** (line 342)
Change:
```python
html_parts.append(f'<h3>Step {i}: {question}</h3>')
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
custom_badge = '<span style="background: #7c3aed; color: white; padding: 2px 6px; border-radius: 3px; font-size: 0.75em; margin-right: 6px;">CUSTOM</span>' if is_custom else ''
html_parts.append(f'<h3>{custom_badge}Step {i}: {question}</h3>')
```
**Step 4: Update `generate_psa_export`** (line 413)
Change:
```python
line = f"{i}. {question}"
```
To:
```python
is_custom = decision.get("node_id", "").startswith("custom-")
prefix = "[CUSTOM] " if is_custom else ""
line = f"{i}. {prefix}{question}"
```
**Step 5: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass. (Existing tests don't have custom steps, so markers won't appear — no regressions.)
**Step 6: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add [CUSTOM] markers to custom steps in all 4 export generators"
```
---
## Task 4: Command Output Truncation + Detail Levels (B3)
**Files:**
- Modify: `backend/app/services/export_service.py`
**Step 1: Add format-aware `_truncate_command_output` helper** (after `_get_command_output` at line 91)
The truncation suffix must match the output format — markdown uses `*(...)*`, text/PSA use plain `(...)`, HTML uses `<em>...</em>`.
```python
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"<em>(full output omitted — {count} lines)</em>"
else: # text, psa
suffix = f"(full output omitted — {count} lines)"
return f"{truncated}\n{suffix}"
```
**Step 2: Apply truncation in `generate_markdown_export`** (line 168)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="markdown")
```
**Step 3: Apply truncation in `generate_text_export`** (line 255)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="text")
```
**Step 4: Apply truncation in `generate_html_export`** (line 347)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="html")
```
**Step 5: Apply truncation in `generate_psa_export`** (line 421)
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output, fmt="psa")
```
**Step 6: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass (default `detail_level="standard"` but existing test command outputs are short).
**Step 7: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add format-aware command output truncation for standard detail level"
```
---
## Task 5: Summary Block Generation (B1)
**Files:**
- Modify: `backend/app/services/export_service.py`
Add summary block generation to all 4 generators. The summary is inserted after metadata, before Evidence/Steps. Only rendered when `options.include_summary is True`.
**Design decision — placeholder policy:** Empty fields (no outcome_notes, no next_steps) are left blank (empty string), NOT filled with `[Edit in preview]`. This avoids placeholder text leaking into copied/exported output. The frontend preview textarea is where users add missing info manually.
**Step 1: Add `_build_summary_fields` helper** (after `_get_outcome_label` at line 113)
```python
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,
}
```
**Step 2: Add `_escape_markdown_table` helper** (right after `_build_summary_fields`)
Pipe characters and newlines in values break markdown table cells:
```python
def _escape_markdown_table(value: str) -> str:
"""Escape value for use in a markdown table cell."""
return value.replace("|", "\\|").replace("\n", " ")
```
**Step 3: Add summary block to `generate_markdown_export`**
Insert after the tree_info block (after line 138, before the scratchpad section):
```python
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("")
```
**Step 4: Add summary block to `generate_text_export`**
Insert after tree_info block (after line 227, before scratchpad section):
```python
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("")
```
**Step 5: Add summary block to `generate_html_export`**
Insert after tree_info `</div>` (after line 321, before scratchpad section):
```python
if options.include_summary:
summary = _build_summary_fields(session)
html_parts.append('<h2>Summary</h2>')
html_parts.append('<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">')
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'<tr><td style="padding: 6px 12px; border: 1px solid #ddd; font-weight: bold; width: 120px;">{html.escape(label)}</td>')
html_parts.append(f'<td style="padding: 6px 12px; border: 1px solid #ddd;">{html.escape(value)}</td></tr>')
html_parts.append('</table>')
```
**Step 6: Add summary block to `generate_psa_export`**
Insert after `lines.append("")` on line 394 (after the header, before PROBLEM section):
```python
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("")
```
**Step 7: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py tests/test_export_security.py -v`
Expected: All pass (summary is opt-in, existing tests don't set `include_summary=True`).
**Step 8: Commit**
```bash
git add backend/app/services/export_service.py
git commit -m "feat: add summary block generation to all 4 export generators"
```
---
## Task 6: Editable Preview Modal (B4)
**Files:**
- Modify: `frontend/src/components/session/ExportPreviewModal.tsx`
**Design decision — edit preservation on summary toggle:** Toggling "Include Summary" re-fetches from the backend, which resets `editedContent`. This is documented and expected — the toast says "Summary updated" so the user understands. Edits are lightweight (engineers tweak a few words), so the cost of re-typing is low vs. the complexity of merging diffs.
**Step 1: Replace read-only preview with editable textarea and add controls**
Rewrite the component:
```tsx
import { useState, useEffect } from 'react'
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
interface ExportPreviewModalProps {
isOpen: boolean
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html' | 'psa'
onDownload: (content: string) => void
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
}
export function ExportPreviewModal({
isOpen,
onClose,
content,
filename,
format,
onDownload,
includeSummary = false,
onToggleSummary,
}: ExportPreviewModalProps) {
const [copied, setCopied] = useState(false)
const [editedContent, setEditedContent] = useState(content)
// Sync editedContent when content prop changes (new fetch / summary toggle)
useEffect(() => {
setEditedContent(content)
}, [content])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(editedContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const handleDownload = () => {
onDownload(editedContent)
onClose()
}
const handleReset = () => {
setEditedContent(content)
}
const handleClose = () => {
setCopied(false)
onClose()
}
const isModified = editedContent !== content
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Export Preview" size="xl">
{/* Filename, format info, and controls */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-white/70">
Filename: <span className="font-mono text-white">{filename}</span>
<span className="ml-3 rounded bg-white/10 px-2 py-0.5 text-xs text-white/70">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
</span>
{isModified && (
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
)}
</p>
<div className="flex items-center gap-3">
{onToggleSummary && (
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input
type="checkbox"
checked={includeSummary}
onChange={(e) => onToggleSummary(e.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/50"
/>
Include Summary
</label>
)}
{isModified && (
<button
onClick={handleReset}
className="flex items-center gap-1 text-xs text-white/40 hover:text-white"
title="Reset to original"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
{/* Editable Content */}
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-white/10 bg-black/50 p-4',
'font-mono text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium',
'text-white/60 hover:bg-white/10 hover:text-white',
'focus:outline-none focus:ring-2 focus:ring-white/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
'hover:bg-white/90 focus:outline-none focus:ring-2 focus:ring-white/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
</Modal>
)
}
export default ExportPreviewModal
```
Key changes:
- `<pre>``<textarea>` with `editedContent` local state
- Copy/Download use `editedContent` instead of `content` prop
- Reset button appears when content is modified
- `onDownload` signature changes to `(content: string) => void` to receive edited content
- New optional `includeSummary` + `onToggleSummary` props for summary checkbox
- `(edited)` indicator when content has been modified
- Summary toggle re-fetches content, resetting edits (documented behavior — avoids diff-merge complexity)
**Step 2: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Fix any type errors from the `onDownload` signature change (see Task 7).
**Step 3: Commit**
```bash
git add frontend/src/components/session/ExportPreviewModal.tsx
git commit -m "feat(frontend): convert ExportPreviewModal to editable textarea with reset"
```
---
## Task 7: Wire Up Phase B Controls in SessionDetailPage
**Files:**
- Modify: `frontend/src/pages/SessionDetailPage.tsx`
**Step 1: Add state for detail level and include summary** (after line 34)
```typescript
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
```
**Step 2: Update `fetchExportContent` to include Phase B options** (line 92-101)
```typescript
const fetchExportContent = async () => {
if (!session) return null
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
return await sessionsApi.export(session.id, options)
}
```
**Step 3: Update `handleCopyForTicket` to include Phase B options** (line 140-145)
```typescript
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
```
**Step 4: Update `handleDownload` to accept content parameter** (line 159)
Change:
```typescript
const handleDownload = () => {
if (!exportContent || !session) return
const blob = new Blob([exportContent], { type: 'text/plain' })
```
To:
```typescript
const handleDownload = (content: string) => {
if (!session) return
const blob = new Blob([content], { type: 'text/plain' })
```
**Step 5: Add `onToggleSummary` handler** with error handling
```typescript
const handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
if (!session) return
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: include,
}
try {
const content = await sessionsApi.export(session.id, options)
if (content) {
setExportContent(content)
toast.success(include ? 'Summary added' : 'Summary removed')
}
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setIncludeSummary(!include) // Revert checkbox on failure
}
}
```
**Step 6: Add detail level dropdown** to the export controls area (after step cutoff dropdown, before copy button — after line 408)
```tsx
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className={cn(
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
```
**Step 7: Update ExportPreviewModal props** (line 516-523)
```tsx
<ExportPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
content={exportContent || ''}
filename={getFilename()}
format={exportFormat}
onDownload={handleDownload}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
/>
```
**Step 8: Build to verify**
Run: `cd /c/Dev/Projects/patherly/frontend && npx tsc --noEmit`
Expected: No type errors.
**Step 9: Commit**
```bash
git add frontend/src/pages/SessionDetailPage.tsx
git commit -m "feat(frontend): add detail level dropdown and summary toggle to export controls"
```
---
## Task 8: Backend Tests for Phase B Features
**Files:**
- Modify: `backend/tests/test_psa_export.py`
Uses existing `_make_session` helper and `_default_options` from `test_psa_export.py`. Also imports additional generators for cross-format tests.
**Step 1: Add imports** at the top of `test_psa_export.py`
```python
from app.services.export_service import (
generate_psa_export, generate_text_export, generate_markdown_export,
generate_html_export, _format_duration,
)
```
**Step 2: Add `TestPhaseB` test class** at the bottom of the file
```python
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</span>" 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 uses <em> 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="html", detail_level="standard")
result = generate_html_export(session, options)
assert "<em>(full output omitted — 20 lines)</em>" 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 — paused at step 1" 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
```
**Step 3: Run all export tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py -v`
Expected: All tests pass including new ones.
**Step 4: Commit**
```bash
git add backend/tests/test_psa_export.py
git commit -m "test: add Phase B tests for custom markers, detail levels, and summary block"
```
---
## Task 9: Final Build Verification
**Step 1: Run full backend tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest --override-ini="addopts=" -v`
Expected: All tests pass.
**Step 2: Run frontend build**
Run: `cd /c/Dev/Projects/patherly/frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Verify git status is clean**
```bash
git status
git log --oneline feat/export-phase-a --not main | head -20
```
---
## Frontend Acceptance Checklist (Manual QA)
1. **Editable preview:** Open Preview, edit text, verify Copy/Download use edited content. Click Reset to restore original.
2. **Summary toggle:** Check "Include Summary" in preview — export re-fetches with summary block (edits reset, toast confirms). Uncheck removes it.
3. **Summary toggle error:** Disconnect network, toggle summary — checkbox reverts, error toast shown.
4. **Detail level:** Select "Full Detail", export a session with long command output — no truncation. Switch to "Standard" — output truncated with format-appropriate marker.
5. **Custom step markers:** Export a session with custom steps — should show `[CUSTOM]` prefix.
6. **Summary block content:** Summary should auto-populate Issue from tree name, Status from completion state, Resolution from outcome_notes. Empty fields are blank (no placeholder text).
7. **No placeholder leak:** Enable summary on a session with no outcome_notes — Resolution field should be blank, not show `[Edit in preview]`.

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Copy, Download, Check } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
@@ -9,7 +9,9 @@ interface ExportPreviewModalProps {
content: string
filename: string
format: 'markdown' | 'text' | 'html' | 'psa'
onDownload: () => void
onDownload: (content: string) => void
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
}
export function ExportPreviewModal({
@@ -19,12 +21,20 @@ export function ExportPreviewModal({
filename,
format,
onDownload,
includeSummary = false,
onToggleSummary,
}: ExportPreviewModalProps) {
const [copied, setCopied] = useState(false)
const [editedContent, setEditedContent] = useState(content)
// Sync editedContent when content prop changes (new fetch / summary toggle)
useEffect(() => {
setEditedContent(content)
}, [content])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content)
await navigator.clipboard.writeText(editedContent)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
@@ -33,39 +43,79 @@ export function ExportPreviewModal({
}
const handleDownload = () => {
onDownload()
onDownload(editedContent)
onClose()
}
// Reset copied state when modal closes
const handleReset = () => {
setEditedContent(content)
}
const handleClose = () => {
setCopied(false)
onClose()
}
const isModified = editedContent !== content
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Export Preview" size="xl">
{/* Filename and format info */}
<p className="mb-3 text-sm text-white/70">
Filename: <span className="font-mono text-white">{filename}</span>
<span className="ml-3 rounded bg-white/10 px-2 py-0.5 text-xs text-white/70">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : 'Plain Text'}
</span>
</p>
{/* Content Preview */}
<div
className={cn(
'max-h-96 overflow-auto rounded-md border border-white/10 bg-black/50 p-4',
'font-mono text-sm text-white'
)}
>
<pre className="whitespace-pre-wrap">{content}</pre>
{/* Filename, format info, and controls */}
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-white/70">
Filename: <span className="font-mono text-white">{filename}</span>
<span className="ml-3 rounded bg-white/10 px-2 py-0.5 text-xs text-white/70">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
</span>
{isModified && (
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
)}
</p>
<div className="flex items-center gap-3">
{onToggleSummary && (
<label className="flex items-center gap-2 text-sm text-white/60 cursor-pointer">
<input
type="checkbox"
checked={includeSummary}
onChange={(e) => onToggleSummary(e.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/50"
/>
Include Summary
</label>
)}
{isModified && (
<button
type="button"
onClick={handleReset}
className="flex items-center gap-1 text-xs text-white/40 hover:text-white"
title="Reset to original"
>
<RotateCcw className="h-3 w-3" />
Reset
</button>
)}
</div>
</div>
{/* Editable Content */}
<label htmlFor="export-content" className="sr-only">
Export content
</label>
<textarea
id="export-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-white/10 bg-black/50 p-4',
'font-mono text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-sm font-medium',
@@ -86,6 +136,7 @@ export function ExportPreviewModal({
)}
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-medium text-black',

View File

@@ -6,7 +6,7 @@ import type { SessionOutcome } from '@/types'
interface SessionOutcomeModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise<void>
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => Promise<void>
isSubmitting?: boolean
}
@@ -30,10 +30,12 @@ export function SessionOutcomeModal({
const formData = new FormData(formRef.current)
const outcome = (formData.get('session-outcome') as SessionOutcome | null) ?? 'resolved'
const outcomeNotes = ((formData.get('outcome-notes') as string | null) ?? '').trim()
const nextSteps = ((formData.get('next-steps') as string | null) ?? '').trim()
await onSubmit({
outcome,
outcome_notes: outcomeNotes || undefined,
next_steps: nextSteps || undefined,
})
}
@@ -113,6 +115,21 @@ export function SessionOutcomeModal({
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-white">Next Steps / Follow-Up (optional)</label>
<textarea
name="next-steps"
defaultValue=""
rows={3}
placeholder="Actions to take after this session..."
className={cn(
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
'text-sm text-white placeholder:text-white/40',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
/>
</div>
</form>
</Modal>
)

View File

@@ -31,6 +31,9 @@ export function SessionDetailPage() {
const [isSavingRatings, setIsSavingRatings] = useState(false)
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
const [includeSummary, setIncludeSummary] = useState(false)
useEffect(() => {
if (id) {
@@ -94,6 +97,9 @@ export function SessionDetailPage() {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
return await sessionsApi.export(session.id, options)
}
@@ -139,6 +145,9 @@ export function SessionDetailPage() {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: includeSummary,
}
const content = await sessionsApi.export(session.id, options)
if (content) {
@@ -153,9 +162,9 @@ export function SessionDetailPage() {
}
}
const handleDownload = () => {
if (!exportContent || !session) return
const blob = new Blob([exportContent], { type: 'text/plain' })
const handleDownload = (content: string) => {
if (!session) return
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -166,6 +175,30 @@ export function SessionDetailPage() {
URL.revokeObjectURL(url)
}
const handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
if (!session) return
const options: SessionExport = {
format: exportFormat,
include_timestamps: true,
include_tree_info: true,
...(maxStepIndex !== null && { max_step_index: maxStepIndex }),
detail_level: detailLevel,
include_summary: include,
}
try {
const content = await sessionsApi.export(session.id, options)
if (content) {
setExportContent(content)
toast.success(include ? 'Summary added' : 'Summary removed')
}
} catch (err) {
console.error('Failed to re-fetch export:', err)
toast.error('Failed to update export')
setIncludeSummary(!include)
}
}
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
if (!session) return
setIsSavingTree(true)
@@ -332,6 +365,12 @@ export function SessionDetailPage() {
{session.outcome_notes && (
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
)}
{session.next_steps && (
<div className="mt-2">
<span className="text-sm text-white/40">Next Steps:</span>
<p className="mt-0.5 text-sm text-white/60 whitespace-pre-wrap">{session.next_steps}</p>
</div>
)}
</div>
{/* Actions */}
@@ -379,6 +418,36 @@ export function SessionDetailPage() {
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
{session.decisions.length > 1 && (
<select
value={maxStepIndex ?? ''}
onChange={(e) => setMaxStepIndex(e.target.value ? Number(e.target.value) : null)}
aria-label="Export through step"
className={cn(
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
>
<option value="">All steps</option>
{session.decisions.map((_, idx) => (
<option key={idx + 1} value={idx + 1}>
Through step {idx + 1}
</option>
))}
</select>
)}
<select
value={detailLevel}
onChange={(e) => setDetailLevel(e.target.value as 'standard' | 'full')}
aria-label="Detail level"
className={cn(
'rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
)}
>
<option value="standard">Standard</option>
<option value="full">Full Detail</option>
</select>
<button
onClick={handleCopy}
disabled={isExporting}
@@ -493,6 +562,8 @@ export function SessionDetailPage() {
filename={getFilename()}
format={exportFormat}
onDownload={handleDownload}
includeSummary={includeSummary}
onToggleSummary={handleToggleSummary}
/>
{/* Save as Tree Modal */}

View File

@@ -10,7 +10,8 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, HelpCircle } from 'lucide-react'
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle } from 'lucide-react'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
interface LocationState {
@@ -45,6 +46,8 @@ export function TreeNavigationPage() {
const [copiedCommand, setCopiedCommand] = useState<string | null>(null)
const [shortcutsModalOpen, setShortcutsModalOpen] = useState(false)
const [selectingOption, setSelectingOption] = useState<string | null>(null)
const [copiedForTicket, setCopiedForTicket] = useState(false)
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
const handleCopyCommand = (text: string) => {
navigator.clipboard.writeText(text)
@@ -52,6 +55,29 @@ export function TreeNavigationPage() {
setTimeout(() => setCopiedCommand(null), 2000)
}
const handleCopyForTicket = async () => {
if (!session || isCopyingForTicket) return
setIsCopyingForTicket(true)
try {
const content = await sessionsApi.export(session.id, {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
})
if (content) {
await navigator.clipboard.writeText(content)
setCopiedForTicket(true)
setTimeout(() => setCopiedForTicket(false), 2000)
toast.success('Copied progress notes to clipboard')
}
} catch (err) {
console.error('Copy for ticket failed:', err)
toast.error('Failed to copy notes')
} finally {
setIsCopyingForTicket(false)
}
}
// Session metadata (prefill from Repeat Last Session)
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
@@ -338,7 +364,7 @@ export function TreeNavigationPage() {
openCompletionModal(completionDecision, 'standard')
}
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string; next_steps?: string }) => {
if (!session) return
setIsCompleting(true)
setError(null)
@@ -549,12 +575,26 @@ export function TreeNavigationPage() {
</p>
)}
</div>
<button
onClick={() => navigate('/sessions')}
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
>
Exit
</button>
<div className="flex items-center gap-2">
<button
onClick={handleCopyForTicket}
disabled={isCopyingForTicket}
title="Copy progress notes for ticket"
className={cn(
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
'hover:bg-white/10 hover:text-white transition-colors disabled:opacity-50'
)}
>
{copiedForTicket ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
{copiedForTicket ? 'Copied!' : 'Copy for Ticket'}
</button>
<button
onClick={() => navigate('/sessions')}
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
>
Exit
</button>
</div>
</div>
{/* Breadcrumb */}

View File

@@ -57,6 +57,7 @@ export interface Session {
client_name: string | null
exported: boolean
scratchpad: string
next_steps: string
}
export interface SessionCreate {
@@ -72,17 +73,24 @@ export interface SessionUpdate {
ticket_number?: string
client_name?: string
scratchpad?: string
next_steps?: string
}
export interface SessionExport {
format: 'text' | 'markdown' | 'html' | 'psa'
include_timestamps?: boolean
include_tree_info?: boolean
include_outcome_notes?: boolean
include_next_steps?: boolean
max_step_index?: number
include_summary?: boolean
detail_level?: 'standard' | 'full'
}
export interface SessionComplete {
outcome: SessionOutcome
outcome_notes?: string
next_steps?: string
}
// Navigation state for active session