From 8797ae0261af8654c752c37aea3f0e45aea76bee Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 13 Feb 2026 08:58:03 -0500 Subject: [PATCH] feat: add step cutoff and mid-session export support - max_step_index slices decisions in all 4 export formats - Only set exported=True when session is completed Co-Authored-By: Claude Opus 4.6 --- backend/app/api/endpoints/sessions.py | 7 +- backend/app/services/export_service.py | 25 ++++- backend/tests/test_sessions.py | 135 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 4dbb83c9..68bdaf4c 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -314,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) diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index bcdad10f..ac8f0135 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -150,7 +150,11 @@ 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", "") @@ -233,7 +237,11 @@ 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", "") @@ -320,7 +328,11 @@ def generate_html_export(session: Session, options: SessionExport) -> str: html_parts.append('

Troubleshooting Steps

') - 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", "")) @@ -388,8 +400,11 @@ 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", "") diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py index b3c49021..4b650738 100644 --- a/backend/tests/test_sessions.py +++ b/backend/tests/test_sessions.py @@ -1184,3 +1184,138 @@ class TestSessions: 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