Files
resolutionflow/backend/app/api/endpoints/flowpilot_analytics.py
Michael Chihlas 52f6d0308f feat(analytics): add escalation time-to-first-action metric endpoint
GET /api/v1/analytics/flowpilot/escalations?period={7d,30d,90d}

Computes the in-product wedge metric for Escalation Mode: average / median /
p95 seconds between SessionHandoff.claimed_at and the first ai_session_step
created on the same session after that timestamp. Account-scoped, role-gated
to engineer-or-admin.

The metric is intentionally NOT called "minutes recovered" — that's the
two-metric framing locked by /codex review: this in-product number must be
paired with manual baseline (the verbal-handoff stopwatch from The Assignment)
to produce the savings claim. Schema's `metric_definition` field surfaces the
disclaimer in every response so callers don't oversell it.

Implementation notes:
- Uses correlated scalar subquery for first-step-after-claim per handoff,
  aggregates avg/median/p95 in Python (~1k rows/account/month is well within
  budget; cleaner than percentile_cont gymnastics in SQL)
- Excludes unclaimed handoffs (claimed_at IS NULL)
- Counts claimed-but-no-action handoffs in n_handoffs_claimed but not in
  n_handoffs_with_action — surfaces the conversion-rate signal
- Floors negative deltas at 0 to handle clock-drift edge cases

Tests cover happy path, zero-data, claimed-but-no-action accounting, period
window filtering, multi-handoff aggregation, multi-tenant isolation (Phase 4
RLS landmine pattern), viewer-role 403 gate, and period validation. 9 tests,
all green. No regressions in existing handoff_manager / session_handoffs
suites.

First piece of the Approach A wedge build per
docs/plans/2026-04-27-escalation-mode-wedge-design.md. Unblocks the queue
stat-card and the analytics page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 15:25:46 -04:00

841 lines
32 KiB
Python

"""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),
)