fix(analytics): fix 6 backend audit issues — domain matching, funnel counts, decimal casts, dead queries

- Issue 1: Normalize domain lookup to lowercase on both sides (flow category names and session problem_domain) to fix case-sensitive mismatch in coverage endpoint
- Issue 2: Count distinct ai_session_id (not PsaPostLog.id) in doc_pushed funnel step to avoid inflating counts on retried sessions
- Issue 3: Clamp recency_score to [0.0, 1.0] with min/max to handle negative days_since from future timestamps (clock skew/test data)
- Issue 4: Wrap func.sum(case(...)) results with int() in dashboard endpoint to handle Decimal returns from asyncpg
- Issue 5: Remove dead domain_flow_counts_result query (result fetched but never consumed) to eliminate unnecessary DB round-trip
- Issue 6: Replace select(Tree) with select(Tree.id, Tree.name, Tree.tree_type) in flow-quality endpoint to avoid loading large tree_structure JSONB column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:36:30 +00:00
parent e89f067f78
commit 94fbb38f84

View File

@@ -81,10 +81,10 @@ async def get_dashboard(
) )
) )
row = counts_result.one() row = counts_result.one()
total = row.total or 0 total = int(row.total or 0)
resolved = row.resolved or 0 resolved = int(row.resolved or 0)
escalated = row.escalated or 0 escalated = int(row.escalated or 0)
abandoned = row.abandoned or 0 abandoned = int(row.abandoned or 0)
avg_steps = float(row.avg_steps or 0) avg_steps = float(row.avg_steps or 0)
avg_rating = float(row.avg_rating) if row.avg_rating else None avg_rating = float(row.avg_rating) if row.avg_rating else None
resolution_rate = (resolved / total * 100) if total > 0 else 0.0 resolution_rate = (resolved / total * 100) if total > 0 else 0.0
@@ -168,10 +168,10 @@ async def get_dashboard(
sessions_by_domain = [ sessions_by_domain = [
DomainBreakdown( DomainBreakdown(
domain=r.problem_domain or "unknown", domain=r.problem_domain or "unknown",
total=r.total, total=int(r.total or 0),
resolved=r.resolved or 0, resolved=int(r.resolved or 0),
escalated=r.escalated or 0, escalated=int(r.escalated or 0),
resolution_rate=round((r.resolved or 0) / r.total * 100, 1) if r.total > 0 else 0.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() for r in domain_result.all()
] ]
@@ -190,7 +190,7 @@ async def get_dashboard(
) )
.group_by(AISession.confidence_tier) .group_by(AISession.confidence_tier)
) )
conf_data = {r.confidence_tier: (r.total or 0, r.resolved or 0) for r in confidence_result.all()} 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)) guided_total, guided_resolved = conf_data.get("guided", (0, 0))
exploring_total, exploring_resolved = conf_data.get("exploring", (0, 0)) exploring_total, exploring_resolved = conf_data.get("exploring", (0, 0))
@@ -259,14 +259,6 @@ async def get_dashboard(
) )
.group_by(AISession.problem_domain) .group_by(AISession.problem_domain)
) )
domain_flow_counts_result = await db.execute(
select(
Tree.tree_type, # Reuse as domain proxy — not ideal but workable
func.count(Tree.id),
)
.where(Tree.account_id == account_id)
.group_by(Tree.tree_type)
)
# For now, flow_count per domain isn't directly available since Tree doesn't have 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. # Use match_keywords or just report 0. We'll improve this in Phase 4 with better flow categorization.
domain_cov_data = {} domain_cov_data = {}
@@ -422,10 +414,10 @@ async def get_coverage_heatmap(
unmapped_session_count = int(unmapped_result.scalar() or 0) unmapped_session_count = int(unmapped_result.scalar() or 0)
# ── Flow counts per domain: match Category.name to problem_domain ── # ── Flow counts per domain: match Category.name to problem_domain ──
# Joins Tree → TreeCategory and groups by category name # Joins Tree → TreeCategory and groups by lowercased category name for case-insensitive matching
flow_counts_result = await db.execute( flow_counts_result = await db.execute(
select( select(
TreeCategory.name.label("domain"), func.lower(TreeCategory.name).label("domain"),
func.count(Tree.id).label("flow_count"), func.count(Tree.id).label("flow_count"),
) )
.join(Tree, Tree.category_id == TreeCategory.id) .join(Tree, Tree.category_id == TreeCategory.id)
@@ -434,7 +426,7 @@ async def get_coverage_heatmap(
Tree.is_active.is_(True), Tree.is_active.is_(True),
Tree.deleted_at.is_(None), Tree.deleted_at.is_(None),
) )
.group_by(TreeCategory.name) .group_by(func.lower(TreeCategory.name))
) )
flow_counts_by_domain: dict[str, int] = { flow_counts_by_domain: dict[str, int] = {
r.domain: int(r.flow_count) for r in flow_counts_result.all() r.domain: int(r.flow_count) for r in flow_counts_result.all()
@@ -452,7 +444,7 @@ async def get_coverage_heatmap(
domains.append( domains.append(
CoverageDomainRow( CoverageDomainRow(
domain=domain_name, domain=domain_name,
flow_count=flow_counts_by_domain.get(domain_name, 0), flow_count=flow_counts_by_domain.get(domain_name.lower(), 0),
session_count=sc, session_count=sc,
resolution_rate=round(resolved / sc, 4) if sc > 0 else 0.0, resolution_rate=round(resolved / sc, 4) if sc > 0 else 0.0,
escalation_rate=round(escalated / sc, 4) if sc > 0 else 0.0, escalation_rate=round(escalated / sc, 4) if sc > 0 else 0.0,
@@ -486,15 +478,15 @@ async def get_flow_quality(
period_start = _get_period_start(period) period_start = _get_period_start(period)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# ── Get all active flows ── # ── Get all active flows (only needed columns — avoids loading large tree_structure JSONB) ──
flows_result = await db.execute( flows_result = await db.execute(
select(Tree).where( select(Tree.id, Tree.name, Tree.tree_type).where(
Tree.account_id == account_id, Tree.account_id == account_id,
Tree.is_active.is_(True), Tree.is_active.is_(True),
Tree.deleted_at.is_(None), Tree.deleted_at.is_(None),
) )
) )
flows = flows_result.scalars().all() flows = flows_result.all()
if not flows: if not flows:
return FlowQualityResponse(flows=[], top_performers=[], needs_attention=[]) return FlowQualityResponse(flows=[], top_performers=[], needs_attention=[])
@@ -557,7 +549,7 @@ async def get_flow_quality(
if last_ever is not None: if last_ever is not None:
last_ever_aware = last_ever.replace(tzinfo=timezone.utc) if last_ever.tzinfo is None else last_ever 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 days_since = (now - last_ever_aware).total_seconds() / 86400
recency_score = max(0.0, 1.0 - days_since / 90.0) recency_score = max(0.0, min(1.0, 1.0 - days_since / 90.0))
else: else:
recency_score = 0.0 recency_score = 0.0
@@ -673,9 +665,9 @@ async def get_enhanced_psa_metrics(
) )
linked_to_ticket = int(linked_result.scalar() or 0) linked_to_ticket = int(linked_result.scalar() or 0)
# Sessions with a successful doc push (via PsaPostLog) # Sessions with a successful doc push (via PsaPostLog) — count unique sessions, not log entries
doc_pushed_result = await db.execute( doc_pushed_result = await db.execute(
select(func.count(PsaPostLog.id.distinct())) select(func.count(PsaPostLog.ai_session_id.distinct()))
.join(AISession, PsaPostLog.ai_session_id == AISession.id) .join(AISession, PsaPostLog.ai_session_id == AISession.id)
.where( .where(
AISession.account_id == account_id, AISession.account_id == account_id,