From 5113d457b73a4f1d9776e4ed2021a55213874554 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 17 Sep 2026 00:48:50 +0000 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20=E8=87=AA=E5=BB=BA?= =?UTF-8?q?=E5=88=86=E7=9B=98=E6=8E=A2=E9=92=88=E6=8C=89=20receipt=20?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=EF=BC=9B=E9=A3=8E=E6=A0=BC=E9=A2=98=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AE=A1=E5=88=86=EF=BC=9B=E6=AD=BB=E5=8D=A1=E4=B8=8D?= =?UTF-8?q?=E9=85=8D=E9=87=87=E9=9B=86=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-915:BUG-912 首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET / idle 三处读持久化 receipt 都找不到它 → 点选与打字回答报 stale_probe、GET 无卡、 刚落库的焦点每次 idle persist 被 superseded。新增 withOwnedDistinguishProbes(state, receipt) 按 window_scan.transitions + state 候选确定性重建(照 withNakshatraBoundaryProbe 的既有模式),接入盖戳、点选答题、 打字答题、GET 投影、idle 过期五处,并加源码契约测试钉住。重建探针的 source 为 owned_varga_style,像 nakshatra_boundary 一样从 contrast packet 与 event 探针池 排除,避免答题写回 state 后它们变成可问的题。 产品决策(任务书 §1d,选项 b):分盘风格题不再作为 distinguish_candidates 计分题。 分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针 计分;没有事件探针时不出题,计划直接走下一条线。删除 attachVargaDistinguishIdentity 与 withFollowupOwnedProbe,保留 buildVargaDistinguishFields / conflictProbeFromFollowup 供重建与将来选项 (a)。 BUG-917:最新助手消息带未答点选而 GET 无卡时,缺口态改走 unavailable 修复出口, 不再与「再说一件带年月的事」同屏;superseded 且未作答的焦点不再渲染成灰色选项。 BUG-916:年月阶段 host 前置挪到会话校验之后,phase 复用 answer.host_fallback。 测试改走生产路径(inferenceForPersistedAnswer / choiceCardFromCaseDossier / persistedDistinguishFocusStale),删掉手工把自建探针塞进 state 的 fixture。 tsc 0 错;lint 0 error / 116 warning;npm test 3421 条 / 31 红,失败清单与基线 c32f81e7 逐条一致;next build --webpack 后 / 仍 ○ Static,产物 JS gzip 1,496,339 → 1,496,408(+0.005%);pytest rectification 定向 63 绿,快速门 Python 段 798 绿。Skill 未 bump。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- CHANGELOG.md | 10 + docs/BUG_HISTORY.md | 60 ++- ...ctification-dead-d9-choice-fix-20260916.md | 102 +++++ docs/tasks/README.md | 2 +- ...ctification-dead-d9-choice-fix-20260916.md | 6 + ...ctification-dead-d9-choice-fix-20260916.md | 15 + frontend/DESIGN.md | 3 +- .../components/rectification-agentic-chat.tsx | 12 + .../rectification-message-entry.tsx | 4 +- .../lib/rectification-agentic/v9/agent-run.ts | 13 +- .../rectification-agentic/v9/answer-choice.ts | 86 ++-- .../v9/decision-from-dossier.ts | 10 +- .../v9/interview-state.ts | 8 +- .../v9/method-followup.ts | 77 +++- .../rectification-agentic/v9/server-focus.ts | 15 +- .../v9/varga-distinguish-probe.ts | 115 +++-- .../src/lib/rectification-surface-state.ts | 9 + frontend/src/mastra/rectification-v9-tools.ts | 7 +- ...tification-dead-d9-choice-20260916.test.ts | 416 +++++++++++++----- 19 files changed, 772 insertions(+), 198 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-dead-d9-choice-fix-20260916.md create mode 100644 docs/testing/rectification-dead-d9-choice-fix-20260916.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b214e5b6..8277a78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 不再问「你更接近哪一种相处方式」;灰掉的选项和「再说一件事」不会同屏 + +分盘风格题(「亲密关系里,你更接近哪一种相处方式?」这类)不再作为计分题出现。关系、事业、家人这些线只在引擎给得出带年份的事件时问「某年前后有没有…」,给不出就直接问下一条线或给结果,不再拿性格描述去淘汰候选时间。性格描述仍然出现在平局时的参考题和交付卡的性格列里。 + +上一版这类题会出现在正文里但答不了:四个选项灰着点不动,同屏的范围行和输入框却写着「再说一件带年月的事」。现在这种自相矛盾的画面不会再有——拿不到可点的题时只会出现「没有拿到下一个问题」和「接着问」,占位文案跟着一致。已经答不了的旧题也不会再画成一排灰选项。 + +如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。 + +Skill 版本不变。 + ## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾 感情判别题下面会有可点的 A/B/C/D,不再只剩一句题干。这类题按关系盘自己的分组计分,不会拿学业盘的对照题来打分。2026-09-16 前如果分盘风格题被盖上了别的盘的探针,当时记下的答案可能计错分,不要采用那个区间,补一件带年月的事或新开一段即可。交付卡每一列性格只留一句,卡头共同点不会把一对相反的话写在一起。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5bf6903a..37f5f3ad 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5894,8 +5894,8 @@ - 根因:(1) `stampChoiceSchemaWithProbe` 用引擎最高增益探针盖 schema,把对比探针换成已答教育题;`event_probe` 缺 `probe_id` 时被当成已答,不建焦点。GET 投影不传 `contrastPacket` / `userStopped`。(2) 对比探针按整窗 D10 星座建题,`supportsCandidateIds` 是星座名不是分钟;`askedKeys` 不含自然语言答过的职责倾向。(3) 质量探针先占领域,挡住 dasha 年界;代表对取整窗第一次换升。`answersFromEvidence` 把同年入学当成考试失常。(4) 停问词匹配不到「没有了」。 - 修复:对比探针用自己的 `semantic_key` 盖戳,缺引擎 `probe_id` 时仍建焦点;GET 与工具侧同一套 plan 输入。剩余候选按 D24/D10 分钟切开;职责倾向记入 `askedKeys`。质量探针不得挡住 dasha;代表对取当前候选集。入学不再自动回答质量探针。停问词加上「没有了」等,停问且可出牌时出并列区间。不把 `occupation_note` 改成主评分事件,不打开 `confirmation_allowed`,不改 Skill `10.0.11`。 - 验证:`rectification-spoken-answer`、`rectification-server-focus`、`rectification-choice-card`、`rectification-decide-next-action`、`rectification-eight-method`、`rectification-inference-machine`、`tests/test_rectification_event_probes.py`。 -- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。 -- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912 +- 防复发:覆盖已齐且候选并列时必须落 A/B/C/D;对比探针的 `supports/conflicts` 必须是剩余候选分钟。点选必须改后验。停问且可出牌时走 `offer_provisional_range`,不得再问已答职责题。2026-09-16 复发(BUG-912):无探针的分盘风格题借最高增益探针。2026-09-17(BUG-915):给风格题自建探针只解决了盖戳,自建探针没有像月宿边界探针那样在每个读 state 的地方重建,答题 / GET / idle 三处读持久化 receipt 都找不到它;产品同日拍板风格题不再作为计分判别题。 +- 相关记录:BUG-348、BUG-350、BUG-351、BUG-366、BUG-373、BUG-374、BUG-912、BUG-915 - 复发自:BUG-348 / BUG-366(区分卡与覆盖≠收敛已写过,焦点持久化和剩余候选切开未接到这条会话) - 修复版本:d7afe5b50de488d98a2ca666763784158e7c3be2 @@ -11847,15 +11847,15 @@ - 状态:resolved - 首次发现:2026-09-16 -- 最近更新:2026-09-16 +- 最近更新:2026-09-17 - 影响面:`stampChoiceSchemaWithProbe`、`varga-distinguish-probe.ts`、`projectRectificationChoiceCard`、`persistNextInterviewIfIdle` - 用户现象:正文念了感情判别题,下面没有 A/B/C/D;打字答「没有」后区间被收窄。 - 触发条件:D9 风格 followup 没有自己的 `semantic_key`,inference_state 里另有高增益 D24 对照探针。 - 根因:`stampChoiceSchemaWithProbe` 在无 preferred key 时退到 `selectHighestGainProbe`。BUG-375 只堵住了对比探针被已答教育题盖掉,没堵住「无探针的分盘风格题借最高增益探针」。 -- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。 -- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`;BUG-375 原断言仍绿。 -- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。 -- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674 +- 修复:不再退到最高增益探针。D9/D10 等风格题按剩余候选的分盘上升自建探针。GET 从落库副本投影;过期焦点 `superseded` 后落下一问。**首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET / idle 三处读持久化 receipt 都找不到它(BUG-915);2026-09-17 由该单补齐重建,并按产品决策不再出风格计分题。** +- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`(2026-09-17 改写为落库形状回放);BUG-375 原断言仍绿。 +- 防复发:无 `semantic_key` 的计分题不得盖上别的分盘探针。宁可不落库(走 BUG-674)也不错分。只在内存里存在的探针不算落库,新增自建探针必须同时给出「每个读 state 的地方都重建」的路径(BUG-915)。 +- 相关记录:BUG-375、BUG-578、BUG-582、BUG-674、BUG-915、BUG-917 - 复发自:BUG-375 - 修复版本:待发布 @@ -11891,4 +11891,50 @@ - 复发自:无 - 修复版本:待发布 +## BUG-915 | 自建分盘探针只活在内存里:点选报 stale_probe、GET 无卡、idle 立刻 superseded +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`varga-distinguish-probe.ts`、`expectedAnswerSchemaFor`、`applyRectificationChoice`、`rectification-resolve-focus`、`choiceCardFromCaseDossier`、`persistNextInterviewIfIdle`、`buildMethodFollowupPlan` +- 用户现象:正文出现「亲密关系里,你更接近哪一种相处方式?」,下面四个选项灰显点不了;同屏范围行与输入框占位却写「再说一件带年月的事」。点选或打字回答会报错。 +- 触发条件:BUG-912 首版给分盘风格题自建了探针;该探针只在盖戳时注入内存 state,`decision_receipt.inference_state` 里没有。 +- 根因:自建探针没有按月宿边界探针的既有模式在每个读 state 的地方重建。答题路径、GET 投影、idle 过期判定都读持久化 receipt:`matchProbeForChoice` 找不到 → `stale_probe`;`liveInferenceDistinguishProbe` 找不到 → 无卡;`liveDistinguishProbe` 找不到 → 刚落库的焦点每次 idle persist 都被 `superseded`。 +- 修复:新增 `withOwnedDistinguishProbes(state, receipt)`,按 `window_scan.transitions` + state 候选确定性重建全部自建探针,接入盖戳、点选答题、打字答题、GET 投影、idle 过期五处(答题两处共用 `inferenceForPersistedAnswer`);重建探针的 `source` 为专用 `owned_varga_style`,并像 `nakshatra_boundary` 一样从 contrast packet 与 event 探针池排除,避免答题写回 state 后它们在下一轮变成可问的题。产品 2026-09-17 同时拍板(选项 b):分盘风格题不再作为 `distinguish_candidates` 计分题——分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针计分,否则该题不出,计划直接走下一条线。删除 `attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`。 +- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts` 10 条:落库形状回放(未重建 = `stale_probe`,重建后 `applied` 且只按 D9 分组计分)、`choiceCardFromCaseDossier` 出卡、`persistedDistinguishFocusStale` 不误伤且候选集变化仍过期、五处接入点源码契约、(b) 决策两半。`npm test` 3421 条 / 31 红(与基线 `c32f81e7` 逐条一致)。 +- 防复发:任何不在引擎 `inference_state` 里的探针,必须有一个从 receipt 确定性重建的函数,并在每个读 state 的地方接入;只在盖戳时注入内存的写法一律视为未落库。分盘风格题不得作为计分判别题。 +- 相关记录:BUG-912、BUG-375、BUG-559、BUG-674、BUG-917 +- 复发自:BUG-912(同一根因的第二层:探针身份有了,持久化没有) +- 修复版本:待发布 + +## BUG-916 | 年月阶段 host 前置排在会话校验之前 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`runV9AgentTurn`、`PUBLIC_RECTIFICATION_PHASES` +- 用户现象:无直接用户现象;跨会话(旧标签页)发来的无年月短句也会被写成一条确定性轮。 +- 触发条件:BUG-910 的 host 前置写在 `dossier.case.sessionId !== sessionId` 校验之前;返回的 `phases: ["answer.host_year_entry"]` 不在公开 phase 白名单里(类型是 string,tsc 不报)。 +- 根因:前置插入位置只考虑了「不要进模型」,没有沿用既有的会话校验顺序;phase 名新造而未登记。 +- 修复:host 前置挪到会话校验之后(计费位置不变);phase 复用既有 `answer.host_fallback`,公开快照的 `answer_origin: "host_fallback"` 语义一致。 +- 验证:`frontend/tests/rectification-v9-stream.test.ts` 等既有 host 前置断言仍绿;`npm test` 失败清单与基线逐条一致。 +- 防复发:任何早退分支都必须排在会话 / Skill 身份校验之后;返回的 phase 必须是 `PUBLIC_RECTIFICATION_PHASES` 里的值。 +- 相关记录:BUG-910、BUG-915 +- 复发自:无 +- 修复版本:待发布 + +## BUG-917 | 死掉的点选卡与「再说一件带年月的事」同屏 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`rectificationQuestionGapState`、`rectification-agentic-chat.tsx`、`rectification-message-entry.tsx` +- 用户现象:最后一条助手消息挂着一张灰色不可点的 A/B/C/D,同屏的范围行与输入框占位却说「再说一件带年月的事就能继续」,两条指令互相矛盾。 +- 触发条件:焦点被 idle persist 判成 `superseded`(BUG-915),GET 没有 `choice_card`;`current_question` 为 null 于是 `interviewCollectWaiting` 为真。 +- 根因:缺口态只看 `current_question`,看不到消息级的未答点选;消息内卡片只把 `status === "active"` 的当成未答,`superseded` 且没有作答的焦点仍然渲染成 `disabled` 的选项。 +- 修复:`rectificationQuestionGapState` 新增 `unansweredDeadChoice` 输入,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」);chat 按最新一条 settled 助手消息计算该标志;message-entry 对「`superseded` 且无 `answer_option`」不再画卡。 +- 验证:`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`「a dead tap card never shares the screen with the collect-wait placeholder」:`collect_waiting` + 死卡 → `unavailable`,并有 chat / message-entry 源码契约。 +- 防复发:死卡与 `collect_waiting` 不得同屏;未作答却已被关闭的点选题不得渲染成灰色选项。 +- 相关记录:BUG-915、BUG-675、BUG-674 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-dead-d9-choice-fix-20260916.md b/docs/tasks/PROGRESS-rectification-dead-d9-choice-fix-20260916.md new file mode 100644 index 00000000..aee2db9f --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-dead-d9-choice-fix-20260916.md @@ -0,0 +1,102 @@ +# 进度 · 自建探针只活在内存里(修复单,2026-09-17) + +基线:`origin/staging` @ `c32f81e7`。分支:`codex/rectification-dead-d9-choice-fix-20260916`。 +任务书:`docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md`(§1d 已补 2026-09-17 决策记录)。 +BUG:915~917(开工时 `docs/BUG_HISTORY.md` 最大号 914)。Skill **未 bump**。 + +## 0. 结论 + +| 任务 | 结果 | +| --- | --- | +| T1 `withOwnedDistinguishProbes` 五处重建(BUG-915) | 完成,三条生产路径验收断言全绿 | +| §1c 计分口径 | 按 **(b)** 落地:风格题不再是计分判别题 | +| BUG-917 死卡与 `collect_waiting` 不得同屏 | 完成(缺口态改 `unavailable`,死卡不再画灰选项) | +| T2 BUG-916 host 前置挪到会话校验之后 + phase 登记 | 完成(phase 让步为复用 `answer.host_fallback`) | +| T3 记录 | BUG-912 改 `resolved` 并补首版缺陷;BUG-915~917 新增;BUG-375 复发段补一行;CHANGELOG / 真机清单 / 本进度 | + +## 1. 做了什么 + +### T1 · 自建探针按 receipt 确定性重建(BUG-915) + +- `frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts` + - 新 `withOwnedDistinguishProbes(state, receipt)`:按 `OWNED_DISTINGUISH_QUESTIONS`(七条分层题,`:`)× `receipt.window_scan.transitions`(缺失时退到 `state.transitions`)× `state.candidates` 重建所有自建探针,同 id / 同 semantic_key 不重复,无变化时原样返回同一对象。不依赖「当前哪个 followup 在问」。 + - 重建探针的 `source` 是专用的 `owned_varga_style`,`decision-from-dossier.ts` 的 `contrastPacketFromLatestResult` / `rectificationFollowupCatalog` / `contrastPacketFromState` 三处像排除 `nakshatra_boundary` 一样把它排除:答题会把重建后的 state 写回 receipt,若不排除,这些探针会在下一轮变成 contrast / event 候选,等于绕开 (b)。 + - 删掉 `withFollowupOwnedProbe`(只在盖戳那一刻往内存 state 注入,是本单 P0 的根因)与 `attachVargaDistinguishIdentity`(风格计分题的生产入口,按 (b) 不再需要)。保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。 +- 接入点(五处,全部写成源码契约断言): + 1. `server-focus.ts` `expectedAnswerSchemaFor` 盖戳; + 2. `answer-choice.ts` `applyRectificationChoice` 点选答题; + 3. `interview-state.ts` `choiceCardFromCaseDossier` 的 GET 投影(`persistedDistinguishCard` 用的就是这个 state); + 4. `answer-choice.ts` `persistNextInterviewIfIdle` 的 `inferenceForStale`; + 5. `src/mastra/rectification-v9-tools.ts` `rectification-resolve-focus`(打字回答同一焦点的路径)。 +- 2 与 5 共用新导出的 `inferenceForPersistedAnswer(receipt)`,测试直接调它,保证断言走的是生产代码而不是测试自己拼的 state。 +- idle 的过期判定抽成导出的 `persistedDistinguishFocusStale(inference, focus)`(逻辑与原来逐字一致,只是可被测),BUG-559 的 split-hash 判定原样保留。 + +**为什么没有在 `previousInferenceFromReceipt` 出口统一注入**(任务书 §2 首选、§3 让步 1 允许退成逐点包装):那个出口的返回值同时喂给 `contrastPacketFromLatestResult` 与 `rectificationFollowupCatalog` 的 `eventProbes`(`decision-from-dossier.ts:226/384`)。自建风格探针一旦进那两条投影,就会作为 `yearless` / `contrast` 候选重新变成可问的题,等于绕开本单的 (b) 决策,并且会改动与本单无关的决策面。因此按让步 1 逐点包装,并用源码契约测试钉住五处。 + +### §1c 计分口径按 (b) 落地 + +- `method-followup.ts` `makeFollowup`:`varga_observation` / `precision_stage` 的分层判别题(`d9_relationship` / `d10_career` / `d4_home` / `d5_education` / `relatives` / `d2_finance` / `d30_health`,且自己没有 `semantic_key`) + - 若引擎在该领域还有**带年份**事件探针(`year > 0`、`choice_kind` 非 `varga_style` / `event_quality`、未在 `askedProbeKeys` / `answeredProbes` 里),就绑定它:题面是「某年前后有没有…」,`semantic_key` / `expected_outcomes` / `probe_year` 全部取该探针,按该探针计分;探针没有 `semantic_key`(盖不上戳)时不绑定身份,维持基线行为(落不了库走 BUG-674 耗尽分支,不会错分)。 + - 没有可用事件探针时**不出题**:不挂 `choice_frame`,由既有 `takeRenderableDistinguish` 记 `frameless_distinguish` 后计划继续往下走(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。 +- `lagna_frame`(`dasha_events`,`domain` 为 null)不在这条规则里,行为与基线一致。 +- 实测对照(同一输入,同机):基线 `next_followup` = `d9_relationship` / `precision_stage` / `choice_kind=varga_style` / 题面「亲密关系里,你更接近哪一种相处方式?」;本单同输入 `next_followup = null` + `dropped_probes` 两条 `frameless_distinguish:d9_relationship`,即 09-17 真机那道题不会再出现。 + +### BUG-917 · 死卡不得与 `collect_waiting` 同屏 + +- `rectification-surface-state.ts`:`RectificationQuestionGapInput` 新增 `unansweredDeadChoice`,在 `delivered` 之后、`collect_waiting` 之前返回 `unavailable`(「没有拿到下一个问题」+「接着问」),占位与范围行随之同步。 +- `rectification-agentic-chat.tsx`:按最新一条 settled 助手消息计算 `unansweredDeadChoiceOnMessages`(kind 为 `choice` / `reverse_verify`、无 `answer_option`、status 为 `active` 或 `superseded`、且没有活卡)并传进缺口态。 +- `rectification-message-entry.tsx`:`unansweredDeadChoice` 补上「status `superseded` 且没有作答」——这类焦点没有答案可展示,原来会画成 `disabled` 的灰色 A/B/C/D(09-17 手机截图那张)。 + +### T2 · BUG-916 + +- `agent-run.ts`:年月阶段 host 前置(`shouldHostReaskYearEntry`)挪到 `dossier.case.sessionId !== sessionId` 校验之后,跨会话消息不再被写成确定性轮;计费位置不变。 +- `phases` 从未登记的 `answer.host_year_entry` 改成既有 `answer.host_fallback`(任务书 §3 让步 2),公开快照的 `answer_origin: "host_fallback"` 语义也对得上。 + +## 2. 门禁数字 + +| 项 | 基线 `c32f81e7` | 本单 | +| --- | --- | --- | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | **0 错** | +| `npm run lint` | 0 error / 116 warning | **0 error / 116 warning** | +| `npm test` | tests 3414 · pass 3368 · fail 31 · skipped 15 | tests **3421** · pass 3375 · fail **31** · skipped 15 | +| `npm test` 失败清单 | 31 条无 Docker 既有缺口 | **`diff` 逐条一致**(见下) | +| `next build --webpack` `/` 渲染标记 | `○ Static` | **`○ Static`** | +| 产物 JS gzip 合计(`.next/static/**/*.js`) | 1,496,339 字节 | **1,496,408 字节(+0.005%)** | +| `pytest tests/test_rectification_refinement_packet.py tests/test_rectification_event_probes.py tests/test_rectification_engine_memoization.py` | 63 绿 | **63 绿** | +| `run_quality_gate.py --profile quick` | 卡在末尾 `npm test`(31 条无 Docker 失败) | 同:pytest 段 **798 passed / 1 skipped**,末尾 `npm test` 步骤 exit 1(同样的 31 条) | + +gzip 口径:本仓没有首屏 gzip 测量脚本,Next 16 的路由表也不再输出 Size 列;取 `.next/static` 下全部 js chunk 的 gzip 字节合计,基线与本单同机同命令(`next build --webpack`)各测一次。 + +失败清单比对:`diff` 基线与本单的 `not ok` 名单为空输出。31 条全部是无 Docker 的既有环境缺口(数据库 / 迁移 / 部署 / 缓存 appendix / ephemeris 等),与 `BLOCKED.md` 一致。 + +测试总数 3414 → 3421:本单测试文件由 4 条改写为 11 条(见下)。没有弱化任何既有断言。 + +### 改过的既有断言 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/rectification-dead-d9-choice-20260916.test.ts` | `GET projects the persisted distinguish copy when its probe is still live`:手工把自建探针塞进 `buildInferenceState({probes:[D24, 自建]})` 再断言有卡 | 删除该 fixture;改成 `choiceCardFromCaseDossier` 读 `{inference_state: 只含 D24, window_scan}` 的落库形状 | 任务书 §1:手塞探针的 state 不是生产形状,正是它让首版「测试全绿、生产报错」 | +| 同上 | `year-stage D9 followup does not borrow the D24 contrast probe`:答题用 `withFollowupOwnedProbe(inference, followup)` | 拆成两条:盖戳断言 + 用生产的 `inferenceForPersistedAnswer(receipt)` 答题,并先断言未重建时确实 `stale_probe` | `withFollowupOwnedProbe` 已删除;答题必须走生产读 state 的那一段 | + +其余既有断言一字未改。 + +### 本单测试(`frontend/tests/rectification-dead-d9-choice-20260916.test.ts`,11 条全绿) + +1. 盖戳按 receipt 重建自建探针、不借 D24(并断言落库 state 里确实没有该探针)。 +2. 生产答题路径:同一 schema 在未重建的 state 上 `stale_probe`,在 `inferenceForPersistedAnswer` 上 `applied`,`answered_probes` 末位 `semantic_key=d9_relationship:relationship_style`,不含 D24。 +3. `choiceCardFromCaseDossier` 对该落库焦点返回卡,`question_id` 与焦点一致、四个选项。 +4. `persistedDistinguishFocusStale`:未重建判 stale(这就是误伤),重建后不 stale;候选集变化后仍 stale(BUG-559 保留)。 +5. 五处接入点的源码契约。 +6. (b) 决策:无带年份事件探针时不出风格题(题面不含「更接近哪一种」,`dropped_probes` 有 `frameless_distinguish:d9_relationship`)。 +7. (b) 决策的另一半:引擎给出 2020 年感情事件探针时该线照问,`semantic_key` 为该探针、`choice_kind=existence`、题面含年份。 +7b. 重建出来的探针不得变成可问的题:`rectificationFollowupCatalog` 的 `eventProbes` 与 `contrastPacket.probes` 里都不含该 key。 +8. BUG-917:`collect_waiting` + 死卡 → `unavailable`;chat 与 message-entry 的源码契约。 +9. BUG-913 `closed_by_design` 复核(原样保留)。 +10. BUG-914 两极不得同时进 `shared_traits`(原样保留)。 + +## 3. 环境缺口 + +- 无 Docker:31 条失败与基线逐条一致;`run_quality_gate.py --profile quick` 因此仍停在末尾 `npm test`。 +- 无登录态 / 无 Chrome / 无模型凭据:浏览器级验收写成 `docs/testing/rectification-dead-d9-choice-fix-20260916.md`,未声称通过。 +- staging 部署核对(`/api/health` 的 `deployment.gitCommit`)不在本单能力范围:只推分支,未推 `staging`。 +- 事发 Case 的 04:48–04:59 仍建立在错分答案上,不要采用(与上一单结论相同)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 2b249eae..273ba5d3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -103,7 +103,7 @@ | `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23(BUG-646~648) | 待验收 | `dd8f35f7` | | `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84)**。主体默认本人不追问,Skill 10.0.29 | 已验收 | `6aabbe38`(BUG-908~911,Skill 10.0.29):tsc 0 / lint 0 error / npm test 3414 条 31 红与基线 05cf20fb 逐条相同 / `/` Static / 首屏 gzip 620,502→620,107(−0.06%)/ 快速门 Python 段 798 绿。两条小项(host 前置排在会话校验之前、`answer.host_year_entry` 未登记)并入 dead-d9 修复单 T2 | | `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | **验收未通过(P0)** | `227a7571`+`8d7dfbf0`:不借探针、一列一句、BUG-913 定性均过;但自建探针只在盖戳时注入内存 state,生产答题路径读持久化 state → `stale_probe` 报错、GET 无卡、idle 立刻 superseded(生产路径回放实证)。修复单见下一行 | -| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过** | 待领取 | — | +| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已实现待验收 | `codex/rectification-dead-d9-choice-fix-20260916`(BUG-915~917,Skill 未 bump):`withOwnedDistinguishProbes` 五处重建(盖戳 / 点选答题 / 打字答题 / GET 投影 / idle 过期;未在 `previousInferenceFromReceipt` 出口统一注入——会把自建探针喂进 contrastPacket 与 eventProbes,按让步 1 逐点包装 + 源码契约测试);按 (b) 删掉风格计分题入口,分层判别题只在有带年份事件探针时出题;死卡改走 `unavailable` 修复出口。tsc 0 / lint 0 error 116 warning / npm test 3421 条 31 红与基线 `c32f81e7` 逐条相同 / `/` `○ Static` / 产物 JS gzip 1,496,339→1,496,408(+0.005%)/ pytest 63 绿、快速门 Python 段 798 绿 | | `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 待领取 | — | ### 聊天主链路与首页 diff --git a/docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md b/docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md index 9d70ef1d..1c2d7049 100644 --- a/docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md +++ b/docs/tasks/TASK-rectification-dead-d9-choice-fix-20260916.md @@ -34,6 +34,12 @@ - **本单默认按既有口径执行(b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分。D9/D10 判别题只在引擎给出该领域带年份事件探针(`discriminating_event_probes` / `prospective_probes`)时以「某年前后有没有…」出题并计分;没有事件探针时不出风格题,直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的平局参考题(±1)与交付卡列。产品若改口为 (a)「允许风格题全额计分」,在本单决策记录追加一行即可,执行方不得自行选边。 - 这条不改变 T1 的重建要求:事件探针来自引擎 state,本就在持久化里;自建探针的重建逻辑仍要做,供 (a) 与将来使用,但默认路径不再产生风格计分题。 +## 1d. 决策记录(2026-09-17,产品负责人) + +- **§1c 采用 (b)**:分盘风格题不得作为 `distinguish_candidates` 全额计分题。D9/D10 等分层判别题只在引擎给出该领域带年份事件探针时才出题(题面是「某年前后有没有…」,按该事件探针计分);没有事件探针时该题不出,计划直接走下一条线(引导窗口 / 跳过线重问 / 未覆盖领域 / 交付)。风格描述只留在既有的 ±1 平局参考题与交付卡列。 +- T1 的重建要求照做:`withOwnedDistinguishProbes` 在每个读 state 的地方按 receipt 确定性重建,保证 (b) 决策之前已落库的风格焦点可答、有卡、不被 idle 误 supersede。 +- 执行方据此删除了「给风格题自建探针再出题」的生产入口(`attachVargaDistinguishIdentity` / `withFollowupOwnedProbe`),保留 `buildVargaDistinguishFields` + `conflictProbeFromFollowup` 供重建与将来 (a) 使用。 + ## 2. 修法(照 `withNakshatraBoundaryProbe` 的既有模式) 月宿边界探针也不在引擎 state 里,它的做法是三处都从 receipt 重建:`server-focus.ts:124`(盖戳)、`answer-choice.ts:591`(答题)、`inference-adapter.ts:235`(`previousInferenceFromReceipt` 之后)。自建分盘探针照抄: diff --git a/docs/testing/rectification-dead-d9-choice-fix-20260916.md b/docs/testing/rectification-dead-d9-choice-fix-20260916.md new file mode 100644 index 00000000..cc574514 --- /dev/null +++ b/docs/testing/rectification-dead-d9-choice-fix-20260916.md @@ -0,0 +1,15 @@ +# 真机清单 · 风格题不再计分、死卡不再同屏(2026-09-17) + +在 staging 已登录账号上走,用公开名人或虚构出生资料。上一单事发那段 04:48–04:59 **不要采用**(建立在错分上):补一件带年月的事触发重算,或新开一段校正。 + +自动化做不到的部分(无登录态、无 Chrome、无模型凭据)都在这里。前六条针对本单改动,第 7 条是旧问题的守门。 + +1. **风格题不再出现**:走到采集中段(已给 3~6 件带年月的经历),继续答题直到助手换线。正文里不得再出现「亲密关系里,你更接近哪一种相处方式?」「事业上你更接近哪一种」这类只讲性格、不讲年份的判别题。 +2. **该问的还在问**:关系 / 事业 / 家人这些线仍然会以「20XX 年前后,有没有…」的形式出现,题干下面必须有可点的 A/B/C/D,点一下要有反应(出现「已记录…」并更新范围)。 +3. **没有可点的题时的样子**:如果某一轮拿不到下一个问题,屏幕上应当是「没有拿到下一个问题」+「接着问」按钮,输入框占位是通用的「继续说你记得的人生经历,或回答刚才的问题…」。**不得**出现「灰色的四个选项」与「再说一件带年月的事」同屏。 +4. **旧的死卡**(只有 09-16~09-17 之间做过校正的账号能复现):打开那段旧校正,最后一条助手消息里那张灰色四选项应当不再出现;缺口处是「没有拿到下一个问题」+「接着问」。点「接着问」应当拿到新的一问。 +5. **手机上重看一遍第 3、4 条**:范围行、输入框占位、缺口提示三处文案必须彼此一致(09-17 的问题是在手机上看出来的)。 +6. **跨会话不落轮**:在一台设备上打开同一段校正的旧标签页,另一台继续对话;旧标签页里发一句没有年月的短句(例如「记不清了」),不应当在新会话里多出一条助手回复;应当报会话不匹配或要求刷新。 +7. **交付卡守门(上一单)**:若出三列交付卡,每一列性格只有一句;卡头共同点里不得同时出现一对相反的话(例如「希望两边都能说得过去」和「必要时会直接选边」)。 + +发现异常时请抄下:屏幕上那句话的原文、`GET /api/rectification/cases/` 里的 `interview.current_question`、`choice_card`、`interview.session_outcome`、`stop_reason`(去掉出生资料再贴)。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 1824db1e..c4a905f6 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -256,7 +256,8 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, persisted question | only when no assistant message can carry the prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” | | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | | `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled | -| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” | +| `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap. Never while the latest assistant message still carries an unanswerable tap question — that is the repair exit below (BUG-917) | enabled, placeholder “再说一件带年月的事” | +| `question-gap`, dead card on the last message | the latest assistant message carries a tap question with no answer and no live card (its focus was superseded, or GET has no card): the card is not drawn at all, and the gap is the repair exit “没有拿到下一个问题。” + “接着问”. A greyed, unclickable A/B/C/D never appears next to the collect-wait placeholder | enabled, generic placeholder | | `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `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 | diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1fc52d70..fdb61611 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1489,8 +1489,20 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { questionMissing: currentQuestion === null, offeringCards: showSelectionCards, }); + // BUG-917: the same judgement the message entry makes, read off the latest + // settled assistant message: a tap question nobody can answer any more. + const unansweredDeadChoiceOnMessages = Boolean( + latestSettledAssistant?.question + && (latestSettledAssistant.question.kind === "choice" + || latestSettledAssistant.question.kind === "reverse_verify") + && !latestSettledAssistant.question.answer_option + && (latestSettledAssistant.question.status === "active" + || latestSettledAssistant.question.status === "superseded") + && !liveQuestionOnMessages, + ); const questionGap = rectificationQuestionGapState({ liveQuestionVisible: liveQuestionOnMessages, + unansweredDeadChoice: unansweredDeadChoiceOnMessages, questionMissing: currentQuestion === null, questionLoadFailed: questionSource === "unavailable" || deadChoice, questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, diff --git a/frontend/src/components/rectification-message-entry.tsx b/frontend/src/components/rectification-message-entry.tsx index f64af75b..90b81efd 100644 --- a/frontend/src/components/rectification-message-entry.tsx +++ b/frontend/src/components/rectification-message-entry.tsx @@ -155,9 +155,11 @@ function RectificationMessageEntryView({ ) ), ); + // A superseded focus the reader never answered has no answer to show either: + // its options would render greyed and unclickable (BUG-917). const unansweredDeadChoice = Boolean( question - && !questionIsAnswered(question) + && (!questionIsAnswered(question) || (question.status === "superseded" && !question.answer_option)) && !liveQuestion && (question.kind === "choice" || question.kind === "reverse_verify") ); diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 2af4beca..7fcd361a 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -338,8 +338,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise> | null; + }> | null | undefined, +): boolean { + if (focus?.intent !== "distinguish_candidates") return false; + const schema = focus.expectedAnswerSchema + && typeof focus.expectedAnswerSchema === "object" + && !Array.isArray(focus.expectedAnswerSchema) + ? focus.expectedAnswerSchema as Record + : null; + if (!schema) return false; + const live = liveDistinguishProbe(inference, schema); + if (!live) return true; + const split = typeof schema.candidate_split_hash === "string" ? schema.candidate_split_hash : ""; + return Boolean( + split && !isSameCandidateSplit(split, live.candidate_split_hash, live.semantic_key), + ); +} + +/** + * The state every persisted-answer path scores against (BUG-915). + * + * The schema on an open focus may name a probe this layer owns, which the + * engine's `inference_state` never carries: it is rebuilt from the same + * receipt. Without this the tap and the typed answer both fail `stale_probe`. + */ +export function inferenceForPersistedAnswer( + receipt: Readonly> | null | undefined, +): InferenceState | null { + return withOwnedDistinguishProbes( + withNakshatraBoundaryProbe( + previousInferenceFromReceipt(receipt ?? null), + refinementFromDecisionReceipt(receipt ?? null).nakshatra_boundary, + ), + receipt ?? null, + ); +} + export async function applyRectificationChoice( accounting: AccountingClient, command: ApplyChoiceCommand, @@ -588,10 +636,8 @@ export async function applyRectificationChoice( const questionId = focus.questionId; const scoring = schema.scoring !== false && !questionId.endsWith(":holdout"); const receipt = dossier.latestResult?.decisionReceipt ?? null; - const previous = withNakshatraBoundaryProbe( - previousInferenceFromReceipt(receipt), - refinementFromDecisionReceipt(receipt).nakshatra_boundary, - ); + // BUG-915 injection point 2/5: answering a tap. + const previous = inferenceForPersistedAnswer(receipt); const answerClass = optionId === "stop" || optionId === "skip_probe" ? null : outcomeIdForOption(optionId, schema); @@ -1604,31 +1650,13 @@ export async function persistNextInterviewIfIdle(input: { }; const staleFocus = dossier.conversationSummary.activeFocus; const staleFocusId = staleFocus?.id; - const inferenceForStale = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); - const distinguishSchema = staleFocus?.expectedAnswerSchema - && typeof staleFocus.expectedAnswerSchema === "object" - && !Array.isArray(staleFocus.expectedAnswerSchema) - ? staleFocus.expectedAnswerSchema as Record - : null; - const liveDistinguish = liveDistinguishProbe(inferenceForStale, distinguishSchema); - const distinguishSplit = typeof distinguishSchema?.candidate_split_hash === "string" - ? distinguishSchema.candidate_split_hash - : ""; - const staleDistinguish = Boolean( - staleFocus?.intent === "distinguish_candidates" - && distinguishSchema - && ( - !liveDistinguish - || ( - distinguishSplit - && !isSameCandidateSplit( - distinguishSplit, - liveDistinguish.candidate_split_hash, - liveDistinguish.semantic_key, - ) - ) - ), + // BUG-915 injection point 4/5: idle supersede. Without the rebuild a fresh + // focus is superseded on every idle persist. + const inferenceForStale = withOwnedDistinguishProbes( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null), + dossier.latestResult?.decisionReceipt ?? null, ); + const staleDistinguish = persistedDistinguishFocusStale(inferenceForStale, staleFocus); if ( isPersistedFocusId(staleFocusId) && ( diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 8aabd3cd..9426bfad 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -55,6 +55,7 @@ import { trainingScoreableGate, } from "./evidence-model"; import { refinementFromDecisionReceipt, EVENT_PROBE_DOMAINS, type DiscriminatingEventProbe } from "./refinement-packet"; +import { OWNED_DISTINGUISH_PROBE_SOURCE } from "./varga-distinguish-probe"; import { windowScanFromDecisionReceipt } from "./varga-observations"; import type { DroppedProbe } from "./probe-question-contract.ts"; import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; @@ -227,6 +228,8 @@ export function contrastPacketFromLatestResult( if (answered.has(probe.id) || probe.information_gain <= 0) return []; if (probe.source === "known_event_quality" && !probe.target_evidence_id) return []; if (probe.source === "nakshatra_boundary") return []; + // Rebuilt varga style probes answer old focuses only (BUG-915 / decision b). + if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return []; return [{ semantic_key: probe.semantic_key, candidate_split_hash: probe.candidate_split_hash, @@ -383,6 +386,7 @@ export function rectificationFollowupCatalog( const answeredIds = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id)); const fromInference = (inference?.probes ?? []).flatMap((probe) => { if (answeredIds.has(probe.id)) return []; + if (probe.source === OWNED_DISTINGUISH_PROBE_SOURCE) return []; const mapped = eventProbeFromInference(probe); return mapped ? [mapped] : []; }); @@ -415,7 +419,11 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket candidateSetVersion: state.candidate_set_id, calculationResultId: null, engineProbes: state.probes - .filter((item) => !answered.has(item.id) && item.source !== "nakshatra_boundary") + .filter((item) => ( + !answered.has(item.id) + && item.source !== "nakshatra_boundary" + && item.source !== OWNED_DISTINGUISH_PROBE_SOURCE + )) .map((item) => ({ semantic_key: item.semantic_key, candidate_split_hash: item.candidate_split_hash, diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index a9606ee5..fac655d8 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -20,6 +20,7 @@ import { internalObservationsFromWindowScan, windowScanFromDecisionReceipt, } from "./varga-observations"; +import { withOwnedDistinguishProbes } from "./varga-distinguish-probe"; import type { RectificationChoiceCard } from "./choice-card"; import { followupCaseArgs } from "./block-scan.ts"; @@ -76,7 +77,12 @@ export function choiceCardFromCaseDossier(dossier: { const observations = internalObservationsFromWindowScan( windowScanFromDecisionReceipt(dossier.latestResult?.decisionReceipt ?? null), ); - const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + // BUG-915 injection point 3/5: the GET card projection matches the persisted + // schema against this state. + const inference = withOwnedDistinguishProbes( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null), + dossier.latestResult?.decisionReceipt ?? null, + ); const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence as never), }); diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index abf41bd2..9a8ab03f 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -173,7 +173,7 @@ import type { } from "./refinement-packet"; import { EVENT_PROBE_DOMAINS } from "./refinement-packet"; import type { InternalVargaObservation, WindowScan } from "./varga-observations"; -import { attachVargaDistinguishIdentity, liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe"; +import { liveDistinguishProbe as liveInferenceDistinguishProbe } from "./varga-distinguish-probe"; import type { InferenceState } from "../core/types.ts"; import { probeBelowAdultFloor } from "./adult-floor.ts"; import { @@ -306,6 +306,20 @@ function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: return evidence.some((item) => item.status === "confirmed" && sameCollectDomain(item.domain, domain)); } +/** + * Layer followups whose only discriminator used to be a varga style question. + * Product decision 2026-09-17 (option b) keeps them dated-probe backed. + */ +const VARGA_STYLE_LAYER_METHODS: Readonly> = { + d9_relationship: true, + d10_career: true, + d4_home: true, + d5_education: true, + relatives: true, + d2_finance: true, + d30_health: true, +}; + export type AnsweredProbeCoverageRow = Readonly<{ semantic_key: string; probe_id?: string; @@ -2214,18 +2228,67 @@ export function buildMethodFollowupPlan(input: { guidedWindows?: readonly GuidedCollectWindow[]; tieBreakRequested?: boolean; }): MethodFollowupPlan { + const isLayerStyleDistinguish = ( + item: Omit, + ): boolean => Boolean( + item.intent === "distinguish_candidates" + && (item.source === "varga_observation" || item.source === "precision_stage") + && !item.semantic_key + && item.domain + && item.method_id in VARGA_STYLE_LAYER_METHODS, + ); + /** + * The dated event probe a layer followup may ask with. Anything already asked + * or answered stays excluded (BUG-559), and style / event-quality probes are + * never dated discriminators. + */ + const datedProbeForLayerFollowup = ( + item: Omit, + ): DiscriminatingEventProbe | null => { + if (!isLayerStyleDistinguish(item)) return null; + const asked = new Set(input.askedProbeKeys ?? []); + const answered = new Set((input.answeredProbes ?? []).map((row) => row.semantic_key)); + return (input.eventProbes ?? []).find((probe) => ( + probe.domain === item.domain + && probe.year > 0 + && probe.choice_kind !== "varga_style" + && probe.choice_kind !== "event_quality" + && !asked.has(probe.semantic_key ?? "") + && !answered.has(probe.semantic_key ?? "") + )) ?? null; + }; const makeFollowup = ( item: Omit, scoring = true, forceChoice?: boolean, ): MethodFollowup => { - const distinguished = attachVargaDistinguishIdentity(item, { - windowScan: input.windowScan ?? null, - candidates: input.candidateRanges?.map((row) => ({ id: row.id || row.time, time: row.time })) - ?? (input.topCandidateTimes ?? []).map((time) => ({ id: time, time })), - }); + // Product decision 2026-09-17 (option b): a varga style question is not a + // full-scoring discriminator. A layer followup is asked only when the + // engine still offers a dated event probe in that domain; otherwise the + // plan moves to the next line (BUG-915). + const datedProbe = datedProbeForLayerFollowup(item); + // A probe with no `semantic_key` cannot be stamped, so the followup stays + // keyless and the focus fails persist (BUG-674) rather than scoring the + // wrong probe (BUG-912). + const distinguished = datedProbe?.semantic_key + ? { + ...item, + semantic_key: datedProbe.semantic_key, + candidate_split_hash: datedProbe.candidate_split_hash, + candidate_ids: datedProbe.candidate_ids ?? item.candidate_ids, + expected_outcomes: datedProbe.expected_outcomes ?? item.expected_outcomes, + probe_year: datedProbe.year, + year_label: datedProbe.year_label, + ...(datedProbe.month ? { probe_month: datedProbe.month } : {}), + choice_kind: "existence" as const, + information_gain: datedProbe.information_gain ?? item.information_gain, + probe_id: datedProbe.semantic_key, + } + : item; const base = { ...distinguished, must_not_label: false as const }; - const attach = forceChoice ?? shouldAttachChoiceFrame(base, input.evidence); + const unbackedLayerCard = !datedProbe && isLayerStyleDistinguish(item); + const attach = forceChoice + ?? (unbackedLayerCard ? false : shouldAttachChoiceFrame(base, input.evidence)); const keyed = Boolean(base.semantic_key) && [ ...(input.eventProbes ?? []), ...(input.eventClarificationProbes ?? []), diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index e4944dd4..92ab81b1 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -10,7 +10,7 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; -import { withFollowupOwnedProbe } from "./varga-distinguish-probe"; +import { withOwnedDistinguishProbes } from "./varga-distinguish-probe"; import { spokenFollowupForUser, spokenCollectFallbackFollowup, @@ -121,9 +121,14 @@ export function expectedAnswerSchemaFor( ...(followup.widen_windows ? { widen_windows: followup.widen_windows } : {}), }; const receipt = decisionReceipt ?? null; - const state = withNakshatraBoundaryProbe( - previousInferenceFromReceipt(receipt), - refinementFromDecisionReceipt(receipt).nakshatra_boundary, + // BUG-915 injection point 1/5: stamping. Probes this layer owns live only in + // the receipt's window scan, so rebuild them before matching the schema. + const state = withOwnedDistinguishProbes( + withNakshatraBoundaryProbe( + previousInferenceFromReceipt(receipt), + refinementFromDecisionReceipt(receipt).nakshatra_boundary, + ), + receipt, ); const verifyOnly = followup.intent === "reverse_verify" || followup.intent === "out_of_sample_check"; const targetedCollect = Boolean( @@ -133,7 +138,7 @@ export function expectedAnswerSchemaFor( if (!targetedCollect && decisionReceipt?.inference_state !== undefined && !state) return null; const stamped = stampChoiceSchemaWithProbe( schema, - verifyOnly || targetedCollect ? null : withFollowupOwnedProbe(state, followup), + verifyOnly || targetedCollect ? null : state, questionId, { semantic_key: followup.semantic_key, diff --git a/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts b/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts index c3524404..ed26e80a 100644 --- a/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts +++ b/frontend/src/lib/rectification-agentic/v9/varga-distinguish-probe.ts @@ -2,12 +2,24 @@ * D9/D10 (and sibling varga) distinguish followups must own their probe. * Borrowing the highest-gain contrast probe (BUG-375 / BUG-912) scores the * wrong chart and leaves GET without a card. + * + * A probe this layer builds is not in the engine's `inference_state`, so it + * must be rebuilt deterministically from the decision receipt at every state + * read (BUG-915), the way `withNakshatraBoundaryProbe` does. A probe that + * exists only in the memory copy that stamped the schema is unanswerable: + * the answer path, GET projection and idle supersede check all read the + * persisted receipt. + * + * Product decision 2026-09-17 (option b): varga style questions are not + * full-scoring `distinguish_candidates` probes, so the plan no longer asks + * one. The rebuild stays for focuses persisted before that decision and for + * option (a) if the product ever reverses it. */ import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts"; -import { signFromTransitions } from "../core/sign-from-transitions.ts"; +import { signFromTransitions, type TransitionSignLookup } from "../core/sign-from-transitions.ts"; import { completeStyleOptions } from "./probe-question-contract.ts"; import { d9StyleLabel, d10StyleLabel, signKey } from "./varga-type-tables.ts"; -import type { WindowScan } from "./varga-observations.ts"; +import { windowScanFromDecisionReceipt } from "./varga-observations.ts"; const METHOD_LAYERS: Readonly> = { d9_relationship: ["d9"], @@ -83,7 +95,7 @@ function choiceKindFor(layer: string): "varga_style" | "existence" { function groupsForLayer( candidates: readonly VargaDistinguishCandidate[], - transitions: WindowScan["transitions"], + transitions: readonly TransitionSignLookup[], layer: string, ): { sign: string; ids: string[] }[] { const bySign = new Map(); @@ -106,7 +118,7 @@ function groupsForLayer( export function buildVargaDistinguishFields(input: { methodId: string; askTheme: string; - transitions: WindowScan["transitions"] | undefined; + transitions: readonly TransitionSignLookup[] | undefined; candidates: readonly VargaDistinguishCandidate[]; }): VargaDistinguishFields | null { const layers = METHOD_LAYERS[input.methodId]; @@ -155,27 +167,6 @@ export function buildVargaDistinguishFields(input: { }; } -export function attachVargaDistinguishIdentity( - item: T, - input: { - windowScan?: WindowScan | null; - candidates?: readonly VargaDistinguishCandidate[]; - }, -): T { - const row = item as T & DistinguishFollowup; - if (row.semantic_key) return item; - if (row.intent !== "distinguish_candidates") return item; - if (row.source !== "varga_observation" && row.source !== "precision_stage") return item; - const fields = buildVargaDistinguishFields({ - methodId: row.method_id, - askTheme: row.ask_theme, - transitions: input.windowScan?.transitions, - candidates: input.candidates ?? [], - }); - if (!fields) return item; - return { ...item, ...fields }; -} - export function conflictProbeFromFollowup(followup: DistinguishFollowup): ConflictProbe | null { const semanticKey = followup.semantic_key?.trim() ?? ""; const outcomes = (followup.expected_outcomes ?? []).flatMap((row) => { @@ -207,23 +198,69 @@ export function conflictProbeFromFollowup(followup: DistinguishFollowup): Confli }; } -export function withFollowupOwnedProbe( +/** + * Every varga style question this layer can own, keyed exactly as the plan + * names it (`:`). Kept as data so the rebuild does not + * depend on which followup happens to be asking. + */ +const OWNED_DISTINGUISH_QUESTIONS = [ + { methodId: "d9_relationship", askTheme: "relationship_style", domain: "relationship" }, + { methodId: "d10_career", askTheme: "career_style", domain: "career" }, + { methodId: "d4_home", askTheme: "home_change", domain: "relocation" }, + { methodId: "d5_education", askTheme: "education_style", domain: "education" }, + { methodId: "relatives", askTheme: "family_event", domain: "family" }, + { methodId: "d2_finance", askTheme: "finance_change", domain: "finance" }, + { methodId: "d30_health", askTheme: "health_pressure", domain: "health_pressure" }, +] as const; + +/** + * Rebuilt probes carry their own source so the decision layer can keep them out + * of the contrast packet and the event-probe pool: they exist only so a focus + * persisted before the 2026-09-17 (b) decision stays answerable, never as a + * question to ask. + */ +export const OWNED_DISTINGUISH_PROBE_SOURCE = "owned_varga_style"; + +/** + * Rebuild every probe this layer owns from the persisted receipt (BUG-915). + * + * Deterministic: the window-scan transitions plus the candidate minutes in the + * state decide the groups, so the answer path, the GET projection and the idle + * supersede check all see the same probe the schema was stamped with. Nothing + * here depends on the followup that is currently being asked. + */ +export function withOwnedDistinguishProbes( state: InferenceState | null, - followup: DistinguishFollowup | null | undefined, + receipt: Readonly> | null | undefined, ): InferenceState | null { - if (!state || !followup) return state; - // Only inject probes this layer built. Catalog probes that are not yet in - // inference_state must still fail persist (BUG-912 must not undo T0 of - // rectification-probe-pool-exhausted-20260911). - const ownedKey = `${followup.method_id}:${followup.ask_theme}`; - if (followup.semantic_key !== ownedKey) return state; - if (followup.source !== "varga_observation" && followup.source !== "precision_stage") return state; - const probe = conflictProbeFromFollowup(followup); - if (!probe) return state; - if (state.probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) { - return state; + if (!state) return state; + const transitions: readonly TransitionSignLookup[] = windowScanFromDecisionReceipt(receipt)?.transitions + ?? state.transitions + ?? []; + if (transitions.length === 0) return state; + const candidates = state.candidates.map((item) => ({ id: item.id, time: item.time })); + let probes = state.probes; + for (const row of OWNED_DISTINGUISH_QUESTIONS) { + const fields = buildVargaDistinguishFields({ + methodId: row.methodId, + askTheme: row.askTheme, + transitions, + candidates, + }); + if (!fields) continue; + const probe = conflictProbeFromFollowup({ + method_id: row.methodId, + ask_theme: row.askTheme, + domain: row.domain, + intent: "distinguish_candidates", + source: OWNED_DISTINGUISH_PROBE_SOURCE, + ...fields, + }); + if (!probe) continue; + if (probes.some((item) => item.id === probe.id || item.semantic_key === probe.semantic_key)) continue; + probes = [...probes, probe]; } - return { ...state, probes: [...state.probes, probe] }; + return probes === state.probes ? state : { ...state, probes }; } export function liveDistinguishProbe( diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index a648cfe6..0bbcdad4 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -302,6 +302,12 @@ export type RectificationQuestionGapInput = Readonly<{ stopReason?: string | null; /** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */ deadChoice?: boolean; + /** + * The latest assistant message still carries an unanswered tap question that + * cannot be answered (no live card, or its focus was superseded). A dead card + * and the collect-wait placeholder must never share a screen (BUG-917). + */ + unansweredDeadChoice?: boolean; questionId?: string | null; hasChoiceCard?: boolean; busy: boolean; @@ -329,6 +335,9 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp if (!input.resumableCase) return "idle"; if (input.liveQuestionVisible) return "idle"; if (interviewDeliveredGap(input)) return "delivered"; + // BUG-917: an unanswerable tap question on the latest message is a repair + // exit, never 「再说一件带年月的事」. + if (input.unansweredDeadChoice) return "unavailable"; if (input.collectWaiting) return "collect_waiting"; if (input.questionPersisted) return "persisted_question"; if (input.offerAwaitingReader) return "idle"; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 91fc2e6f..dd7c4bb5 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -91,6 +91,7 @@ import { compactInferenceProjection, previousInferenceFromReceipt, } from "@/lib/rectification-agentic/v9/inference-adapter"; +import { inferenceForPersistedAnswer } from "@/lib/rectification-agentic/v9/answer-choice"; import { COLLECT_FOCUS_SCHEMA_KEY, collectFocusSchema, @@ -1410,7 +1411,11 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); const focus = dossier.conversationSummary.activeFocus; - const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + // BUG-915 injection point 5/5: a typed answer to a persisted + // choice reads the same state the tap path reads. + const previous = inferenceForPersistedAnswer( + dossier.latestResult?.decisionReceipt ?? null, + ); if (focus && focus.id === input.focusId && previous) { const applied = applyChoiceWithoutEvidence(previous, { choiceKey: input.choiceKey, diff --git a/frontend/tests/rectification-dead-d9-choice-20260916.test.ts b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts index 9ebba421..567122ab 100644 --- a/frontend/tests/rectification-dead-d9-choice-20260916.test.ts +++ b/frontend/tests/rectification-dead-d9-choice-20260916.test.ts @@ -1,27 +1,39 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { readFileSync } from "node:fs"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { applyChoiceWithoutEvidence, + previousInferenceFromReceipt, stampChoiceSchemaWithProbe, } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { - projectRectificationChoiceCard, + buildMethodFollowupPlan, yearlessPersonalityCanAsk, type MethodFollowup, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts"; +import { rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + inferenceForPersistedAnswer, + persistedDistinguishFocusStale, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts"; import { - attachVargaDistinguishIdentity, - withFollowupOwnedProbe, + buildVargaDistinguishFields, + withOwnedDistinguishProbes, } from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts"; +import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts"; import { FOCUS_ID } from "./rectification-v9-test-support.ts"; +const OWNED_KEY = "d9_relationship:relationship_style"; +const QUESTION_ID = "d9_relationship:relationship_style:score"; + const DATED = [ { status: "confirmed", @@ -72,7 +84,8 @@ const D24 = { choice_kind: "event_quality" as const, }; -const WINDOW = parseWindowScan({ +/** Engine window scan as it arrives on the decision receipt. */ +const WINDOW_SCAN_RECEIPT = { scanned: true, d9_lagna_count: 2, d10_lagna_count: 1, @@ -84,9 +97,18 @@ const WINDOW = parseWindowScan({ { layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" }, { layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" }, ], -}); +}; -function state() { +const WINDOW = parseWindowScan(WINDOW_SCAN_RECEIPT)!; + +const CANDIDATES = [ + { id: "04:48", time: "04:48" }, + { id: "04:53", time: "04:53" }, + { id: "04:59", time: "04:59" }, +]; + +/** The persisted state: engine probes only, exactly as the receipt stores it. */ +function persistedState() { return buildInferenceState({ range_start: "04:48", range_end: "05:07", @@ -105,8 +127,24 @@ function state() { }); } -function d9Followup() { - const attached = attachVargaDistinguishIdentity({ +function receipt() { + return { inference_state: persistedState(), window_scan: WINDOW_SCAN_RECEIPT }; +} + +/** + * A D9 style focus as it was persisted before the 2026-09-17 (b) decision: + * its identity is the layer's own key, and its probe lives nowhere but the + * receipt's window scan. + */ +function legacyD9Followup(): MethodFollowup { + const fields = buildVargaDistinguishFields({ + methodId: "d9_relationship", + askTheme: "relationship_style", + transitions: WINDOW.transitions, + candidates: CANDIDATES, + }); + assert.ok(fields); + const followup = { method_id: "d9_relationship", intent: "distinguish_candidates", ask_theme: "relationship_style", @@ -116,17 +154,11 @@ function d9Followup() { source: "varga_observation", must_not_label: false, choice_frame: null, - }, { - windowScan: WINDOW, - candidates: [ - { id: "04:48", time: "04:48" }, - { id: "04:53", time: "04:53" }, - { id: "04:59", time: "04:59" }, - ], - }) as MethodFollowup; - const frame = buildChoiceFrame(attached, { + ...fields, + } as MethodFollowup; + const frame = buildChoiceFrame(followup, { evidence: DATED, - probes: attached.style_options ? [{ + probes: [{ year: 0, year_label: "当前这几个候选", domain: "relationship", @@ -135,23 +167,36 @@ function d9Followup() { tracks: ["vimshottari", "narayana"], tracks_agree: true, unique_minute_claim: false, - user_meaning: attached.user_prompt_hint, + user_meaning: followup.user_prompt_hint, role: "distinguish", phase: "candidate_discriminator", - information_gain: attached.information_gain, - semantic_key: attached.semantic_key, - candidate_split_hash: attached.candidate_split_hash, - candidate_ids: attached.candidate_ids, - expected_outcomes: attached.expected_outcomes, + information_gain: followup.information_gain, + semantic_key: followup.semantic_key, + candidate_split_hash: followup.candidate_split_hash, + candidate_ids: followup.candidate_ids, + expected_outcomes: followup.expected_outcomes, choice_kind: "varga_style" as const, - style_options: attached.style_options, - }] : [] as never, + style_options: followup.style_options, + }], }); - return { ...attached, choice_frame: frame } as MethodFollowup; + assert.ok(frame); + return { ...followup, choice_frame: frame }; } -test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)", () => { - const inference = state(); +function persistedSchema(): Record { + const followup = legacyD9Followup(); + const schema = expectedAnswerSchemaFor( + followup.choice_frame!, + QUESTION_ID, + receipt(), + followup, + ); + assert.ok(schema); + return schema; +} + +test("stamping rebuilds the owned probe from the receipt instead of borrowing D24 (BUG-912/915)", () => { + const state = persistedState(); const borrowed = stampChoiceSchemaWithProbe({ choice: { options: [ @@ -161,89 +206,260 @@ test("year-stage D9 followup does not borrow the D24 contrast probe (BUG-912)", { key: "D", answer_class: "unsure" }, ], }, - }, inference, "d9_relationship:relationship_style:score"); + }, state, QUESTION_ID); assert.notEqual(borrowed.probe_id, D24.id); - const followup = d9Followup(); - assert.equal(followup.semantic_key, "d9_relationship:relationship_style"); - assert.ok(followup.choice_frame); - const schema = expectedAnswerSchemaFor( - followup.choice_frame!, - followup.choice_frame!.question_id, - { inference_state: inference, window_scan: WINDOW }, - followup, - ); - assert.ok(schema); - assert.equal(schema.semantic_key, "d9_relationship:relationship_style"); - assert.notEqual(String(schema.probe_id ?? ""), D24.id); - assert.match(String(schema.probe_id ?? ""), /d9_relationship:relationship_style/); + // The persisted receipt itself never carries the owned probe. + const stored = previousInferenceFromReceipt(receipt()); + assert.ok(stored); + assert.equal(stored.probes.some((probe) => probe.semantic_key === OWNED_KEY), false); + const rebuilt = withOwnedDistinguishProbes(stored, receipt()); + assert.ok(rebuilt?.probes.some((probe) => probe.semantic_key === OWNED_KEY)); - const applied = applyChoiceWithoutEvidence(withFollowupOwnedProbe(inference, followup)!, { + const schema = persistedSchema(); + assert.equal(schema.semantic_key, OWNED_KEY); + assert.equal(schema.probe_id, `probe:${OWNED_KEY}`); + assert.notEqual(String(schema.probe_id ?? ""), D24.id); +}); + +test("the production answer path applies a persisted owned-probe choice (BUG-915)", () => { + const schema = persistedSchema(); + const answerInput = { choiceKey: "C", schema, - questionId: "d9_relationship:relationship_style:score", + questionId: QUESTION_ID, domain: "relationship", - classifiedFrom: "choice", - }); + classifiedFrom: "choice" as const, + }; + // Without the rebuild the same schema is unanswerable: that was the P0. + const withoutRebuild = applyChoiceWithoutEvidence(previousInferenceFromReceipt(receipt())!, answerInput); + assert.equal(withoutRebuild.applied, false); + assert.equal(withoutRebuild.reason, "stale_probe"); + + const applied = applyChoiceWithoutEvidence(inferenceForPersistedAnswer(receipt())!, answerInput); + assert.equal(applied.reason, "applied"); assert.equal(applied.applied, true); - assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, "d9_relationship:relationship_style"); + assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, OWNED_KEY); assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id)); }); -test("GET projects the persisted distinguish copy when its probe is still live (BUG-912)", () => { - const inference = state(); - const followup = d9Followup(); - const schema = expectedAnswerSchemaFor( - followup.choice_frame!, - "d9_relationship:relationship_style:score", - { inference_state: inference, window_scan: WINDOW }, - followup, - ); - const withOwned = buildInferenceState({ - range_start: "04:48", - range_end: "05:07", - candidates: [ - { id: "04:48", time: "04:48", relative_support: 44 }, - { id: "04:53", time: "04:53", relative_support: 35 }, - { id: "04:59", time: "04:59", relative_support: 21 }, - ], - events: inference.events, - probes: [ - D24, - { - id: String(schema?.probe_id), - semantic_key: String(schema?.semantic_key), - candidate_split_hash: String(schema?.candidate_split_hash), - domain: "relationship", - year: 0, - question: "D9", - candidate_ids: ["04:48", "04:59"], - expected_outcomes: D24.expected_outcomes, - information_gain: 0.4, - source: "varga_observation", - choice_kind: "varga_style" as const, - style_options: followup.style_options as never, - }, - ], - }); - const card = projectRectificationChoiceCard({ +test("GET projects the persisted D9 card from the receipt alone (BUG-915)", () => { + const schema = persistedSchema(); + const card = choiceCardFromCaseDossier({ evidence: DATED, - activeFocus: { - id: FOCUS_ID, - questionId: "d9_relationship:relationship_style:score", - intent: "distinguish_candidates", - targetDomain: "relationship", - targetKind: null, - expectedAnswerSchema: schema, + conversationSummary: { + activeFocus: { + id: FOCUS_ID, + questionId: QUESTION_ID, + intent: "distinguish_candidates", + targetDomain: "relationship", + targetKind: null, + expectedAnswerSchema: schema, + }, + declinedSkippedTopics: [], }, - observations: [{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }], - windowScan: WINDOW, - inferenceState: withOwned, - sessionOutcome: "collect_evidence", - caseRevision: 1, + latestResult: { + resultId: "result-1", + decisionReceipt: receipt(), + selectionAllowed: false, + confirmationAllowed: false, + candidates: [ + { time: "04:48", rank: 1, relativeSupport: 44 }, + { time: "04:53", rank: 2, relativeSupport: 35 }, + { time: "04:59", rank: 3, relativeSupport: 21 }, + ], + }, + case: { acceptedTime: null, status: "active", stage: "minute" }, }); - assert.equal(card?.question_id, "d9_relationship:relationship_style:score"); - assert.equal(card?.options.length, 4); + assert.ok(card, "GET must render the persisted focus"); + assert.equal(card.question_id, QUESTION_ID); + assert.equal(card.options.length, 4); +}); + +test("idle persist does not supersede a focus whose probe is rebuilt (BUG-915)", () => { + const schema = persistedSchema(); + const focus = { intent: "distinguish_candidates", expectedAnswerSchema: schema }; + // The raw receipt state has no owned probe: that is what superseded the + // fresh focus on every idle persist. + assert.equal(persistedDistinguishFocusStale(previousInferenceFromReceipt(receipt()), focus), true); + assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(receipt()), focus), false); + + // A changed candidate set still expires the focus (BUG-559 stays). + const movedReceipt = { + window_scan: WINDOW_SCAN_RECEIPT, + inference_state: buildInferenceState({ + range_start: "04:48", + range_end: "05:07", + candidates: [ + { id: "04:48", time: "04:48", relative_support: 60 }, + { id: "04:59", time: "04:59", relative_support: 40 }, + ], + events: persistedState().events, + probes: [D24], + }), + }; + assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(movedReceipt), focus), true); +}); + +test("every state read that can touch an owned probe rebuilds it (BUG-915 source contract)", () => { + const sites = [ + "src/lib/rectification-agentic/v9/server-focus.ts", + "src/lib/rectification-agentic/v9/interview-state.ts", + ]; + for (const site of sites) { + const source = readFileSync(new URL(`../${site}`, import.meta.url), "utf8"); + assert.match(source, /withOwnedDistinguishProbes\(/, site); + } + const answerChoice = readFileSync( + new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), + "utf8", + ); + // The tap answer and the idle supersede check both read the rebuilt state. + assert.match(answerChoice, /const previous = inferenceForPersistedAnswer\(receipt\);/); + assert.match(answerChoice, /const inferenceForStale = withOwnedDistinguishProbes\(/); + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + // The typed answer goes through the same helper as the tap. + assert.match(tools, /const previous = inferenceForPersistedAnswer\(/); +}); + +const REL_2020_PROBE = { + year: 2020, + year_label: "2020 年前后", + domain: "relationship" as const, + event_family: "开始认真关系、分手或结婚", + source: "dasha_activation" as const, + tracks: ["vimshottari", "narayana"] as const, + tracks_agree: true, + unique_minute_claim: false as const, + user_meaning: "2020 年感情线", + role: "distinguish" as const, + phase: "candidate_discriminator" as const, + information_gain: 0.5, + semantic_key: "relationship.2020", + candidate_split_hash: "set:relationship.2020", + candidate_ids: ["04:48", "04:53", "04:59"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:53", "04:59"] }, + { answer_class: "weak_yes" as const, supports: ["04:53"], conflicts: ["04:48", "04:59"] }, + { answer_class: "no" as const, supports: ["04:59"], conflicts: ["04:48", "04:53"] }, + { answer_class: "unsure" as const, supports: [], conflicts: [] }, + ], + choice_kind: "existence" as const, +}; + +/** Every dated collect line already closed, as in the 2026-09-17 real run. */ +const CLOSED_COLLECT = [ + "family", + "education", + "finance", + "relocation", + "health_pressure", + "career", + "relationship", +].map((domain) => ({ + question_id: `collect:targeted:${domain}`, + target_kind: `targeted:${domain}`, + domain, + status: "declined", + intent: "collect_method_evidence", +})); + +function planWithD9Split(input: { askedProbeKeys: readonly string[] }) { + return buildMethodFollowupPlan({ + evidence: DATED, + observations: [ + { layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }, + { layer: "d10", candidates_differ: false, ask_theme: null }, + ], + windowScan: WINDOW, + inferenceState: persistedState(), + sessionOutcome: "discriminate_candidates", + precisionStage: "d9_refine", + eventProbes: [REL_2020_PROBE], + askedProbeKeys: input.askedProbeKeys, + closedCollectFocuses: CLOSED_COLLECT, + topCandidateTimes: ["04:48", "04:53", "04:59"], + candidateRanges: CANDIDATES.map((row) => ({ id: row.id, time: row.time })), + }); +} + +test("a varga style question is never a scoring discriminator (2026-09-17 decision b)", () => { + // The engine's relationship probe is already asked, so nothing dates this + // line: before the decision this is where 「你更接近哪一种相处方式」 came from. + const plan = planWithD9Split({ askedProbeKeys: ["relationship.2020"] }); + const next = plan.next_followup; + assert.notEqual(next?.semantic_key, OWNED_KEY); + assert.notEqual(next?.choice_kind, "varga_style"); + assert.doesNotMatch(next?.choice_frame?.prompt ?? "", /更接近哪一种/); + assert.equal( + next?.intent === "distinguish_candidates" + && (next.source === "varga_observation" || next.source === "precision_stage"), + false, + JSON.stringify({ intent: next?.intent, source: next?.source, key: next?.semantic_key }), + ); + assert.ok( + plan.dropped_probes.some((row) => ( + row.semantic_key === "d9_relationship" && row.reason === "frameless_distinguish" + )), + JSON.stringify(plan.dropped_probes), + ); +}); + +test("the D9 line is still asked when the engine dates it (2026-09-17 decision b)", () => { + const plan = planWithD9Split({ askedProbeKeys: [] }); + const next = plan.next_followup; + assert.equal(next?.method_id, "d9_relationship"); + assert.equal(next?.intent, "distinguish_candidates"); + assert.equal(next?.semantic_key, REL_2020_PROBE.semantic_key); + assert.equal(next?.choice_kind, "existence"); + assert.match(next?.choice_frame?.prompt ?? "", /2020/); +}); + +test("a rebuilt owned probe never becomes an askable discriminator (decision b)", () => { + // Answering an old style focus writes the rebuilt probes into the receipt. + const answered = withOwnedDistinguishProbes(previousInferenceFromReceipt(receipt()), receipt())!; + const owned = answered.probes.find((probe) => probe.semantic_key === OWNED_KEY); + assert.ok(owned); + const catalog = rectificationFollowupCatalog( + { resultId: "result-1", decisionReceipt: { ...receipt(), inference_state: answered } }, + DATED, + ); + assert.equal(catalog.eventProbes.some((probe) => probe.semantic_key === OWNED_KEY), false); + assert.equal( + (catalog.contrastPacket?.probes ?? []).some((probe) => probe.semanticKey === OWNED_KEY), + false, + ); +}); + +test("a dead tap card never shares the screen with the collect-wait placeholder (BUG-917)", () => { + const base = { + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: 0, + collectWaiting: true, + }; + assert.equal(rectificationQuestionGapState(base), "collect_waiting"); + assert.equal( + rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }), + "unavailable", + ); + const chat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", + ); + assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/); + assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/); + const entry = readFileSync( + new URL("../src/components/rectification-message-entry.tsx", import.meta.url), + "utf8", + ); + assert.match(entry, /question\.status === "superseded" && !question\.answer_option/); }); test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => { @@ -255,7 +471,7 @@ test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)", const earlier = "希望两边都能说得过去"; const later = "必要时会直接选边"; const delivery = buildRangeDelivery({ - inference: state(), + inference: persistedState(), publicCandidates: [ { candidateId: "a", time: "04:48" }, { candidateId: "b", time: "04:53" },