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
225 lines
7.7 KiB
TypeScript
225 lines
7.7 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|