"""FlowPilot Analytics API — MTTR, resolution rates, knowledge coverage. Endpoints: GET /analytics/flowpilot?period=30d — Main dashboard data GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report GET /analytics/flowpilot/escalations?period=30d — Escalation handoff metrics """ import logging import statistics from datetime import datetime, timezone, timedelta from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy import select, func, case, cast, Date, extract from sqlalchemy.ext.asyncio import AsyncSession from app.core.rate_limit import limiter from app.api.deps import ( get_current_active_user, get_db, require_engineer_or_admin, require_team_admin, ) from app.models.user import User from app.models.tree import Tree from app.models.ai_session import AISession from app.models.ai_session_step import AISessionStep from app.models.session_handoff import SessionHandoff from app.models.flow_proposal import FlowProposal from app.models.psa_activity_log import PsaActivityLog from app.models.psa_post_log import PsaPostLog from app.models.category import TreeCategory from app.schemas.flowpilot_analytics import ( FlowPilotDashboard, MTTRDataPoint, DomainBreakdown, ConfidenceBreakdown, KnowledgeCoverage, DomainCoverage, PsaMetrics, CoverageDomainRow, CoverageResponse, FlowQualityRow, FlowQualityResponse, EnhancedPsaMetrics, PsaFunnel, PsaDailyTrend, EscalationMetrics, ) from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport logger = logging.getLogger(__name__) router = APIRouter(prefix="/analytics/flowpilot", tags=["flowpilot-analytics"]) def _get_period_start(period: str) -> datetime: days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30) return datetime.now(timezone.utc) - timedelta(days=days) @router.get("", response_model=FlowPilotDashboard) @limiter.limit("15/minute") async def get_dashboard( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_team_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), ): """Get FlowPilot analytics dashboard data.""" if not current_user.account_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") account_id = current_user.account_id period_start = _get_period_start(period) # ── Session counts ── counts_result = await db.execute( select( func.count(AISession.id).label("total"), func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"), func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"), func.sum(case((AISession.status == "abandoned", 1), else_=0)).label("abandoned"), func.avg(case((AISession.status == "resolved", AISession.step_count), else_=None)).label("avg_steps"), func.avg(AISession.session_rating).label("avg_rating"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, ) ) row = counts_result.one() total = int(row.total or 0) resolved = int(row.resolved or 0) escalated = int(row.escalated or 0) abandoned = int(row.abandoned or 0) avg_steps = float(row.avg_steps or 0) avg_rating = float(row.avg_rating) if row.avg_rating else None resolution_rate = (resolved / total * 100) if total > 0 else 0.0 # ── MTTR ── mttr_result = await db.execute( select( func.avg( extract("epoch", AISession.resolved_at - AISession.created_at) / 60 ).label("avg_mttr"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.status == "resolved", AISession.resolved_at.isnot(None), ) ) mttr_row = mttr_result.one() mttr_minutes = float(mttr_row.avg_mttr) if mttr_row.avg_mttr else None # ── Average duration ── duration_result = await db.execute( select( func.avg( extract("epoch", AISession.resolved_at - AISession.created_at) / 60 ).label("avg_duration"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.resolved_at.isnot(None), ) ) dur_row = duration_result.one() avg_duration = float(dur_row.avg_duration) if dur_row.avg_duration else 0.0 # ── MTTR trend ── mttr_trend_result = await db.execute( select( cast(AISession.resolved_at, Date).label("day"), func.avg( extract("epoch", AISession.resolved_at - AISession.created_at) / 60 ).label("mttr"), func.count(AISession.id).label("count"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.status == "resolved", AISession.resolved_at.isnot(None), ) .group_by(cast(AISession.resolved_at, Date)) .order_by(cast(AISession.resolved_at, Date)) ) mttr_trend = [ MTTRDataPoint( date=str(r.day), mttr_minutes=round(float(r.mttr or 0), 1), session_count=r.count, ) for r in mttr_trend_result.all() ] # ── Domain breakdown ── domain_result = await db.execute( select( AISession.problem_domain, func.count(AISession.id).label("total"), func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"), func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.problem_domain.isnot(None), ) .group_by(AISession.problem_domain) .order_by(func.count(AISession.id).desc()) ) sessions_by_domain = [ DomainBreakdown( domain=r.problem_domain or "unknown", total=int(r.total or 0), resolved=int(r.resolved or 0), escalated=int(r.escalated or 0), resolution_rate=round(int(r.resolved or 0) / int(r.total) * 100, 1) if r.total else 0.0, ) for r in domain_result.all() ] # ── Confidence breakdown ── confidence_result = await db.execute( select( AISession.confidence_tier, func.count(AISession.id).label("total"), func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.status.in_(["resolved", "escalated", "requesting_escalation"]), ) .group_by(AISession.confidence_tier) ) conf_data = {r.confidence_tier: (int(r.total or 0), int(r.resolved or 0)) for r in confidence_result.all()} guided_total, guided_resolved = conf_data.get("guided", (0, 0)) exploring_total, exploring_resolved = conf_data.get("exploring", (0, 0)) discovery_total, discovery_resolved = conf_data.get("discovery", (0, 0)) confidence_breakdown = ConfidenceBreakdown( guided_sessions=guided_total, guided_resolution_rate=round(guided_resolved / guided_total * 100, 1) if guided_total > 0 else 0.0, exploring_sessions=exploring_total, exploring_resolution_rate=round(exploring_resolved / exploring_total * 100, 1) if exploring_total > 0 else 0.0, discovery_sessions=discovery_total, discovery_resolution_rate=round(discovery_resolved / discovery_total * 100, 1) if discovery_total > 0 else 0.0, ) # ── Knowledge coverage ── total_flows_result = await db.execute( select(func.count(Tree.id)).where(Tree.account_id == account_id) ) total_flows = total_flows_result.scalar() or 0 ai_flows_result = await db.execute( select(func.count(Tree.id)).where( Tree.account_id == account_id, Tree.origin.in_(["ai_generated", "ai_enhanced"]), ) ) ai_generated_flows = ai_flows_result.scalar() or 0 pending_proposals_result = await db.execute( select(func.count(FlowProposal.id)).where( FlowProposal.account_id == account_id, FlowProposal.status == "pending", ) ) total_proposals_pending = pending_proposals_result.scalar() or 0 approved_result = await db.execute( select(func.count(FlowProposal.id)).where( FlowProposal.account_id == account_id, FlowProposal.reviewed_at >= period_start, FlowProposal.status.in_(["approved", "modified"]), ) ) proposals_approved = approved_result.scalar() or 0 rejected_result = await db.execute( select(func.count(FlowProposal.id)).where( FlowProposal.account_id == account_id, FlowProposal.reviewed_at >= period_start, FlowProposal.status == "rejected", ) ) proposals_rejected = rejected_result.scalar() or 0 # Domain coverage domain_coverage_result = await db.execute( select( AISession.problem_domain, func.count(AISession.id).label("session_count"), func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided_count"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.problem_domain.isnot(None), ) .group_by(AISession.problem_domain) ) # For now, flow_count per domain isn't directly available since Tree doesn't have problem_domain. # Use match_keywords or just report 0. We'll improve this in Phase 4 with better flow categorization. domain_cov_data = {} for r in domain_coverage_result.all(): domain = r.problem_domain or "unknown" sc = r.session_count or 0 gc = r.guided_count or 0 domain_cov_data[domain] = DomainCoverage( domain=domain, flow_count=0, # TODO: match via category/tags in Phase 4 session_count=sc, guided_rate=round(gc / sc * 100, 1) if sc > 0 else 0.0, ) knowledge_coverage = KnowledgeCoverage( total_flows=total_flows, ai_generated_flows=ai_generated_flows, total_proposals_pending=total_proposals_pending, proposals_approved_this_period=proposals_approved, proposals_rejected_this_period=proposals_rejected, coverage_by_domain=list(domain_cov_data.values()), ) # ── PSA metrics ── psa_metrics = None psa_linked = await db.execute( select(func.count(AISession.id)).where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.psa_ticket_id.isnot(None), ) ) psa_linked_count = psa_linked.scalar() or 0 if psa_linked_count > 0 and total > 0: psa_push_result = await db.execute( select( func.count(PsaPostLog.id).label("total_pushes"), func.sum(case((PsaPostLog.status == "success", 1), else_=0)).label("first_success"), func.sum(case( ((PsaPostLog.status == "success") & (PsaPostLog.retry_count > 0), 1), else_=0 )).label("retry_success"), ) .join(AISession, PsaPostLog.ai_session_id == AISession.id) .where( AISession.account_id == account_id, PsaPostLog.ai_session_id.isnot(None), PsaPostLog.posted_at >= period_start, ) ) push_row = psa_push_result.one() total_pushes = push_row.total_pushes or 0 first_success = push_row.first_success or 0 retry_success = push_row.retry_success or 0 psa_metrics = PsaMetrics( ticket_link_rate=round(psa_linked_count / total * 100, 1), auto_push_success_rate=round(first_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0, auto_push_retry_success_rate=round(retry_success / total_pushes * 100, 1) if total_pushes > 0 else 0.0, total_time_entries_logged=0, # TODO: track from CW time entries total_hours_logged=0.0, ) return FlowPilotDashboard( period=period, total_sessions=total, resolved_sessions=resolved, escalated_sessions=escalated, abandoned_sessions=abandoned, resolution_rate=round(resolution_rate, 1), avg_steps_to_resolution=round(avg_steps, 1), avg_session_duration_minutes=round(avg_duration, 1), avg_rating=round(avg_rating, 2) if avg_rating else None, mttr_minutes=round(mttr_minutes, 1) if mttr_minutes else None, mttr_trend=mttr_trend, sessions_by_domain=sessions_by_domain, confidence_breakdown=confidence_breakdown, knowledge_coverage=knowledge_coverage, psa_metrics=psa_metrics, ) @router.get("/knowledge-gaps", response_model=KnowledgeGapReport) @limiter.limit("10/minute") async def get_knowledge_gaps_endpoint( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_team_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), ): """Get knowledge gap analysis report.""" if not current_user.account_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30) return await get_knowledge_gaps(current_user.account_id, db, period_days=days) @router.get("/coverage", response_model=CoverageResponse) @limiter.limit("15/minute") async def get_coverage_heatmap( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_team_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), ): """Get coverage heatmap: sessions and flow coverage broken down by problem domain.""" if not current_user.account_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") account_id = current_user.account_id period_start = _get_period_start(period) # ── Session stats per domain ── domain_stats_result = await db.execute( select( AISession.problem_domain, func.count(AISession.id).label("session_count"), func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved_count"), func.sum(case((AISession.status.in_(["escalated", "requesting_escalation"]), 1), else_=0)).label("escalated_count"), func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided_count"), func.avg( case( ( (AISession.status == "resolved") & AISession.resolved_at.isnot(None), extract("epoch", AISession.resolved_at - AISession.created_at) / 60, ), else_=None, ) ).label("avg_resolution_minutes"), ) .where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.problem_domain.isnot(None), ) .group_by(AISession.problem_domain) .order_by(func.count(AISession.id).desc()) ) domain_rows = domain_stats_result.all() # ── Unmapped sessions (no problem_domain) ── unmapped_result = await db.execute( select(func.count(AISession.id)).where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.problem_domain.is_(None), ) ) unmapped_session_count = int(unmapped_result.scalar() or 0) # ── Flow counts per domain: match Category.name to problem_domain ── # Joins Tree → TreeCategory and groups by lowercased category name for case-insensitive matching flow_counts_result = await db.execute( select( func.lower(TreeCategory.name).label("domain"), func.count(Tree.id).label("flow_count"), ) .join(Tree, Tree.category_id == TreeCategory.id) .where( Tree.account_id == account_id, Tree.is_active.is_(True), Tree.deleted_at.is_(None), ) .group_by(func.lower(TreeCategory.name)) ) flow_counts_by_domain: dict[str, int] = { r.domain: int(r.flow_count) for r in flow_counts_result.all() } domains = [] for r in domain_rows: sc = int(r.session_count or 0) resolved = int(r.resolved_count or 0) escalated = int(r.escalated_count or 0) guided = int(r.guided_count or 0) domain_name = r.problem_domain or "unknown" avg_res = float(r.avg_resolution_minutes) if r.avg_resolution_minutes is not None else None domains.append( CoverageDomainRow( domain=domain_name, flow_count=flow_counts_by_domain.get(domain_name.lower(), 0), session_count=sc, resolution_rate=round(resolved / sc, 4) if sc > 0 else 0.0, escalation_rate=round(escalated / sc, 4) if sc > 0 else 0.0, guided_rate=round(guided / sc, 4) if sc > 0 else 0.0, avg_resolution_minutes=round(avg_res, 1) if avg_res is not None else None, ) ) return CoverageResponse( domains=domains, unmapped_session_count=unmapped_session_count, total_domains=len(domains), ) @router.get("/flow-quality", response_model=FlowQualityResponse) @limiter.limit("15/minute") async def get_flow_quality( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_team_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), sort: str = Query("quality", pattern="^(quality|usage|success_rate)$"), ): """Get flow quality scoring for all active flows in the account.""" if not current_user.account_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") account_id = current_user.account_id period_start = _get_period_start(period) now = datetime.now(timezone.utc) # ── Get all active flows (only needed columns — avoids loading large tree_structure JSONB) ── flows_result = await db.execute( select(Tree.id, Tree.name, Tree.tree_type).where( Tree.account_id == account_id, Tree.is_active.is_(True), Tree.deleted_at.is_(None), ) ) flows = flows_result.all() if not flows: return FlowQualityResponse(flows=[], top_performers=[], needs_attention=[]) flow_ids = [f.id for f in flows] # ── Session stats per flow within the period ── session_stats_result = await db.execute( select( AISession.matched_flow_id, func.count(AISession.id).label("total"), func.sum(case((AISession.status == "resolved", 1), else_=0)).label("resolved"), func.sum(case((AISession.confidence_tier == "guided", 1), else_=0)).label("guided"), func.max(AISession.created_at).label("last_matched_at"), ) .where( AISession.account_id == account_id, AISession.matched_flow_id.in_(flow_ids), AISession.created_at >= period_start, ) .group_by(AISession.matched_flow_id) ) stats_by_flow: dict = {} for r in session_stats_result.all(): stats_by_flow[r.matched_flow_id] = { "total": int(r.total or 0), "resolved": int(r.resolved or 0), "guided": int(r.guided or 0), "last_matched_at": r.last_matched_at, } # ── Also get the most recent match ever (for recency score, regardless of period) ── recent_match_result = await db.execute( select( AISession.matched_flow_id, func.max(AISession.created_at).label("last_ever"), ) .where( AISession.account_id == account_id, AISession.matched_flow_id.in_(flow_ids), ) .group_by(AISession.matched_flow_id) ) last_ever_by_flow: dict = {r.matched_flow_id: r.last_ever for r in recent_match_result.all()} # ── Build scored rows ── scored_rows: list[FlowQualityRow] = [] for flow in flows: stats = stats_by_flow.get(flow.id) last_ever = last_ever_by_flow.get(flow.id) if stats and stats["total"] > 0: total = stats["total"] resolved = stats["resolved"] guided = stats["guided"] success_rate = resolved / total guided_rate = guided / total # Recency score based on last match ever if last_ever is not None: last_ever_aware = last_ever.replace(tzinfo=timezone.utc) if last_ever.tzinfo is None else last_ever days_since = (now - last_ever_aware).total_seconds() / 86400 recency_score = max(0.0, min(1.0, 1.0 - days_since / 90.0)) else: recency_score = 0.0 quality_score = round( (success_rate * 0.5) + (guided_rate * 0.3) + (recency_score * 0.2), 4, ) avg_confidence = round(guided_rate, 4) # guided_rate as confidence proxy last_matched_at = stats.get("last_matched_at") else: success_rate = None avg_confidence = None quality_score = 0.0 last_matched_at = last_ever # may be None if last_matched_at is not None and last_matched_at.tzinfo is None: last_matched_at = last_matched_at.replace(tzinfo=timezone.utc) scored_rows.append( FlowQualityRow( flow_id=str(flow.id), name=flow.name, tree_type=flow.tree_type, usage_count=stats["total"] if stats else 0, success_rate=round(success_rate, 4) if success_rate is not None else None, last_matched_at=last_matched_at, avg_confidence=avg_confidence, quality_score=quality_score, ) ) # ── Sort ── if sort == "usage": scored_rows.sort(key=lambda r: r.usage_count, reverse=True) elif sort == "success_rate": scored_rows.sort(key=lambda r: (r.success_rate is not None, r.success_rate or 0.0), reverse=True) else: scored_rows.sort(key=lambda r: r.quality_score, reverse=True) # ── Top performers: top 5 by quality_score with usage > 0 ── top_performers = [r for r in scored_rows if r.usage_count > 0] top_performers = sorted(top_performers, key=lambda r: r.quality_score, reverse=True)[:5] # ── Needs attention: used at least once, AND (success_rate < 0.5 OR not used in 30+ days) ── thirty_days_ago = now - timedelta(days=30) needs_attention = [] for r in scored_rows: if r.usage_count == 0: continue low_success = r.success_rate is not None and r.success_rate < 0.5 stale = r.last_matched_at is not None and r.last_matched_at < thirty_days_ago if low_success or stale: needs_attention.append(r) return FlowQualityResponse( flows=scored_rows, top_performers=top_performers, needs_attention=needs_attention, ) @router.get("/psa-metrics", response_model=EnhancedPsaMetrics) @limiter.limit("15/minute") async def get_enhanced_psa_metrics( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_team_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), ): """Get enhanced PSA integration metrics including time entry stats, push funnel, and daily trend.""" if not current_user.account_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No account") account_id = current_user.account_id period_start = _get_period_start(period) # ── Time entry totals from psa_activity_logs ── time_entry_result = await db.execute( select( func.count(PsaActivityLog.id).label("entry_count"), func.sum(PsaActivityLog.hours_logged).label("total_hours"), ) .where( PsaActivityLog.account_id == account_id, PsaActivityLog.activity_type == "time_entry_posted", PsaActivityLog.created_at >= period_start, ) ) te_row = time_entry_result.one() total_time_entries = int(te_row.entry_count or 0) total_hours_raw = te_row.total_hours or 0 total_hours_logged = round(float(total_hours_raw), 2) avg_hours = round(total_hours_logged / total_time_entries, 2) if total_time_entries > 0 else 0.0 # ── Push funnel ── # Total sessions in period total_sessions_result = await db.execute( select(func.count(AISession.id)).where( AISession.account_id == account_id, AISession.created_at >= period_start, ) ) total_sessions = int(total_sessions_result.scalar() or 0) # Sessions linked to a ticket linked_result = await db.execute( select(func.count(AISession.id)).where( AISession.account_id == account_id, AISession.created_at >= period_start, AISession.psa_ticket_id.isnot(None), ) ) linked_to_ticket = int(linked_result.scalar() or 0) # Sessions with a successful doc push (via PsaPostLog) — count unique sessions, not log entries doc_pushed_result = await db.execute( select(func.count(PsaPostLog.ai_session_id.distinct())) .join(AISession, PsaPostLog.ai_session_id == AISession.id) .where( AISession.account_id == account_id, PsaPostLog.ai_session_id.isnot(None), PsaPostLog.status == "success", PsaPostLog.posted_at >= period_start, ) ) doc_pushed = int(doc_pushed_result.scalar() or 0) # Sessions with a time entry logged (via psa_activity_logs) time_entry_sessions_result = await db.execute( select(func.count(PsaActivityLog.session_id.distinct())).where( PsaActivityLog.account_id == account_id, PsaActivityLog.activity_type == "time_entry_posted", PsaActivityLog.created_at >= period_start, PsaActivityLog.session_id.isnot(None), ) ) time_entry_logged = int(time_entry_sessions_result.scalar() or 0) push_funnel = PsaFunnel( total_sessions=total_sessions, linked_to_ticket=linked_to_ticket, doc_pushed=doc_pushed, time_entry_logged=time_entry_logged, ) # ── Daily trend (time entries grouped by date) ── daily_result = await db.execute( select( cast(PsaActivityLog.created_at, Date).label("day"), func.count(PsaActivityLog.id).label("entries"), func.sum(PsaActivityLog.hours_logged).label("hours"), ) .where( PsaActivityLog.account_id == account_id, PsaActivityLog.activity_type == "time_entry_posted", PsaActivityLog.created_at >= period_start, ) .group_by(cast(PsaActivityLog.created_at, Date)) .order_by(cast(PsaActivityLog.created_at, Date)) ) daily_trend = [ PsaDailyTrend( date=str(r.day), entries=int(r.entries or 0), hours=round(float(r.hours or 0), 2), ) for r in daily_result.all() ] return EnhancedPsaMetrics( total_time_entries=total_time_entries, total_hours_logged=total_hours_logged, avg_hours_per_session=avg_hours, push_funnel=push_funnel, daily_trend=daily_trend, ) # ─── Escalation Mode metrics (wedge stat for /escalations queue + analytics page) # # Pulls all (handoff.claimed_at, first_step_after_claim.created_at) pairs in the # window and aggregates avg/median/p95 of the delta in Python. Pilot scale # (~1k rows max per account per month) makes this cheaper and clearer than # Postgres percentile_cont gymnastics. # # IMPORTANT: this is the in-product metric only. The "minutes recovered" # sales claim requires manual baseline measurement (see The Assignment in # docs/plans/2026-04-27-escalation-mode-wedge-design.md). @router.get("/escalations", response_model=EscalationMetrics) @limiter.limit("30/minute") async def get_escalation_metrics( request: Request, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), period: str = Query("30d", pattern="^(7d|30d|90d)$"), ) -> EscalationMetrics: """Time-to-first-action after escalation claim, account-scoped. Returns: n_handoffs_claimed: handoffs in window that were claimed by a senior. n_handoffs_with_action: subset where the senior took at least one action (an ai_session_step row created after claimed_at). avg/median/p95_seconds_to_first_action: aggregates of (first_step.created_at - claimed_at) in seconds. Excludes handoffs where claimed_at IS NULL (never claimed) and handoffs where no ai_session_step was created after the claim. Both are counted — n_handoffs_claimed includes "no action yet" handoffs so the conversion rate is visible. """ if not current_user.account_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No account" ) account_id = current_user.account_id period_start = _get_period_start(period) # First-action timestamp per handoff via correlated scalar subquery. first_action_subq = ( select(func.min(AISessionStep.created_at)) .where( AISessionStep.session_id == SessionHandoff.session_id, AISessionStep.created_at > SessionHandoff.claimed_at, ) .correlate(SessionHandoff) .scalar_subquery() ) rows = ( await db.execute( select( SessionHandoff.claimed_at, first_action_subq.label("first_action_at"), ).where( SessionHandoff.account_id == account_id, SessionHandoff.claimed_at.isnot(None), SessionHandoff.claimed_at >= period_start, ) ) ).all() n_handoffs_claimed = len(rows) deltas: list[float] = [] for claimed_at, first_action_at in rows: if first_action_at is None: continue delta_s = (first_action_at - claimed_at).total_seconds() # Floor at zero — clock drift between rows could in theory yield a # tiny negative if a step's created_at races claimed_at. Surface as # 0s rather than absurd negative deltas. if delta_s < 0: delta_s = 0.0 deltas.append(delta_s) n_handoffs_with_action = len(deltas) if n_handoffs_with_action == 0: return EscalationMetrics( period=period, n_handoffs_claimed=n_handoffs_claimed, n_handoffs_with_action=0, ) sorted_deltas = sorted(deltas) p95_idx = max(0, int(round(0.95 * (n_handoffs_with_action - 1)))) return EscalationMetrics( period=period, n_handoffs_claimed=n_handoffs_claimed, n_handoffs_with_action=n_handoffs_with_action, avg_seconds_to_first_action=round(statistics.fmean(deltas), 2), median_seconds_to_first_action=round(statistics.median(deltas), 2), p95_seconds_to_first_action=round(sorted_deltas[p95_idx], 2), )