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>
841 lines
32 KiB
Python
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),
|
|
)
|