Files
resolutionflow/docs/plans/2026-02-13-export-phase-a.md
2026-02-15 00:43:41 -05:00

24 KiB

Export Improvements Phase A — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add outcome_notes, next_steps, mid-session export awareness, and partial step cutoff to all four export formats.

Architecture: Add next_steps column via migration. Extend SessionExport schema with new options. Update all four generators in export_service.py to render Resolution + Next Steps sections and support step slicing. Guard exported=True behind completion check.

Tech Stack: Python, FastAPI, SQLAlchemy, Alembic, pytest

Spec: 2026-02-13-EXPORT-IMPROVEMENTS-SPEC.md — Phase A only


Task 1: Add next_steps column — Migration + Model

Files:

  • Modify: backend/app/models/session.py:56 (after scratchpad)
  • Create: backend/alembic/versions/034_add_next_steps_to_sessions.py

Step 1: Add column to Session model

In backend/app/models/session.py, add after the scratchpad column (line 58):

    next_steps: Mapped[Optional[str]] = mapped_column(
        Text, nullable=True, server_default=sa.text("''")
    )

Step 2: Generate migration

Run: cd /c/Dev/Projects/patherly/backend && python -m alembic revision --autogenerate -m "add next_steps to sessions"

Review the generated migration — it should add a single next_steps TEXT column with server_default ''.

Rename the file to 034_add_next_steps_to_sessions.py and update the revision ID comment if needed for clarity.

Step 3: Run migration

Run: cd /c/Dev/Projects/patherly/backend && python -m alembic upgrade head

Step 4: Commit

git add backend/app/models/session.py backend/alembic/versions/*next_steps*
git commit -m "feat: add next_steps column to sessions table"

Task 2: Update Schemas — SessionExport, SessionUpdate, SessionResponse, SessionComplete

Files:

  • Modify: backend/app/schemas/session.py

Step 1: Update SessionExport (line 82)

Replace the current SessionExport class with:

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

Step 2: Add next_steps to SessionUpdate (line 46)

Add to SessionUpdate:

    next_steps: Optional[str] = None

Step 3: Add next_steps to SessionResponse (line 56)

Add after outcome_notes:

    next_steps: str = ""

Update the validator to normalize both fields:

    @validator('scratchpad', 'next_steps', pre=True, always=True)
    def normalize_text_fields(cls, v):
        return v or ""

Remove the old normalize_scratchpad validator.

Step 4: Add next_steps to SessionComplete (line 88)

class SessionComplete(BaseModel):
    outcome: SessionOutcome
    outcome_notes: Optional[str] = None
    next_steps: Optional[str] = None

Step 5: Run tests to verify no regressions

Run: cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q

Expected: All existing tests pass (new fields have defaults).

Step 6: Commit

git add backend/app/schemas/session.py
git commit -m "feat: add next_steps and export options to session schemas"

Task 3: Update Completion Endpoint to Save next_steps

Files:

  • Modify: backend/app/api/endpoints/sessions.py:236-238

Step 1: Write failing test

In backend/tests/test_sessions.py, add after the existing completion tests:

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

Step 2: Run test to verify it fails

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_complete_session_with_next_steps -v

Expected: FAIL — next_steps not saved (returns "" because endpoint doesn't set it).

Step 3: Update completion endpoint

In backend/app/api/endpoints/sessions.py, line 238, add after session.outcome_notes = completion_data.outcome_notes:

    session.next_steps = completion_data.next_steps

Step 4: Run test to verify it passes

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_complete_session_with_next_steps -v

Expected: PASS

Step 5: Commit

git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
git commit -m "feat: save next_steps on session completion"

Task 4: Update SessionUpdate Endpoint to Save next_steps

Note: update_session blocks updates to completed sessions (returns 400). This is intentional — next_steps is set during active sessions or at completion time, not after. No changes needed to that guard.

Files:

  • Modify: backend/app/api/endpoints/sessions.py (the update_session handler)

Step 1: Write failing test

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

Step 2: Run test — verify it fails

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_update_session_next_steps -v

Step 3: Update the update_session endpoint

Find the update_session handler in sessions.py. Look for where it applies SessionUpdate fields to the session object. Add next_steps to that block, following the same pattern as scratchpad:

    if update_data.next_steps is not None:
        session.next_steps = update_data.next_steps

Step 4: Run test — verify it passes

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py::TestSessions::test_update_session_next_steps -v

Step 5: Commit

git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
git commit -m "feat: allow next_steps update via session update endpoint"

Task 5: Add outcome_notes + next_steps to Export Generators

Files:

  • Modify: backend/app/services/export_service.py
  • Test: backend/tests/test_sessions.py

This is the core export change. All four generators need Resolution and Next Steps sections after the steps.

Step 1: Write failing tests

Add to test_sessions.py:

    @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

Step 2: Run tests — verify they fail

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "outcome_notes_in_resolution or omits_empty_resolution or exclude_outcome_notes_flag" -v

Step 3: Update all four generators in export_service.py

Add Resolution and Next Steps sections after the Troubleshooting Steps in each generator. The generators receive options: SessionExport — use options.include_outcome_notes and options.include_next_steps.

Markdown generator — add before return "\n".join(lines) (line 178):

    # Resolution / Outcome Notes
    outcome_notes = getattr(session, 'outcome_notes', '') or ''
    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
    next_steps = getattr(session, 'next_steps', '') or ''
    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("")

Text generator — add before return "\n".join(lines) (line 235):

    # Resolution
    outcome_notes = getattr(session, 'outcome_notes', '') or ''
    if outcome_notes.strip() and options.include_outcome_notes:
        lines.append("")
        lines.append("RESOLUTION")
        lines.append("-" * 20)
        lines.append(outcome_notes.strip())

    # Next Steps
    next_steps = getattr(session, 'next_steps', '') or ''
    if next_steps.strip() and options.include_next_steps:
        lines.append("")
        lines.append("NEXT STEPS")
        lines.append("-" * 20)
        lines.append(next_steps.strip())

HTML generator — add before html_parts.extend(['</body>', '</html>']) (line 307):

    # Resolution
    outcome_notes = getattr(session, 'outcome_notes', '') or ''
    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
    next_steps = getattr(session, 'next_steps', '') or ''
    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>')

PSA generator — update the existing --- RESOLUTION --- section (lines 363-373) to use outcome_notes when available, and add a next steps section:

Replace the resolution section with:

    # Resolution
    lines.append("--- RESOLUTION ---")
    outcome_notes = getattr(session, 'outcome_notes', '') or ''
    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
    next_steps = getattr(session, 'next_steps', '') or ''
    if next_steps.strip() and options.include_next_steps:
        lines.append("--- NEXT STEPS ---")
        lines.append(next_steps.strip())
        lines.append("")

Step 4: Run tests — verify they pass

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "outcome_notes_in_resolution or omits_empty_resolution or exclude_outcome_notes_flag" -v

Step 5: Run full test suite

Run: cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q

Step 6: Commit

git add backend/app/services/export_service.py backend/tests/test_sessions.py
git commit -m "feat: add Resolution and Next Steps sections to all export formats"

Task 6: Partial Export with Step Cutoff (max_step_index)

Files:

  • Modify: backend/app/services/export_service.py
  • Test: backend/tests/test_sessions.py

Step 1: Write failing tests

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

        # Add 3 decisions
        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
        )

        # Export with cutoff at step 2
        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

Step 2: Run tests — verify they fail (the 422 test should already pass due to ge=1 on schema)

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "max_step_index" -v

Step 3: Apply step slicing in all four generators

In each generator, right before the for i, decision in enumerate(session.decisions, 1): loop, add:

    decisions = session.decisions
    if options.max_step_index is not None:
        decisions = decisions[:options.max_step_index]

Then change the loop to iterate over decisions instead of session.decisions.

Apply this in:

  • generate_markdown_export (line ~153)
  • generate_text_export (line ~214)
  • generate_html_export (line ~283)
  • generate_psa_export (line ~338)

Step 4: Run tests — verify they pass

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "max_step_index" -v

Step 5: Commit

git add backend/app/services/export_service.py backend/tests/test_sessions.py
git commit -m "feat: add max_step_index for partial exports"

Task 7: Mid-Session Export — Don't Set exported=True

Files:

  • Modify: backend/app/api/endpoints/sessions.py:316-318
  • Test: backend/tests/test_sessions.py

Step 1: Write failing test

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

        # Export without completing
        await client.post(
            f"/api/v1/sessions/{session_id}/export",
            json={"format": "markdown"},
            headers=auth_headers
        )

        # Check session - should NOT be marked exported
        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"]

        # Complete first
        await client.post(
            f"/api/v1/sessions/{session_id}/complete",
            json={"outcome": "resolved"},
            headers=auth_headers
        )

        # Export
        await client.post(
            f"/api/v1/sessions/{session_id}/export",
            json={"format": "markdown"},
            headers=auth_headers
        )

        # Check - should be marked exported
        response = await client.get(
            f"/api/v1/sessions/{session_id}",
            headers=auth_headers
        )
        assert response.json()["exported"] is True

Step 2: Run tests — verify the first one fails

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "in_progress_session_does_not_mark_exported or completed_session_marks_exported" -v

Step 3: Update export endpoint

In backend/app/api/endpoints/sessions.py, replace lines 316-318:

    # Mark as exported
    session.exported = True
    await db.commit()

With:

    # Only mark as exported if session is completed
    if session.completed_at:
        session.exported = True
        await db.commit()

Step 4: Run tests — verify they pass

Run: cd /c/Dev/Projects/patherly/backend && pytest tests/test_sessions.py -k "in_progress_session_does_not_mark_exported or completed_session_marks_exported" -v

Step 5: Run full test suite

Run: cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -x -q

Step 6: Commit

git add backend/app/api/endpoints/sessions.py backend/tests/test_sessions.py
git commit -m "feat: only mark session exported when completed"

Task 8: Final Verification

Step 1: Run full test suite

Run: cd /c/Dev/Projects/patherly/backend && pytest --override-ini="addopts=" -v

Expected: All tests pass, including all new tests.

Step 2: Verify backward compatibility

Confirm that the existing export tests (the ones from before our changes) still pass with no modifications — the new schema fields all have defaults.

Step 3: Commit any remaining changes and verify git status is clean

git status