Documents the exact Prisma query per dashboard figure, the one new durable table this feature needs (ErrorCodeLookup — 014's own equivalent metric is process-lifetime, unusable for a historical report), the "no data -> null, never NaN" convention, and why the AI dashboard's confidence distribution deliberately uses the system-default threshold rather than resolving a per-diagnosis policy (AIDiagnosis has no reliable FK back to which policy applied). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
Research: Reporting and Analytics Dashboards
1. Where this lives
Decision: Wire up the existing, pre-scaffolded src/modules/platform/reports module (today
just ReportsService.generateSummaryReport() returning {}, confirmed unused anywhere) rather
than creating a new module. Its four real methods (getManagementDashboard,
getProductDashboard, getSupportDashboard, getAiDashboard) replace the one stub method.
Routes live at GET /admin/reports/management, GET /admin/reports/product/:externalProductId,
GET /admin/reports/support, GET /admin/reports/ai, admin-gated the same way every other
admin-only endpoint since 010-identity-auth already is (requireRole('ADMIN')).
Why not the ANALYTICS queue (src/jobs/analytics, also pre-scaffolded, also inert): a
queued background job fits pre-computing a report nobody is currently waiting on; a dashboard
request is someone waiting right now for an answer. Per spec.md's Assumptions, this first cut is
synchronous, direct-query aggregation — the queue stub stays exactly as inert as it already was,
untouched by this feature.
2. Per-dashboard queries
All four use Prisma's groupBy/count/aggregate, scoped by createdAt (or the
milestone-specific timestamp named below) within [from, to], computed directly against the
tables that already own each fact — no new table, no denormalized rollup.
Management (FR-001)
| Figure | Source |
|---|---|
| Total cases | Ticket.count({ createdAt in range }) |
| AI resolved | Ticket.count({ createdAt in range, status: 'AI_RESOLVED' }) — a ticket that reached AI_RESOLVED and stayed there or moved straight to RESOLVED without a Resolution.resolvedBy other than 'ai'; see §4 below for the exact "who resolved it" rule shared with the Product dashboard |
| Human escalated | Ticket.count({ createdAt in range, status in [HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED] }) minus AI-only-path tickets — i.e. any ticket that ever reached HUMAN_ESCALATION; the state machine research.md already establishes this as one-way (003-ticketing) |
| Resolved (either path) | Ticket.count({ createdAt in range, status in [RESOLVED, CLOSED] }) |
| Open | Ticket.count({ createdAt in range, status not in [RESOLVED, CLOSED] }) |
| SLA compliance / breach count | SLARun.groupBy(['status'], { ticket: { createdAt in range } }), status: 'completed' = met, 'breached' = breached (mirrors 014's own metric semantics — see 014 research.md §5's "read status before the overwrite" caveat, which applies equally here: a 'breached'-then-'completed' run is still counted breached, by reading the breachedAt/firstResponseBreachedAt timestamps rather than only the current status string) |
| Escalation count | EscalationEvent.count({ createdAt in range }) |
| Average response time | avg(firstAgentMessage.createdAt - ticket.createdAt) over tickets with at least one AGENT_MESSAGE in range — computed in application code over a bounded query result (see §5, no raw SQL) |
| Average resolution time | avg(resolution.createdAt - ticket.createdAt) over tickets with a Resolution row in range |
Product (FR-002)
Same shape as Management, WHERE Ticket.productId = :productId (resolved from the given
externalProductId, 404 if not found — FR-006), plus:
| Figure | Source |
|---|---|
| Problem-type breakdown | Problem.groupBy(['categoryId'], { productId, createdAt in range }) |
| Recurring problems | Same grouped result, sorted descending, top N (config, default 10) |
| Top errors | reuses 014's own instrumentation point conceptually but queries fresh — no, see §6: there is no persisted "error code lookup" table, only 014's in-memory Prometheus counter, which is NOT queryable historically. Resolved by adding a durable audit read instead: see §6. |
Support (FR-003)
| Figure | Source |
|---|---|
| Per-agent workload | Assignment.groupBy(['agentId'], { isCurrent: true }) — a snapshot of right now, not date-ranged (workload is inherently current, not historical — spec.md's own framing: "how much work is currently assigned") |
| SLA risk / breached | SLARun.findMany({ status: 'running', resolutionDueAt: {gte: now} }) filtered in application code by "due within SLA_RISK_THRESHOLD_MINUTES of now" for risk, vs. status: 'breached' for already-breached |
| Escalation count | Same as Management, unfiltered by product |
| Response/resolution performance | Same computation as Management's averages |
AI (FR-004)
| Figure | Source |
|---|---|
| AI resolution rate / human-handoff rate | AISupportSession.groupBy(['status'], { startedAt in range }) — resolved vs. escalated/ended_by_agent as a share of total terminal sessions |
| Failed-troubleshooting-then-escalated rate | Sessions with status: 'escalated' that have at least one AIInteraction/AIAction recording a failed troubleshooting attempt — see 005-ai-support's own runbook-step-outcome classification (classifyStepOutcome), reused rather than reinvented |
| Knowledge-match rate | AIKnowledgeReference presence per session (recordMany is only ever called with actual retrieval results — 005-ai-support's own diagnose.ts) vs. sessions with zero references recorded |
| Confidence distribution | AIDiagnosis.findMany({ createdAt in range }), bucketed in application code against aiConfig.defaultHighConfidence/defaultLowConfidence (see §7 — NOT a per-diagnosis resolved policy) |
| Tool success/failure | AIAction joined to AIActionResult, grouped by result.status |
3. "No data" convention (FR-007)
Decision: every rate/average field is number | null — null means "no qualifying records
in range," distinguished in the response shape from a genuine 0 (e.g., a real 0% AI resolution
rate because everything escalated is a valid, meaningful 0; "nobody's data exists yet" is
null). Application code computes every average by fetching the qualifying count first and
returning null before ever dividing, never relying on 0/0 producing NaN and hoping a caller
notices.
4. "Who resolved it" — reused from 014, not reinvented
014-full-observability's own event subscriber already established the authoritative rule: a
ticket's Resolution.resolvedBy field ("ai" | an agentId) is the single source of truth for
whether a resolution was AI- or human-driven (014 research.md §5). This feature's Management/
Product dashboards reuse the exact same join (Resolution.findMany scoped to the range,
resolvedBy === 'ai' vs. not) rather than re-deriving it from Ticket.status transitions a
second, potentially-inconsistent way.
5. No raw SQL
Decision: every duration average (response time, resolution time) is computed by fetching
the bounded set of qualifying rows (ticket createdAt + the milestone timestamp) via Prisma and
averaging in application code, not a raw $queryRaw computing AVG(EXTRACT(EPOCH FROM ...)) in
SQL. At the data volumes spec.md's Assumptions accept for this first cut (no pre-aggregation,
synchronous queries), a bounded per-range fetch is simple, type-safe, and testable without
hand-writing SQL — consistent with this codebase's near-total avoidance of $queryRaw elsewhere
(confirmed by grep: no existing module uses it for reporting-shaped queries).
6. Top errors needs a durable, queryable record — a real gap 014 left open
014-full-observability's supporthub_known_error_lookups_total Prometheus counter is
process-lifetime, in-memory, and reset on every restart — useless for "top errors in the last 30
days." Since no durable "error code lookup" record exists anywhere in this codebase today (the
existing error-codes.service.ts just reads KnownIssue/ErrorCode rows, never records that a
lookup happened), this feature adds one small, focused piece of new state: a durable
ErrorCodeLookup audit row (errorCodeId, productId, createdAt), written by
error-codes.service.ts's already-existing findKnownIssuesByErrorCode (the same call site
014 instrumented for its own live counter — this feature adds one more line there, a durable
write alongside the existing live-metric increment, not a replacement for it). This is the one
schema change this feature needs; every other dashboard figure is computed from tables that
already exist.
7. Confidence distribution uses the system default threshold, not a per-diagnosis policy
Decision: bucket every AIDiagnosis.confidence value in range against the env-configured
system-wide defaults (aiConfig.defaultHighConfidence/defaultLowConfidence), the same
decideConfidenceBand pure function 005-ai-support already exports — reused directly, not
reimplemented.
Why not resolve each diagnosis's actual applicable per-product/category policy (what the
live reasoning path itself does): AIDiagnosis.product/feature are the AI's own free-text
classification output, not foreign keys to Product/Category — there is no reliable, existing
join from a diagnosis row back to which ConfidencePolicy row actually applied to it at the time
without speculatively string-matching free text against product names, which this codebase does
nowhere else and which research.md declines to invent here. A dashboard-level aggregate
distribution using the system-wide default is an honest, documented simplification (spec.md
Assumptions) — precise enough to show a meaningful shape without fabricating a false precision
the data doesn't actually support.
8. New configuration (Constitution Principle II — nothing hardcoded)
| Env var | Default | Used by |
|---|---|---|
REPORTING_DEFAULT_WINDOW_DAYS |
30 |
Every dashboard's from/to default when omitted (FR-005) |
REPORTING_SLA_RISK_THRESHOLD_MINUTES |
60 |
Support dashboard's "at risk" classification (FR-003) |
REPORTING_TOP_N_LIMIT |
10 |
Product dashboard's recurring-problems/top-errors ranking length |
9. Test strategy
Integration tests create real tickets/problems/SLA runs/escalation events/AI sessions/diagnoses/
actions/error-code lookups directly against real Postgres (mixing real HTTP-driven setup where a
realistic flow matters and direct repository/Prisma writes where only the aggregation math is
under test — the same mix 014's own business-metrics.test.ts used), then request each
dashboard endpoint and assert every figure against hand-computed expected values. Unit tests
cover the "no data → null, never NaN" guard and the confidence-bucketing pure-function reuse.