From a998b6ec53f93efea2b4a5777b28ce0118cb425f Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 10 Sep 2026 17:38:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20stop=20unwritten-evidence?= =?UTF-8?q?=20claims=20and=20same-cluster=20dasha=20false=20conflicts=20(B?= =?UTF-8?q?UG-635=E2=80=93640)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host only says 记下了 after a real write; Mastra schema rejections fail closed. Ledger year keys no longer drop quality probes, dual-dasha agreement is per cluster, width uses cluster span, and public house tables follow the inference minute. Co-authored-by: Cursor --- CHANGELOG.md | 10 + docs/BUG_HISTORY.md | 115 +++++- ...ESS-rectification-latent-audit-20260910.md | 51 +++ ...ation-unwritten-evidence-claim-20260910.md | 75 ++++ docs/tasks/README.md | 4 +- ...ation-unwritten-evidence-claim-20260910.md | 4 +- .../rectification-scenarios-20260907.md | 10 + frontend/DESIGN.md | 2 +- .../src/app/api/rectification/agent/route.ts | 52 +-- .../components/rectification-agentic-chat.tsx | 13 +- .../core/candidate-contrast-packet.ts | 4 +- .../lib/rectification-agentic/user-copy.ts | 2 + .../lib/rectification-agentic/v9/agent-run.ts | 151 +++++--- .../v9/candidate-plateau.ts | 13 +- .../v9/confirmation-gate.ts | 2 + .../rectification-agentic/v9/host-fallback.ts | 46 ++- .../v9/method-followup.ts | 20 +- .../v9/probe-question-contract.ts | 24 ++ .../v9/refinement-packet.ts | 51 +++ .../v9/run-diagnostic.ts | 2 + .../v9/stream-mapping.ts | 11 + .../v9/turn-intent-classifier.ts | 41 ++- frontend/src/mastra/rectification-v9-tools.ts | 20 +- .../tests/agent-voice-copy-contract.test.ts | 2 + .../tests/rectification-agentic-entry.test.ts | 2 + .../rectification-confirmation-gate.test.ts | 17 + ...ectification-delivery-report-facts.test.ts | 50 +++ .../rectification-engine-convergence.test.ts | 59 +++ .../tests/rectification-host-fallback.test.ts | 32 ++ ...ication-probe-year-dedupe-20260906.test.ts | 104 +++++- .../rectification-spoken-collect.test.ts | 9 + ...ctification-turn-intent-classifier.test.ts | 28 ++ .../rectification-unwritten-evidence.test.ts | 346 ++++++++++++++++++ .../tests/rectification-v9-stream.test.ts | 7 +- scripts/rectification/decision_policy.py | 13 +- scripts/rectification/refinement_packet.py | 34 +- tests/test_rectification_confirmation_and.py | 20 + tests/test_rectification_refinement_packet.py | 115 +++++- 38 files changed, 1437 insertions(+), 124 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-latent-audit-20260910.md create mode 100644 docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md create mode 100644 frontend/tests/rectification-unwritten-evidence.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b4d206..1b075ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 印度占星 Skill 更新日志 +## 2026-09-10 — 已记学业后仍可问发挥;同段候选的双轨不再互相打架;宫位表跟卡片分钟对齐 + +已经记下某年学业后,仍可能再问那次发挥得怎么样。主限和分盘大运落在同一段候选里,不再当成互相打架。区间宽度按整段候选来算,不再只看各段代表分钟。宫位表和「已按该分钟重算」跟卡片上的代表分钟对齐。Skill 版本不变。 + + +## 2026-09-10 — 证据没记上时助手会明说并再显示原题,不再只说记下了 + +生时校正补经历时,如果助手说「记下了」但其实没有写入,不再假装记下并扣点。会收回那句,必要时再试一次;两次都没写就说明「这件我还没记上」,并再给出原来那道采集题。Skill 版本不变。 + + ## 2026-09-10 — 记下的经历不再因为模型没写正文而整轮失败;范围没变时会说出哪段领先 生时校正补经历后,如果事情已经记下、下一问也算好了,只是模型没写出「记下了」那句,页面不再只剩「没有拿到下一个问题」。会用已经记下的事情写成那句话,下一问照常出现。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c8ff6c7c..342b94a5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5679,16 +5679,16 @@ - 状态:resolved - 首次发现:2026-08-26 -- 最近更新:2026-08-26 -- 影响面:`method_followup_plan`、`persistServerOwnedFocus`、`known_event_quality` +- 最近更新:2026-09-10 +- 影响面:`method_followup_plan`、`persistServerOwnedFocus`、`known_event_quality`、`askedKeysForSameYearDedup` - 用户现象:记下带年份的学业经历后,口语会问那次考试有没有发挥失常,界面却没有 A/B/C/D 点选卡,只能打字。dasha 冲突反推卡仍按采用门等待,不在此列。 - 触发条件:账本已有该年学业等可评分事件;decision receipt 含 `known_event_quality`;采用门 3 件/2 领域未齐,下一方法层仍是感情收集。 - 根因:`remainingReverseVerifyProbes` 跳过 `known_event_quality`。采用前只有 dasha 冲突探针能变成 `event_probe` 点选卡,且还要等 3 件/2 领域。发挥质量探针留在 receipt 里,模型用自然语言问,服务器不盖 `choice_frame`。即便盖了,`information_gain` 为 0 也会被 `shouldSkipDiscriminatorFollowup` 丢掉。 -- 修复:已记下对应年份后,`known_event_quality` 在方法轮换之前出 `event_quality` 点选卡,不要求采用门。摘要已编码发挥质量则不再出卡。零信息增益不再挡住发挥质量卡。dasha 冲突探针仍等 3 件/2 领域。不改 Skill `10.0.11`。 -- 验证:`frontend/tests/rectification-eight-method.test.ts`、`frontend/tests/rectification-server-focus.test.ts`、`tests/test_rectification_event_probes.py`。 -- 防复发:已记下年份的发挥质量探针必须挂 `choice_frame` 并持久化。不得把 dasha 冲突探针的采用门门槛套到发挥质量卡上。不得把 `confirmation_allowed` 改成 true。 -- 相关记录:BUG-348、BUG-384、BUG-386、BUG-388 -- 复发自:BUG-384(口语问发挥质量,点选卡却被冲突探针占住;采用门修好后变成完全没有卡) +- 修复:已记下对应年份后,`known_event_quality` 在方法轮换之前出 `event_quality` 点选卡,不要求采用门。摘要已编码发挥质量则不再出卡。零信息增益不再挡住发挥质量卡。dasha 冲突探针仍等 3 件/2 领域。不改 Skill `10.0.11`。2026-09-10:账本派生的 `domain.year` 键不得再把发挥质量题判成 `same_year_asked`(见 BUG-637)。 +- 验证:`frontend/tests/rectification-eight-method.test.ts`、`frontend/tests/rectification-server-focus.test.ts`、`tests/test_rectification_event_probes.py`;2026-09-10 补 `rectification-probe-year-dedupe-20260906.test.ts`、`rectification-engine-convergence.test.ts`。 +- 防复发:已记下年份的发挥质量探针必须挂 `choice_frame` 并持久化。不得把 dasha 冲突探针的采用门门槛套到发挥质量卡上。不得把 `confirmation_allowed` 改成 true。账本 `domain.year` 键只拦存在性探针。 +- 相关记录:BUG-348、BUG-384、BUG-386、BUG-388、BUG-592、BUG-637 +- 复发自:BUG-384(口语问发挥质量,点选卡却被冲突探针占住;采用门修好后变成完全没有卡);2026-09-10 再次复发自 BUG-592(见 BUG-637) - 修复版本:待发布 ## BUG-390 | 事业经历未过采用门就出高考发挥点选卡 @@ -9194,15 +9194,16 @@ - 状态:resolved - 首次发现:2026-09-08 -- 最近更新:2026-09-08 +- 最近更新:2026-09-10 - 影响面:`latestResultToolProjection`、`buildSkillVerificationPacket`、`jyotish-birth-time-rectification@10.0.16` - 用户现象:区间卡已是约 3 分钟可信窗,交付轮正文却写宽度约半小时、双轨偏向已淘汰分钟、某候选 D10 升成下一座。 - 触发条件:推断层已把候选收到短区间并出交付卡;引擎 `tied_minute_count` / `dasha_agreement` 仍按全部候选计算;`window_scan` 换升在代表分钟之后。 - 根因:结果投影把 `candidates` 换成推断层,但报告仍喂引擎 `indistinguishable_width_minutes` 和 `dasha_agreement`。分盘上升没有按候选分钟给出星座,模型按换升时刻自己算。 - 修复:有推断层时报告宽度用 `credible_range` 含两端分钟数;双轨在 active 候选内重算,引擎原值只留 `dasha_agreement_pre_inference` 供审计。顶层宽度改名 `engine_indistinguishable_width_minutes`。报告给出 `sign_by_candidate`,与分歧面板共用换升函数。确认门不改。Skill 10.0.16 要求只抄该表。 + 补正(2026-09-10):代码里**已有** `dasha_agreement_pre_inference`。BUG-638 修的是引擎原值仍按网格分钟判冲突、与报告口径不一致,不是这个字段缺失。 - 验证:`frontend/tests/rectification-delivery-report-facts.test.ts`:推断窗 04:51–04:53、引擎宽 29、双轨 top 为已淘汰分钟时,报告 `width_minutes === 3` 且正文不含 29 / 已淘汰分钟;D10 05:00 换升时 `sign_by_candidate["04:53"].d10 === "巨蟹座"`。`agent-voice-copy-contract` / `skill-registry` 锁 10.0.16 新句。 - 防复发:交付轮宽度、双轨、分盘星座必须来自推断层报告字段,不得再把引擎原跨度或换升时刻交给模型自算。 -- 相关记录:BUG-545、BUG-568、BUG-290 +- 相关记录:BUG-545、BUG-568、BUG-290、BUG-638 - 复发自:BUG-290(宽度字段进投影后未随推断层更新);BUG-568(区间读盘,交付正文仍用开工跨度) - 修复版本:`06e44104` @@ -9859,3 +9860,99 @@ - 相关记录:BUG-593、BUG-606、BUG-629 - 复发自:无 - 修复版本:待发布 + +## BUG-635 | 证据轮只说「记下了」却没写入,财务经历静默丢失、流程停在原题 + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`runV9AgentTurn` `expectedWrite`、`streamAttempt` 收尾、`RECTIFICATION_USER_COPY.evidenceNotRecorded`、`liveQuestionOnMessages` +- 用户现象:财务采集题下回一句带年月的欠债/收入变化后,助手只回「记下了:…。」下面没有下一问也没有卡;时间线仍是原范围;证据数不变;该轮照常扣点。 +- 触发条件:collect 焦点下用户提供带年月经历,模型只调 `rectification-read-case` 后按格式写「记下了」,不调 `rectification-record-evidence-batch` / `rectification-set-focus`。 +- 根因:路由分类结果没传给运行器,收尾只要正文非空就 `completeAttempt`,不看有没有公开写工具。客户端把旧消息上的同 `focus_id` 当成问题仍在显示,缺口不补主持人问题行。 +- 修复:路由把 `expectedWrite` 设为 `"evidence" | "none" | "unknown"` 传进运行器;`provide_new_evidence` 或 `answer_current_focus` + `has_new_dated_event` → `"evidence"`。分类器 null/出错重试一次,仍失败则 `"unknown"` 且守卫 fail-open,**不用年份正则或关键词兜底**。无焦点的 `message` 路径也跑同一分类器。无写入的「记下了」第一次 `evidence_not_written` 可重试(零写工具,不进 `RETRYABLE_ERROR_CODES`);第二次主持人正文「这件我还没记上。请再说一次大概年月和发生的事。」,`answer.host_fallback`,不结算计费。`liveQuestionOnMessages` 只认最后一条 settled 助手消息。 +- 验证:`frontend/tests/rectification-unwritten-evidence.test.ts`,以及 host-fallback / spoken-collect / agentic-entry / turn-intent-classifier / agent-voice-copy-contract。 +- 防复发:有公开写工具 completed 不得重试(BUG-186);兜底正文不得由宿主复述用户原话冒充「记下了」;旧消息同 `focus_id` 不得挡住主持人问题行。 +- 相关记录:BUG-186、BUG-278、BUG-359、BUG-449、BUG-633 +- 复发自:BUG-633(只补了 batch 已完成且无正文,没补正文声称记下了但 batch 没跑) +- 修复版本:待发布 + +## BUG-636 | 校正流把 Mastra inputSchema 拒绝信封报成 tool completed + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`mapStreamChunkToActivity` / `mapStreamChunkToPhase` / `batchResultFromToolChunk` +- 用户现象:本事故未触发。若模型给 batch 传了不在 `EVIDENCE_KINDS` 的 kind,回执会显示 `evidence.proposed` completed,写工具守卫会被假 completed 绕过。 +- 触发条件:`createTool` 的 `inputSchema` 校验失败时 resolve `{ error: true, message, validationErrors }`,流层当普通 `tool-result`。 +- 根因:咨询流 BUG-278 已按信封改发 `tool.failed`;校正流 `tool-result` 没有同样判定。 +- 修复:信封结构 `error === true` 且 `validationErrors` 为对象时发 `tool.activity failed code=tool_call_rejected`,不发 completed phase;`batchResultFromToolChunk` 与 `composeHostFallbackNarration` 对信封返回 null。公开回执不含 `validationErrors`。 +- 验证:`frontend/tests/rectification-unwritten-evidence.test.ts`、`rectification-host-fallback.test.ts`。 +- 防复发:不得把 Mastra 拒绝信封当 batch 返回值;不得把 `validationErrors` 写进公开事件。 +- 相关记录:BUG-278、BUG-635 +- 复发自:BUG-278(咨询流已修,校正流未跟) +- 修复版本:待发布 + +## BUG-637 | 账本同年键把已记学业的发挥质量题一并丢掉 + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`askedKeysForSameYearDedup`、`inspectDiscriminatorProbes`、`renderableEventProbe`、`remainingReverseVerifyProbes` +- 用户现象:账本已有某年学业经历,`dropped_probes` 里 `education.YYYY.known_event_quality` 被标 `same_year_asked`,界面再也问不到「那次发挥怎么样」。 +- 触发条件:账本派生 `domain.year` 键进入 `askedDiscriminatorKeys`;发挥质量语义键必然同域同年。 +- 根因:BUG-592 的同年硬排除对账本键和已答键一视同仁。发挥质量题存在的前提就是账本已有那年事件,于是被误杀。BUG-389 的测试没覆盖「账本已有同年事件」。 +- 修复:账本派生的精确 `domain.year` 键只拦存在性探针。`known_event_quality` / `event_quality` 仍受回执已答键约束,同一道不问两遍。不改 `SCORE_DELTA`、四选项、确认门。 +- 验证:`frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`、`rectification-engine-convergence.test.ts`。 +- 防复发:账本 `domain.year` 不得单独让发挥质量题变成 `same_year_asked`。已答过该质量键后仍须丢掉。 +- 相关记录:BUG-389、BUG-592 +- 复发自:BUG-389(被 BUG-592 覆盖) +- 修复版本:待发布 + +## BUG-638 | 双轨一致性按网格分钟判冲突,同簇两个峰值被降置信度 + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`dasha_agreement`、`dashaAgreementAmongActive`、`decision_policy` +- 用户现象:主限和分盘大运峰值落在同一段候选里,回执仍写冲突并降低把握;报告另算一套说只作观察。 +- 触发条件:两轨 argmax 分钟不同,但同属一个 `cluster_start–cluster_end`。 +- 根因:Python 对整窗网格分钟逐分取峰值,分钟不等即 `conflict`。决策把该冲突写进 reasons 并降置信度。TS 报告只在推断层 active 候选上重算。 +- 修复:按簇比较:两轨 argmax 映射到所在簇,同簇即 `agree`。没有簇时仍按分钟比较。推断层 active 簇一致也视为 agree,即使某一轨峰值不是代表分钟。引擎原值保留为 `dasha_agreement_pre_inference`。确认门不因本次 `agree` 从关到开。 +- 验证:`tests/test_rectification_refinement_packet.py`、`frontend/tests/rectification-delivery-report-facts.test.ts`。 +- 防复发:同簇不同分钟不得再写 `vimshottari_narayana_conflict`。跨簇仍须 `conflict`。不得把 `confirmation_allowed` 因本次 agree 打开。 +- 相关记录:BUG-593 +- 复发自:无 +- 修复版本:待发布 + +## BUG-639 | 不可分宽度按簇代表分钟少算两端延伸 + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`indistinguishable_width_minutes`、`indistinguishableWidthMinutes`、确认门 `adjacent_passed` +- 用户现象:报告宽度跟区间两端对得上,引擎「不可分宽度」却按各簇代表分钟算,少算两端延伸;极端时可把 ≤5 分钟的确认门误开。 +- 触发条件:各簇 `cluster_start` / `cluster_end` 超出代表分钟。 +- 根因:宽度公式用代表分钟的 `max-min+1`,不是簇覆盖。 +- 修复:改为 `max(cluster_end)-min(cluster_start)+1`;缺簇字段时回退到 `time`。`tied_minute_count` 不动。 +- 验证:`tests/test_rectification_confirmation_and.py`、`frontend/tests/rectification-delivery-report-facts.test.ts`、`rectification-confirmation-gate.test.ts`。 +- 防复发:两簇 04:50–04:55 与 04:56–05:02 必须得到 13,不得算成 7。确认门不得因代表分钟看起来相邻而打开。 +- 相关记录:BUG-593 +- 复发自:无 +- 修复版本:待发布 + +## BUG-640 | 宫位表和本命重算跟的不是卡片上的代表分钟 + +- 状态:resolved +- 首次发现:2026-09-10 +- 最近更新:2026-09-10 +- 影响面:`latestResultToolProjection`、`publicChartForMinute`、`natalRecastMeaning` +- 用户现象:卡片代表分钟是推断层的一分钟,回执宫位表和「已按该分钟重算」写的是引擎另一分钟;两者若跨分盘换升,正文会写错盘。 +- 触发条件:推断代表分钟 ≠ 引擎代表分钟,且 `house_tables_by_time` 两分钟都有表。 +- 根因:投影已能按推断分钟取表,但引擎原表没有改名留下;Agent 看见的事实仍可能混用。 +- 修复:有推断层且两分钟不同时,公开 `house_table` / `natal_recast` 跟推断代表分钟;引擎原值改名 `representative_time_pre_inference` / `house_table_pre_inference` / `natal_recast_pre_inference`。Agent 可见投影剥掉这三个字段。不改引擎。 +- 验证:`frontend/tests/rectification-delivery-report-facts.test.ts`。 +- 防复发:公开 `house_table.time` 必须等于投影 `representative_time`。Agent 可见 JSON 不得含 `*_pre_inference`。 +- 相关记录:BUG-593 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md b/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md new file mode 100644 index 00000000..9393b3e4 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md @@ -0,0 +1,51 @@ +# PROGRESS · 生时校正顺带审计:同年去重、双轨按簇、簇宽、代表分钟(2026-09-10) + +工作树:`.worktrees/rectification-unwritten-evidence-claim-20260910` +分支:`codex/rectification-unwritten-evidence-claim-20260910` +任务书:`docs/tasks/TASK-rectification-latent-audit-20260910.md` +基线:任务书写 `origin/staging` @ `a15fc3ef`;本工作树同时交付 BUG-635/636。BUG 编号从 **637** 起,与任务书一致。 + +## 开工回执 + +- 编号 **BUG-637**(P2)/ **BUG-638**(P2)/ **BUG-639**(P3)/ **BUG-640**(P3)。 +- 观察项 §5 未立单。 +- 与 BUG-635/636 不改同一代码文件;文档与本单一起验收。 + +## 决策(BUG-638) + +Python 按 **argmax 映射到所在簇** 比较,不改成代表分钟列表。两轨峰值同簇即 `agree`;没有簇时仍按分钟比较。确认门不因本次 `agree` 从关到开。 + +## 已完成 + +- **BUG-637**:账本派生的精确 `domain.year` 键只拦存在性探针。`known_event_quality` / `event_quality` 用 `askedKeysForSameYearDedup` 去掉这些键后再做同年去重;已答质量键仍丢。`remainingConflictProbes` 本来就跳过质量题,未改。不改 `SCORE_DELTA`、四选项、确认门。 +- **BUG-638**:`dasha_agreement(..., clusters=)` 与 TS `dashaAgreementAmongActive(..., clusters)` 按簇比较。`decision_policy` 把 `candidate_decisions` 传入 `build_refinement_packet`。引擎原值仍是 `dasha_agreement_pre_inference`。BUG-593 记录已补正。 +- **BUG-639**:`indistinguishable_width_minutes` / `indistinguishableWidthMinutes` 用 `max(cluster_end)-min(cluster_start)+1`;缺字段回退到 `time`。`tied_minute_count` 不动。 +- **BUG-640**:推断代表分钟 ≠ 引擎代表分钟时,公开 `house_table` / `natal_recast` 跟推断分钟;引擎原值改名 `*_pre_inference`。Agent 可见投影剥掉这三个字段。不改引擎。 +- 记录:BUG-389/593/637~640、CHANGELOG、本文件。Skill 版本不变(`10.0.21`)。 + +## 验收命令(实测) + +工作树 `codex/rectification-unwritten-evidence-claim-20260910`。 + +| 命令 | 结果 | +| --- | --- | +| `.venv` `pytest` `tests/test_rectification_refinement_packet.py` `tests/test_rectification_confirmation_and.py` | **23/23 pass** | +| `frontend` `tsx --test` 探针去重 / 收敛 / 确认门 / 交付报告 / ingest-p0 | **38/38 pass** | +| `frontend` `./node_modules/.bin/tsc --noEmit` | **0 error** | +| `frontend` `npm run lint` | **0 error** / 111 warning(既有债) | +| `git diff --check` | 干净 | + +### 既有断言改动 + +- `tests/test_rectification_refinement_packet.py` `test_decision_receipt_downgrades_confidence_on_dasha_conflict`:原值 05:13 vs 05:14、两分钟同 d1;新值 05:13 vs 05:20、d1=1 vs d1=2。原因:BUG-638 同簇改为 `agree`,相邻 1 分钟会被并成一簇,必须改成跨簇才能继续锁 conflict。 + +## 环境缺口 + +- 无登录态:未做真人走查。 +- 未跑 `next build`,未核 `/` 是否仍 `○ Static`、首屏 gzip ±2%。 +- 未跑 `run_quality_gate.py --profile quick`。 + +## 未做 + +- 观察项 §5 未立产品单。 +- 未提升 `main`。Skill 未升版。 diff --git a/docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md b/docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md new file mode 100644 index 00000000..809b6c7a --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-unwritten-evidence-claim-20260910.md @@ -0,0 +1,75 @@ +# PROGRESS · 证据轮只说「记下了」却没写入(2026-09-10) + +工作树:`.worktrees/rectification-unwritten-evidence-claim-20260910` +分支:`codex/rectification-unwritten-evidence-claim-20260910` +任务书:`docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md` +基线:任务书写 `origin/staging` @ `d96b24c2`;开工接到 `84572c07`;合入前再接到 `origin/staging` @ `301827ad`(collect-semantics 任务书 + BUG-635 决策 4 去掉年份正则)。 + +## 开工回执 + +- 编号 **BUG-635**(P1)/ **BUG-636**(P2)。`docs/BUG_HISTORY.md` 当时最大已发布号是 634。 +- 顺序:分类器 `expectedWrite` → 无写入的「记下了」收回并重试一次 → 第二次主持人正文且不结算 → 客户端只认最后一条 settled 助手消息 → schema 拒绝信封失败映射 → 记录。 +- 未实现 BUG-641~643。同一工作树交付 BUG-637~640,见 `PROGRESS-rectification-latent-audit-20260910.md`。 + +## 决策 3 + +选 **turn `status=completed` + `answer_origin=host_fallback` + 不结算计费**。 + +实现:`completeAttempt(false)` → `settleBilling === false` → `billing.release()`,不写 `billing.settled`。客户端仍走完成轮刷新;未答焦点靠 T4 的主持人问题行。 + +## 决策 4(`301827ad` 修订) + +`expectedWrite: "evidence" | "none" | "unknown"`。分类器 null/出错重试一次,仍失败则 `"unknown"`,守卫 fail-open。**没有年份正则 / 关键词兜底。** 无焦点的 `message` 路径也跑同一分类器。正文里的「记下了」仍触发守卫(决策 1)。 + +## 已完成 + +- **T1**:`expectedWrite` 声明在 `immediateResponse` IIFE **之外**(`POST` 作用域),否则 `runV9AgentTurn({ expectedWrite })` 编译失败(TS18004)。collect / choice / 无焦点 message 都走 `classifyTurnIntentWithRetry`。`provide_new_evidence` 或 `answer_current_focus` + `has_new_dated_event` → `"evidence"`。`opening` / `read_only` 不触发。 +- **T2**:`streamAttempt` 在 `empty_stream` 之后、`loadV9CaseDossier` 之前:`needsWrite && !publicWriteToolCompleted && !hostFallbackUsed` → 第一次 `evidence_not_written` + `retractSpoken()`(不进 `RETRYABLE_ERROR_CODES`);第二次主持人正文 `RECTIFICATION_USER_COPY.evidenceNotRecorded`。`needsWrite` = `expectedWrite === "evidence"` 或正文含「记下了」。batch 已完成且无正文仍走 BUG-633 `applyHostFallback`。 +- **T3**:`buildAgentMessages` 在 `attempt > 1` 时用 `retryConstraintForAttempt(lastAttemptError)`。 +- **T4**:`liveQuestionOnMessages` 只认 `latestSettledAssistant`。未答焦点落到既有 `persisted_question` 主持人问题行。`frontend/DESIGN.md` 表已补完成轮未答焦点。 +- **T5**:schema 拒绝信封 `{ error: true, validationErrors: object }` → `tool.activity failed code=tool_call_rejected`;phase 映射与 batch recap 返回 null;公开事件不含 `validationErrors`。 +- **T6**:BUG-635/636、CHANGELOG、走查第 15 条、任务表待验收、本文件。Skill 版本不变(`10.0.21`)。 + +## 验收命令(实测) + +工作树 `codex/rectification-unwritten-evidence-claim-20260910`,相对 `origin/staging` @ `301827ad`。 + +| 命令 | 结果 | +| --- | --- | +| `frontend` `tsx --test` 任务书相关文件(unwritten / host-fallback / spoken-collect / turn-intent / agentic-entry / agent-voice / v9-stream) | **141/141 pass** | +| `frontend` `./node_modules/.bin/tsc --noEmit` | **0 error** | +| `frontend` `npm run lint` | **0 error** / 111 warning(既有债) | +| `git diff --check` | 干净 | +| `next build` | 未跑;记环境缺口 | + +### 本单测试名 + +- `collect intent maps provide_new_evidence and dated current-focus to expectedWrite` +- `runner does not use a year-utterance fallback` +- `retry bootstrap uses the unwritten-evidence constraint only for that error` +- `route passes classifier expectedWrite into the agent runner` +- `classifyTurnIntentWithRetry fails open as unknown after two misses` +- `unwritten 记下了 retries once then host-falls-back without billing` +- `read-case then batch then set-focus then 记下了 stays on the baseline path` +- `empty body after a completed batch still uses BUG-633 host fallback` +- `read_only action with a dated utterance does not trigger the write guard` +- `schema rejection envelopes fail the tool and never count as a batch recap` +- `rejected batch tool-result is failed in the runner terminal status` +- `chat live question only reads the last settled assistant message` +- host-fallback:`schema rejection envelopes are not treated as batch recaps or write completion` +- host-fallback:`year fallback is gone; only explicit evidence expectedWrite triggers the guard` + +### 既有断言改动 + +- `rectification-v9-stream.test.ts` empty_stream 二次成功正文:原值「记下了这件事。」/ 新值「先继续核对。」;原因:BUG-635 把无写入的「记下了」收口成 `evidence_not_written`,该用例只锁 empty_stream 重试。 + +## 环境缺口 + +- 无登录态:`docs/testing/rectification-scenarios-20260907.md` 第 15 节真人走查未做,不得写通过。 +- 未跑 `next build`,未核 `/` 是否仍 `○ Static`、首屏 gzip ±2%。 +- 推 staging 后才核 `/api/health` 的 `deployment.gitCommit`;未部署则记缺口。 + +## 未做 + +- 未提升 `main`。Skill 未升版。 +- 观察项与 BUG-641~643 未做。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 0dbd4668..6a0dfd23 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -95,8 +95,8 @@ | `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) | | `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) | | `TASK-rectification-evidence-turn-empty-answer-20260910.md` | `PROGRESS-rectification-evidence-turn-empty-answer-20260910.md` | 证据轮模型无正文被判整轮失败:证据、评分、下一问都已落库却只剩『没有拿到下一个问题』(BUG-633);答题旁白只说『范围没变』、时间线写死『还在收窄』(BUG-634) | 待验收 | -| `TASK-rectification-unwritten-evidence-claim-20260910.md` | `PROGRESS-rectification-unwritten-evidence-claim-20260910.md` | 证据轮模型只说『记下了』却没调 batch、没设下一问,财务一件静默丢失、流程停在原题且照常扣点(BUG-635);校正流不识别 Mastra schema 拒绝信封,会把被拒的 batch 报成 completed(BUG-636) | 待执行 | `codex/rectification-unwritten-evidence-claim-20260910` | -| `TASK-rectification-latent-audit-20260910.md` | `PROGRESS-rectification-latent-audit-20260910.md` | 顺带审计:账本派生同年键误杀 `known_event_quality`(BUG-637,BUG-389 复发);双轨一致性按 31 分钟逐分判冲突并降置信度、与报告两套口径(BUG-638);不可分宽度按簇代表分钟少算(BUG-639);引擎与推断层两套代表分钟、回执宫位表不是卡片那一分钟(BUG-640);另 8 条观察项 | 待执行 | `codex/rectification-latent-audit-20260910` | +| `TASK-rectification-unwritten-evidence-claim-20260910.md` | `PROGRESS-rectification-unwritten-evidence-claim-20260910.md` | 证据轮模型只说『记下了』却没调 batch、没设下一问,财务一件静默丢失、流程停在原题且照常扣点(BUG-635);校正流不识别 Mastra schema 拒绝信封,会把被拒的 batch 报成 completed(BUG-636) | 待验收 | `codex/rectification-unwritten-evidence-claim-20260910` | +| `TASK-rectification-latent-audit-20260910.md` | `PROGRESS-rectification-latent-audit-20260910.md` | 顺带审计:账本派生同年键误杀 `known_event_quality`(BUG-637,BUG-389 复发);双轨一致性按 31 分钟逐分判冲突并降置信度、与报告两套口径(BUG-638);不可分宽度按簇代表分钟少算(BUG-639);引擎与推断层两套代表分钟、回执宫位表不是卡片那一分钟(BUG-640);另 8 条观察项 | 待验收 | `codex/rectification-unwritten-evidence-claim-20260910` | | `TASK-rectification-collect-semantics-20260910.md` | `PROGRESS-rectification-collect-semantics-20260910.md` | 产品决策三改:Skill 去掉「财务/健康只有主动说才问」并清空 Python `VOLUNTEER_ONLY`,升 10.0.22(BUG-641);带年份线索的采集题优先、题干带线索不矛盾,推翻 BUG-539 的整句去前缀(BUG-642);删掉「没有/记不清」原字匹配,分类器分 no/unsure 两类(BUG-643) | 待执行 | `codex/rectification-collect-semantics-20260910` | ### 聊天主链路与首页 diff --git a/docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md b/docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md index a8f06463..eee73ef5 100644 --- a/docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md +++ b/docs/tasks/TASK-rectification-unwritten-evidence-claim-20260910.md @@ -71,8 +71,8 @@ BUG-278 已实测:`createTool` 的 `inputSchema` 校验失败时**不抛**, ### T1 · 路由把「本轮应有写入」传给运行器(BUG-635) -- `route.ts` collect 焦点分支:分类结果落到 Agent 的两种情形计算 `expectedWrite`;其他路径传 `null`,由运行器按年份正则兜底。 -- `V9AgentRunOptions` 新增 `expectedWrite?: "evidence" | null`。 +- `route.ts` collect 焦点分支:分类结果落到 Agent 的两种情形计算 `expectedWrite`;非 collect 的 `message` 路径也对原话跑同一分类器。分类失败两次则 `"unknown"`,守卫 fail-open,不用年份正则。 +- `V9AgentRunOptions` 新增 `expectedWrite?: "evidence" | "none" | "unknown"`。 - 验收:单测覆盖 `provide_new_evidence` → `"evidence"`;`answer_current_focus` + `has_new_dated_event` → `"evidence"`;`answer_current_focus` + `no` 不进 Agent(既有);分类器两次都抛错 → `"unknown"` 且守卫不触发、诊断里有记录;源码合同断言 `agent-run.ts` / `route.ts` 不含年份正则。 ### T2 · 收尾守卫:无写入的「记下了」不得交付,可重试一次(BUG-635) diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 81e773ba..fdbdf722 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -211,3 +211,13 @@ - 缺口按钮若出现,文案是「接着问」,点了会向服务端修复,不是只重取快照 - 连续两次仍无载体才显示「暂时接不上,请新建一次校正。」 +## 15. 财务采集题回带年月欠债后必须写入或明说没记下 + +资料与开场同第 0 条。虚构经历。走到财务采集题(「钱的方面,还记得哪一年…」)时,回一句带年月的欠债或收入变化,例如「2018 年 3 月开始欠债」。 + +期望: + +- 要么证据数 +1 且下一问出现 +- 要么助手说「这件我还没记上。请再说一次大概年月和发生的事。」并再显示财务题 +- **不得出现**:助手只说「记下了:…」而证据数不变、下面没有问题 + diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index b6444298..06bc2d7c 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -227,7 +227,7 @@ The birth-time rectification session is the consultation transcript plus a house | `opening` | live row “正在读取你的出生资料,准备第一个问题…”, then tool labels | enabled (typing queues), stop visible | | `empty` | “这段校正还没有开始。” and one primary action “开始提问” | enabled | | `question-live` | the asking message carries the embedded card or the spoken stem | enabled, placeholder points at the card | -| `question-gap`, persisted question | snapshot already has the next prompt; a host question row (`data-testid="persisted-question"`) | enabled, placeholder “请回答上面的问题…” | +| `question-gap`, persisted question | snapshot already has the next prompt, including a completed turn that claimed to record evidence but left the same focus unanswered; 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 | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index f69d6b36..5adabf9b 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -41,6 +41,7 @@ import { optionIdForAnswerClass, shouldDeclineCollectFocus, shouldContinueAgentForDatedEvent, + classifyTurnIntentWithRetry, } from "@/lib/rectification-agentic/v9/turn-intent-classifier"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus"; import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup"; @@ -307,6 +308,8 @@ export async function POST(request: Request) { chatSession.model_config_version, ), }); + let expectedWrite: "evidence" | "none" | "unknown" = "none"; + let writeClassified = false; const immediateResponse = await (async (): Promise => { if (isStructuredChoice) { const actionId = parsed.data.actionId; @@ -448,17 +451,15 @@ export async function POST(request: Request) { const focus = dossier.conversationSummary.activeFocus; const choice = focus ? parseAgentChoiceCopy(focus.expectedAnswerSchema) : null; if (focus && choice) { - let classified = null; - try { - classified = await classifyRectificationTurnIntent(resolvedModel, { - focus, - userMessage: parsed.data.message ?? "", - caseStatus, - signal: request.signal, - }); - } catch { - classified = null; - } + const intent = await classifyTurnIntentWithRetry(resolvedModel, { + focus, + userMessage: parsed.data.message ?? "", + caseStatus, + signal: request.signal, + }); + const classified = intent.classified; + expectedWrite = intent.expectedWrite; + writeClassified = true; if (!classified || classified.intent === "unclear") { const narration = RECTIFICATION_USER_COPY.unclearFocusReply; const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { @@ -540,16 +541,15 @@ export async function POST(request: Request) { return completedMessageResponse(finished.streamText, requestId, caseId, finished.turnId); } let classified = null; - try { - classified = await classifyRectificationTurnIntent(selectedModel, { - focus, - userMessage, - caseStatus, - signal: request.signal, - }); - } catch { - classified = null; - } + const intent = await classifyTurnIntentWithRetry(resolvedModel, { + focus, + userMessage, + caseStatus, + signal: request.signal, + }); + classified = intent.classified; + expectedWrite = intent.expectedWrite; + writeClassified = true; if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") { const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const applied = await applyRectificationChoice(accounting, { @@ -741,6 +741,15 @@ export async function POST(request: Request) { { status: 409 }, ); } + if (action === "message" && !writeClassified) { + const extra = await classifyTurnIntentWithRetry(selectedModel, { + focus: null, + userMessage: parsed.data.message ?? "", + caseStatus, + signal: request.signal, + }); + expectedWrite = extra.expectedWrite; + } const requestTime = new Date(); const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000) @@ -864,6 +873,7 @@ export async function POST(request: Request) { signal: request.signal, timeContext, generationModel: selectedModel.model, + expectedWrite, buildAgent: async (turnId, skillPackage, attemptId) => { let decision; try { diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 98cf32c4..dbe21906 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1498,14 +1498,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? currentQuestion.prompt : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); - const liveQuestionOnMessages = messages.some((message) => ( - message.role === "assistant" - && message.state === "settled" - && message.question + const liveQuestionOnMessages = Boolean( + latestSettledAssistant + && latestSettledAssistant.question && currentQuestion - && message.question.focus_id === currentQuestion.focus_id - && !questionIsAnswered(message.question) - )); + && latestSettledAssistant.question.focus_id === currentQuestion.focus_id + && !questionIsAnswered(latestSettledAssistant.question), + ); // The gap between a settled turn and its next question has two visible // states: `preparing` (one live timeline row, timed refetches) and // `unavailable` (copy and a repair button) — never bare copy telling the diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 1308ffd0..f1709337 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -18,6 +18,7 @@ import { isRenderableProbe, type DroppedProbe, rankDiscriminatorScore, + askedKeysForSameYearDedup, sameYearProbeAsked, SEMANTIC_YEAR_KEY, } from "../v9/probe-question-contract.ts"; @@ -473,7 +474,8 @@ export function inspectDiscriminatorProbes( return []; } const domainYear = probeDomainYear(completed.probe); - if (domainYear && sameYearProbeAsked(asked, domainYear.domain, domainYear.year)) { + const yearAsked = askedKeysForSameYearDedup(asked, completed.probe); + if (domainYear && sameYearProbeAsked(yearAsked, domainYear.domain, domainYear.year)) { dropped.push({ semantic_key: completed.probe.semanticKey, information_gain: completed.probe.informationGain, diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 6c00039d..5361ca9d 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -116,6 +116,7 @@ export const RECTIFICATION_USER_COPY = { adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。", hostNarrationFallback: "我按现有材料继续往下收。", collectHandoff: "接下来我们继续。", + evidenceNotRecorded: "这件我还没记上。请再说一次大概年月和发生的事。", collectDeclinedAck: "记下了,这方面先跳过。", collectSkippedAck: "记下了,这题先放着。", firstDatedCollectInvite: FIRST_DATED_COLLECT_INVITE, @@ -467,6 +468,7 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.adoptCue, RECTIFICATION_USER_COPY.hostNarrationFallback, RECTIFICATION_USER_COPY.collectHandoff, + RECTIFICATION_USER_COPY.evidenceNotRecorded, RECTIFICATION_USER_COPY.collectDeclinedAck, RECTIFICATION_USER_COPY.collectSkippedAck, FIRST_DATED_COLLECT_INVITE, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 670596cf..c16e67ee 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -64,6 +64,9 @@ import { composeHostFallbackNarration, lastCompletedPublicTool, publicWriteToolCompleted, + answerClaimsEvidenceRecorded, + retryConstraintForAttempt, + turnExpectsEvidenceWrite, } from "./host-fallback"; import { applyStepAnswerChunk, @@ -112,6 +115,7 @@ export type V9AgentRunOptions = Readonly<{ timeContext?: string; generationModel?: unknown; attemptTimeoutMs?: number; + expectedWrite?: "evidence" | "none" | "unknown"; }>; export const RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000; @@ -144,6 +148,7 @@ type AttemptOutcome = Readonly<{ skillBound: boolean; caseLoaded: boolean; attemptId: string; + settleBilling?: boolean; }>; const MAX_ATTEMPTS = 2; @@ -203,6 +208,7 @@ function safeErrorCode(error: unknown): string { const message = error instanceof Error ? error.message : String(error); for (const code of [ "empty_stream", + "evidence_not_written", "stream_aborted", "stream_unfinished", "skill_not_loaded", @@ -237,6 +243,7 @@ function shouldAutoRetry( ): boolean { if (signal?.aborted) return false; if (errorCode === "empty_stream") return status === "retryable"; + if (errorCode === "evidence_not_written") return status === "retryable"; return isRetryableError(errorCode); } @@ -438,6 +445,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise { + async function streamAttempt( + attemptNumber: number, + attemptId: string, + previousErrorCode: string | null, + ): Promise { const agent = await buildAgent(turnId, skillPackage, attemptId); let frameworkSkill: unknown = null; try { @@ -674,7 +695,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise abortController.abort(); @@ -965,7 +993,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => { + const completeAttempt = async (settleBilling = true): Promise => { let inputTokens = 0; let outputTokens = 0; let cache: ReturnType = null; @@ -992,6 +1020,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise 1 - ? ["【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"] - : []), + ...(attempt > 1 ? [retryConstraintForAttempt(previousErrorCode)] : []), ].join("\n\n"); const bootstrap = cachedSystemMessage(bootstrapContent, options.generationModel) ?? { role: "system" as const, content: bootstrapContent }; diff --git a/frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts b/frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts index 094bb881..66278c79 100644 --- a/frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts +++ b/frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts @@ -29,17 +29,22 @@ export function indistinguishableWidthMinutes( time: string; rank: number; tiedMinuteCount: number; + clusterStart?: string; + clusterEnd?: string; }>[], ): number { if (candidates.length === 0) return 0; const ranked = [...candidates].sort((left, right) => left.rank - right.rank); const top = ranked[0]!; - const minutes = ranked - .map((candidate) => timeToMinutes(candidate.time)) + const starts = ranked + .map((candidate) => timeToMinutes(candidate.clusterStart ?? candidate.time)) .filter((value): value is number => value !== null); - const span = minutes.length === 0 + const ends = ranked + .map((candidate) => timeToMinutes(candidate.clusterEnd ?? candidate.time)) + .filter((value): value is number => value !== null); + const span = starts.length === 0 || ends.length === 0 ? 0 - : Math.max(...minutes) - Math.min(...minutes) + 1; + : Math.max(...ends) - Math.min(...starts) + 1; return Math.max(top.tiedMinuteCount, span, 1); } diff --git a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts index 058f0e4f..e85bbcc0 100644 --- a/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts +++ b/frontend/src/lib/rectification-agentic/v9/confirmation-gate.ts @@ -26,6 +26,8 @@ type GateCandidate = Readonly<{ time: string; rank: number; tiedMinuteCount: number; + clusterStart?: string; + clusterEnd?: string; }>; export type ConfirmationGateBlocker = Readonly<{ diff --git a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts index b3d1f10e..48dd3707 100644 --- a/frontend/src/lib/rectification-agentic/v9/host-fallback.ts +++ b/frontend/src/lib/rectification-agentic/v9/host-fallback.ts @@ -14,6 +14,47 @@ export type HostFallbackRecap = Readonly<{ event_phrase?: string; }>; +export const UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT = + "【重试约束】上一 attempt 没有调用 rectification-record-evidence-batch 就写了『记下了』。本轮必须先把用户原话里的带日期事件提交 batch,再用 rectification-set-focus 写下一问,最后才写正文。"; + +export const DEFAULT_RETRY_CONSTRAINT = + "【重试约束】不得复用上一次 attempt 的文本或工具状态;从 rectification-read-case 重新读取服务器事实。"; + +export function isToolInputRejection(result: unknown): boolean { + if (!result || typeof result !== "object" || Array.isArray(result)) return false; + const envelope = result as { error?: unknown; validationErrors?: unknown }; + return envelope.error === true + && typeof envelope.validationErrors === "object" + && envelope.validationErrors !== null; +} + +export function toolResultFromChunk(chunk: { + type?: string; + payload?: { result?: unknown; output?: unknown }; + object?: unknown; +}): unknown { + if (chunk.type !== "tool-result") return null; + return chunk.payload?.result ?? chunk.payload?.output ?? chunk.object ?? null; +} + +export function answerClaimsEvidenceRecorded(text: string): boolean { + return /记下了/.test(text); +} + +export function turnExpectsEvidenceWrite( + action: string, + expectedWrite: "evidence" | "none" | "unknown" | null | undefined, +): boolean { + if (action === "opening" || action === "read_only") return false; + return expectedWrite === "evidence"; +} + +export function retryConstraintForAttempt(previousErrorCode: string | null): string { + return previousErrorCode === "evidence_not_written" + ? UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT + : DEFAULT_RETRY_CONSTRAINT; +} + export function publicWriteToolCompleted( status: ReadonlyMap, ): boolean { @@ -65,11 +106,14 @@ export function batchResultFromToolChunk(chunk: { }): unknown { if (chunk.type !== "tool-result") return null; if (chunk.payload?.toolName !== "rectification-record-evidence-batch") return null; - return chunk.payload?.result ?? chunk.payload?.output ?? chunk.object ?? null; + const result = toolResultFromChunk(chunk); + if (isToolInputRejection(result)) return null; + return result; } export function composeHostFallbackNarration(batchResult: unknown): string | null { if (batchResult == null) return null; + if (isToolInputRejection(batchResult)) return null; const lines = recapsFromBatchResult(batchResult) .map(recapLine) .filter(Boolean); diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 2d998588..79fb8bba 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -111,6 +111,8 @@ import { informationGainAmongActive, isRenderableProbe, rankDiscriminatorScore, + askedKeysForSameYearDedup, + isEventQualityProbe, sameYearProbeAsked, SEMANTIC_YEAR_KEY, EXISTENCE_STYLE_OPTIONS, @@ -146,7 +148,7 @@ import { } from "./window-widen.ts"; import { DATE_RELIABILITY_PROMPT, pendingDateReliabilityEvidence } from "./date-reliability.ts"; -export { sameYearProbeAsked }; +export { isEventQualityProbe, sameYearProbeAsked }; export const METHOD_FOLLOWUP_IDS = [ "dasha_events", @@ -694,8 +696,10 @@ function reverseVerifyProbeAsked( ): boolean { const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`; const split = probe.candidate_split_hash ?? ""; - return askedKeys.has(semantic) - || (split !== "" && askedKeys.has(split)) + const exact = askedKeys.has(semantic) + || (split !== "" && askedKeys.has(split)); + if (isEventQualityProbe(probe)) return exact; + return exact || askedKeys.has(`${probe.domain}.${probe.year}`) || existenceProbeAsked(askedKeys, probe.domain, probe.year); } @@ -877,13 +881,14 @@ function renderableEventProbe( if (!rankedGain) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "no_split_among_active") }; } - if (sameYearProbeAsked(askedKeys, probe.domain, probe.year)) { + const yearAskedKeys = askedKeysForSameYearDedup(askedKeys, probe); + if (sameYearProbeAsked(yearAskedKeys, probe.domain, probe.year)) { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "same_year_asked") }; } const layer = vargaLayerFromSemanticKey(key); const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) - || existenceProbeAsked(askedKeys, probe.domain, probe.year) + || existenceProbeAsked(yearAskedKeys, probe.domain, probe.year) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { row: { @@ -951,7 +956,8 @@ function renderableContrastProbe( dropped: droppedFromProbe(working.semanticKey, working.informationGain, "no_split_among_active"), }; } - if (sameYearProbeAsked(askedKeys, working.domain, working.year)) { + const yearAskedKeys = askedKeysForSameYearDedup(askedKeys, working); + if (sameYearProbeAsked(yearAskedKeys, working.domain, working.year)) { return { row: null, dropped: droppedFromProbe(working.semanticKey, working.informationGain, "same_year_asked"), @@ -961,7 +967,7 @@ function renderableContrastProbe( const asked = askedKeys.has(working.semanticKey) || askedKeys.has(working.candidateSplitHash) || askedKeys.has(working.probeId) - || existenceProbeAsked(askedKeys, working.domain ?? "", working.year ?? 0) + || existenceProbeAsked(yearAskedKeys, working.domain ?? "", working.year ?? 0) || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { row: { diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index 00f6b4ce..090110f2 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -316,6 +316,30 @@ export function discriminatorPriority(input: { } export const SEMANTIC_YEAR_KEY = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/; +export const LEDGER_YEAR_KEY = /^[a-z_]+\.(?:19|20)\d{2}$/; + +export function isEventQualityProbe(probe: { + source?: string | null; + choiceKind?: string | null; + choice_kind?: string | null; +}): boolean { + return probe.source === "known_event_quality" + || probe.choiceKind === "event_quality" + || probe.choice_kind === "event_quality"; +} + +export function askedKeysForSameYearDedup( + askedKeys: ReadonlySet | readonly string[], + probe: { + source?: string | null; + choiceKind?: string | null; + choice_kind?: string | null; + }, +): Set { + const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys); + if (!isEventQualityProbe(probe)) return asked; + return new Set([...asked].filter((key) => !LEDGER_YEAR_KEY.test(key))); +} export function sameYearProbeAsked( askedKeys: ReadonlySet | readonly string[], diff --git a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts index e7d6d0a9..c0065c18 100644 --- a/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts +++ b/frontend/src/lib/rectification-agentic/v9/refinement-packet.ts @@ -475,14 +475,65 @@ function dashaAgreementCopy( }; } +export type DashaClusterHint = Readonly<{ + time?: string; + cluster_range?: readonly [string, string]; + cluster_start?: string; + cluster_end?: string; + clusterTimes?: readonly string[]; + cluster_times?: readonly string[]; +}>; + +function clockMinutes(value: string): number | null { + if (!TIME.test(value)) return null; + return Number(value.slice(0, 2)) * 60 + Number(value.slice(3)); +} + +function clusterKeyForTime(time: string, clusters: readonly DashaClusterHint[]): string { + const point = clockMinutes(time); + for (const row of clusters) { + const start = row.cluster_start ?? row.cluster_range?.[0]; + const end = row.cluster_end ?? row.cluster_range?.[1]; + const times = row.cluster_times ?? row.clusterTimes ?? []; + const inTimes = times.includes(time) || row.time === time; + const startMinutes = start ? clockMinutes(start) : null; + const endMinutes = end ? clockMinutes(end) : null; + const inSpan = point !== null && startMinutes !== null && endMinutes !== null + && point >= startMinutes && point <= endMinutes; + if (inTimes || inSpan) { + return start && end ? `${start}–${end}` : time; + } + } + return time; +} + +function activeClusterKey( + time: string | null, + activeTimes: readonly string[], + clusters: readonly DashaClusterHint[], +): string | null { + if (!time) return null; + const key = clusterKeyForTime(time, clusters); + if (activeTimes.some((item) => item === time || clusterKeyForTime(item, clusters) === key)) { + return key; + } + return null; +} + /** Recompute Vimshottari / Narayana tops inside the still-active inference minutes. */ export function dashaAgreementAmongActive( engine: DashaAgreement | null, activeTimes: readonly string[], + clusters: readonly DashaClusterHint[] = [], ): DashaAgreement | null { if (!engine) return null; const active = new Set(activeTimes); if (active.size === 0) return dashaAgreementCopy("unavailable", null, null); + const vimCluster = activeClusterKey(engine.vimshottari_top, activeTimes, clusters); + const narCluster = activeClusterKey(engine.narayana_top, activeTimes, clusters); + if (vimCluster && narCluster && vimCluster === narCluster) { + return dashaAgreementCopy("agree", engine.vimshottari_top, engine.narayana_top); + } const vim = engine.vimshottari_top && active.has(engine.vimshottari_top) ? engine.vimshottari_top : null; diff --git a/frontend/src/lib/rectification-agentic/v9/run-diagnostic.ts b/frontend/src/lib/rectification-agentic/v9/run-diagnostic.ts index 77ea5a94..54375d61 100644 --- a/frontend/src/lib/rectification-agentic/v9/run-diagnostic.ts +++ b/frontend/src/lib/rectification-agentic/v9/run-diagnostic.ts @@ -32,6 +32,7 @@ export type RectificationRunDiagnostic = Readonly<{ elapsedMs: number; lastCompletedTool: string | null; stateMutationCommitted: boolean; + expectedWrite: "evidence" | "none" | "unknown" | null; }>; const USER_COPY: Readonly> = { @@ -46,6 +47,7 @@ const USER_COPY: Readonly> = { provider_error: "上游模型连接失败,状态已记录。", run_failed: "上游模型连接失败,状态已记录。", empty_stream: "本轮没有生成可展示的回复,状态已记录。", + evidence_not_written: "这件我还没记上。请再说一次大概年月和发生的事。", }; export function finishReasonFromErrorCode(errorCode: string | null | undefined): RectificationFinishReason { diff --git a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts index 94253de8..1c8d339b 100644 --- a/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts +++ b/frontend/src/lib/rectification-agentic/v9/stream-mapping.ts @@ -23,6 +23,7 @@ import { type PublicRectificationTool, } from "./public-receipt"; import { sanitizePublicThinkingText } from "../../public-thinking"; +import { isToolInputRejection, toolResultFromChunk } from "./host-fallback"; export type PublicPhaseStreamEvent = Readonly<{ type: PublicRectificationPhase; @@ -158,6 +159,7 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE const toolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; if (toolName === "skill") return { type: "skill.bound" }; if (!isPublicRectificationTool(toolName)) return null; + if (isToolInputRejection(toolResultFromChunk(chunk))) return null; const phase = TOOL_PHASE_ON_RESULT[toolName]; const methods = METHOD_TOOLS.has(toolName) ? resultMethods(chunk) : []; return phase ? { type: phase, tool: toolName, ...(methods.length > 0 ? { methods } : {}) } : null; @@ -213,6 +215,7 @@ const SAFE_TOOL_ACTIVITY_CODES = new Set([ "already_exists", "focus_idempotency_conflict", "invalid_focus", + "tool_call_rejected", ]); function safeToolActivityCode(error: unknown): string | undefined { @@ -247,6 +250,14 @@ export function mapStreamChunkToActivity(chunk: AgentChunkType): RectificationAc ...(code ? { code } : {}), }; } + if (isToolInputRejection(toolResultFromChunk(chunk))) { + return { + type: "tool.activity", + tool: toolName, + status: "failed", + code: "tool_call_rejected", + }; + } const methods = resultMethods(chunk); return { type: "tool.activity", diff --git a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts index 30af220d..0759cebd 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts @@ -50,6 +50,40 @@ export function shouldContinueAgentForDatedEvent( return classified?.has_new_dated_event === true; } +export type ExpectedWriteSignal = "evidence" | "none" | "unknown"; + +export function expectedWriteFromCollectIntent( + classified: RectificationTurnIntent | null, +): "evidence" | "none" { + if (classified?.intent === "provide_new_evidence") return "evidence"; + if (classified?.intent === "answer_current_focus" && classified.has_new_dated_event === true) { + return "evidence"; + } + return "none"; +} + +export async function classifyTurnIntentWithRetry( + model: ResolvedLanguageModel, + input: { + focus?: ConversationFocus | null; + userMessage: string; + caseStatus: string; + signal?: AbortSignal; + }, +): Promise<{ classified: RectificationTurnIntent | null; expectedWrite: ExpectedWriteSignal }> { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const classified = await classifyRectificationTurnIntent(model, input); + if (classified) { + return { classified, expectedWrite: expectedWriteFromCollectIntent(classified) }; + } + } catch { + // One retry, then fail-open as unknown. + } + } + return { classified: null, expectedWrite: "unknown" }; +} + export function optionIdForAnswerClass( focus: ConversationFocus, answerClass: AnswerClass, @@ -61,17 +95,16 @@ export function optionIdForAnswerClass( export async function classifyRectificationTurnIntent( model: ResolvedLanguageModel, input: { - focus: ConversationFocus; + focus?: ConversationFocus | null; userMessage: string; caseStatus: string; signal?: AbortSignal; }, ): Promise { - const choice = parseAgentChoiceCopy(input.focus.expectedAnswerSchema); - const collectPrompt = isCollectFocusSchema(input.focus.expectedAnswerSchema) + const choice = input.focus ? parseAgentChoiceCopy(input.focus.expectedAnswerSchema) : null; + const collectPrompt = input.focus && isCollectFocusSchema(input.focus.expectedAnswerSchema) ? String(input.focus.expectedAnswerSchema.prompt ?? "").trim() : ""; - if (!choice && !collectPrompt) return null; const agent = new Agent({ id: `rectification-focus-intent-${model.id}`, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 2d5e0188..52e71836 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -459,11 +459,19 @@ export function latestResultToolProjection( : inference ? null : (typeof latest.representativeTime === "string" ? latest.representativeTime : null); + const engineRepresentative = typeof latest.representativeTime === "string" ? latest.representativeTime : null; const chart = publicChartForMinute( representativeTime, latest as unknown as Record, latest.decisionReceipt ?? null, ); + const engineChart = engineRepresentative && representativeTime && engineRepresentative !== representativeTime + ? publicChartForMinute( + engineRepresentative, + latest as unknown as Record, + latest.decisionReceipt ?? null, + ) + : null; const reportCandidates = candidateProjection.fromInference ? candidateProjection.candidates : latest.candidates; @@ -478,7 +486,7 @@ export function latestResultToolProjection( : width; const engineDasha = refinement.dasha_agreement; const reportDasha = inference - ? dashaAgreementAmongActive(engineDasha, activeTimes) + ? dashaAgreementAmongActive(engineDasha, activeTimes, inference.candidates) : engineDasha; const signByCandidate = signByCandidateFromTransitions( activeTimes.length > 0 ? activeTimes : reportCandidates.map((item) => item.time), @@ -538,6 +546,13 @@ export function latestResultToolProjection( }), ...(chart.houseTable ? { house_table: chart.houseTable } : {}), ...(chart.natalRecast ? { natal_recast: chart.natalRecast } : {}), + ...(engineChart + ? { + representative_time_pre_inference: engineRepresentative, + ...(engineChart.houseTable ? { house_table_pre_inference: engineChart.houseTable } : {}), + ...(engineChart.natalRecast ? { natal_recast_pre_inference: engineChart.natalRecast } : {}), + } + : {}), ...(Array.isArray(latest.decisionReceipt?.technique_audit_table) ? { technique_audit_table: latest.decisionReceipt.technique_audit_table } : {}), @@ -581,6 +596,9 @@ function agentVisibleLatestProjection( candidate_contrast_opportunities: _opportunities, inference_state: inference, dasha_agreement_pre_inference: _preDasha, + representative_time_pre_inference: _preTime, + house_table_pre_inference: _preHouse, + natal_recast_pre_inference: _preNatal, ...rest } = projection; const currentQuestion = extras.openQuestion diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 75c2c05a..7a41ab97 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -201,6 +201,8 @@ test("settled assistant body with a focus has no question-mark sentences", () => }]); assert.doesNotMatch(linked[0]?.text ?? "", /[??]/); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff)); + assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.evidenceNotRecorded)); + assert.doesNotMatch(RECTIFICATION_USER_COPY.evidenceNotRecorded, /系统|模型/); assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/); const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); assert.match(agentRun, /stripQuestionSentences/); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 06499e2d..d2d1fa8f 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -277,8 +277,10 @@ test("usage completes or releases without hiding settlement failures", () => { assert.match(run, /composeHostFallbackNarration/); assert.match(run, /errorCode: "empty_stream"/); assert.match(run, /if \(errorCode === "empty_stream"\) return status === "retryable"/); + assert.match(run, /if \(errorCode === "evidence_not_written"\) return status === "retryable"/); assert.doesNotMatch(run, /if \(!answerText\.trim\(\)\) return failedAttempt\(attemptId, "empty_stream"\)/); assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*empty_stream/); + assert.doesNotMatch(run, /RETRYABLE_ERROR_CODES = new Set\(\[[^\]]*evidence_not_written/); assert.doesNotMatch(run, /bindSpokenToOpenQuestion|CHOICE_CARD_CONTINUATION_ACK|openQuestionPromptFromToolResult/); assert.doesNotMatch(run, /heldSpoken/); assert.match(run, /replace: true/); diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index a6847ad5..259ed645 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { indistinguishableWidthMinutes } from "../src/lib/rectification-agentic/v9/candidate-plateau.ts"; import { SEALED_MINUTE_HOLDOUT, buildConfirmationGate, @@ -413,6 +414,22 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as assert.equal(safeToolErrorCode(new RectificationToolServiceError("confirmation_blocked")), "confirmation_blocked"); }); +test("confirmation width uses cluster span so representative minutes cannot open the gate", () => { + const candidates = [ + { time: "04:53", rank: 1, tiedMinuteCount: 1, clusterStart: "04:50", clusterEnd: "04:55" }, + { time: "04:59", rank: 2, tiedMinuteCount: 1, clusterStart: "04:56", clusterEnd: "05:02" }, + ]; + assert.equal(indistinguishableWidthMinutes(candidates), 13); + const gate = buildConfirmationGate({ + engineConfirmationAllowed: true, + candidates, + decisionReceipt: { vedastro_minute_sensitive: { status: "passed" } }, + }); + assert.equal(gate.confirmation_allowed, false); + const adjacent = gate.blockers.find((item) => item.id === "adjacent_minutes_indistinguishable"); + assert.equal(adjacent?.indistinguishable_width_minutes, 13); +}); + test("collect_evidence keeps public can_adopt closed while selection_allowed follows the decision", () => { const decision = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, diff --git a/frontend/tests/rectification-delivery-report-facts.test.ts b/frontend/tests/rectification-delivery-report-facts.test.ts index c6cb1545..9b5682a9 100644 --- a/frontend/tests/rectification-delivery-report-facts.test.ts +++ b/frontend/tests/rectification-delivery-report-facts.test.ts @@ -8,6 +8,7 @@ import { decideRectification } from "../src/lib/rectification-agentic/core/recti import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; import { signFromTransitions } from "../src/lib/rectification-agentic/v9/divergence-panel.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { dashaAgreementAmongActive } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; import { OPEN_ENGINE_CAPABILITY_CEILING, @@ -93,6 +94,14 @@ function accidentInferenceState() { }; } +function twelveHouses(sign: string) { + return Array.from({ length: 12 }, (_, index) => ({ + house: index + 1, + sign, + occupants: [] as string[], + })); +} + function accidentProjection() { return latestResultToolProjection({ resultId: RESULT_ID, @@ -108,6 +117,18 @@ function accidentProjection() { selection_allowed: true, propose_allowed: true, confirmation_allowed: false, + house_table: { time: "05:06", lagna: "狮子座", houses: twelveHouses("狮子座") }, + house_tables_by_time: { + "04:53": { time: "04:53", lagna: "巨蟹座", houses: twelveHouses("巨蟹座") }, + "05:06": { time: "05:06", lagna: "狮子座", houses: twelveHouses("狮子座") }, + }, + natal_recast: { + time: "05:06", + lagna: "狮子座", + user_meaning: "本命宫位已按 05:06 重算(上升 狮子座)。", + unique_minute_claim: false, + confirmation_allowed: false, + }, inference_state: accidentInferenceState(), dasha_agreement: { status: "conflict", @@ -199,6 +220,35 @@ test("delivery report gives 04:53 D10 as Cancer instead of letting the model inf assert.match(report.markdown, /04:53 \| .*巨蟹座/); }); +test("same active cluster dasha tops agree even when the peak is not a representative", () => { + const engine = { + status: "conflict" as const, + vimshottari_top: "04:54", + narayana_top: "04:53", + user_meaning: "主限更偏向 04:54,分盘大运更偏向 04:53。冲突时不能按更高把握收口。", + }; + const clusters = [{ time: "04:53", cluster_range: ["04:50", "04:55"] as const }]; + const clustered = dashaAgreementAmongActive(engine, ["04:53"], clusters); + assert.equal(clustered?.status, "agree"); + const minuteOnly = dashaAgreementAmongActive(engine, ["04:53"]); + assert.equal(minuteOnly?.status, "partial"); +}); + +test("public house table follows the inference representative minute", () => { + const projection = accidentProjection(); + assert.equal(projection.representative_time, "04:53"); + assert.equal((projection.house_table as { time?: string } | undefined)?.time, "04:53"); + const recast = projection.natal_recast as { time?: string; user_meaning?: string } | undefined; + assert.equal(recast?.time, "04:53"); + assert.match(String(recast?.user_meaning), /04:53/); + assert.equal(projection.representative_time_pre_inference, "05:06"); + assert.equal((projection.house_table_pre_inference as { time?: string } | undefined)?.time, "05:06"); + const source = readFileSync(fileURLToPath(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url)), "utf8"); + assert.match(source, /representative_time_pre_inference: _preTime/); + assert.match(source, /house_table_pre_inference: _preHouse/); + assert.match(source, /natal_recast_pre_inference: _preNatal/); +}); + test("skill 10.0.21 forbids computing varga signs from transition times", () => { const skillDir = fileURLToPath(new URL("../../skills/jyotish-birth-time-rectification", import.meta.url)); const skill = readFileSync(`${skillDir}/SKILL.md`, "utf8"); diff --git a/frontend/tests/rectification-engine-convergence.test.ts b/frontend/tests/rectification-engine-convergence.test.ts index 91fe2329..4fd4020d 100644 --- a/frontend/tests/rectification-engine-convergence.test.ts +++ b/frontend/tests/rectification-engine-convergence.test.ts @@ -10,7 +10,10 @@ import { type CandidateSnapshotSource, } from "../src/lib/rectification-agentic/core/snapshot-source.ts"; import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; +import { askedEventProbeKeysFromLedgerEvidence, inspectDiscriminatorProbes } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { contrastPacketFromLatestResult } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { remainingReverseVerifyProbes } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { QUALITY_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; import { cachedEngineScoreIsReusable, liveEngineScoringIdentityFromEnv, @@ -163,6 +166,62 @@ test("anchored known_event_quality distinguish probes stay in the public packet" ); }); +test("ledger domain.year keys do not same-year-drop known_event_quality", () => { + const probe = qualityProbe({ information_gain: 0.8 }); + const evidence = [{ + status: "confirmed", + domain: "education", + occurredFrom: "2016-09-01", + occurredTo: null, + }]; + const ledgerKeys = askedEventProbeKeysFromLedgerEvidence(evidence); + assert.deepEqual(ledgerKeys, ["education.2016"]); + + const remaining = remainingReverseVerifyProbes( + [probe as never], + [{ + id: "00000000-0000-4000-8000-000000000001", + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: null, + eventKind: "education_start", + }], + new Set(), + new Set(ledgerKeys), + "1997-08-08", + ); + assert.equal(remaining[0]?.semantic_key, "education.2016.known_event_quality"); + + const inspected = inspectDiscriminatorProbes({ + candidateSetVersion: "set-a", + vargaDifferences: [], + probes: [{ + probeId: "probe:education.2016.known_event_quality", + candidateSetVersion: "set-a", + question: "2016 年 9 月那次上大学,更接近哪一种实际体验。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] }, + { outcomeId: "no", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: "quality-split", + informationGain: 0.8, + sourceFeatures: [{ technique: "known_event_quality", calculationResultId: null }], + domain: "education", + year: 2016, + semanticKey: "education.2016.known_event_quality", + choiceKind: "event_quality", + styleOptions: QUALITY_STYLE_OPTIONS.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + })), + }], + }, { askedKeys: ledgerKeys, topCandidateTimes: ["05:00", "05:07"] }); + assert.equal(inspected.selected?.semanticKey, "education.2016.known_event_quality"); + assert.equal(inspected.selected?.choiceKind, "event_quality"); +}); + test("quality answers are not inferred from event existence alone", () => { const probe: ConflictProbe = { id: "probe-quality", diff --git a/frontend/tests/rectification-host-fallback.test.ts b/frontend/tests/rectification-host-fallback.test.ts index dd4df3e8..abb6db0d 100644 --- a/frontend/tests/rectification-host-fallback.test.ts +++ b/frontend/tests/rectification-host-fallback.test.ts @@ -3,8 +3,14 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + batchResultFromToolChunk, composeHostFallbackNarration, + DEFAULT_RETRY_CONSTRAINT, + isToolInputRejection, publicWriteToolCompleted, + retryConstraintForAttempt, + turnExpectsEvidenceWrite, + UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT, } from "../src/lib/rectification-agentic/v9/host-fallback.ts"; test("host fallback recap uses only batch return lines", () => { @@ -45,6 +51,32 @@ test("write-tool completion is limited to batch, set-focus and compare", () => { assert.equal(publicWriteToolCompleted(failedBatch), false); }); +test("schema rejection envelopes are not treated as batch recaps or write completion", () => { + const envelope = { + error: true, + message: "Tool input validation failed", + validationErrors: { errors: [], fields: { kind: "debt" } }, + }; + assert.equal(isToolInputRejection(envelope), true); + assert.equal(composeHostFallbackNarration(envelope), null); + assert.equal(batchResultFromToolChunk({ + type: "tool-result", + payload: { toolName: "rectification-record-evidence-batch", result: envelope }, + }), null); + assert.equal(isToolInputRejection({ error: true, message: "no fields" }), false); +}); + +test("year fallback is gone; only explicit evidence expectedWrite triggers the guard", () => { + assert.equal(turnExpectsEvidenceWrite("opening", "evidence"), false); + assert.equal(turnExpectsEvidenceWrite("read_only", "none"), false); + assert.equal(turnExpectsEvidenceWrite("evidence", "evidence"), true); + assert.equal(turnExpectsEvidenceWrite("evidence", "none"), false); + assert.equal(turnExpectsEvidenceWrite("evidence", "unknown"), false); + assert.equal(turnExpectsEvidenceWrite("evidence", undefined), false); + assert.equal(retryConstraintForAttempt("evidence_not_written"), UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT); + assert.equal(retryConstraintForAttempt("empty_stream"), DEFAULT_RETRY_CONSTRAINT); +}); + test("case snapshot receipts expose host_fallback origin from the durable phase", () => { const route = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), diff --git a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts index da95c9e5..51d70e45 100644 --- a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts +++ b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts @@ -18,9 +18,12 @@ import { } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; -import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; +import { askedDiscriminatorKeys, askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts"; -import { EXISTENCE_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; +import { + EXISTENCE_STYLE_OPTIONS, + QUALITY_STYLE_OPTIONS, +} from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; function existenceProbe( domain: DiscriminatingEventProbe["domain"], @@ -279,7 +282,19 @@ function contrastFromEvent(probe: DiscriminatingEventProbe): CandidateContrastPa domain: probe.domain, year: probe.year, semanticKey: key, - choiceKind: "existence", + choiceKind: probe.choice_kind === "event_quality" + ? "event_quality" + : probe.choice_kind === "varga_style" + ? "varga_style" + : "existence", + ...(probe.choice_kind === "event_quality" + ? { + styleOptions: QUALITY_STYLE_OPTIONS.map((item) => ({ + label: item.label, + answerClass: item.answer_class, + })), + } + : {}), }; } @@ -309,7 +324,11 @@ function conflictFromContrast(probe: CandidateContrastPacket["probes"][number]): { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: probe.informationGain, - source: probe.choiceKind === "varga_style" ? "varga_contrast" : "dasha_boundary", + source: probe.choiceKind === "event_quality" + ? "known_event_quality" + : probe.choiceKind === "varga_style" + ? "varga_contrast" + : "dasha_boundary", choice_kind: probe.choiceKind ?? "existence", style_options: probe.styleOptions ? probe.styleOptions.map((item) => ({ @@ -547,3 +566,80 @@ test("accident replay: fifth card is not the 2023 activation after four answers" || decided.sessionOutcome === "adopt_representative", ); }); + +function educationQualityProbe(): DiscriminatingEventProbe { + return { + year: 2016, + year_label: "2016 年 9 月", + month: 9, + domain: "education", + event_family: "学业或考试发挥失常、压力特别大", + source: "known_event_quality", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "2016 年 9 月那次学业,更接近哪一种实际体验。", + role: "distinguish", + information_gain: 0.8, + semantic_key: "education.2016.known_event_quality", + candidate_split_hash: "education.2016.known_event_quality", + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + choice_kind: "event_quality", + style_options: [...QUALITY_STYLE_OPTIONS], + target_evidence_id: "e-edu", + }; +} + +test("ledger year keys drop existence probes but keep known_event_quality", () => { + const quality = educationQualityProbe(); + const existence = existenceProbe("education", 2016, { + month: 9, + gain: 1.1, + key: "education.2016.09.dasha_boundary", + }); + const ledgerAsked = askedDiscriminatorKeys(null, COVERED_FOR_DISCRIMINATE); + assert.ok(ledgerAsked.includes("education.2016")); + + const remaining = remainingReverseVerifyProbes( + [quality, existence], + COVERED_FOR_DISCRIMINATE, + new Set(), + new Set(ledgerAsked), + "1997-08-08", + ); + assert.equal(remaining.some((item) => item.semantic_key === quality.semantic_key), true); + assert.equal(remaining.some((item) => item.semantic_key === existence.semantic_key), false); + + const inspected = inspectDiscriminatorProbes( + accidentPacket([contrastFromEvent(quality), contrastFromEvent(existence)]), + { askedKeys: ledgerAsked, topCandidateTimes: ["05:00", "05:20"], birthDate: "1997-08-08" }, + ); + assert.equal(inspected.selected?.semanticKey, quality.semantic_key); + assert.equal(inspected.selected?.choiceKind, "event_quality"); + assert.equal( + inspected.dropped.some((item) => ( + item.semantic_key === existence.semantic_key && item.reason === "same_year_asked" + )), + true, + ); + + const askedAgain = inspectDiscriminatorProbes( + accidentPacket([contrastFromEvent(quality)]), + { + askedKeys: [...ledgerAsked, quality.semantic_key!], + topCandidateTimes: ["05:00", "05:20"], + birthDate: "1997-08-08", + }, + ); + assert.equal(askedAgain.selected, null); + assert.equal( + askedAgain.dropped.some((item) => ( + item.semantic_key === quality.semantic_key && item.reason === "same_year_asked" + )), + true, + ); +}); diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index cb5c9360..59f3f8b6 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -166,6 +166,15 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic chat, /\(liveQuestionOnMessages \|\| questionGap === "persisted_question"\) && collectSpokenPrompt\n\s+\? "请回答上面的问题…"/, ); + // 原值: messages.some(同 focus_id) + // 新值: 只认 latestSettledAssistant + // 原因: BUG-635 决策 5,旧消息上的同焦点不再算仍在显示 + assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/); + assert.doesNotMatch(chat, /const liveQuestionOnMessages = messages\.some\(/); + // 原值: const liveQuestionOnMessages = messages.some((message) => + // 新值: latestSettledAssistant 上的 question.focus_id + // 原因: BUG-635 完成轮无问题行时,旧消息的同 focus_id 不得挡住主持人问题行 + assert.match(chat, /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/); assert.match( readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8"), /\[describedBy, showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/, diff --git a/frontend/tests/rectification-turn-intent-classifier.test.ts b/frontend/tests/rectification-turn-intent-classifier.test.ts index 02fc2333..b3512ff3 100644 --- a/frontend/tests/rectification-turn-intent-classifier.test.ts +++ b/frontend/tests/rectification-turn-intent-classifier.test.ts @@ -7,6 +7,7 @@ import { parseRectificationTurnIntent, shouldContinueAgentForDatedEvent, shouldDeclineCollectFocus, + expectedWriteFromCollectIntent, } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts"; @@ -142,4 +143,31 @@ test("production intent handling contains no semantic regex or positional text p assert.match(fastPath, /persistNextInterviewIfIdle/); assert.match(fastPath, /!plan\.next_followup/); assert.doesNotMatch(route, /classified\.answer_class!/); + assert.match(route, /classifyTurnIntentWithRetry/); + assert.match(route, /expectedWrite,/); + assert.equal(expectedWriteFromCollectIntent({ + intent: "provide_new_evidence", + answer_class: null, + }), "evidence"); + assert.equal(expectedWriteFromCollectIntent({ + intent: "answer_current_focus", + answer_class: "yes", + has_new_dated_event: true, + }), "evidence"); + assert.equal(expectedWriteFromCollectIntent({ + intent: "answer_current_focus", + answer_class: "no", + }), "none"); + assert.equal(expectedWriteFromCollectIntent(null), "none"); +}); + +test("classifyTurnIntentWithRetry fails open as unknown after two misses", () => { + const src = readFileSync( + new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), + "utf8", + ); + assert.match(src, /export async function classifyTurnIntentWithRetry/); + assert.match(src, /for \(let attempt = 0; attempt < 2/); + assert.match(src, /expectedWrite: "unknown"/); + assert.doesNotMatch(src, /\(\?:19\|20\)\\d\{2\}/); }); diff --git a/frontend/tests/rectification-unwritten-evidence.test.ts b/frontend/tests/rectification-unwritten-evidence.test.ts new file mode 100644 index 00000000..d1117cda --- /dev/null +++ b/frontend/tests/rectification-unwritten-evidence.test.ts @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; +import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts"; +import { + batchResultFromToolChunk, + composeHostFallbackNarration, + isToolInputRejection, + publicWriteToolCompleted, + retryConstraintForAttempt, + turnExpectsEvidenceWrite, + UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT, + DEFAULT_RETRY_CONSTRAINT, +} from "../src/lib/rectification-agentic/v9/host-fallback.ts"; +import { + mapStreamChunkToActivity, + mapStreamChunkToPhase, + safePublicEvent, +} from "../src/lib/rectification-agentic/v9/stream-mapping.ts"; +import { expectedWriteFromCollectIntent } from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts"; +import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { + CASE_ID, + SESSION_ID, + TURN_ID, + USER_ID, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const REJECTION = { + error: true, + message: "Tool input validation failed", + validationErrors: { errors: ["kind"], fields: { kind: "debt" } }, +}; + +type StreamChunk = { + type: string; + payload?: Record; + object?: unknown; +}; + +function chunk(type: string, payload?: Record): StreamChunk { + return { type, ...(payload ? { payload } : {}) }; +} + +function readCaseThen(rest: StreamChunk[]): StreamChunk[] { + return [ + chunk("start"), + chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), + chunk("tool-result", { toolName: "skill" }), + chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-read-case" }), + ...rest, + ]; +} + +function fakeAgentStream( + chunks: StreamChunk[], + sink?: unknown[], +) { + return { + stream: async (messages: unknown[]) => { + sink?.push(messages); + return { + fullStream: (async function* () { + for (const item of chunks) yield item; + })(), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + }; + }, + getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }), + }; +} + +function runOptions(overrides: Partial = {}) { + const emitted: Array> = []; + const billing = { reserved: 0, completed: 0, released: 0 }; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }); + const optionsValue: V9AgentRunOptions = { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + action: "evidence", + message: "2018 年 3 月开始欠债", + modelName: "gpt-4o-mini", + accounting: accounting.client, + billing: { + reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; }, + complete: async () => { billing.completed += 1; return true; }, + release: async () => { billing.released += 1; return true; }, + }, + emit: (event) => { emitted.push(event as Record); }, + buildAgent: async () => fakeAgentStream([]) as never, + ...overrides, + }; + return { options: optionsValue, emitted, billing, accounting }; +} + +function captureDiagnostics(run: () => Promise): Promise<{ result: T; logs: Record[] }> { + const logs: Record[] = []; + const original = console.info; + console.info = (...args: unknown[]) => { + const text = typeof args[0] === "string" ? args[0] : ""; + if (text.includes("RectificationRunDiagnostic")) { + logs.push(JSON.parse(text) as Record); + } + }; + return run().then((result) => ({ result, logs })).finally(() => { + console.info = original; + }); +} + +test("collect intent maps provide_new_evidence and dated current-focus to expectedWrite", () => { + assert.equal(expectedWriteFromCollectIntent({ + intent: "provide_new_evidence", + answer_class: null, + }), "evidence"); + assert.equal(expectedWriteFromCollectIntent({ + intent: "answer_current_focus", + answer_class: "yes", + has_new_dated_event: true, + }), "evidence"); + assert.equal(expectedWriteFromCollectIntent({ + intent: "answer_current_focus", + answer_class: "no", + }), "none"); + assert.equal(expectedWriteFromCollectIntent(null), "none"); +}); + +test("runner does not use a year-utterance fallback", () => { + assert.equal(turnExpectsEvidenceWrite("evidence", "evidence"), true); + assert.equal(turnExpectsEvidenceWrite("evidence", "none"), false); + assert.equal(turnExpectsEvidenceWrite("evidence", "unknown"), false); + assert.equal(turnExpectsEvidenceWrite("evidence", undefined), false); + assert.equal(turnExpectsEvidenceWrite("read_only", "evidence"), false); + assert.equal(turnExpectsEvidenceWrite("opening", "none"), false); +}); + +test("retry bootstrap uses the unwritten-evidence constraint only for that error", () => { + assert.equal(retryConstraintForAttempt("evidence_not_written"), UNWRITTEN_EVIDENCE_RETRY_CONSTRAINT); + assert.equal(retryConstraintForAttempt("empty_stream"), DEFAULT_RETRY_CONSTRAINT); + assert.equal(retryConstraintForAttempt(null), DEFAULT_RETRY_CONSTRAINT); +}); + +test("route passes classifier expectedWrite into the agent runner", () => { + const route = readFileSync( + new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), + "utf8", + ); + const agentRun = readFileSync( + new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), + "utf8", + ); + assert.match(route, /classifyTurnIntentWithRetry/); + assert.match(route, /expectedWrite,/); + assert.match(route, /let expectedWrite: "evidence" \| "none" \| "unknown" = "none"/); + assert.doesNotMatch(route, /\(\?:19\|20\)\\d\{2\}/); + assert.doesNotMatch(agentRun, /\(\?:19\|20\)\\d\{2\}/); +}); + +test("unwritten 记下了 retries once then host-falls-back without billing", async () => { + let buildCount = 0; + const messages: unknown[] = []; + const { options, emitted, billing } = runOptions({ + expectedWrite: "evidence", + buildAgent: async () => { + buildCount += 1; + return fakeAgentStream(readCaseThen([ + chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }), + chunk("finish"), + ]), messages) as never; + }, + }); + const { result, logs } = await captureDiagnostics(() => runV9AgentTurn(options)); + assert.equal(buildCount, 2); + assert.equal(result.ok, true); + assert.equal(result.answerText, RECTIFICATION_USER_COPY.evidenceNotRecorded); + assert.ok(result.phases.includes("answer.host_fallback")); + assert.equal(result.phases.includes("billing.settled"), false); + assert.equal(emitted.some((event) => event.type === "attempt.reset"), true); + assert.equal(emitted.some((event) => event.type === "billing.settled"), false); + assert.equal(emitted.some((event) => event.type === "run.completed"), true); + assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 }); + assert.equal( + emitted.some((event) => event.type === "answer.delta" && event.replace === true && event.text === ""), + true, + ); + const first = logs[0]; + assert.equal(first?.stateMutationCommitted, false); + assert.equal(first?.expectedWrite, "evidence"); + const retryBootstrap = JSON.stringify(messages[1] ?? messages[0]); + assert.match(retryBootstrap, /rectification-record-evidence-batch/); + assert.match(retryBootstrap, /记下了/); +}); + +test("read-case then batch then set-focus then 记下了 stays on the baseline path", async () => { + const { options, billing } = runOptions({ + expectedWrite: "evidence", + buildAgent: async () => fakeAgentStream(readCaseThen([ + chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }), + chunk("tool-result", { + toolName: "rectification-record-evidence-batch", + result: { accepted_recaps: [{ display_date_label: "2018年3月", event_phrase: "欠债" }] }, + }), + chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }), + chunk("tool-result", { toolName: "rectification-set-focus" }), + chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }), + chunk("finish"), + ])) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.match(result.answerText, /记下了/); + assert.equal(result.phases.includes("answer.host_fallback"), false); + assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); +}); + +test("empty body after a completed batch still uses BUG-633 host fallback", async () => { + const { options, billing } = runOptions({ + expectedWrite: "evidence", + buildAgent: async () => fakeAgentStream(readCaseThen([ + chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }), + chunk("tool-result", { + toolName: "rectification-record-evidence-batch", + result: { accepted_recaps: [{ display_date_label: "2018年3月", event_phrase: "欠债" }] }, + }), + chunk("finish"), + ])) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, "记下了:2018年3月 欠债。"); + assert.ok(result.phases.includes("answer.host_fallback")); + assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); +}); + +test("read_only action with a dated utterance does not trigger the write guard", async () => { + const { options, billing } = runOptions({ + action: "read_only", + expectedWrite: "none", + message: "2018 年 3 月开始欠债", + buildAgent: async () => fakeAgentStream(readCaseThen([ + chunk("text-delta", { text: "目前还在核对。" }), + chunk("finish"), + ])) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, true); + assert.equal(result.answerText, "目前还在核对。"); + assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); +}); + +test("schema rejection envelopes fail the tool and never count as a batch recap", () => { + const rejected = chunk("tool-result", { + toolName: "rectification-record-evidence-batch", + result: REJECTION, + }); + assert.equal(isToolInputRejection(REJECTION), true); + assert.deepEqual( + mapStreamChunkToActivity(rejected as never), + { + type: "tool.activity", + tool: "rectification-record-evidence-batch", + status: "failed", + code: "tool_call_rejected", + }, + ); + assert.equal(mapStreamChunkToPhase(rejected as never), null); + assert.equal(batchResultFromToolChunk(rejected), null); + assert.equal(composeHostFallbackNarration(REJECTION), null); + const publicEvent = safePublicEvent(mapStreamChunkToActivity(rejected as never)); + assert.deepEqual(publicEvent, { + type: "tool.activity", + tool: "rectification-record-evidence-batch", + status: "failed", + code: "tool_call_rejected", + }); + assert.doesNotMatch(JSON.stringify(publicEvent), /validationErrors/); +}); + +test("rejected batch tool-result is failed in the runner terminal status", async () => { + const { options, emitted } = runOptions({ + expectedWrite: "evidence", + buildAgent: async () => fakeAgentStream(readCaseThen([ + chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }), + chunk("tool-result", { + toolName: "rectification-record-evidence-batch", + result: REJECTION, + }), + chunk("text-delta", { text: "记下了:2018 年 3 月开始欠债。" }), + chunk("finish"), + ])) as never, + }); + const result = await runV9AgentTurn(options); + assert.equal( + emitted.some((event) => ( + event.type === "tool.activity" + && event.tool === "rectification-record-evidence-batch" + && event.status === "failed" + && event.code === "tool_call_rejected" + )), + true, + ); + assert.equal( + emitted.some((event) => ( + event.type === "tool.activity" + && event.tool === "rectification-record-evidence-batch" + && event.status === "completed" + )), + false, + ); + assert.doesNotMatch(JSON.stringify(emitted), /validationErrors/); + const status = new Map([ + ["rectification-record-evidence-batch", "failed"], + ]); + assert.equal(publicWriteToolCompleted(status), false); + assert.equal(result.ok, true); + assert.equal(result.answerText, RECTIFICATION_USER_COPY.evidenceNotRecorded); +}); + +test("chat live question only reads the last settled assistant message", () => { + const chat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", + ); + // 原值: messages.some((message) => role/state/question/focus_id) + // 新值: latestSettledAssistant 且 question.focus_id 对应当前焦点 + // 原因: BUG-635 旧消息上的同 focus_id 不再算仍在显示 + assert.match( + chat, + /const liveQuestionOnMessages = Boolean\(\s*latestSettledAssistant/, + ); + assert.doesNotMatch(chat, /const liveQuestionOnMessages = messages\.some/); + const design = readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8"); + assert.match(design, /completed turn that claimed to record evidence/); +}); diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts index 8484466d..492b58d1 100644 --- a/frontend/tests/rectification-v9-stream.test.ts +++ b/frontend/tests/rectification-v9-stream.test.ts @@ -918,7 +918,10 @@ test("empty stream without a write tool retries and the second attempt can succe chunk("tool-result", { toolName: "skill" }), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), - chunk("text-delta", { text: "记下了这件事。" }), + // 原值: 「记下了这件事。」 + // 新值: 「先继续核对。」 + // 原因: BUG-635 把无写入的「记下了」收口成 evidence_not_written;本用例只锁 empty_stream 重试后成功。 + chunk("text-delta", { text: "先继续核对。" }), chunk("finish"), ]) as never; }, @@ -926,7 +929,7 @@ test("empty stream without a write tool retries and the second attempt can succe const result = await runV9AgentTurn(options); assert.equal(buildCount, 2); assert.equal(result.ok, true); - assert.equal(result.answerText, "记下了这件事。"); + assert.equal(result.answerText, "先继续核对。"); assert.equal(emitted.some((event) => event.type === "attempt.reset"), true); assert.ok(!result.phases.includes("answer.host_fallback")); assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 }); diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index 52e1a5b9..2309a93b 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -156,8 +156,16 @@ def indistinguishable_width_minutes(candidates: Sequence[dict[str, Any]]) -> int return 0 ranked = sorted(candidates, key=lambda row: int(row.get("rank") or 0)) top = ranked[0] - minutes = [value for value in (_clock_minutes(row.get("time")) for row in ranked) if value is not None] - span = (max(minutes) - min(minutes) + 1) if minutes else 0 + starts: list[int] = [] + ends: list[int] = [] + for row in ranked: + start = _clock_minutes(row.get("cluster_start") or row.get("time")) + end = _clock_minutes(row.get("cluster_end") or row.get("time")) + if start is not None: + starts.append(start) + if end is not None: + ends.append(end) + span = (max(ends) - min(starts) + 1) if starts and ends else 0 tied = int(top.get("tied_minute_count") or 1) return max(tied, span, 1) @@ -606,6 +614,7 @@ def build_decision_receipt( cluster_width_minutes=width, include_discriminators=int(request.get("minute_step") or 1) <= 1, column_times=_column_times_for_packet(request, candidate_decisions), + clusters=candidate_decisions, ) if packet["dasha_agreement"]["status"] == "conflict": if overall_confidence == "high": diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 9458f188..50ee9c65 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -58,6 +58,29 @@ def _clock(value: str) -> int: return int(value[:2]) * 60 + int(value[3:5]) +def _cluster_span_key(time: str, clusters: Sequence[dict[str, Any]] | None) -> str: + clock = str(time)[:5] + if not clusters or len(clock) != 5 or clock[2] != ":": + return clock + clock_minutes = _clock(clock) + for row in clusters: + if not isinstance(row, dict): + continue + times = [str(item)[:5] for item in (row.get("cluster_times") or []) if str(item or "")[:5]] + start = str(row.get("cluster_start") or "")[:5] + end = str(row.get("cluster_end") or "")[:5] + representative = str(row.get("time") or "")[:5] + in_times = clock in times or clock == representative + in_span = False + if len(start) == 5 and start[2] == ":" and len(end) == 5 and end[2] == ":": + in_span = _clock(start) <= clock_minutes <= _clock(end) + if in_times or in_span: + if len(start) == 5 and len(end) == 5: + return f"{start}–{end}" + return clock + return clock + + def _feature_time(feature: dict[str, Any]) -> str | None: raw = feature.get("time") if isinstance(raw, str) and len(raw) >= 5: @@ -380,7 +403,11 @@ def event_fit_rate(rows: Sequence[dict[str, Any]]) -> dict[str, Any]: } -def dasha_agreement(built: dict[str, Any], candidate_times: Sequence[str]) -> dict[str, Any]: +def dasha_agreement( + built: dict[str, Any], + candidate_times: Sequence[str], + clusters: Sequence[dict[str, Any]] | None = None, +) -> dict[str, Any]: times = [str(item)[:5] for item in candidate_times if isinstance(item, str) and len(str(item)) >= 5] if not times: return { @@ -413,7 +440,7 @@ def dasha_agreement(built: dict[str, Any], candidate_times: Sequence[str]) -> di } vim_top = max(times, key=lambda time: (vim_scores[time], -_clock(time))) narayana_top = max(times, key=lambda time: (narayana_scores[time], -_clock(time))) - if vim_top == narayana_top: + if vim_top == narayana_top or _cluster_span_key(vim_top, clusters) == _cluster_span_key(narayana_top, clusters): return { "status": "agree", "vimshottari_top": vim_top, @@ -618,6 +645,7 @@ def build_refinement_packet( cluster_width_minutes: int | None = None, include_discriminators: bool = True, column_times: Sequence[str] | None = None, + clusters: Sequence[dict[str, Any]] | None = None, ) -> dict[str, Any]: from time import perf_counter @@ -642,7 +670,7 @@ def build_refinement_packet( ledgers_by_time = event_dasha_ledgers_by_time(request, built, columns) windows_by_time = prospective_windows_by_time(request, built, columns) column_compare_ms = round((perf_counter() - compare_started) * 1000, 1) - agreement = dasha_agreement(built, candidate_times) + agreement = dasha_agreement(built, candidate_times, clusters=clusters) stage = precision_stage(cluster, len(request.get("events") or [])) if not include_discriminators: return { diff --git a/tests/test_rectification_confirmation_and.py b/tests/test_rectification_confirmation_and.py index 40ab2aa5..9f89ab25 100644 --- a/tests/test_rectification_confirmation_and.py +++ b/tests/test_rectification_confirmation_and.py @@ -325,6 +325,26 @@ def test_indistinguishable_width_matches_public_span() -> None: assert width >= 25 +def test_indistinguishable_width_uses_cluster_span_not_representatives() -> None: + width = indistinguishable_width_minutes([ + { + "time": "04:53", + "rank": 1, + "tied_minute_count": 1, + "cluster_start": "04:50", + "cluster_end": "04:55", + }, + { + "time": "04:59", + "rank": 2, + "tied_minute_count": 1, + "cluster_start": "04:56", + "cluster_end": "05:02", + }, + ]) + assert width == 13 + + def test_fourteen_minute_tied_cluster_proposes_but_does_not_confirm() -> None: extra = [{ "id": "00000000-0000-4000-8000-000000000004", diff --git a/tests/test_rectification_refinement_packet.py b/tests/test_rectification_refinement_packet.py index a1eb2f0e..fd2c6266 100644 --- a/tests/test_rectification_refinement_packet.py +++ b/tests/test_rectification_refinement_packet.py @@ -132,6 +132,64 @@ class RefinementPacketTest(unittest.TestCase): self.assertEqual(agreement["narayana_top"], "05:14") self.assertIn("冲突", agreement["user_meaning"]) + def test_dasha_agreement_same_cluster_peaks_agree(self): + built = { + "matrix": { + "00000000-0000-4000-8000-000000000001": { + "04:54": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "04:59": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + "00000000-0000-4000-8000-000000000002": { + "04:54": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, + "04:59": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + }, + } + } + clusters = [{ + "time": "04:54", + "cluster_start": "04:54", + "cluster_end": "04:59", + "cluster_times": ["04:54", "04:59"], + }] + without_clusters = dasha_agreement(built, ["04:54", "04:59"]) + self.assertEqual(without_clusters["status"], "conflict") + agreement = dasha_agreement(built, ["04:54", "04:59"], clusters=clusters) + self.assertEqual(agreement["status"], "agree") + self.assertEqual(agreement["vimshottari_top"], "04:54") + self.assertEqual(agreement["narayana_top"], "04:59") + + def test_dasha_agreement_cross_cluster_peaks_conflict(self): + built = { + "matrix": { + "00000000-0000-4000-8000-000000000001": { + "05:13": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "05:20": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + "00000000-0000-4000-8000-000000000002": { + "05:13": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, + "05:20": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + }, + } + } + clusters = [ + {"time": "05:13", "cluster_start": "05:13", "cluster_end": "05:13", "cluster_times": ["05:13"]}, + {"time": "05:20", "cluster_start": "05:20", "cluster_end": "05:20", "cluster_times": ["05:20"]}, + ] + agreement = dasha_agreement(built, ["05:13", "05:20"], clusters=clusters) + self.assertEqual(agreement["status"], "conflict") + + def test_dasha_agreement_one_track_is_partial(self): + built = { + "matrix": { + "00000000-0000-4000-8000-000000000001": { + "05:13": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "05:20": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + } + } + agreement = dasha_agreement(built, ["05:13", "05:20"]) + self.assertEqual(agreement["status"], "partial") + def test_precision_stage_walks_d1_then_d9_then_ready(self): self.assertEqual(precision_stage({"d1_candidates_differ": True}, 2)["current"], "lagna_frame") self.assertEqual(precision_stage({"d9_candidates_differ": True}, 2)["current"], "d9_refine") @@ -278,24 +336,24 @@ class RefinementPacketTest(unittest.TestCase): request = request_events() rows = [ {"time": "05:13", "score": 20, "evidence": [], "missing_layers": []}, - {"time": "05:14", "score": 8, "evidence": [], "missing_layers": []}, + {"time": "05:20", "score": 8, "evidence": [], "missing_layers": []}, ] decisions = build_candidate_decisions(rows, result_id="00000000-0000-4000-8000-000000000099") built = { "missing_layers": [], - "static_contexts": [feature("05:13", d1=1, d9=1, d10=4), feature("05:14", d1=1, d9=1, d10=4)], + "static_contexts": [feature("05:13", d1=1, d9=1, d10=4), feature("05:20", d1=2, d9=1, d10=4)], "matrix": { request["events"][0]["id"]: { "05:13": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, - "05:14": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + "05:20": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, }, request["events"][1]["id"]: { "05:13": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, - "05:14": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + "05:20": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, }, request["events"][2]["id"]: { "05:13": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, - "05:14": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, + "05:20": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, }, }, } @@ -316,6 +374,53 @@ class RefinementPacketTest(unittest.TestCase): self.assertTrue(any(row["technique"] == "VedAstro 分钟级校验" and row["status"] == "blocked" for row in receipt["technique_audit_table"])) self.assertFalse(receipt["unique_minute_claim"]) + def test_same_cluster_dasha_peaks_agree_without_opening_confirmation(self): + request = request_events() + rows = [ + {"time": "04:54", "score": 20, "evidence": [], "missing_layers": []}, + {"time": "04:59", "score": 18, "evidence": [], "missing_layers": []}, + ] + contexts = [ + feature("04:54", d1=1, d9=1, d10=4), + feature("04:59", d1=1, d9=1, d10=4), + ] + decisions = build_candidate_decisions( + rows, + result_id="00000000-0000-4000-8000-000000000099", + static_contexts=contexts, + ) + built = { + "missing_layers": [], + "static_contexts": contexts, + "candidate_times": ["04:54", "04:59"], + "matrix": { + request["events"][0]["id"]: { + "04:54": {"points": 8, "rule_ids": ["vim_md_domain_house"]}, + "04:59": {"points": 1, "rule_ids": ["vim_ad_domain_house"]}, + }, + request["events"][1]["id"]: { + "04:54": {"points": 1, "rule_ids": ["narayana_ad_domain_house"]}, + "04:59": {"points": 9, "rule_ids": ["narayana_md_domain_house"]}, + }, + request["events"][2]["id"]: { + "04:54": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, + "04:59": {"points": 1, "rule_ids": ["vim_md_domain_varga"]}, + }, + }, + } + diagnostics = { + "leave_one_event_out_retention_rate": 1, + "leave_one_domain_out_retention_rate": 1, + "date_sensitivity_retention_rate": 1, + "primary_secondary_margin_percent": 50, + } + receipt = build_decision_receipt(request, decisions, built, diagnostics) + self.assertEqual(receipt["dasha_agreement"]["status"], "agree") + self.assertNotIn("vimshottari_narayana_conflict", receipt["reasons"]) + self.assertFalse(receipt["confirmation_allowed"]) + width = receipt["gates"]["exact_confirmation"]["indistinguishable_width_minutes"] + self.assertGreaterEqual(width, 6) + def test_receipt_recasts_house_tables_per_candidate_and_blocks_unique_minute(self): request = request_events() rows = [