feat(admin): profile freshness panel in data-quality (#81 phase B.4)

Adds a per-feature freshness summary to /admin/data-quality so the admin
can spot features that are systematically stale or never computed:

  totalEligible — distinct users with tip_views in the last 30 days
  missing       — eligible users with no row stored for the feature
  stale         — eligible users whose stored row is past its TTL

Backend exposes summarizeProfileFreshness() in profile/builder.ts; one
query per feature joins eligible users LEFT JOIN profile rows.
Coverage = (eligible − missing − stale) / eligible, colored
green/yellow/red via the new PctGood helper (high-is-good, opposite of
the existing Pct used for missing-feature/stale-token rates).

Refs #81.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:34:46 +00:00
parent 9e96540bcc
commit 4a42a6aabf
6 changed files with 167 additions and 3 deletions

View File

@@ -120,6 +120,58 @@ export interface ProfileFeatureView {
description: string;
}
export interface FeatureFreshnessSummary {
feature: string;
ttlSec: number;
/** Distinct users with tip activity in the last 30 days. */
totalEligible: number;
/** Eligible users with no row stored at all for this feature. */
missing: number;
/** Eligible users whose stored row is past its TTL. */
stale: number;
}
/**
* Per-feature staleness summary across all eligible users (anyone with a tip
* served in the last 30 days). Used by `/admin/data-quality` so the admin can
* spot features that are systematically stale or never computed.
*
* Hand-written SQL because this joins (eligible_users LEFT JOIN profile_rows)
* with conditional aggregations — drizzle's query builder is more pain than
* value here, and the column allowlist is the registry.
*/
export function summarizeProfileFreshness(): FeatureFreshnessSummary[] {
return FEATURES.map((f) => {
const row = rawSqlite
.prepare(`
WITH eligible AS (
SELECT DISTINCT user_id
FROM tip_views
WHERE served_at >= datetime('now', '-30 days')
)
SELECT
COUNT(*) AS total_eligible,
SUM(CASE WHEN upf.user_id IS NULL THEN 1 ELSE 0 END) AS missing,
SUM(CASE WHEN upf.user_id IS NOT NULL
AND upf.ttl_sec > 0
AND (julianday('now') - julianday(upf.updated_at)) * 86400.0 > upf.ttl_sec
THEN 1 ELSE 0 END) AS stale
FROM eligible e
LEFT JOIN user_profile_features upf
ON upf.user_id = e.user_id AND upf.name = ?
`)
.get(f.name) as { total_eligible: number; missing: number; stale: number } | undefined;
return {
feature: f.name,
ttlSec: f.ttlSec,
totalEligible: Number(row?.total_eligible ?? 0),
missing: Number(row?.missing ?? 0),
stale: Number(row?.stale ?? 0),
};
});
}
/**
* Inspection helper for the admin UI: returns one row per registered feature,
* joining stored value + metadata. No compute — surface staleness; rebuild is