diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 1d7148a0..bcdad10f 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -175,6 +175,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) @@ -232,6 +254,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) @@ -304,6 +344,20 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append(f'

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

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

Resolution

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

Next Steps

') + html_parts.append(f'
{html.escape(next_steps.strip())}
') + html_parts.extend(['', '']) return "\n".join(html_parts) @@ -360,9 +414,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: lines.append("No steps recorded.") lines.append("") - # Resolution - last decision answer + # Resolution lines.append("--- RESOLUTION ---") - if session.decisions: + _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) @@ -372,6 +430,14 @@ def generate_psa_export(session: Session, options: SessionExport) -> str: 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 ---") duration = _format_duration(session.started_at, session.completed_at) diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index 64342e0d..b3c49021 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -1058,3 +1058,129 @@ class TestSessions: 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