Implement session outcomes, step timing, and live timer fixes

This commit is contained in:
Michael Chihlas
2026-02-11 17:52:12 -05:00
parent 2a1ed4d250
commit ca4ce7cad6
15 changed files with 574 additions and 59 deletions

View File

@@ -0,0 +1,31 @@
"""add outcome fields to sessions
Revision ID: 029
Revises: 028
Create Date: 2026-02-11
Adds outcome and outcome_notes columns for session completion tracking.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "029"
down_revision: Union[str, None] = "028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True))
op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True))
op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions")
op.drop_column("sessions", "outcome_notes")
op.drop_column("sessions", "outcome")

View File

@@ -10,7 +10,16 @@ from app.core.database import get_db
from app.models.tree import Tree
from app.models.session import Session
from app.models.user import User
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
from app.schemas.session import (
SessionCreate,
SessionUpdate,
SessionResponse,
SessionExport,
ScratchpadUpdate,
SaveAsTreeRequest,
SaveAsTreeResponse,
SessionComplete,
)
from app.api.deps import get_current_active_user
from app.core.permissions import can_access_tree
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
@@ -198,6 +207,7 @@ async def update_session(
@router.post("/{session_id}/complete", response_model=SessionResponse)
async def complete_session(
session_id: UUID,
completion_data: SessionComplete,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
@@ -224,6 +234,8 @@ async def complete_session(
)
session.completed_at = datetime.now(timezone.utc)
session.outcome = completion_data.outcome
session.outcome_notes = completion_data.outcome_notes
await db.commit()
await db.refresh(session)
return session

View File

@@ -45,6 +45,8 @@ class Session(Base):
nullable=True,
index=True
)
outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@@ -3,6 +3,8 @@ from typing import Optional, Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, validator
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
class CustomStepSchema(BaseModel):
"""Enhanced custom step with source tracking.
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
notes: Optional[str] = None
automation_used: Optional[bool] = False
timestamp: datetime
entered_at: Optional[datetime] = None
exited_at: Optional[datetime] = None
duration_seconds: Optional[int] = Field(None, ge=0)
attachments: list[str] = Field(default_factory=list)
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
started_at: datetime
completed_at: Optional[datetime] = None
outcome: Optional[SessionOutcome] = None
outcome_notes: Optional[str] = None
ticket_number: Optional[str] = None
client_name: Optional[str] = None
exported: bool
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
include_tree_info: bool = True
class SessionComplete(BaseModel):
outcome: SessionOutcome
outcome_notes: Optional[str] = None
class ScratchpadUpdate(BaseModel):
scratchpad: str

View File

@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from app.models.session import Session
from app.schemas.session import SessionExport
OUTCOME_LABELS = {
"resolved": "Resolved",
"escalated": "Escalated",
"workaround": "Workaround Applied",
"unresolved": "Unresolved",
}
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
"""Format duration between two datetimes as human-readable string."""
if not completed_at:
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
return f"{minutes} minutes"
def _format_step_duration(duration_seconds: int) -> str:
"""Format step duration seconds as compact human-readable text."""
if duration_seconds < 0:
return "0s"
if duration_seconds < 60:
return f"{duration_seconds}s"
hours, remainder = divmod(duration_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if hours > 0:
if seconds > 0:
return f"{hours}h {minutes}m {seconds}s"
return f"{hours}h {minutes}m"
if seconds > 0:
return f"{minutes}m {seconds}s"
return f"{minutes}m"
def _parse_iso_datetime(value: Any) -> datetime | None:
"""Parse ISO datetime strings from JSONB with support for trailing Z."""
if isinstance(value, datetime):
return value
if not isinstance(value, str) or not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
"""Get step duration from explicit field or entered/exited timestamps."""
explicit_duration = decision.get("duration_seconds")
if isinstance(explicit_duration, (int, float)):
duration = int(explicit_duration)
if duration >= 0:
return duration
entered_at = _parse_iso_datetime(decision.get("entered_at"))
exited_at = _parse_iso_datetime(decision.get("exited_at"))
if entered_at and exited_at:
total_seconds = int((exited_at - entered_at).total_seconds())
if total_seconds >= 0:
return total_seconds
return None
def _get_outcome_label(session: Session) -> str | None:
"""Map stored outcome enum to human-friendly label."""
outcome = getattr(session, "outcome", None)
if not outcome:
return None
return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title())
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"**Outcome:** {outcome_label}")
lines.append("")
lines.append("---")
lines.append("")
@@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"### Step {i}: {question}")
if answer:
lines.append(f"**Answer:** {answer}")
if notes:
lines.append(f"**Notes:** {notes}")
if duration_seconds is not None:
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
if options.include_timestamps and decision.get("timestamp"):
lines.append(f"*{decision['timestamp']}*")
lines.append("")
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
lines = []
outcome_label = _get_outcome_label(session)
if options.include_tree_info:
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
@@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Scratchpad / Evidence section
@@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
lines.append(f"\n{i}. {question}")
if answer:
lines.append(f" Answer: {answer}")
if notes:
lines.append(f" Notes: {notes}")
if duration_seconds is not None:
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
return "\n".join(lines)
@@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
outcome_label = _get_outcome_label(session)
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
@@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
'.step h3 { margin: 0 0 10px 0; color: #444; }',
'.answer { font-weight: bold; }',
'.notes { font-style: italic; color: #555; }',
'.duration { color: #444; margin-top: 6px; }',
'.timestamp { font-size: 0.85em; color: #888; }',
'</style>',
'</head>', '<body>']
@@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html_parts.append(f'<p><strong>Duration:</strong> {_format_duration(session.started_at, session.completed_at)}</p>')
if outcome_label:
html_parts.append(f'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
html_parts.append('</div>')
# Scratchpad / Evidence section
@@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
answer = html.escape(decision.get("answer", ""))
notes = html.escape(decision.get("notes", ""))
duration_seconds = _get_step_duration_seconds(decision)
html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
@@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if duration_seconds is not None:
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
if options.include_timestamps and decision.get("timestamp"):
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
html_parts.append('</div>')
@@ -181,6 +266,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
tree_description = session.tree_snapshot.get("description", "")
@@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append("=== TROUBLESHOOTING NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Tree: {tree_name} | Date: {date_str}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Problem section
@@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
question = decision.get("question") or decision.get("action_performed", "Step")
answer = decision.get("answer", "")
notes = decision.get("notes", "")
duration_seconds = _get_step_duration_seconds(decision)
line = f"{i}. {question}"
if answer:
line += f" -> {answer}"
if duration_seconds is not None:
line += f" ({_format_step_duration(duration_seconds)})"
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(resolution)
else:
lines.append("No resolution recorded.")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Time spent

View File

@@ -153,12 +153,34 @@ class TestSessions:
# Complete session
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["completed_at"] is not None
assert data["outcome"] == "resolved"
assert data["outcome_notes"] == "Issue fixed after restarting service"
@pytest.mark.asyncio
async def test_complete_session_requires_outcome(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test that completion requires an outcome payload."""
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",
headers=auth_headers
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_complete_already_completed_session(
@@ -175,12 +197,14 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
# Try to complete again
response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -217,6 +241,59 @@ class TestSessions:
assert "EXP-001" in content # Should contain ticket number
assert "#" in content # Markdown headers
@pytest.mark.asyncio
async def test_export_markdown_includes_outcome_and_step_duration(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
):
"""Test markdown export includes session outcome and per-step duration."""
create_response = await client.post(
"/api/v1/sessions",
json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
headers=auth_headers
)
session_id = create_response.json()["id"]
step_timestamp = "2026-02-11T10:10:00Z"
update_response = await client.put(
f"/api/v1/sessions/{session_id}",
json={
"decisions": [{
"node_id": "root",
"question": "Is this a test?",
"answer": "Yes",
"action_performed": None,
"notes": "Validated quickly",
"automation_used": False,
"timestamp": step_timestamp,
"entered_at": "2026-02-11T10:08:30Z",
"exited_at": step_timestamp,
"duration_seconds": 90,
"attachments": []
}]
},
headers=auth_headers
)
assert update_response.status_code == 200
complete_response = await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
headers=auth_headers
)
assert complete_response.status_code == 200
export_response = await client.post(
f"/api/v1/sessions/{session_id}/export",
json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
headers=auth_headers
)
assert export_response.status_code == 200
content = export_response.text
assert "**Outcome:** Workaround Applied" in content
assert "**Duration:**" in content
assert "**Duration:** 1m 30s" in content
@pytest.mark.asyncio
async def test_export_session_text(
self, client: AsyncClient, auth_headers: dict, test_tree: dict
@@ -291,6 +368,7 @@ class TestSessions:
# Complete first session
await client.post(
f"/api/v1/sessions/{session1_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -449,6 +527,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)
@@ -857,6 +936,7 @@ class TestSessions:
await client.post(
f"/api/v1/sessions/{session_id}/complete",
json={"outcome": "resolved"},
headers=auth_headers
)