Files
Jyotisha/docs/tasks/PROGRESS-rectification-telemetry-20260926.md
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

122 lines
13 KiB
Markdown
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.
# PROGRESS · 生时校正匿名聚合统计(2026-09-26)
- 任务书:`docs/tasks/TASK-rectification-telemetry-20260926.md`
- 分支:`codex/rectification-telemetry-20260926`,基线 `origin/staging` `7203d94e`(fewer-probes-card 合入之后);提交前变基到 `f74825a2`(其后只多了离线研究脚本与文档,不涉及本单文件)
- 执行:Claude 子代理(直接执行模式,产品授权)。未推送。
- 产品新增能力,任务书未要求 BUG 号,不开 BUG 号。Skill 不 bump(用户可见行为不变)。
## 结论先行
| 任务 | 做了什么 | 状态 |
| --- | --- | --- |
| T1 迁移 + RLS + 保留期 | `frontend/supabase/migrations/20260926010000_rectification_telemetry.sql`:两张新表(统计行 `rectification_telemetry`、去重台账 `rectification_telemetry_reported_cases`)、三个 SECURITY DEFINER 函数(写 / 清理 / 汇总)。只加不改 | 完成;**test:db 本机无 Docker 未跑**,以门禁 DB job 为准 |
| T2 写入点 + 纯函数 + 白名单测试 | `frontend/src/lib/rectification-agentic/v9/telemetry.ts`;挂在 `GET /api/rectification/cases/[caseId]`,复用这次响应已算好的决策与卡片;`setImmediate` 后写、不 await、吞错 | 完成 |
| T3 后台汇总页 | 「校正统计」`/admin/rectification-telemetry` + `GET /api/admin/rectification-telemetry`:宽度分布、提问数分布(含分类均值)、停止原因占比、门槛达标率、按周趋势、差距分布(=卡上是否显示百分比)、出生时间来源、版本 | 完成 |
| T4 记录 | CHANGELOG、DESIGN(后台节)、本文件、`docs/testing/rectification-telemetry-20260926.md`、`docs/operations/rectification-telemetry-privacy.md`、BLOCKED | 完成 |
## 字段表(行里只有这些,外加 `id` 随机 uuid 和 `recorded_week`)
| 字段 | 取值 / 口径 | 来源 |
| --- | --- | --- |
| `recorded_week` | 记录所在 ISO 周的周一(UTC),库里默认值生成 | DB default |
| `window_radius_minutes` | 出卡时搜索窗口宽度的一半,0–720 | `case.candidateRange` |
| `birth_time_source` | `hospital_record` / `approximate` / `period_only` / `unknown`(`block_scan` 阶段或来源为 unknown 记 unknown;family_exact、legacy_import、空都并入 approximate,与 `normalizeBirthTimeSource` 一致) | `case.birthTimeSource`、`case.stage` |
| `questions_total` | 已问过的问题数:焦点状态为 active / resolved / declined / skipped;`superseded`(未答就被换掉)不算 | 焦点列表 |
| `questions_targeted` | 问题 id 以 `collect:targeted:` 开头 | 同上 |
| `questions_guided` | 以 `collect:guided:` 开头(D1 之后每个校正最多 2 道,可直接看新流程是否生效) | 同上 |
| `questions_dated_probe` | schema 带 `probe_year` 的点选探针 | 同上 |
| `questions_personality` | `choice_kind = varga_style`、`tie_break_round`、D9/D10 风格题,以及不带年份的引擎探针(09-09 起按性格题处理) | 同上 |
| `questions_open` | 其余 `collect:*`(开放采集、邀请补经历、职业、其他) | 同上 |
| 其他类 | 不单独存;= 总数 − 五类之和(时段选择、扩窗等),汇总页显示 | 派生 |
| `experiences_added` | 仍有效的经历件数(confirmed / draft / pending_confirmation) | 证据台账 |
| `range_width_minutes` | 卡片范围宽度(卡片没有范围时用决策的可信范围),0–1440 | `range_delivery.range` |
| `candidate_count` | 仍在比较的候选分钟数 | `decision.separation.ranked` |
| `top_two_gap_points` | 卡片三列相对可能性第一与第二之差(百分点);只有一列记空 | `range_delivery.columns` |
| `stop_reason` | 见下 | 决策 + 焦点 + 推断回执 |
| `precision_gate_met` | 决策层本轮测得的 D1 门槛 | `decision.precisionGateMet` |
| `duration_seconds` | 从最早一轮到现在的秒数,封顶 1 年 | 轮次时间 |
| `algorithm_version` / `policy_version` / `skill_version` | 只允许 `[A-Za-z0-9._:+-]{1,64}`,否则记空 | 快照 / Case |
停止原因(先中先得):
1. `user_stopped`:`sessionOutcome = provisional_range_user_stopped`(Case 为 paused,点「停止」或说不想继续)。
2. `converged`:精度门槛达标。
3. `round_cap`:决策层自己的熔断 `budgetExhausted`(8 轮 / 有效答题上限 / 平台期),从推断回执算,和决策用的是同一个函数(本单只加了 `export`)。
4. `user_no_more`:`stopReason = user_uncertainty_too_high`(多半答「说不好」),或出卡前最后一道已关闭的采集题被用户答了「没有 / 记不清」。
5. `pool_exhausted`:其余(题源问空,包括 `probe_pool_exhausted`、`tied_first`、七条线问完就出卡)。
6. `error`:枚举保留,**当前没有任何路径会写**(产品里不存在「因错误结束一个校正」的事件)。
与新流程(fewer-probes-card,`7203d94e`)的对应:引导题数量(D1 上限 2)直接是 `questions_guided`;「卡上是否显示百分比」(D4,差距 ≥ 5)没有单独存,由 `top_two_gap_points` 派生——汇总页的差距分布按 0–2 / 3–4 / 5–9 / 10+ / 只有一列分桶,5–9 与 10+ 即显示了百分比。这样以后阈值变了,历史行仍能按新阈值重算,也没有在任务书字段表之外另加字段。
## 偏离任务书之处(请验收方裁决)
1. **不存用户 id(D4 更严)**。任务书 D4 允许为去重与删除联动存用户 id;协调方的硬红线是行里不存任何用户 / 案例 / 会话 id。落法:统计行没有任何 id;去重另用 `rectification_telemetry_reported_cases(case_id)`,外键 `on delete cascade` 到校正记录——账户删除 → 身份用户 → Case 级联 → 台账行删除。台账没有时间列也没有指向统计行的列,统计行无法关联回人;它本来就不含个人信息,所以账户删除后留下也不违反 D4 的目的。
2. **只在「第一次出卡」时写,不覆盖放弃 / 超时(D2 的后半句未做)**。产品里没有「放弃」或「超时结束」事件:Case 一直可续,`POST /api/rectification/cases/[caseId]/close` 前端没有调用方。没出卡就离开的会话目前不入统计。要覆盖需另开单(例如新开校正时把旧 Case 记为放弃,或定时扫描长时间无活动的 Case),届时可沿用 `error` / 新增 `abandoned` 枚举(加枚举要改 CHECK,属收紧/放宽约束,需按 §7.6 拆两轮)。
3. **时间只到周**。按周趋势只需要周;记录日期精确到天会让统计行能和 Case 的活动时间对上,所以只存周一日期。
4. **权限用 `admin.customers.read`**。现有权限里没有「只给产品负责人」的读权限;能看单个客户资料的角色看汇总不增加暴露面。若要只给 owner,需新增权限(动 RBAC 表),本单未做。
## 迁移(为什么向后兼容)
- 只有 `create table` ×2、`create index` ×1、`create or replace function` ×3(均为新函数名)、RLS 开启与 grant / revoke(只作用于新对象)。没有改任何旧表、旧函数、旧权限。
- 当前已部署的代码不引用这些对象;门禁「Migrate Staging Database」先迁移后部署,旧代码在迁移后照常运行;新代码若在迁移前运行,写入失败也只记一行日志(吞错),不影响用户。
- 表权限:两张表 RLS 开启,`anon` / `authenticated` / `app_runtime` / `admin_runtime` / `service_role` 全部 revoke,没有 policy;写只能走 `record_rectification_telemetry`(service_role),读只能走 `rectification_telemetry_summary`(admin_runtime),清理 `purge_expired_rectification_telemetry`(service_role)。
- 保留期 180 天:每次写入先删「周一早于今天 − 180 天」的行,汇总不读 180 天以前的行,另有手动清理函数。没有定时任务。
- 写入函数先核对 Case 属于该用户;任一 CHECK 不过(枚举、范围、版本格式、五类之和不超过总数)整笔回滚,去重标记不会残留。
## 测试
### 新增
| 文件 | 条数 | 内容 |
| --- | --- | --- |
| `frontend/tests/rectification-telemetry.test.ts` | 18 | 行键 = 白名单(新增字段必须改测试);写入参数 = 白名单 + 两个 id;行里只有整数 / 布尔 / 枚举 / 版本号,虚构姓名、生日、地点、原话、id、HH:MM 都不出现;各字段取值;非法版本与缺值;问题分类;来源分类;停止原因全分支(含 `budgetExhausted` 同源);资格(非交付、无卡、历史读、停止);调度:不在本 tick 写、同 Case 只写一次、只读 / 不合格不写、写失败 / 同步抛错 / build 抛错都不外抛且日志只有原因码;迁移源文本:表列 = 白名单、函数参数 = 白名单、只加不改、RLS、授权、180 天;GET 路由 `void` 调度不 await |
| `frontend/tests/admin-rectification-telemetry-contract.test.ts` | 5 | 后台接口只读、只调汇总函数、不直接查表、admin 无表权限;菜单与权限;无导出;解析器固定形状;周数只收 4 / 12 / 26;标签覆盖全部停止原因、差距分桶与卡片 5 个百分点规则对齐 |
| `frontend/tests/database-rectification-telemetry.test.ts` | 1(Docker) | 真实 PG17:列白名单、RLS、各角色零表权限、函数执行权限矩阵、admin 直接查表被拒、写入 / 重复写入 no-op、他人 Case 被拒、五种非法值整笔回滚后仍可写、180 天清理、admin 汇总内容且不含 id / 生日 / 地点、周数封顶 26、账户删除级联台账而统计行保留。**本机 docker unavailable 跳过** |
### 改动的既有断言(原值 / 新值 / 原因)
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/database-local-business.test.ts`(public 表白名单) | `profiles` 后直接 `redemption_attempts` | 中间加 `rectification_telemetry`、`rectification_telemetry_reported_cases` | 本迁移新增两张表;该断言要求与 `pg_tables` 一致(Docker 测试,本机跳过) |
| `tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources` | `admin/products` 后直接 `admin/roles` | 中间加 `admin/rectification-telemetry` | 新后台页是新的 app 路由,能力审计扫描到 |
两处都在代码里写了三栏注释。未弱化任何断言。
### 前端全量(Node 22.14.0,`/exec-daemon/node`)
- 基线:`origin/staging` `7203d94e` 同代码日志 4012 条 / 24 失败(全部 Docker / DB)。
- 本分支:**4036 条 / pass 3984 / fail 24 / skipped 28**。失败名单与基线逐条 `diff` 为空(新增失败 0);测试名列表无消失,新增 24 个名字(18 + 5 + 1)。
- `tsc --noEmit` 0 错;`npm run lint` 0 error(126 warning,均为既有文件,本单改动文件单独 eslint 0 输出)。
### Python 门禁集合
- `python3 -m pytest $(cat gate-pytest-args.txt)`:**948 passed / 1 skipped**,与基线一致,0 失败。
### 构建
- `npm run build -- --webpack`:成功;`/` 仍 `○ Static`;新增 `ƒ /admin/rectification-telemetry`、`ƒ /api/admin/rectification-telemetry`。
- rootMainFiles gzip:zlib 默认级 131,255 B / 9 级 130,950 B;基线 130,933 B → **+0.25%(默认级)/ +0.01%(9 级)**,在 ±2% 内。增量来自 webpack runtime 里多了新后台页 chunk 的映射,首页代码未变。
- 构建产生的 `frontend/frontend/` 已删除,未提交。
### 截图(虚构汇总数据)
后台布局要求服务端管理员会话,本机没有;截图用一个**临时**页面(未提交,已删除)把同一个 `AdminApp` + `RectificationTelemetrySummaryView` 渲染出来,`next start` + Chrome 无头,CDP 拦截 `/api/admin/session` 与 `/api/admin/rectification-telemetry`,后者返回经真实 `parseRectificationTelemetrySummary` 处理过的虚构汇总。控制台 0 error。
- `docs/testing/rectification-telemetry-20260926/telemetry-data-1280.png`:桌面,98 个虚构会话
- `docs/testing/rectification-telemetry-20260926/telemetry-data-390.png`:手机宽度(按周趋势表横向滚动)
- `docs/testing/rectification-telemetry-20260926/telemetry-empty-1280.png`:无数据时
首轮截图后按手机宽度把分布表「占比」列从固定 200px 改为 36% 并重截,上面三张是改后版本。最终干净构建复测 gzip 不变(131,255 / 130,950 B)。
## 环境缺口
- 无 Docker:`npm run test:db` 未跑(`database-rectification-telemetry.test.ts` 与 `database-local-business.test.ts` 的新表白名单都待门禁 DB job;run 号推送后补记)。
- 无受控后台账号:登录态、真实数据按 `docs/testing/rectification-telemetry-20260926.md` 待产品走。
- 未部署。
## 没做的
- 放弃 / 超时结束的会话不入统计(见偏离 2)。
- `error` 停止原因无写入路径。
- 没有定时清理任务(写入时清理 + 汇总只读 180 天内)。