"""PSA Documentation Push Service. Generates structured documentation from FlowPilot AI sessions and pushes it back to ConnectWise as internal notes + optional time entries. """ import logging import math import uuid from datetime import datetime, timezone, timedelta from typing import Optional, Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.ai_session import AISession from app.models.psa_activity_log import PsaActivityLog from app.models.psa_connection import PsaConnection from app.models.psa_member_mapping import PsaMemberMapping from app.models.psa_post_log import PsaPostLog from app.services.psa.registry import get_provider_for_connection from app.services.psa.types import NoteType from app.services.redaction_service import apply_redaction_to_text logger = logging.getLogger(__name__) # Default flowpilot_settings values DEFAULT_SETTINGS = { "auto_push": True, "auto_time_entry": True, "time_rounding": "15min", # "15min", "30min", "exact", "none" "note_visibility": "internal", # "internal", "both" "include_diagnostic_steps": True, } def _get_setting(connection: PsaConnection, key: str) -> Any: """Get a flowpilot setting with default fallback.""" settings = connection.flowpilot_settings or {} return settings.get(key, DEFAULT_SETTINGS.get(key)) def _round_hours(hours: float, rounding: str) -> float: """Round hours according to the rounding setting.""" if rounding == "exact": return round(hours, 2) elif rounding == "30min": return math.ceil(hours * 2) / 2 else: # default 15min return math.ceil(hours * 4) / 4 def _format_datetime(dt: datetime | None) -> str: """Format a datetime for display in notes.""" if not dt: return "N/A" return dt.strftime("%Y-%m-%d %I:%M %p UTC") def _get_engineer_response(step) -> str | None: """Extract the engineer's response label from a step.""" if step.was_skipped: return "Skipped" if step.selected_option and step.options_presented: for opt in step.options_presented: if opt.get("value") == step.selected_option: return opt.get("label", step.selected_option) return step.selected_option if step.selected_option: return step.selected_option if step.free_text_input: return step.free_text_input return None def format_resolution_note(session: AISession, include_steps: bool = True) -> str: """Format a resolved session as a plain-text note for CW.""" engineer_name = getattr(session, 'user', None) engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown" duration_str = "" if session.resolved_at and session.created_at: delta = session.resolved_at - session.created_at total_hrs = round(delta.total_seconds() / 3600, 2) duration_str = f" — {total_hrs} hrs" lines = [ f"FlowPilot Session — {engineer_display}{duration_str}", f"Problem: {session.problem_summary or 'No summary available'}", ] # Diagnostic steps if include_steps and session.steps: lines.append("") lines.append("Steps:") for step in session.steps: content = step.content or {} step_type = content.get("type", "") if step_type == "resolution_suggestion": continue # Not a diagnostic step description = content.get("text", "").strip() if not description: continue response = _get_engineer_response(step) line = f"{step.step_order + 1}. {description}" if response and response != "Skipped": line += f" — {response}" elif response == "Skipped": line += " (skipped)" lines.append(line) # Resolution lines.append("") lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}") if session.resolution_action: lines.append(session.resolution_action) # Follow-up recommendations from resolution suggestion step follow_ups: list[str] = [] for step in session.steps: content = step.content or {} if content.get("type") == "resolution_suggestion": recs = content.get("follow_up_recommendations", []) if isinstance(recs, list): follow_ups.extend(recs) if follow_ups: lines.append("") lines.append("Follow-up:") for rec in follow_ups: lines.append(f"- {rec}") # Timing lines.append("") lines.append(f"Start: {_format_datetime(session.created_at)}") lines.append(f"End: {_format_datetime(session.resolved_at)}") if session.resolved_at and session.created_at: delta = session.resolved_at - session.created_at total_hrs = round(delta.total_seconds() / 3600, 2) lines.append(f"Total: {total_hrs} hrs") lines.append("") lines.append("Generated by ResolutionFlow") return "\n".join(lines) def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]: """Return (confirmed, ruled_out, pending) findings. Uses session.evidence_items when the cockpit branch is merged; falls back to deriving from completed diagnostic steps. """ evidence_items = getattr(session, 'evidence_items', None) if evidence_items: confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed'] ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out'] pending = [e['text'] for e in evidence_items if e.get('status') == 'pending'] return confirmed, ruled_out, pending # Derive from completed steps — all answered steps become findings findings = [] for step in sorted(session.steps or [], key=lambda s: s.step_order): content = step.content or {} if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): continue description = content.get("text", "").strip() if not description or step.was_skipped: continue response = _get_engineer_response(step) if response: findings.append(f"{description} — {response}") return findings, [], [] def format_escalation_note(session: AISession, include_steps: bool = True) -> str: """Format an escalated session as a plain-text note for CW.""" engineer_obj = getattr(session, 'user', None) engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown" escalated_to_obj = getattr(session, 'escalated_to', None) escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None escalated_at = session.resolved_at or datetime.now(timezone.utc) duration_str = "" if session.created_at: delta = escalated_at - session.created_at total_hrs = round(delta.total_seconds() / 3600, 2) duration_str = f" — {total_hrs} hrs" header = f"FlowPilot Escalation — {engineer_display}{duration_str}" if escalated_to_display: header += f" → {escalated_to_display}" lines = [ header, f"Problem: {session.problem_summary or 'No summary available'}", ] # Work completed with responses if include_steps and session.steps: lines.append("") lines.append("Work completed:") for step in sorted(session.steps, key=lambda s: s.step_order): content = step.content or {} if content.get("type") in ("resolution_suggestion", "briefing", "status_update"): continue description = content.get("text", "").strip() if not description: continue response = _get_engineer_response(step) line = f"{step.step_order + 1}. {description}" if response and response != "Skipped": line += f" — {response}" elif response == "Skipped": line += " (skipped)" lines.append(line) # What We Know confirmed, ruled_out, pending = _derive_what_we_know(session) if confirmed or ruled_out or pending: lines.append("") lines.append("What we know:") for f in confirmed: lines.append(f" ✓ {f}") for f in ruled_out: lines.append(f" ✗ {f}") for f in pending: lines.append(f" ? {f}") # Escalation reason lines.append("") lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}") # Suggested next steps from escalation package pkg = session.escalation_package or {} if suggestions := pkg.get("suggested_next_steps"): lines.append("") lines.append("Suggested next steps:") items = suggestions if isinstance(suggestions, list) else [str(suggestions)] for s in items: lines.append(f"- {s}") # Timing lines.append("") lines.append(f"Start: {_format_datetime(session.created_at)}") lines.append(f"Escalated: {_format_datetime(escalated_at)}") if session.created_at: delta = escalated_at - session.created_at total_hrs = round(delta.total_seconds() / 3600, 2) lines.append(f"Total: {total_hrs} hrs") lines.append("") lines.append("Generated by ResolutionFlow") return "\n".join(lines) async def push_documentation( session: AISession, user_id: UUID, db: AsyncSession, ) -> dict[str, Any]: """Push session documentation to PSA ticket. Returns: { "psa_push_status": "sent" | "pending_retry" | "failed" | "no_psa", "psa_push_error": str | None, "member_mapping_warning": str | None, } """ if not session.psa_ticket_id or not session.psa_connection_id: return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None} # Load connection and check settings result = await db.execute( select(PsaConnection).where(PsaConnection.id == session.psa_connection_id) ) connection = result.scalar_one_or_none() if not connection: return {"psa_push_status": "failed", "psa_push_error": "PSA connection not found", "member_mapping_warning": None} if not _get_setting(connection, "auto_push"): return {"psa_push_status": "no_psa", "psa_push_error": None, "member_mapping_warning": None} # Format the note include_steps = _get_setting(connection, "include_diagnostic_steps") if session.status == "resolved": note_text = format_resolution_note(session, include_steps=include_steps) else: note_text = format_escalation_note(session, include_steps=include_steps) # Redact sensitive data note_text, _ = apply_redaction_to_text(note_text) # Determine note type visibility = _get_setting(connection, "note_visibility") note_type = NoteType.INTERNAL_ANALYSIS if visibility == "internal" else NoteType.DESCRIPTION # Check member mapping for time entry member_mapping_warning = None member_mapping = None if _get_setting(connection, "auto_time_entry") and _get_setting(connection, "time_rounding") != "none": mapping_result = await db.execute( select(PsaMemberMapping).where( PsaMemberMapping.psa_connection_id == session.psa_connection_id, PsaMemberMapping.user_id == user_id, ) ) member_mapping = mapping_result.scalar_one_or_none() if not member_mapping: member_mapping_warning = "Map your CW account in Settings → Integrations to enable auto-logged time entries." # Push to PSA try: provider = await get_provider_for_connection(session.psa_connection_id, db) # Post the note posted_note = await provider.post_note( ticket_id=session.psa_ticket_id, text=note_text, note_type=note_type, ) # Create time entry if member mapping exists time_entry_hours: Optional[float] = None if member_mapping and session.resolved_at and session.created_at: try: delta = session.resolved_at - session.created_at hours = delta.total_seconds() / 3600 rounding = _get_setting(connection, "time_rounding") rounded_hours = _round_hours(hours, rounding) if rounded_hours > 0: await provider.create_time_entry( ticket_id=session.psa_ticket_id, member_id=member_mapping.external_member_id, hours=rounded_hours, notes=f"FlowPilot session: {session.problem_summary or 'Troubleshooting'}", ) time_entry_hours = rounded_hours except Exception as e: logger.warning("Failed to create time entry for session %s: %s", session.id, e) # Don't fail the note push just because time entry failed # Log PSA activity — note posted try: note_activity = PsaActivityLog( account_id=session.account_id, session_id=session.id, activity_type="note_posted", hours_logged=None, psa_ticket_id=session.psa_ticket_id, ) db.add(note_activity) except Exception as e: logger.warning("Failed to log PSA note activity for session %s: %s", session.id, e) # Log time entry activity if one was created if time_entry_hours is not None: try: time_activity = PsaActivityLog( account_id=session.account_id, session_id=session.id, activity_type="time_entry_posted", hours_logged=time_entry_hours, psa_ticket_id=session.psa_ticket_id, ) db.add(time_activity) except Exception as e: logger.warning("Failed to log PSA time entry activity for session %s: %s", session.id, e) # Log success log_entry = PsaPostLog( id=uuid.uuid4(), account_id=session.account_id, ai_session_id=session.id, psa_connection_id=session.psa_connection_id, ticket_id=session.psa_ticket_id, note_type=note_type, content_posted=note_text[:10000], # Truncate for storage external_note_id=posted_note.id, status="success", posted_by=user_id, ) db.add(log_entry) return { "psa_push_status": "sent", "psa_push_error": None, "member_mapping_warning": member_mapping_warning, } except Exception as e: logger.warning("PSA push failed for session %s: %s", session.id, e) # Log failure with retry scheduling log_entry = PsaPostLog( id=uuid.uuid4(), account_id=session.account_id, ai_session_id=session.id, psa_connection_id=session.psa_connection_id, ticket_id=session.psa_ticket_id, note_type=note_type, content_posted=note_text[:10000], status="pending_retry", error_message=str(e)[:500], retry_count=0, next_retry_at=datetime.now(timezone.utc) + timedelta(minutes=5), posted_by=user_id, ) db.add(log_entry) return { "psa_push_status": "pending_retry", "psa_push_error": str(e)[:200], "member_mapping_warning": member_mapping_warning, } async def retry_failed_push( log_entry: PsaPostLog, db: AsyncSession, ) -> bool: """Retry a failed PSA push. Returns True on success.""" try: provider = await get_provider_for_connection(log_entry.psa_connection_id, db) posted_note = await provider.post_note( ticket_id=log_entry.ticket_id, text=log_entry.content_posted, note_type=log_entry.note_type, ) log_entry.status = "success" log_entry.external_note_id = posted_note.id log_entry.error_message = None log_entry.next_retry_at = None return True except Exception as e: log_entry.retry_count += 1 log_entry.error_message = str(e)[:500] if log_entry.retry_count >= 3: log_entry.status = "failed" log_entry.next_retry_at = None else: # Exponential backoff: 5min, 15min, 45min backoff_minutes = 5 * (3 ** log_entry.retry_count) log_entry.next_retry_at = datetime.now(timezone.utc) + timedelta(minutes=backoff_minutes) logger.warning( "PSA retry %d failed for log %s: %s", log_entry.retry_count, log_entry.id, e, ) return False