diff --git a/BLOCKED.md b/BLOCKED.md index b041c248..e144d3a3 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,11 @@ # BLOCKED +## TASK-rectification-telemetry:真实 PostgreSQL 测试与登录态后台待验(2026-09-26) + +- 本机无 Docker:`npm run test:db` 未跑。本单新增 `frontend/tests/database-rectification-telemetry.test.ts`(列白名单、RLS 与表权限、函数执行权限、去重、归属校验、非法值整笔回滚、180 天清理、管理员只读汇总、账户删除级联),并把两张新表加进 `database-local-business.test.ts` 的 public 表白名单;两者在本机均为 docker unavailable 跳过,结果以门禁 DB job 为准(run 号待推送后补记)。替代证据:迁移 SQL 按既有迁移写法逐段复核;纯函数与白名单、迁移源文本合同在 `rectification-telemetry.test.ts`(18 条)里已跑通。 +- 无受控后台登录账号:「校正统计」页只用本地构建 + Chrome 无头、虚构汇总数据截过图;真实数据与登录态按 `docs/testing/rectification-telemetry-20260926.md` 待产品走。 +- 未部署。 + ## TASK-rectification-latency:真实模型耗时、登录态真机与引擎探针复用待验(2026-09-26) - 无模型凭据与受控登录账号:进度句与开流顺序只在本地 `next start` + Chrome 151 无头、CDP 虚构接口 + 本地延迟 NDJSON 流上验过(脚本在 scratchpad,未提交)。真实模型下每段耗时、分类 10 s 上限是否触发,按 `docs/testing/rectification-latency-20260926.md` 部署后用 `RectificationRunDiagnostic` / `RectificationTurnDiagnostic` / `RectificationClassifierDiagnostic` 日志复核。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 36659292..32e0d92b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-26 — 后台新增「校正统计」:生时校正匿名汇总(待验收) + +- 每个生时校正会话第一次给出范围卡时,服务端记一行匿名统计:起始窗口半径、出生时间来源类别、按类型分的提问数(定向 / 引导 / 带年月探针 / 性格 / 开放)、补充经历件数、最终范围宽度、候选分钟数、第一名与第二名差几个百分点、停止原因、精度门槛是否达标、用时、算法 / 策略 / Skill 版本。只有数字和类别,不含用户、校正记录或会话编号,不含出生资料、姓名、对话内容和模型原文,时间只精确到「哪一周」。 +- 后台左侧新增「校正统计」页(需要「查看客户基本资料」权限):会话数、宽度 / 提问数的中位与 90% 分位、宽度与提问数分布、停止原因占比、门槛达标率、卡上显示百分比的占比、按周趋势、出生时间来源与版本分布。只能看汇总,不能逐条查看或导出;数据保留 180 天。 +- 写入不影响用户:在回复已经算好之后另起执行,失败只记一行不带内容的原因码,不重试、不报错给用户。同一个校正会话只记一次(以第一次出卡为准)。 +- 动数据库:新增两张表和三个函数(加表,不改旧表),需要先跑 Migrate Staging Database(门禁自动执行)。用户可见行为不变,Skill 版本不 bump。 + ## 2026-09-26 — 生时校正:少问几道就出结果,结果卡以范围为主(待验收) - 按时间段点名的补经历题(「某年某月之间有没有什么事」)一次校正最多两道;七条线和跳过线的一次重问问完就出目前范围卡,不再为了「10 分钟门槛」继续追问。离线回放(20 例公开名人资料,理想作答):平均提问从 11.4 道降到 7.8 道,真值落在范围内的比例没有下降,范围宽度中位差 1 分钟以内。 diff --git a/docs/operations/rectification-telemetry-privacy.md b/docs/operations/rectification-telemetry-privacy.md new file mode 100644 index 00000000..16419adb --- /dev/null +++ b/docs/operations/rectification-telemetry-privacy.md @@ -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,失败吞掉。 diff --git a/docs/tasks/PROGRESS-rectification-telemetry-20260926.md b/docs/tasks/PROGRESS-rectification-telemetry-20260926.md new file mode 100644 index 00000000..a37b211c --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-telemetry-20260926.md @@ -0,0 +1,121 @@ +# 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 天内)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 49f5bf20..3dc6c952 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -36,7 +36,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-rectification-fewer-probes-card-20260926.md` | [PROGRESS](PROGRESS-rectification-fewer-probes-card-20260926.md) | **减少无效追问 + 卡片区间为主**:引导补件离线新增达标 0 → 上限 6→2、定向题问完门槛未达直接出卡;卡片区间为主标题、代表分钟副标题,第一二名差距 ≥5 个百分点才显示百分比,否则写「目前区分不开」。不放宽任何置信度。先做 | 已验收(真机清单欠) | `4e6e8d87` + 验收补改 `7203d94e`(区分不开时隐藏「最可能」),deploy-staging run 2933 已部署;回放真值 ±30/±60 19→20、宽度中位 ±1 分钟、提问 11.4→7.8;Skill 10.0.30;真机清单 `docs/testing/rectification-fewer-probes-card-20260926.md` 未走 | -| `TASK-rectification-telemetry-20260926.md` | — | **匿名聚合统计**:每会话一行只存数字 / 枚举(题数分类、宽度、差距、停止原因、门槛达标、耗时、版本),管理后台只看汇总、保留 180 天;动表须 test:db。排在 fewer-probes 后 | 待领取 | — | +| `TASK-rectification-telemetry-20260926.md` | [PROGRESS](PROGRESS-rectification-telemetry-20260926.md) | **匿名聚合统计**:每会话一行只存数字 / 枚举(题数分类、宽度、差距、停止原因、门槛达标、耗时、版本),管理后台只看汇总、保留 180 天;动表须 test:db。排在 fewer-probes 后 | 待验收 | `codex/rectification-telemetry-20260926`(未推送;迁移 `20260926010000` 加两表三函数;行里不存用户 / 案例 id,去重另走级联台账;本机无 Docker,test:db 以门禁为准) | | `TASK-rectification-offline-research-20260926.md` | `PROGRESS-rectification-offline-research-20260926.md` | **三项离线研究**:答错 1–2 题的容错、V1/V2 分盘配权正确重跑、改正「1 分钟≈1.1 天」(实测中位 3.8 天)并核实 `_representative_pairs` 推断。不改线上。结论(`docs/research/rectification_offline_research_2026_09_26.md`):R1 答错 1 题真值在区间 98–100%、头名降三到四成,答错 2 题 ±30/±60 挤出 7–10%(两道反答=8 分=淘汰线);R2 权重生效,V1/V2 在 ±30/±60 按定义恒等、±10 六题后指标不变 → `no_benefit`(已实测);R3 3.8 天/分钟复现,45 天闸≈8–34 分钟,`_representative_pairs` 推断被推翻(全配对题数不变,卡在逐月评估),另记 BUG-1048 `investigating`(闸门跨年豁免 + zip 错位)。三项均不建议立实现单 | 已验收 | `0f5442ce`(纯研究,不改线上);三项均不立实现单:答错 2 题 ±30 真值入区间 0.93、V1/V2 no_benefit、每分钟边界位移中位 3.82 天(勘误 5 份文档);新发现 BUG-1048 出题闸门两处漏洞待产品决定 | | `TASK-rectification-code-split-20260926.md` | — | **代码拆分(只搬不改)**:聊天组件 2043 行 / 35 useState、`POST` 926 行、`runV9AgentTurn` 1047 行,269 处切源码测试;拆分 + 增长合同 + 切片测试改调用函数。排在 fewer-probes、telemetry 之后 | 待领取 | — | | `TASK-rectification-dup-question-20260926.md` | `PROGRESS-rectification-dup-question-20260926.md` | **同一轮问题出现两次(BUG-1045,复发自 BUG-585,BUG-969 拼回题干、去重只在刷新路径)+ 同一道选择题连画两张(BUG-1046:提交失败不回滚本地已答 + 兜底问题块条件过宽;H2 漏收回合)**。先于 latency 单 | 已验收(Claude 09-26 直接执行:子代理复现 A 与 B-H1(选择题提交 409 / 网络错误不回滚本地已答);Claude 变基到含 BUG-1043/1044 的 staging 后独立复验 tsc/lint 0、全量 3981 条失败名单与基线逐条一致、四路由 ○、gzip 不变) | `e4c1c7a3`(已部署 `f1d16405`,health 一致) | diff --git a/docs/testing/rectification-telemetry-20260926.md b/docs/testing/rectification-telemetry-20260926.md new file mode 100644 index 00000000..cfe992ce --- /dev/null +++ b/docs/testing/rectification-telemetry-20260926.md @@ -0,0 +1,28 @@ +# 生时校正匿名统计:真人验收清单(2026-09-26) + +前提:staging 已部署含 `20260926010000_rectification_telemetry.sql` 的版本(门禁会先自动跑 Migrate Staging Database),并用有「查看客户基本资料」权限的后台账号登录 `https://admin.staging.jyotisha.chat`。 + +## A. 后台能看到汇总 + +1. 左侧菜单出现「校正统计」,点进去页面标题是「生时校正统计」。 +2. 顶部说明写着匿名、只看汇总、不能逐条查看或导出、保留 180 天。 +3. 右上角可以切「近 4 周 / 近 12 周 / 近 26 周」,切换后数字随之变化,不报错。 +4. 还没有任何数据时:会话数为 0,各分布表占比为 0,页面不报错。 + +## B. 做一次校正后数字会增加 + +1. 用自己的测试账号(受控账号,不要用真实用户)在 staging 走一次生时校正,直到出现「目前范围」卡片。 +2. 回到后台「校正统计」刷新:会话数 +1;「最终范围宽度分布」「提问数分布」「停止原因」各有一格 +1;按周趋势里本周 +1。 +3. 在同一个校正里继续补一件经历、再出卡,或者点「更像这个」采用:会话数**不再**增加(每个校正只记一次)。 +4. 点「停止」结束另一个校正并出现范围卡:停止原因「用户中止」+1。 + +## C. 看不到个人信息 + +1. 页面上没有任何用户邮箱、姓名、出生日期、地点、时间点(HH:MM)或对话内容。 +2. 页面上没有下载、导出或逐条列表入口。 + +## D. 用户侧无感 + +1. 做校正的过程中,出卡那一轮的等待时间与以前一样,没有新的报错或提示。 + +发现问题请截图并记下操作时间(不要截到别人的资料)。 diff --git a/docs/testing/rectification-telemetry-20260926/telemetry-data-1280.png b/docs/testing/rectification-telemetry-20260926/telemetry-data-1280.png new file mode 100644 index 00000000..67b71ab4 Binary files /dev/null and b/docs/testing/rectification-telemetry-20260926/telemetry-data-1280.png differ diff --git a/docs/testing/rectification-telemetry-20260926/telemetry-data-390.png b/docs/testing/rectification-telemetry-20260926/telemetry-data-390.png new file mode 100644 index 00000000..9948fa38 Binary files /dev/null and b/docs/testing/rectification-telemetry-20260926/telemetry-data-390.png differ diff --git a/docs/testing/rectification-telemetry-20260926/telemetry-empty-1280.png b/docs/testing/rectification-telemetry-20260926/telemetry-empty-1280.png new file mode 100644 index 00000000..7e8a98bd Binary files /dev/null and b/docs/testing/rectification-telemetry-20260926/telemetry-empty-1280.png differ diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b0ddfce6..e175a2f9 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -671,6 +671,12 @@ admin page — so nothing here inherits the tokens above. sanctioned exception to the no-raw-color rule. - **Content width:** 1200px centred, set by antd layout, not by `.admin-scroll`. - **Loading:** antd ``, outside the waiting vocabulary in section 9. +- **Aggregate pages (「校正统计」, 2026-09-26):** built only from antd `Card`, + `Statistic`, `Table`, `Progress` and `Segmented` — no chart library and no new + class. Distributions are a table whose last column is a small `Progress` bar; + the weekly trend is a table, not a line chart. The page states in its header + card that it is anonymous, summary-only (no per-row view or export) and kept + for 180 days. ## 6. Motion & Interaction diff --git a/frontend/src/app/admin/rectification-telemetry/page.tsx b/frontend/src/app/admin/rectification-telemetry/page.tsx new file mode 100644 index 00000000..6b3d0cee --- /dev/null +++ b/frontend/src/app/admin/rectification-telemetry/page.tsx @@ -0,0 +1,5 @@ +import { RectificationTelemetrySummaryView } from "@/components/admin/rectification-telemetry-summary"; + +export default function RectificationTelemetryPage() { + return ; +} diff --git a/frontend/src/app/api/admin/rectification-telemetry/route.ts b/frontend/src/app/api/admin/rectification-telemetry/route.ts new file mode 100644 index 00000000..e3d7e518 --- /dev/null +++ b/frontend/src/app/api/admin/rectification-telemetry/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +import { requirePermission } from "@/lib/admin/auth"; +import { queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { + parseRectificationTelemetrySummary, + parseTelemetrySummaryWeeks, +} from "@/lib/admin/rectification-telemetry-summary"; + +export const runtime = "nodejs"; + +/** + * GET /api/admin/rectification-telemetry?weeks=4|12|26 + * + * Aggregates only (TASK-rectification-telemetry-20260926, D3). The admin role + * has no table privilege on `rectification_telemetry`; the only read path is + * the SECURITY DEFINER summary function, which returns medians, distributions + * and a weekly trend. There is no per-row list and no export. + */ +export async function GET(request: Request) { + try { + await requirePermission("admin.customers.read"); + const weeks = parseTelemetrySummaryWeeks(new URL(request.url).searchParams.get("weeks")); + const rows = await queryAdminRows<{ summary: unknown }>( + "select public.rectification_telemetry_summary($1::integer) as summary", + [weeks], + ); + return NextResponse.json({ data: parseRectificationTelemetrySummary(rows[0]?.summary ?? null) }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 16b01c92..cd475cdc 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -14,6 +14,7 @@ import { safeToolErrorCode, } from "@/lib/rectification-agentic/v9/tool-service"; import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response"; +import { scheduleRectificationTelemetry } from "@/lib/rectification-agentic/v9/telemetry"; export const runtime = "nodejs"; @@ -96,7 +97,22 @@ export async function GET(request: Request, context: RouteContext) { }), ); const listed = await listV10ConversationFocuses(accounting, user.id, caseId); - return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { fullDetail, listed })); + return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { + fullDetail, + listed, + // Anonymous aggregate telemetry: fire-and-forget, never awaited here. + onProjected: ({ decision, rangeDelivery, readOnly }) => { + void scheduleRectificationTelemetry({ + accounting, + userId: user.id, + caseId, + readOnly, + build: () => listed.available + ? { dossier, focuses: listed.focuses, decision, rangeDelivery, now: Date.now() } + : null, + }); + }, + })); } catch (error) { if (error instanceof RectificationToolServiceError) { const message = error.message; diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index cf4428e8..d90d2d8f 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -3,6 +3,7 @@ import { ApiOutlined, AuditOutlined, + BarChartOutlined, ControlOutlined, CreditCardOutlined, DatabaseOutlined, @@ -110,6 +111,7 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: } }, { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, + { name: "rectification-telemetry", list: "/admin/rectification-telemetry", meta: { label: "校正统计", icon: } }, { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } }, { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } }, diff --git a/frontend/src/components/admin/rectification-telemetry-summary.tsx b/frontend/src/components/admin/rectification-telemetry-summary.tsx new file mode 100644 index 00000000..11c84d6c --- /dev/null +++ b/frontend/src/components/admin/rectification-telemetry-summary.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { Alert, Card, Col, Progress, Row, Segmented, Space, Spin, Statistic, Table, Typography } from "antd"; +import { useEffect, useState } from "react"; + +import { adminRequestJson } from "@/lib/admin/providers"; +import { + TELEMETRY_BIRTH_TIME_SOURCE_LABEL, + TELEMETRY_GAP_BUCKET_LABEL, + TELEMETRY_PERCENT_SHOWN_BUCKETS, + TELEMETRY_QUESTION_BUCKET_LABEL, + TELEMETRY_STOP_REASON_LABEL, + TELEMETRY_SUMMARY_DEFAULT_WEEKS, + TELEMETRY_SUMMARY_WEEK_OPTIONS, + TELEMETRY_WIDTH_BUCKET_LABEL, + share, + type RectificationTelemetrySummary, + type TelemetryBucket, + type TelemetryVersionRow, + type TelemetryWeek, +} from "@/lib/admin/rectification-telemetry-summary"; + +type LoadState = + | { status: "loading"; weeks: number } + | { status: "ready"; weeks: number; summary: RectificationTelemetrySummary } + | { status: "error"; weeks: number; message: string }; + +function formatNumber(value: number | null, suffix = "") { + return value === null || !Number.isFinite(value) + ? "—" + : `${value.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}${suffix}`; +} + +function formatPercent(value: number | null) { + return value === null ? "—" : `${(value * 100).toFixed(0)}%`; +} + +function formatMinutes(seconds: number | null) { + return seconds === null ? "—" : formatNumber(seconds / 60, " 分钟"); +} + +function BucketTable({ + rows, + labels, + total, + emptyText, + keyTitle = "区间", +}: { + keyTitle?: string; + rows: readonly TelemetryBucket[]; + labels: Readonly>; + total: number; + emptyText: string; +}) { + return ( + + size="small" + pagination={false} + rowKey="key" + dataSource={[...rows]} + locale={{ emptyText }} + columns={[ + { title: keyTitle, dataIndex: "key", render: (key: string) => labels[key] ?? key }, + { title: "会话", dataIndex: "count", width: 64 }, + { + title: "占比", + width: "36%", + render: (_, row) => { + const value = share(row.count, total); + return ; + }, + }, + ]} + /> + ); +} + +export function RectificationTelemetrySummaryView() { + const [state, setState] = useState({ status: "loading", weeks: TELEMETRY_SUMMARY_DEFAULT_WEEKS }); + const weeks = state.weeks; + + useEffect(() => { + let active = true; + adminRequestJson<{ data: RectificationTelemetrySummary }>(`/api/admin/rectification-telemetry?weeks=${weeks}`) + .then((payload) => { + if (active) setState({ status: "ready", weeks, summary: payload.data }); + }) + .catch((error: unknown) => { + if (active) { + setState({ + status: "error", + weeks, + message: error instanceof Error ? error.message : "统计暂时不可用", + }); + } + }); + return () => { + active = false; + }; + }, [weeks]); + + const summary = state.status === "ready" ? state.summary : null; + const total = summary?.sessions ?? 0; + const gateKnown = summary ? summary.precisionGate.met + summary.precisionGate.notMet : 0; + const percentShown = summary + ? summary.gapDistribution + .filter((bucket) => TELEMETRY_PERCENT_SHOWN_BUCKETS.includes(bucket.key)) + .reduce((sum, bucket) => sum + bucket.count, 0) + : 0; + + return ( + + ({ label: `近 ${value} 周`, value }))} + onChange={(value) => setState({ status: "loading", weeks: Number(value) })} + /> + )} + > + + 匿名汇总:每个校正会话在第一次给出范围卡时记一行,只有数字和类别,不含用户、出生资料或对话内容。 + 这里只看汇总,不能逐条查看或导出;数据保留 180 天。 + {summary?.since ? ` 当前统计从 ${summary.since} 那一周起。` : ""} + + {state.status === "error" ? ( + + ) : null} + + + {state.status === "loading" ? ( + + ) : null} + + {summary ? ( + <> + + + + + + + + + + + 0 ? `(${summary.precisionGate.met}/${gateKnown})` : undefined} + /> + + + + + + + + + + + + + + + + + + + 每个会话平均:定向 {formatNumber(summary.questionTypes.targeted)} · 引导 {formatNumber(summary.questionTypes.guided)} + {" "}· 带年月探针 {formatNumber(summary.questionTypes.datedProbe)} · 性格 {formatNumber(summary.questionTypes.personality)} + {" "}· 开放 {formatNumber(summary.questionTypes.open)} · 其他 {formatNumber(summary.questionTypes.other)}。 + 问过引导题的会话 {summary.questionTypes.guidedAskedSessions} 个。 + + + + + + + + + + + + + + + + + + size="small" + pagination={false} + rowKey="week" + dataSource={[...summary.weekly]} + scroll={{ x: 640 }} + columns={[ + { title: "周(周一起)", dataIndex: "week" }, + { title: "会话", dataIndex: "sessions" }, + { title: "宽度中位", dataIndex: "rangeWidthMinutesP50", render: (value: number | null) => formatNumber(value, " 分钟") }, + { title: "提问中位", dataIndex: "questionsTotalP50", render: (value: number | null) => formatNumber(value, " 题") }, + { + title: "门槛达标", + render: (_, row) => row.precisionGateKnown > 0 + ? `${formatPercent(share(row.precisionGateMet, row.precisionGateKnown))}(${row.precisionGateMet}/${row.precisionGateKnown})` + : "—", + }, + ]} + /> + + + + + + + + 起始窗口半径中位 {formatNumber(summary.medians.windowRadiusMinutesP50, " 分钟")}。 + + + + + + + size="small" + pagination={false} + rowKey={(row) => `${row.algorithmVersion}|${row.policyVersion}|${row.skillVersion}`} + dataSource={[...summary.versions]} + locale={{ emptyText: "暂无数据" }} + columns={[ + { title: "算法", dataIndex: "algorithmVersion", render: (value: string | null) => value ?? "—" }, + { title: "策略", dataIndex: "policyVersion", render: (value: string | null) => value ?? "—" }, + { title: "Skill", dataIndex: "skillVersion", render: (value: string | null) => value ?? "—" }, + { title: "会话", dataIndex: "count", width: 72 }, + ]} + /> + + + + + ) : null} + + ); +} diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index 17d84bc1..2947b766 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -202,6 +202,7 @@ const resourcePermissions: Record = { }, 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" }, diff --git a/frontend/src/lib/admin/rectification-telemetry-summary.ts b/frontend/src/lib/admin/rectification-telemetry-summary.ts new file mode 100644 index 00000000..267c2de3 --- /dev/null +++ b/frontend/src/lib/admin/rectification-telemetry-summary.ts @@ -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> = { + converged: "收敛(精度门槛达标)", + pool_exhausted: "题源问空", + user_no_more: "用户说没有了 / 多半说不好", + round_cap: "8 轮上限", + user_stopped: "用户中止", + error: "错误", +}; + +export const TELEMETRY_BIRTH_TIME_SOURCE_LABEL: Readonly> = { + hospital_record: "出生记录", + approximate: "大概时间", + period_only: "时间段", + unknown: "不知道", +}; + +export const TELEMETRY_WIDTH_BUCKET_LABEL: Readonly> = { + "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> = { + "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> = { + "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> { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +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; +} diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 4f9451c3..20dfbfd4 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -560,7 +560,10 @@ function decideRectificationInner(input: DecideRectificationInput): Rectificatio }); } -function budgetExhausted(input: DecideRectificationInput): boolean { +/** The 8-round / answer-cap / plateau fuse. Exported for telemetry's stop reason. */ +export function budgetExhausted( + input: Pick, +): boolean { return (input.inferenceRounds ?? 0) >= DEFAULT_MAX_DISCRIMINATION_ROUNDS || (input.effectiveAnswerCount ?? 0) >= EFFECTIVE_ANSWER_SAFETY_CAP || (input.plateauRounds ?? 0) >= RECTIFICATION_POLICY.maxPlateauRounds; diff --git a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts index a02507e7..d8c90e8b 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts @@ -34,7 +34,16 @@ export function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, - options: { fullDetail?: boolean; listed?: ConversationFocusList; liveIdentity?: LiveEngineScoringIdentity } = {}, + options: { + fullDetail?: boolean; + listed?: ConversationFocusList; + liveIdentity?: LiveEngineScoringIdentity; + /** + * Hands the decision and card this projection already computed to the + * caller (anonymous telemetry), so nothing is decided twice. + */ + onProjected?: (projected: DossierProjection) => void; + } = {}, ) { const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null }); const decision = decideFromDossier(dossier, { @@ -52,7 +61,7 @@ export function dossierResponse( canAdopt: !identity.read_only && overlaid.can_adopt === true, acceptedTime: dossier.case.acceptedTime, }); - return { + const response = { case: { result_identity: identity, result_notice: identity.read_only ? OLD_ALGORITHM_NOTICE : null, @@ -102,8 +111,20 @@ export function dossierResponse( next_user_action: nextUserActionFromDossier(dossier), question_source: questionSourceFromFocusList(listed), }; + options.onProjected?.({ + decision, + rangeDelivery: response.latest_result?.range_delivery ?? null, + readOnly: identity.read_only, + }); + return response; } +export type DossierProjection = Readonly<{ + decision: ReturnType; + rangeDelivery: ReturnType | null; + readOnly: boolean; +}>; + function publicLatestResult( latest: V9CaseDossier["latestResult"], decision: ReturnType, diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 239bc300..9218b816 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -616,7 +616,7 @@ function userInterviewAnswers( )) ?? []; } -function decisionBudgetFromInference(inference: InferenceState | null | undefined) { +export function decisionBudgetFromInference(inference: InferenceState | null | undefined) { const rounds = inference?.rounds ?? []; const userAnswers = userInterviewAnswers(inference?.answered_probes); let plateauRounds = 0; diff --git a/frontend/src/lib/rectification-agentic/v9/telemetry.ts b/frontend/src/lib/rectification-agentic/v9/telemetry.ts new file mode 100644 index 00000000..b61f5564 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/telemetry.ts @@ -0,0 +1,396 @@ +/** + * Anonymous aggregate telemetry for rectification (TASK-rectification-telemetry-20260926). + * + * One row per Case, written once, when the range card is first delivered. + * The row is numbers and closed enums only; `RECTIFICATION_TELEMETRY_FIELDS` + * is the whole allowlist and the privacy test pins it. No user / Case / + * session id, birth data, names, evidence text, model output or timestamps + * are ever part of the row. The Case id and user id travel to the database + * function only for the ownership check and the dedupe ledger. + * + * The write never touches the user's turn: `scheduleRectificationTelemetry` + * starts after the current response is built (`setImmediate`), swallows every + * failure, and logs only a short reason code with no payload. + */ +import { sessionOutcomeAllowsDelivery, budgetExhausted } from "../core/rectification-decision.ts"; +import type { RectificationDecision } from "../core/rectification-decision.ts"; +import { deliveryWidthMinutes } from "../core/precision-gate.ts"; +import { rangeWidthMinutes } from "../user-copy.ts"; +import { previousInferenceFromReceipt } from "./inference-adapter.ts"; +import { decisionBudgetFromInference } from "./decision-from-dossier.ts"; + +export const RECTIFICATION_TELEMETRY_FIELDS = [ + "window_radius_minutes", + "birth_time_source", + "questions_total", + "questions_targeted", + "questions_guided", + "questions_dated_probe", + "questions_personality", + "questions_open", + "experiences_added", + "range_width_minutes", + "candidate_count", + "top_two_gap_points", + "stop_reason", + "precision_gate_met", + "duration_seconds", + "algorithm_version", + "policy_version", + "skill_version", +] as const; + +export type RectificationTelemetryField = (typeof RECTIFICATION_TELEMETRY_FIELDS)[number]; + +export const RECTIFICATION_TELEMETRY_STOP_REASONS = [ + "converged", + "pool_exhausted", + "user_no_more", + "round_cap", + "user_stopped", + "error", +] as const; + +export type RectificationTelemetryStopReason = (typeof RECTIFICATION_TELEMETRY_STOP_REASONS)[number]; + +export const RECTIFICATION_TELEMETRY_BIRTH_TIME_SOURCES = [ + "hospital_record", + "approximate", + "period_only", + "unknown", +] as const; + +export type RectificationTelemetryBirthTimeSource = + (typeof RECTIFICATION_TELEMETRY_BIRTH_TIME_SOURCES)[number]; + +export type RectificationTelemetryQuestionKind = + | "targeted" + | "guided" + | "dated_probe" + | "personality" + | "open" + | "other"; + +export type RectificationTelemetryRow = Readonly<{ + window_radius_minutes: number | null; + birth_time_source: RectificationTelemetryBirthTimeSource; + questions_total: number; + questions_targeted: number; + questions_guided: number; + questions_dated_probe: number; + questions_personality: number; + questions_open: number; + experiences_added: number; + range_width_minutes: number | null; + candidate_count: number; + top_two_gap_points: number | null; + stop_reason: RectificationTelemetryStopReason; + precision_gate_met: boolean | null; + duration_seconds: number; + algorithm_version: string | null; + policy_version: string | null; + skill_version: string | null; +}>; + +type TelemetryFocus = Readonly<{ + questionId: string; + expectedAnswerSchema: Readonly>; + status: string; + askedAt: string; + resolvedAt: string | null; +}>; + +export type RectificationTelemetryInput = Readonly<{ + dossier: Readonly<{ + case: Readonly<{ + candidateRange: Readonly<{ start_time?: string; end_time?: string }> | null; + stage?: string; + birthTimeSource?: string | null; + skillVersion: string; + lastActivityAt: string; + }>; + turns: readonly Readonly<{ createdAt: string }>[]; + evidence: readonly Readonly<{ status: string }>[]; + latestResult: Readonly<{ + algorithmVersion: string | null; + policyVersion: string | null; + decisionReceipt: Readonly> | null; + createdAt?: string; + }> | null; + }>; + focuses: readonly TelemetryFocus[]; + decision: Pick< + RectificationDecision, + "sessionOutcome" | "stopReason" | "precisionGateMet" | "credibleRange" + > & Readonly<{ separation: Readonly<{ ranked: readonly unknown[] }> }>; + rangeDelivery: Readonly<{ + range: readonly [string, string] | null; + columns: readonly Readonly<{ probability_percent: number }>[]; + }> | null; + now: number; +}>; + +/** A delivery older than this is a history read, not a live delivery. */ +export const RECTIFICATION_TELEMETRY_LIVE_WINDOW_MS = 2 * 60 * 60 * 1000; +const MAX_DURATION_SECONDS = 31_536_000; +const MAX_COUNT = 1000; +const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$/; +const LIVE_EVIDENCE = new Set(["confirmed", "draft", "pending_confirmation"]); +const ASKED_FOCUS = new Set(["active", "resolved", "declined", "skipped"]); +const COLLECT_KINDS = new Set(["targeted", "guided", "open"]); + +function count(value: number): number { + if (!Number.isFinite(value) || value < 0) return 0; + return Math.min(MAX_COUNT, Math.trunc(value)); +} + +function epoch(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function safeVersion(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return VERSION_PATTERN.test(trimmed) ? trimmed : null; +} + +/** + * Question type from the persisted focus id and schema. Ids are server-built + * (`stableFollowupQuestionId`); nothing the user typed reaches this. + */ +export function telemetryQuestionKind( + focus: Pick, +): RectificationTelemetryQuestionKind { + const id = focus.questionId; + const schema = focus.expectedAnswerSchema ?? {}; + if (id.startsWith("collect:targeted:")) return "targeted"; + if (id.startsWith("collect:guided:")) return "guided"; + if ( + schema.choice_kind === "varga_style" + || schema.tie_break_round === true + || /(?:^|[:.])varga\.d(?:9|10)(?:\b|[:.])/.test(id) + ) { + return "personality"; + } + if (typeof schema.probe_year === "number" && schema.probe_year > 0) return "dated_probe"; + if (id.startsWith("collect:")) return "open"; + // Yearless engine probes were downgraded to tie-break style questions + // (2026-09-09); they are personality questions, not dated probes. + if (id.startsWith("probe:") || typeof schema.probe_id === "string") return "personality"; + return "other"; +} + +export function telemetryBirthTimeSource(input: Readonly<{ + birthTimeSource?: string | null; + stage?: string; +}>): RectificationTelemetryBirthTimeSource { + if (input.stage === "block_scan" || input.birthTimeSource === "unknown") return "unknown"; + if (input.birthTimeSource === "hospital_record") return "hospital_record"; + if (input.birthTimeSource === "period_only") return "period_only"; + return "approximate"; +} + +function askedFocuses(focuses: readonly TelemetryFocus[]): TelemetryFocus[] { + return focuses.filter((focus) => ASKED_FOCUS.has(focus.status)); +} + +/** The user declined the last closed collect question ("没有了" / 记不清). */ +function lastCollectDeclined(focuses: readonly TelemetryFocus[]): boolean { + const closed = focuses + .filter((focus) => focus.status !== "active" && focus.status !== "superseded") + .map((focus) => ({ focus, at: epoch(focus.resolvedAt) ?? epoch(focus.askedAt) ?? 0 })) + .sort((left, right) => left.at - right.at); + const last = closed.at(-1)?.focus; + if (!last) return false; + return last.status === "declined" && COLLECT_KINDS.has(telemetryQuestionKind(last)); +} + +/** + * Stop reason, first match wins: the user stopped; the precision gate is met + * (converged); the 8-round / answer-cap / plateau fuse fired; the user's + * answers ran out ("没有了" on the last collect question, or mostly "说不好"); + * otherwise the question sources were asked out. `error` is reserved: no + * rectification path ends a Case on an error today. + */ +export function telemetryStopReason(input: Readonly<{ + sessionOutcome: string; + precisionGateMet: boolean | null | undefined; + stopReason: string | null | undefined; + roundCapReached: boolean; + lastCollectDeclined: boolean; +}>): RectificationTelemetryStopReason { + if (input.sessionOutcome === "provisional_range_user_stopped") return "user_stopped"; + if (input.precisionGateMet === true) return "converged"; + if (input.roundCapReached) return "round_cap"; + if (input.stopReason === "user_uncertainty_too_high" || input.lastCollectDeclined) return "user_no_more"; + return "pool_exhausted"; +} + +function roundCapReached(decisionReceipt: Readonly> | null | undefined): boolean { + const inference = previousInferenceFromReceipt(decisionReceipt ?? null); + return inference ? budgetExhausted(decisionBudgetFromInference(inference)) : false; +} + +function windowRadiusMinutes( + range: Readonly<{ start_time?: string; end_time?: string }> | null, +): number | null { + const start = range?.start_time?.trim().slice(0, 5) ?? ""; + const end = range?.end_time?.trim().slice(0, 5) ?? ""; + if (!start || !end) return null; + if (start === end) return 0; + const width = rangeWidthMinutes(start, end); + return width == null ? null : Math.min(720, Math.round(width / 2)); +} + +function topTwoGapPoints( + columns: readonly Readonly<{ probability_percent: number }>[] | undefined, +): number | null { + const percents = (columns ?? []) + .map((column) => column.probability_percent) + .filter((value) => Number.isFinite(value)) + .sort((left, right) => right - left); + if (percents.length < 2) return null; + return Math.max(0, Math.min(100, Math.round(percents[0]! - percents[1]!))); +} + +function durationSeconds(input: RectificationTelemetryInput): number { + const starts = [ + ...input.dossier.turns.map((turn) => epoch(turn.createdAt)), + epoch(input.dossier.latestResult?.createdAt), + ].filter((value): value is number => value != null); + if (starts.length === 0) return 0; + const seconds = Math.round((input.now - Math.min(...starts)) / 1000); + return Math.max(0, Math.min(MAX_DURATION_SECONDS, seconds)); +} + +/** Pure: the whole row, nothing else. */ +export function buildRectificationTelemetryRow(input: RectificationTelemetryInput): RectificationTelemetryRow { + const asked = askedFocuses(input.focuses); + const kinds = asked.map(telemetryQuestionKind); + const kindCount = (kind: RectificationTelemetryQuestionKind) => count(kinds.filter((item) => item === kind).length); + const typed = { + questions_targeted: kindCount("targeted"), + questions_guided: kindCount("guided"), + questions_dated_probe: kindCount("dated_probe"), + questions_personality: kindCount("personality"), + questions_open: kindCount("open"), + }; + const typedSum = Object.values(typed).reduce((sum, value) => sum + value, 0); + const width = deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange); + return { + window_radius_minutes: windowRadiusMinutes(input.dossier.case.candidateRange), + birth_time_source: telemetryBirthTimeSource(input.dossier.case), + questions_total: Math.max(count(asked.length), typedSum), + ...typed, + experiences_added: count(input.dossier.evidence.filter((item) => LIVE_EVIDENCE.has(item.status)).length), + range_width_minutes: width == null ? null : Math.min(1440, Math.max(0, Math.round(width))), + candidate_count: Math.min(1440, count(input.decision.separation.ranked.length)), + top_two_gap_points: topTwoGapPoints(input.rangeDelivery?.columns), + stop_reason: telemetryStopReason({ + sessionOutcome: input.decision.sessionOutcome, + precisionGateMet: input.decision.precisionGateMet, + stopReason: input.decision.stopReason, + roundCapReached: roundCapReached(input.dossier.latestResult?.decisionReceipt), + lastCollectDeclined: lastCollectDeclined(input.focuses), + }), + precision_gate_met: typeof input.decision.precisionGateMet === "boolean" ? input.decision.precisionGateMet : null, + duration_seconds: durationSeconds(input), + algorithm_version: safeVersion(input.dossier.latestResult?.algorithmVersion), + policy_version: safeVersion(input.dossier.latestResult?.policyVersion), + skill_version: safeVersion(input.dossier.case.skillVersion), + }; +} + +/** Only a live first delivery of a range card is recorded. */ +export function rectificationTelemetryEligible( + input: Pick, +): boolean { + if (!sessionOutcomeAllowsDelivery(input.decision.sessionOutcome)) return false; + if (!input.rangeDelivery || input.rangeDelivery.columns.length === 0) return false; + const lastActivity = epoch(input.dossier.case.lastActivityAt); + if (lastActivity == null) return false; + return input.now - lastActivity <= RECTIFICATION_TELEMETRY_LIVE_WINDOW_MS; +} + +export function rectificationTelemetryRpcArgs( + ids: Readonly<{ userId: string; caseId: string }>, + row: RectificationTelemetryRow, +): Record { + const args: Record = { p_user_id: ids.userId, p_case_id: ids.caseId }; + for (const field of RECTIFICATION_TELEMETRY_FIELDS) { + args[`p_${field}`] = row[field]; + } + return args; +} + +type TelemetryRpcClient = Readonly<{ + rpc( + fn: string, + args: Record, + ): PromiseLike<{ data: unknown; error: { message?: string; code?: string } | null }>; +}>; + +const reportedCases = new Set(); +const REPORTED_CASES_LIMIT = 10_000; + +export function resetRectificationTelemetryForTests(): void { + reportedCases.clear(); +} + +/** Short, payload-free reason for the log line. */ +function telemetryFailureReason(error: unknown): string { + if (error && typeof error === "object") { + const code = (error as { code?: unknown }).code; + if (typeof code === "string" && /^[A-Z0-9]{5}$/.test(code)) return code; + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + const known = /rectification_telemetry_[a-z_]+/.exec(message); + if (known) return known[0]; + } + if (error instanceof Error && /^[A-Za-z]{1,40}$/.test(error.name)) return error.name; + } + return "unknown"; +} + +function logTelemetryFailure(reason: string): void { + console.warn(`[rectification-telemetry] write skipped reason=${reason}`); +} + +/** + * Fire-and-forget. Returns a promise only so tests can await it; it never + * rejects, and callers must not await it on the user's path. + */ +export function scheduleRectificationTelemetry(input: Readonly<{ + accounting: TelemetryRpcClient; + userId: string; + caseId: string; + readOnly?: boolean; + build: () => RectificationTelemetryInput | null; +}>): Promise { + let row: RectificationTelemetryRow; + try { + if (input.readOnly === true || !input.userId || !input.caseId) return Promise.resolve(); + if (reportedCases.has(input.caseId)) return Promise.resolve(); + const telemetryInput = input.build(); + if (!telemetryInput || !rectificationTelemetryEligible(telemetryInput)) return Promise.resolve(); + row = buildRectificationTelemetryRow(telemetryInput); + } catch (error) { + logTelemetryFailure(telemetryFailureReason(error)); + return Promise.resolve(); + } + if (reportedCases.size >= REPORTED_CASES_LIMIT) reportedCases.clear(); + reportedCases.add(input.caseId); + const args = rectificationTelemetryRpcArgs({ userId: input.userId, caseId: input.caseId }, row); + return new Promise((resolve) => setImmediate(resolve)) + .then(() => input.accounting.rpc("record_rectification_telemetry", args)) + .then( + (result) => { + if (result?.error) logTelemetryFailure(telemetryFailureReason(result.error)); + }, + (error: unknown) => { + logTelemetryFailure(telemetryFailureReason(error)); + }, + ) + .catch(() => undefined); +} diff --git a/frontend/supabase/migrations/20260926010000_rectification_telemetry.sql b/frontend/supabase/migrations/20260926010000_rectification_telemetry.sql new file mode 100644 index 00000000..f215b445 --- /dev/null +++ b/frontend/supabase/migrations/20260926010000_rectification_telemetry.sql @@ -0,0 +1,405 @@ +-- Rectification anonymous aggregate telemetry +-- (docs/tasks/TASK-rectification-telemetry-20260926.md). +-- +-- One row per rectification Case, written once, when its range card is first +-- delivered. The row holds numbers and closed enums only. It carries no user, +-- Case or session id, no birth data, no names, no conversation text, no model +-- output, and no timestamp finer than the ISO week (`recorded_week`). +-- +-- Dedupe lives in a separate ledger keyed by case_id. The ledger cascades with +-- the Case (and so with account deletion) and has no column that points at a +-- stats row, so a stats row cannot be joined back to a person. +-- +-- Access: +-- * both tables enable RLS and grant no table privilege to any runtime role; +-- * the web server writes only through record_rectification_telemetry +-- (SECURITY DEFINER, service_role only); +-- * the admin reads only aggregates through rectification_telemetry_summary +-- (SECURITY DEFINER, admin_runtime only). There is no per-row read path. +-- +-- Retention: 180 days. Every write first deletes rows whose week started more +-- than 180 days ago; the summary never reads past 180 days either; and +-- purge_expired_rectification_telemetry() lets an operator run the same delete. +-- +-- Backward compatibility: additive only. Two new tables and three new +-- functions; no existing table, column, function or grant changes, so the code +-- that is deployed before this migration keeps working unchanged. + +begin; + +create table if not exists public.rectification_telemetry ( + id uuid primary key default gen_random_uuid(), + recorded_week date not null + default (pg_catalog.date_trunc('week', pg_catalog.timezone('UTC', pg_catalog.now())))::date + check (extract(isodow from recorded_week) = 1), + window_radius_minutes integer check (window_radius_minutes between 0 and 720), + birth_time_source text not null + check (birth_time_source in ('hospital_record', 'approximate', 'period_only', 'unknown')), + questions_total integer not null check (questions_total between 0 and 1000), + questions_targeted integer not null check (questions_targeted between 0 and 1000), + questions_guided integer not null check (questions_guided between 0 and 1000), + questions_dated_probe integer not null check (questions_dated_probe between 0 and 1000), + questions_personality integer not null check (questions_personality between 0 and 1000), + questions_open integer not null check (questions_open between 0 and 1000), + experiences_added integer not null check (experiences_added between 0 and 1000), + range_width_minutes integer check (range_width_minutes between 0 and 1440), + candidate_count integer not null check (candidate_count between 0 and 1440), + top_two_gap_points integer check (top_two_gap_points between 0 and 100), + stop_reason text not null check (stop_reason in ( + 'converged', + 'pool_exhausted', + 'user_no_more', + 'round_cap', + 'user_stopped', + 'error' + )), + precision_gate_met boolean, + duration_seconds integer not null check (duration_seconds between 0 and 31536000), + algorithm_version text check (algorithm_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'), + policy_version text check (policy_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'), + skill_version text check (skill_version ~ '^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$'), + check ( + questions_targeted + questions_guided + questions_dated_probe + + questions_personality + questions_open <= questions_total + ) +); + +create index if not exists rectification_telemetry_week_idx + on public.rectification_telemetry (recorded_week); + +create table if not exists public.rectification_telemetry_reported_cases ( + case_id uuid primary key + references public.agentic_rectification_cases(id) on delete cascade +); + +alter table public.rectification_telemetry enable row level security; +alter table public.rectification_telemetry_reported_cases enable row level security; + +revoke all on table public.rectification_telemetry from public, anon, authenticated, service_role; +revoke all on table public.rectification_telemetry_reported_cases from public, anon, authenticated, service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'app_runtime') then + revoke all on table public.rectification_telemetry from app_runtime; + revoke all on table public.rectification_telemetry_reported_cases from app_runtime; + end if; + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + revoke all on table public.rectification_telemetry from admin_runtime; + revoke all on table public.rectification_telemetry_reported_cases from admin_runtime; + end if; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Write path: server only. The ids are used for the ownership check and the +-- dedupe ledger; neither is written into the stats row. +-- --------------------------------------------------------------------------- + +create or replace function public.record_rectification_telemetry( + p_user_id uuid, + p_case_id uuid, + p_window_radius_minutes integer, + p_birth_time_source text, + p_questions_total integer, + p_questions_targeted integer, + p_questions_guided integer, + p_questions_dated_probe integer, + p_questions_personality integer, + p_questions_open integer, + p_experiences_added integer, + p_range_width_minutes integer, + p_candidate_count integer, + p_top_two_gap_points integer, + p_stop_reason text, + p_precision_gate_met boolean, + p_duration_seconds integer, + p_algorithm_version text, + p_policy_version text, + p_skill_version text +) +returns boolean +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_marked uuid; +begin + if p_user_id is null or p_case_id is null then + raise exception 'rectification_telemetry_invalid' using errcode = '22023'; + end if; + perform 1 from public.agentic_rectification_cases c + where c.id = p_case_id and c.user_id = p_user_id; + if not found then + raise exception 'rectification_telemetry_case_not_found' using errcode = 'P0002'; + end if; + + delete from public.rectification_telemetry + where recorded_week < (pg_catalog.timezone('UTC', pg_catalog.now()))::date - 180; + + insert into public.rectification_telemetry_reported_cases (case_id) + values (p_case_id) + on conflict (case_id) do nothing + returning case_id into v_marked; + if v_marked is null then + return false; + end if; + + insert into public.rectification_telemetry ( + window_radius_minutes, + birth_time_source, + questions_total, + questions_targeted, + questions_guided, + questions_dated_probe, + questions_personality, + questions_open, + experiences_added, + range_width_minutes, + candidate_count, + top_two_gap_points, + stop_reason, + precision_gate_met, + duration_seconds, + algorithm_version, + policy_version, + skill_version + ) values ( + p_window_radius_minutes, + p_birth_time_source, + p_questions_total, + p_questions_targeted, + p_questions_guided, + p_questions_dated_probe, + p_questions_personality, + p_questions_open, + p_experiences_added, + p_range_width_minutes, + p_candidate_count, + p_top_two_gap_points, + p_stop_reason, + p_precision_gate_met, + p_duration_seconds, + p_algorithm_version, + p_policy_version, + p_skill_version + ); + return true; +end; +$$; + +create or replace function public.purge_expired_rectification_telemetry() +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_deleted integer; +begin + delete from public.rectification_telemetry + where recorded_week < (pg_catalog.timezone('UTC', pg_catalog.now()))::date - 180; + get diagnostics v_deleted = row_count; + return v_deleted; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Read path: aggregates only (medians, distributions, weekly trend). Weeks +-- are capped at 26 and rows older than 180 days are never read. +-- --------------------------------------------------------------------------- + +create or replace function public.rectification_telemetry_summary(p_weeks integer default 12) +returns jsonb +language sql +stable +security definer +set search_path = '' +as $$ + with bounds as ( + select + greatest(1, least(coalesce(p_weeks, 12), 26)) as weeks, + (pg_catalog.timezone('UTC', pg_catalog.now()))::date as today + ), window_start as ( + select + b.weeks, + greatest( + (pg_catalog.date_trunc('week', b.today::timestamp))::date - (b.weeks - 1) * 7, + b.today - 180 + ) as since + from bounds b + ), recent as ( + select t.* + from public.rectification_telemetry t, window_start w + where t.recorded_week >= w.since + ), width_buckets(key, lo, hi, ord) as ( + values ('0', 0, 0, 1), ('1-5', 1, 5, 2), ('6-10', 6, 10, 3), ('11-20', 11, 20, 4), + ('21-30', 21, 30, 5), ('31-60', 31, 60, 6), ('61+', 61, null, 7) + ), question_buckets(key, lo, hi, ord) as ( + values ('0-3', 0, 3, 1), ('4-6', 4, 6, 2), ('7-9', 7, 9, 3), ('10-12', 10, 12, 4), + ('13-15', 13, 15, 5), ('16+', 16, null, 6) + ), gap_buckets(key, lo, hi, ord) as ( + values ('0-2', 0, 2, 1), ('3-4', 3, 4, 2), ('5-9', 5, 9, 3), ('10+', 10, null, 4) + ), stop_reasons(key, ord) as ( + values ('converged', 1), ('pool_exhausted', 2), ('user_no_more', 3), + ('round_cap', 4), ('user_stopped', 5), ('error', 6) + ), sources(key, ord) as ( + values ('hospital_record', 1), ('approximate', 2), ('period_only', 3), ('unknown', 4) + ), weeks as ( + select (pg_catalog.date_trunc('week', b.today::timestamp))::date - g.n * 7 as week + from bounds b, pg_catalog.generate_series(0, (select weeks from bounds) - 1) as g(n) + ) + select pg_catalog.jsonb_build_object( + 'weeks', (select weeks from window_start), + 'since', (select since from window_start), + 'retention_days', 180, + 'sessions', (select count(*) from recent), + 'medians', ( + select pg_catalog.jsonb_build_object( + 'range_width_minutes_p50', round((percentile_cont(0.5) within group (order by range_width_minutes))::numeric, 1), + 'range_width_minutes_p90', round((percentile_cont(0.9) within group (order by range_width_minutes))::numeric, 1), + 'questions_total_p50', round((percentile_cont(0.5) within group (order by questions_total))::numeric, 1), + 'questions_total_p90', round((percentile_cont(0.9) within group (order by questions_total))::numeric, 1), + 'experiences_added_p50', round((percentile_cont(0.5) within group (order by experiences_added))::numeric, 1), + 'candidate_count_p50', round((percentile_cont(0.5) within group (order by candidate_count))::numeric, 1), + 'window_radius_minutes_p50', round((percentile_cont(0.5) within group (order by window_radius_minutes))::numeric, 1), + 'duration_seconds_p50', round((percentile_cont(0.5) within group (order by duration_seconds))::numeric, 1), + 'duration_seconds_p90', round((percentile_cont(0.9) within group (order by duration_seconds))::numeric, 1) + ) + from recent + ), + 'question_types', ( + select pg_catalog.jsonb_build_object( + 'targeted', round(coalesce(avg(questions_targeted), 0)::numeric, 2), + 'guided', round(coalesce(avg(questions_guided), 0)::numeric, 2), + 'dated_probe', round(coalesce(avg(questions_dated_probe), 0)::numeric, 2), + 'personality', round(coalesce(avg(questions_personality), 0)::numeric, 2), + 'open', round(coalesce(avg(questions_open), 0)::numeric, 2), + 'other', round(coalesce(avg( + questions_total - questions_targeted - questions_guided + - questions_dated_probe - questions_personality - questions_open + ), 0)::numeric, 2), + 'guided_asked_sessions', count(*) filter (where questions_guided > 0) + ) + from recent + ), + 'width_distribution', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord) + from ( + select wb.key, wb.ord, count(r.id) as n + from width_buckets wb + left join recent r + on r.range_width_minutes >= wb.lo + and (wb.hi is null or r.range_width_minutes <= wb.hi) + group by wb.key, wb.ord + union all + select 'unknown', 99, count(*) from recent where range_width_minutes is null + ) x + ), + 'question_distribution', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord) + from ( + select qb.key, qb.ord, count(r.id) as n + from question_buckets qb + left join recent r + on r.questions_total >= qb.lo + and (qb.hi is null or r.questions_total <= qb.hi) + group by qb.key, qb.ord + ) x + ), + 'gap_distribution', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', x.key, 'count', x.n) order by x.ord) + from ( + select gb.key, gb.ord, count(r.id) as n + from gap_buckets gb + left join recent r + on r.top_two_gap_points >= gb.lo + and (gb.hi is null or r.top_two_gap_points <= gb.hi) + group by gb.key, gb.ord + union all + select 'single', 99, count(*) from recent where top_two_gap_points is null + ) x + ), + 'stop_reasons', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', s.key, 'count', ( + select count(*) from recent r where r.stop_reason = s.key + )) order by s.ord) + from stop_reasons s + ), + 'birth_time_sources', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object('key', s.key, 'count', ( + select count(*) from recent r where r.birth_time_source = s.key + )) order by s.ord) + from sources s + ), + 'precision_gate', ( + select pg_catalog.jsonb_build_object( + 'met', count(*) filter (where precision_gate_met is true), + 'not_met', count(*) filter (where precision_gate_met is false), + 'unknown', count(*) filter (where precision_gate_met is null) + ) + from recent + ), + 'weekly', ( + select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object( + 'week', w.week, + 'sessions', (select count(*) from recent r where r.recorded_week = w.week), + 'range_width_minutes_p50', ( + select round((percentile_cont(0.5) within group (order by r.range_width_minutes))::numeric, 1) + from recent r where r.recorded_week = w.week + ), + 'questions_total_p50', ( + select round((percentile_cont(0.5) within group (order by r.questions_total))::numeric, 1) + from recent r where r.recorded_week = w.week + ), + 'precision_gate_met', ( + select count(*) from recent r where r.recorded_week = w.week and r.precision_gate_met is true + ), + 'precision_gate_known', ( + select count(*) from recent r where r.recorded_week = w.week and r.precision_gate_met is not null + ) + ) order by w.week) + from weeks w + where w.week >= (select since from window_start) - 6 + ), + 'versions', ( + select coalesce(pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object( + 'algorithm_version', v.algorithm_version, + 'policy_version', v.policy_version, + 'skill_version', v.skill_version, + 'count', v.n + ) order by v.n desc, v.algorithm_version, v.policy_version, v.skill_version), '[]'::jsonb) + from ( + select algorithm_version, policy_version, skill_version, count(*) as n + from recent + group by algorithm_version, policy_version, skill_version + order by count(*) desc + limit 10 + ) v + ) + ); +$$; + +revoke all on function public.record_rectification_telemetry( + uuid, uuid, integer, text, integer, integer, integer, integer, integer, integer, + integer, integer, integer, integer, text, boolean, integer, text, text, text +) from public, anon, authenticated; +revoke all on function public.purge_expired_rectification_telemetry() from public, anon, authenticated; +revoke all on function public.rectification_telemetry_summary(integer) from public, anon, authenticated; + +grant execute on function public.record_rectification_telemetry( + uuid, uuid, integer, text, integer, integer, integer, integer, integer, integer, + integer, integer, integer, integer, text, boolean, integer, text, text, text +) to service_role; +grant execute on function public.purge_expired_rectification_telemetry() to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant execute on function public.rectification_telemetry_summary(integer) to admin_runtime; + end if; +end; +$$; + +commit; diff --git a/frontend/tests/admin-rectification-telemetry-contract.test.ts b/frontend/tests/admin-rectification-telemetry-contract.test.ts new file mode 100644 index 00000000..673f5587 --- /dev/null +++ b/frontend/tests/admin-rectification-telemetry-contract.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + TELEMETRY_GAP_BUCKET_LABEL, + TELEMETRY_PERCENT_SHOWN_BUCKETS, + TELEMETRY_STOP_REASON_LABEL, + parseRectificationTelemetrySummary, + parseTelemetrySummaryWeeks, +} from "../src/lib/admin/rectification-telemetry-summary.ts"; +import { RECTIFICATION_TELEMETRY_STOP_REASONS } from "../src/lib/rectification-agentic/v9/telemetry.ts"; +import { RANGE_DELIVERY_PERCENT_MIN_GAP } from "../src/lib/rectification-agentic/user-copy.ts"; + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const route = read("../src/app/api/admin/rectification-telemetry/route.ts"); +const view = read("../src/components/admin/rectification-telemetry-summary.tsx"); +const adminApp = read("../src/components/admin/admin-app.tsx"); +const providers = read("../src/lib/admin/providers.ts"); +const migration = read("../supabase/migrations/20260926010000_rectification_telemetry.sql"); + +test("admin telemetry route is a read-only aggregate behind admin permission", () => { + assert.match(route, /requirePermission\("admin\.customers\.read"\)/); + assert.match(route, /select public\.rectification_telemetry_summary\(\$1::integer\) as summary/); + assert.doesNotMatch(route, /export async function (?:POST|PUT|PATCH|DELETE)/); + // No per-row read: the route never selects from the table, and the admin role has no table grant. + assert.doesNotMatch(route, /from public\.rectification_telemetry\b/); + assert.doesNotMatch(migration, /grant [a-z, ]+ on table public\.rectification_telemetry[a-z_]* to admin_runtime/); + assert.match(migration, /revoke all on table public\.rectification_telemetry from admin_runtime;/); +}); + +test("admin telemetry page is registered with the same permission and offers no export", () => { + assert.match(adminApp, /name: "rectification-telemetry", list: "\/admin\/rectification-telemetry"/); + assert.match(providers, /"rectification-telemetry": \{ read: "admin\.customers\.read" \}/); + assert.doesNotMatch(view, /download|\.csv|Blob\(|createObjectURL|\/api\/admin\/rectification-telemetry\/rows/i); + assert.match(view, /不能逐条查看或导出/); + assert.match(view, /保留 180 天/); +}); + +test("summary parser keeps a fixed shape for empty and partial payloads", () => { + const empty = parseRectificationTelemetrySummary(null); + assert.equal(empty.sessions, 0); + assert.equal(empty.retentionDays, 180); + assert.deepEqual(empty.weekly, []); + assert.equal(empty.medians.rangeWidthMinutesP50, null); + + const parsed = parseRectificationTelemetrySummary({ + weeks: 4, + since: "2026-09-07", + retention_days: 180, + sessions: 3, + medians: { range_width_minutes_p50: 12.5, questions_total_p50: "7" }, + question_types: { targeted: 2.33, guided: 1, guided_asked_sessions: 2 }, + width_distribution: [{ key: "11-20", count: 2 }, { key: "unknown", count: 1 }, { nope: true }], + stop_reasons: [{ key: "converged", count: 1 }], + precision_gate: { met: 1, not_met: 2, unknown: 0 }, + weekly: [{ week: "2026-09-21", sessions: 3, precision_gate_met: 1, precision_gate_known: 3 }], + versions: [{ algorithm_version: "a-1", policy_version: null, skill_version: "10.0.30", count: 3 }], + }); + assert.equal(parsed.weeks, 4); + assert.equal(parsed.medians.questionsTotalP50, 7); + assert.equal(parsed.questionTypes.guidedAskedSessions, 2); + assert.deepEqual(parsed.widthDistribution, [{ key: "11-20", count: 2 }, { key: "unknown", count: 1 }]); + assert.equal(parsed.weekly[0]!.precisionGateKnown, 3); + assert.equal(parsed.versions[0]!.policyVersion, null); +}); + +test("week selector accepts only the offered windows", () => { + assert.equal(parseTelemetrySummaryWeeks("4"), 4); + assert.equal(parseTelemetrySummaryWeeks("26"), 26); + assert.equal(parseTelemetrySummaryWeeks("52"), 12); + assert.equal(parseTelemetrySummaryWeeks(null), 12); +}); + +test("labels cover every stop reason and the percent buckets follow the card rule", () => { + for (const reason of RECTIFICATION_TELEMETRY_STOP_REASONS) { + assert.ok(TELEMETRY_STOP_REASON_LABEL[reason], reason); + assert.match(migration, new RegExp(`'${reason}'`)); + } + assert.equal(RANGE_DELIVERY_PERCENT_MIN_GAP, 5); + assert.deepEqual(TELEMETRY_PERCENT_SHOWN_BUCKETS, ["5-9", "10+"]); + assert.match(migration, /\('5-9', 5, 9, 3\), \('10\+', 10, null, 4\)/); + for (const key of ["0-2", "3-4", "5-9", "10+", "single"]) assert.ok(TELEMETRY_GAP_BUCKET_LABEL[key], key); +}); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index b66410e0..b9ce10cc 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -275,6 +275,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "pricing_experiment_events", "product_entitlements", "profiles", + // 原值: profiles 之后直接是 redemption_attempts + // 新值: 中间加入 rectification_telemetry 与 rectification_telemetry_reported_cases + // 原因: 20260926010000 加了匿名校正统计表与去重台账(TASK-rectification-telemetry-20260926) + "rectification_telemetry", + "rectification_telemetry_reported_cases", "redemption_attempts", "redemption_codes", "synastry_reports", diff --git a/frontend/tests/database-rectification-telemetry.test.ts b/frontend/tests/database-rectification-telemetry.test.ts new file mode 100644 index 00000000..e0005f02 --- /dev/null +++ b/frontend/tests/database-rectification-telemetry.test.ts @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { RECTIFICATION_TELEMETRY_FIELDS } from "../src/lib/rectification-agentic/v9/telemetry.ts"; +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const skipWithoutDocker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + stdio: "ignore", +}).status === 0 + ? false + : "docker unavailable on this host"; + +const RECORD_SIGNATURE = "public.record_rectification_telemetry(uuid,uuid,integer,text,integer,integer,integer,integer,integer,integer,integer,integer,integer,integer,text,boolean,integer,text,text,text)"; + +// Fictional synthetic Case snapshot; no real person. +const snapshot = { + birth_date: "1990-01-01", + birth_place_label: "虚构市", + latitude: 30, + longitude: 120, + timezone_id: "Asia/Shanghai", + timezone_offset: 8, + birth_time_source: "approximate", + reported_birth_time: "05:00", + active_birth_time: "05:00", + birth_time_period: null, + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, +}; + +function rpcError(error: unknown): string { + if (!error || typeof error !== "object") return ""; + const value = error as { message?: unknown }; + return typeof value.message === "string" ? value.message : ""; +} + +function metrics(overrides: Record = {}) { + return { + p_window_radius_minutes: 30, + p_birth_time_source: "approximate", + p_questions_total: 7, + p_questions_targeted: 2, + p_questions_guided: 1, + p_questions_dated_probe: 1, + p_questions_personality: 1, + p_questions_open: 1, + p_experiences_added: 4, + p_range_width_minutes: 12, + p_candidate_count: 4, + p_top_two_gap_points: 7, + p_stop_reason: "pool_exhausted", + p_precision_gate_met: false, + p_duration_seconds: 1800, + p_algorithm_version: "rectification-v5-matrix-scoring-9", + p_policy_version: "rectification-policy-v1", + p_skill_version: "10.0.30", + ...overrides, + }; +} + +function seedUser(fixture: PostgresFixture, email: string): string { + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (name, email, email_verified, email_verified_at) values ('Fixture', '${email}', true, now());`, + ); + return fixture.psql(`select id from identity.users where email = '${email}'`); +} + +test("rectification telemetry: server-only write, dedupe, retention, aggregate-only admin read, account cascade", { + skip: skipWithoutDocker, +}, async () => { + const fixture = startPostgresFixture(); + const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password") }, + }); + assert.equal(migration.status, 0, migration.stderr); + + // 1. Columns: exactly the allowlist, no identity column. + assert.equal( + fixture.psql(` + select string_agg(column_name, ',' order by ordinal_position) + from information_schema.columns + where table_schema = 'public' and table_name = 'rectification_telemetry' + `), + ["id", "recorded_week", ...RECTIFICATION_TELEMETRY_FIELDS].join(","), + ); + assert.equal( + fixture.psql(` + select string_agg(column_name, ',' order by ordinal_position) + from information_schema.columns + where table_schema = 'public' and table_name = 'rectification_telemetry_reported_cases' + `), + "case_id", + ); + + // 2. RLS on, no table privilege for any runtime role. + assert.equal( + fixture.psql(` + select string_agg(relname || '=' || relrowsecurity::text, ',' order by relname) + from pg_class + where relname in ('rectification_telemetry', 'rectification_telemetry_reported_cases') + `), + "rectification_telemetry=true,rectification_telemetry_reported_cases=true", + ); + const tablePrivileges = fixture.psql(` + select string_agg(role || ':' || tbl || ':' || has_table_privilege(role, tbl, 'SELECT,INSERT,UPDATE,DELETE')::text, ',') + from unnest(array['anon', 'authenticated', 'app_runtime', 'admin_runtime', 'service_role', 'service_runtime']) as role, + unnest(array['public.rectification_telemetry', 'public.rectification_telemetry_reported_cases']) as tbl + `); + assert.doesNotMatch(tablePrivileges, /:true/, tablePrivileges); + + // 3. Function privileges: write = service_role, summary = admin_runtime. + assert.equal( + fixture.psql(` + select concat_ws(':', + has_function_privilege('service_role', '${RECORD_SIGNATURE}', 'EXECUTE'), + has_function_privilege('admin_runtime', '${RECORD_SIGNATURE}', 'EXECUTE'), + has_function_privilege('authenticated', '${RECORD_SIGNATURE}', 'EXECUTE'), + has_function_privilege('app_runtime', '${RECORD_SIGNATURE}', 'EXECUTE'), + has_function_privilege('admin_runtime', 'public.rectification_telemetry_summary(integer)', 'EXECUTE'), + has_function_privilege('service_role', 'public.rectification_telemetry_summary(integer)', 'EXECUTE'), + has_function_privilege('authenticated', 'public.rectification_telemetry_summary(integer)', 'EXECUTE'), + has_function_privilege('service_role', 'public.purge_expired_rectification_telemetry()', 'EXECUTE'), + has_function_privilege('admin_runtime', 'public.purge_expired_rectification_telemetry()', 'EXECUTE') + ) + `), + "t:f:f:f:t:f:f:t:f", + ); + assert.throws(() => fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select count(*) from public.rectification_telemetry", + )); + + // 4. Seed two fictional users with one Case each. + const firstUser = seedUser(fixture, "telemetry-one@example.com"); + const secondUser = seedUser(fixture, "telemetry-two@example.com"); + fixture.psql(` + with provider as ( + insert into public.model_providers (code, name, provider_type, encrypted_api_key, enabled) + values ('telemetry-test', 'Telemetry Test', 'openai', 'test-ciphertext', true) + returning id + ), config as ( + insert into public.model_configs (model_id) values ('telemetry-default-model') returning id + ) + insert into public.model_config_versions ( + config_id, version, provider_id, label, provider_model, enabled, is_default, status, published_at + ) + select config.id, 1, provider.id, 'Default', 'gpt-test', true, true, 'published', now() + from config cross join provider; + `); + const service = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + const openCase = async (userId: string, requestId: string) => { + const opened = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: requestId, + p_intent: "homepage", + p_session_id: null, + p_skill_name: "jyotish-birth-time-rectification", + p_skill_version: "10.0.30", + p_baseline_profile_fingerprint: "c".repeat(64), + p_baseline_birth_snapshot: snapshot, + p_candidate_range: { start_time: "04:30", end_time: "05:30" }, + }); + assert.equal(opened.error, null, rpcError(opened.error)); + return String((opened.data as Record).case_id); + }; + const firstCase = await openCase(firstUser, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"); + const secondCase = await openCase(secondUser, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2"); + + // 5. First write stores one row; the second write for the same Case is a no-op. + const first = await service.rpc("record_rectification_telemetry", { + p_user_id: firstUser, p_case_id: firstCase, ...metrics(), + }); + assert.equal(first.error, null, rpcError(first.error)); + assert.equal(first.data, true); + const again = await service.rpc("record_rectification_telemetry", { + p_user_id: firstUser, p_case_id: firstCase, ...metrics({ p_stop_reason: "converged" }), + }); + assert.equal(again.error, null, rpcError(again.error)); + assert.equal(again.data, false); + assert.equal(fixture.psql("select count(*) || ':' || min(stop_reason) from public.rectification_telemetry"), "1:pool_exhausted"); + assert.equal( + fixture.psql("select extract(isodow from recorded_week)::int from public.rectification_telemetry"), + "1", + ); + + // 6. Ownership: another user's id cannot write for this Case. + const foreign = await service.rpc("record_rectification_telemetry", { + p_user_id: firstUser, p_case_id: secondCase, ...metrics(), + }); + assert.match(rpcError(foreign.error), /rectification_telemetry_case_not_found/); + + // 7. A rejected row rolls back the dedupe mark, so a later valid write still lands. + for (const bad of [ + { p_stop_reason: "free text" }, + { p_birth_time_source: "1990-01-01" }, + { p_algorithm_version: "v 1 with spaces" }, + { p_questions_total: 1 }, + { p_top_two_gap_points: 101 }, + ]) { + const rejected = await service.rpc("record_rectification_telemetry", { + p_user_id: secondUser, p_case_id: secondCase, ...metrics(bad), + }); + assert.ok(rejected.error, JSON.stringify(bad)); + } + assert.equal( + fixture.psql(`select count(*) from public.rectification_telemetry_reported_cases where case_id = '${secondCase}'`), + "0", + ); + const second = await service.rpc("record_rectification_telemetry", { + p_user_id: secondUser, p_case_id: secondCase, + ...metrics({ p_stop_reason: "converged", p_precision_gate_met: true, p_range_width_minutes: 4, p_top_two_gap_points: null }), + }); + assert.equal(second.error, null, rpcError(second.error)); + assert.equal(second.data, true); + + // 8. Retention: rows whose week started more than 180 days ago are purged. + fixture.psql(` + insert into public.rectification_telemetry ( + recorded_week, birth_time_source, questions_total, questions_targeted, questions_guided, + questions_dated_probe, questions_personality, questions_open, experiences_added, + candidate_count, stop_reason, duration_seconds + ) values ( + (date_trunc('week', (timezone('UTC', now()))::date - 200))::date, + 'unknown', 3, 0, 0, 0, 0, 0, 1, 2, 'user_stopped', 60 + ); + `); + assert.equal(fixture.psql("select count(*) from public.rectification_telemetry"), "3"); + const purged = await service.rpc("purge_expired_rectification_telemetry", {}); + assert.equal(purged.error, null, rpcError(purged.error)); + assert.equal(Number(purged.data), 1); + assert.equal(fixture.psql("select count(*) from public.rectification_telemetry"), "2"); + + // 9. Admin sees aggregates only. + const summary = JSON.parse(fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select public.rectification_telemetry_summary(12)::text", + )) as Record; + assert.equal(summary.sessions, 2); + assert.equal(summary.retention_days, 180); + assert.deepEqual( + (summary.stop_reasons as Array<{ key: string; count: number }>).map((row) => `${row.key}=${row.count}`), + ["converged=1", "pool_exhausted=1", "user_no_more=0", "round_cap=0", "user_stopped=0", "error=0"], + ); + assert.deepEqual(summary.precision_gate, { met: 1, not_met: 1, unknown: 0 }); + const width = Object.fromEntries( + (summary.width_distribution as Array<{ key: string; count: number }>).map((row) => [row.key, row.count]), + ); + assert.equal(width["1-5"], 1); + assert.equal(width["11-20"], 1); + assert.equal(width.unknown, 0); + const gap = Object.fromEntries( + (summary.gap_distribution as Array<{ key: string; count: number }>).map((row) => [row.key, row.count]), + ); + assert.equal(gap["5-9"], 1); + assert.equal(gap.single, 1); + assert.equal((summary.weekly as unknown[]).length, 12); + const serialized = JSON.stringify(summary); + for (const forbidden of [firstUser, secondUser, firstCase, secondCase, "1990-01-01", "虚构市"]) { + assert.equal(serialized.includes(forbidden), false, `summary must not contain ${forbidden}`); + } + assert.equal( + JSON.parse(fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select public.rectification_telemetry_summary(999)::text", + )).weeks, + 26, + ); + + // 10. Account deletion removes the dedupe mark with the Case; the anonymous row stays unlinkable. + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `delete from identity.users where id = '${firstUser}'`, + ); + assert.equal( + fixture.psql(` + select concat_ws(':', + (select count(*) from public.agentic_rectification_cases where id = '${firstCase}'), + (select count(*) from public.rectification_telemetry_reported_cases where case_id = '${firstCase}'), + (select count(*) from public.rectification_telemetry_reported_cases where case_id = '${secondCase}'), + (select count(*) from public.rectification_telemetry) + ) + `), + "0:0:1:2", + ); + } finally { + try { + await closeLocalPostgresDataPool(serviceUrl); + } finally { + fixture.stop(); + } + } +}); diff --git a/frontend/tests/rectification-telemetry.test.ts b/frontend/tests/rectification-telemetry.test.ts new file mode 100644 index 00000000..a2e8b6d3 --- /dev/null +++ b/frontend/tests/rectification-telemetry.test.ts @@ -0,0 +1,441 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + RECTIFICATION_TELEMETRY_BIRTH_TIME_SOURCES, + RECTIFICATION_TELEMETRY_FIELDS, + RECTIFICATION_TELEMETRY_LIVE_WINDOW_MS, + RECTIFICATION_TELEMETRY_STOP_REASONS, + buildRectificationTelemetryRow, + rectificationTelemetryEligible, + rectificationTelemetryRpcArgs, + resetRectificationTelemetryForTests, + scheduleRectificationTelemetry, + telemetryBirthTimeSource, + telemetryQuestionKind, + telemetryStopReason, + type RectificationTelemetryInput, +} from "../src/lib/rectification-agentic/v9/telemetry.ts"; +import { budgetExhausted } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { DEFAULT_MAX_DISCRIMINATION_ROUNDS } from "../src/lib/rectification-agentic/core/types.ts"; + +const USER_ID = "11111111-1111-4111-8111-111111111111"; +const CASE_ID = "22222222-2222-4222-8222-222222222222"; +const NOW = Date.parse("2026-09-26T08:00:00.000Z"); + +// Fictional, deliberately identifying-looking content: none of it may reach the row. +const FICTIONAL_NAME = "测试虚构人甲"; +const FICTIONAL_BIRTH_DATE = "1990-05-12"; +const FICTIONAL_PLACE = "虚构市示例区"; +const FICTIONAL_QUOTE = "2015 年 9 月我在虚构市示例区换了工作"; + +function focus( + questionId: string, + status: string, + minute: number, + schema: Record = {}, +) { + const at = new Date(NOW - (60 - minute) * 60_000).toISOString(); + return { questionId, expectedAnswerSchema: schema, status, askedAt: at, resolvedAt: status === "active" ? null : at }; +} + +function input(overrides: Partial = {}): RectificationTelemetryInput { + return { + dossier: { + case: { + candidateRange: { start_time: "04:30", end_time: "05:30" }, + stage: "minute", + birthTimeSource: "approximate", + skillVersion: "10.0.30", + lastActivityAt: new Date(NOW - 30_000).toISOString(), + }, + turns: [ + { createdAt: new Date(NOW - 45 * 60_000).toISOString() }, + { createdAt: new Date(NOW - 10 * 60_000).toISOString() }, + ], + evidence: [ + { status: "confirmed", summary: FICTIONAL_QUOTE, domain: "career" }, + { status: "draft", summary: `${FICTIONAL_NAME} ${FICTIONAL_BIRTH_DATE}`, domain: "family" }, + { status: "superseded", summary: FICTIONAL_PLACE, domain: "relocation" }, + { status: "rejected", summary: "不算", domain: "other" }, + ] as unknown as RectificationTelemetryInput["dossier"]["evidence"], + latestResult: { + algorithmVersion: "rectification-v5-matrix-scoring-9", + policyVersion: "rectification-policy-v1", + decisionReceipt: null, + createdAt: new Date(NOW - 5 * 60_000).toISOString(), + }, + }, + focuses: [ + focus("collect:targeted:career", "resolved", 1), + focus("collect:targeted:relationship", "declined", 2), + focus("collect:guided:window:2015-09", "resolved", 3), + focus("probe:career.2015.change", "resolved", 4, { probe_year: 2015, probe_id: "p1" }), + focus("probe:varga.d9:style", "resolved", 5, { choice_kind: "varga_style" }), + focus("collect:invite:more", "resolved", 6), + focus("block:morning", "resolved", 7, { choice_kind: "block_choice" }), + focus("collect:targeted:finance", "superseded", 8), + ], + decision: { + sessionOutcome: "completed_with_range", + stopReason: "probe_pool_exhausted", + precisionGateMet: false, + credibleRange: ["04:50", "05:06"], + separation: { ranked: [{}, {}, {}, {}] }, + }, + rangeDelivery: { + range: ["04:52", "05:04"], + columns: [{ probability_percent: 41 }, { probability_percent: 34 }, { probability_percent: 25 }], + }, + now: NOW, + ...overrides, + }; +} + +test("telemetry row keys are exactly the allowlist (new fields must change this test)", () => { + const row = buildRectificationTelemetryRow(input()); + assert.deepEqual(Object.keys(row).sort(), [...RECTIFICATION_TELEMETRY_FIELDS].sort()); + assert.deepEqual([...RECTIFICATION_TELEMETRY_FIELDS].sort(), [ + "algorithm_version", + "birth_time_source", + "candidate_count", + "duration_seconds", + "experiences_added", + "policy_version", + "precision_gate_met", + "questions_dated_probe", + "questions_guided", + "questions_open", + "questions_personality", + "questions_targeted", + "questions_total", + "range_width_minutes", + "skill_version", + "stop_reason", + "top_two_gap_points", + "window_radius_minutes", + ]); +}); + +test("telemetry insert payload is the allowlist plus the two ids used only for ownership and dedupe", () => { + const args = rectificationTelemetryRpcArgs({ userId: USER_ID, caseId: CASE_ID }, buildRectificationTelemetryRow(input())); + assert.deepEqual( + Object.keys(args).sort(), + ["p_case_id", "p_user_id", ...RECTIFICATION_TELEMETRY_FIELDS.map((field) => `p_${field}`)].sort(), + ); +}); + +test("telemetry row holds only numbers, booleans, closed enums and version ids", () => { + const row = buildRectificationTelemetryRow(input()); + const serialized = JSON.stringify(row); + for (const forbidden of [FICTIONAL_NAME, FICTIONAL_BIRTH_DATE, FICTIONAL_PLACE, FICTIONAL_QUOTE, USER_ID, CASE_ID, "04:52", "04:30"]) { + assert.equal(serialized.includes(forbidden), false, `row must not contain ${forbidden}`); + } + const textFields = new Set(["birth_time_source", "stop_reason", "algorithm_version", "policy_version", "skill_version"]); + for (const [key, value] of Object.entries(row)) { + if (value === null || typeof value === "boolean") continue; + if (typeof value === "number") { + assert.ok(Number.isInteger(value) && value >= 0, `${key} must be a non-negative integer`); + continue; + } + assert.ok(textFields.has(key), `${key} must not be text`); + assert.match(String(value), /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$/); + } + assert.ok((RECTIFICATION_TELEMETRY_BIRTH_TIME_SOURCES as readonly string[]).includes(row.birth_time_source)); + assert.ok((RECTIFICATION_TELEMETRY_STOP_REASONS as readonly string[]).includes(row.stop_reason)); +}); + +test("telemetry row values reflect the delivered card and the new fewer-probes flow", () => { + const row = buildRectificationTelemetryRow(input()); + assert.equal(row.window_radius_minutes, 30); + assert.equal(row.birth_time_source, "approximate"); + // superseded focuses were replaced before the user answered them + assert.equal(row.questions_total, 7); + assert.equal(row.questions_targeted, 2); + assert.equal(row.questions_guided, 1); + assert.equal(row.questions_dated_probe, 1); + assert.equal(row.questions_personality, 1); + assert.equal(row.questions_open, 1); + assert.equal(row.experiences_added, 2); + // the card range wins over the decision range + assert.equal(row.range_width_minutes, 12); + assert.equal(row.candidate_count, 4); + // 41 − 34 ≥ 5: this card showed percentages (RANGE_DELIVERY_PERCENT_MIN_GAP) + assert.equal(row.top_two_gap_points, 7); + assert.equal(row.stop_reason, "pool_exhausted"); + assert.equal(row.precision_gate_met, false); + assert.equal(row.duration_seconds, 45 * 60); + assert.equal(row.algorithm_version, "rectification-v5-matrix-scoring-9"); + assert.equal(row.policy_version, "rectification-policy-v1"); + assert.equal(row.skill_version, "10.0.30"); +}); + +test("telemetry drops versions that are not plain version ids and clamps odd values", () => { + const base = input(); + const row = buildRectificationTelemetryRow({ + ...base, + dossier: { + ...base.dossier, + case: { ...base.dossier.case, skillVersion: "10.0 我的版本", candidateRange: null }, + turns: [], + latestResult: { algorithmVersion: "x".repeat(80), policyVersion: " ", decisionReceipt: null }, + }, + rangeDelivery: { range: null, columns: [{ probability_percent: 100 }] }, + decision: { ...base.decision, credibleRange: null, precisionGateMet: null }, + }); + assert.equal(row.skill_version, null); + assert.equal(row.algorithm_version, null); + assert.equal(row.policy_version, null); + assert.equal(row.window_radius_minutes, null); + assert.equal(row.range_width_minutes, null); + assert.equal(row.top_two_gap_points, null); + assert.equal(row.precision_gate_met, null); + assert.equal(row.duration_seconds, 0); +}); + +test("question kinds come from server-built focus ids and schemas", () => { + assert.equal(telemetryQuestionKind({ questionId: "collect:targeted:career", expectedAnswerSchema: {} }), "targeted"); + assert.equal(telemetryQuestionKind({ questionId: "collect:guided:window:2015-09", expectedAnswerSchema: {} }), "guided"); + assert.equal(telemetryQuestionKind({ questionId: "probe:x", expectedAnswerSchema: { probe_year: 2019 } }), "dated_probe"); + assert.equal(telemetryQuestionKind({ questionId: "d9_relationship:relationship_style", expectedAnswerSchema: { choice_kind: "varga_style" } }), "personality"); + assert.equal(telemetryQuestionKind({ questionId: "x", expectedAnswerSchema: { tie_break_round: true } }), "personality"); + assert.equal(telemetryQuestionKind({ questionId: "probe:nakshatra.trait", expectedAnswerSchema: {} }), "personality"); + assert.equal(telemetryQuestionKind({ questionId: "collect:relationship:collect_method_evidence", expectedAnswerSchema: {} }), "open"); + assert.equal(telemetryQuestionKind({ questionId: "collect:other:more", expectedAnswerSchema: {} }), "open"); + assert.equal(telemetryQuestionKind({ questionId: "window_widen:widen_window", expectedAnswerSchema: { choice_kind: "widen_window" } }), "other"); +}); + +test("birth time source collapses to the four categories", () => { + assert.equal(telemetryBirthTimeSource({ birthTimeSource: "hospital_record" }), "hospital_record"); + assert.equal(telemetryBirthTimeSource({ birthTimeSource: "period_only" }), "period_only"); + assert.equal(telemetryBirthTimeSource({ birthTimeSource: "unknown" }), "unknown"); + assert.equal(telemetryBirthTimeSource({ birthTimeSource: "approximate", stage: "block_scan" }), "unknown"); + assert.equal(telemetryBirthTimeSource({ birthTimeSource: "family_exact" }), "approximate"); + assert.equal(telemetryBirthTimeSource({ birthTimeSource: null }), "approximate"); +}); + +test("stop reason: user stop, converged, round cap, user ran out, pool exhausted", () => { + const base = { + sessionOutcome: "completed_with_range", + precisionGateMet: false, + stopReason: "probe_pool_exhausted", + roundCapReached: false, + lastCollectDeclined: false, + }; + assert.equal(telemetryStopReason({ ...base, sessionOutcome: "provisional_range_user_stopped", precisionGateMet: true }), "user_stopped"); + assert.equal(telemetryStopReason({ ...base, precisionGateMet: true, roundCapReached: true }), "converged"); + assert.equal(telemetryStopReason({ ...base, roundCapReached: true, lastCollectDeclined: true }), "round_cap"); + assert.equal(telemetryStopReason({ ...base, stopReason: "user_uncertainty_too_high" }), "user_no_more"); + assert.equal(telemetryStopReason({ ...base, lastCollectDeclined: true }), "user_no_more"); + assert.equal(telemetryStopReason(base), "pool_exhausted"); + assert.equal(telemetryStopReason({ ...base, stopReason: "tied_first" }), "pool_exhausted"); + // round_cap uses the decision layer's own fuse, not a second copy of it + assert.equal(budgetExhausted({ inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS }), true); + assert.equal(budgetExhausted({ inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS - 1 }), false); +}); + +test("a declined last collect question marks the row user_no_more", () => { + const base = input(); + const row = buildRectificationTelemetryRow({ + ...base, + focuses: [...base.focuses, focus("collect:targeted:health", "declined", 30)], + }); + assert.equal(row.stop_reason, "user_no_more"); +}); + +test("only a live range-card delivery is eligible", () => { + assert.equal(rectificationTelemetryEligible(input()), true); + assert.equal(rectificationTelemetryEligible(input({ + decision: { ...input().decision, sessionOutcome: "collect_evidence" }, + })), false); + assert.equal(rectificationTelemetryEligible(input({ rangeDelivery: null })), false); + assert.equal(rectificationTelemetryEligible(input({ rangeDelivery: { range: ["04:52", "05:04"], columns: [] } })), false); + const stale = input(); + assert.equal(rectificationTelemetryEligible({ + ...stale, + dossier: { + ...stale.dossier, + case: { ...stale.dossier.case, lastActivityAt: new Date(NOW - RECTIFICATION_TELEMETRY_LIVE_WINDOW_MS - 1).toISOString() }, + }, + }), false); + assert.equal(rectificationTelemetryEligible(input({ + decision: { ...input().decision, sessionOutcome: "provisional_range_user_stopped" }, + })), true); +}); + +function recordingClient(result: { data: unknown; error: { message?: string; code?: string } | null } | Error) { + const calls: Array<{ fn: string; args: Record }> = []; + return { + calls, + client: { + rpc(fn: string, args: Record) { + calls.push({ fn, args }); + if (result instanceof Error) throw result; + return Promise.resolve(result); + }, + }, + }; +} + +function captureWarnings() { + const lines: string[] = []; + const original = console.warn; + console.warn = (...parts: unknown[]) => { + lines.push(parts.map(String).join(" ")); + }; + return { lines, restore: () => { console.warn = original; } }; +} + +test("schedule writes once per Case, after the response, with the allowlisted payload", async () => { + resetRectificationTelemetryForTests(); + const recorder = recordingClient({ data: true, error: null }); + const pending = scheduleRectificationTelemetry({ + accounting: recorder.client, + userId: USER_ID, + caseId: CASE_ID, + build: () => input(), + }); + assert.equal(recorder.calls.length, 0, "the write must not run on the request's own tick"); + await pending; + assert.equal(recorder.calls.length, 1); + assert.equal(recorder.calls[0]!.fn, "record_rectification_telemetry"); + assert.deepEqual( + Object.keys(recorder.calls[0]!.args).sort(), + ["p_case_id", "p_user_id", ...RECTIFICATION_TELEMETRY_FIELDS.map((field) => `p_${field}`)].sort(), + ); + await scheduleRectificationTelemetry({ + accounting: recorder.client, + userId: USER_ID, + caseId: CASE_ID, + build: () => input(), + }); + assert.equal(recorder.calls.length, 1, "the same process does not re-send a Case"); +}); + +test("schedule skips read-only, non-delivery and unavailable builds without writing", async () => { + resetRectificationTelemetryForTests(); + const recorder = recordingClient({ data: true, error: null }); + await scheduleRectificationTelemetry({ accounting: recorder.client, userId: USER_ID, caseId: CASE_ID, readOnly: true, build: () => input() }); + await scheduleRectificationTelemetry({ accounting: recorder.client, userId: USER_ID, caseId: CASE_ID, build: () => null }); + await scheduleRectificationTelemetry({ + accounting: recorder.client, + userId: USER_ID, + caseId: CASE_ID, + build: () => input({ decision: { ...input().decision, sessionOutcome: "collect_evidence" } }), + }); + assert.equal(recorder.calls.length, 0); + // a Case that was not eligible yet is still recorded once it delivers + await scheduleRectificationTelemetry({ accounting: recorder.client, userId: USER_ID, caseId: CASE_ID, build: () => input() }); + assert.equal(recorder.calls.length, 1); +}); + +test("write failures never throw and log only a reason code, never the payload or ids", async () => { + for (const failure of [ + { data: null, error: { message: "rectification_telemetry_case_not_found", code: "P0002" } }, + { data: null, error: { message: `insert failed for ${CASE_ID} ${FICTIONAL_NAME}` } }, + new Error(`socket closed ${USER_ID}`), + ]) { + resetRectificationTelemetryForTests(); + const recorder = recordingClient(failure); + const warnings = captureWarnings(); + try { + await assert.doesNotReject(scheduleRectificationTelemetry({ + accounting: recorder.client, + userId: USER_ID, + caseId: CASE_ID, + build: () => input(), + })); + } finally { + warnings.restore(); + } + assert.equal(warnings.lines.length, 1); + const line = warnings.lines[0]!; + assert.match(line, /^\[rectification-telemetry\] write skipped reason=[A-Za-z0-9_]+$/); + for (const forbidden of [CASE_ID, USER_ID, FICTIONAL_NAME, "04:52"]) { + assert.equal(line.includes(forbidden), false); + } + } +}); + +test("a throwing build is swallowed on the request path", async () => { + resetRectificationTelemetryForTests(); + const recorder = recordingClient({ data: true, error: null }); + const warnings = captureWarnings(); + let pending: Promise | null = null; + try { + assert.doesNotThrow(() => { + pending = scheduleRectificationTelemetry({ + accounting: recorder.client, + userId: USER_ID, + caseId: CASE_ID, + build: () => { + throw new TypeError(`bad ${FICTIONAL_BIRTH_DATE}`); + }, + }); + }); + await pending; + } finally { + warnings.restore(); + } + assert.equal(recorder.calls.length, 0); + assert.deepEqual(warnings.lines, ["[rectification-telemetry] write skipped reason=TypeError"]); +}); + +const migration = readFileSync( + new URL("../supabase/migrations/20260926010000_rectification_telemetry.sql", import.meta.url), + "utf8", +); + +function tableColumns(sql: string, table: string): string[] { + const start = sql.indexOf(`create table if not exists public.${table} (`); + assert.ok(start >= 0, `${table} missing`); + const body = sql.slice(sql.indexOf("(", start) + 1, sql.indexOf("\n);", start)); + return body + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^[a-z_]+ (?:uuid|date|integer|text|boolean)\b/.test(line)) + .map((line) => line.split(" ")[0]!); +} + +test("the database row has exactly the allowlisted columns and no identity column", () => { + assert.deepEqual( + tableColumns(migration, "rectification_telemetry"), + ["id", "recorded_week", ...RECTIFICATION_TELEMETRY_FIELDS], + ); + assert.deepEqual(tableColumns(migration, "rectification_telemetry_reported_cases"), ["case_id"]); + const telemetryTable = migration.slice( + migration.indexOf("create table if not exists public.rectification_telemetry ("), + migration.indexOf("create index if not exists rectification_telemetry_week_idx"), + ); + assert.doesNotMatch(telemetryTable, /user_id|case_id|session_id|birth_date|birth_place|email|name text|created_at|timestamptz/); +}); + +test("the write function takes exactly the allowlisted parameters", () => { + const start = migration.indexOf("create or replace function public.record_rectification_telemetry("); + const signature = migration.slice(start, migration.indexOf(")\nreturns boolean", start)); + const params = [...signature.matchAll(/\n (p_[a-z_]+) /g)].map((match) => match[1]); + assert.deepEqual(params, ["p_user_id", "p_case_id", ...RECTIFICATION_TELEMETRY_FIELDS.map((field) => `p_${field}`)]); +}); + +test("migration is additive, RLS-guarded, server-write / admin-aggregate-read, 180-day retention", () => { + assert.doesNotMatch(migration, /alter table public\.(?!rectification_telemetry)/); + assert.doesNotMatch(migration, /drop (?:table|column|function)/i); + assert.match(migration, /alter table public\.rectification_telemetry enable row level security/); + assert.match(migration, /alter table public\.rectification_telemetry_reported_cases enable row level security/); + assert.match(migration, /references public\.agentic_rectification_cases\(id\) on delete cascade/); + assert.doesNotMatch(migration, /grant (?:select|insert|update|delete|all)[^;]*on table public\.rectification_telemetry/); + assert.match(migration, /grant execute on function public\.record_rectification_telemetry\([\s\S]*?\) to service_role;/); + assert.match(migration, /grant execute on function public\.rectification_telemetry_summary\(integer\) to admin_runtime;/); + assert.doesNotMatch(migration, /rectification_telemetry_summary\(integer\) to (?:service_role|authenticated|anon|app_runtime)/); + assert.equal((migration.match(/recorded_week < \(pg_catalog\.timezone\('UTC', pg_catalog\.now\(\)\)\)::date - 180/g) ?? []).length, 2); + assert.match(migration, /b\.today - 180/); + assert.match(migration, /least\(coalesce\(p_weeks, 12\), 26\)/); +}); + +test("the case GET route schedules telemetry without awaiting it", () => { + const route = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); + assert.match(route, /onProjected: \(\{ decision, rangeDelivery, readOnly \}\) => \{\s*void scheduleRectificationTelemetry\(/); + assert.doesNotMatch(route, /await scheduleRectificationTelemetry/); + assert.match(route, /return NextResponse\.json\(await dossierResponseWithIdentity/); +}); diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 70578cf5..e4dc65da 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -1586,6 +1586,10 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: 'admin/payments', 'admin/pricing-simulator', 'admin/products', + # 原值: admin/products 后面直接是 admin/roles + # 新值: 中间加入 admin/rectification-telemetry + # 原因: 匿名校正统计的后台汇总页(TASK-rectification-telemetry-20260926)是新的 app 路由 + 'admin/rectification-telemetry', 'admin/roles', 'admin/security', 'admin/subscriptions',