diff --git a/CHANGELOG.md b/CHANGELOG.md
index eedbc9cb..905764a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@
- 助手回答旁边的圆形头像去掉了(对话、生时校正、首次引导三处都去),回答从栏边开始,追问按钮和校正时间轴跟着对齐正文。
- Skill 版本不变;不改数据库;不改模型、答题时钟和计费。
+## 2026-10-06 — 校正交付不再连出两段,不能采用时不再给采用按钮(未上线)
+
+- 答完最后一题后,如果这一轮已经写下交付,收尾不再补第二段(BUG-1241)。
+- 点采用和打开案例,用的判定和答题相同,都会带上出生日期。以前缺了日期,一道低于成年下限的题会把「可以采用」翻成拒绝(BUG-1242)。采用条件没有放宽。
+- 不能采用时,区间卡还在,但没有采用按钮,也没有「采用后按…排盘」。被拒绝时显示「这次的结果还不能采用,已刷新到最新状态」,并重新读取当前卡片(BUG-1243)。
+- Skill 版本不变;不改数据库;不改打分引擎。
+
## 2026-10-05 — 普通对话不再白等校正闸的外部快照,用量页能看分段时间(未上线)
- 问父母、问年运这类普通对话,每个领域以前会在校正闸里再等一次外部星历快照,大约多 4 秒,失败了同一天还会再等。现在咨询路径复用当天已经拿到的官方结果;没有的话不再干等,记成「咨询里先不做」,同一天不重复等。生时校正自己的核对不变。顶层那一次外部核对仍会做(BUG-1231)。
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index aa5b4869..c9f2ccfe 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -16674,6 +16674,52 @@
- 复发自:无
- 修复版本:研究分支 `codex/rectification-nadi-seconds-research-20261005`,2026-10-05 Claude 验收后合入 staging(只有研究脚本与文档,没有运行时改动)
+## BUG-1241 | 校正交付后同一轮又补出第二段回答
+
+- 状态:resolved(分支 `codex/rectification-delivery-dup-adopt-20261006`)
+- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
+- 来源:10-06 测试环境真机。任务书 `docs/tasks/TASK-rectification-delivery-dup-adopt-20261006.md`。
+- 影响面:生时校正答完最后一题后的交付。不改引擎,不改数据库。
+- 现象:同一轮出现两段助手回答。前一段是采用旁白的改写。后一段是确定性兜底,带分钟范围和「还剩几个候选」。刷新后两段都在。
+- 触发条件:答题路径已经把改写后的交付句写入本轮;收尾补位找不到已问轮次编号,又用子串去比对。改写句不是兜底原文的子串,于是再写一条。
+- 根因:BUG-1153 的防重只看「上一条是否包含确定性原文」。当时的测试把上一条写成已经包含这段原文,所以没覆盖「上一条是另一句改写」。60 秒内不重复记账的标记也没有拿来比对这句已经写下的交付。
+- 修复:答题回复在落库前记下这句交付原文。收尾补位若发现上一条助手原文(去掉空白后)就是这句,就不再写。子串比对保留。没有任何回复承载这句时,补位仍写 1 条。不把旁白函数传进收尾,也不改旁白提示词。选择题 JSON 在已有轮次编号时带上 `x-rectification-turn-id`,作为额外保险;真库验收不依赖这个头。
+- 验证:`frontend/tests/rectification-delivery-dup-adopt-20261006.test.ts` 覆盖「上一条是改写句则 0 条新写入」和「上一条不是这句则仍写 1 条」。BUG-1153 原测试仍通过。真 PostgreSQL 17 上,种好的并列卷宗走同一条收尾后,助手交付消息恰好 1 条。本机没有星历引擎,收尾里的刷新失败得很快,失败之后也没有写出第 2 条。
+- 防复发:上述测试。跳过条件是「上一条等于已记下的交付句」,不能只靠加长子串。
+- 相关记录:BUG-596、BUG-1149、BUG-1153。
+- 复发自:BUG-1153。旧测试的上一条已经包含确定性原文,改写句走不到那条断言。
+- 修复版本:待发布
+
+## BUG-1242 | 点采用被拒,因为采用和读取没用答题时的出生日期
+
+- 状态:resolved(分支 `codex/rectification-delivery-dup-adopt-20261006`)
+- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
+- 来源:同上,同一轮真机。卡片上能点采用,接口返回 409 `adoption_not_allowed`。
+- 影响面:生时校正的采用与案例读取。不放宽采用门,不改成年下限,不改数据库。
+- 现象:答题当时判定可以按代表时间采用。点采用时同一份卷宗被判成还在区分候选,`can_adopt` 为假,返回 409。案例读取和采用走的是同一种缺出生日期的判定,和答题不一致。
+- 触发条件:证据指纹已经对上,卷宗里还有一道低于成年下限的区分探针。答题路径带了出生日期,探针被拿掉,结论是可以采用。采用和读取只传指纹,缺出生日期时这道探针仍像能问,结论翻成不可采用。只补「快照仍是当前」不能对齐;出生日期能对齐。过期指纹上强行标成当前也会改结论,生产重算不会对着过期指纹这样做。
+- 根因:BUG-598 只把成年下限接到答题和巡检回退。GET 与 accept 仍各自调用判定,不带出生日期。BUG-680 的成对测试没有把出生日期算进「两边必须相同」的入参。
+- 修复:出生快照读出校验过的日期,答题、GET、accept 以及其余生产判定调用共用这一份入参。读不到快照就失败关闭:accept 返回 503「暂时无法采用」,GET 仍是「暂时无法读取校正记录」,不退回缺日期的相反结论。快照是否当前仍由卷宗自己算,答题路径不再单独传 true。采用门的集合没有加成员。
+- 验证:对拍测试覆盖有无未成年探针、并列与不并列。修前那种只传指纹的调用仍是不可采用;共享入参后答题、采用、GET 都是可以按代表时间采用。源码合同要求生产代码里新增的判定调用旁边出现共享入参函数。真 PostgreSQL 17 上同一份并列卷宗:共享入参 `can_adopt` 为真,GET 与之相同,旧的只传指纹仍为假。
+- 防复发:上述对拍与源码合同。`collect_evidence`、`discriminate_candidates`、`validate_holdout` 的公开 `can_adopt` 仍必须为假。
+- 相关记录:BUG-399、BUG-417、BUG-497、BUG-598、BUG-680。
+- 复发自:BUG-598。关联 BUG-680:成对测试没有覆盖出生日期这一个入参。
+- 修复版本:待发布
+
+## BUG-1243 | 不能采用时卡片仍给出采用按钮,拒绝文案是英文代码
+
+- 状态:resolved(分支 `codex/rectification-delivery-dup-adopt-20261006`)
+- 首次发现 / 最近更新:2026-10-06 / 2026-10-06
+- 来源:同上。卡片在交付结果上显示采用,点下去被拒,界面带出 `adoption_not_allowed`。
+- 影响面:盘型卡和旧分钟卡。不新增结果字段,不新增第二张卡。
+- 现象:交付结果里只要卡片出现,按钮也出现,不看公开的 `can_adopt`。拒绝时原文把机器代码给到界面。快照不刷新,按钮还在。
+- 根因:BUG-681 让交付结果都显示卡片,按钮却没有使用和采用接口相同的判据(采用集合且 `can_adopt` 为真)。客户端把 409 的代码直接显示出来。
+- 修复:盘型卡和分钟卡都只在采用集合且 `can_adopt` 为真时显示按钮和「采用后按…排盘」。不能采用时卡片仍在,没有按钮,也没有那一行。已采用仍显示「已采用」。409 的 `error` 改为「这次的结果还不能采用,已刷新到最新状态」,`code` 仍是 `adoption_not_allowed`。客户端先重拉快照,再显示这句中文。
+- 验证:组件测试覆盖交付但不可采用、可以采用、分钟卡三种。渲染结果里没有 `adoption_not_allowed`。409 的假请求会重拉快照,错误文案是那句中文。本轮没有在浏览器或手机上点过按钮。
+- 防复发:上述组件测试和 409 测试。`frontend/DESIGN.md` 校正状态表写明不能采用时卡片仍在、没有按钮。
+- 相关记录:BUG-497、BUG-681。
+- 复发自:无。BUG-681 要求卡片出现,没有要求按钮和公开 `can_adopt` 一致;BUG-497 禁止把不可采用的结果放进采用集合,本轮没有放宽。
+
## BUG-1244 | 普通对话首轮按固定小标题汇报,读起来不像聊天
- 状态:fixed-pending-verify(分支 `codex/consult-conversational-answer-20261006`;部署和真机清单完成前不标 resolved)
diff --git a/docs/tasks/PROGRESS-rectification-delivery-dup-adopt-20261006.md b/docs/tasks/PROGRESS-rectification-delivery-dup-adopt-20261006.md
new file mode 100644
index 00000000..52b0cbcd
--- /dev/null
+++ b/docs/tasks/PROGRESS-rectification-delivery-dup-adopt-20261006.md
@@ -0,0 +1,107 @@
+# PROGRESS · 校正交付一次两段回答与点采用被拒(2026-10-06)
+
+- 任务书:`docs/tasks/TASK-rectification-delivery-dup-adopt-20261006.md`(staging `65b61ed5`,只改文档,未部署)
+- 执行分支:`codex/rectification-delivery-dup-adopt-20261006`
+- 基线:任务书写的产品基线是 `e3f3a1fc`;实现从已含任务书的 `65b61ed5` 开始
+- BUG:1241、1242、1243。开工时 `docs/BUG_HISTORY.md` 最大号是 BUG-1240,没有撞号
+- 修复版本:待发布。本轮不推送,不声称已部署
+
+## T0 修前复现(先于 T1–T3)
+
+虚构出生日期 `1988-03-12`。开口窗口 `03:00–07:00`,可信范围 `04:45–05:15`,并列分钟 `04:53` 与 `05:00`。1998 年搬家探针(10 岁,低于搬家下限 18 岁)。四条带日期的证据。候选集合 `04:45-05:15:04:53,05:00`。探针的支持/冲突写的是分钟,不是候选编号。
+
+下面两轮诊断不算复现:第一轮内存夹具把支持写成了候选编号,开口窗口又等于可信范围,采用被「范围没变窄」挡住,三处都不可采用。第一轮 PG 回执的展示许可与选择许可对不上,能力上限关掉,旁白没有改写,两条消息是同一段确定性原文。成功的那一轮才是下面的记录。
+
+### 内存矩阵
+
+当前证据指纹 `f0cbba1a89013d33aaa89a7ee39fc81caf3528e5a9e9407a3b41eddfea2f2c06`。
+
+| 入参 | session_outcome | can_adopt | 探针 |
+| --- | --- | --- | --- |
+| 答题:出生日期 + snapshotCurrent | adopt_representative | true | 无(低于成年下限) |
+| 采用:只有指纹 | discriminate_candidates | false | relocation.1998.dasha_activation |
+| 只补出生日期 | adopt_representative | true | 无 |
+| 只补 snapshotCurrent | discriminate_candidates | false | 探针还在 |
+| 两个都补 | adopt_representative | true | 无 |
+
+指纹已经对上时,只补出生日期就和答题一致;只补 snapshotCurrent 不行。没有未成年探针时,缺出生日期也不改变结论。
+
+过期指纹(64 个 `b`)上,答题路径若强行 `snapshotCurrent: true`,结论也会变。生产里的重算只在重新读出的卷宗已经对上、或重算之后再从这份卷宗算出来时才标当前,不会对着过期指纹硬写成 true。所以共享入参必须带校验过的出生日期,snapshotCurrent 由卷宗自己算,答题路径不再私自传 true。
+
+### PG17
+
+真 PostgreSQL 17。种的是答完之后的并列卷宗,没有跑星历引擎。算法 `rectification-v5-matrix-scoring-8`,策略 `fictional-decision-policy-v1`,回执的展示/选择/确认三项与列一致且上限打开。Windows 上 `db/migrations` 的四个符号链接被检出成普通文件,和 `supabase/migrations` 重名,两目录迁移会停。这一轮把 supabase 全部加上 db 里不重名的本地身份文件放进一个临时目录,设 `MIGRATIONS_DIRECTORY`。这不是「两目录迁移在 Windows 上通过」。
+
+同一份已对上指纹的卷宗:
+
+| 路径 | session_outcome | can_adopt |
+| --- | --- | --- |
+| 采用那种只传指纹 | discriminate_candidates | false |
+| 答题:出生日期 + snapshotCurrent | adopt_representative | true |
+| 只补出生日期 | adopt_representative | true |
+
+采用路由在 `can_adopt !== true` 时返回 409 `adoption_not_allowed`。GET 用的也是只传指纹,和答题不一致。
+
+助手消息修前正好 2 条:
+
+1. 采用旁白改写「这一轮已经把范围收窄,可以按代表时间看盘。」写入方是答题后的 `persistV9DeterministicTurn`。
+2. 确定性兜底,写入方是 `finalizeSuccessfulTurnExit`(日志 `rectification_delivery_turn`,trigger 为该函数)。正文含「分钟范围 04:45–05:15」和「现在还剩 04:45–05:15 里 2 个候选」。改写句不是这段的子串,子串防重没挡住。
+
+收尾里的引擎刷新失败得很快(本机没有引擎),失败之后仍然写下第 2 条。选择题 JSON 没有带 `x-rectification-turn-id`,所以收尾看不到已经问过的轮次,补位照写。
+
+T2 做法:不把采用旁白函数传进收尾,也不改旁白提示词。答题回复已经记下交付原文;收尾补位在「上一条助手原文去掉空白后等于这份已记原文」时不再写。子串比对留下。没有任何回复承载时,补位仍写 1 条。
+
+## 实现
+
+共享入参在 `decision-inputs.ts`。出生日期读不出来就抛 `birth_snapshot_unavailable`,不退回缺日期的判定。GET 和接续仍回到原来的中文 503。accept 在这条失败上返回 503「暂时无法采用」。快照是否当前仍由卷宗比较指纹,答题路径不再单独传 true。
+
+收尾补位先看子串,再看已记下的交付句是否就是上一条助手原文,然后才写。先记下的那句在 60 秒内不被后一句盖掉。选择题 JSON 在有轮次编号时带 `x-rectification-turn-id`。真库验收没有靠这个头:收尾函数自己比对上一条。
+
+盘型卡和分钟卡的按钮都要「结果属于采用集合,且 `can_adopt` 为真」。`frontend/DESIGN.md` 校正状态表的候选行补了「不能采用时卡片仍在、没有按钮」。
+
+测试缝:`interview-state` 和 `projectTurnDecision` 在调用方没传出生日期时仍只传指纹,避免改掉几十条既有单测的结论。生产的 GET、accept、答题都传校验后的日期。`dossierResponse` 缺日期会直接抛错。
+
+既有断言改动:
+
+| 文件 | 原值 | 新值 | 原因 |
+| --- | --- | --- | --- |
+| `rectification-v9-contracts.test.ts` | `step_state: stepStateFromCaseDossier(dossier)` | `step_state: stepStateFromCaseDossier(dossier, decisionInputs.birthDate)` | GET 投影与答题共用出生日期(BUG-1242) |
+
+`rectification-readopt-card-20261002.test.ts` 的两处盘型卡渲染补了 `canAdopt: true`,断言原文没改。若干 `dossierResponse` 测试补了虚构日期 `1900-06-15`,好让成年下限不滤掉夹具里的现代探针。新夹具用 `1988-03-12`,没有复用事故卡片上的日期。
+
+## 修后真库
+
+仍是种好的答完并列卷宗,没有跑星历。同一份已对上指纹的卷宗:
+
+| 路径 | session_outcome | can_adopt |
+| --- | --- | --- |
+| 旧的只传指纹 | discriminate_candidates | false |
+| 共享出生日期 | adopt_representative | true |
+| GET | adopt_representative | true |
+
+然后走答题后的改写落库,再调收尾且不带已问轮次编号。助手交付消息恰好 1 条,就是那句改写。日志里收尾补位被调用了,没有写出第 2 条。引擎刷新仍然很快失败(本机没有引擎),失败后也没有补写。
+
+## 本机测试
+
+`tsc --noEmit`:0 错。`npm run lint`:0 error,126 条既有 warning,没有改它们。
+
+新文件 `rectification-delivery-dup-adopt-20261006.test.ts` 16 项通过。相关既有文件 122 项通过,含 BUG-1153 原测试。`# cancelled` 都是 0。
+
+全量 `npm test`(`node scripts/run-tests.mjs`,退出码 1):
+
+| | tests | pass | fail | cancelled |
+| --- | --- | --- | --- | --- |
+| 基线 `65b61ed5` | 4848 | 4704 | 144 | 0 |
+| 本分支 | 4864 | 4719 | 145 | 0 |
+
+失败名比基线多 1 条、少 0 条。多出来的是「发出后 300 毫秒内出现第一行阶段句」(`rectification-latency-20260926.test.tsx`)。这次全量跑了 414 秒,这条墙钟断言没过。同一条单独再跑通过。它不读出生快照,也不写交付轮。基线全量里没有这条名字。不记成新 bug。
+
+其余 144 条名字与基线相同。测试总数多 16,就是新文件的 16 条,都通过。
+
+假账本原来不返回出生快照。决策入口改成缺快照就拒绝之后,第一次全量多了 60 个失败名。给没桩过的计算 RPC 补了虚构日期 `1900-06-15`(成年下限不滤掉夹具里的现代探针),采用路由的工具桩补了 `loadV9CaseCompute`,范围句那条路由测试补了同一份快照。这三处的断言原文没改,除了 `persistApplied` 的源码顺序:原值查 `loadV9CaseCompute`,新值查 `loadCaseDecisionInputs`,原因是出生日期改由共享入口读取。三栏写在对应测试里。
+
+## 构建
+
+`next build --webpack`。Turbopack 会拒绝工作树里指到主检出的 `node_modules` 联接,所以用 webpack。基线 `65b61ed5` 编译 2.9 分钟、构建自带的 TypeScript 100 秒,都通过,收集页面数据时停在 `/api/rectification/agent`。本分支编译 2.2 分钟、TypeScript 83 秒,都通过,收集页面数据时停在 `/api/birth-time-guide`。两边都是 Windows 不允许为 Skill 运行别名建符号链接(`EPERM`)。先碰到哪条路由取决于收集页面的工人,不是这次改动。路由表没有打出来,这台机器没能确认 `/` 仍是 Static,也没有首屏 gzip 数字。没有为了构建改符号链接,也没有在工作树里重装依赖。首页 `page.tsx` 没改;校正卡片组件有改,gzip 要等能建符号链接的环境再量。
+
+本轮没有打开浏览器,也没有在手机上点采用。真机步骤在 `docs/testing/rectification-delivery-dup-adopt-20261006.md`。修复版本待发布,没有推送,没有部署。
diff --git a/docs/testing/rectification-delivery-dup-adopt-20261006.md b/docs/testing/rectification-delivery-dup-adopt-20261006.md
new file mode 100644
index 00000000..e315b19b
--- /dev/null
+++ b/docs/testing/rectification-delivery-dup-adopt-20261006.md
@@ -0,0 +1,22 @@
+# 真机清单:校正交付一次一段,采用和卡片一致(BUG-1241~1243)
+
+修复还在分支 `codex/rectification-delivery-dup-adopt-20261006`,没有推上测试环境。等测试环境 `/api/health` 的 `deployment.gitCommit` 等于这次推上去的提交后,再用手机走下面几步。本轮没有在浏览器或手机上点过。
+
+准备一条已经走到并列交付的校正。开口窗口要比最后留下的分钟范围宽。出生年份要让至少一道还没问的区分题落在成年下限以下。不要把真实出生资料写进反馈,只记结构。
+
+## 怎样算通过
+
+1. 答完最后一题后,对话里只有一段交付说明。刷新一次,仍然只有这一段,不要再出现另一段「现在还剩几个候选」。
+2. 卡片还在。可以采用时才有采用按钮,以及「采用后按…排盘」。不能采用时卡片仍在,没有按钮,也没有那一行,界面上不出现 `adoption_not_allowed`。
+3. 可以点的那次采用要成功。成功后卡片仍在,并显示「已采用」。再刷新一次,卡片还在,仍是已采用,不要回到还能点采用、点了却被拒绝。
+4. 若这次结果还不能采用,点不到按钮。不要看到英文代码。
+
+## 记下来
+
+| 看什么 | 结果 |
+| --- | --- |
+| 交付说明是一段还是两段 | |
+| 刷新后段数变不变 | |
+| 有没有采用按钮 | |
+| 点采用之后卡片是否显示已采用 | |
+| 再刷新是否仍是已采用 | |
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 2788d7fa..f6d72d18 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -381,7 +381,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `typed-pending` | the live row reads “收到,正在对照你的档案…” from the frame the answer is sent, then follows `turn.progress`: “正在记下这件事…” → “正在重新对照盘面…” → “正在准备下一个问题…” (BUG-1047). A running tool step shows the same stage line; finished steps keep their done labels. None of it survives the settle | enabled (typing queues), stop visible |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
-| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. A subtitle “最可能 HH:MM” (the representative minute) sits under the title (D3, 2026-09-26), only when the percentages are shown (first column ≥ 5 points ahead). The card appears once the refresh, the skip retry and the seven targeted kinds have been asked, or the reader said there is nothing more to add. Guided boundary windows are asked when they come up (at most two per Case, D1 2026-09-26) but an unasked window no longer holds the card (D2 2026-09-26). The precision gate (range ≤ 10 minutes, top-two gap > 3 points, no exact tie) is reported as `precision_gate_met` but never gates the card on its own: meeting it skips no remaining line, and failing it adds no marker to the card once there is nothing left to ask. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled |
+| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. A subtitle “最可能 HH:MM” (the representative minute) sits under the title (D3, 2026-09-26), only when the percentages are shown (first column ≥ 5 points ahead). The card appears once the refresh, the skip retry and the seven targeted kinds have been asked, or the reader said there is nothing more to add. Guided boundary windows are asked when they come up (at most two per Case, D1 2026-09-26) but an unasked window no longer holds the card (D2 2026-09-26). The precision gate (range ≤ 10 minutes, top-two gap > 3 points, no exact tie) is reported as `precision_gate_met` but never gates the card on its own: meeting it skips no remaining line, and failing it adds no marker to the card once there is nothing left to ask. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. When the result is a delivery outcome but adoption is not allowed (`can_adopt` false, or the outcome is outside the adopt set, such as `completed_with_range`), the card stays and has no adopt button and no “采用后按…排盘” line. The button appears only for an adopt outcome with `can_adopt` true. After adoption the card stays and shows “已采用”. | enabled |
| `guided-collect` | a named yes/no card then an entry card. A window whose boundary track carries a domain (D9 / D10 Narayana) asks “YYYY 年 M 到 M 月之间,有没有<那一类事>?”; every other window asks openly — “YYYY 年 M 到 M 月之间,有没有什么事,比如?” — and one window is asked once, whatever label it carries. The entry card is seven type chips (an open window defaults to the first still-open kind) plus a month-granularity date picker (day optional, years from birth year to this year); submitting writes “YYYY 年 M 月(D 日),<领域标签>方面有一件事”, never the question’s example list. Composer stays enabled. | enabled (typing still records), stop visible |
| `adopting` | “正在采用 HH:MM…” until the adopt message arrives (server-written with the first post-adopt check, BUG-1146; idempotent accept still runs the follow-up turn) | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts
index f42e848c..db394213 100644
--- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts
+++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts
@@ -111,6 +111,9 @@ export async function POST(request: Request, context: RouteContext) {
const { loadV9CaseDossier, evidenceLedgerFingerprint } = await import(
"@/lib/rectification-agentic/v9/tool-service"
);
+ const { loadCaseDecisionInputs } = await import(
+ "@/lib/rectification-agentic/v9/decision-inputs"
+ );
const { decideFromDossier, overlayPublicDecision } = await import(
"@/lib/rectification-agentic/v9/interview-state"
);
@@ -120,16 +123,20 @@ export async function POST(request: Request, context: RouteContext) {
const { assertV9ResultWritable } = await import("@/lib/rectification-agentic/v9/result-identity");
await assertV9ResultWritable(accounting, user.id, caseId, parsed.data.resultId);
const dossier = await loadV9CaseDossier(accounting, user.id, caseId);
- const decision = decideFromDossier(dossier, {
+ const decision = decideFromDossier(dossier, await loadCaseDecisionInputs(accounting, user.id, caseId, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
- });
+ }));
const fields = publicDecisionFields(decision);
const overlaid = dossier.latestResult
? overlayPublicDecision(dossier.latestResult, decision)
: fields;
if (overlaid.can_adopt !== true) {
return NextResponse.json(
- { error: "adoption_not_allowed", session_outcome: fields.session_outcome },
+ {
+ error: "这次的结果还不能采用,已刷新到最新状态",
+ code: "adoption_not_allowed",
+ session_outcome: fields.session_outcome,
+ },
{ status: 409 },
);
}
@@ -207,6 +214,12 @@ export async function POST(request: Request, context: RouteContext) {
...(adoptTurnId ? { adopt_turn_id: adoptTurnId } : {}),
});
} catch (error) {
+ if (error instanceof RectificationToolServiceError && error.code === "birth_snapshot_unavailable") {
+ return NextResponse.json(
+ { error: "暂时无法采用", code: "birth_snapshot_unavailable" },
+ { status: 503 },
+ );
+ }
if (error instanceof RectificationToolServiceError) {
return errorResponse(error);
}
diff --git a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts
index d6dd234e..f540d133 100644
--- a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts
+++ b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts
@@ -13,6 +13,7 @@ import {
} from "@/lib/rectification-agentic/v9/tool-service";
import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice";
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
+import { loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
export const runtime = "nodejs";
@@ -75,9 +76,13 @@ export async function POST(request: Request, context: RouteContext) {
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
+ const decisionInputs = await loadCaseDecisionInputs(accounting, user.id, caseId);
return NextResponse.json({
ok: true,
- ...await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { listed }),
+ ...await dossierResponseWithIdentity(dossier, receipts, skillIdentity, {
+ listed,
+ birthDate: decisionInputs.birthDate,
+ }),
});
} catch (error) {
if (error instanceof RectificationToolServiceError) {
diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts
index cd475cdc..db3f02cf 100644
--- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts
+++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts
@@ -14,6 +14,7 @@ import {
safeToolErrorCode,
} from "@/lib/rectification-agentic/v9/tool-service";
import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response";
+import { loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
import { scheduleRectificationTelemetry } from "@/lib/rectification-agentic/v9/telemetry";
export const runtime = "nodejs";
@@ -97,9 +98,11 @@ export async function GET(request: Request, context: RouteContext) {
}),
);
const listed = await listV10ConversationFocuses(accounting, user.id, caseId);
+ const decisionInputs = await loadCaseDecisionInputs(accounting, user.id, caseId);
return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, {
fullDetail,
listed,
+ birthDate: decisionInputs.birthDate,
// Anonymous aggregate telemetry: fire-and-forget, never awaited here.
onProjected: ({ decision, rangeDelivery, readOnly }) => {
void scheduleRectificationTelemetry({
diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx
index 0ec4e825..5f860eb1 100644
--- a/frontend/src/components/rectification-range-delivery.tsx
+++ b/frontend/src/components/rectification-range-delivery.tsx
@@ -16,6 +16,7 @@ import {
} from "@/lib/rectification-agentic/birth-time-provenance";
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
import { RectificationSegmentDelivery } from "@/components/rectification-segment-delivery";
+import { sessionOutcomeAllowsAdopt } from "@/lib/rectification-agentic/core/rectification-decision";
import { deliveryWindowFromReceipt, protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
@@ -41,6 +42,7 @@ export function RectificationRangeDelivery({
const eventCount = delivery?.event_count ?? 0;
const selected = result.selectedTime;
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
+ const adoptAllowed = sessionOutcomeAllowsAdopt(result.sessionOutcome) && result.canAdopt === true;
const columns = delivery?.columns ?? [];
const rangeLabel = result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate)
: range ? `${range[0]}–${range[1]}` : null;
@@ -69,7 +71,7 @@ export function RectificationRangeDelivery({
if (delivery && (delivery.product_delivery || delivery.segment_summary)) {
return candidate.candidateId === delivery.representative_candidate_id)?.candidate_date} />;
}
@@ -125,7 +127,7 @@ export function RectificationRangeDelivery({
className={`rectification-range-delivery__column${adopted ? " is-adopted" : ""}`}
>
{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}
- {candidateDate ? 采用后,排盘使用这一日期和时间;原填报日期保留。
: null}
+ {adoptAllowed && !adoptLocked && candidateDate ? 采用后,排盘使用这一日期和时间;原填报日期保留。
: null}
{showPercents ? (
{RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood}
@@ -152,25 +154,31 @@ export function RectificationRangeDelivery({
{column.window_line}
>
)}
-
- {recordConflict && !adopted ? (
-
- {switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)}
-
+ {adoptAllowed && !adoptLocked ? (
+ <>
+
+ {recordConflict && !adopted ? (
+
+ {switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)}
+
+ ) : null}
+ >
+ ) : adopted ? (
+ {RECTIFICATION_USER_COPY.rangeDeliveryAdopted}
) : null}
);
diff --git a/frontend/src/components/rectification-segment-delivery.tsx b/frontend/src/components/rectification-segment-delivery.tsx
index 4c7f8696..e5423b20 100644
--- a/frontend/src/components/rectification-segment-delivery.tsx
+++ b/frontend/src/components/rectification-segment-delivery.tsx
@@ -8,7 +8,7 @@ import { RectificationVerificationReport } from "@/components/rectification-veri
import { protectVargaReport } from "@/lib/rectification-agentic/v9/delivery-window";
import { unknownProductDeliveryCopy } from "@/lib/rectification-agentic/v9/product-delivery";
-export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate }: Readonly<{
+export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, selection, resultId, busy, accepting, onAccept, adoptLocked, markdown, candidateDate, canAdopt = false }: Readonly<{
summary: SegmentSummary | null;
delivery: RangeDeliveryProjection;
rangeLabel: string | null;
@@ -20,6 +20,7 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
adoptLocked?: boolean;
markdown?: string | null;
candidateDate?: string;
+ canAdopt?: boolean;
}>) {
const adoption = summary?.adoption_minute;
const candidateId = delivery.representative_candidate_id;
@@ -33,6 +34,7 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
const charts = summary?.charts.filter((chart) => chart.tier !== "blocked") ?? [];
const blocked = summary?.charts.some((chart) => chart.tier === "blocked") === true;
const reportBlocked = !summary || blocked;
+ const showAdopt = Boolean(canAdopt && adoption && candidateId && !adoptLocked);
return (
用哪张盘解读
@@ -52,10 +54,10 @@ export function RectificationSegmentDelivery({ summary, delivery, rangeLabel, se
{summary && !summary.scan_complete ? 完整窗口扫描暂不可用;候选盘型不能证明全窗一致。{candidateDate ? `候选代表日期 ${candidateDate};原填报日期保留。` : ""}
: null}
{adoptLocked ? {RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked}
: null}
{delivery.provenance_line ? {delivery.provenance_line}
: null}
- {adoption && candidateId ? (
+ {showAdopt && adoption && candidateId ? (
<>
采用后按 {adoption.date ? `${adoption.date} ` : ""}{adoption.time} 排盘;原填报资料保留。
-
) : null}
>
+ ) : adopted ? (
+ {RECTIFICATION_USER_COPY.rangeDeliveryAdopted}
) : null}
{delivery.boundary || REPRESENTATIVE_MINUTE_DISCLAIMER}
} | undefined {
+ return turnId ? { headers: { "x-rectification-turn-id": turnId } } : undefined;
+}
+
export async function handleRectificationStructuredChoice(
input: RectificationAgentRouteContext & Readonly<{ narrateAdopt: RectificationAdoptNarrator }>,
): Promise {
@@ -76,7 +80,7 @@ export async function handleRectificationStructuredChoice(
narration: assistantMessage,
userMessage: CHOICE_STOP_LABEL,
turnId: turn.turnId,
- });
+ }, rectificationTurnHeaders(turn.turnId));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
@@ -145,7 +149,7 @@ export async function handleRectificationStructuredChoice(
next_user_action: applied.nextUserAction,
nextInterviewPersisted: applied.nextInterviewPersisted,
nextChoiceReady: applied.nextChoiceReady,
- });
+ }, rectificationTurnHeaders(applied.turnId));
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const mapped = mapRectificationRpcError(error);
diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts
index 2920748e..ea7920de 100644
--- a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts
+++ b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts
@@ -8,12 +8,12 @@
*/
import { NextResponse } from "next/server";
import {
- loadV9CaseCompute,
loadV9CaseDossier,
persistV9DeterministicTurn,
RectificationToolServiceError,
transitionV9CaseStatus,
} from "@/lib/rectification-agentic/v9/tool-service";
+import { loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import {
applyRectificationChoice,
@@ -251,14 +251,9 @@ export async function handleRectificationTypedMessage(
}
}
} else {
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(accounting, userId, caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- // Ranking stays fail-open when compute is unavailable.
- }
- const decision = decideFromDossier(dossier, { birthDate });
+ const decisionInputs = await loadCaseDecisionInputs(accounting, userId, caseId);
+ const birthDate = decisionInputs.birthDate;
+ const decision = decideFromDossier(dossier, decisionInputs);
if (isNonConvergingRangeOffer(decision)) {
const idle = await persistNextInterviewIfIdle({
accounting,
diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts
index 19924025..e316373d 100644
--- a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts
+++ b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts
@@ -11,6 +11,7 @@
*/
import { resolveRectificationStepBudget } from "./step-budget";
import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
+import { loadCaseDecisionInputs } from "./decision-inputs.ts";
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
@@ -580,12 +581,16 @@ export async function streamV9Attempt(
return completeAttempt(false);
}
let latestDossier: V9CaseDossier;
+ let decision;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
+ decision = decideFromDossier(
+ latestDossier,
+ await loadCaseDecisionInputs(accounting, userId, caseId),
+ );
} catch {
return failedAttempt(attemptId, "state_invariant_failed");
}
- const decision = decideFromDossier(latestDossier);
if (decision.nextAction === "ask_candidate_discriminator") {
const openFocus = latestDossier.conversationSummary.activeFocus;
if (!(
diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
index 32bd627b..ba0e1e9e 100644
--- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
+++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts
@@ -44,7 +44,8 @@ import {
import { liveDistinguishProbe, withOwnedDistinguishProbes } from "./varga-distinguish-probe";
import { isSameCandidateSplit } from "../core/candidate-contrast-packet.ts";
import { datedEventCount, scoredChoiceCount } from "./divergence-panel.ts";
-import { alreadyDelivered, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
+import { alreadyDelivered, deliveryCarrierMatchesLastAssistant, logRectificationDeliveryTurn, markDeliveryTurn } from "./delivery-turn-guard.ts";
+import { decisionInputsFromSnapshot, loadCaseDecisionInputs } from "./decision-inputs.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
@@ -66,7 +67,6 @@ import {
import {
evidenceLedgerFingerprint,
inferenceFingerprintForState,
- loadV9CaseCompute,
loadV9CaseDossier,
persistV9ChoiceAction,
persistV9DeterministicTurn,
@@ -207,14 +207,23 @@ export async function persistExhaustionGateTurn(input: {
resultId: input.resultId ?? null,
terminalNote: true,
});
+ // BUG-1153 kept the substring check. BUG-1241: a rewritten delivery is not a
+ // substring of the deterministic fill-in, so also skip when the latest
+ // assistant text is the delivery sentence already stored for this result.
+ // Read that mark before this call's own mark, or the unsaid fill-in (BUG-1149)
+ // would see a mark it just wrote and skip itself.
+ const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
+ const markedResultId = input.resultId ?? dossier.latestResult?.resultId ?? null;
+ const alreadySaid = narrationAlreadyInLastAssistant(input.hostNarration, dossier)
+ || (markedResultId !== null && deliveryCarrierMatchesLastAssistant({
+ caseId: input.caseId,
+ resultId: markedResultId,
+ turns: dossier.turns,
+ }));
if (input.resultId) {
markDeliveryTurn({ caseId: input.caseId, resultId: input.resultId });
}
- // BUG-1153: the answered choice's own reply often already carries this delivery text
- // (「已记录,…」 + the same sentences). Once BUG-1149 let the gate turn persist, the same
- // narration showed twice. The gate only fills a gap; it never repeats the last reply.
- const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
- if (narrationAlreadyInLastAssistant(input.hostNarration, dossier)) return;
+ if (alreadySaid) return;
await persistV9DeterministicTurn(input.accounting, input.userId, input.caseId, {
requestId: exhaustionGateRequestId(input.askedTurnId, input.caseId),
userMessage: null,
@@ -226,9 +235,10 @@ function rememberDeliveryTurn(
caseId: string,
resultId: string | null | undefined,
terminalNote?: boolean,
+ narration?: string | null,
): void {
if (!terminalNote || !resultId) return;
- markDeliveryTurn({ caseId, resultId });
+ markDeliveryTurn({ caseId, resultId, narration });
}
export function appendPostAdoptProspectiveWindows(
@@ -1030,7 +1040,10 @@ export async function persistNextInterviewAfterChoice(input: {
if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) {
return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE };
}
- const birthDate = input.birthDate ?? null;
+ const decisionInputs = input.birthDate == null
+ ? await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)
+ : decisionInputsFromSnapshot(input.birthDate);
+ const birthDate = decisionInputs.birthDate;
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
let decisionState = input.decisionState;
let decision = input.decision ?? decideAfterInferenceChange({
@@ -1152,7 +1165,7 @@ export async function persistNextInterviewAfterChoice(input: {
const narrated = input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback;
- rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true);
+ rememberDeliveryTurn(input.caseId, liveDossier.latestResult?.resultId, true, narrated);
return {
hostNarration: narrated,
choiceReady: false,
@@ -1177,6 +1190,7 @@ export async function persistNextInterviewAfterChoice(input: {
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
+ birthDate,
};
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
@@ -1352,13 +1366,7 @@ export async function requestTieBreakPersonality(input: {
focus: null,
};
}
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- birthDate = null;
- }
+ const birthDate = (await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)).birthDate;
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
@@ -1549,13 +1557,8 @@ export async function applyCollectFocusDenial(
focus: null,
};
}
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(accounting, input.userId, input.caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- birthDate = null;
- }
+ const decisionInputs = await loadCaseDecisionInputs(accounting, input.userId, input.caseId);
+ const birthDate = decisionInputs.birthDate;
let latest: V9CaseDossier = dossier;
try {
latest = await loadV9CaseDossier(accounting, input.userId, input.caseId);
@@ -1572,7 +1575,7 @@ export async function applyCollectFocusDenial(
},
},
}, closeStatus);
- const decision = decideFromDossier(withDeclined, { birthDate });
+ const decision = decideFromDossier(withDeclined, decisionInputs);
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
@@ -1764,11 +1767,11 @@ export async function persistNextInterviewIfIdle(input: {
hostNarration = withLastSuccessfulCompareNotice(hostNarration);
}
if (hostNarration === result.hostNarration) {
- rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote);
+ rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, result.terminalNote, hostNarration);
return result;
}
const finished = { ...result, hostNarration };
- rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote);
+ rememberDeliveryTurn(input.caseId, dossier.latestResult?.resultId, finished.terminalNote, hostNarration);
return finished;
};
const staleFocus = dossier.conversationSummary.activeFocus;
@@ -1818,17 +1821,9 @@ export async function persistNextInterviewIfIdle(input: {
}
return finishIdle({ persisted: false, choiceReady: false, hostNarration: null, focusActive: true });
}
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- birthDate = null;
- }
- let decision = decideFromDossier(dossier, {
- birthDate,
- snapshotCurrent: rescored.snapshotCurrent,
- });
+ const decisionInputs = await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId);
+ const birthDate = decisionInputs.birthDate;
+ let decision = decideFromDossier(dossier, decisionInputs);
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: input.accounting,
userId: input.userId,
@@ -1850,10 +1845,7 @@ export async function persistNextInterviewIfIdle(input: {
}
: latest,
};
- decision = decideFromDossier(dossier, {
- birthDate,
- snapshotCurrent: rescored.snapshotCurrent,
- });
+ decision = decideFromDossier(dossier, decisionInputsFromSnapshot(birthDate));
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
if (decision.heldForTieBreak === true) {
@@ -2028,6 +2020,7 @@ async function persistExhaustionCollect(input: {
decision: RectificationDecision;
decisionReceipt?: Readonly> | null;
askedTurnId?: string | null;
+ birthDate?: string | null;
}): Promise<{
persisted: boolean;
choiceReady: boolean;
@@ -2038,6 +2031,9 @@ async function persistExhaustionCollect(input: {
}> {
let dossier = input.dossier as DecisionDossier;
const receipt = input.decisionReceipt ?? dossier.latestResult?.decisionReceipt ?? null;
+ const birthDate = input.birthDate == null
+ ? (await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId)).birthDate
+ : decisionInputsFromSnapshot(input.birthDate).birthDate;
let decision = input.decision;
let catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
@@ -2147,6 +2143,7 @@ async function persistExhaustionCollect(input: {
dossier,
state: previousInferenceFromReceipt(receipt),
userStopped: false,
+ birthDate,
});
catalog = rectificationFollowupCatalog(
dossier.latestResult ?? null,
@@ -2308,19 +2305,12 @@ async function persistApplied(
inference: input.inference,
});
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(accounting, command.userId, command.caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- // Ranking stays fail-open when compute is unavailable.
- }
+ const birthDate = (await loadCaseDecisionInputs(accounting, command.userId, command.caseId)).birthDate;
const nextDecision = decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
birthDate,
- snapshotCurrent: input.snapshotCurrent,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
@@ -2544,17 +2534,10 @@ async function inspectNonTerminalTurnExit(input: {
caseId: input.caseId,
});
const dossier = rescored.dossier;
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- birthDate = null;
- }
- const decision = decideFromDossier(dossier, {
- birthDate,
- snapshotCurrent: rescored.snapshotCurrent,
- });
+ const decision = decideFromDossier(
+ dossier,
+ await loadCaseDecisionInputs(input.accounting, input.userId, input.caseId),
+ );
const satisfied = Boolean(
projectCurrentQuestion(dossier.conversationSummary.activeFocus)
|| dossier.case.acceptedTime
diff --git a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts
index 5458b72f..9b32558a 100644
--- a/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts
+++ b/frontend/src/lib/rectification-agentic/v9/case-dossier-response.ts
@@ -9,6 +9,7 @@ import {
type ConversationFocusList,
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
+import { decisionInputsFromSnapshot } from "@/lib/rectification-agentic/v9/decision-inputs";
import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision, nextUserActionFromDossier, stepStateFromCaseDossier } from "@/lib/rectification-agentic/v9/interview-state";
import { projectCurrentQuestion } from "@/lib/rectification-agentic/v9/turn-decision";
import { attachQuestionsToTurns, attachOfferResultToTurns, recordedEvidenceByTurn } from "@/lib/rectification-agentic/v9/turn-question";
@@ -41,6 +42,7 @@ export function dossierResponse(
fullDetail?: boolean;
listed?: ConversationFocusList;
liveIdentity?: LiveEngineScoringIdentity;
+ birthDate?: string | null;
/**
* Hands the decision and card this projection already computed to the
* caller (anonymous telemetry), so nothing is decided twice.
@@ -49,9 +51,10 @@ export function dossierResponse(
} = {},
) {
const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null });
- const decision = decideFromDossier(dossier, {
+ const decisionInputs = decisionInputsFromSnapshot(options.birthDate, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
});
+ const decision = decideFromDossier(dossier, decisionInputs);
const listed = options.listed ?? { focuses: [] as const, available: true };
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
const fields = publicDecisionFields(decision);
@@ -124,9 +127,9 @@ export function dossierResponse(
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: identity.read_only || consistencySummary ? null : projectCurrentQuestion(dossier.conversationSummary.activeFocus),
- choice_card: identity.read_only || consistencySummary ? null : choiceCardFromCaseDossier(dossier),
- step_state: stepStateFromCaseDossier(dossier),
- next_user_action: nextUserActionFromDossier(dossier),
+ choice_card: identity.read_only || consistencySummary ? null : choiceCardFromCaseDossier(dossier, decisionInputs.birthDate),
+ step_state: stepStateFromCaseDossier(dossier, decisionInputs.birthDate),
+ next_user_action: nextUserActionFromDossier(dossier, decisionInputs.birthDate),
question_source: questionSourceFromFocusList(listed),
};
if (consistencySummary) {
diff --git a/frontend/src/lib/rectification-agentic/v9/decision-inputs.ts b/frontend/src/lib/rectification-agentic/v9/decision-inputs.ts
new file mode 100644
index 00000000..973927b2
--- /dev/null
+++ b/frontend/src/lib/rectification-agentic/v9/decision-inputs.ts
@@ -0,0 +1,43 @@
+/**
+ * One birth-snapshot input for every production decideFromDossier call.
+ * A missing or unreadable snapshot throws. Callers must not decide with a
+ * null birth date: that keeps an under-age probe askable and flips adoption.
+ */
+
+import { birthYearFromDate } from "./adult-floor.ts";
+import type { DecideFromDossierOptions } from "./decision-from-dossier.ts";
+import {
+ loadV9CaseCompute,
+ RectificationToolServiceError,
+ type AccountingClient,
+} from "./tool-service.ts";
+
+export function decisionInputsFromSnapshot(
+ birthDate: string | null | undefined,
+ extras: Omit = {},
+): DecideFromDossierOptions & { readonly birthDate: string } {
+ if (birthYearFromDate(birthDate) === null) {
+ throw new RectificationToolServiceError("birth_snapshot_unavailable");
+ }
+ return { ...extras, birthDate: birthDate as string };
+}
+
+export async function loadCaseDecisionInputs(
+ accounting: AccountingClient,
+ userId: string,
+ caseId: string,
+ extras: Omit = {},
+): Promise {
+ let birthDate: string | null = null;
+ try {
+ const compute = await loadV9CaseCompute(accounting, userId, caseId);
+ const raw = compute.baselineBirthSnapshot.birth_date;
+ birthDate = typeof raw === "string" ? raw : null;
+ } catch (error) {
+ if (error instanceof RectificationToolServiceError && error.code === "birth_snapshot_unavailable") {
+ throw error;
+ }
+ throw new RectificationToolServiceError("birth_snapshot_unavailable");
+ }
+ return decisionInputsFromSnapshot(birthDate, extras);
+}
diff --git a/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts b/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts
index ed911859..0b602a9d 100644
--- a/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts
+++ b/frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts
@@ -7,7 +7,7 @@
const WINDOW_MS = 60_000;
-type DeliveryMark = Readonly<{ resultId: string; at: number }>;
+type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>;
const delivered = new Map();
@@ -35,14 +35,67 @@ export function markDeliveryTurn(input: {
caseId: string;
resultId: string;
now?: number;
+ narration?: string | null;
}): void {
if (!input.caseId || !input.resultId) return;
+ const now = input.now ?? Date.now();
+ const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined;
+ const existing = delivered.get(input.caseId);
+ if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) {
+ if (existing.narration) return;
+ delivered.set(input.caseId, {
+ resultId: existing.resultId,
+ at: narration ? existing.at : now,
+ ...(narration ? { narration } : {}),
+ });
+ return;
+ }
delivered.set(input.caseId, {
resultId: input.resultId,
- at: input.now ?? Date.now(),
+ at: now,
+ ...(narration ? { narration } : {}),
});
}
+/** The first delivery sentence stored for this result inside the 60s window. */
+export function deliveryCarrierNarration(input: {
+ caseId: string;
+ resultId: string;
+ now?: number;
+}): string | null {
+ if (!input.caseId || !input.resultId) return null;
+ const mark = delivered.get(input.caseId);
+ if (!mark || mark.resultId !== input.resultId || !mark.narration) return null;
+ if ((input.now ?? Date.now()) - mark.at > WINDOW_MS) return null;
+ return mark.narration;
+}
+
+/**
+ * True when the latest assistant text is the stored delivery sentence.
+ * Whitespace is ignored. A needle shorter than 8 characters does not match.
+ */
+export function deliveryCarrierMatchesLastAssistant(input: {
+ caseId: string;
+ resultId: string;
+ turns?: readonly { role?: string; text?: string | null }[];
+ now?: number;
+}): boolean {
+ const carrier = deliveryCarrierNarration(input);
+ if (!carrier) return false;
+ const squash = (value: string) => value.replace(/\s+/g, "");
+ const needle = squash(carrier);
+ if (needle.length < 8) return false;
+ const turns = input.turns ?? [];
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
+ const turn = turns[index];
+ if (turn?.role !== "assistant") continue;
+ const text = typeof turn.text === "string" ? squash(turn.text) : "";
+ if (!text) continue;
+ return text === needle;
+ }
+ return false;
+}
+
export function alreadyDelivered(input: {
caseId: string;
resultId: string;
diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts
index fac655d8..024b2c6c 100644
--- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts
+++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts
@@ -13,6 +13,7 @@ import {
decideFromDossier,
rectificationFollowupCatalog,
} from "./decision-from-dossier";
+import { decisionInputsFromSnapshot } from "./decision-inputs.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { projectRectificationChoiceCard, buildMethodFollowupPlan, buildNextUserAction, blockingMethodsCovered } from "./method-followup";
import { projectRectificationStepState, type RectificationStepState } from "./step-state.ts";
@@ -72,7 +73,7 @@ export function choiceCardFromCaseDossier(dossier: {
widenDeclinedAtFingerprint?: string | null;
};
turns?: readonly Readonly<{ role: string; text: string | null }>[];
-}): RectificationChoiceCard | null {
+}, birthDate?: string | null): RectificationChoiceCard | null {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
@@ -83,9 +84,10 @@ export function choiceCardFromCaseDossier(dossier: {
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
dossier.latestResult?.decisionReceipt ?? null,
);
- const decision = decideFromDossier(dossier, {
- currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
- });
+ const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
+ const decision = decideFromDossier(dossier, typeof birthDate === "string"
+ ? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
+ : { currentEvidenceFingerprint });
const latestAssistantText = [...(dossier.turns ?? [])]
.reverse()
.find((turn) => turn.role === "assistant")
@@ -131,11 +133,15 @@ export function choiceCardFromCaseDossier(dossier: {
});
}
-export function stepStateFromCaseDossier(dossier: Parameters[0]): RectificationStepState {
+export function stepStateFromCaseDossier(
+ dossier: Parameters[0],
+ birthDate?: string | null,
+): RectificationStepState {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
- const decision = decideFromDossier(dossier, {
- currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
- });
+ const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
+ const decision = decideFromDossier(dossier, typeof birthDate === "string"
+ ? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
+ : { currentEvidenceFingerprint });
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
@@ -161,14 +167,18 @@ export function stepStateFromCaseDossier(dossier: Parameters[0]) {
+export function nextUserActionFromDossier(
+ dossier: Parameters[0],
+ birthDate?: string | null,
+) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const observations = internalObservationsFromWindowScan(
windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null),
);
- const decision = decideFromDossier(dossier, {
- currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never),
- });
+ const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence as never);
+ const decision = decideFromDossier(dossier, typeof birthDate === "string"
+ ? decisionInputsFromSnapshot(birthDate, { currentEvidenceFingerprint })
+ : { currentEvidenceFingerprint });
const accepted = Boolean(dossier.case.acceptedTime);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts
index 477b58c0..e53f4333 100644
--- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts
+++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts
@@ -6,6 +6,7 @@
*/
import { compactInferenceProjection, previousInferenceFromReceipt } from "./inference-adapter";
+import { decisionInputsFromSnapshot } from "./decision-inputs.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { evidenceLedgerFingerprint } from "./tool-service";
import { collectionProgressFromReceipt } from "./evidence-model";
@@ -241,12 +242,14 @@ export function projectTurnDecision(
currentQuestion?: (CurrentQuestionProjection & Record) | null;
followupHint?: string | null;
questionContract?: Readonly> | null;
+ birthDate?: string | null;
} = {},
): Record {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
- const decision = decideFromDossier(dossier, {
- currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
- });
+ const currentEvidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence);
+ const decision = decideFromDossier(dossier, typeof extras.birthDate === "string"
+ ? decisionInputsFromSnapshot(extras.birthDate, { currentEvidenceFingerprint })
+ : { currentEvidenceFingerprint });
const recentTurns = dossier.turns.slice(-TURN_DECISION_RECENT_TURNS).flatMap((turn) => {
const text = clipText(turn.text, TURN_DECISION_TURN_TEXT_LIMIT);
if (!text || (turn.status !== "completed" && !(turn.role === "user" && turn.status === "pending"))) {
diff --git a/frontend/src/lib/rectification-chat-accept-run.ts b/frontend/src/lib/rectification-chat-accept-run.ts
index d0071386..8c180545 100644
--- a/frontend/src/lib/rectification-chat-accept-run.ts
+++ b/frontend/src/lib/rectification-chat-accept-run.ts
@@ -27,6 +27,15 @@ import type { RenderMessage } from "@/components/rectification-message-entry";
import { messagesFromTurns, type PersistedTurn } from "@/lib/rectification-chat-messages";
import { applySnapshotTurnsToMessages } from "@/lib/rectification-snapshot-messages";
+export function adoptionRefusalCopy(payload: { error?: unknown; code?: unknown } | null | undefined): string {
+ if (payload?.code === "adoption_not_allowed" || payload?.error === "adoption_not_allowed") {
+ return "这次的结果还不能采用,已刷新到最新状态";
+ }
+ const raw = typeof payload?.error === "string" ? payload.error : "";
+ if (!raw || raw.includes("adoption_not_allowed")) return "暂时无法采用该候选时间";
+ return raw;
+}
+
export type RectificationCandidateAcceptDeps = Readonly<{
caseId: string;
sessionId: string;
@@ -127,7 +136,14 @@ export async function runRectificationCandidateAccept(
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") {
- throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
+ if (payload?.code === "adoption_not_allowed" || payload?.error === "adoption_not_allowed") {
+ try {
+ await loadCaseSnapshot();
+ } catch {
+ // The card refresh is best-effort; the refusal copy still replaces the button.
+ }
+ }
+ throw new Error(adoptionRefusalCopy(payload));
}
setCandidateResult((current) => {
if (!current) return current;
diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts
index 96919b7a..e766cf5a 100644
--- a/frontend/src/mastra/rectification-v9-tools.ts
+++ b/frontend/src/mastra/rectification-v9-tools.ts
@@ -124,6 +124,7 @@ import {
resolveEvidenceQuote,
} from "@/lib/rectification-agentic/v9/evidence-quote";
import { modelVisibleInference, projectTurnDecision } from "@/lib/rectification-agentic/v9/turn-decision";
+import { decisionInputsFromSnapshot, loadCaseDecisionInputs } from "@/lib/rectification-agentic/v9/decision-inputs";
import {
contrastPacketFromLatestResult,
decideFromDossier,
@@ -275,10 +276,11 @@ export function safeCaseProjection(
const parsed = parseDossierForTools(dossier);
const caseRow = parsed.case;
const latest = parsed.latestResult;
- const decision = decideFromDossier(dossier, {
- currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
- birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null,
- });
+ const decisionInputs = decisionInputsFromSnapshot(
+ typeof compute.baselineBirthSnapshot.birth_date === "string" ? compute.baselineBirthSnapshot.birth_date : null,
+ { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence) },
+ );
+ const decision = decideFromDossier(dossier, decisionInputs);
const windowScan = windowScanFromDecisionReceipt(latest?.decisionReceipt ?? null);
const observations = internalObservationsFromWindowScan(windowScan);
const catalog = rectificationFollowupCatalog(latest, parsed.evidence);
@@ -295,7 +297,7 @@ export function safeCaseProjection(
observations,
sessionOutcome: decision.sessionOutcome,
...catalog,
- birthDate: String(compute.baselineBirthSnapshot.birth_date ?? "") || null,
+ birthDate: decisionInputs.birthDate,
accepted,
candidatesSeparated: decision.separation.sufficient,
holdoutValidation: decision.holdoutValidation,
@@ -780,7 +782,7 @@ function followupPlanForParsed(
function sessionAwareFollowupForParsed(
parsed: DossierForTools,
latest: DossierForTools["latestResult"],
- options?: { birthDate?: string | null; snapshotCurrent?: boolean },
+ birthDate: string,
) {
const decision = decideFromDossier({
evidence: parsed.evidence,
@@ -788,11 +790,10 @@ function sessionAwareFollowupForParsed(
latestResult: latest,
case: parsed.case,
turns: parsed.turns,
- }, {
+ }, decisionInputsFromSnapshot(birthDate, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(parsed.evidence),
- birthDate: options?.birthDate,
- });
- return followupPlanForParsed(parsed, latest, decision, options?.birthDate);
+ }));
+ return followupPlanForParsed(parsed, latest, decision, birthDate);
}
function safeConversationSummary(dossier: DossierForTools) {
@@ -1090,14 +1091,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
parsed: DossierForTools,
latest: DossierForTools["latestResult"],
) => {
- let birthDate: string | null = null;
- try {
- const compute = await loadV9CaseCompute(accounting, userId, caseId);
- birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null;
- } catch {
- // Ranking stays fail-open when compute is unavailable.
- }
- const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate });
+ const birthDate = (await loadCaseDecisionInputs(accounting, userId, caseId)).birthDate;
+ const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, birthDate);
const currentDossier = await loadV9CaseDossier(accounting, userId, caseId);
if (resultIdentityView(currentDossier, await readV9EngineScoringIdentity()).read_only) {
return { collectingPlan: { ...collectingPlan, next_followup: null },
@@ -1233,9 +1228,10 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
try {
let dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
let parsed = parseDossierForTools(dossier);
+ const birthDate = (await loadCaseDecisionInputs(accounting, userId, input.caseId)).birthDate;
const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity());
if (identity.read_only) {
- const storedProjection = projectTurnDecision(dossier);
+ const storedProjection = projectTurnDecision(dossier, { birthDate });
const projection = { ...storedProjection, read_only: true,
notice: OLD_ALGORITHM_NOTICE, can_adopt: false, current_question: null, choice_card: null,
current_probe: null, inference: null,
@@ -1270,7 +1266,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
refreshed,
await loadV9CaseCompute(accounting, userId, input.caseId),
)
- : projectTurnDecision(refreshed);
+ : projectTurnDecision(refreshed, { birthDate });
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
hasReadCase = true;
return projection;
@@ -1282,7 +1278,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
dossier,
await loadV9CaseCompute(accounting, userId, input.caseId),
)
- : projectTurnDecision(dossier);
+ : projectTurnDecision(dossier, { birthDate });
await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) });
hasReadCase = true;
return projection;
@@ -1346,7 +1342,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
});
return projection;
}
- nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup;
+ nextFollowup = sessionAwareFollowupForParsed(
+ parsed,
+ parsed.latestResult,
+ (await loadCaseDecisionInputs(accounting, userId, input.caseId)).birthDate,
+ ).plan.next_followup;
decisionReceipt = parsed.latestResult?.decisionReceipt ?? null;
} catch {
nextFollowup = null;
@@ -2318,7 +2318,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
}
const latest = parsed.latestResult;
await assertV9ResultWritable(accounting, userId, input.caseId, latest.resultId);
- const { decision } = sessionAwareFollowupForParsed(parsed, latest);
+ const { decision } = sessionAwareFollowupForParsed(
+ parsed,
+ latest,
+ (await loadCaseDecisionInputs(accounting, userId, input.caseId)).birthDate,
+ );
const currentSnapshot = snapshotSourceFromDossier(parsed, null);
const storedSnapshot = storedSnapshotSource(latest);
const scoreableCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot);
@@ -2406,9 +2410,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint });
try {
const dossier = await loadV9CaseDossier(accounting, userId, input.caseId);
- const decision = decideFromDossier(dossier, {
+ const decision = decideFromDossier(dossier, await loadCaseDecisionInputs(accounting, userId, input.caseId, {
currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
- });
+ }));
const credibleRange = adoptedCredibleRangePayload({
startTime: decision.credibleRange?.[0],
endTime: decision.credibleRange?.[1],
diff --git a/frontend/tests/database-segment-opening.test.ts b/frontend/tests/database-segment-opening.test.ts
index a158615f..c68aab98 100644
--- a/frontend/tests/database-segment-opening.test.ts
+++ b/frontend/tests/database-segment-opening.test.ts
@@ -81,7 +81,7 @@ test("segment opening PostgreSQL owns stable domain, full original checks and on
assert.equal(fixture.psql(`select count(*) from public.agentic_rectification_conversation_focuses where case_id='${caseId}'`), "0");
const dossier = await loadV9CaseDossier(service, userId, caseId);
assert.equal(dossier.case.status, "closed");
- const publicView = dossierResponse(dossier, [], { status: "current", requiresSkillAdoption: false } as never);
+ const publicView = dossierResponse(dossier, [], { status: "current", requiresSkillAdoption: false } as never, { birthDate: "1900-06-15" });
assert.equal(publicView.current_question, null);
assert.equal(publicView.choice_card, null);
assert.equal(publicView.step_state.headline, "盘型一致,不用校正");
diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts
index f4ab30d5..1a5ab92c 100644
--- a/frontend/tests/rectification-answer-choice.test.ts
+++ b/frontend/tests/rectification-answer-choice.test.ts
@@ -1206,7 +1206,10 @@ test("failed card persist still leaves a spoken collect next step", () => {
assert.match(source, /persist next focus failed case=/);
assert.doesNotMatch(source, /if \(fallback\.status === "created"/);
const applied = source.slice(source.indexOf("async function persistApplied"));
- const loadAt = applied.indexOf("loadV9CaseCompute");
+ // 原值: persistApplied 切片里 loadV9CaseCompute 出现在 decideAfterInferenceChange 之前。
+ // 新值: 同一位置改查 loadCaseDecisionInputs。
+ // 原因: 出生日期改由共享入口读取;该函数内部才调用 loadV9CaseCompute,切片里不再出现原函数名。
+ const loadAt = applied.indexOf("loadCaseDecisionInputs");
// 原值: publicNextAction(decideAfterInferenceChange({...})) 内联一次。
// 新值: 先 decideAfterInferenceChange 得到 nextDecision,再 publicNextAction(nextDecision)。
// 原因: 采用旁白需要完整 decision(precisionStage / droppedProbes),不能只拿公开 nextAction。
diff --git a/frontend/tests/rectification-delivery-dup-adopt-20261006.test.ts b/frontend/tests/rectification-delivery-dup-adopt-20261006.test.ts
new file mode 100644
index 00000000..9b11fdfb
--- /dev/null
+++ b/frontend/tests/rectification-delivery-dup-adopt-20261006.test.ts
@@ -0,0 +1,549 @@
+import assert from "node:assert/strict";
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
+import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
+import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
+import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
+import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
+import { RectificationSegmentDelivery } from "../src/components/rectification-segment-delivery.tsx";
+import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
+import type { RangeDeliveryProjection } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
+import { persistExhaustionGateTurn } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
+import { dossierResponse } from "../src/lib/rectification-agentic/v9/case-dossier-response.ts";
+import { decisionInputsFromSnapshot } from "../src/lib/rectification-agentic/v9/decision-inputs.ts";
+import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
+import {
+ alreadyDelivered,
+ deliveryCarrierNarration,
+ markDeliveryTurn,
+ resetDeliveryTurnGuardForTests,
+} from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
+import {
+ evidenceLedgerFingerprint,
+ parseV9CaseDossier,
+ RectificationToolServiceError,
+ type V9CaseDossier,
+} from "../src/lib/rectification-agentic/v9/tool-service.ts";
+import { adoptionRefusalCopy, runRectificationCandidateAccept } from "../src/lib/rectification-chat-accept-run.ts";
+import {
+ CASE_DOSSIER_RPC,
+ CASE_ID,
+ candidateSnapshotFixture,
+ dossierFixture,
+ fakeAccounting,
+ RESULT_ID,
+ USER_ID,
+} from "./rectification-v9-test-support.ts";
+import {
+ chatRunRecorder,
+ jsonResponse,
+ offerableCandidateResult,
+ withFakeFetch,
+} from "./rectification-chat-run-fixtures.ts";
+
+// Fictional birth. The 10-06 incident card mentioned another date; do not copy it into fixtures.
+const BIRTH = "1988-03-12";
+const OPEN_START = "03:00";
+const OPEN_END = "07:00";
+const RANGE_START = "04:45";
+const RANGE_END = "05:15";
+const TIME_A = "04:53";
+const TIME_B = "05:00";
+const ID_A = "88888888-8888-4888-8888-888888888881";
+const ID_B = "88888888-8888-4888-8888-888888888882";
+const TIMES = [TIME_A, TIME_B];
+const SET_ID = candidateSetId(RANGE_START, RANGE_END, TIMES);
+const ALGO = "rectification-v5-matrix-scoring-8";
+const POLICY = "fictional-decision-policy-v1";
+const REWRITE = "这一轮已经把范围收窄,可以按代表时间看盘。";
+const FALLBACK = "分钟范围 04:45–05:15。现在还剩 04:45–05:15 里 2 个候选。这两分钟按现有信息分不开。";
+
+const evidence = [
+ ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", "career_entry", "career", "2012-09-01", "入职"],
+ ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", "education_completion", "education", "2008-06-01", "毕业"],
+ ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", "family_event", "family", "2014-03-01", "家里添丁"],
+ ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4", "relationship_start", "relationship", "2016-05-01", "恋爱"],
+].map(([id, event_kind, domain, occurred_from, summary], index) => ({
+ id,
+ source_turn_id: "22222222-2222-4222-8222-222222222222",
+ subject: "self",
+ event_kind,
+ domain,
+ occurred_from,
+ occurred_to: null,
+ date_precision: "month",
+ summary,
+ status: "confirmed",
+ supersedes_evidence_id: null,
+ created_at: `2026-10-06T10:00:0${index}.000Z`,
+}));
+
+const probe = {
+ id: "probe:relocation.1998.dasha_activation",
+ semantic_key: "relocation.1998.dasha_activation",
+ candidate_split_hash: `${SET_ID}:relocation.1998`,
+ domain: "relocation",
+ year: 1998,
+ question: "1998 年前后有没有搬过家?",
+ candidate_ids: TIMES,
+ expected_outcomes: [
+ { answer_class: "yes", supports: [TIME_A], conflicts: [TIME_B] },
+ { answer_class: "weak_yes", supports: [TIME_A], conflicts: [TIME_B] },
+ { answer_class: "no", supports: [TIME_B], conflicts: [TIME_A] },
+ { answer_class: "unsure", supports: [], conflicts: [] },
+ ],
+ information_gain: 0.56,
+ source: "dasha_activation",
+};
+
+function inference(withProbe: boolean, tied: boolean) {
+ const supportA = tied ? 16 : 40;
+ const supportB = tied ? 16 : 8;
+ return {
+ algorithm_version: INFERENCE_ALGORITHM_VERSION,
+ candidate_set_id: SET_ID,
+ revision: 4,
+ phase: "discrimination",
+ result_status: "discriminating",
+ range_start: RANGE_START,
+ range_end: RANGE_END,
+ candidates: [
+ { id: ID_A, time: TIME_A, cluster_range: [RANGE_START, RANGE_END], prior_score: supportA, posterior_score: supportA, probability: tied ? 0.5 : 0.8, status: "active", rank: 1, strong_conflict_count: 0 },
+ { id: ID_B, time: TIME_B, cluster_range: [RANGE_START, RANGE_END], prior_score: supportB, posterior_score: supportB, probability: tied ? 0.5 : 0.2, status: "active", rank: 2, strong_conflict_count: 0 },
+ ],
+ events: evidence.map((item, index) => ({
+ id: item.id, domain: item.domain, year: 2008 + index, precision: "month", usage: "training",
+ })),
+ probes: withProbe ? [probe] : [],
+ answered_probes: [
+ { probe_id: "probe-1", semantic_key: "career.2012.test", candidate_split_hash: "split-1", answer_class: "yes", classified_from: "choice" },
+ { probe_id: "probe-2", semantic_key: "education.2008.test", candidate_split_hash: "split-2", answer_class: "yes", classified_from: "choice" },
+ { probe_id: "probe-3", semantic_key: "family.2014.test", candidate_split_hash: "split-3", answer_class: "no", classified_from: "choice" },
+ { probe_id: "probe-4", semantic_key: "relationship.2016.test", candidate_split_hash: "split-4", answer_class: "yes", classified_from: "choice" },
+ ],
+ rounds: [],
+ entropy: 1,
+ representative_time: TIME_A,
+ credible_range: [RANGE_START, RANGE_END],
+ refresh_attempts: [],
+ };
+}
+
+function parsed(withProbe: boolean, tied: boolean, fingerprint: string | null): V9CaseDossier {
+ const supportA = tied ? 16 : 40;
+ const supportB = tied ? 16 : 8;
+ const snapshot = candidateSnapshotFixture({
+ representativeTime: TIME_A,
+ selectionAllowed: true,
+ evidenceLedgerFingerprint: fingerprint,
+ candidates: [
+ { candidate_id: ID_A, rank: 1, time: TIME_A, relative_support: supportA, tied_minute_count: tied ? 2 : 1 },
+ { candidate_id: ID_B, rank: 2, time: TIME_B, relative_support: supportB, tied_minute_count: tied ? 2 : 1 },
+ ],
+ decisionReceipt: {
+ policy_version: POLICY,
+ display_allowed: true,
+ accept_allowed: true,
+ confirm_allowed: false,
+ acceptance_allowed: true,
+ selection_allowed: true,
+ propose_allowed: true,
+ confirmation_allowed: false,
+ inference_state: inference(withProbe, tied),
+ },
+ });
+ snapshot.algorithm_version = ALGO;
+ const rpc = dossierFixture({
+ evidenceCount: evidence.length,
+ evidence,
+ candidateRange: { start_time: OPEN_START, end_time: OPEN_END },
+ latestResult: snapshot,
+ });
+ (rpc.case as { rectification_domain?: string }).rectification_domain = "general";
+ const dossier = parseV9CaseDossier(rpc);
+ if (!dossier) throw new Error("dossier did not parse");
+ return dossier;
+}
+
+function currentDossier(withProbe: boolean, tied: boolean): V9CaseDossier {
+ const draft = parsed(withProbe, tied, null);
+ return parsed(withProbe, tied, evidenceLedgerFingerprint(draft.evidence));
+}
+
+function sharedView(dossier: V9CaseDossier) {
+ const inputs = decisionInputsFromSnapshot(BIRTH, {
+ currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence),
+ });
+ const answer = decideFromDossier(dossier, inputs);
+ const accept = decideFromDossier(dossier, inputs);
+ const projected = dossierResponse(dossier, [], { status: "verified", requiresSkillAdoption: false } as never, {
+ birthDate: inputs.birthDate,
+ liveIdentity: { algorithmVersion: ALGO, policyVersion: POLICY },
+ });
+ const fields = publicDecisionFields(answer);
+ assert.equal(publicDecisionFields(accept).session_outcome, fields.session_outcome);
+ assert.equal(publicDecisionFields(accept).can_adopt, fields.can_adopt);
+ assert.equal(projected.case.result_identity.read_only, false);
+ assert.equal(projected.interview.session_outcome, fields.session_outcome);
+ assert.equal(projected.interview.can_adopt, fields.can_adopt);
+ assert.equal(projected.latest_result && "can_adopt" in projected.latest_result ? projected.latest_result.can_adopt : null, fields.can_adopt);
+ return fields;
+}
+
+test("answer, accept, and GET share the birth date when a tied range still has an under-age probe", () => {
+ const dossier = currentDossier(true, true);
+ const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
+ const shared = sharedView(dossier);
+ assert.equal(shared.session_outcome, "adopt_representative");
+ assert.equal(shared.can_adopt, true);
+ const oldAccept = publicDecisionFields(decideFromDossier(dossier, { currentEvidenceFingerprint: fingerprint }));
+ assert.equal(oldAccept.session_outcome, "discriminate_candidates");
+ assert.equal(oldAccept.can_adopt, false);
+});
+
+test("a tied range with no under-age probe adopts with or without the birth date", () => {
+ const dossier = currentDossier(false, true);
+ const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
+ const shared = sharedView(dossier);
+ assert.equal(shared.session_outcome, "adopt_representative");
+ assert.equal(shared.can_adopt, true);
+ const oldAccept = publicDecisionFields(decideFromDossier(dossier, { currentEvidenceFingerprint: fingerprint }));
+ assert.equal(oldAccept.session_outcome, shared.session_outcome);
+ assert.equal(oldAccept.can_adopt, shared.can_adopt);
+});
+
+for (const withProbe of [true, false]) {
+ test(`answer, accept, and GET stay aligned when the minutes are not tied (probe ${withProbe})`, () => {
+ const fields = sharedView(currentDossier(withProbe, false));
+ assert.equal(typeof fields.session_outcome, "string");
+ assert.equal(typeof fields.can_adopt, "boolean");
+ });
+}
+
+test("a dossier projection without a readable birth date fails closed", () => {
+ const dossier = currentDossier(true, true);
+ assert.throws(
+ () => dossierResponse(dossier, [], { status: "verified", requiresSkillAdoption: false } as never, {
+ liveIdentity: { algorithmVersion: ALGO, policyVersion: POLICY },
+ }),
+ (error: unknown) => error instanceof RectificationToolServiceError && error.code === "birth_snapshot_unavailable",
+ );
+ assert.throws(
+ () => decisionInputsFromSnapshot(null),
+ (error: unknown) => error instanceof RectificationToolServiceError && error.code === "birth_snapshot_unavailable",
+ );
+});
+
+test("production decideFromDossier calls share the birth snapshot helper", () => {
+ const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src");
+ const files: string[] = [];
+ const walk = (dir: string) => {
+ for (const name of readdirSync(dir)) {
+ const full = path.join(dir, name);
+ if (statSync(full).isDirectory()) walk(full);
+ else if (name.endsWith(".ts") || name.endsWith(".tsx")) files.push(full);
+ }
+ };
+ walk(srcRoot);
+ const misses: string[] = [];
+ const needle = "decideFromDossier(";
+ for (const file of files) {
+ const text = readFileSync(file, "utf8");
+ let from = 0;
+ while (from < text.length) {
+ const at = text.indexOf(needle, from);
+ if (at < 0) break;
+ from = at + needle.length;
+ const before = text.slice(Math.max(0, at - 40), at);
+ if (before.includes("function ")) continue;
+ const window = text.slice(Math.max(0, at - 1800), at + needle.length + 240);
+ if (!window.includes("decisionInputsFromSnapshot") && !window.includes("loadCaseDecisionInputs")) {
+ const line = text.slice(0, at).split("\n").length;
+ misses.push(`${path.relative(srcRoot, file)}:${line}`);
+ }
+ }
+ }
+ assert.deepEqual(misses, []);
+});
+
+function turns(text: string) {
+ return [
+ { id: "t-user", role: "user", text: "A", status: "completed", created_at: "2026-10-06T00:00:00.000Z" },
+ { id: "t-ack", role: "assistant", text, status: "completed", created_at: "2026-10-06T00:00:01.000Z" },
+ ];
+}
+
+function gateAccounting(last: string) {
+ return fakeAccounting({
+ [CASE_DOSSIER_RPC]: () => dossierFixture({ turns: turns(last) }),
+ append_agentic_rectification_turn: () => ({ turn_id: "44444444-4444-4444-8444-444444444444", idempotent: false }),
+ });
+}
+
+test("the exit fill-in does not write again when the last reply is the stored delivery rewrite", async () => {
+ resetDeliveryTurnGuardForTests();
+ markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: REWRITE });
+ const accounting = gateAccounting(REWRITE);
+ await persistExhaustionGateTurn({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ hostNarration: FALLBACK,
+ resultId: RESULT_ID,
+ });
+ assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 0);
+ assert.equal(alreadyDelivered({ caseId: CASE_ID, resultId: RESULT_ID, hasUserMessage: false }), true);
+ resetDeliveryTurnGuardForTests();
+});
+
+test("the exit fill-in still writes once when the stored rewrite is not the last reply", async () => {
+ resetDeliveryTurnGuardForTests();
+ markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: REWRITE });
+ const accounting = gateAccounting("已记录,范围收到 04:45–05:15。");
+ await persistExhaustionGateTurn({
+ accounting: accounting.client,
+ userId: USER_ID,
+ caseId: CASE_ID,
+ hostNarration: FALLBACK,
+ resultId: RESULT_ID,
+ });
+ assert.equal(accounting.calls.filter((call) => call.fn === "append_agentic_rectification_turn").length, 1);
+ assert.equal(alreadyDelivered({ caseId: CASE_ID, resultId: RESULT_ID, hasUserMessage: false }), true);
+ resetDeliveryTurnGuardForTests();
+});
+
+test("the first stored delivery sentence wins inside the window", () => {
+ resetDeliveryTurnGuardForTests();
+ markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: REWRITE, now: 1_000 });
+ markDeliveryTurn({ caseId: CASE_ID, resultId: RESULT_ID, narration: FALLBACK, now: 2_000 });
+ assert.equal(deliveryCarrierNarration({ caseId: CASE_ID, resultId: RESULT_ID, now: 3_000 }), REWRITE);
+ resetDeliveryTurnGuardForTests();
+});
+
+const CANDIDATE = "88888888-8888-4888-8888-888888888881";
+
+function segmentSummary(): SegmentSummary {
+ return {
+ targets: ["D1"],
+ window_minutes: 30,
+ charts: [{
+ chart: "D1",
+ segments: [],
+ full_window_unique: false,
+ tier: "credible",
+ sign: 3,
+ alternatives: [],
+ shares: [1],
+ alive_segments: 1,
+ top_segment: 0,
+ top_share: 1,
+ tied: false,
+ }],
+ scan_complete: false,
+ no_rectification_needed: false,
+ adoption_minute: { offset: 0, time: TIME_A, date: BIRTH, signs: { D1: 3 } },
+ };
+}
+
+function segmentDelivery(): RangeDeliveryProjection {
+ return {
+ segment_summary: segmentSummary(),
+ range: [RANGE_START, RANGE_END],
+ representative_time: TIME_A,
+ representative_candidate_id: CANDIDATE,
+ event_count: 4,
+ fit_percent: null,
+ boundary: "这只是代表性候选,不是已确认的唯一出生分钟。",
+ shared_traits: [],
+ columns: [],
+ more_count: 0,
+ more_label: null,
+ verification_markdown: null,
+ narrow_hint: null,
+ tie_break_available: false,
+ tie_break_note: null,
+ provenance_line: null,
+ record_conflict: null,
+ };
+}
+
+function cardResult(
+ sessionOutcome: RectificationCandidateResult["sessionOutcome"],
+ canAdopt: boolean,
+ delivery: RangeDeliveryProjection,
+): RectificationCandidateResult {
+ return {
+ resultId: "result-1",
+ candidates: [{
+ candidateId: CANDIDATE,
+ rank: 1,
+ time: TIME_A,
+ relativeSupport: 16,
+ tiedMinuteCount: 2,
+ candidate_date: BIRTH,
+ }],
+ overallConfidence: "medium",
+ selectionAllowed: true,
+ canAdopt,
+ confirmationAllowed: false,
+ decisionReceipt: null,
+ representativeTime: TIME_A,
+ selectedTime: null,
+ selectionKind: null,
+ houseTable: null,
+ houseTablesByTime: {},
+ natalRecast: null,
+ techniqueAudit: [],
+ windowTransitions: [],
+ eventDashaLedger: [],
+ dashaAgreement: null,
+ lagnaContrast: null,
+ nakshatraBoundary: null,
+ precisionStage: null,
+ oosBlindPrompts: [],
+ confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
+ validated: false,
+ completionStatus: null,
+ sessionOutcome,
+ credibleRange: [RANGE_START, RANGE_END],
+ declaredBirthDate: "1988-03-11",
+ rangeDelivery: delivery,
+ verificationReportMarkdown: null,
+ };
+}
+
+function renderRange(result: RectificationCandidateResult): string {
+ return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
+ result,
+ acceptingCandidateId: null,
+ readonly: false,
+ onAccept: () => {},
+ }));
+}
+
+test("the segment card keeps the range and hides the adopt button when adoption is not allowed", () => {
+ const html = renderToStaticMarkup(createElement(RectificationSegmentDelivery, {
+ summary: segmentSummary(),
+ delivery: segmentDelivery(),
+ rangeLabel: "04:45–05:15",
+ resultId: "result-1",
+ busy: false,
+ accepting: false,
+ onAccept: () => {},
+ markdown: null,
+ canAdopt: false,
+ }));
+ assert.match(html, /用哪张盘解读/);
+ assert.match(html, /分钟范围/);
+ assert.doesNotMatch(html, /