docs: update Phase B plan — fix test refs, placeholders, truncation formats

Addresses review feedback:
- Fix test file references (test_psa_export.py, not test_export.py)
- Use _make_session helper instead of nonexistent fixtures
- Format-aware truncation markers (markdown/html/text/psa)
- No placeholder leak: empty fields stay blank, not [Edit in preview]
- Markdown table escaping for pipe/newline chars
- Summary toggle error handling with checkbox revert
- Phase A prerequisite called out explicitly
- Document edit-reset behavior on summary toggle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-13 12:22:41 -05:00
parent 4e9d269b53
commit c5adab1685

View File

@@ -8,6 +8,8 @@
**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
@@ -35,7 +37,7 @@ 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`
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**
@@ -144,7 +146,7 @@ To:
**Step 5: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_export.py -v`
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**
@@ -161,65 +163,73 @@ git commit -m "feat: add [CUSTOM] markers to custom steps in all 4 export genera
**Files:**
- Modify: `backend/app/services/export_service.py`
**Step 1: Add `_truncate_command_output` helper** (after `_get_command_output` at line 91)
**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) -> str:
"""Truncate command output to max_lines for standard detail level."""
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])
return f"{truncated}\n*(full output omitted — {len(lines)} 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)
Change:
After `if command_output := _get_command_output(decision):` add:
```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)
command_output = _truncate_command_output(command_output, fmt="markdown")
```
**Step 3: Apply truncation in `generate_text_export`** (line 255)
Same pattern — add truncation right after `if command_output := _get_command_output(decision):`:
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output)
command_output = _truncate_command_output(command_output, fmt="text")
```
**Step 4: Apply truncation in `generate_html_export`** (line 347)
Same pattern after `if command_output := _get_command_output(decision):`:
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output)
command_output = _truncate_command_output(command_output, fmt="html")
```
**Step 5: Apply truncation in `generate_psa_export`** (line 421)
Same pattern after `if command_output := _get_command_output(decision):`:
After `if command_output := _get_command_output(decision):` add:
```python
if options.detail_level == "standard":
command_output = _truncate_command_output(command_output)
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_export.py -v`
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 command output truncation for standard detail level"
git commit -m "feat: add format-aware command output truncation for standard detail level"
```
---
@@ -231,11 +241,16 @@ git commit -m "feat: add command output truncation for standard detail level"
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."""
"""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
@@ -248,42 +263,53 @@ def _build_summary_fields(session: Session) -> dict[str, str]:
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]"
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() or "[Edit in preview]"
next_steps = (_raw_next if isinstance(_raw_next, str) else '').strip()
return {
"issue": issue,
"impact": "[Edit in preview]",
"impact": "",
"status": status,
"resolution": resolution,
"next_steps": next_steps,
}
```
**Step 2: Add summary block to `generate_markdown_export`**
**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(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("| 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 3: Add summary block to `generate_text_export`**
**Step 4: Add summary block to `generate_text_export`**
Insert after tree_info block (after line 227, before scratchpad section):
@@ -300,7 +326,7 @@ Insert after tree_info block (after line 227, before scratchpad section):
lines.append("")
```
**Step 4: Add summary block to `generate_html_export`**
**Step 5: Add summary block to `generate_html_export`**
Insert after tree_info `</div>` (after line 321, before scratchpad section):
@@ -317,7 +343,7 @@ Insert after tree_info `</div>` (after line 321, before scratchpad section):
html_parts.append('</table>')
```
**Step 5: Add summary block to `generate_psa_export`**
**Step 6: Add summary block to `generate_psa_export`**
Insert after `lines.append("")` on line 394 (after the header, before PROBLEM section):
@@ -333,12 +359,12 @@ Insert after `lines.append("")` on line 394 (after the header, before PROBLEM se
lines.append("")
```
**Step 6: Run tests**
**Step 7: Run tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_export.py -v`
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 7: Commit**
**Step 8: Commit**
```bash
git add backend/app/services/export_service.py
@@ -352,6 +378,8 @@ git commit -m "feat: add summary block generation to all 4 export generators"
**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:
@@ -386,7 +414,7 @@ export function ExportPreviewModal({
const [copied, setCopied] = useState(false)
const [editedContent, setEditedContent] = useState(content)
// Sync editedContent when content prop changes (new fetch)
// Sync editedContent when content prop changes (new fetch / summary toggle)
useEffect(() => {
setEditedContent(content)
}, [content])
@@ -420,7 +448,7 @@ export function ExportPreviewModal({
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Export Preview" size="xl">
{/* Filename, format info, and controls */}
<div className="mb-3 flex items-center justify-between">
<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">
@@ -513,6 +541,7 @@ Key changes:
- `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**
@@ -586,12 +615,11 @@ To:
const blob = new Blob([content], { type: 'text/plain' })
```
**Step 5: Add `onToggleSummary` handler**
**Step 5: Add `onToggleSummary` handler** with error handling
```typescript
const handleToggleSummary = async (include: boolean) => {
setIncludeSummary(include)
// Re-fetch with new option
if (!session) return
const options: SessionExport = {
format: exportFormat,
@@ -603,9 +631,14 @@ To:
}
try {
const content = await sessionsApi.export(session.id, options)
if (content) setExportContent(content)
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
}
}
```
@@ -659,75 +692,176 @@ git commit -m "feat(frontend): add detail level dropdown and summary toggle to e
## Task 8: Backend Tests for Phase B Features
**Files:**
- Modify: `backend/tests/test_export.py`
- Modify: `backend/tests/test_psa_export.py`
**Step 1: Add test for custom step markers**
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
def test_custom_step_markers_psa(self, completed_session):
"""Custom steps should have [CUSTOM] prefix in export."""
# Add a custom step to decisions
completed_session.decisions.append({
"node_id": "custom-abc123",
"question": "Check Additional Logs",
"answer": "Found error in event log",
"notes": None,
"timestamp": "2024-01-01T12:05:00Z",
})
options = SessionExport(format="psa")
result = generate_psa_export(completed_session, options)
assert "[CUSTOM] Check Additional Logs" in result
from app.services.export_service import (
generate_psa_export, generate_text_export, generate_markdown_export,
generate_html_export, _format_duration,
)
```
**Step 2: Add test for command output truncation**
**Step 2: Add `TestPhaseB` test class** at the bottom of the file
```python
def test_command_output_truncation_standard(self, completed_session):
"""Standard detail level truncates long command output."""
long_output = "\n".join(f"line {i}" for i in range(20))
completed_session.decisions[0]["command_output"] = long_output
options = SessionExport(format="text", detail_level="standard")
result = generate_text_export(completed_session, options)
assert "*(full output omitted — 20 lines)*" in result
class TestPhaseB:
"""Tests for Phase B export features: custom markers, detail levels, summary."""
def test_command_output_full_detail(self, completed_session):
"""Full detail level shows all command output."""
long_output = "\n".join(f"line {i}" for i in range(20))
completed_session.decisions[0]["command_output"] = long_output
options = SessionExport(format="text", detail_level="full")
result = generate_text_export(completed_session, options)
assert "*(full output omitted" not in result
assert "line 19" in result
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: Add test for summary block**
**Step 3: Run all export tests**
```python
def test_summary_block_psa(self, completed_session):
"""Summary block appears when include_summary is True."""
options = SessionExport(format="psa", include_summary=True)
result = generate_psa_export(completed_session, options)
assert "--- SUMMARY ---" in result
assert "Issue:" in result
assert "Status:" in result
def test_no_summary_by_default(self, completed_session):
"""Summary block should not appear by default."""
options = SessionExport(format="psa")
result = generate_psa_export(completed_session, options)
assert "--- SUMMARY ---" not in result
```
**Step 4: Run all export tests**
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_export.py -v`
Run: `cd /c/Dev/Projects/patherly/backend && python -m pytest tests/test_psa_export.py -v`
Expected: All tests pass including new ones.
**Step 5: Commit**
**Step 4: Commit**
```bash
git add backend/tests/test_export.py
git commit -m "test: add tests for custom step markers, detail levels, and summary block"
git add backend/tests/test_psa_export.py
git commit -m "test: add Phase B tests for custom markers, detail levels, and summary block"
```
---
@@ -756,7 +890,9 @@ 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. Uncheck removes it.
3. **Detail level:** Select "Full Detail", export a session with long command output — no truncation. Switch to "Standard" — output truncated.
4. **Custom step markers:** Export a session with custom steps — should show `[CUSTOM]` prefix.
5. **Summary block content:** Summary should auto-populate Issue from tree name, Status from completion state, Resolution from outcome_notes.
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]`.