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:
Jesse_Chen
2026-09-26 15:36:29 +08:00
co-authored by Claude Opus 5.5
parent f74825a27c
commit d0bfc1fc3d
27 changed files with 2422 additions and 6 deletions
@@ -0,0 +1,51 @@
# 生时校正匿名统计:隐私说明
任务书:`docs/tasks/TASK-rectification-telemetry-20260926.md`。迁移:`frontend/supabase/migrations/20260926010000_rectification_telemetry.sql`。
## 存什么
每个校正会话第一次给出范围卡时记一行,只有下面这些字段(白名单写死在 `frontend/src/lib/rectification-agentic/v9/telemetry.ts` 的 `RECTIFICATION_TELEMETRY_FIELDS`,由 `frontend/tests/rectification-telemetry.test.ts` 钉住;加字段必须改测试):
| 字段 | 含义 | 类型 |
| --- | --- | --- |
| `recorded_week` | 记录所在 ISO 周的周一(UTC) | 日期,只到周 |
| `window_radius_minutes` | 出卡时搜索窗口的一半宽度 | 0–720 |
| `birth_time_source` | `hospital_record` / `approximate` / `period_only` / `unknown` | 枚举 |
| `questions_total` 及五个分类 | 定向 / 引导 / 带年月探针 / 性格 / 开放的提问数(总数含其他类) | 0–1000 |
| `experiences_added` | 用户讲过、仍有效的经历件数 | 0–1000 |
| `range_width_minutes` | 卡上范围宽度 | 0–1440 |
| `candidate_count` | 仍在比较的候选分钟数 | 0–1440 |
| `top_two_gap_points` | 第一名与第二名差几个百分点 | 0–100 |
| `stop_reason` | `converged` / `pool_exhausted` / `user_no_more` / `round_cap` / `user_stopped` / `error` | 枚举 |
| `precision_gate_met` | 精度门槛是否达标 | 布尔 |
| `duration_seconds` | 从第一轮到出卡的秒数 | 0–1 年 |
| `algorithm_version` / `policy_version` / `skill_version` | 版本号(只允许 `[A-Za-z0-9._:+-]`,不合规的记空) | 版本 id |
## 不存什么
- 不存用户、校正记录(Case)、会话编号;不存出生日期、时间、地点、姓名、邮箱;不存用户原话、证据摘要、模型输出;不存 IP;时间不细于周。
- 候选时刻、范围起止时刻(`HH:MM`)也不存,只存宽度。
- 写入失败的日志只有一行 `[rectification-telemetry] write skipped reason=<错误码>`,不带任何字段值、编号或错误原文。
## 去重与账户删除(任务书 D4 的落法)
- 任务书 D4 允许为去重和删除联动存用户 id。本实现更严:统计行里**不存**任何 id。
- 去重用另一张表 `rectification_telemetry_reported_cases`,只有一列 `case_id`,外键指向校正记录并 `on delete cascade`。账户删除 → 身份用户删除 → 校正记录级联删除 → 这张台账的行一并删除。
- 台账没有时间列,也没有指向统计行的列,统计行无法再关联回任何人;账户删除后留下的统计行本来就不含个人信息,所以不需要(也无法)按人删除。
- 同一个校正会话只记一次,以第一次出卡时的状态为准;之后继续补经历、采用、确认都不改这一行。
## 谁能读写
- 两张表都开启 RLS,且不给任何运行角色表权限(`anon` / `authenticated` / `app_runtime` / `admin_runtime` / `service_role` 全部 revoke)。
- 写:只能通过 `record_rectification_telemetry`(SECURITY DEFINER,只授权 `service_role`);函数先核对该 Case 属于该用户,再写台账和统计行,任一 CHECK 不过则整笔回滚。
- 读:只能通过 `rectification_telemetry_summary(weeks)`(SECURITY DEFINER,只授权 `admin_runtime`),返回中位数、分布、按周趋势和版本分布,不返回任何单行。后台接口 `GET /api/admin/rectification-telemetry` 需要 `admin.customers.read` 权限,没有逐条列表、没有导出。
## 保留期
- 180 天。每次写入先删除「所在周的周一早于今天 180 天」的行;汇总函数也不读 180 天以前的行;运维可调用 `purge_expired_rectification_telemetry()`(只授权 `service_role`)手动执行同一删除。
- 没有定时任务:长时间没有新写入时,过期行在库里但汇总看不到,下一次写入即删除。
## 写入时机与性能
- 写入点是 `GET /api/rectification/cases/[caseId]`(每轮结束后前端都会刷新它),只在当前决策为交付结果(`sessionOutcomeAllowsDelivery`)、卡片有候选列、且最近活动在 2 小时内时记录;翻看很久以前的历史不会补记。
- 复用这次响应已经算好的决策和卡片,不再重复计算;写库在响应构建完之后另起(`setImmediate`),不 await,失败吞掉。