diff --git a/CHANGELOG.md b/CHANGELOG.md index 4232a22e..3ed3fff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-07 — 出生时间有多确定只问一次,校正按你选的范围去比 + +填报出生资料时问一次「你对这个时间有多确定」:有医院记录、家人记得大概时间(差不多准 / 前后半小时 / 一小时 / 两小时),或只知道时段、完全不清楚。校正直接读档案里的范围,不再追问。家人说前后一小时,顶部就是两小时;前后两小时或傍晚这种超过两小时的范围,先切成三段再比。按你说的经历对不上、时间又贴着窗口边上时,会出一张卡问要不要放宽,放宽后设置页的范围跟着变。日级经历会补问一句日子是查过记录还是凭记忆。文案不用「偏移 / 误差 / 置信度 / 概率」。Skill 版本仍是 10.0.14。 + ## 2026-09-07 — 答完题后的范围跟顶部一致,不清楚出生时间时不再偏爱下午 生时校正点完选择题后,旁白里的范围跟输入框上方那一行走,不会再把开工搜索窗口说成「范围没变」。完全不清楚出生时间时,清晨、上午、下午、傍晚、夜里五段按段内平均分来比,不会因为下午更长就先天占优。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0c179bc3..74898ae2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8838,6 +8838,54 @@ - 复发自:unknown-time `block_scan`(`814c924e`)按时段长度偏置 - 修复版本:`517df002` +## BUG-571 | 档案里声明的不确定度被忽略,有钟点一律 ±15 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`frontend/src/lib/birth-time-intake-model.ts`、`birth-time-intake.tsx`、`account-profile-patch.ts`、`case-service.ts::deriveRectificationOpenPlan` +- 用户现象:家人记得大概时间时,intake 选不到前后半小时 / 一小时 / 两小时;校正开工窗口仍是填报钟点 ±15,档案里的 `uncertainty_before/after` 不参与搜索。 +- 触发条件:资料有填报钟点;来源本应是 `approximate` 或医院记录。 +- 根因:intake 单选只露出 `family_exact` / `period_only`(含 unknown)。开 Case 用固定 `FRESH_CASE_SEARCH_RADIUS_MINUTES = 15`,注释写明那是引擎边界、不是用户声明。 +- 修复:intake 改三档——医院记录(固定 ±2 无感检查)、家人大概时间(±15/30/60/120)、时段或完全不知道。`family_exact` 从新选项移除,读档映射成「差不多准」。有钟点时:`hospital_record` / `family_exact` → ±15;`approximate` → ±max(15, 声明值),上限 120,前后独立。窗口 exclusive span >120 先进 `block_scan`(BUG-573)。 +- 验证:`birth-time-intake.test.ts` 三档与 120 校验;`account-api.test.ts` 120 合法、90 非法;`rectification-v9-case-service.test.ts` 既有三处 `family_exact` 04:45–05:15 保留,新增 approximate ±60 → 04:00–06:00、±120 → 03:00–07:00。 +- 防复发:有钟点开 Case 不得再写死 ±15;`approximate` 校验集合必须与 intake 按钮同为 {15,30,60,120}。 +- 相关记录:BUG-572、BUG-573 +- 复发自:无 +- 修复版本:待合入 `origin/staging`(`codex/rectification-declared-uncertainty-20260907`) + +## BUG-572 | 事件吻合率低且贴窗口边缘时不提议放宽搜索窗 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`decision-from-dossier.ts`、`window-widen.ts`、`method-followup.ts`、`widen_agentic_rectification_case_window`、`set_agentic_rectification_widen_declined` +- 用户现象:经历已经明显对不上家人记的时间,校正仍只在原来的 ±15/±30 里出区分卡,没有一键放宽。 +- 触发条件:训练门已开、`event_fit_rate.band=low` 且 `total≥3`、代表分钟距窗口边缘 ≤3 分钟、`stage=minute`、未采用、有填报钟点、当前证据指纹未拒过、当前半径 <120。 +- 根因:`event_fit_rate` 只进采用后八法报告。分钟阶段没有改 `candidate_range` 的 RPC;唯一改窗 RPC 要求 `stage=block_scan`。 +- 修复:服务端四选卡 `choice_kind: widen_window`(不进 Python 探针契约),排在区分卡之前。A/B 扩大窗口(必须包含旧窗、inclusive 宽 ≤241)、重算,并把档案写成 `approximate` + 新半径;`hospital_record` 只改窗口不改档案来源。C/D 记 `widen_declined_at_fingerprint`。档位 15→±30/±60,30→±60/±120,60→±120 / 「先补一件带月份的经历再说」。D 文案「说不好 / 先这样」(`clippedCopy` 最短 4 字)。不动采用/确认门、`MIN_SEPARATION_LEAD`、分钟级 `_relative_support`、`adopted_credible_range`。 +- 验证:`rectification-window-widen-20260907.test.ts` 六条;迁移合同锁 widen RPC 与不得改 `adopted_credible_range`。Docker `test:db` 在 `database-rectification-block-scan.test.ts` 加 widen 扩大/缩小与权限。 +- 防复发:放宽卡必须服务端生成;不得让模型问「偏移 / 误差」;widen RPC 只许扩大且包含旧窗。 +- 相关记录:BUG-571、BUG-573 +- 复发自:无 +- 修复版本:待合入 `origin/staging`(`codex/rectification-declared-uncertainty-20260907`) + +## BUG-573 | 选定时段或 ±120 窗口后直接进 4~6 小时分钟网格 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`scripts/rectification/api_service.py::block_scan`、`contracts.py` 可选 `blocks`、`advance_agentic_rectification_case_from_block_scan`、`search-window.ts`、`block-scan.ts` +- 用户现象:只知道傍晚或家人说前后两小时时,选定后马上在 4~6 小时上按分钟比,网格过密、题也问不清。 +- 触发条件:exclusive span >120(例如 18:00–22:59、03:00–07:00、下午 12:00–17:59)。 +- 根因:`block_scan` 只服务全日五时段;`advance_*` 要求原窗 00:00–23:59,选定后一律 `stage=minute`。`set_stage` 也曾要求全日窗。 +- 修复:请求可带 1~5 段 `blocks`(须落在窗口内、除共享端点外不重叠)。无 `blocks` 时仍用五声明时段;全日默认步长 10。子窗三等分、无共享分钟;步长 >360→10、>180→5、其余 2。`advance_*` 改为新窗须为当前窗子区间;span>120 且 rounds<3 仍 `block_scan`,否则 `minute`。每 Case 最多 3 张子段卡,超过旁白「时段分不开,直接按分钟比」。unknown 仍先五时段,赢段再切三段。全零相对支持按 100/n 均分(三段 33.3/33.3/33.4),不再写死五段各 20。不改分钟级 `_relative_support`。 +- 验证:`test_rectification_v5_services.py` 自定义三段和为 100、等分 33.3/33.3/33.4、越界/重叠报错;`rectification-block-scan-20260906.test.ts` ±120 开 Case 为 block_scan、傍晚三子段、选下午后仍 block_scan;Docker 上午 08:00–11:59 选定后仍 block_scan,08:00–09:00 进 minute。 +- 防复发:exclusive span >120 不得直接 `stage=minute`。`block_scan` 全零均分必须按段数,不得写死 20%。 +- 相关记录:BUG-570、BUG-571 +- 复发自:unknown-time 选段后一律进分钟(`814c924e`) +- 修复版本:待合入 `origin/staging`(`codex/rectification-declared-uncertainty-20260907`) + ## BUG-574 | 报告列表 JSON 路径 select 在 staging PostgREST 上 500 - 状态:resolved diff --git a/docs/tasks/PROGRESS-rectification-declared-uncertainty-20260907.md b/docs/tasks/PROGRESS-rectification-declared-uncertainty-20260907.md new file mode 100644 index 00000000..5f9c3458 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-declared-uncertainty-20260907.md @@ -0,0 +1,56 @@ +# PROGRESS · 出生时间「有多确定」只问一次(2026-09-07) + +工作树:`.worktrees/rectification-declared-uncertainty-20260907` +分支:`codex/rectification-declared-uncertainty-20260907`(跟踪 `origin/staging`) +基线:任务书落地 commit `ad9283d1`(含 BUG-565~570);合入前 rebase 到当时 `origin/staging`(含 BUG-574 `b2a8d3a0`)。 + +未改 `SKILL.md`(仍 10.0.14)、未改采用门 / 确认门 / `MIN_SEPARATION_LEAD` / 分钟级 `_relative_support`、未改 `page.tsx`、未改 `jyotish_api_server.py`、未改 `contracts/probe-question-v1.json`。迁移文件为 `20260907020000_rectification_declared_uncertainty.sql`(`20260907010000` 已被 `personal_report_card_summary` 占用)。 + +本单状态:**待验收**。BUG-571 / 572 / 573 已实现。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| D1 intake 三档 + ±120 | 完成 | — | +| D2 校正窗口读档案 | 完成 | BUG-571 | +| D5 子段扫描 | 完成 | BUG-573 | +| D3 放宽窗口一键卡 | 完成 | BUG-572 | +| D6 日级可靠度 + 手测脚本 | 完成 | — | +| D4 记录 | 完成 | 本文件 / BUG_HISTORY / CHANGELOG / DESIGN / VOICE / testing | + +## 实现要点 + +- 窗口阶段看 **exclusive span**(04:00–06:00 = 120 → minute;03:00–07:00 = 240 → block_scan)。放宽上限看 **inclusive width ≤ 241**。 +- 子段是当前窗三等分、无共享分钟。unknown 仍先扫五个声明时段(步长 10),赢段再切三段。 +- 放宽卡优先级高于区分卡。`hospital_record` 答 A 只改窗口。 +- 日级可靠度只在**本回合刚写入**的 day 事件上问(`source_turn_id === askedTurnId`),不拦采用门、不改历史账本上的旧日事件采集顺序。 + +### 书面偏差 + +| 项 | 任务书 | 实际 | 原因 | +| --- | --- | --- | --- | +| D6 写回 | 「用现有证据修订链,不新造 RPC」 | 薄 RPC `set_agentic_rectification_evidence_date_reliability`(原地 high/medium) | revise/supersede 会新开证据行、改指纹,误伤训练门 | +| 放宽 D 文案 | 「说不好」 | 「说不好 / 先这样」 | `clippedCopy` 最短 4 字 | +| `block_scan` 全零均分 | 五段各 20% | 100/n(三段 33.3/33.3/33.4) | 自定义 blocks 时写死 20 只给到 60 | +| 迁移时间戳 | `20260907010000_...` | `20260907020000_...` | staging 已占用 `20260907010000_personal_report_card_summary.sql` | + +### 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-v9-case-service` 04:45–05:15 | 有钟点一律 | 仅 `family_exact` / 医院记录;approximate 另写 | BUG-571 | +| `database-rectification-block-scan` 08:00–11:59 | 选定后 `minute` | 选定后仍 `block_scan` | span 239>120 | +| `rectification-block-scan` 选下午 | 进分钟 | 仍 `block_scan` | 12:00–17:59 span 359 | +| `birth-time-intake` `/前后两小时/` | 扫组件源码 | 扫 `approximateUncertaintyOptions` | 文案在 model | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| 非 DB `rectification-*` + `consultation-*` + `report-*` + `personal-report-*` + `birth-time-*` + `account-api` + `agent-voice-copy-contract` | **1913 pass / 0 fail**(≥ 1521) | +| `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -k 'not seven_events'` | **33 pass / 0 fail**(含自定义三段 100 与 33.3/33.3/33.4) | +| `test_block_scan_seven_events_finishes_within_fifteen_seconds` | 本机连跑偶发超 15s,与 BUG-570 同一 P2,未改断言 | +| `npx tsc --noEmit` | 0 错 | +| `./node_modules/.bin/eslint --quiet src tests` | 0 error | +| `frontend npm run test:db` | **37 pass / 0 fail**(547.7s;含 `database-rectification-block-scan`:08:00–11:59 选定后仍 `block_scan`,08:00–09:00 进 `minute`,widen 扩大成功 / 缩小失败 / service_role-only) | + +排除 `*-database.test.ts` 于非 DB 套件。ERR-011:Python 用 `.venv/bin/python -m pytest`。未改 `page.tsx`。未改分钟级 `_relative_support`。无 Docker 缺口,未写 `BLOCKED.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 65602b53..f13d9666 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -67,7 +67,7 @@ | `TASK-rectification-range-reading-20260906.md` | `PROGRESS-rectification-range-reading-20260906.md` | 可信区间成为一等公民:采用时落库 `adopted_credible_range`,报告 `read_report_candidate_range` 与聊天 `verified_chart(accepted)` 都改读它并接同一份 `birth_time_sensitivity`(现在报告读的是开工窗口,BUG-568);引擎 >15 分钟只取 3 样本改为 ≤31 逐分钟;采用旁白加「稳定 / 随分钟变」两句;`declared_birth_window` 复用 | 已验收通过(test:db 37/0 为执行方数字,本机无 Docker) | `814c924e`(BUG-568);两条迁移待应用 | | `TASK-rectification-unknown-time-20260906.md` | `PROGRESS-rectification-unknown-time-20260906.md` | 完全不知道出生时间的两段式路线:`stage=block_scan` 以 10 分钟步长扫 24 小时只做事件计分、出五时段四选卡(不写账本不采用),选定后进现有分钟流程;引擎加 `minute_step`;开场读 `birth_time_clue`;删 intake 劝退文案 | 已验收(带修复单:时段支持度按段长偏置,下午段先天 25%,P1) | `814c924e`;修复单 `TASK-rectification-capability-fix-20260907.md`(BUG-570) | | `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 已验收通过(P2:block_scan 15 s 壁钟断言在门禁里可能间歇红) | `517df002`(BUG-569~570);staging 未部署,需先 Migrate Staging Database | -| `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 待领取 | `codex/rectification-declared-uncertainty-20260907` | +| `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 待验收 | `codex/rectification-declared-uncertainty-20260907` | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-declared-uncertainty-20260907.md b/docs/testing/rectification-declared-uncertainty-20260907.md new file mode 100644 index 00000000..1999425a --- /dev/null +++ b/docs/testing/rectification-declared-uncertainty-20260907.md @@ -0,0 +1,37 @@ +# Staging 人肉复核 · 出生时间有多确定只问一次(2026-09-07) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 `TASK-rectification-declared-uncertainty-20260907.md` / BUG-571~573。测之前先做第 0 条。四个虚构脚本见 `rectification-scenarios-20260907.md`。 + +## 0. 确认测的是新版本 + +浏览器打开 staging 的 `/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。本迁移必须先 `Migrate Staging Database`。 + +## 1. intake 三档,家人一小时进校正是两小时窗(P0) + +设置 → 星盘资料。出生时间选「家人记得大概时间」,钟点填一个整点,范围点「前后一小时」,保存后开生时校正。 + +- ✅ 预期:顶部目前范围是该钟点前后各一小时(共两小时)。没有「偏移 / 误差 / 置信度 / 概率」。校正过程不再问你有多确定。 +- ❌ 失败:范围仍是 ±15;或校正开场又问不确定度。 + +## 2. 吻合低且贴边时出放宽卡,点 A 后设置页跟着变(P0) + +用上一档资料。口述三件带年月、不同领域、和这个两小时窗明显对不上的经历(不要用真实资料原句)。等到出现放宽卡。 + +- ✅ 预期:卡在区分卡之前。A 是放宽到前后两小时(或下一档),没有采用。点 A 后顶部范围变宽;再打开设置,家人范围按钮停在更大的档。 +- ❌ 失败:没有放宽卡、先出了分钟区分卡;或点 A 后设置页还是前后一小时。 + +## 3. 医院记录放宽不改档案来源(P1) + +另建资料,选「有出生证或医院记录」,走完能出放宽卡的条件后点 A。 + +- ✅ 预期:窗口变宽,设置页来源仍是医院记录,没有变成「家人记得大概时间」。 +- ❌ 失败:来源被改成 approximate。 + +## 4. 超过两小时先切三段(P0) + +资料选「只知道大概时段」填傍晚,或家人「前后两小时」。开校正,说够带年月经历。 + +- ✅ 预期:先出三段钟点卡,不是分钟或采用。选一段后若仍超过两小时,再出子段卡。 +- ❌ 失败:一上来分钟卡;或选傍晚后立刻按分钟比。 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md new file mode 100644 index 00000000..7364eb3d --- /dev/null +++ b/docs/testing/rectification-scenarios-20260907.md @@ -0,0 +1,64 @@ +# 生时校正手测脚本(2026-09-07) + +虚构走查。不要填真实姓名、出生资料或真实经历。对应 `TASK-rectification-declared-uncertainty-20260907.md` 决策 9。 + +测之前先看 `rectification-declared-uncertainty-20260907.md` 第 0 条。 + +## 1. 范围:家人说两点到四点 + +资料:家人记得大概时间,钟点 15:00,范围「前后一小时」。地点任意公开城市。 + +开场后依次说(均可改年份,保持不同领域): + +1. 2016 年 9 月去外地上学 +2. 2018 年 7 月入职第一份工作 +3. 2023 年夏天搬家 + +期望: + +- 顶部范围 14:00–16:00,不是 14:45–15:15 +- 若三件和这个窗对不上且代表分钟贴边:出现放宽卡,选项是前后两小时 / 不放宽 / 说不好 +- 点 A 后范围变成 13:00–17:00,设置页停在「前后两小时」 +- 不得出现:偏移、误差、置信度、概率、采用卡(在放宽之前) + +## 2. 时段:只知道傍晚 + +资料:只知道大概时段,开始 18:00 结束 22:59。 + +经历同上三件,领域仍要两个以上。 + +期望: + +- 第一张卡是三段钟点(例如 18:00—19:xx),不是「更像 20 点」、不是采用 +- 选 A/B/C 后范围收到该子段;若仍大于两小时,再出一张子段卡 +- 输入框上方是比较时段 / 子段,不是分钟 +- 不得出现:全日 00:00–23:59 上的分钟卡 + +## 3. 完全未知 + +资料:完全不清楚。 + +经历三件同上。 + +期望: + +- 先出清晨 / 上午 / 下午 / 傍晚或夜里这类五时段卡(实际露出支持最高的三段) +- 选定后若该时段超过两小时,再切子段,然后才分钟 +- 家人线索如果填了,只出现在旁白,窗口仍是全日直到点选 +- 不得出现:劝退「以后再做」;不得在全日窗上采用 + +## 4. 必须停下 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。 + +经历故意说不清: + +1. 「大概哪年有过事,具体不记得」 +2. 「地点也说不准」 +3. 连续几道选择题都点「说不好 / 先这样」 + +期望: + +- 到不确定度停止规则时停下,旁白说明再问也分不开 +- 不采用、不编唯一出生分钟 +- 不得把「先这样,先看当前范围」当成已经选定时间 diff --git a/docs/testing/rectification-unknown-time-20260906.md b/docs/testing/rectification-unknown-time-20260906.md index 9c27dc04..f1da4619 100644 --- a/docs/testing/rectification-unknown-time-20260906.md +++ b/docs/testing/rectification-unknown-time-20260906.md @@ -22,12 +22,14 @@ - ✅ 预期:出现四选卡,问哪一段更像出生时段(清晨 / 上午 / 下午 / 傍晚或夜里这类)。没有「更像 X 点」,没有采用。输入框上方是「第 1 步·比较时段」。 - ❌ 失败:仍在问经历却已经给出分钟或采用卡;或时段卡上写了代表分钟。 -## 3. 选定一段后进入分钟流程(P0) +## 3. 选定一段后若仍超过两小时,先切子段(P0) -在时段卡上选 A、B 或 C 中的一段。 +在时段卡上选 A、B 或 C 中的一段(例如下午 12:00–17:59)。 -- ✅ 预期:旁白说明已选定出生时段,接下来只在这一段里按分钟比较。目前范围变成该时段(不超过大约 6 小时),不再是 00:00–23:59。之后才可能出现分钟区分题。 -- ❌ 失败:范围仍是全日;或选定后立刻采用某个分钟。 +- ✅ 预期:范围变成该时段,不再是 00:00–23:59。若这一段超过大约两小时,下一张仍是三段钟点卡,不是分钟或采用。收到大约两小时及以内之后,才按分钟比较。 +- ❌ 失败:范围仍是全日;选定下午后立刻出现分钟或采用卡。 + +更完整的子段与放宽走查见 `docs/testing/rectification-declared-uncertainty-20260907.md`。 ## 4. 说不好会再收经历,不会结束校正(P1) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 9b67ba00..b740c0aa 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -243,7 +243,7 @@ The birth-time rectification session is the consultation transcript plus a house ### Birth time intake -- **Structure:** birth date, five radio choice rows for time knowledge, then only the time, uncertainty, period, or clue field required by the selected source. +- **Structure:** birth date, then three radio rows for “你对这个时间有多确定”——医院记录精确到分钟、家人记得大概时间、只知道时段或完全不知道。家人那档再给四个范围按钮(差不多准 / 前后半小时 / 前后一小时 / 前后两小时 = ±15/30/60/120)。存量 `family_exact` 档案显示成「差不多准」。之后只露出该档需要的钟点、时段或线索字段。 - **Surface:** choice rows use the warm canvas and hairline system; the selected row uses `--color-action-soft` with a deep-brown border, never a dark promotional card. - **States:** no source selected, source selected, source-specific details incomplete, ready to continue, assessing, rectifying, candidate saved, confirmed. - **Copy:** labels describe what the user actually knows. “完全不清楚” starts rectification from remembered events and compares declared periods before any minute grid; it does not defer correction. Candidate results explicitly distinguish a reported time, a candidate range, and an active chart time. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index d9c22750..09301a6a 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -46,6 +46,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 选择题只有题干,不知道为什么问、答了会怎样 | 题干下可展开「为什么问这题」;选项旁有一段「会让哪段领先 / 落后」。 | 解释由服务端生成,不让模型编分数。 | | 每轮只有技法清单,不知道现在第几步 | 输入框上方有「第 N 步 · 原因 · 下一步」。 | 用户始终看得见该做什么。 | | 完全不清楚出生时间后劝退「生时校正以后需要时再做」 | 可以直接开始生时校正:先从你记得的经历比出大致时段。 | 不知道钟点时先比时段,不劝退、不给分钟卡。 | +| 问「偏移 / 误差有多少分钟」 | 你对这个时间有多确定? | 不确定度只在资料里问一次,校正不再追问。 | +| 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 | +| 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 | +| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 | ## 服务端探针 → Agent 题干 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 26ab38f5..ac8d038a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -863,6 +863,11 @@ button:disabled { cursor: default; opacity: .45; } .birth-time-source-option > span { display: grid; gap: 3px; } .birth-time-source-option b { color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 600; } .birth-time-source-option small, .birth-time-detail-note, .birth-time-legacy-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } +.birth-time-uncertainty-fieldset { margin: var(--space-3) 0 0; padding: 0; border: 0; } +.birth-time-uncertainty-fieldset legend { margin-bottom: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.birth-time-uncertainty-list { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.birth-time-uncertainty-option { min-height: 44px; padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font-size: var(--type-body-sm); cursor: pointer; } +.birth-time-uncertainty-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } .birth-time-detail-grid { display: grid; grid-template-columns: minmax(0, 180px) minmax(0, 1fr); align-items: end; gap: var(--space-3); } .birth-time-detail-grid > label { min-width: 0; } .birth-time-detail-note { padding-bottom: 12px; } diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 40851fcd..0b12ffe0 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -5,11 +5,13 @@ import { BirthDatePicker } from "@/components/birth-date-picker"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent"; import { + approximateUncertaintyOptions, birthTimeDisplayState, birthTimeSourceDefaults, birthTimeSourceOptions, type BirthTimeDraft, type BirthTimeDraftPatch, + type BirthTimeSource, } from "@/lib/birth-time-intake-model"; type BirthTimeIntakeProps = { @@ -79,13 +81,20 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) const source = value.birthTimeSource; const isConfirmed = value.birthTimeStatus === "confirmed"; const displayState = birthTimeDisplayState(value); - const knowledgeMode = source === "period_only" || source === "unknown" - ? "uncertain" - : source ? "exact" : ""; + const selectedSourceOption: BirthTimeSource | "" = source === "family_exact" || source === "approximate" + ? "approximate" + : source === "unknown" + ? "period_only" + : source === "hospital_record" || source === "period_only" + ? source + : ""; const usesClockTime = source === "hospital_record" || source === "family_exact" || source === "approximate" || source === "legacy_import"; + const approximateRadius = source === "family_exact" + ? 15 + : value.uncertaintyBeforeMinutes; return (
@@ -126,20 +135,16 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) )} {!isConfirmed &&
- 你对出生时间了解多少? -

不用猜具体分钟,先选一个最符合你的情况的选项。

+ 你对这个时间有多确定? +

只用在这里问一次。校正会按你选的范围去比,不再追问你有多确定。

{birthTimeSourceOptions.map((option) => (
)} diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index eaf4bf39..59ca70e8 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -104,7 +104,7 @@ export const accountProfilePatchSchema = z.object({ if (time || value.birth_time || value.birth_time_period || value.declared_window_start || value.declared_window_end || value.birth_time_clue || before != null || after != null) { - addIssue("birth_time_source", "未选择时间来源时不得提交时间或误差范围"); + addIssue("birth_time_source", "未选择时间来源时不得提交时间或时间范围"); } return; } @@ -123,7 +123,7 @@ export const accountProfilePatchSchema = z.object({ }; const ensureNoUncertainty = () => { if (before != null || after != null) { - addIssue("uncertainty_before_minutes", "该时间来源不得提交误差范围"); + addIssue("uncertainty_before_minutes", "该时间来源不得提交时间范围"); } }; @@ -141,8 +141,8 @@ export const accountProfilePatchSchema = z.object({ ensureNoDeclaredWindow(); } else if (source === "approximate") { if (!time) addIssue("reported_birth_time", "大概时间需要具体 HH:mm"); - if (![15, 30, 60].includes(before ?? -1) || before !== after) { - addIssue("uncertainty_before_minutes", "大概时间误差必须为前后 15、30 或 60 分钟"); + if (![15, 30, 60, 120].includes(before ?? -1) || before !== after) { + addIssue("uncertainty_before_minutes", "大概时间必须是前后 15、30、60 或 120 分钟"); } ensureNoPeriod(); ensureNoDeclaredWindow(); diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index 866aa102..6e6ac46b 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -88,9 +88,19 @@ export function formatBirthDate(value: Date): string { return format(value, birthDatePattern); } +export const APPROXIMATE_UNCERTAINTY_MINUTES = [15, 30, 60, 120] as const; + +export const approximateUncertaintyOptions = [ + { minutes: 15, label: "差不多准" }, + { minutes: 30, label: "前后半小时" }, + { minutes: 60, label: "前后一小时" }, + { minutes: 120, label: "前后两小时" }, +] as const; + export const birthTimeSourceOptions = [ - { value: "family_exact", label: "我知道准确出生时间", hint: "按医院记录或家人记得的时间填写,精确到分钟" }, - { value: "period_only", label: "我不确定准确时间", hint: "选择一段开始到结束的时间,或完全不清楚" }, + { value: "hospital_record", label: "有出生证或医院记录,精确到分钟", hint: "按记录填写时和分。系统会无感检查前后 2 分钟。" }, + { value: "approximate", label: "家人记得大概时间", hint: "先填一个大概钟点,再选前后半小时或一小时这样的范围。" }, + { value: "period_only", label: "只知道大概时段,或完全不知道", hint: "选择一段开始到结束的时间,或完全不清楚" }, ] as const; export const birthTimeSourceDefaults = { @@ -115,8 +125,8 @@ export const birthTimeSourceDefaults = { declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "", - uncertaintyBeforeMinutes: 30, - uncertaintyAfterMinutes: 30, + uncertaintyBeforeMinutes: 15, + uncertaintyAfterMinutes: 15, }, period_only: { reportedTime: "", @@ -240,10 +250,9 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) { && draft.uncertaintyAfterMinutes === 0; case "approximate": return isBirthClockTime(draft.reportedTime) - && (draft.uncertaintyBeforeMinutes === null - || [15, 30, 60].includes(draft.uncertaintyBeforeMinutes)) - && (draft.uncertaintyAfterMinutes === null - || draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes); + && draft.uncertaintyBeforeMinutes !== null + && (APPROXIMATE_UNCERTAINTY_MINUTES as readonly number[]).includes(draft.uncertaintyBeforeMinutes) + && draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes; case "period_only": return Boolean(declaredWindowRangeForDraft(draft)) && !draft.reportedTime @@ -265,7 +274,7 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) { export function birthTimeDraftReadyHint(draft: BirthTimeDraft) { if (!parseBirthDate(draft.date)) return "请先选择出生日期"; - if (!draft.birthTimeSource) return "请选择你对出生时间了解多少"; + if (!draft.birthTimeSource) return "请选择你对这个时间有多确定"; if (isBirthTimeDraftReady(draft)) return ""; if (draft.birthTimeSource === "period_only") { if (draft.declaredWindowStart && draft.declaredWindowEnd @@ -275,6 +284,12 @@ export function birthTimeDraftReadyHint(draft: BirthTimeDraft) { return "请选择开始和结束时间"; } if (draft.birthTimeSource === "unknown") return ""; + if ( + (draft.birthTimeSource === "approximate" || draft.birthTimeSource === "family_exact") + && isBirthClockTime(draft.reportedTime) + ) { + return "请选择一个最接近的范围"; + } return "请填写时和分"; } diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 048ae29d..c64fcc50 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -31,6 +31,7 @@ export { REPRESENTATIVE_MINUTE_DISCLAIMER, nonConvergingRangeNarration }; export type RectificationNextActionType = | "ask_fact_collection" | "ask_block_choice" + | "ask_window_widen" | "ask_candidate_discriminator" | "ask_holdout_validation" | "offer_provisional_range" @@ -110,6 +111,7 @@ export const MIN_STANDALONE_DATED_DOMAINS = 2; export type DecisionSessionOutcome = | "collect_evidence" | "compare_blocks" + | "widen_window" | "discriminate_candidates" | "validate_holdout" | "provisional_range" @@ -193,6 +195,7 @@ export type DecideRectificationInput = Readonly<{ userUncertaintyHigh?: boolean; caseStage?: "minute" | "block_scan"; blockScanDeclined?: boolean; + windowWidenSuggested?: boolean; }>; function classifyStop( @@ -275,6 +278,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat trainingGateOpen: input.trainingGateOpen !== false, }); + if (input.windowWidenSuggested === true && input.trainingGateOpen !== false && !userStopped) { + return askWindowWiden(separation, range); + } + if (input.snapshotCurrent === false) { if (probe && !userStopped && input.trainingGateOpen !== false) { return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); @@ -458,6 +465,36 @@ function askBlockChoice( }; } +function askWindowWiden( + separation: CandidateSeparation, + range: readonly [string, string] | null, +): RectificationDecision { + const closed: DeliveryCapability = { + canAdopt: false, + selectionAllowed: false, + proposeAllowed: false, + canConfirmExactMinute: false, + }; + return { + phase: "discrimination", + nextAction: "ask_window_widen", + sessionOutcome: "widen_window", + resultStatus: "discriminating", + canOfferRange: false, + ...closed, + precisionStage: "theme_refine", + activeFocusPolicy: "keep", + completionStatus: null, + validated: false, + credibleRange: range, + representativeTime: separation.representativeTime, + separation, + probe: null, + holdoutValidation: "unavailable", + droppedProbes: [], + }; +} + function collect( separation: CandidateSeparation, holdout: HoldoutValidationStatus, @@ -669,6 +706,7 @@ export function sessionKindFromNextAction( ): DecisionSessionOutcome { if (type === "ask_fact_collection") return "collect_evidence"; if (type === "ask_block_choice") return "compare_blocks"; + if (type === "ask_window_widen") return "widen_window"; if (type === "ask_candidate_discriminator") return "discriminate_candidates"; if (type === "ask_holdout_validation") return "validate_holdout"; if (type === "offer_provisional_range") return "provisional_range"; diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 715f0f93..23ca4633 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -86,6 +86,7 @@ export const RECTIFICATION_USER_COPY = { insufficientDomainsGate: "还差另一个领域的带月份经历。", lowDateQualityGate: "两件事的日期还没对清。", noCandidatesGate: "当前还排不出可比较的候选时间。", + forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。", } as const; export const ACCEPTANCE_GATE_COPY: Readonly> = { @@ -284,7 +285,9 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.insufficientDomainsGate, RECTIFICATION_USER_COPY.lowDateQualityGate, RECTIFICATION_USER_COPY.noCandidatesGate, + RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks, RECTIFICATION_USER_COPY.postAdoptVerifyDone, + "刚才那个日子是查过记录,还是凭记忆?", PROBE_EXPLAIN_COPY.unsureImpact, PROBE_EXPLAIN_COPY.splitGroups, `${PROBE_EXPLAIN_COPY.vargaWhyPrefix} D9 ${PROBE_EXPLAIN_COPY.vargaWhySuffix}`, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 765ef18b..8e6b280f 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -21,6 +21,8 @@ import { loadV9CaseSkillIdentity, loadV9CaseCompute, RectificationToolServiceError, + resolveV10ConversationFocus, + setV9EvidenceDateReliability, type RectificationRpcClient, type V9CaseDossier, } from "./tool-service"; @@ -28,6 +30,7 @@ import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-st import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt"; import { agentGenerationSettings, cachedSystemMessage, promptCacheUsage } from "../../agent-generation-settings.ts"; import { toAgentModelFinishReason } from "../../agent-observability.ts"; +import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts"; import { decideFromDossier } from "./decision-from-dossier"; import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; @@ -244,7 +247,38 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise>; optionId: ChoiceKey | "stop" | "skip_probe"; -}): Promise<{ dossier: V9CaseDossier; snapshotCurrent: boolean }> { +}): Promise<{ dossier: V9CaseDossier; snapshotCurrent: boolean; narration?: string }> { if (!isBlockChoiceSchema(input.schema)) { return { dossier: input.dossier, snapshotCurrent: true }; } @@ -40,19 +52,28 @@ export async function mutateCaseForBlockChoice(input: { optionId: input.optionId, }); if (window) { + const previousRounds = input.dossier.case.blockScan?.rounds ?? 0; await advanceV9CaseFromBlockScan(input.accounting, input.userId, input.caseId, window); try { - await rescoreMinuteAfterBlockAdvance(input.accounting, input.userId, input.caseId); + await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId); } catch (error) { console.warn( - `[rectification-v9] block_scan minute rescore deferred case=${input.caseId} reason=${ + `[rectification-v9] window rescore deferred case=${input.caseId} reason=${ error instanceof Error ? error.message : String(error) }`, ); } + const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const forcedMinute = dossier.case.stage === "minute" + && previousRounds >= MAX_SUB_BLOCK_ROUNDS; return { - dossier: await loadV9CaseDossier(input.accounting, input.userId, input.caseId), + dossier, snapshotCurrent: false, + narration: dossier.case.stage === "block_scan" + ? "已选定这一段,接下来再比更短的三段。" + : forcedMinute + ? RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks + : "已选定出生时段,接下来只在这一段里按分钟比较。", }; } const current = input.dossier.case.blockScan; @@ -73,17 +94,94 @@ export async function mutateCaseForBlockChoice(input: { }; } -async function rescoreMinuteAfterBlockAdvance( +export async function mutateCaseForWidenWindow(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + dossier: V9CaseDossier; + schema: Readonly>; + optionId: ChoiceKey | "stop" | "skip_probe"; +}): Promise<{ dossier: V9CaseDossier; snapshotCurrent: boolean }> { + if (!isWidenWindowSchema(input.schema)) { + return { dossier: input.dossier, snapshotCurrent: true }; + } + const chosen = windowFromWidenChoice({ + schema: input.schema, + optionId: input.optionId, + }); + if (chosen && chosen.radius) { + await widenV9CaseWindow(input.accounting, input.userId, input.caseId, { + start_time: chosen.start_time, + end_time: chosen.end_time, + }); + const source = input.dossier.case.birthTimeSource; + if (source !== "hospital_record") { + await patchV9BirthUncertainty(input.accounting, input.userId, { + birth_time_source: "approximate", + uncertainty_before_minutes: chosen.radius, + uncertainty_after_minutes: chosen.radius, + }); + } + const nextStage = stageForClockWindow(chosen); + if (nextStage === "block_scan") { + await setV9CaseStage(input.accounting, input.userId, input.caseId, "block_scan"); + } + try { + await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId); + } catch (error) { + console.warn( + `[rectification-v9] widen rescore deferred case=${input.caseId} reason=${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return { + dossier: await loadV9CaseDossier(input.accounting, input.userId, input.caseId), + snapshotCurrent: false, + }; + } + await setV9WidenDeclined( + input.accounting, + input.userId, + input.caseId, + evidenceLedgerFingerprint(input.dossier.evidence), + ); + return { + dossier: await loadV9CaseDossier(input.accounting, input.userId, input.caseId), + snapshotCurrent: true, + }; +} + +export async function rescoreMinuteAfterWindowChange( accounting: AccountingClient, userId: string, caseId: string, ): Promise { const dossier = await loadV9CaseDossier(accounting, userId, caseId); - if (dossier.case.stage !== "minute" || !dossier.case.candidateRange) return; + if (!dossier.case.candidateRange) return; const scorable = scorableEvidence(dossier.evidence); if (scorable.length === 0) return; const compute = await loadV9CaseCompute(accounting, userId, caseId); const events = toEngineEvents(scorable); + const fingerprint = evidenceLedgerFingerprint(dossier.evidence); + if (dossier.case.stage === "block_scan") { + const extras = blockScanRequestExtras(dossier.case.candidateRange); + const scan = await runV9BlockScan({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange: dossier.case.candidateRange, + events, + ...extras, + }); + await persistBlockScanPayload({ + accounting, + userId, + caseId, + evidenceFingerprint: fingerprint, + scan, + previousRounds: dossier.case.blockScan?.rounds ?? 0, + }); + return; + } const score = await runV9CandidateScore({ baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange: dossier.case.candidateRange, @@ -93,7 +191,7 @@ async function rescoreMinuteAfterBlockAdvance( await persistV9Candidate(accounting, userId, caseId, { engineResultId: score.engineResultId, algorithmVersion: score.algorithmVersion, - evidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), + evidenceFingerprint: fingerprint, rangeFingerprint: candidateRangeFingerprint( dossier.case.candidateRange, compute.baselineProfileFingerprint, @@ -114,11 +212,16 @@ export async function persistBlockScanPayload(input: { caseId: string; evidenceFingerprint: string; scan: Awaited>; + previousRounds?: number; }): Promise { + const isSub = input.scan.blocks.some((block) => ( + block.period === "sub_1" || block.period === "sub_2" || block.period === "sub_3" + )); await writeV9BlockScan(input.accounting, input.userId, input.caseId, { evidence_ledger_fingerprint: input.evidenceFingerprint, algorithm_version: input.scan.algorithmVersion, minute_step: input.scan.minuteStep, + rounds: isSub ? (input.previousRounds ?? 0) + 1 : 0, blocks: input.scan.blocks, }); } diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan.ts b/frontend/src/lib/rectification-agentic/v9/block-scan.ts index e7fee331..c063d926 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan.ts @@ -7,6 +7,8 @@ import { DECLARED_PERIOD_RANGES, + isBirthClockText, + isDeclaredBirthPeriod, type DeclaredBirthPeriod, } from "../../declared-birth-window.ts"; import type { AnswerClass } from "../core/types.ts"; @@ -18,12 +20,21 @@ import { type RectificationChoiceFrame, type RectificationChoiceOption, } from "./choice-card.ts"; +import { + MAX_SUB_BLOCK_ROUNDS, + SUB_BLOCK_LABELS, + type SubBlockLabel, +} from "./search-window.ts"; export const RECTIFICATION_CASE_STAGES = ["minute", "block_scan"] as const; export type RectificationCaseStage = (typeof RECTIFICATION_CASE_STAGES)[number]; export const BLOCK_CHOICE_KIND = "block_choice" as const; export const BLOCK_CHOICE_INTENT = "choose_birth_block" as const; +export const WIDEN_WINDOW_KIND = "widen_window" as const; +export const WIDEN_WINDOW_INTENT = "widen_search_window" as const; + +export type BlockScanPeriod = DeclaredBirthPeriod | SubBlockLabel; export const BLOCK_PERIOD_LABELS: Readonly> = { early_morning: "清晨 04:00—07:59", @@ -36,7 +47,7 @@ export const BLOCK_PERIOD_LABELS: Readonly> const BLOCK_ANSWER_CLASSES: readonly AnswerClass[] = ["yes", "weak_yes", "no", "unsure"]; export type BlockScanBlock = Readonly<{ - period: DeclaredBirthPeriod; + period: BlockScanPeriod; start_time: string; end_time: string; relative_support: number; @@ -49,6 +60,7 @@ export type BlockScanPayload = Readonly<{ algorithmVersion?: string | null; minuteStep?: number | null; declinedAtFingerprint?: string | null; + rounds?: number | null; blocks: readonly BlockScanBlock[]; }>; @@ -60,16 +72,16 @@ export function parseRectificationCaseStage(value: unknown): RectificationCaseSt return value === "block_scan" ? "block_scan" : "minute"; } -function isDeclaredPeriod(value: unknown): value is DeclaredBirthPeriod { - return value === "early_morning" - || value === "morning" - || value === "afternoon" - || value === "evening" - || value === "late_night"; +function isSubBlockLabel(value: unknown): value is SubBlockLabel { + return value === "sub_1" || value === "sub_2" || value === "sub_3"; +} + +function isBlockScanPeriod(value: unknown): value is BlockScanPeriod { + return isDeclaredBirthPeriod(value as string) || isSubBlockLabel(value); } function clockText(value: unknown): string | null { - return typeof value === "string" && /^([01]\d|2[0-3]):[0-5]\d$/.test(value.slice(0, 5)) + return typeof value === "string" && isBirthClockText(value.slice(0, 5)) ? value.slice(0, 5) : null; } @@ -77,16 +89,20 @@ function clockText(value: unknown): string | null { export function parseBlockScanBlock(value: unknown): BlockScanBlock | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const row = value as Record; - if (!isDeclaredPeriod(row.period)) return null; - const expected = DECLARED_PERIOD_RANGES[row.period]; - const start = clockText(row.start_time) ?? expected.startTime; - const end = clockText(row.end_time) ?? expected.endTime; - if (start !== expected.startTime || end !== expected.endTime) return null; + const period = row.period ?? row.label; + if (!isBlockScanPeriod(period)) return null; + const start = clockText(row.start_time); + const end = clockText(row.end_time); + if (!start || !end || start === end) return null; + if (isDeclaredBirthPeriod(period)) { + const expected = DECLARED_PERIOD_RANGES[period]; + if (start !== expected.startTime || end !== expected.endTime) return null; + } const support = typeof row.relative_support === "number" && Number.isFinite(row.relative_support) ? Math.max(0, Math.round(row.relative_support)) : 0; return { - period: row.period, + period, start_time: start, end_time: end, relative_support: support, @@ -98,12 +114,19 @@ export function parseBlockScanBlock(value: unknown): BlockScanBlock | null { export function parseBlockScanPayload(value: unknown): BlockScanPayload | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const row = value as Record; + const rounds = typeof row.rounds === "number" && Number.isInteger(row.rounds) + ? Math.max(0, row.rounds) + : 0; const rawBlocks = Array.isArray(row.blocks) ? row.blocks : []; const blocks = rawBlocks.flatMap((item) => { const parsed = parseBlockScanBlock(item); return parsed ? [parsed] : []; }); - if (blocks.length !== 5) return null; + if (blocks.length === 0) { + if (!("rounds" in row) || typeof row.rounds !== "number") return null; + } else if (blocks.length < 3 || blocks.length > 5) { + return null; + } return { evidenceLedgerFingerprint: typeof row.evidence_ledger_fingerprint === "string" ? row.evidence_ledger_fingerprint @@ -113,6 +136,7 @@ export function parseBlockScanPayload(value: unknown): BlockScanPayload | null { declinedAtFingerprint: typeof row.declined_at_fingerprint === "string" ? row.declined_at_fingerprint : null, + rounds, blocks, }; } @@ -123,6 +147,7 @@ export function serializeBlockScanPayload(payload: BlockScanPayload): Record ({ period: block.period, start_time: block.start_time, @@ -134,6 +159,18 @@ export function serializeBlockScanPayload(payload: BlockScanPayload): Record isSubBlockLabel(block.period))); +} + +export function subBlockRounds(payload: BlockScanPayload | null | undefined): number { + return payload?.rounds ?? 0; +} + +export function shouldForceMinuteAfterSubBlocks(payload: BlockScanPayload | null | undefined): boolean { + return subBlockRounds(payload) >= MAX_SUB_BLOCK_ROUNDS; +} + export function blockScanDeclinedForFingerprint( payload: BlockScanPayload | null | undefined, fingerprint: string | null | undefined, @@ -148,10 +185,27 @@ export function blockScanDeclinedForFingerprint( export function followupCaseArgs(input: { stage?: string | null; blockScan?: BlockScanPayload | null; -}): { caseStage: RectificationCaseStage; blockScan: BlockScanPayload | null } { + reportedTime?: string | null; + reportedBirthTime?: string | null; + candidateRange?: { start_time?: string; end_time?: string } | null; +}): { + caseStage: RectificationCaseStage; + blockScan: BlockScanPayload | null; + reportedTime?: string | null; + candidateRange?: { start_time: string; end_time: string } | null; +} { + const reported = input.reportedTime ?? input.reportedBirthTime ?? null; + const range = input.candidateRange; + const candidateRange = range + && typeof range.start_time === "string" + && typeof range.end_time === "string" + ? { start_time: range.start_time, end_time: range.end_time } + : null; return { caseStage: input.stage === "block_scan" ? "block_scan" : "minute", blockScan: input.blockScan ?? null, + ...(reported ? { reportedTime: reported } : {}), + ...(candidateRange ? { candidateRange } : {}), }; } @@ -173,8 +227,18 @@ export function topBlockScanChoices( } function optionLabel(block: BlockScanBlock): string { - const name = BLOCK_PERIOD_LABELS[block.period]; - return `${name}(相对支持 ${block.relative_support})`; + const clock = `${block.start_time}—${block.end_time}`; + if (isSubBlockLabel(block.period)) { + return `${clock}(相对支持 ${block.relative_support})`; + } + return `${BLOCK_PERIOD_LABELS[block.period]}(相对支持 ${block.relative_support})`; +} + +function impactLabel(block: BlockScanBlock): string { + if (isSubBlockLabel(block.period)) { + return `选定后只在 ${block.start_time}—${block.end_time} 里继续比较`; + } + return `选定后只在 ${BLOCK_PERIOD_LABELS[block.period]} 里继续比较`; } export function buildBlockChoiceFrame( @@ -192,7 +256,9 @@ export function buildBlockChoiceFrame( question_id: "block_scan:choose_birth_block:holdout", method_id: "dasha_events", period: "", - prompt: "按你说的经历,这三段里哪一段更像出生时段?", + prompt: isSubBlockScan(payload) + ? "按你说的经历,这三段里哪一段更像出生时间?" + : "按你说的经历,这三段里哪一段更像出生时段?", varga: null, why: "还不知道具体钟点,先用经历比出大概时段,选定后再按分钟收窄。", option_a_hint: options[0]!.label, @@ -209,9 +275,9 @@ export function buildBlockChoiceFrame( scoring: false, why_user: "还不知道具体钟点,先用经历比出大概时段,选定后再按分钟收窄。", answer_impact: { - A: `选定后只在 ${BLOCK_PERIOD_LABELS[top[0].period]} 里按分钟比较`, - B: `选定后只在 ${BLOCK_PERIOD_LABELS[top[1].period]} 里按分钟比较`, - C: `选定后只在 ${BLOCK_PERIOD_LABELS[top[2].period]} 里按分钟比较`, + A: impactLabel(top[0]), + B: impactLabel(top[1]), + C: impactLabel(top[2]), D: "不计分,再收一件带年份的经历后重比时段", }, choice_kind: BLOCK_CHOICE_KIND, @@ -243,3 +309,7 @@ export function windowFromBlockChoice(input: { export function isBlockChoiceSchema(schema: Readonly> | null | undefined): boolean { return schema?.choice_kind === BLOCK_CHOICE_KIND; } + +export function isWidenWindowSchema(schema: Readonly> | null | undefined): boolean { + return schema?.choice_kind === WIDEN_WINDOW_KIND; +} diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index dfbce5be..b74267c4 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -29,6 +29,10 @@ import { export { projectRectificationStepState } from "./step-state.ts"; export type { RectificationStepState } from "./step-state.ts"; import { RECTIFICATION_USER_COPY } from "../user-copy.ts"; +import { + deriveDeclaredSearchWindow, + stageForClockWindow, +} from "./search-window.ts"; import { openResponse, type OpenRectificationCaseRequest, @@ -124,25 +128,28 @@ function shiftedTime(time: string, offsetMinutes: number): string { return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; } -// This is an engine execution boundary, not a user-declared uncertainty. -// Exact-time Cases receive a movable search radius; imprecise declarations keep -// the honest server-owned range instead of inventing a baseline minute. -const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15; - function deriveRectificationOpenPlan(input: { reportedTime: string | null; source: string; period: string | null; windowStart: string | null; windowEnd: string | null; + uncertaintyBefore?: number | null; + uncertaintyAfter?: number | null; }): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } { if (input.reportedTime) { + const candidateRange = deriveDeclaredSearchWindow({ + reportedTime: input.reportedTime, + source: input.source, + uncertaintyBefore: input.uncertaintyBefore ?? null, + uncertaintyAfter: input.uncertaintyAfter ?? null, + }) ?? { + start_time: shiftedTime(input.reportedTime, -15), + end_time: shiftedTime(input.reportedTime, 15), + }; return { - candidateRange: { - start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES), - end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES), - }, - stage: "minute", + candidateRange, + stage: input.source === "unknown" ? "minute" : stageForClockWindow(candidateRange), }; } @@ -153,9 +160,13 @@ function deriveRectificationOpenPlan(input: { endTime: input.windowEnd, }); if (!range) throw new RectificationCaseServiceError("profile_incomplete"); + const candidateRange = { start_time: range.startTime, end_time: range.endTime }; + if (input.source === "unknown") { + return { candidateRange, stage: "block_scan" }; + } return { - candidateRange: { start_time: range.startTime, end_time: range.endTime }, - stage: input.source === "unknown" ? "block_scan" : "minute", + candidateRange, + stage: stageForClockWindow(candidateRange), }; } @@ -165,6 +176,8 @@ export function deriveRectificationOpenWindow(input: { period: string | null; windowStart: string | null; windowEnd: string | null; + uncertaintyBefore?: number | null; + uncertaintyAfter?: number | null; }): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } { return deriveRectificationOpenPlan(input); } @@ -263,6 +276,8 @@ export async function loadV9RectificationProfile( period, windowStart, windowEnd, + uncertaintyBefore, + uncertaintyAfter, }); return { @@ -299,9 +314,13 @@ const KNOWN_RPC_ERROR_CODES = new Map[], + currentTurnId?: string | null, +): { id: string } | null { + if (!currentTurnId) return null; + const pending = [...evidence].reverse().find((item) => ( + item.status === "confirmed" + && item.datePrecision === "day" + && Boolean(item.id) + && !item.dateReliability + && item.sourceTurnId === currentTurnId + )); + return pending?.id ? { id: pending.id } : null; +} + +export function classifyDateReliabilityUtterance(text: string): DateReliabilityValue | null { + const value = text.trim(); + if (!value) return null; + if (/查过|记录|证件|日历|日记|医院|户口/.test(value)) return "high"; + if (/记忆|记得|印象|凭记|大概/.test(value)) return "medium"; + return null; +} + +export function isDateReliabilitySchema( + schema: Readonly> | null | undefined, +): schema is Readonly> & { date_reliability: true; target_evidence_id: string } { + return schema?.date_reliability === true && typeof schema.target_evidence_id === "string"; +} 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 634b8475..34ed1005 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -50,10 +50,12 @@ import { windowScanFromDecisionReceipt } from "./varga-observations"; import type { DroppedProbe } from "./probe-question-contract.ts"; import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; +import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts"; import { - blockScanDeclinedForFingerprint, - followupCaseArgs, -} from "./block-scan.ts"; + representativeNearWindowEdge, +} from "./search-window.ts"; +import { shouldSuggestWindowWiden, currentApproximateRadius } from "./window-widen.ts"; +import { isBirthClockText } from "../../declared-birth-window.ts"; import { candidateSnapshotSource, storedSnapshotIsCurrent, @@ -69,6 +71,7 @@ export type DecisionDossier = Readonly<{ occurredTo: string | null; eventKind?: string | null; summary?: string | null; + dateReliability?: string | null; }>[]; conversationSummary: { activeFocus: { @@ -105,6 +108,9 @@ export type DecisionDossier = Readonly<{ candidateRange?: { start_time?: string; end_time?: string } | null; stage?: "minute" | "block_scan"; blockScan?: import("./block-scan.ts").BlockScanPayload | null; + widenDeclinedAtFingerprint?: string | null; + reportedBirthTime?: string | null; + birthTimeSource?: string | null; }; turns?: readonly Readonly<{ role: string; text: string | null }>[]; }>; @@ -572,6 +578,38 @@ function uncertaintyHighFromAnswers( return uncertain >= Math.ceil(userAnswers.length / 2); } +function windowWidenSuggestedFromDossier(dossier: DecisionDossier, evidenceFingerprint: string): boolean { + const caseStage = dossier.case.stage === "block_scan" ? "block_scan" : "minute"; + const fit = refinementFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null).event_fit_rate; + const candidateRange = dossier.case.candidateRange + && typeof dossier.case.candidateRange.start_time === "string" + && typeof dossier.case.candidateRange.end_time === "string" + ? { start_time: dossier.case.candidateRange.start_time, end_time: dossier.case.candidateRange.end_time } + : null; + const reportedTime = dossier.case.reportedBirthTime && isBirthClockText(dossier.case.reportedBirthTime) + ? dossier.case.reportedBirthTime + : null; + if (!candidateRange || !reportedTime) return false; + return shouldSuggestWindowWiden({ + trainingGateOpen: trainingScoreableGate(dossier.evidence).open, + fitBand: fit?.band, + fitTotal: fit?.total, + representativeNearEdge: representativeNearWindowEdge( + dossier.latestResult?.representativeTime ?? dossier.latestResult?.candidates?.[0]?.time ?? null, + candidateRange, + ), + stage: caseStage, + accepted: Boolean(dossier.case.acceptedTime), + reportedTime, + declinedForFingerprint: Boolean( + dossier.case.widenDeclinedAtFingerprint + && evidenceFingerprint + && dossier.case.widenDeclinedAtFingerprint === evidenceFingerprint, + ), + currentRadius: currentApproximateRadius(reportedTime, candidateRange), + }); +} + export function decideFromDossier( dossier: DecisionDossier, options?: DecideFromDossierOptions, @@ -592,6 +630,8 @@ export function decideFromDossier( ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, + reportedBirthTime: dossier.case.reportedBirthTime, + candidateRange: dossier.case.candidateRange, }), }); const remainingCollect = exhaustionSpokenCollectFollowup({ @@ -609,6 +649,9 @@ export function decideFromDossier( const blockScanReady = caseStage === "block_scan" && evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS && evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS; + const evidenceFingerprint = options?.currentEvidenceFingerprint + ?? evidenceLedgerFingerprint(dossier.evidence as never); + const windowWidenSuggested = windowWidenSuggestedFromDossier(dossier, evidenceFingerprint); const confirmationGate = buildConfirmationGate({ engineConfirmationAllowed: latest?.confirmationAllowed === true, candidates: (latest?.candidates ?? []).map((candidate) => ({ @@ -667,6 +710,7 @@ export function decideFromDossier( dossier.case.blockScan, options?.currentEvidenceFingerprint ?? evidenceLedgerFingerprint(dossier.evidence as never), ), + windowWidenSuggested, }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; @@ -689,6 +733,8 @@ export function decideAfterInferenceChange(input: { ...followupCaseArgs({ stage: input.dossier.case.stage, blockScan: input.dossier.case.blockScan, + reportedBirthTime: input.dossier.case.reportedBirthTime, + candidateRange: input.dossier.case.candidateRange, }), }); const remainingCollect = exhaustionSpokenCollectFollowup({ @@ -716,6 +762,10 @@ export function decideAfterInferenceChange(input: { input.dossier.case.blockScan, evidenceLedgerFingerprint(input.dossier.evidence as never), ), + windowWidenSuggested: windowWidenSuggestedFromDossier( + input.dossier, + evidenceLedgerFingerprint(input.dossier.evidence as never), + ), }); } const training = input.state.events.filter((item) => item.usage === "training"); @@ -782,6 +832,10 @@ export function decideAfterInferenceChange(input: { input.dossier.case.blockScan, evidenceLedgerFingerprint(input.dossier.evidence as never), ), + windowWidenSuggested: windowWidenSuggestedFromDossier( + input.dossier, + evidenceLedgerFingerprint(input.dossier.evidence as never), + ), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), }; diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 57a2ae15..8e2fce06 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -748,10 +748,13 @@ export async function runV9BlockScan(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; + blocks?: readonly { period: string; start_time: string; end_time: string }[]; + minuteStep?: number; }): Promise { const data = await postEngine("/api/rectification/v5/block_scan", { ...engineRequestBody(input), - minute_step: 10, + minute_step: input.minuteStep ?? 10, + ...(input.blocks?.length ? { blocks: input.blocks } : {}), }); const parsed = parseBlockScanPayload({ blocks: data.blocks, diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index b2c13936..e5e42336 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -32,6 +32,7 @@ export function choiceCardFromCaseDossier(dossier: { occurredTo: string | null; eventKind?: string | null; summary?: string | null; + dateReliability?: string | null; }>[]; conversationSummary: { activeFocus: { @@ -64,6 +65,9 @@ export function choiceCardFromCaseDossier(dossier: { stage?: "minute" | "block_scan"; blockScan?: import("./block-scan.ts").BlockScanPayload | null; candidateRange?: { start_time?: string; end_time?: string } | null; + reportedBirthTime?: string | null; + birthTimeSource?: string | null; + widenDeclinedAtFingerprint?: string | null; }; turns?: readonly Readonly<{ role: string; text: string | null }>[]; }): RectificationChoiceCard | null { @@ -113,6 +117,8 @@ export function choiceCardFromCaseDossier(dossier: { ...followupCaseArgs({ stage: dossier.case.stage, blockScan: dossier.case.blockScan, + reportedBirthTime: dossier.case.reportedBirthTime, + candidateRange: dossier.case.candidateRange, }), }); } @@ -134,6 +140,8 @@ export function stepStateFromCaseDossier(dossier: Parameters>; + widen_windows?: Readonly>; + date_reliability_evidence_id?: string; }>; export type MethodFollowupPlan = Readonly<{ @@ -206,6 +215,8 @@ export type MethodFollowupEvidence = Readonly<{ eventKind?: string | null; id?: string; summary?: string | null; + dateReliability?: string | null; + sourceTurnId?: string | null; }>; export type MethodFollowupFocus = Readonly<{ @@ -1010,7 +1021,7 @@ function followupOwnedProbe( if (!item.style_options?.length) return null; if (!item.domain || !EVENT_PROBE_DOMAINS.includes(item.domain as EventProbeDomain)) return null; const kind = item.choice_kind ?? "existence"; - if (kind === "block_choice") return null; + if (kind === "block_choice" || kind === "widen_window") return null; const styleOptions: EventProbeStyleOption[] = []; for (const row of item.style_options) { const answer = row.answer_class; @@ -1179,6 +1190,7 @@ export function shouldAttachChoiceFrame( export function spokenFollowupForUser(followup: MethodFollowup | null): string | null { if (!followup) return null; if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null; + if (followup.date_reliability_evidence_id) return DATE_RELIABILITY_PROMPT; if (followup.intent !== "collect_method_evidence") return null; const domain = followup.domain ?? ""; const openingOther = followup.source === "method_coverage" @@ -1258,9 +1270,10 @@ const REMAINING_EVIDENCE_COLLECT_DOMAINS = new Set([ /** Spoken collect that can still add distinguishing evidence. Not `other` / horary. */ export function isRemainingEvidenceCollect( - followup: Pick | null | undefined, + followup: Pick | null | undefined, ): boolean { if (!followup || followup.intent !== "collect_method_evidence") return false; + if (followup.date_reliability_evidence_id) return false; return typeof followup.domain === "string" && REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain); } @@ -1361,6 +1374,7 @@ export type NextUserActionId = | "ask_candidate_discriminator" | "ask_holdout_validation" | "ask_block_choice" + | "ask_window_widen" | "offer_provisional_range" | "explain_current_window" | "verify_adopted_time" @@ -1370,7 +1384,7 @@ export type NextUserAction = Readonly<{ id: NextUserActionId; user_meaning: string; on_user_stop: { - id: Exclude; + id: Exclude; user_meaning: string; }; }>; @@ -1409,7 +1423,7 @@ export function isOfferBlockingFollowup( function discriminatorFromFollowup(followup: MethodFollowup | null): CandidateDiscriminatorProbe | null { if (!followup) return null; - if (followup.choice_kind === "block_choice") return null; + if (followup.choice_kind === "block_choice" || followup.choice_kind === "widen_window") return null; if (followup.intent === "clarify_event") return null; if (followup.choice_kind === "event_quality" && followup.intent !== "distinguish_candidates") return null; const realProbe = followup.source === "event_probe" @@ -1593,6 +1607,16 @@ export function buildNextUserAction(input: { ), }; } + if (input.sessionOutcome === "widen_window" && input.nextFollowup) { + return { + id: "ask_window_widen", + user_meaning: input.nextFollowup.user_prompt_hint, + on_user_stop: action( + "explain_current_window", + "不放宽就按现在的范围继续。不要采用时间卡。", + ), + }; + } const scoreNow = action( "score_now", "已有可评分事件但还没有候选结果。本轮必须比较候选,不要只口头确认事件。", @@ -1670,6 +1694,62 @@ export function holdoutFollowupFor( return holdoutAskFields(prompt, reserved); } +function widenWindowFollowup(input: { + reportedTime: string; + currentWindow: { start_time: string; end_time: string }; +}): MethodFollowup | null { + const frame = buildWidenWindowFrame(input); + const windows = widenWindowsForChoice(input); + if (!frame || !windows) return null; + return { + method_id: "dasha_events", + intent: WIDEN_WINDOW_INTENT, + ask_theme: "widen_window", + domain: null, + kind_hint: null, + user_prompt_hint: frame.prompt, + must_not_label: false, + choice_frame: frame, + source: "window_widen", + choice_kind: WIDEN_WINDOW_KIND, + semantic_key: "widen_window.search_range", + widen_windows: windows, + }; +} + +function dateReliabilityFollowup( + evidence: readonly MethodFollowupEvidence[], + currentTurnId?: string | null, +): MethodFollowup | null { + const pending = pendingDateReliabilityEvidence(evidence, currentTurnId); + if (!pending) return null; + const row = evidence.find((item) => item.id === pending.id); + return { + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain: row?.domain ?? "other", + kind_hint: row?.eventKind ?? null, + user_prompt_hint: DATE_RELIABILITY_PROMPT, + must_not_label: false, + choice_frame: null, + source: "method_coverage", + semantic_key: `date_reliability.${pending.id}`, + date_reliability_evidence_id: pending.id, + }; +} + +export function planWithDateReliability( + plan: MethodFollowupPlan, + evidence: readonly MethodFollowupEvidence[], + currentTurnId?: string | null, +): MethodFollowupPlan { + if (plan.session_outcome !== "collect_evidence") return plan; + const reliability = dateReliabilityFollowup(evidence, currentTurnId); + if (!reliability) return plan; + return { ...plan, next_followup: reliability }; +} + function blockChoiceFollowup(payload: BlockScanPayload | null | undefined): MethodFollowup | null { if (!payload) return null; const frame = buildBlockChoiceFrame(payload); @@ -1716,6 +1796,8 @@ export function buildMethodFollowupPlan(input: { candidateRanges?: readonly ProbeExplainCandidate[]; caseStage?: RectificationCaseStage; blockScan?: BlockScanPayload | null; + reportedTime?: string | null; + candidateRange?: { start_time: string; end_time: string } | null; }): MethodFollowupPlan { const makeFollowup = ( item: Omit, @@ -2529,6 +2611,12 @@ export function buildMethodFollowupPlan(input: { if (input.caseStage === "block_scan" && sessionOutcome === "compare_blocks") { next = blockChoiceFollowup(input.blockScan); } + if (sessionOutcome === "widen_window" && input.reportedTime && input.candidateRange) { + next = widenWindowFollowup({ + reportedTime: input.reportedTime, + currentWindow: input.candidateRange, + }); + } const deferAdoption = sessionOutcome === "adopt_representative" || sessionOutcome === "validated_range" || sessionOutcome === "exact_minute_confirmed" diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index 1fc6e948..b15fe064 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -762,6 +762,7 @@ export type EventFitRate = Readonly<{ matched: number; total: number; percent: number | null; + band: "high" | "medium" | "low" | "insufficient" | null; label: string; user_meaning: string; unique_minute_claim: false; @@ -774,10 +775,14 @@ export function parseEventFitRate(value: unknown): EventFitRate | null { const total = typeof row.total === "number" && Number.isInteger(row.total) ? row.total : null; if (matched === null || total === null) return null; const percent = typeof row.percent === "number" && Number.isFinite(row.percent) ? row.percent : null; + const band = row.band === "high" || row.band === "medium" || row.band === "low" || row.band === "insufficient" + ? row.band + : null; return { matched, total, percent, + band, label: asText(row.label, 80) ?? "事件吻合率", user_meaning: asText(row.user_meaning, 280) ?? "这是相对拟合,不是已确认唯一出生分钟。", diff --git a/frontend/src/lib/rectification-agentic/v9/search-window.ts b/frontend/src/lib/rectification-agentic/v9/search-window.ts new file mode 100644 index 00000000..6cbae3bc --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/search-window.ts @@ -0,0 +1,214 @@ +/** + * Search-window arithmetic for declared uncertainty, sub-block scans, and widen. + * + * Exclusive span (end − start) decides minute vs block_scan. + * Inclusive width (span + 1) is the SQL widen cap of 241 minutes (±120). + */ + +import { + clockToMinutes, + isBirthClockText, + minutesToClock, +} from "../../declared-birth-window.ts"; + +export const ENGINE_SEARCH_RADIUS_MINUTES = 15; +export const MAX_DECLARED_RADIUS_MINUTES = 120; +export const MINUTE_GRID_MAX_SPAN_MINUTES = 120; +export const WIDEN_MAX_INCLUSIVE_MINUTES = 241; +export const EDGE_PROXIMITY_MINUTES = 3; +export const MAX_SUB_BLOCK_ROUNDS = 3; +export const APPROXIMATE_RADII = [15, 30, 60, 120] as const; +export type ApproximateRadius = (typeof APPROXIMATE_RADII)[number]; + +export const SUB_BLOCK_LABELS = ["sub_1", "sub_2", "sub_3"] as const; +export type SubBlockLabel = (typeof SUB_BLOCK_LABELS)[number]; + +const MINUTES_PER_DAY = 24 * 60; + +export type ClockWindow = Readonly<{ start_time: string; end_time: string }>; + +export function isApproximateRadius(value: number | null | undefined): value is ApproximateRadius { + return value === 15 || value === 30 || value === 60 || value === 120; +} + +export function shiftClock(time: string, offsetMinutes: number): string { + return minutesToClock(clockToMinutes(time) + offsetMinutes); +} + +/** Exclusive span; 04:00–06:00 = 120, 03:00–07:00 = 240, 00:00–23:59 = 1439. */ +export function clockSpanMinutes(start: string, end: string): number { + const from = clockToMinutes(start); + const to = clockToMinutes(end); + return to >= from ? to - from : MINUTES_PER_DAY - from + to; +} + +export function clockInclusiveWidth(start: string, end: string): number { + return clockSpanMinutes(start, end) + 1; +} + +export function stageForWindow(widthMinutes: number): "minute" | "block_scan" { + return widthMinutes > MINUTE_GRID_MAX_SPAN_MINUTES ? "block_scan" : "minute"; +} + +export function stageForClockWindow(window: ClockWindow): "minute" | "block_scan" { + return stageForWindow(clockSpanMinutes(window.start_time, window.end_time)); +} + +export function adaptiveMinuteStep(widthMinutes: number): 2 | 5 | 10 { + if (widthMinutes > 360) return 10; + if (widthMinutes > 180) return 5; + return 2; +} + +export function clockContains( + outer: ClockWindow, + inner: ClockWindow, +): boolean { + const outerWidth = clockInclusiveWidth(outer.start_time, outer.end_time); + if (outerWidth >= MINUTES_PER_DAY) return true; + const origin = clockToMinutes(outer.start_time); + const rotate = (clock: string) => ( + (clockToMinutes(clock) - origin + MINUTES_PER_DAY) % MINUTES_PER_DAY + ); + const innerStart = rotate(inner.start_time); + const innerEnd = rotate(inner.end_time); + const outerLast = outerWidth - 1; + if (innerStart <= innerEnd) return innerStart <= outerLast && innerEnd <= outerLast; + return false; +} + +export function minutesToWindowEdge( + time: string, + window: ClockWindow, +): number { + const current = clockToMinutes(time); + const start = clockToMinutes(window.start_time); + const end = clockToMinutes(window.end_time); + const dist = (left: number, right: number) => { + const delta = Math.abs(left - right); + return Math.min(delta, MINUTES_PER_DAY - delta); + }; + return Math.min(dist(current, start), dist(current, end)); +} + +export function representativeNearWindowEdge( + time: string | null | undefined, + window: ClockWindow | null | undefined, + proximity = EDGE_PROXIMITY_MINUTES, +): boolean { + if (!time || !window || !isBirthClockText(time)) return false; + if (!isBirthClockText(window.start_time) || !isBirthClockText(window.end_time)) return false; + return minutesToWindowEdge(time, window) <= proximity; +} + +export function clampDeclaredRadius(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return ENGINE_SEARCH_RADIUS_MINUTES; + return Math.min(MAX_DECLARED_RADIUS_MINUTES, Math.max(ENGINE_SEARCH_RADIUS_MINUTES, Math.round(value))); +} + +export function radiusFromReportedWindow( + reportedTime: string, + window: ClockWindow, +): number { + const reported = clockToMinutes(reportedTime); + const start = clockToMinutes(window.start_time); + const end = clockToMinutes(window.end_time); + const before = (reported - start + MINUTES_PER_DAY) % MINUTES_PER_DAY; + const after = (end - reported + MINUTES_PER_DAY) % MINUTES_PER_DAY; + return Math.max(before, after); +} + +export function snapWidenRadius(radius: number): ApproximateRadius { + if (radius <= 15) return 15; + if (radius <= 30) return 30; + if (radius <= 60) return 60; + return 120; +} + +export function nextWidenRadii(current: ApproximateRadius): { + primary: ApproximateRadius | null; + secondary: ApproximateRadius | null; +} { + if (current === 15) return { primary: 30, secondary: 60 }; + if (current === 30) return { primary: 60, secondary: 120 }; + if (current === 60) return { primary: 120, secondary: null }; + return { primary: null, secondary: null }; +} + +export function windowAroundReportedTime(input: { + reportedTime: string; + beforeMinutes: number; + afterMinutes: number; +}): ClockWindow { + return { + start_time: shiftClock(input.reportedTime, -input.beforeMinutes), + end_time: shiftClock(input.reportedTime, input.afterMinutes), + }; +} + +export function deriveDeclaredSearchWindow(input: { + reportedTime: string | null; + source: string; + uncertaintyBefore: number | null; + uncertaintyAfter: number | null; +}): ClockWindow | null { + if (!input.reportedTime) return null; + if (input.source === "approximate") { + return windowAroundReportedTime({ + reportedTime: input.reportedTime, + beforeMinutes: clampDeclaredRadius(input.uncertaintyBefore), + afterMinutes: clampDeclaredRadius(input.uncertaintyAfter), + }); + } + return windowAroundReportedTime({ + reportedTime: input.reportedTime, + beforeMinutes: ENGINE_SEARCH_RADIUS_MINUTES, + afterMinutes: ENGINE_SEARCH_RADIUS_MINUTES, + }); +} + +export type SubBlockWindow = ClockWindow & Readonly<{ period: SubBlockLabel }>; + +/** + * Three adjacent inclusive blocks covering `window` without shared minutes. + */ +export function splitWindowIntoThirds(window: ClockWindow): readonly [SubBlockWindow, SubBlockWindow, SubBlockWindow] { + const start = clockToMinutes(window.start_time); + const span = clockSpanMinutes(window.start_time, window.end_time); + const first = Math.floor(span / 3); + const second = Math.floor((span * 2) / 3); + const endOf = (offset: number) => minutesToClock(start + Math.max(0, offset) - (offset > 0 ? 1 : 0)); + const startOf = (offset: number) => minutesToClock(start + offset); + return [ + { period: "sub_1", start_time: window.start_time, end_time: endOf(first) }, + { period: "sub_2", start_time: startOf(first), end_time: endOf(second) }, + { period: "sub_3", start_time: startOf(second), end_time: window.end_time }, + ]; +} + +export function isFullDayWindow(window: ClockWindow): boolean { + return window.start_time === "00:00" && window.end_time === "23:59"; +} + +export function blockScanRequestExtras(window: ClockWindow): { + blocks?: readonly { period: SubBlockLabel; start_time: string; end_time: string }[]; + minuteStep: 2 | 5 | 10; +} { + if (isFullDayWindow(window)) return { minuteStep: 10 }; + const span = clockSpanMinutes(window.start_time, window.end_time); + return { + blocks: splitWindowIntoThirds(window).map((block) => ({ + period: block.period, + start_time: block.start_time, + end_time: block.end_time, + })), + minuteStep: adaptiveMinuteStep(span), + }; +} + +export function radiusPhrase(radius: ApproximateRadius): string { + if (radius === 15) return "一刻钟"; + if (radius === 30) return "半小时"; + if (radius === 60) return "一小时"; + return "两小时"; +} diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 5c9029cf..b3403b96 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -97,6 +97,7 @@ export function expectedAnswerSchemaFor( choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence", scoring: frame.scoring, ...(followup.block_periods ? { block_periods: followup.block_periods } : {}), + ...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}), }; const receipt = decisionReceipt ?? null; const state = withNakshatraBoundaryProbe( @@ -250,6 +251,10 @@ export function collectFocusSchema(followup: MethodFollowup): Record { + const row = await rpc>( + accounting, + "widen_agentic_rectification_case_window", + { + p_user_id: userId, + p_case_id: caseId, + p_start_time: window.start_time, + p_end_time: window.end_time, + }, + ); + const range = rowObject(row.candidate_range); + const start = typeof range?.start_time === "string" ? range.start_time : window.start_time; + const end = typeof range?.end_time === "string" ? range.end_time : window.end_time; + return { start_time: start, end_time: end }; +} + +export async function setV9CaseStage( + accounting: AccountingClient, + userId: string, + caseId: string, + stage: RectificationCaseStage, +): Promise { + await rpc(accounting, "set_agentic_rectification_case_stage", { + p_user_id: userId, + p_case_id: caseId, + p_stage: stage, + }); +} + +export async function setV9WidenDeclined( + accounting: AccountingClient, + userId: string, + caseId: string, + fingerprint: string, +): Promise { + await rpc(accounting, "set_agentic_rectification_widen_declined", { + p_user_id: userId, + p_case_id: caseId, + p_fingerprint: fingerprint, + }); +} + +export async function setV9EvidenceDateReliability( + accounting: AccountingClient, + userId: string, + caseId: string, + evidenceId: string, + reliability: "high" | "medium", +): Promise { + await rpc(accounting, "set_agentic_rectification_evidence_date_reliability", { + p_user_id: userId, + p_case_id: caseId, + p_evidence_id: evidenceId, + p_date_reliability: reliability, + }); +} + +export async function patchV9BirthUncertainty( + accounting: AccountingClient & { + from?: (table: string) => { + update: (values: Record) => { + eq: (column: string, value: string) => PromiseLike<{ error: { message: string } | null }>; + }; + }; + }, + userId: string, + patch: { + birth_time_source: "approximate"; + uncertainty_before_minutes: number; + uncertainty_after_minutes: number; + }, +): Promise { + if (typeof accounting.from !== "function") { + throw new RectificationToolServiceError("profile_patch_unavailable"); + } + const result = await accounting.from("profiles").update({ + birth_time_source: patch.birth_time_source, + uncertainty_before_minutes: patch.uncertainty_before_minutes, + uncertainty_after_minutes: patch.uncertainty_after_minutes, + }).eq("id", userId); + if (result.error) throw new RectificationToolServiceError(result.error.message); +} + function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot | null { const row = rowObject(value); const inference = asInferenceState(row?.inference_state); diff --git a/frontend/src/lib/rectification-agentic/v9/window-widen.ts b/frontend/src/lib/rectification-agentic/v9/window-widen.ts new file mode 100644 index 00000000..3848c2b4 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/window-widen.ts @@ -0,0 +1,157 @@ +/** + * Server-owned widen-window card. TypeScript-internal; never enters the + * Python probe contract. A/B enlarge the search window; C/D decline for + * the current evidence fingerprint. + */ + +import type { AnswerClass } from "../core/types.ts"; +import { + CHOICE_MODE, + CHOICE_STOP_LABEL, + CHOICE_STOP_MESSAGE, + type ChoiceKey, + type RectificationChoiceFrame, +} from "./choice-card.ts"; +import { WIDEN_WINDOW_KIND, WIDEN_WINDOW_INTENT } from "./block-scan.ts"; +import { + MAX_DECLARED_RADIUS_MINUTES, + nextWidenRadii, + radiusFromReportedWindow, + radiusPhrase, + snapWidenRadius, + type ApproximateRadius, + type ClockWindow, + windowAroundReportedTime, +} from "./search-window.ts"; + +const WIDEN_ANSWER_CLASSES: readonly AnswerClass[] = ["yes", "weak_yes", "no", "unsure"]; + +export type WidenWindowOption = Readonly<{ + radius: ApproximateRadius | null; + start_time: string; + end_time: string; +}>; + +export function currentApproximateRadius( + reportedTime: string, + window: ClockWindow, +): ApproximateRadius { + return snapWidenRadius(radiusFromReportedWindow(reportedTime, window)); +} + +export function widenWindowsForChoice(input: { + reportedTime: string; + currentWindow: ClockWindow; +}): Readonly> | null { + const current = currentApproximateRadius(input.reportedTime, input.currentWindow); + const next = nextWidenRadii(current); + if (!next.primary) return null; + const around = (radius: ApproximateRadius): WidenWindowOption => { + const window = windowAroundReportedTime({ + reportedTime: input.reportedTime, + beforeMinutes: radius, + afterMinutes: radius, + }); + return { radius, ...window }; + }; + return { + A: around(next.primary), + B: next.secondary + ? around(next.secondary) + : { radius: null, start_time: input.currentWindow.start_time, end_time: input.currentWindow.end_time }, + }; +} + +export function buildWidenWindowFrame(input: { + reportedTime: string; + currentWindow: ClockWindow; +}): RectificationChoiceFrame | null { + const windows = widenWindowsForChoice(input); + if (!windows) return null; + const current = currentApproximateRadius(input.reportedTime, input.currentWindow); + const next = nextWidenRadii(current); + if (!next.primary) return null; + const primaryLabel = `放宽到前后${radiusPhrase(next.primary)}`; + const secondaryLabel = next.secondary + ? `放宽到前后${radiusPhrase(next.secondary)}` + : "先补一件带月份的经历再说"; + const options = [ + { key: "A" as const, label: primaryLabel, answer_class: WIDEN_ANSWER_CLASSES[0]!, role: "primary" as const }, + { key: "B" as const, label: secondaryLabel, answer_class: next.secondary ? WIDEN_ANSWER_CLASSES[1]! : "no", role: "primary" as const }, + { key: "C" as const, label: "不放宽,按现在的范围继续", answer_class: WIDEN_ANSWER_CLASSES[2]!, role: "primary" as const }, + { key: "D" as const, label: "说不好 / 先这样", answer_class: WIDEN_ANSWER_CLASSES[3]!, role: "primary" as const }, + ]; + return { + question_id: "widen_window:search_range:holdout", + method_id: "dasha_events", + period: "", + prompt: "按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次?", + varga: null, + why: "按你说的经历,现在这个范围对不上。放宽后再比一次。", + option_a_hint: options[0]!.label, + option_b_hint: options[1]!.label, + neither_label: options[2]!.label, + unsure_label: options[3]!.label, + option_a_answer_class: options[0]!.answer_class, + option_b_answer_class: options[1]!.answer_class, + option_c_answer_class: options[2]!.answer_class, + option_d_answer_class: options[3]!.answer_class, + choice_mode: CHOICE_MODE, + stop_label: CHOICE_STOP_LABEL, + stop_message: CHOICE_STOP_MESSAGE, + scoring: false, + why_user: "按你说的经历,现在这个范围对不上。放宽后再比一次。", + answer_impact: { + A: `选定后按前后${radiusPhrase(next.primary)}再比`, + B: next.secondary + ? `选定后按前后${radiusPhrase(next.secondary)}再比` + : "不改范围,先再收一件带月份的经历", + C: "保持现在的范围,继续按分钟比较", + D: "这次先不放宽,同一批经历不再问", + }, + choice_kind: WIDEN_WINDOW_KIND, + }; +} + +export function windowFromWidenChoice(input: { + schema: Readonly>; + optionId: ChoiceKey | "stop" | "skip_probe"; +}): { start_time: string; end_time: string; radius: ApproximateRadius | null } | null { + if (input.optionId !== "A" && input.optionId !== "B") return null; + const windows = input.schema.widen_windows; + if (!windows || typeof windows !== "object" || Array.isArray(windows)) return null; + const row = (windows as Record)[input.optionId]; + if (!row || typeof row !== "object" || Array.isArray(row)) return null; + const record = row as Record; + const start = typeof record.start_time === "string" ? record.start_time.slice(0, 5) : ""; + const end = typeof record.end_time === "string" ? record.end_time.slice(0, 5) : ""; + if (!start || !end || start === end) return null; + const radius = record.radius; + if (radius !== 15 && radius !== 30 && radius !== 60 && radius !== 120 && radius !== null) { + return null; + } + return { start_time: start, end_time: end, radius }; +} + +export function shouldSuggestWindowWiden(input: { + trainingGateOpen: boolean; + fitBand: string | null | undefined; + fitTotal: number | null | undefined; + representativeNearEdge: boolean; + stage: string | null | undefined; + accepted: boolean; + reportedTime: string | null | undefined; + declinedForFingerprint: boolean; + currentRadius: number; +}): boolean { + if (!input.trainingGateOpen) return false; + if (input.fitBand !== "low" || (input.fitTotal ?? 0) < 3) return false; + if (!input.representativeNearEdge) return false; + if (input.stage !== "minute") return false; + if (input.accepted) return false; + if (!input.reportedTime) return false; + if (input.declinedForFingerprint) return false; + return input.currentRadius < MAX_DECLARED_RADIUS_MINUTES; +} + +export { WIDEN_WINDOW_INTENT }; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 32692dfe..08388229 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -34,6 +34,7 @@ import { acceptV9Candidate, confirmV9BirthTime, closeV9Case, + setV9CaseStage, safeToolErrorCode, scorableEvidence, RectificationToolServiceError, @@ -55,8 +56,9 @@ import { isHoldoutVerificationQuote, } from "@/lib/rectification-agentic/v9/choice-card"; import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau"; -import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan"; +import { followupCaseArgs, shouldForceMinuteAfterSubBlocks } from "@/lib/rectification-agentic/v9/block-scan"; import { persistBlockScanPayload } from "@/lib/rectification-agentic/v9/block-scan-answer"; +import { blockScanRequestExtras } from "@/lib/rectification-agentic/v9/search-window"; import { buildConfirmationGate, readVedastroMinuteSensitiveStatus, @@ -267,6 +269,8 @@ export function safeCaseProjection( ...followupCaseArgs({ stage: parsed.case.stage, blockScan: parsed.case.blockScan, + reportedBirthTime: parsed.case.reportedBirthTime, + candidateRange: parsed.case.candidateRange, }), }); const latestProjection = latest && parsed.case.stage !== "block_scan" @@ -340,6 +344,8 @@ type DossierForTools = { candidateRange: { start_time: string; end_time: string } | null; stage: import("@/lib/rectification-agentic/v9/block-scan").RectificationCaseStage; blockScan: import("@/lib/rectification-agentic/v9/block-scan").BlockScanPayload | null; + reportedBirthTime?: string | null; + birthTimeSource?: string | null; acceptedTime: string | null; confirmedTime: string | null; completedAt: string | null; @@ -612,6 +618,8 @@ function followupPlanForParsed( ...followupCaseArgs({ stage: parsed.case.stage, blockScan: parsed.case.blockScan, + reportedBirthTime: parsed.case.reportedBirthTime, + candidateRange: parsed.case.candidateRange, }), }), contrastPacket: catalog.contrastPacket, @@ -704,6 +712,8 @@ function parseDossierForTools(dossier: V9CaseDossier): DossierForTools { candidateRange: dossier.case.candidateRange, stage: dossier.case.stage, blockScan: dossier.case.blockScan, + reportedBirthTime: dossier.case.reportedBirthTime ?? null, + birthTimeSource: dossier.case.birthTimeSource ?? null, acceptedTime: dossier.case.acceptedTime, confirmedTime: dossier.case.confirmedTime, completedAt: dossier.case.completedAt, @@ -874,6 +884,13 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const candidateRange = parsed.case.candidateRange; const compute = await loadV9CaseCompute(accounting, userId, targetCaseId); const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence); + if ( + parsed.case.stage === "block_scan" + && shouldForceMinuteAfterSubBlocks(parsed.case.blockScan) + ) { + await setV9CaseStage(accounting, userId, targetCaseId, "minute"); + return scoreAndPersistCurrentEvidence(targetCaseId); + } if (parsed.case.stage === "block_scan") { const cachedPayload = parsed.case.blockScan; const cached = Boolean( @@ -882,11 +899,13 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { ); if (!cached) { const events = toEngineEvents(scorableEvidence(dossier.evidence)); + const extras = blockScanRequestExtras(candidateRange); const scoreStarted = Date.now(); const scan = await runV9BlockScan({ baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange, events, + ...extras, }); const engineCompareMs = Date.now() - scoreStarted; const persistStarted = Date.now(); @@ -896,6 +915,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { caseId: targetCaseId, evidenceFingerprint, scan, + previousRounds: cachedPayload?.rounds ?? 0, }); const reloaded = parseDossierForTools(await loadV9CaseDossier(accounting, userId, targetCaseId)); return { diff --git a/frontend/supabase/migrations/20260907020000_rectification_declared_uncertainty.sql b/frontend/supabase/migrations/20260907020000_rectification_declared_uncertainty.sql new file mode 100644 index 00000000..60b42d75 --- /dev/null +++ b/frontend/supabase/migrations/20260907020000_rectification_declared_uncertainty.sql @@ -0,0 +1,700 @@ +-- Declared uncertainty: sub-window block_scan, widen RPC, date reliability. +-- Exclusive span > 120 stays in block_scan; inclusive width cap for widen is 241. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'rectification_declared_uncertainty_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +alter table public.agentic_rectification_cases + add column if not exists widen_declined_at_fingerprint text; + +comment on column public.agentic_rectification_cases.widen_declined_at_fingerprint is + 'Evidence ledger fingerprint at which the user declined a widen-window card.'; + +alter table public.agentic_rectification_evidence + add column if not exists date_reliability text; + +alter table public.agentic_rectification_evidence + drop constraint if exists agentic_rectification_evidence_date_reliability_check; + +alter table public.agentic_rectification_evidence + add constraint agentic_rectification_evidence_date_reliability_check + check ( + date_reliability is null + or date_reliability in ('high', 'medium', 'low', 'uncertain', 'unreliable') + ); + +comment on column public.agentic_rectification_evidence.date_reliability is + 'Optional day-event reliability: high = checked record, medium = memory.'; + +create or replace function public.agentic_rectification_is_clock(p_clock text) +returns boolean +language sql +immutable +parallel safe +set search_path = '' +as $$ + select p_clock is not null + and p_clock ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'; +$$; + +create or replace function public.agentic_rectification_clock_minutes(p_clock text) +returns integer +language sql +immutable +strict +parallel safe +set search_path = '' +as $$ + select split_part(p_clock, ':', 1)::integer * 60 + split_part(p_clock, ':', 2)::integer; +$$; + +create or replace function public.agentic_rectification_clock_span_minutes( + p_start text, + p_end text +) +returns integer +language sql +immutable +strict +parallel safe +set search_path = '' +as $$ + select case + when public.agentic_rectification_clock_minutes(p_end) + >= public.agentic_rectification_clock_minutes(p_start) + then public.agentic_rectification_clock_minutes(p_end) + - public.agentic_rectification_clock_minutes(p_start) + else 1440 - public.agentic_rectification_clock_minutes(p_start) + + public.agentic_rectification_clock_minutes(p_end) + end; +$$; + +create or replace function public.agentic_rectification_clock_inclusive_width( + p_start text, + p_end text +) +returns integer +language sql +immutable +strict +parallel safe +set search_path = '' +as $$ + select public.agentic_rectification_clock_span_minutes(p_start, p_end) + 1; +$$; + +create or replace function public.agentic_rectification_clock_contains( + p_outer_start text, + p_outer_end text, + p_inner_start text, + p_inner_end text +) +returns boolean +language plpgsql +immutable +strict +parallel safe +set search_path = '' +as $$ +declare + v_outer_width integer; + v_origin integer; + v_inner_start integer; + v_inner_end integer; + v_outer_last integer; +begin + v_outer_width := public.agentic_rectification_clock_inclusive_width(p_outer_start, p_outer_end); + if v_outer_width >= 1440 then + return true; + end if; + v_origin := public.agentic_rectification_clock_minutes(p_outer_start); + v_inner_start := ( + public.agentic_rectification_clock_minutes(p_inner_start) - v_origin + 1440 + ) % 1440; + v_inner_end := ( + public.agentic_rectification_clock_minutes(p_inner_end) - v_origin + 1440 + ) % 1440; + v_outer_last := v_outer_width - 1; + if v_inner_start <= v_inner_end then + return v_inner_start <= v_outer_last and v_inner_end <= v_outer_last; + end if; + return false; +end; +$$; + +revoke all on function public.agentic_rectification_is_clock(text) + from public, anon, authenticated; +revoke all on function public.agentic_rectification_clock_minutes(text) + from public, anon, authenticated; +revoke all on function public.agentic_rectification_clock_span_minutes(text, text) + from public, anon, authenticated; +revoke all on function public.agentic_rectification_clock_inclusive_width(text, text) + from public, anon, authenticated; +revoke all on function public.agentic_rectification_clock_contains(text, text, text, text) + from public, anon, authenticated; +grant execute on function public.agentic_rectification_is_clock(text) to service_role; +grant execute on function public.agentic_rectification_clock_minutes(text) to service_role; +grant execute on function public.agentic_rectification_clock_span_minutes(text, text) to service_role; +grant execute on function public.agentic_rectification_clock_inclusive_width(text, text) to service_role; +grant execute on function public.agentic_rectification_clock_contains(text, text, text, text) to service_role; + +create or replace function public.set_agentic_rectification_case_stage( + p_user_id uuid, + p_case_id uuid, + p_stage text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null or p_stage not in ('minute', 'block_scan') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + update public.agentic_rectification_cases + set + stage = p_stage, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', p_stage + ); +end; +$$; + +create or replace function public.advance_agentic_rectification_case_from_block_scan( + p_user_id uuid, + p_case_id uuid, + p_start_time text, + p_end_time text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_range jsonb; + v_current_start text; + v_current_end text; + v_span integer; + v_rounds integer; + v_stage text; + v_block_scan jsonb; +begin + if p_user_id is null or p_case_id is null + or not public.agentic_rectification_is_clock(p_start_time) + or not public.agentic_rectification_is_clock(p_end_time) + or p_start_time = p_end_time then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_case.stage is distinct from 'block_scan' then + raise exception 'agentic_rectification_not_block_scan' using errcode = 'P0001'; + end if; + v_current_start := coalesce(v_case.candidate_range->>'start_time', ''); + v_current_end := coalesce(v_case.candidate_range->>'end_time', ''); + if not public.agentic_rectification_is_clock(v_current_start) + or not public.agentic_rectification_is_clock(v_current_end) then + raise exception 'agentic_rectification_invalid_range' using errcode = 'P0001'; + end if; + if not public.agentic_rectification_clock_contains( + v_current_start, v_current_end, p_start_time, p_end_time + ) then + raise exception 'agentic_rectification_invalid_block_window' using errcode = 'P0001'; + end if; + v_span := public.agentic_rectification_clock_span_minutes(p_start_time, p_end_time); + begin + v_rounds := coalesce((v_case.block_scan->>'rounds')::integer, 0); + exception + when invalid_text_representation then + v_rounds := 0; + end; + if v_rounds < 0 then + v_rounds := 0; + end if; + v_range := jsonb_build_object('start_time', p_start_time, 'end_time', p_end_time); + if v_span > 120 and v_rounds < 3 then + v_stage := 'block_scan'; + v_block_scan := jsonb_build_object('rounds', v_rounds); + else + v_stage := 'minute'; + v_block_scan := null; + end if; + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now() + where case_id = v_case.id + and invalidated_at is null; + update public.agentic_rectification_cases + set + candidate_range = v_range, + stage = v_stage, + block_scan = v_block_scan, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', v_stage, + 'candidate_range', v_range + ); +end; +$$; + +create or replace function public.widen_agentic_rectification_case_window( + p_user_id uuid, + p_case_id uuid, + p_start_time text, + p_end_time text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_range jsonb; + v_current_start text; + v_current_end text; + v_old_width integer; + v_new_width integer; +begin + if p_user_id is null or p_case_id is null + or not public.agentic_rectification_is_clock(p_start_time) + or not public.agentic_rectification_is_clock(p_end_time) + or p_start_time = p_end_time then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + if v_case.accepted_time is not null then + raise exception 'agentic_rectification_already_adopted' using errcode = 'P0001'; + end if; + if coalesce(v_case.stage, 'minute') is distinct from 'minute' then + raise exception 'agentic_rectification_not_minute' using errcode = 'P0001'; + end if; + v_current_start := coalesce(v_case.candidate_range->>'start_time', ''); + v_current_end := coalesce(v_case.candidate_range->>'end_time', ''); + if not public.agentic_rectification_is_clock(v_current_start) + or not public.agentic_rectification_is_clock(v_current_end) then + raise exception 'agentic_rectification_invalid_range' using errcode = 'P0001'; + end if; + if not public.agentic_rectification_clock_contains( + p_start_time, p_end_time, v_current_start, v_current_end + ) then + raise exception 'agentic_rectification_invalid_widen_window' using errcode = 'P0001'; + end if; + v_old_width := public.agentic_rectification_clock_inclusive_width(v_current_start, v_current_end); + v_new_width := public.agentic_rectification_clock_inclusive_width(p_start_time, p_end_time); + if v_new_width <= v_old_width or v_new_width > 241 then + raise exception 'agentic_rectification_invalid_widen_window' using errcode = 'P0001'; + end if; + v_range := jsonb_build_object('start_time', p_start_time, 'end_time', p_end_time); + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now() + where case_id = v_case.id + and invalidated_at is null; + update public.agentic_rectification_cases + set + candidate_range = v_range, + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'stage', 'minute', + 'candidate_range', v_range + ); +end; +$$; + +create or replace function public.set_agentic_rectification_widen_declined( + p_user_id uuid, + p_case_id uuid, + p_fingerprint text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null + or p_fingerprint is null or btrim(p_fingerprint) = '' then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + update public.agentic_rectification_cases + set + widen_declined_at_fingerprint = btrim(p_fingerprint), + last_activity_at = pg_catalog.now() + where id = v_case.id; + return jsonb_build_object( + 'case_id', v_case.id, + 'widen_declined_at_fingerprint', btrim(p_fingerprint) + ); +end; +$$; + +create or replace function public.set_agentic_rectification_evidence_date_reliability( + p_user_id uuid, + p_case_id uuid, + p_evidence_id uuid, + p_date_reliability text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_evidence public.agentic_rectification_evidence%rowtype; +begin + if p_user_id is null or p_case_id is null or p_evidence_id is null + or p_date_reliability not in ('high', 'medium') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + select * into v_evidence + from public.agentic_rectification_evidence + where id = p_evidence_id and case_id = v_case.id + for update; + if not found then + raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; + end if; + update public.agentic_rectification_evidence + set date_reliability = p_date_reliability + where id = v_evidence.id; + return jsonb_build_object( + 'evidence_id', v_evidence.id, + 'date_reliability', p_date_reliability + ); +end; +$$; + +revoke all on function public.widen_agentic_rectification_case_window(uuid, uuid, text, text) + from public, anon, authenticated; +grant execute on function public.widen_agentic_rectification_case_window(uuid, uuid, text, text) + to service_role; + +revoke all on function public.set_agentic_rectification_widen_declined(uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.set_agentic_rectification_widen_declined(uuid, uuid, text) + to service_role; + +revoke all on function public.set_agentic_rectification_evidence_date_reliability(uuid, uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.set_agentic_rectification_evidence_date_reliability(uuid, uuid, uuid, text) + to service_role; + +create or replace function public.get_agentic_rectification_case( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_evidence_count bigint; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select count(*) into v_evidence_count + from public.agentic_rectification_evidence + where case_id = v_case.id; + + select count(*) into v_turn_count + from public.agentic_rectification_turns + where case_id = v_case.id; + + select * into v_result + from public.agentic_rectification_results + where case_id = v_case.id + and invalidated_at is null + order by created_at desc + limit 1; + + return jsonb_build_object( + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'candidate_range', v_case.candidate_range, + 'stage', coalesce(v_case.stage, 'minute'), + 'block_scan', v_case.block_scan, + 'widen_declined_at_fingerprint', v_case.widen_declined_at_fingerprint, + 'reported_birth_time', v_case.baseline_birth_snapshot->>'reported_birth_time', + 'birth_time_source', v_case.baseline_birth_snapshot->>'birth_time_source', + 'accepted_time', v_case.accepted_time, + 'confirmed_time', v_case.confirmed_time, + 'created_at', v_case.created_at, + 'last_activity_at', v_case.last_activity_at, + 'completed_at', v_case.completed_at, + 'closed_reason', v_case.closed_reason, + 'evidence_count', v_evidence_count, + 'turn_count', v_turn_count, + 'latest_result', case + when v_result.id is null then null + else jsonb_build_object( + 'result_id', v_result.id, + 'candidates', v_result.candidates, + 'overall_confidence', v_result.overall_confidence, + 'display_allowed', v_result.display_allowed, + 'selection_allowed', v_result.selection_allowed, + 'confirmation_allowed', v_result.confirmation_allowed, + 'representative_time', v_result.representative_time, + 'selected_candidate_id', v_result.selected_candidate_id, + 'selected_time', v_result.selected_time, + 'selection_kind', v_result.selection_kind, + 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, + 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, + 'skill_version', v_result.skill_version, + 'algorithm_version', v_result.algorithm_version, + 'event_contract_version', v_result.event_contract_version, + 'decision_policy_version', v_result.decision_policy_version, + 'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt), + 'execution_ledger', v_result.execution_ledger, + 'created_at', v_result.created_at, + 'invalidated_at', v_result.invalidated_at + ) + end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_case(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_case(uuid, uuid) + to service_role; + +create or replace function public.get_agentic_rectification_case_dossier( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turns jsonb; + v_evidence jsonb; + v_summary public.agentic_rectification_case_conversation_summaries%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_evidence_count bigint; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select coalesce(jsonb_agg(jsonb_build_object( + 'id', recent.id, + 'role', message.role, + 'text', message.text, + 'status', recent.status, + 'created_at', recent.created_at, + 'completed_at', recent.completed_at + ) order by recent.created_at, recent.id, message.ordinal), '[]'::jsonb) + into v_turns + from ( + select t.* + from public.agentic_rectification_turns t + where t.case_id = v_case.id + order by t.created_at desc, t.id desc + limit 50 + ) recent + cross join lateral ( + values + (1, 'user'::text, recent.user_message), + (2, 'assistant'::text, recent.assistant_message) + ) as message(ordinal, role, text) + where message.text is not null; + + select coalesce(jsonb_agg(jsonb_build_object( + 'id', e.id, + 'source_turn_id', e.source_turn_id, + 'subject', e.subject, + 'event_kind', e.event_kind, + 'domain', e.domain, + 'occurred_from', e.occurred_from, + 'occurred_to', e.occurred_to, + 'date_precision', e.date_precision, + 'date_reliability', e.date_reliability, + 'summary', e.summary, + 'status', e.status, + 'supersedes_evidence_id', e.supersedes_evidence_id, + 'created_at', e.created_at + ) order by e.created_at, e.id), '[]'::jsonb) + into v_evidence + from public.agentic_rectification_evidence e + where e.case_id = v_case.id; + + select count(*) into v_evidence_count + from public.agentic_rectification_evidence where case_id = v_case.id; + select count(*) into v_turn_count + from public.agentic_rectification_turns where case_id = v_case.id; + + select * into v_summary + from public.agentic_rectification_case_conversation_summaries + where case_id = v_case.id; + if not found then + perform public.refresh_agentic_rectification_case_conversation_summary(v_case.id); + select * into v_summary + from public.agentic_rectification_case_conversation_summaries + where case_id = v_case.id; + end if; + + select * into v_result + from public.agentic_rectification_results + where case_id = v_case.id and invalidated_at is null + order by created_at desc, id desc + limit 1; + + return jsonb_build_object( + 'case', jsonb_build_object( + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'candidate_range', v_case.candidate_range, + 'stage', coalesce(v_case.stage, 'minute'), + 'block_scan', v_case.block_scan, + 'widen_declined_at_fingerprint', v_case.widen_declined_at_fingerprint, + 'reported_birth_time', v_case.baseline_birth_snapshot->>'reported_birth_time', + 'birth_time_source', v_case.baseline_birth_snapshot->>'birth_time_source', + 'accepted_time', v_case.accepted_time, + 'confirmed_time', v_case.confirmed_time, + 'completed_at', v_case.completed_at, + 'closed_reason', v_case.closed_reason, + 'last_activity_at', v_case.last_activity_at, + 'evidence_count', v_evidence_count, + 'turn_count', v_turn_count + ), + 'turns', v_turns, + 'evidence', v_evidence, + 'conversation_summary', jsonb_build_object( + 'confirmed_evidence_summary', v_summary.confirmed_evidence_summary, + 'pending_revisions', v_summary.pending_revisions, + 'active_focus', v_summary.active_focus, + 'declined_skipped_topics', v_summary.declined_skipped_topics, + 'candidate_divergence_summary', v_summary.candidate_divergence_summary, + 'missing_evidence_categories', v_summary.missing_evidence_categories, + 'last_result_policy', v_summary.last_result_policy, + 'summary_version', v_summary.summary_version, + 'updated_at', v_summary.updated_at + ), + 'latest_result', case when v_result.id is null then null else jsonb_build_object( + 'result_id', v_result.id, + 'candidates', v_result.candidates, + 'overall_confidence', v_result.overall_confidence, + 'display_allowed', v_result.display_allowed, + 'selection_allowed', v_result.selection_allowed, + 'confirmation_allowed', v_result.confirmation_allowed, + 'representative_time', v_result.representative_time, + 'selected_candidate_id', v_result.selected_candidate_id, + 'selected_time', v_result.selected_time, + 'selection_kind', v_result.selection_kind, + 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, + 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, + 'skill_version', v_result.skill_version, + 'algorithm_version', v_result.algorithm_version, + 'event_contract_version', v_result.event_contract_version, + 'decision_policy_version', v_result.decision_policy_version, + 'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt), + 'execution_ledger', v_result.execution_ledger, + 'created_at', v_result.created_at, + 'invalidated_at', v_result.invalidated_at + ) end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid) + to service_role; + +commit; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index dcfb6516..e308e4ad 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -79,6 +79,18 @@ test("profile patch schema validates calendar, clock, source requirements, and l assert.equal(accountProfilePatchSchema.safeParse({ ...valid, birth_date: "2001-02-29" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, reported_birth_time: "24:00" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, uncertainty_before_minutes: 7, uncertainty_after_minutes: 7 }).success, false); + assert.equal(accountProfilePatchSchema.safeParse({ + ...valid, + birth_time_source: "approximate", + uncertainty_before_minutes: 120, + uncertainty_after_minutes: 120, + }).success, true); + assert.equal(accountProfilePatchSchema.safeParse({ + ...valid, + birth_time_source: "approximate", + uncertainty_before_minutes: 90, + uncertainty_after_minutes: 90, + }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false); diff --git a/frontend/tests/birth-time-consultation-consent.test.ts b/frontend/tests/birth-time-consultation-consent.test.ts index 9c15d39c..3843939c 100644 --- a/frontend/tests/birth-time-consultation-consent.test.ts +++ b/frontend/tests/birth-time-consultation-consent.test.ts @@ -209,17 +209,17 @@ test("unverified birth time no longer emits a modal or toast gate", () => { assert.doesNotMatch(page, /role="alertdialog"[\s\S]{0,300}出生时间还没有完成校正/); }); -test("birth time intake starts with exact or uncertain choices and keeps rectification optional", () => { +test("birth time intake asks how sure the time is, with three source rows", () => { const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); const model = readFileSync(new URL("../src/lib/birth-time-intake-model.ts", import.meta.url), "utf8"); - assert.match(model, /我知道准确出生时间/); - assert.match(model, /我不确定准确时间/); - assert.match(intake, /你对出生时间了解多少?/); - assert.match(intake, /不用猜具体分钟/); + assert.match(model, /有出生证或医院记录,精确到分钟/); + assert.match(model, /家人记得大概时间/); + assert.match(model, /只知道大概时段,或完全不知道/); + assert.match(intake, /你对这个时间有多确定?/); assert.match(intake, /aria-label=\{ariaLabel\}/); assert.match(intake, /ariaLabel = "选择出生时间"/); - assert.doesNotMatch(intake, /source === "family_exact" \|\| source === "approximate"/); + assert.match(intake, /source === "family_exact" \|\| source === "approximate"/); assert.match(intake, /选择你记得的开始和结束时间/); assert.match(intake, /完全不清楚,跳过出生时间/); assert.match(intake, /可以直接开始生时校正:先从你记得的经历比出大致时段/); diff --git a/frontend/tests/birth-time-intake.test.ts b/frontend/tests/birth-time-intake.test.ts index c8815fc4..3620c6fe 100644 --- a/frontend/tests/birth-time-intake.test.ts +++ b/frontend/tests/birth-time-intake.test.ts @@ -8,6 +8,7 @@ import { birthTimeDisplayState, birthTimeDraftReadyHint, birthTimePersistenceValues, + approximateUncertaintyOptions, birthTimeSourceDefaults, birthTimeSourceOptions, describeBirthTimeDraft, @@ -66,7 +67,17 @@ test("birth time intake requires only the fields selected by the source", () => ...customWindow, declaredWindowEnd: "14:30", }), false); - assert.equal(isBirthTimeDraftReady(incompleteApproximate), true); + assert.equal(isBirthTimeDraftReady(incompleteApproximate), false); + assert.equal(isBirthTimeDraftReady({ + ...incompleteApproximate, + uncertaintyBeforeMinutes: 120, + uncertaintyAfterMinutes: 120, + }), true); + assert.equal(isBirthTimeDraftReady({ + ...incompleteApproximate, + uncertaintyBeforeMinutes: 90, + uncertaintyAfterMinutes: 90, + }), false); assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true); }); @@ -348,8 +359,12 @@ test("persisted birth dates normalize database ISO values without accepting inva test("fresh intake copy stays user-facing and explains the next missing field", () => { assert.equal( - birthTimeSourceOptions.find((option) => option.value === "family_exact")?.hint, - "按医院记录或家人记得的时间填写,精确到分钟", + birthTimeSourceOptions.find((option) => option.value === "hospital_record")?.hint, + "按记录填写时和分。系统会无感检查前后 2 分钟。", + ); + assert.equal( + birthTimeSourceOptions.find((option) => option.value === "approximate")?.label, + "家人记得大概时间", ); assert.equal( birthTimeSourceOptions.find((option) => option.value === "period_only")?.hint, @@ -357,13 +372,18 @@ test("fresh intake copy stays user-facing and explains the next missing field", ); assert.equal(birthTimeSourceOptions.every((option) => !/引擎确认|初始化填报/.test(option.hint)), true); assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, date: "" }), "请先选择出生日期"); - assert.equal(birthTimeDraftReadyHint(emptyDraft), "请选择你对出生时间了解多少"); + assert.equal(birthTimeDraftReadyHint(emptyDraft), "请选择你对这个时间有多确定"); assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, birthTimeSource: "family_exact", uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0, }), "请填写时和分"); + assert.equal(birthTimeDraftReadyHint({ + ...emptyDraft, + birthTimeSource: "approximate", + reportedTime: "14:30", + }), "请选择一个最接近的范围"); assert.equal(birthTimeDraftReadyHint({ ...emptyDraft, birthTimeSource: "period_only", @@ -376,13 +396,13 @@ test("fresh intake copy stays user-facing and explains the next missing field", }), "开始和结束不能是同一分钟"); }); -test("fresh intake preserves exact, approximate-period, and unknown-time paths without auto-confirming", () => { +test("fresh intake preserves hospital, approximate, and unknown-time paths without auto-confirming", () => { const source = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8"); assert.doesNotMatch(source, /引擎确认/); assert.deepEqual( birthTimeSourceOptions.map((option) => option.value), - ["family_exact", "period_only"], + ["hospital_record", "approximate", "period_only"], ); assert.doesNotMatch(source, /已用于当前排盘/); assert.doesNotMatch(source, /birthTimePeriodOptions/); @@ -390,6 +410,12 @@ test("fresh intake preserves exact, approximate-period, and unknown-time paths w assert.match(source, /选择你记得的开始和结束时间/); assert.match(source, /完全不清楚,跳过出生时间/); assert.match(source, /可以直接开始生时校正:先从你记得的经历比出大致时段/); + assert.match(source, /你对这个时间有多确定/); + assert.equal( + approximateUncertaintyOptions.find((option) => option.minutes === 120)?.label, + "前后两小时", + ); + assert.match(source, /approximateUncertaintyOptions/); assert.doesNotMatch(source, /我可以选一段时间范围/); assert.doesNotMatch(source, /生时校正以后需要时再做/); assert.doesNotMatch(source, /补充描述|请选择最接近的时间范围|我可以描述一个时间范围/); diff --git a/frontend/tests/database-rectification-block-scan.test.ts b/frontend/tests/database-rectification-block-scan.test.ts index 53d2771d..186c2cc6 100644 --- a/frontend/tests/database-rectification-block-scan.test.ts +++ b/frontend/tests/database-rectification-block-scan.test.ts @@ -150,37 +150,76 @@ test("block_scan RPCs are service_role-only and advance a declared period", { sk p_end_time: "11:59", }); assert.equal(advanced.error, null, rpcError(advanced.error)); - assert.equal((advanced.data as { stage?: string }).stage, "minute"); + assert.equal((advanced.data as { stage?: string }).stage, "block_scan"); assert.deepEqual((advanced.data as { candidate_range?: unknown }).candidate_range, { start_time: "08:00", end_time: "11:59", }); assert.equal( fixture.psql(`select stage from public.agentic_rectification_cases where id = '${caseId}'`), - "minute", - ); - assert.equal( - fixture.psql(`select block_scan is null from public.agentic_rectification_cases where id = '${caseId}'`), - "t", + "block_scan", ); - const invalidPeriod = await service.rpc("advance_agentic_rectification_case_from_block_scan", { + const outside = await service.rpc("advance_agentic_rectification_case_from_block_scan", { p_user_id: userId, p_case_id: caseId, p_start_time: "04:50", p_end_time: "05:10", }); - assert.match(rpcError(invalidPeriod.error), /agentic_rectification_not_block_scan|agentic_rectification_invalid_block_period/); + assert.match(rpcError(outside.error), /agentic_rectification_invalid_block_window/); + + const narrowed = await service.rpc("advance_agentic_rectification_case_from_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "08:00", + p_end_time: "09:00", + }); + assert.equal(narrowed.error, null, rpcError(narrowed.error)); + assert.equal((narrowed.data as { stage?: string }).stage, "minute"); + + const invalidPeriod = await service.rpc("advance_agentic_rectification_case_from_block_scan", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "08:00", + p_end_time: "09:00", + }); + assert.match(rpcError(invalidPeriod.error), /agentic_rectification_not_block_scan/); + + const widened = await service.rpc("widen_agentic_rectification_case_window", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "07:30", + p_end_time: "09:30", + }); + assert.equal(widened.error, null, rpcError(widened.error)); + assert.deepEqual((widened.data as { candidate_range?: unknown }).candidate_range, { + start_time: "07:30", + end_time: "09:30", + }); + assert.equal( + fixture.psql(`select adopted_credible_range is null from public.agentic_rectification_cases where id = '${caseId}'`), + "t", + ); + + const shrink = await service.rpc("widen_agentic_rectification_case_window", { + p_user_id: userId, + p_case_id: caseId, + p_start_time: "08:00", + p_end_time: "09:00", + }); + assert.match(rpcError(shrink.error), /agentic_rectification_invalid_widen_window/); const privileges = fixture.psql(` select concat_ws(':', has_function_privilege('service_role', 'public.set_agentic_rectification_case_stage(uuid,uuid,text)', 'EXECUTE'), has_function_privilege('anon', 'public.set_agentic_rectification_case_stage(uuid,uuid,text)', 'EXECUTE'), has_function_privilege('authenticated', 'public.write_agentic_rectification_block_scan(uuid,uuid,jsonb)', 'EXECUTE'), - has_function_privilege('service_role', 'public.advance_agentic_rectification_case_from_block_scan(uuid,uuid,text,text)', 'EXECUTE') + has_function_privilege('service_role', 'public.advance_agentic_rectification_case_from_block_scan(uuid,uuid,text,text)', 'EXECUTE'), + has_function_privilege('service_role', 'public.widen_agentic_rectification_case_window(uuid,uuid,text,text)', 'EXECUTE'), + has_function_privilege('anon', 'public.widen_agentic_rectification_case_window(uuid,uuid,text,text)', 'EXECUTE') ) `); - assert.equal(privileges, "t:f:f:t"); + assert.equal(privileges, "t:f:f:t:t:f"); } finally { try { await closeLocalPostgresDataPool( diff --git a/frontend/tests/rectification-block-scan-20260906.test.ts b/frontend/tests/rectification-block-scan-20260906.test.ts index 86212cdc..f3a4f4fc 100644 --- a/frontend/tests/rectification-block-scan-20260906.test.ts +++ b/frontend/tests/rectification-block-scan-20260906.test.ts @@ -15,6 +15,7 @@ import { serializeBlockScanPayload, windowFromBlockChoice, } from "../src/lib/rectification-agentic/v9/block-scan.ts"; +import { splitWindowIntoThirds } from "../src/lib/rectification-agentic/v9/search-window.ts"; import { CASE_ID, FOCUS_ID, @@ -246,7 +247,7 @@ test("declined block_scan fingerprint returns collect instead of another card", assert.equal(decision.canAdopt, false); }); -test("choosing B advances the declared period and does not keep the 24h snapshot current", async () => { +test("choosing afternoon from a full-day scan stays in block_scan for a window wider than 120 minutes", async () => { let current = blockDossier(); const accounting = fakeAccounting({ ...receiptHandlers, @@ -285,14 +286,23 @@ test("choosing B advances the declared period and does not keep the 24h snapshot }, idempotent: false, }), + write_agentic_rectification_block_scan: (_fn, args) => { + current = blockDossier({ + stage: "block_scan", + range: current.case.candidate_range as { start_time: string; end_time: string }, + blockScan: args.p_block_scan, + }); + return { case_id: CASE_ID, stage: "block_scan", block_scan: args.p_block_scan }; + }, advance_agentic_rectification_case_from_block_scan: (_fn, args) => { current = blockDossier({ - stage: "minute", + stage: "block_scan", range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) }, + blockScan: { rounds: 0, blocks: [] }, }); return { case_id: CASE_ID, - stage: "minute", + stage: "block_scan", candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time }, }; }, @@ -313,7 +323,7 @@ test("choosing B advances the declared period and does not keep the 24h snapshot assert.equal(receipt.snapshotCurrent, false); assert.equal(receipt.nextAction.can_adopt, false); const parsed = parseV9CaseDossier(current); - assert.equal(parsed?.case.stage, "minute"); + assert.equal(parsed?.case.stage, "block_scan"); assert.deepEqual(parsed?.case.candidateRange, { start_time: "12:00", end_time: "17:59" }); assert.ok(accounting.calls.some((item) => item.fn === "advance_agentic_rectification_case_from_block_scan")); }); @@ -392,3 +402,70 @@ test("existing minute-stage cases keep the minute interview", () => { assert.notEqual(decision.nextAction, "ask_block_choice"); assert.notEqual(decision.sessionOutcome, "compare_blocks"); }); + +test("period_only evening and approximate ±120 open as block_scan, custom 30 minutes stay minute", () => { + assert.deepEqual( + deriveRectificationOpenWindow({ + reportedTime: null, + source: "period_only", + period: "evening", + windowStart: null, + windowEnd: null, + }), + { + candidateRange: { start_time: "18:00", end_time: "22:59" }, + stage: "block_scan", + }, + ); + assert.deepEqual( + deriveRectificationOpenWindow({ + reportedTime: "05:00", + source: "approximate", + period: null, + windowStart: null, + windowEnd: null, + uncertaintyBefore: 120, + uncertaintyAfter: 120, + }), + { + candidateRange: { start_time: "03:00", end_time: "07:00" }, + stage: "block_scan", + }, + ); + assert.equal( + deriveRectificationOpenWindow({ + reportedTime: null, + source: "period_only", + period: null, + windowStart: "14:30", + windowEnd: "15:00", + }).stage, + "minute", + ); +}); + +test("advance stubs with empty blocks still parse rounds", () => { + const parsed = parseBlockScanPayload({ rounds: 0, blocks: [] }); + assert.equal(parsed?.rounds, 0); + assert.deepEqual(parsed?.blocks, []); + assert.equal(parseBlockScanPayload({ blocks: [] }), null); +}); + +test("evening thirds become a sub-block card with clock labels", () => { + const thirds = splitWindowIntoThirds({ start_time: "18:00", end_time: "22:59" }); + const payload = parseBlockScanPayload({ + rounds: 1, + blocks: thirds.map((block, index) => ({ + period: block.period, + start_time: block.start_time, + end_time: block.end_time, + relative_support: [40, 35, 25][index], + })), + }); + assert.ok(payload); + const frame = buildBlockChoiceFrame(payload); + assert.ok(frame); + assert.match(frame.prompt, /更像出生时间/); + assert.match(frame.option_a_hint ?? "", /18:00/); + assert.doesNotMatch(frame.option_a_hint ?? "", /傍晚到晚上/); +}); diff --git a/frontend/tests/rectification-collect-date-reliability-20260907.test.ts b/frontend/tests/rectification-collect-date-reliability-20260907.test.ts new file mode 100644 index 00000000..92d509fa --- /dev/null +++ b/frontend/tests/rectification-collect-date-reliability-20260907.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DATE_RELIABILITY_PROMPT, + classifyDateReliabilityUtterance, + pendingDateReliabilityEvidence, +} from "../src/lib/rectification-agentic/v9/date-reliability.ts"; +import { + buildMethodFollowupPlan, + planWithDateReliability, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; + +const TURN_ID = "33333333-3333-4333-8333-333333333333"; + +const monthEvent = { + id: "ev-month", + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + eventKind: "education_start", + sourceTurnId: TURN_ID, +}; + +const dayEvent = { + id: "ev-day", + status: "confirmed", + domain: "relationship", + datePrecision: "day", + occurredFrom: "2024-08-08", + occurredTo: "2024-08-08", + eventKind: "relationship_end", + sourceTurnId: TURN_ID, +}; + +test("a day-precision event recorded this turn becomes the date reliability prompt", () => { + const plan = planWithDateReliability( + buildMethodFollowupPlan({ + evidence: [dayEvent], + sessionOutcome: "collect_evidence", + }), + [dayEvent], + TURN_ID, + ); + assert.equal(plan.next_followup?.user_prompt_hint, DATE_RELIABILITY_PROMPT); + assert.equal(plan.next_followup?.date_reliability_evidence_id, "ev-day"); +}); + +test("a month-precision event does not ask date reliability", () => { + const plan = planWithDateReliability( + buildMethodFollowupPlan({ + evidence: [monthEvent], + sessionOutcome: "collect_evidence", + }), + [monthEvent], + TURN_ID, + ); + assert.notEqual(plan.next_followup?.user_prompt_hint, DATE_RELIABILITY_PROMPT); +}); + +test("凭记忆 maps to medium and 查过 maps to high", () => { + assert.equal(classifyDateReliabilityUtterance("凭记忆"), "medium"); + assert.equal(classifyDateReliabilityUtterance("那天是查过记录的"), "high"); + assert.equal(classifyDateReliabilityUtterance(""), null); +}); + +test("an already tagged day event is not asked again", () => { + const tagged = { ...dayEvent, dateReliability: "medium" }; + const plan = planWithDateReliability( + buildMethodFollowupPlan({ + evidence: [tagged], + sessionOutcome: "collect_evidence", + }), + [tagged], + TURN_ID, + ); + assert.notEqual(plan.next_followup?.user_prompt_hint, DATE_RELIABILITY_PROMPT); +}); + +test("historical day events are not asked on a later collect turn", () => { + assert.equal(pendingDateReliabilityEvidence([dayEvent], "other-turn"), null); + const plan = buildMethodFollowupPlan({ + evidence: [dayEvent], + sessionOutcome: "collect_evidence", + }); + assert.notEqual(plan.next_followup?.user_prompt_hint, DATE_RELIABILITY_PROMPT); +}); diff --git a/frontend/tests/rectification-declared-uncertainty-migration.test.ts b/frontend/tests/rectification-declared-uncertainty-migration.test.ts new file mode 100644 index 00000000..34742c20 --- /dev/null +++ b/frontend/tests/rectification-declared-uncertainty-migration.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260907020000_rectification_declared_uncertainty.sql", import.meta.url), + "utf8", +); + +test("declared-uncertainty migration stays additive and service_role-only", () => { + assert.match(migration, /add column if not exists widen_declined_at_fingerprint text/); + assert.match(migration, /add column if not exists date_reliability text/); + assert.match(migration, /create or replace function public\.widen_agentic_rectification_case_window/); + assert.match(migration, /create or replace function public\.set_agentic_rectification_widen_declined/); + assert.match(migration, /create or replace function public\.set_agentic_rectification_evidence_date_reliability/); + assert.match(migration, /grant execute on function public\.widen_agentic_rectification_case_window\(uuid, uuid, text, text\)\s+to service_role/); + assert.match(migration, /v_span > 120 and v_rounds < 3/); + assert.match(migration, /v_new_width > 241/); + assert.doesNotMatch(migration, /adopted_credible_range = v_range/); + assert.doesNotMatch(migration, /create table public\.agentic_rectification_cases/); +}); diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index 98d44dbf..aa1fd10f 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -67,7 +67,7 @@ function openRpc(data: unknown): RpcHandler { return async () => ({ data, error: null }); } -test("loadV9RectificationProfile uses a server-owned radius around reported time", async () => { +test("loadV9RectificationProfile uses a ±15 engine boundary for family_exact", async () => { const accounting = fakeAccounting({ profile: completeProfile }); const profile = await loadV9RectificationProfile(accounting, "user-1"); assert.equal(profile.userId, "user-1"); @@ -81,9 +81,34 @@ test("loadV9RectificationProfile uses a server-owned radius around reported time assert.equal(profile.baseline.uncertainty_after_minutes, 10); assert.equal(profile.baseline.ayanamsa, "raman"); assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" }); + assert.equal(profile.stage, "minute"); assert.ok(!("password" in profile.baseline)); }); +test("approximate ±60 opens 04:00–06:00 as minute; ±120 opens 03:00–07:00 as block_scan", async () => { + const hour = await loadV9RectificationProfile(fakeAccounting({ + profile: { + ...completeProfile, + birth_time_source: "approximate", + uncertainty_before_minutes: 60, + uncertainty_after_minutes: 60, + }, + }), "user-1"); + assert.deepEqual(hour.candidateRange, { start_time: "04:00", end_time: "06:00" }); + assert.equal(hour.stage, "minute"); + + const twoHours = await loadV9RectificationProfile(fakeAccounting({ + profile: { + ...completeProfile, + birth_time_source: "approximate", + uncertainty_before_minutes: 120, + uncertainty_after_minutes: 120, + }, + }), "user-1"); + assert.deepEqual(twoHours.candidateRange, { start_time: "03:00", end_time: "07:00" }); + assert.equal(twoHours.stage, "block_scan"); +}); + test("profile fingerprint ignores display labels and prior accepted minutes", async () => { const first = await loadV9RectificationProfile(fakeAccounting({ profile: completeProfile, @@ -166,8 +191,8 @@ test("period-only profiles open with their selected server-owned range", async ( birth_time_source: "period_only", birth_time_period: item.period, }, - rpc: async (_fn, args) => { - capturedArgs.value = args; + rpc: async (fn, args) => { + if (fn === "open_agentic_rectification_case_v2") capturedArgs.value = args; return { data: { disposition: "created", @@ -207,8 +232,8 @@ test("a custom declared window opens the Case with those clocks, not the leftove declared_window_start: "14:30", declared_window_end: "15:00", }, - rpc: async (_fn, args) => { - capturedArgs.value = args; + rpc: async (fn, args) => { + if (fn === "open_agentic_rectification_case_v2") capturedArgs.value = args; return { data: { disposition: "created", @@ -264,7 +289,9 @@ test("tightening a declared window changes the Case fingerprint", async () => { assert.notEqual(afternoon.baselineFingerprint, tight.baselineFingerprint); assert.deepEqual(afternoon.candidateRange, { start_time: "12:00", end_time: "17:59" }); + assert.equal(afternoon.stage, "block_scan"); assert.deepEqual(tight.candidateRange, { start_time: "14:30", end_time: "15:00" }); + assert.equal(tight.stage, "minute"); }); test("period-only profiles with an IANA timezone can open when the cached offset is missing", async () => { @@ -296,8 +323,8 @@ test("period-only profiles with an IANA timezone can open when the cached offset birth_time_period: "evening", timezone_offset: null, }, - rpc: async (_fn, args) => { - capturedArgs.value = args; + rpc: async (fn, args) => { + if (fn === "open_agentic_rectification_case_v2") capturedArgs.value = args; return { data: { disposition: "created", @@ -398,13 +425,13 @@ test("fresh profiles still fail closed when their birth-time declaration is inco } }); -test("homepage and new opens ignore active time and legacy uncertainty for their fresh range", async () => { +test("homepage and new opens ignore active time and keep family_exact at ±15", async () => { for (const intent of ["homepage", "new"] as const) { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({ profile: completeProfile, - rpc: async (_fn, args) => { - capturedArgs.value = args; + rpc: async (fn, args) => { + if (fn === "open_agentic_rectification_case_v2") capturedArgs.value = args; return { data: { disposition: "created", diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index f22550a7..32f8fa3a 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -7,7 +7,14 @@ export type FakeRpcHandler = ( export type FakeAccounting = { calls: Array<{ fn: string; args: Record }>; - client: RectificationRpcClient; + profilePatches: Array>; + client: RectificationRpcClient & { + from: (table: string) => { + update: (values: Record) => { + eq: (column: string, value: string) => Promise<{ error: null }>; + }; + }; + }; }; export function fakeAccounting( @@ -15,7 +22,8 @@ export function fakeAccounting( options: { fallback?: FakeRpcHandler } = {}, ): FakeAccounting { const calls: Array<{ fn: string; args: Record }> = []; - const client: RectificationRpcClient = { + const profilePatches: Array> = []; + const client: FakeAccounting["client"] = { rpc(fn, args) { calls.push({ fn, args }); const handler = handlers[fn] ?? options.fallback; @@ -35,8 +43,21 @@ export function fakeAccounting( }), ); }, + from(table: string) { + return { + update(values: Record) { + return { + async eq(column: string, value: string) { + calls.push({ fn: `from:${table}.update`, args: { values, column, value } }); + if (table === "profiles") profilePatches.push(values); + return { error: null }; + }, + }; + }, + }; + }, }; - return { calls, client }; + return { calls, profilePatches, client }; } export const CASE_ID = "11111111-1111-4111-8111-111111111111"; @@ -74,6 +95,9 @@ export function dossierFixture(overrides: { blockScan?: unknown; acceptedTime?: string | null; conversationSummary?: unknown; + widenDeclinedAtFingerprint?: string | null; + reportedBirthTime?: string | null; + birthTimeSource?: string | null; } = {}) { return { case: { @@ -85,6 +109,9 @@ export function dossierFixture(overrides: { candidate_range: overrides.candidateRange ?? CANDIDATE_RANGE, stage: overrides.stage ?? "minute", block_scan: overrides.blockScan ?? null, + widen_declined_at_fingerprint: overrides.widenDeclinedAtFingerprint ?? null, + reported_birth_time: overrides.reportedBirthTime ?? null, + birth_time_source: overrides.birthTimeSource ?? null, accepted_time: overrides.acceptedTime ?? null, confirmed_time: null, completed_at: null, diff --git a/frontend/tests/rectification-window-widen-20260907.test.ts b/frontend/tests/rectification-window-widen-20260907.test.ts new file mode 100644 index 00000000..984a8749 --- /dev/null +++ b/frontend/tests/rectification-window-widen-20260907.test.ts @@ -0,0 +1,409 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + buildWidenWindowFrame, + shouldSuggestWindowWiden, +} from "../src/lib/rectification-agentic/v9/window-widen.ts"; +import { clockSpanMinutes, splitWindowIntoThirds } from "../src/lib/rectification-agentic/v9/search-window.ts"; +import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { WIDEN_WINDOW_INTENT, WIDEN_WINDOW_KIND } from "../src/lib/rectification-agentic/v9/block-scan.ts"; +import { + CASE_ID, + FOCUS_ID, + SESSION_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + candidateSnapshotFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const TRAINING_EVIDENCE = [ + { + id: "e-edu", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_start", + domain: "education", + occurred_from: "2016-09-01", + occurred_to: "2016-09-30", + date_precision: "month", + summary: "education start", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:00.000Z", + }, + { + id: "e-rel", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_end", + domain: "relationship", + occurred_from: "2024-08-08", + occurred_to: "2024-08-08", + date_precision: "day", + summary: "relationship end", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:01.000Z", + }, + { + id: "e-move", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relocation", + domain: "relocation", + occurred_from: "2023-07-01", + occurred_to: "2023-07-31", + date_precision: "month", + summary: "relocation", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:02.000Z", + }, + { + id: "e-job", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "career_entry", + domain: "career", + occurred_from: "2018-07-01", + occurred_to: "2018-07-31", + date_precision: "month", + summary: "career entry", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:03.000Z", + }, +] as const; + +function widenSchema(reportedTime = "05:00", window = { start_time: "04:45", end_time: "05:15" }) { + const frame = buildWidenWindowFrame({ reportedTime, currentWindow: window }); + assert.ok(frame); + const windows = { + A: { radius: 30, start_time: "04:30", end_time: "05:30" }, + B: { radius: 60, start_time: "04:00", end_time: "06:00" }, + }; + return { + choice: { + prompt: frame.prompt, + option_a: frame.option_a_hint, + option_b: frame.option_b_hint, + option_c: frame.neither_label, + option_d: frame.unsure_label, + options: [ + { key: "A", label: frame.option_a_hint, answer_class: frame.option_a_answer_class, role: "primary" }, + { key: "B", label: frame.option_b_hint, answer_class: frame.option_b_answer_class, role: "primary" }, + { key: "C", label: frame.neither_label, answer_class: frame.option_c_answer_class, role: "primary" }, + { key: "D", label: frame.unsure_label, answer_class: frame.option_d_answer_class, role: "primary" }, + ], + }, + choice_kind: WIDEN_WINDOW_KIND, + scoring: false, + widen_windows: windows, + }; +} + +function widenDossier(extra: { + representativeTime?: string; + range?: { start_time: string; end_time: string }; + declinedFingerprint?: string | null; + birthTimeSource?: string; + evidence?: unknown[]; + withFocus?: boolean; +} = {}) { + const range = extra.range ?? { start_time: "04:45", end_time: "05:15" }; + const schema = widenSchema("05:00", range); + return dossierFixture({ + candidateRange: range, + stage: "minute", + reportedBirthTime: "05:00", + birthTimeSource: extra.birthTimeSource ?? "approximate", + widenDeclinedAtFingerprint: extra.declinedFingerprint ?? null, + evidence: extra.evidence ?? [...TRAINING_EVIDENCE], + latestResult: candidateSnapshotFixture({ + representativeTime: extra.representativeTime ?? "04:45", + decisionReceipt: { + event_fit_rate: { + matched: 1, + total: 4, + percent: 25, + band: "low", + label: "事件吻合率", + user_meaning: "这是相对拟合,不是已确认唯一出生分钟。", + unique_minute_claim: false, + }, + }, + }), + conversationSummary: { + confirmed_evidence_summary: [], + pending_revisions: [], + active_focus: extra.withFocus === false + ? null + : activeFocusFixture({ + intent: WIDEN_WINDOW_INTENT, + targetDomain: null, + targetKind: null, + questionId: "widen_window:search_range:holdout", + expectedAnswerSchema: schema, + }), + declined_skipped_topics: [], + candidate_divergence_summary: null, + missing_evidence_categories: [], + last_result_policy: null, + summary_version: 1, + updated_at: "2026-09-07T00:00:00.000Z", + }, + }); +} + +function choiceHandlers(current: { value: ReturnType }) { + return { + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => current.value, + get_agentic_rectification_case_compute: () => ({ + case_id: CASE_ID, + skill_version: "9.0.0", + baseline_profile_fingerprint: "a".repeat(64), + baseline_birth_snapshot: { + birth_date: "1997-08-08", + latitude: 36.42, + longitude: 114.2, + timezone_id: "Asia/Shanghai", + timezone_offset: 8, + birth_time_source: current.value.case.birth_time_source, + reported_birth_time: "05:00", + }, + candidate_range: current.value.case.candidate_range, + }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + apply_agentic_rectification_choice_action: (_fn: string, args: Record) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + revision: 1, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + set_agentic_rectification_conversation_focus: (_fn: string, args: Record) => ({ + focus: { + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-07T00:00:00.000Z", + resolved_at: null, + }, + idempotent: false, + }), + persist_agentic_rectification_inference_transition: () => { + throw new Error("widen must not rewrite inference"); + }, + }; +} + +test("exclusive span 120 stays minute; 240 splits into three non-overlapping thirds", () => { + assert.equal(clockSpanMinutes("04:00", "06:00"), 120); + assert.equal(clockSpanMinutes("03:00", "07:00"), 240); + const thirds = splitWindowIntoThirds({ start_time: "03:00", end_time: "07:00" }); + assert.equal(thirds.length, 3); + assert.equal(thirds[0]?.start_time, "03:00"); + assert.equal(thirds[2]?.end_time, "07:00"); + assert.notEqual(thirds[0]?.end_time, thirds[1]?.start_time); +}); + +test("fit low plus a representative on the edge asks a four-option widen card", () => { + const parsed = parseV9CaseDossier(widenDossier({ withFocus: false })); + assert.ok(parsed); + const decision = decideFromDossier(parsed); + assert.equal(decision.nextAction, "ask_window_widen"); + assert.equal(decision.sessionOutcome, "widen_window"); + assert.equal(decision.canAdopt, false); + const frame = buildWidenWindowFrame({ + reportedTime: "05:00", + currentWindow: { start_time: "04:45", end_time: "05:15" }, + }); + assert.ok(frame); + assert.equal(frame.choice_kind, WIDEN_WINDOW_KIND); + assert.match(frame.option_a_hint, /半小时/); + assert.match(frame.option_b_hint, /一小时/); + assert.match(frame.neither_label, /不放宽/); + assert.ok(frame.unsure_label.length >= 4); + const labels = [frame.option_a_hint, frame.option_b_hint, frame.neither_label, frame.unsure_label]; + assert.equal(new Set(labels).size, 4); +}); + +test("fit low with a representative in the middle of the window does not suggest widen", () => { + const parsed = parseV9CaseDossier(widenDossier({ + representativeTime: "05:00", + withFocus: false, + })); + assert.ok(parsed); + assert.notEqual(decideFromDossier(parsed).nextAction, "ask_window_widen"); + assert.equal( + shouldSuggestWindowWiden({ + trainingGateOpen: true, + fitBand: "low", + fitTotal: 4, + representativeNearEdge: false, + stage: "minute", + accepted: false, + reportedTime: "05:00", + declinedForFingerprint: false, + currentRadius: 15, + }), + false, + ); +}); + +test("answering A widens the window, patches approximate radius, and does not rewrite answered probes", async () => { + const current = { value: widenDossier() }; + const accounting = fakeAccounting({ + ...choiceHandlers(current), + widen_agentic_rectification_case_window: (_fn, args) => { + current.value = widenDossier({ + range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) }, + withFocus: false, + }); + return { + case_id: CASE_ID, + stage: "minute", + candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time }, + }; + }, + }); + const askedBefore = accounting.calls.filter((item) => item.fn.includes("inference")).length; + const receipt = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", + action: CHOICE_ACTION, + focusId: FOCUS_ID, + optionId: "A", + expectedRevision: 0, + }); + assert.equal(receipt.snapshotCurrent, false); + assert.ok(accounting.calls.some((item) => item.fn === "widen_agentic_rectification_case_window")); + const widenCall = accounting.calls.find((item) => item.fn === "widen_agentic_rectification_case_window"); + assert.equal(widenCall?.args.p_start_time, "04:30"); + assert.equal(widenCall?.args.p_end_time, "05:30"); + assert.deepEqual(accounting.profilePatches, [{ + birth_time_source: "approximate", + uncertainty_before_minutes: 30, + uncertainty_after_minutes: 30, + }]); + assert.equal( + accounting.calls.filter((item) => item.fn.includes("inference")).length, + askedBefore, + ); +}); + +test("answering C records the fingerprint and does not ask again until evidence changes", async () => { + const current = { value: widenDossier() }; + const parsedBefore = parseV9CaseDossier(current.value); + assert.ok(parsedBefore); + const fingerprint = evidenceLedgerFingerprint(parsedBefore.evidence); + const accounting = fakeAccounting({ + ...choiceHandlers(current), + set_agentic_rectification_widen_declined: (_fn, args) => { + current.value = widenDossier({ + declinedFingerprint: String(args.p_fingerprint), + withFocus: false, + }); + return { + case_id: CASE_ID, + widen_declined_at_fingerprint: args.p_fingerprint, + }; + }, + }); + await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4", + action: CHOICE_ACTION, + focusId: FOCUS_ID, + optionId: "C", + expectedRevision: 0, + }); + const declined = parseV9CaseDossier(current.value); + assert.ok(declined); + assert.equal(declined.case.widenDeclinedAtFingerprint, fingerprint); + assert.notEqual(decideFromDossier(declined, { currentEvidenceFingerprint: fingerprint }).nextAction, "ask_window_widen"); + const freshEvidence = [ + ...TRAINING_EVIDENCE, + { + ...TRAINING_EVIDENCE[3], + id: "e-job-2", + occurred_from: "2019-03-01", + occurred_to: "2019-03-31", + summary: "later career", + }, + ]; + const later = parseV9CaseDossier(widenDossier({ + declinedFingerprint: fingerprint, + evidence: freshEvidence, + withFocus: false, + })); + assert.ok(later); + assert.equal(decideFromDossier(later).nextAction, "ask_window_widen"); +}); + +test("already ±120 does not suggest a widen card", () => { + assert.equal( + shouldSuggestWindowWiden({ + trainingGateOpen: true, + fitBand: "low", + fitTotal: 4, + representativeNearEdge: true, + stage: "minute", + accepted: false, + reportedTime: "05:00", + declinedForFingerprint: false, + currentRadius: 120, + }), + false, + ); +}); + +test("hospital_record answering A widens the window but does not rewrite the archive source", async () => { + const current = { value: widenDossier({ birthTimeSource: "hospital_record" }) }; + const accounting = fakeAccounting({ + ...choiceHandlers(current), + widen_agentic_rectification_case_window: (_fn, args) => { + current.value = widenDossier({ + birthTimeSource: "hospital_record", + range: { start_time: String(args.p_start_time), end_time: String(args.p_end_time) }, + withFocus: false, + }); + return { + case_id: CASE_ID, + stage: "minute", + candidate_range: { start_time: args.p_start_time, end_time: args.p_end_time }, + }; + }, + }); + await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5", + action: CHOICE_ACTION, + focusId: FOCUS_ID, + optionId: "A", + expectedRevision: 0, + }); + assert.ok(accounting.calls.some((item) => item.fn === "widen_agentic_rectification_case_window")); + assert.deepEqual(accounting.profilePatches, []); +}); diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 529dfabe..7e427575 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -65,6 +65,38 @@ def _report_candidate_range( } +def _window_span_minutes(start_time: str, end_time: str) -> int: + start = _clock_minutes(start_time) + end = _clock_minutes(end_time) + return end - start if end >= start else 1440 - start + end + + +def _adaptive_minute_step(start_time: str, end_time: str) -> int: + width = _window_span_minutes(start_time, end_time) + if width > 360: + return 10 + if width > 180: + return 5 + return 2 + + +def _block_scan_periods(request: RectificationRequest) -> list[tuple[str, str, str]]: + custom = request.get("blocks") + if isinstance(custom, list) and custom: + periods: list[tuple[str, str, str]] = [] + for item in custom: + if not isinstance(item, dict): + continue + label = str(item.get("period") or item.get("label") or "") + start_time = str(item.get("start_time") or "")[:5] + end_time = str(item.get("end_time") or "")[:5] + if label and start_time and end_time: + periods.append((label, start_time, end_time)) + if periods: + return periods + return list(BLOCK_SCAN_PERIODS) + + def _report_evidence( request: RectificationRequest, built: dict[str, Any], @@ -402,9 +434,12 @@ def _clock_in_declared_period(clock: str, start_time: str, end_time: str) -> boo def _normalize_relative_support(raw: Sequence[float]) -> list[float]: floored = [max(0.0, float(value)) for value in raw] total = sum(floored) + if not floored: + return [] if total <= 0: - return [20.0 for _ in floored] - shares = [round(100.0 * value / total, 1) for value in floored] + shares = [round(100.0 / len(floored), 1) for _ in floored] + else: + shares = [round(100.0 * value / total, 1) for value in floored] delta = round(100.0 - sum(shares), 1) if shares: shares[shares.index(max(shares))] = round(shares[shares.index(max(shares))] + delta, 1) @@ -412,10 +447,16 @@ def _normalize_relative_support(raw: Sequence[float]) -> list[float]: def block_scan(request: RectificationRequest) -> dict[str, Any]: - """Aggregate 24h event scores into the five declared birth-time periods.""" - step = int(request.get("minute_step") or 10) - if step <= 1: - step = 10 + """Aggregate event scores into declared periods or caller-supplied sub-blocks.""" + requested_step = request.get("minute_step") + if isinstance(requested_step, int) and requested_step > 1: + step = requested_step + else: + step = _adaptive_minute_step(request["start_time"], request["end_time"]) + if not request.get("blocks"): + step = int(request.get("minute_step") or 10) + if step <= 1: + step = 10 scoring_request = {**request, "minute_step": step} scored = score_candidates(scoring_request) events_by_id = { @@ -429,9 +470,10 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]: ] day_scores = [float(row.get("score") or 0) for row in rows] min_day = min(day_scores) if day_scores else 0.0 + periods = _block_scan_periods(request) raw_support: list[float] = [] blocks: list[dict[str, Any]] = [] - for period, start_time, end_time in BLOCK_SCAN_PERIODS: + for period, start_time, end_time in periods: members = [ row for row in rows if _clock_in_declared_period(str(row.get("time") or "")[:5], start_time, end_time) diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index f63519a3..3850fc5f 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -52,10 +52,88 @@ _EVENT_PROVENANCE_FIELDS = frozenset({ }) _REQUEST_FIELDS = frozenset({ "birth_date", "start_time", "end_time", "lat", "lon", "tz", "events", - "ayanamsa", "node_mode", "asked_probe_keys", "minute_step", + "ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks", }) | _REQUEST_PROVENANCE_FIELDS _EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS _CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z") +_MINUTES_PER_DAY = 24 * 60 + + +def _clock_minutes(value: str) -> int: + hour, minute = value.split(":", 1) + return int(hour) * 60 + int(minute) + + +def _clock_in_window(clock: str, start_time: str, end_time: str) -> bool: + current = _clock_minutes(clock) + start = _clock_minutes(start_time) + end = _clock_minutes(end_time) + if start <= end: + return start <= current <= end + return current >= start or current <= end + + +def _inclusive_minutes(start_time: str, end_time: str) -> list[int]: + start = _clock_minutes(start_time) + end = _clock_minutes(end_time) + span = end - start if end >= start else _MINUTES_PER_DAY - start + end + return [(start + offset) % _MINUTES_PER_DAY for offset in range(span + 1)] + + +def _block_contained(block_start: str, block_end: str, window_start: str, window_end: str) -> bool: + window = set(_inclusive_minutes(window_start, window_end)) + return all(minute in window for minute in _inclusive_minutes(block_start, block_end)) + + +def _ranges_overlap(left_start: str, left_end: str, right_start: str, right_end: str) -> bool: + left = set(_inclusive_minutes(left_start, left_end)) + right = set(_inclusive_minutes(right_start, right_end)) + shared = left & right + if not shared: + return False + endpoints = { + _clock_minutes(left_start), + _clock_minutes(left_end), + _clock_minutes(right_start), + _clock_minutes(right_end), + } + interior = shared - endpoints + if interior: + return True + # Adjacent blocks may share a single endpoint minute; more than that is overlap. + return len(shared) > 1 + + +def _normalize_blocks(body: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]: + raw_blocks = body.get("blocks") + if not isinstance(raw_blocks, list) or not 1 <= len(raw_blocks) <= 5: + raise ValueError("blocks must contain between 1 and 5 items") + cleaned: list[dict[str, str]] = [] + for index, raw in enumerate(raw_blocks): + if not isinstance(raw, dict): + raise ValueError(f"blocks[{index}] must be an object") + label = raw.get("label") or raw.get("period") + block_start, block_end = raw.get("start_time"), raw.get("end_time") + if not isinstance(label, str) or not label.strip() or len(label.strip()) > 40: + raise ValueError(f"blocks[{index}].label must be a non-empty string up to 40 characters") + if not isinstance(block_start, str) or not _CLOCK.fullmatch(block_start): + raise ValueError(f"blocks[{index}].start_time must be HH:MM") + if not isinstance(block_end, str) or not _CLOCK.fullmatch(block_end): + raise ValueError(f"blocks[{index}].end_time must be HH:MM") + if block_start == block_end: + raise ValueError(f"blocks[{index}] start_time and end_time must differ") + if not _block_contained(block_start, block_end, start_time, end_time): + raise ValueError(f"blocks[{index}] must fall inside the request window") + for previous in cleaned: + if _ranges_overlap(previous["start_time"], previous["end_time"], block_start, block_end): + raise ValueError("blocks must not overlap") + cleaned.append({ + "label": label.strip(), + "period": label.strip(), + "start_time": block_start, + "end_time": block_end, + }) + return cleaned class LifeEvent(TypedDict): @@ -90,6 +168,7 @@ class RectificationRequest(TypedDict): local_time_status: NotRequired[str | None] asked_probe_keys: NotRequired[list[str]] minute_step: NotRequired[int] + blocks: NotRequired[list[dict[str, Any]]] JsonObject = dict[str, Any] @@ -273,4 +352,6 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> raise ValueError("minute_step must be an integer from 1 to 15") if minute_step != 1: cleaned_request["minute_step"] = minute_step + if "blocks" in body: + cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time) return cast(RectificationRequest, cleaned_request) diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index fd9ebc64..1b72b3a2 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -948,6 +948,72 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertGreater(shares["early_morning"], shares["afternoon"]) self.assertEqual(shares["afternoon"], 0.0) + def test_block_scan_custom_three_blocks_sum_to_one_hundred(self): + from scripts.rectification.api_service import block_scan + + dummy = { + "result_id": "00000000-0000-4000-8000-000000000099", + "algorithm_version": "test", + "calculation_spec": {}, + "calculation_spec_hash": "abc", + "decision_receipt": {}, + } + rows = [ + {"time": f"{hour:02d}:{minute:02d}", "score": 12.0, "supporting_event_ids": []} + for hour in range(12, 18) + for minute in range(0, 60, 10) + if not (hour == 17 and minute > 50) + ] + request_body = { + "birth_date": "1998-03-15", + "start_time": "12:00", + "end_time": "17:59", + "lat": 39.9042, + "lon": 116.4074, + "tz": 8.0, + "minute_step": 10, + "events": [], + "blocks": [ + {"label": "sub_1", "start_time": "12:00", "end_time": "13:59"}, + {"label": "sub_2", "start_time": "14:00", "end_time": "15:59"}, + {"label": "sub_3", "start_time": "16:00", "end_time": "17:59"}, + ], + } + with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": rows}): + result = block_scan(request_body) + self.assertEqual(len(result["blocks"]), 3) + self.assertEqual(round(sum(float(row["relative_support"]) for row in result["blocks"]), 1), 100.0) + shares = sorted(float(row["relative_support"]) for row in result["blocks"]) + self.assertEqual(shares, [33.3, 33.3, 33.4]) + + def test_block_scan_rejects_overlapping_or_out_of_window_blocks(self): + from scripts.rectification.contracts import normalize_rectification_request + + base = { + "birth_date": "1998-03-15", + "start_time": "12:00", + "end_time": "17:59", + "lat": 39.9042, + "lon": 116.4074, + "tz": 8.0, + "events": [], + } + with self.assertRaises(ValueError): + normalize_rectification_request({ + **base, + "blocks": [ + {"label": "sub_1", "start_time": "12:00", "end_time": "14:00"}, + {"label": "sub_2", "start_time": "13:30", "end_time": "15:00"}, + ], + }, today=date(2026, 7, 28)) + with self.assertRaises(ValueError): + normalize_rectification_request({ + **base, + "blocks": [ + {"label": "sub_1", "start_time": "03:00", "end_time": "04:00"}, + ], + }, today=date(2026, 7, 28)) + if __name__ == "__main__": unittest.main()