# 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 天内)。 ## 验收(Claude,2026-09-26) 基于 `origin/staging` `f74825a2` 独立复跑(Node 22.14):tsc 0;lint 0 error;`npm test` 4036 / 24 fail,失败名单与 `7203d94e` 基线(4012 / 24)逐条一致;Python 门禁集退出 0;`next build --webpack` `/`、`/chart`、`/ephemeris`、`/people` 仍 ○ Static,rootMainFiles gzip(level 9)130933 B,±0%。迁移逐段审读:只加表/索引/函数,`begin/commit` 与授权写法与 0925 系列迁移一致,RLS 开、运行时角色无表权限、写只经 SECURITY DEFINER RPC、读只出聚合。 偏差裁定: 1. 统计行不存用户 id、改用按 case_id 级联删除的去重账本——比任务书 D4 更严,认可。 2. 放弃/超时的会话不记录——产品里没有这个事件,认可;汇总只覆盖「出过卡」的会话,读数时须记住这个偏差。 3. 周粒度日期、`admin.customers.read` 权限——认可。 环境缺口:`npm run test:db` 以本次推送的门禁 DB job 为准(run 号见下方补记);管理员真实登录走查见 `docs/testing/rectification-telemetry-20260926.md`。 补记(2026-09-26):40d7930a 的门禁 run 2937 三次都挂在 `npm ci`(镜像挂起,900s 超时,非代码问题);产品侧修复 CI(`e801fcf5`)后门禁 run 2938 通过:Python 948 passed / 1 skipped,前端含 DB 4049 / 0 fail / 0 skip,其中 `database-rectification-telemetry` 在真实 PostgreSQL 通过;migrate run 2940 应用 `20260926010000_rectification_telemetry.sql`;deploy run 2941,`/api/health` gitCommit = `e801fcf5`。剩余:管理员登录态真实页面走查。