Files
resolutionflow/backend/app/services/export_service.py

335 lines
13 KiB
Python

"""
Session export generators for ResolutionFlow.
Provides markdown, plain text, HTML, and PSA/ticket note export formatters
for troubleshooting sessions.
"""
import html
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:
return "In progress"
delta = completed_at - started_at
total_seconds = int(delta.total_seconds())
if total_seconds < 0:
return "0 minutes"
hours, remainder = divmod(total_seconds, 3600)
minutes = remainder // 60
if hours > 0:
return f"{hours}h {minutes}m"
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")
lines.append(f"# {tree_name}")
lines.append("")
if session.ticket_number:
lines.append(f"**Ticket:** {session.ticket_number}")
if session.client_name:
lines.append(f"**Client:** {session.client_name}")
if options.include_timestamps:
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("")
# Scratchpad / Evidence section
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("## Evidence / Reference")
lines.append("")
lines.append(scratchpad)
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Troubleshooting Steps")
lines.append("")
for i, decision in enumerate(session.decisions, 1):
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("")
return "\n".join(lines)
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")
lines.append(tree_name)
lines.append("=" * len(tree_name))
if session.ticket_number:
lines.append(f"Ticket: {session.ticket_number}")
if session.client_name:
lines.append(f"Client: {session.client_name}")
if options.include_timestamps:
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
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
lines.append("EVIDENCE / REFERENCE")
lines.append("-" * 20)
lines.append(scratchpad)
lines.append("")
lines.append("TROUBLESHOOTING STEPS")
lines.append("-" * 20)
for i, decision in enumerate(session.decisions, 1):
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)
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">',
f'<title>{tree_name}</title>',
'<style>',
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
'h1 { color: #333; }',
'.meta { color: #666; margin-bottom: 20px; }',
'.step { margin-bottom: 15px; padding: 10px; background: #f5f5f5; border-radius: 5px; }',
'.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>']
if options.include_tree_info:
html_parts.append(f'<h1>{tree_name}</h1>')
html_parts.append('<div class="meta">')
if session.ticket_number:
html_parts.append(f'<p><strong>Ticket:</strong> {html.escape(session.ticket_number)}</p>')
if session.client_name:
html_parts.append(f'<p><strong>Client:</strong> {html.escape(session.client_name)}</p>')
if options.include_timestamps:
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
scratchpad = getattr(session, 'scratchpad', '') or ''
if scratchpad.strip():
html_parts.append('<h2>Evidence / Reference</h2>')
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
html_parts.append('<h2>Troubleshooting Steps</h2>')
for i, decision in enumerate(session.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", ""))
duration_seconds = _get_step_duration_seconds(decision)
html_parts.append('<div class="step">')
html_parts.append(f'<h3>Step {i}: {question}</h3>')
if answer:
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>')
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)
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", "")
ticket_number = session.ticket_number or "N/A"
client_name = session.client_name or "N/A"
date_str = session.started_at.strftime("%Y-%m-%d %H:%M")
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
lines.append("--- PROBLEM ---")
lines.append(tree_description if tree_description else "No description provided.")
lines.append("")
# Steps taken
lines.append("--- STEPS TAKEN ---")
if session.decisions:
for i, decision in enumerate(session.decisions, 1):
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}")
else:
lines.append("No steps recorded.")
lines.append("")
# Resolution - last decision answer
lines.append("--- RESOLUTION ---")
if 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("")
# Time spent
lines.append("--- TIME SPENT ---")
duration = _format_duration(session.started_at, session.completed_at)
lines.append(f"Duration: {duration}")
lines.append("")
# Engineer notes (scratchpad)
lines.append("--- ENGINEER NOTES ---")
scratchpad = getattr(session, 'scratchpad', '') or ''
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
return "\n".join(lines)