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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f74825a27c
commit
d0bfc1fc3d
@@ -202,6 +202,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
|
||||
},
|
||||
orders: { read: "billing.orders.read", write: "billing.adjustments.write" },
|
||||
usage: { read: "billing.orders.read" },
|
||||
"rectification-telemetry": { read: "admin.customers.read" },
|
||||
models: { read: "models.read", write: "models.write" },
|
||||
"model-releases": { read: "models.read", write: "models.publish" },
|
||||
"feature-flags": { read: "admin.access", write: "ops.flags.write" },
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user