diff --git a/docs/plans/2026-02-13-export-phase-b.md b/docs/plans/2026-02-13-export-phase-b.md new file mode 100644 index 00000000..87b1fdfc --- /dev/null +++ b/docs/plans/2026-02-13-export-phase-b.md @@ -0,0 +1,762 @@ +# 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 + +--- + +## 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_export.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'

Step {i}: {question}

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

{custom_badge}Step {i}: {question}

') +``` + +**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_export.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 `_truncate_command_output` helper** (after `_get_command_output` at line 91) + +```python +def _truncate_command_output(output: str, max_lines: int = 5) -> str: + """Truncate command output to max_lines for standard detail level.""" + lines = output.splitlines() + if len(lines) <= max_lines: + return output + truncated = "\n".join(lines[:max_lines]) + return f"{truncated}\n*(full output omitted — {len(lines)} lines)*" +``` + +**Step 2: Apply truncation in `generate_markdown_export`** (line 168) + +Change: +```python + if command_output := _get_command_output(decision): +``` +To: +```python + if command_output := _get_command_output(decision): + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output) +``` + +**Step 3: Apply truncation in `generate_text_export`** (line 255) + +Same pattern — add truncation right after `if command_output := _get_command_output(decision):`: +```python + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output) +``` + +**Step 4: Apply truncation in `generate_html_export`** (line 347) + +Same pattern after `if command_output := _get_command_output(decision):`: +```python + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output) +``` + +**Step 5: Apply truncation in `generate_psa_export`** (line 421) + +Same pattern after `if command_output := _get_command_output(decision):`: +```python + if options.detail_level == "standard": + command_output = _truncate_command_output(command_output) +``` + +**Step 6: Run tests** + +Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_export.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 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`. + +**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.""" + 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() or "[Edit in preview]" + + _raw_next = getattr(session, 'next_steps', None) + next_steps = (_raw_next if isinstance(_raw_next, str) else '').strip() or "[Edit in preview]" + + return { + "issue": issue, + "impact": "[Edit in preview]", + "status": status, + "resolution": resolution, + "next_steps": next_steps, + } +``` + +**Step 2: 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) + lines.append("## Summary") + lines.append("") + lines.append(f"| Field | Details |") + lines.append(f"|-------|---------|") + 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("") + lines.append("---") + lines.append("") +``` + +**Step 3: 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 4: Add summary block to `generate_html_export`** + +Insert after tree_info `` (after line 321, before scratchpad section): + +```python + 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)}
') +``` + +**Step 5: 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 6: Run tests** + +Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_export.py -v` +Expected: All pass (summary is opt-in, existing tests don't set `include_summary=True`). + +**Step 7: 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` + +**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) + 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 ( + + {/* Filename, format info, and controls */} +
+

+ Filename: {filename} + + {format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'} + + {isModified && ( + (edited) + )} +

+
+ {onToggleSummary && ( + + )} + {isModified && ( + + )} +
+
+ + {/* Editable Content */} +