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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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('<h2>Troubleshooting Steps</h2>')
|
||||
|
||||
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", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user