Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b410c141a | |||
| ca4e2408d8 | |||
| 3edb628cb5 |
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-05 — 带年份经历没问完不出时间卡,也不写验证报告
|
||||
|
||||
生时校正里,学业、钱、搬家、身体或工作这些带年份的口述还没问完(也没说没有)时,页面只显示只读时间范围,没有可点的时间卡,助手也不写验证报告。这些问完之后,报告和卡片在同一轮出现,下面不再追问。你说「没有更多」、已经选定时间、或题目已经问尽,仍按原来的收口。Skill 版本仍是 10.0.14。
|
||||
|
||||
## 2026-09-05 — 区分卡答发生了之后不再问同一领域哪年
|
||||
|
||||
生时校正里,如果刚在选择卡上确认某类经历发生过,不会紧接着再问「还记得哪年…」同一领域的采集题。点选仍不记入证据账本。那年没有或记不清时,仍可以问别的年份。Skill 版本仍是 10.0.14。
|
||||
|
||||
@@ -8437,6 +8437,7 @@
|
||||
- 复发自:BUG-519(采用早退把未用采集域一并吃掉);BUG-539 把该收口写成非缺陷
|
||||
- 修复版本:`998406ce`
|
||||
- 修正:`998406ce` 把家人之后的 dated 补采集做成无条件优先,现成搬家 A/B 卡被财务口述挤掉。见 BUG-547。
|
||||
- 修正:剩余采集豁免了 `deferAdoption`,但决策层在分离度够时仍判 `adopt_representative`,出牌与追问同轮。见 BUG-550。
|
||||
|
||||
## BUG-547 | 现成区分卡被口述补采集挤掉
|
||||
|
||||
@@ -8486,3 +8487,19 @@
|
||||
- 复发自:无
|
||||
- 修复版本:`988d97ae`
|
||||
|
||||
## BUG-550 | 带年份采集没问完就出采用卡,同一轮又被下一道采集题挤掉
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-05
|
||||
- 最近更新:2026-09-05
|
||||
- 影响面:`decideRectification`、`decideFromDossier`、`decideAfterInferenceChange`;`next_user_action`、offer 工具、采集焦点持久化、客户端时间卡四个消费者
|
||||
- 用户现象:家人题答「没有」后出现财务口述题,下面已经挂着可点的候选时间卡;答完财务后助手写出整份生时校正验证报告并说可以采用某个钟点,紧跟着又问搬家,时间卡消失。
|
||||
- 触发条件:学业 / 感情 / 事业已有带年份经历,家人已拒答,财务 / 搬家 / 健康 / 职业口述还没问也没拒答;候选分离度已经够给代表时间。
|
||||
- 根因:决策层与计划层各说各的。`datedMethodCollectOpen` 只数四个阻断方法(Dasha / D9 / D10 / 家人),不数 `DATED_COLLECT_ORDER` 里还没问的学业、财务、搬家、健康与职业口述。分离度够时直接 `adopt_representative`。BUG-546 只让剩余采集豁免 `deferAdoption`,所以 `session_outcome` 已经是可采用,`next_followup` 却仍是下一道采集。四个消费者听了不同的一层:`next_user_action` 让模型写出牌报告并调 offer;offer 工具放行;焦点持久化仍写下道采集题;客户端按 `session_outcome` 出卡,未答题在场时才被 BUG-544 藏掉。
|
||||
- 修复:传给 `decideRectification` 的 `datedMethodCollectOpen` 叠加「采集计划的下一问仍是剩余带年份采集」。分离度够、尚未采用、用户未停止、也未耗尽时,保持 `collect_evidence`,公开 `can_adopt=false`。问完(覆盖、拒答)之后同一轮才 `adopt_representative`,报告和卡片一起出,下面不再跟采集题。不改 Skill、不改引擎、不改 BUG-544 隐藏规则。用户说「没有更多」、已采用、已耗尽仍走原出口。
|
||||
- 验证:`rectification-decide-next-action`:分离且采集仍开 → `collect_evidence` / `ask_fact_collection` / 公开不可采用;`userStopped` 与 `accepted` 不因此改口。`rectification-collect-direction-20260904`:家人已拒、财务未问 → 决策采集、持久化财务、offer 工具 `offer_not_allowed`;财务 / 搬家 / 健康 / 职业补齐后 `adopt_representative` 且 `next_followup` 为空。`npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` 904/904。
|
||||
- 防复发:剩余带年份采集未完时,不得因分离度够就判 `adopt_representative`。不得只改客户端藏卡或只改 offer 工具描述来掩盖决策与计划矛盾。不得把「卡先给、问题照问」当成默认;那条路要产品另开单。
|
||||
- 相关记录:BUG-544、BUG-546、BUG-547、BUG-549
|
||||
- 复发自:BUG-546(计划层继续采集,决策层已经可采用)
|
||||
- 修复版本:`ca4e2408`
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# PROGRESS · 采集没问完就出采用卡(2026-09-05)
|
||||
|
||||
工作树:`.worktrees/rectification-collect-vs-offer-consistency-20260905`
|
||||
分支:`codex/rectification-collect-vs-offer-consistency-20260905`
|
||||
基线:`origin/staging` @ `3edb628c`(任务书写的是 `1052a027`;开工时 staging 已含后续提交,以当时 `origin/staging` 为准)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`、未改引擎、未改 BUG-544 隐藏规则。
|
||||
|
||||
| 任务 | 状态 | BUG |
|
||||
| --- | --- | --- |
|
||||
| 剩余带年份采集未完不判可采用;出牌轮才出卡写报告 | 完成 | BUG-550 |
|
||||
|
||||
## 编号
|
||||
|
||||
开工时最大号 BUG-549。本单 **BUG-550**。未占用预留的 BUG-542。
|
||||
|
||||
## 产品取舍
|
||||
|
||||
任务书决策 1:**采集优先**。学业 / 财务 / 搬家 / 健康 / 职业口述没问完(也没拒答)之前,决策保持 `collect_evidence`:页面只有只读区间、没有可点的卡、Agent 不写验证报告。问完之后一轮内报告 + 卡片一起出,下面不再跟问题。用户说「没有更多」、已采用、已耗尽出口不变。
|
||||
|
||||
未走决策记录 3 的否决项(卡先给、问题照问)。那条路要动 BUG-544 藏卡规则、不动决策层;本单按默认走决策层一处改。
|
||||
|
||||
未过滤流事件 JSON(`{"type":"skill.bound"}`),见任务书 §7。
|
||||
|
||||
## 实现要点
|
||||
|
||||
- `decideFromDossier` / `decideAfterInferenceChange`:`datedMethodCollectOpen(collecting.methods) || isRemainingEvidenceCollect(collecting.next_followup)`。`collecting` 仍用 `sessionOutcome: "collect_evidence"`、不带区分卡目录,所以下一问是账本上还没问的带年份采集。
|
||||
- `decideRectification`:分离度够、`accepted` 之后、确认门之前,若采集仍开且用户未停止 → `collect()`。`exhausted` 已在上一分支返回,条件里不再写 `stopClass?.kind !== "exhausted"`(tsc 会判无重叠)。
|
||||
- 四个消费者不改各自规则:`next_user_action` 变成 `ask_fact_collection`;offer 因 kind 不在 `offerSessionKinds` 而 `offer_not_allowed`;客户端走只读区间;`shouldSkipFollowupPersist` 行为不变。
|
||||
- 既有「可采用 / 出卡 / 采用旁白」夹具把剩余带年份域补覆盖或拒答,避免训练门未开或剩余采集把主题测成采集。
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` | 904 项;pass 904;fail 0 |
|
||||
| `npx tsc --noEmit` | 0 错 |
|
||||
| 改动文件 eslint `--quiet` | 0 error |
|
||||
| `npm run lint` | 0 error;73 条既有 warning,不在本单文件 |
|
||||
| `page.tsx` | 未改 |
|
||||
|
||||
开工对照 BUG-549 进度记录为 898 项。本单新增决策层与端到端用例后为 904。
|
||||
|
||||
未做浏览器真人走查。清单:`docs/testing/rectification-collect-vs-offer-consistency-20260905.md`。旧会话在部署前仍可能先出卡再被采集题挤掉。
|
||||
@@ -55,6 +55,7 @@
|
||||
| — | `PROGRESS-rectification-family-collect-continue-20260905.md` | 家人答「没有」后直接给结果,未继续收能区分分钟的经历 | 已由 cd704a92 修正并验收通过 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-546) |
|
||||
| `TASK-rectification-family-collect-continue-fix-20260905.md` | `PROGRESS-rectification-family-collect-continue-20260905.md` | `998406ce` 修复单:家人之后的 dated 补采集无条件抢在现成区分卡之前(choice-card A/B 卡用例回归),另两条顺序断言缺三栏说明 | 已验收通过(浏览器走查欠) | `cd704a92`(BUG-547) |
|
||||
| `TASK-rectification-probe-answer-covers-domain-20260905.md` | `PROGRESS-rectification-probe-answer-covers-domain-20260905.md` | 区分卡答 A/B 后紧接着又问同领域"哪年…"采集题:点选答案只进推断层不写账本,计划层领域覆盖只看账本;改为 yes/weak_yes 视为该领域采集已覆盖(不写账本、不动门) | 待验收 | `988d97ae`(BUG-549) |
|
||||
| `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 待验收 | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) |
|
||||
| `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) |
|
||||
| `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) |
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# TASK · 采集没问完就出采用卡、出卡后又被采集题挤掉(BUG-546 的另一半)(2026-09-05)
|
||||
|
||||
- 基线:`origin/staging` @ `1052a027`(含 BUG-546/547/549)
|
||||
- 分支:`codex/rectification-collect-vs-offer-consistency-20260905`
|
||||
- 执行方:coding agent;验收:Claude
|
||||
- 涉及文件:`frontend/src/lib/rectification-agentic/core/rectification-decision.ts`、`frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts`;测试见 §5。不改客户端隐藏规则(BUG-544)、不改 Skill 正文、不改引擎。
|
||||
- BUG 编号起点:**BUG-550**(开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核;BUG-542 仍为 `TASK-api-not-configured-mislabel-20260904.md` 预留)
|
||||
- 串行:与 `TASK-rectification-probe-answer-covers-domain-20260905.md`(已合入 `988d97ae`)无冲突;若 `TASK-api-not-configured-mislabel` 同日开工,两者不改同一文件。
|
||||
|
||||
## 1. 事故实证(staging,2026-09-05,部署 `afd14948`)
|
||||
|
||||
同一个 Case,用户连续经历:
|
||||
|
||||
| 步 | 用户看到 | 期望 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 家人题答「没有」→「记下了,这方面先跳过。」→ 财务口述采集题「钱的方面…」 | 只问财务题 |
|
||||
| 2 | 财务题还没答,**候选时间卡(9 个候选 + 相对支持)已经挂在下面** | 采集没完不出卡 |
|
||||
| 3 | 用户答完财务题 → Agent 调 `rectification-offer-candidates`,正文写出整份八法「生时校正验证报告」,含「唯一分钟确认 · 本会话以代表性时间收口」「当前可以采用 HH:MM」 | 出牌轮才写报告 |
|
||||
| 4 | 报告末尾紧跟搬家采集题「有没有哪年搬家,或开始长期住在外地?」,**时间卡消失** | 出了牌就不再追问;或者压根不出牌 |
|
||||
|
||||
## 2. 根因
|
||||
|
||||
一句话:**决策层已经判成「可采用」,计划层却还有没问完的带年份采集,两层各说各的,四个消费者听了不同的一层。**
|
||||
|
||||
1. `core/rectification-decision.ts::decideRectification`:候选分离度够(`separation.sufficient`)时直接 `finish("adopt_representative")`。`datedMethodCollectOpen` 只在 `!separation.sufficient` 分支被看(约 L307),而且 `v9/method-followup.ts::datedMethodCollectOpen`(L377)只数 `dasha_events / d9_relationship / d10_career / relatives` 四个阻塞方法,**不数** `DATED_COLLECT_ORDER` 里的学业、财务、搬家、健康与职业口述。事业 + 家人一过,四个方法都 covered,决策就成了 `adopt_representative`。
|
||||
2. `v9/method-followup.ts::buildMethodFollowupPlan` 尾部(约 L2360–2371):BUG-546 让 `isRemainingEvidenceCollect(next)` 的采集**豁免** `deferAdoption`,于是 `next_followup = 搬家采集` 与 `session_outcome = adopt_representative` 同时成立。
|
||||
3. 四个消费者:
|
||||
- `buildNextUserAction`(约 L1434):只看 `sessionOutcome`,输出 `adopt_representative`「请用户从下方时间卡片选择」→ 模型照 Skill 10.0.14 L119「出牌/采用轮正文按八法写出验证报告」写了整份报告,并调 `rectification-offer-candidates`。
|
||||
- `mastra/rectification-v9-tools.ts` offer 工具(约 L1961):门只看 `offerSessionKinds()` 含 `adopt_representative` → 放行,Case 转 `candidate_ready`。
|
||||
- `answer-choice.ts::shouldSkipFollowupPersist`(L135):`isRemainingEvidenceCollect` → 不跳过 → `persistNextInterviewIfIdle` 在同一轮把搬家采集写成焦点、挂到本轮 turn。
|
||||
- 客户端 `rectification-agentic-chat.tsx`(约 L1374–1383):`canShowRectificationSelectionCards` 看 `sessionOutcome` 为真 → 卡挂在最后一条已结算助手消息;唯一的挡板是「某条消息挂着未答问题」(BUG-544)。步 2 卡出现时那条财务题没有把卡挡住(焦点绑 turn 是 best-effort,`linkFocusAskedTurn` 失败只 warn),步 4 搬家题绑上了 turn 就把卡挡掉——同一条规则,一次没挡住、一次挡住了,看起来就是「卡先来、再丢」。
|
||||
|
||||
所以步 2 与步 4 是一个病:状态本身自相矛盾。步 3 的长报告不是模型自作主张,是 `next_user_action` 告诉它「这是出牌轮」。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
1. **采集优先,与 BUG-546 的既定意图一致**:只要采集计划(`decideFromDossier` 里的 `collecting` 计划)的 `next_followup` 仍是 `isRemainingEvidenceCollect`(学业 / 财务 / 搬家 / 健康 / 职业口述,未覆盖且未拒答),且 Case 未 accepted、用户未停止、未 exhausted,决策就保持 `collect_evidence / ask_fact_collection`,**不得**给出 `adopt_representative`。带年份域问完(覆盖、拒答或 BUG-549 的 yes 探针)之后,才进 `adopt_representative`,此时出牌 + 报告 + 卡片一次到位,且不再追问。
|
||||
2. 由决策层一处改,四个消费者自然一致:`next_user_action` 变 `ask_fact_collection`;offer 工具因 kind 不在 `offerSessionKinds` 而拒绝(`offer_not_allowed`);客户端 `canShowRectificationSelectionCards` 为假,改走 `canShowRectificationReadonlyRange`(只读区间,`collect_evidence` 已允许);`shouldSkipFollowupPersist` 行为不变。**不改** BUG-544 的隐藏规则,不改 offer 工具描述,不改 Skill 正文。
|
||||
3. 产品可否决项:若产品更想「卡先给、问题照问」(用户可随时选,也可再补一件事),改为放宽客户端 BUG-544 对 `collect_spoken` 的隐藏而**不动**决策层——两条路只能选一条,执行方按本单默认走 1;产品改主意时在进度记录写明。
|
||||
4. 报告里「本会话以代表性时间收口」这句来自 Skill 10.0.14 `SKILL.md` L41「本会话以此收口」与 L112 的转述,BUG 历史 L6151 删掉的是宿主机械文案,没删 Skill。本单不动 Skill;产品若要模型也不说这句,另开 Skill 10.0.15 单(词表禁用 + Skill 措辞)。
|
||||
5. 步 3 之前用户看到一段原始流事件 JSON(`{"type":"skill.bound"}`…)短暂出现又消失:本次代码通读没有找到把流事件当正文渲染的路径(客户端只把 `answer.delta.text` 进正文,服务端 `answer.delta` 只来自模型 `text-delta`,事件不持久化、模型看不到)。**不在本单**,见 §7。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
- BUG-546(无区分卡时继续补采集)、BUG-547(有卡先问卡)、BUG-549(yes 探针视为覆盖)、BUG-544(未答问题在场时隐藏采用卡)的用例原样通过。
|
||||
- `accepted` 早退路径(采用后只走 `remainingReverseVerifyProbes`)与 BUG-536–539 不受影响:`input.accepted` 为真时本单新增判断不得生效。
|
||||
- 用户停止(`userStopped` / `paused`)与 `exhausted` 的现有出口不变:用户说「没有更多了」仍按现有规则给区间或代表时间,不得因本单变成继续追问。
|
||||
- 不放宽 `meetsAcceptanceEventQuality`、`buildConfirmationGate`;不写账本;不改 `DATED_COLLECT_ORDER`。
|
||||
- 任务书 / 进度 / Bug 历史只用抽象年份与抽象分钟。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### 5.1 决策层:带年份采集未完不判「可采用」
|
||||
- `decision-from-dossier.ts::decideFromDossier` 与 `decideAfterInferenceChange`:把传给 `decideRectification` 的 `datedMethodCollectOpen` 改为 `datedMethodCollectOpen(collecting.methods) || isRemainingEvidenceCollect(collecting.next_followup)`(两处同改;`collecting` 计划已用 `sessionOutcome: "collect_evidence"` 构建,`next_followup` 现成)。
|
||||
- `core/rectification-decision.ts::decideRectification`:`separation.sufficient` 分支在 `finish("adopt_representative")` 之前(放在 `input.accepted` 判断之后、`confirmationAllowed` 之前),若 `input.datedMethodCollectOpen === true && !input.userStopped && stopClass?.kind !== "exhausted"` → `collect(separation, holdout, range, null, capability, stopReason)`。`accepted` 与 `awaiting_confirmation`(`confirmationAllowed`)路径不受影响。
|
||||
- 验收:`tests/rectification-decide-next-action.test.ts` 新增——分离度足够、`datedMethodCollectOpen: true`、未 accepted、未 stopped → `sessionOutcome === "collect_evidence"`、`nextAction === "ask_fact_collection"`、`can_adopt === false`;同输入 `userStopped: true` → 现有停止出口不变;同输入 `accepted: true` → `adopt_representative`。
|
||||
|
||||
### 5.2 端到端:出卡与追问不再同轮
|
||||
- `tests/rectification-collect-direction-20260904.test.ts` 或 `rectification-candidate-offer-anchor.test.ts` 新增用例(用现有 dossier fixture 的组装方式):学业 / 感情 / 事业有带年份证据、家人已拒答、财务未问、候选分离度足够 → `decideFromDossier` 给 `collect_evidence`,`buildNextUserAction` 给 `ask_fact_collection`,`persistNextInterviewIfIdle` 的计划 `next_followup.domain === "finance"`;把财务、搬家、健康都补齐(或拒答)后同一 dossier → `adopt_representative`,`next_followup === null`。
|
||||
- offer 工具:在 `tests/rectification-v9-tools*.test.ts`(或现有 offer 工具测试)加一条——上面「财务未问」的 dossier 调 `rectification-offer-candidates` 抛 `offer_not_allowed`。
|
||||
- 验收:`npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts` fail=0,总数不低于开工时。
|
||||
|
||||
### 5.3 记录
|
||||
- `docs/BUG_HISTORY.md`:BUG-550(决策层「可采用」与计划层「还要采集」并存;关联 BUG-544、546、547、549,说明 BUG-546 为何只修了一半)。
|
||||
- `CHANGELOG.md` 一条;`docs/tasks/PROGRESS-rectification-collect-vs-offer-consistency-20260905.md`;`docs/testing/rectification-collect-vs-offer-consistency-20260905.md`(真实环境:家人「没有」之后到财务 / 搬家 / 健康问完之前,页面只有只读区间、没有可点的时间卡、Agent 不写验证报告;带年份域问完后一轮内同时出现报告 + 卡片,且下面不再跟采集题)。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
5.1 与 5.2 一起做,不可拆;5.3 不可省。产品若选决策记录 3 的另一条路,5.1 整段作废、改客户端,须先在进度记录写明并通知验收方。
|
||||
|
||||
## 7. 不在本单(待定位)
|
||||
|
||||
- 原始流事件 JSON 短暂出现在对话区:需要产品下次复现时截图(是出现在助手气泡里,还是「正在…」工作行里)并记下当时选的模型;执行方顺手查该轮服务端日志是否有 `attempt.reset` / 重试。定位前不得加「防御性」过滤代码。
|
||||
|
||||
## 8. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-collect-vs-offer-consistency-20260905 .worktrees/rectification-collect-vs-offer-consistency-20260905 origin/staging
|
||||
cd .worktrees/rectification-collect-vs-offer-consistency-20260905
|
||||
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
|
||||
cd frontend && npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)|^not ok"
|
||||
```
|
||||
|
||||
收尾跑同一条命令 fail=0,再 `tsc --noEmit`、`npm run lint`。
|
||||
@@ -0,0 +1,39 @@
|
||||
# Staging 人肉复核 · 带年份采集没问完不出卡、也不写报告(2026-09-05)
|
||||
|
||||
给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。
|
||||
|
||||
对应 BUG-550。测之前先做第 0 条。本单部署前,旧会话仍可能在财务题还没答时就挂出可点时间卡,答完后又写整份验证报告并追问搬家。
|
||||
|
||||
## 0. 确认测的是新版本
|
||||
|
||||
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。
|
||||
|
||||
## 1. 家人「没有」之后到财务问完之前:只有只读区间(P0)
|
||||
|
||||
1. 新建生时校正。
|
||||
2. 先说清学业、感情、事业各一件记得住大概年份的事,把区分卡答完,直到出现家人那题。
|
||||
3. 回答「没有」。
|
||||
4. 出现财务口述题(「钱的方面…」)时看页面。
|
||||
|
||||
- ✅ 预期:只有只读时间范围。没有可点的候选时间卡。助手正文不是生时校正验证报告,也不说「当前可以采用」某个钟点。
|
||||
- ❌ 失败:财务题还没答,下面已经挂着可点的时间卡;或助手已经在写验证报告。
|
||||
|
||||
## 2. 搬家 / 健康 / 职业口述同样不出卡(P0)
|
||||
|
||||
继续答财务(补一件带年份的事或说「没有」),再看搬家、身体、工作口述。
|
||||
|
||||
- ✅ 预期:这些题还没问完(也没拒答)时,仍然只有只读区间、没有可点的卡、没有验证报告。
|
||||
- ❌ 失败:某一题还在问,卡片或报告已经出现。
|
||||
|
||||
## 3. 问完之后一轮内报告 + 卡片,下面不再跟问题(P0)
|
||||
|
||||
把财务、搬家、健康、职业口述都答完或都说没有。
|
||||
|
||||
- ✅ 预期:同一轮出现验证报告和可点的时间卡。这条助手消息下面不再跟财务 / 搬家 / 健康 / 职业采集题。
|
||||
- ❌ 失败:先出报告和卡,下面又问搬家;或报告出了但没有卡;或卡出了但下面还有口述题。
|
||||
|
||||
## 4. 三个旧出口不变(P1)
|
||||
|
||||
- 用户说「没有更多了」:仍按原来的规则给区间或代表时间,不得因此改口继续追问财务 / 搬家。
|
||||
- 已经点选并采用某个时间:不得因为本单再开始问带年份采集。
|
||||
- 题目已经问尽(耗尽):仍按原来的收口,不得改口继续采集。
|
||||
@@ -335,6 +335,9 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
|
||||
capability,
|
||||
});
|
||||
}
|
||||
if (input.datedMethodCollectOpen === true && !input.userStopped) {
|
||||
return collect(separation, holdout, range, null, capability, stopReason);
|
||||
}
|
||||
if (confirmationAllowed) {
|
||||
return finish("awaiting_confirmation", {
|
||||
input,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
blockingMethodsCovered,
|
||||
buildMethodFollowupPlan,
|
||||
datedMethodCollectOpen,
|
||||
isRemainingEvidenceCollect,
|
||||
} from "./method-followup";
|
||||
import { buildConfirmationGate } from "./confirmation-gate";
|
||||
import {
|
||||
@@ -629,7 +630,8 @@ export function decideFromDossier(
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
inferenceCredibleRange: inference?.credible_range ?? null,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
|
||||
|| isRemainingEvidenceCollect(collecting.next_followup),
|
||||
...decisionBudget,
|
||||
...evidenceStops,
|
||||
userUncertaintyHigh,
|
||||
@@ -715,7 +717,8 @@ export function decideAfterInferenceChange(input: {
|
||||
userStopped: input.userStopped,
|
||||
accepted: Boolean(input.dossier.case.acceptedTime),
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods),
|
||||
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
|
||||
|| isRemainingEvidenceCollect(collecting.next_followup),
|
||||
...decisionBudgetFromInference(input.state),
|
||||
...evidenceStops,
|
||||
userUncertaintyHigh,
|
||||
|
||||
@@ -240,8 +240,16 @@ test("collect-phase nonterminal exit persists a spoken collect when public can_a
|
||||
});
|
||||
|
||||
test("adopt_representative nonterminal exit stays satisfied without a new question", async () => {
|
||||
const decisionDossier = collectDecisionDossier([{ target_domain: "family", status: "declined" }]);
|
||||
const decisionDossier = collectDecisionDossier([
|
||||
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
|
||||
]);
|
||||
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
|
||||
// 旧:家人拒答即可 adopt_representative。新:带年份采集未完不得出牌;本夹具把剩余域拒答后才满足。
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
assert.equal(publicCanAdopt(decision), true);
|
||||
|
||||
|
||||
@@ -676,11 +676,13 @@ test("fourteen-probe case decides offer_provisional_range and skips leftover pro
|
||||
);
|
||||
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
// 旧:十四探针分不开就 offer_provisional_range / adopt_representative,财务采集只挂计划。
|
||||
// 新:财务口述未问完,决策保持采集;出牌轮才写报告。
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.probe, null);
|
||||
assert.equal(decision.precisionStage, "ready_to_adopt");
|
||||
assert.equal(decision.precisionStage, "collect_events");
|
||||
|
||||
const plan = planFrom(dossier);
|
||||
assert.equal(plan.next_followup?.domain, "finance");
|
||||
@@ -753,7 +755,7 @@ test("adoptDeliveryFacts names the representative, range, stop class, and post-a
|
||||
});
|
||||
|
||||
test("adopt narration agent keeps in-fact copy and fail-closes the rest", async () => {
|
||||
const dossier = caseDossier();
|
||||
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
@@ -949,7 +951,7 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
|
||||
});
|
||||
|
||||
test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
|
||||
const dossier = caseDossier();
|
||||
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
@@ -991,7 +993,7 @@ test("adopt narration diagnostics distinguish agent, validation, error, and not-
|
||||
});
|
||||
|
||||
test("adopt narration times out to the template without throwing", async () => {
|
||||
const dossier = caseDossier();
|
||||
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
@@ -1006,7 +1008,7 @@ test("adopt narration times out to the template without throwing", async () => {
|
||||
});
|
||||
|
||||
test("adopt narration does not leave an active timeout after the model returns", async () => {
|
||||
const dossier = caseDossier();
|
||||
const dossier = caseDossier({ declinedTopics: EXHAUSTED_COLLECT_TOPICS });
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
const facts = adoptDeliveryFacts(decision, dossier);
|
||||
const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。";
|
||||
|
||||
@@ -477,7 +477,13 @@ function adoptionDossier() {
|
||||
},
|
||||
}),
|
||||
conversationSummary: conversationSummaryFixture({
|
||||
declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }],
|
||||
declinedSkippedTopics: [
|
||||
{ target_domain: "family", status: "skipped" },
|
||||
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
|
||||
],
|
||||
activeFocus: activeFocusFixture({
|
||||
intent: "distinguish_candidates",
|
||||
targetDomain: "relocation",
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
collectionProgressFromReceipt,
|
||||
meetsAcceptanceEventQuality,
|
||||
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||||
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import {
|
||||
buildMethodFollowupPlan,
|
||||
buildNextUserAction,
|
||||
exhaustionSpokenCollectFollowup,
|
||||
holdoutFollowupFor,
|
||||
nextDatedCollectFollowup,
|
||||
@@ -15,14 +17,24 @@ import {
|
||||
type MethodFollowupEvidence,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import {
|
||||
persistNextInterviewAfterChoice,
|
||||
persistNextInterviewIfIdle,
|
||||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import {
|
||||
evidenceLedgerFingerprint,
|
||||
parseV9CaseDossier,
|
||||
RectificationToolServiceError,
|
||||
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||||
import {
|
||||
CANDIDATE_ID,
|
||||
CASE_ID,
|
||||
FOCUS_ID,
|
||||
SECOND_CANDIDATE_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
candidateSnapshotFixture,
|
||||
@@ -569,3 +581,209 @@ test("ledger-classified career probe answers do not cover career collect", () =>
|
||||
assert.equal(plan.next_followup?.domain, "career");
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
|
||||
});
|
||||
|
||||
const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883";
|
||||
|
||||
const CORE_DATED_FOR_OFFER = [
|
||||
dated("education", "2008", { eventKind: "education_start" }),
|
||||
dated("education", "2012", { eventKind: "education_completion" }),
|
||||
dated("relationship", "2014", { eventKind: "relationship_start" }),
|
||||
dated("career", "2011", { eventKind: "career_entry" }),
|
||||
] as const;
|
||||
|
||||
function rpcEvidenceFromDated(
|
||||
item: MethodFollowupEvidence & { id: string },
|
||||
) {
|
||||
return {
|
||||
id: item.id,
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: item.eventKind ?? "event",
|
||||
domain: item.domain,
|
||||
occurred_from: item.occurredFrom,
|
||||
occurred_to: item.occurredTo,
|
||||
date_precision: item.datePrecision,
|
||||
summary: `${item.occurredFrom ?? "undated"} ${item.eventKind ?? "event"}`,
|
||||
status: item.status,
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-09-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function declinedTopics(
|
||||
domains: readonly string[],
|
||||
): Array<{ target_domain: string; status: "declined"; intent: "collect_method_evidence" }> {
|
||||
return domains.map((domain) => ({
|
||||
target_domain: domain,
|
||||
status: "declined" as const,
|
||||
intent: "collect_method_evidence" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function conversationWithDeclines(domains: readonly string[]) {
|
||||
return {
|
||||
confirmed_evidence_summary: [],
|
||||
pending_revisions: [],
|
||||
active_focus: null,
|
||||
declined_skipped_topics: declinedTopics(domains),
|
||||
candidate_divergence_summary: null,
|
||||
missing_evidence_categories: [],
|
||||
last_result_policy: null,
|
||||
summary_version: 1,
|
||||
updated_at: "2026-09-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function separatedOfferSnapshot(rawEvidence: unknown[]) {
|
||||
const parsed = parseV9CaseDossier(dossierFixture({ evidence: rawEvidence }));
|
||||
assert.ok(parsed);
|
||||
return candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "05:00",
|
||||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(parsed.evidence),
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 62, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 22, tied_minute_count: 1 },
|
||||
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 16, tied_minute_count: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function collectVsOfferDossier(input: {
|
||||
extraEvidence?: readonly (MethodFollowupEvidence & { id: string })[];
|
||||
declined: readonly string[];
|
||||
}) {
|
||||
const evidence = [...CORE_DATED_FOR_OFFER, ...(input.extraEvidence ?? [])];
|
||||
const raw = evidence.map(rpcEvidenceFromDated);
|
||||
return dossierFixture({
|
||||
evidence: raw,
|
||||
evidenceCount: raw.length,
|
||||
latestResult: separatedOfferSnapshot(raw),
|
||||
conversationSummary: conversationWithDeclines(input.declined),
|
||||
});
|
||||
}
|
||||
|
||||
function nextActionFromDecision(
|
||||
dossier: NonNullable<ReturnType<typeof parseV9CaseDossier>>,
|
||||
decision: ReturnType<typeof decideFromDossier>,
|
||||
) {
|
||||
const catalog = {
|
||||
evidence: dossier.evidence,
|
||||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
};
|
||||
const plan = buildMethodFollowupPlan(catalog);
|
||||
return {
|
||||
plan,
|
||||
userAction: buildNextUserAction({
|
||||
scorableCount: dossier.evidence.length,
|
||||
evidenceCount: dossier.evidence.length,
|
||||
hasLatestResult: true,
|
||||
selectionAllowed: true,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
nextFollowup: plan.next_followup,
|
||||
workingTime: decision.representativeTime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistIdleFocus(rawDossier: ReturnType<typeof dossierFixture>) {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => rawDossier,
|
||||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||||
set_agentic_rectification_conversation_focus: (_fn, args) => ({
|
||||
focus: {
|
||||
id: FOCUS_ID,
|
||||
case_id: CASE_ID,
|
||||
question_id: args.p_question_id,
|
||||
intent: args.p_intent,
|
||||
target_evidence_id: args.p_target_evidence_id,
|
||||
target_domain: args.p_target_domain,
|
||||
target_kind: args.p_target_kind,
|
||||
expected_answer_schema: args.p_expected_answer_schema,
|
||||
status: "active",
|
||||
asked_at: "2026-09-04T00:00:00.000Z",
|
||||
resolved_at: null,
|
||||
asked_turn_id: args.p_asked_turn_id ?? null,
|
||||
},
|
||||
idempotent: false,
|
||||
}),
|
||||
});
|
||||
const persisted = await persistNextInterviewIfIdle({
|
||||
accounting: accounting.client,
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
});
|
||||
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
|
||||
return { persisted, setFocus };
|
||||
}
|
||||
|
||||
test("separated candidates keep collecting finance before offering time cards", async () => {
|
||||
const raw = collectVsOfferDossier({ declined: ["family"] });
|
||||
const dossier = parseV9CaseDossier(raw);
|
||||
assert.ok(dossier);
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(publicDecisionFields(decision).can_adopt, false);
|
||||
const { plan, userAction } = nextActionFromDecision(dossier, decision);
|
||||
assert.equal(plan.next_followup?.domain, "finance");
|
||||
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(userAction.id, "ask_method_followup");
|
||||
assert.notEqual(userAction.id, "adopt_representative");
|
||||
const idle = await persistIdleFocus(raw);
|
||||
assert.equal(idle.persisted.persisted, true);
|
||||
assert.equal(idle.setFocus?.args.p_target_domain, "finance");
|
||||
});
|
||||
|
||||
test("dated collect plus occupation close lets one round offer without another question", async () => {
|
||||
const extra = [
|
||||
dated("finance", "2017", { eventKind: "income_change" }),
|
||||
dated("relocation", "2016", { eventKind: "home_change" }),
|
||||
dated("health_pressure", "2015", { eventKind: "self_health_event" }),
|
||||
dated("occupation", "2011", { eventKind: "occupation_note" }),
|
||||
];
|
||||
const raw = collectVsOfferDossier({
|
||||
extraEvidence: extra,
|
||||
declined: ["family"],
|
||||
});
|
||||
const dossier = parseV9CaseDossier(raw);
|
||||
assert.ok(dossier);
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
assert.equal(publicDecisionFields(decision).can_adopt, true);
|
||||
const { plan, userAction } = nextActionFromDecision(dossier, decision);
|
||||
assert.equal(plan.next_followup, null);
|
||||
assert.equal(userAction.id, "adopt_representative");
|
||||
const idle = await persistIdleFocus(raw);
|
||||
assert.equal(idle.persisted.persisted, false);
|
||||
assert.equal(idle.setFocus, undefined);
|
||||
});
|
||||
|
||||
test("offer-candidates refuses while remaining dated collect is still open", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => collectVsOfferDossier({ declined: ["family"] }),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => (tools["rectification-offer-candidates"] as unknown as {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({ caseId: CASE_ID }),
|
||||
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
|
||||
);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) =>
|
||||
call.fn === "transition_agentic_rectification_case_status"
|
||||
&& call.args.p_status === "candidate_ready"
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1049,6 +1049,7 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt",
|
||||
caseId: string;
|
||||
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
|
||||
assert.equal(typeof ensureExit, "function");
|
||||
// 旧:内部 canAdopt 即 satisfied。新:公开可采用才算出牌;本夹具把剩余带年份域拒答。
|
||||
const decision = liveCaseDossier();
|
||||
const current = rpcDossier(decision);
|
||||
const fingerprint = evidenceLedgerFingerprint(
|
||||
@@ -1064,7 +1065,26 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt",
|
||||
})) as never,
|
||||
);
|
||||
(current.latest_result as { evidence_ledger_fingerprint: string }).evidence_ledger_fingerprint = fingerprint;
|
||||
const next = decideFromDossier(decision, { birthDate: "1997-08-08" });
|
||||
(current.conversation_summary as { declined_skipped_topics: unknown[] }).declined_skipped_topics = [
|
||||
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
|
||||
];
|
||||
const next = decideFromDossier({
|
||||
...decision,
|
||||
conversationSummary: {
|
||||
...decision.conversationSummary,
|
||||
declinedSkippedTopics: [
|
||||
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
|
||||
],
|
||||
},
|
||||
}, { birthDate: "1997-08-08" });
|
||||
assert.equal(publicCanAdopt(next), true);
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
@@ -1380,6 +1400,10 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
|
||||
const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), {
|
||||
declinedTopics: [
|
||||
{ target_domain: "family", status: "declined" },
|
||||
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
|
||||
{ target_domain: "occupation", status: "declined" },
|
||||
{ target_domain: "horary", status: "declined" },
|
||||
],
|
||||
|
||||
@@ -170,7 +170,9 @@ test("separated scores with uncovered relatives stay in collection and still ask
|
||||
test("covering family without occupation still opens adopt after scores already separated", () => {
|
||||
const familyOnly = producedDossier([...TRAINING_EVIDENCE, FAMILY_EVIDENCE]);
|
||||
const familyDecision = decideFromDossier(familyOnly.dossier);
|
||||
assert.notEqual(familyDecision.nextAction, "ask_fact_collection");
|
||||
// 旧:家人覆盖即可出牌。新:财务/搬家/健康/职业口述未问完保持采集。
|
||||
assert.equal(familyDecision.nextAction, "ask_fact_collection");
|
||||
assert.equal(familyDecision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(familyDecision.canAdopt, true);
|
||||
assert.equal(familyDecision.canConfirmExactMinute, false);
|
||||
});
|
||||
@@ -182,14 +184,9 @@ test("covering family and occupation still opens adopt after scores already sepa
|
||||
const covered = [...TRAINING_EVIDENCE, FAMILY_EVIDENCE, OCCUPATION_EVIDENCE];
|
||||
const { dossier } = producedDossier(covered, { previous: state });
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.notEqual(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.ok(
|
||||
decision.nextAction === "ask_holdout_validation"
|
||||
|| decision.nextAction === "ready_to_adopt"
|
||||
|| decision.nextAction === "offer_provisional_range",
|
||||
decision.nextAction,
|
||||
);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
assert.equal(decision.separation.sufficient, true);
|
||||
});
|
||||
@@ -206,13 +203,8 @@ test("declining family and occupation also opens adopt after scores already sepa
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered");
|
||||
|
||||
const decision = decideFromDossier(dossier);
|
||||
assert.notEqual(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.ok(
|
||||
decision.nextAction === "ask_holdout_validation"
|
||||
|| decision.nextAction === "ready_to_adopt"
|
||||
|| decision.nextAction === "offer_provisional_range",
|
||||
decision.nextAction,
|
||||
);
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,33 @@ const COVERAGE_EVIDENCE = [
|
||||
occurredTo: null,
|
||||
eventKind: "occupation_note",
|
||||
},
|
||||
{
|
||||
id: "e-fin",
|
||||
status: "confirmed",
|
||||
domain: "finance",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2017-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "income_change",
|
||||
},
|
||||
{
|
||||
id: "e-reloc",
|
||||
status: "confirmed",
|
||||
domain: "relocation",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2019-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "home_change",
|
||||
},
|
||||
{
|
||||
id: "e-health",
|
||||
status: "confirmed",
|
||||
domain: "health_pressure",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2021-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "self_health_event",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function producedSnapshot(
|
||||
|
||||
@@ -609,3 +609,43 @@ test("collect_evidence with open capability still publishes can_adopt=false", ()
|
||||
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
|
||||
});
|
||||
|
||||
const SEPARATED_COLLECT_OPEN = {
|
||||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||||
methodCoverageAll: true,
|
||||
datedMethodCollectOpen: true,
|
||||
datedEventCount: 5,
|
||||
datedDomainCount: 3,
|
||||
trainingGateOpen: true,
|
||||
snapshotCurrent: true,
|
||||
candidateScores: SEPARATED,
|
||||
holdoutValidation: "unavailable" as const,
|
||||
confirmationAllowed: false,
|
||||
};
|
||||
|
||||
test("separated candidates stay in collect while dated method collect is still open", () => {
|
||||
const decision = decideRectification(SEPARATED_COLLECT_OPEN);
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(publicDecisionFields(decision).can_adopt, false);
|
||||
});
|
||||
|
||||
test("user stop still delivers while dated method collect is open", () => {
|
||||
const stopped = decideRectification({
|
||||
...SEPARATED_COLLECT_OPEN,
|
||||
userStopped: true,
|
||||
});
|
||||
assert.equal(stopped.sessionOutcome, "adopt_representative");
|
||||
assert.equal(stopped.nextAction, "complete_with_range");
|
||||
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
|
||||
assert.equal(publicDecisionFields(stopped).can_adopt, true);
|
||||
});
|
||||
|
||||
test("accepted representative adopt ignores open dated collect", () => {
|
||||
const accepted = decideRectification({
|
||||
...SEPARATED_COLLECT_OPEN,
|
||||
accepted: true,
|
||||
});
|
||||
assert.equal(accepted.sessionOutcome, "adopt_representative");
|
||||
assert.equal(accepted.nextAction, "ready_to_adopt");
|
||||
});
|
||||
|
||||
|
||||
@@ -348,6 +348,48 @@ const methodCoverageTieEvidence = [
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:10.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444447",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "income_change",
|
||||
domain: "finance",
|
||||
occurred_from: "2017-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "收入变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:11.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444448",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "home_change",
|
||||
domain: "relocation",
|
||||
occurred_from: "2015-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "搬家",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:12.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444449",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "self_health_event",
|
||||
domain: "health_pressure",
|
||||
occurred_from: "2013-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "身体压力变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:13.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
function scoreableFingerprintForRawEvidence(evidence: unknown[]): string {
|
||||
|
||||
@@ -63,6 +63,33 @@ const COVERED_EVIDENCE = [
|
||||
occurredTo: null,
|
||||
eventKind: "occupation_note",
|
||||
},
|
||||
{
|
||||
id: "e-fin",
|
||||
status: "confirmed",
|
||||
domain: "finance",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2017-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "income_change",
|
||||
},
|
||||
{
|
||||
id: "e-reloc",
|
||||
status: "confirmed",
|
||||
domain: "relocation",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2019-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "home_change",
|
||||
},
|
||||
{
|
||||
id: "e-health",
|
||||
status: "confirmed",
|
||||
domain: "health_pressure",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2021-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "self_health_event",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function producedDossier(
|
||||
|
||||
@@ -369,8 +369,10 @@ test("decideFromDossier offers a range with adopt when training is complete and
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
});
|
||||
assert.equal(decision.canOfferRange, true);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
// 旧:训练门开、职业未覆盖即可出牌。新:财务/健康/职业口述未问完保持采集。
|
||||
assert.equal(decision.canOfferRange, false);
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
|
||||
@@ -365,10 +365,9 @@ test("incident shape 1a: occupation coverage must open provisional adopt without
|
||||
const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered }));
|
||||
assert.equal(decision.separation.lead, 5, occupationCovered);
|
||||
assert.equal(decision.separation.sufficient, false, occupationCovered);
|
||||
assert.equal(overlaid.can_adopt, true, occupationCovered);
|
||||
assert.equal(overlaid.selection_allowed, true, occupationCovered);
|
||||
assert.deepEqual(overlaid.credible_range, ["05:00", "05:07"], occupationCovered);
|
||||
assert.equal(overlaid.representative_time, "05:00", occupationCovered);
|
||||
// 旧:职业覆盖即可公开采用。新:财务等带年份采集未问完,公开 can_adopt 保持关闭。
|
||||
assert.equal(overlaid.can_adopt, false, occupationCovered);
|
||||
assert.equal(overlaid.session_outcome, "collect_evidence", occupationCovered);
|
||||
assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered);
|
||||
assert.equal(decision.canConfirmExactMinute, false, occupationCovered);
|
||||
}
|
||||
@@ -384,8 +383,8 @@ test("incident shape 1b: exact-minute confirm stays closed on the accident case"
|
||||
test("incident shape 1c: uncovered occupation still collects remaining dated events", async () => {
|
||||
const dossier = incidentDossier({ occupationCovered: false });
|
||||
const { decision, overlaid } = publicFields(dossier);
|
||||
assert.equal(overlaid.can_adopt, true);
|
||||
assert.equal(overlaid.selection_allowed, true);
|
||||
assert.equal(overlaid.can_adopt, false);
|
||||
assert.equal(overlaid.session_outcome, "collect_evidence");
|
||||
assert.equal(overlaid.can_confirm_exact_minute, false);
|
||||
|
||||
let writtenDomain: string | null = null;
|
||||
@@ -449,7 +448,7 @@ test("incident shape 1c: uncovered occupation still collects remaining dated eve
|
||||
can_adopt: overlaid.can_adopt,
|
||||
representativeTime: overlaid.representative_time,
|
||||
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
|
||||
})), true);
|
||||
})), false);
|
||||
});
|
||||
|
||||
test("after-choice persist collects remaining dated events after family denial", async () => {
|
||||
@@ -579,8 +578,9 @@ test("incident shape 4: exact-minute confirm stays fail-closed even when local g
|
||||
test("delivery B1: GET overlay after occupation coverage renders representative cards", () => {
|
||||
const dossier = incidentDossier({ occupationCovered: "declined" });
|
||||
const { decision, overlaid } = publicFields(dossier);
|
||||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
// 旧:职业拒答即可出可点卡。新:财务口述未问完只给只读区间。
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
const parsed = parseRectificationCandidateResult({
|
||||
resultId: dossier.latestResult?.resultId,
|
||||
candidates: overlaid.candidates,
|
||||
@@ -592,7 +592,7 @@ test("delivery B1: GET overlay after occupation coverage renders representative
|
||||
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(canRenderRectificationSelectionCards(parsed), true);
|
||||
assert.equal(canRenderRectificationSelectionCards(parsed), false);
|
||||
assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]);
|
||||
assert.equal(parsed.representativeTime, "05:00");
|
||||
});
|
||||
@@ -606,7 +606,7 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi
|
||||
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
});
|
||||
assert.equal(plan.session_outcome, "adopt_representative");
|
||||
assert.equal(plan.session_outcome, "collect_evidence");
|
||||
assert.equal(plan.next_followup?.domain, "finance");
|
||||
const action = buildNextUserAction({
|
||||
scorableCount: 5,
|
||||
@@ -617,8 +617,8 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi
|
||||
nextFollowup: plan.next_followup,
|
||||
workingTime: "05:00",
|
||||
});
|
||||
// 能力层仍可 adopt;真正下一问是财务采集。公开 next-action 仍跟 sessionOutcome。
|
||||
assert.equal(action.id, "adopt_representative");
|
||||
// 旧:能力层 adopt、next-action 仍 adopt_representative。新:决策与计划同为采集。
|
||||
assert.equal(action.id, "ask_method_followup");
|
||||
|
||||
let writes = 0;
|
||||
const accounting = fakeAccounting({
|
||||
|
||||
@@ -505,7 +505,8 @@ test("live remaining probes with zero active split drop and open provisional ado
|
||||
const dossier = liveDossier(remaining);
|
||||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||||
assert.equal(decision.probe, null);
|
||||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||||
assert.equal(isNonConvergingRangeOffer(decision), false);
|
||||
assert.equal(decision.canAdopt, true);
|
||||
assert.equal(decision.selectionAllowed, true);
|
||||
|
||||
Reference in New Issue
Block a user