Files
Jyotisha/frontend/src/lib/admin/rectification-telemetry-summary.ts
T
Jesse_ChenandClaude Opus 5.5 d0bfc1fc3d feat(rectification): anonymous aggregate telemetry + admin summary page
One row per rectification Case, written once when the range card is first
delivered (GET /api/rectification/cases/[caseId], fire-and-forget after the
response is built). Numbers and closed enums only: no user / case / session
id, birth data, names, text or timestamps finer than the ISO week. Dedupe via
a separate case_id ledger that cascades with the Case (and account deletion).

Migration 20260926010000 is additive: two RLS tables with no runtime table
grants, SECURITY DEFINER write (service_role), purge (service_role) and
aggregate-only summary (admin_runtime) functions; 180-day retention.

Admin: 「校正统计」 page + GET /api/admin/rectification-telemetry
(admin.customers.read), aggregates only, no per-row view or export.

TASK-rectification-telemetry-20260926. test:db not run locally (no Docker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 15:36:29 +08:00

225 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Admin view of the anonymous rectification telemetry
* (TASK-rectification-telemetry-20260926, D3).
*
* The database function `rectification_telemetry_summary` returns aggregates
* only; this module turns its jsonb into a fixed shape and holds the labels.
* There is no per-row type here on purpose: the admin never sees a row.
*/
export const TELEMETRY_SUMMARY_WEEK_OPTIONS = [4, 12, 26] as const;
export const TELEMETRY_SUMMARY_DEFAULT_WEEKS = 12;
export type TelemetryBucket = Readonly<{ key: string; count: number }>;
export type TelemetryWeek = Readonly<{
week: string;
sessions: number;
rangeWidthMinutesP50: number | null;
questionsTotalP50: number | null;
precisionGateMet: number;
precisionGateKnown: number;
}>;
export type TelemetryVersionRow = Readonly<{
algorithmVersion: string | null;
policyVersion: string | null;
skillVersion: string | null;
count: number;
}>;
export type RectificationTelemetrySummary = Readonly<{
weeks: number;
since: string | null;
retentionDays: number;
sessions: number;
medians: Readonly<{
rangeWidthMinutesP50: number | null;
rangeWidthMinutesP90: number | null;
questionsTotalP50: number | null;
questionsTotalP90: number | null;
experiencesAddedP50: number | null;
candidateCountP50: number | null;
windowRadiusMinutesP50: number | null;
durationSecondsP50: number | null;
durationSecondsP90: number | null;
}>;
questionTypes: Readonly<{
targeted: number;
guided: number;
datedProbe: number;
personality: number;
open: number;
other: number;
guidedAskedSessions: number;
}>;
widthDistribution: readonly TelemetryBucket[];
questionDistribution: readonly TelemetryBucket[];
gapDistribution: readonly TelemetryBucket[];
stopReasons: readonly TelemetryBucket[];
birthTimeSources: readonly TelemetryBucket[];
precisionGate: Readonly<{ met: number; notMet: number; unknown: number }>;
weekly: readonly TelemetryWeek[];
versions: readonly TelemetryVersionRow[];
}>;
export const TELEMETRY_STOP_REASON_LABEL: Readonly<Record<string, string>> = {
converged: "收敛(精度门槛达标)",
pool_exhausted: "题源问空",
user_no_more: "用户说没有了 / 多半说不好",
round_cap: "8 轮上限",
user_stopped: "用户中止",
error: "错误",
};
export const TELEMETRY_BIRTH_TIME_SOURCE_LABEL: Readonly<Record<string, string>> = {
hospital_record: "出生记录",
approximate: "大概时间",
period_only: "时间段",
unknown: "不知道",
};
export const TELEMETRY_WIDTH_BUCKET_LABEL: Readonly<Record<string, string>> = {
"0": "0 分钟(单点)",
"1-5": "1–5 分钟",
"6-10": "6–10 分钟",
"11-20": "11–20 分钟",
"21-30": "21–30 分钟",
"31-60": "31–60 分钟",
"61+": "60 分钟以上",
unknown: "未知",
};
export const TELEMETRY_QUESTION_BUCKET_LABEL: Readonly<Record<string, string>> = {
"0-3": "0–3 题",
"4-6": "4–6 题",
"7-9": "7–9 题",
"10-12": "10–12 题",
"13-15": "13–15 题",
"16+": "16 题以上",
};
/**
* Gap buckets line up with the card's percent rule (`RANGE_DELIVERY_PERCENT_MIN_GAP`
* = 5): the 5–9 and 10+ buckets are the sessions whose card showed percentages.
*/
export const TELEMETRY_GAP_BUCKET_LABEL: Readonly<Record<string, string>> = {
"0-2": "0–2 个百分点(打平)",
"3-4": "3–4 个百分点(不显示百分比)",
"5-9": "5–9 个百分点(显示百分比)",
"10+": "10 个百分点以上(显示百分比)",
single: "只有一个候选",
};
export const TELEMETRY_PERCENT_SHOWN_BUCKETS: readonly string[] = ["5-9", "10+"];
function record(value: unknown): Readonly<Record<string, unknown>> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function numberOrNull(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value))) return Number(value);
return null;
}
function numberOrZero(value: unknown): number {
return numberOrNull(value) ?? 0;
}
function textOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function buckets(value: unknown): TelemetryBucket[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
const row = record(item);
const key = textOrNull(row.key);
return key ? [{ key, count: numberOrZero(row.count) }] : [];
});
}
export function parseRectificationTelemetrySummary(value: unknown): RectificationTelemetrySummary {
const row = record(value);
const medians = record(row.medians);
const types = record(row.question_types);
const gate = record(row.precision_gate);
return {
weeks: numberOrNull(row.weeks) ?? TELEMETRY_SUMMARY_DEFAULT_WEEKS,
since: textOrNull(row.since),
retentionDays: numberOrNull(row.retention_days) ?? 180,
sessions: numberOrZero(row.sessions),
medians: {
rangeWidthMinutesP50: numberOrNull(medians.range_width_minutes_p50),
rangeWidthMinutesP90: numberOrNull(medians.range_width_minutes_p90),
questionsTotalP50: numberOrNull(medians.questions_total_p50),
questionsTotalP90: numberOrNull(medians.questions_total_p90),
experiencesAddedP50: numberOrNull(medians.experiences_added_p50),
candidateCountP50: numberOrNull(medians.candidate_count_p50),
windowRadiusMinutesP50: numberOrNull(medians.window_radius_minutes_p50),
durationSecondsP50: numberOrNull(medians.duration_seconds_p50),
durationSecondsP90: numberOrNull(medians.duration_seconds_p90),
},
questionTypes: {
targeted: numberOrZero(types.targeted),
guided: numberOrZero(types.guided),
datedProbe: numberOrZero(types.dated_probe),
personality: numberOrZero(types.personality),
open: numberOrZero(types.open),
other: numberOrZero(types.other),
guidedAskedSessions: numberOrZero(types.guided_asked_sessions),
},
widthDistribution: buckets(row.width_distribution),
questionDistribution: buckets(row.question_distribution),
gapDistribution: buckets(row.gap_distribution),
stopReasons: buckets(row.stop_reasons),
birthTimeSources: buckets(row.birth_time_sources),
precisionGate: {
met: numberOrZero(gate.met),
notMet: numberOrZero(gate.not_met),
unknown: numberOrZero(gate.unknown),
},
weekly: Array.isArray(row.weekly)
? row.weekly.flatMap((item) => {
const week = record(item);
const label = textOrNull(week.week);
return label
? [{
week: label,
sessions: numberOrZero(week.sessions),
rangeWidthMinutesP50: numberOrNull(week.range_width_minutes_p50),
questionsTotalP50: numberOrNull(week.questions_total_p50),
precisionGateMet: numberOrZero(week.precision_gate_met),
precisionGateKnown: numberOrZero(week.precision_gate_known),
}]
: [];
})
: [],
versions: Array.isArray(row.versions)
? row.versions.map((item) => {
const version = record(item);
return {
algorithmVersion: textOrNull(version.algorithm_version),
policyVersion: textOrNull(version.policy_version),
skillVersion: textOrNull(version.skill_version),
count: numberOrZero(version.count),
};
})
: [],
};
}
export function parseTelemetrySummaryWeeks(value: string | null): number {
const parsed = Number(value);
return (TELEMETRY_SUMMARY_WEEK_OPTIONS as readonly number[]).includes(parsed)
? parsed
: TELEMETRY_SUMMARY_DEFAULT_WEEKS;
}
export function share(part: number, whole: number): number | null {
return whole > 0 ? part / whole : null;
}