/** * 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> = { converged: "收敛(精度门槛达标)", pool_exhausted: "题源问空", user_no_more: "用户说没有了 / 多半说不好", round_cap: "8 轮上限", user_stopped: "用户中止", error: "错误", }; export const TELEMETRY_BIRTH_TIME_SOURCE_LABEL: Readonly> = { hospital_record: "出生记录", approximate: "大概时间", period_only: "时间段", unknown: "不知道", }; export const TELEMETRY_WIDTH_BUCKET_LABEL: Readonly> = { "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> = { "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> = { "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> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } 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; }