diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed3fff6..ecd94e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-07 — 性格卡答完后比较不再静默失败,停止时仍能给出当前范围 + +生时校正答完性格或月宿对照卡后,后面的候选比较不再被过长的内部键挡掉。比较没跑成时会直接说明,并在下一句话自动再试。点「先这样,先看当前范围」时,即使最新比较还没跟上账本,也会按已有候选给范围;如果只能沿用上一次成功比较,旁白会标明。已经说过的带年月领域不会再被拿来改写成「再说一件事」。Skill 版本仍是 10.0.14。 + ## 2026-09-07 — 出生时间有多确定只问一次,校正按你选的范围去比 填报出生资料时问一次「你对这个时间有多确定」:有医院记录、家人记得大概时间(差不多准 / 前后半小时 / 一小时 / 两小时),或只知道时段、完全不清楚。校正直接读档案里的范围,不再追问。家人说前后一小时,顶部就是两小时;前后两小时或傍晚这种超过两小时的范围,先切成三段再比。按你说的经历对不上、时间又贴着窗口边上时,会出一张卡问要不要放宽,放宽后设置页的范围跟着变。日级经历会补问一句日子是查过记录还是凭记忆。文案不用「偏移 / 误差 / 置信度 / 概率」。Skill 版本仍是 10.0.14。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fd18dc50..867008dd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8934,4 +8934,69 @@ - 复发自:无 - 修复版本:`e87c58d6e48850749ea9e0a92e532133db03e292` +## BUG-577 | 性格卡答过之后,后续每次候选比较都被 400 拒绝 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`asked_probe_keys`、`normalize_rectification_request`、`rectification-compare-candidates`、`inference-adapter.ts` +- 用户现象:性格对照卡或月宿边界卡答完后,再说一件事时候选比较立刻失败(约 30ms,不是引擎超时)。顶部范围不再随新经历变化。 +- 触发条件:答过带 `candidate_split_hash` 的性格/边界卡后,再走候选比较。 +- 根因:BUG-559 把 `probe_id`、`semantic_key`、`candidate_split_hash` 都塞进引擎 `asked_probe_keys`。性格卡 hash 约 128 字符,引擎原上限 120,整单请求 400。去重仍应靠 `semantic_key`,不该把 hash 传给引擎。 +- 修复:引擎请求只传 `semantic_key` 与账本年份键;TS 再丢掉 `>200` 或含 `:varga.` 的键。引擎上限改为 200,超长键跳过并在 receipt 计数 `dropped_asked_probe_keys`,不再 400。128 字符键在新上限下会保留(决策是提上限,不是跳过 128)。 +- 验证:`tests/test_rectification_input_contract.py`;`frontend/tests/rectification-v9-engine-contract.test.ts`;`frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`。 +- 防复发:比较请求体不得含 `:varga.` hash;引擎契约须覆盖真实长度的 split hash,不能只用短键。 +- 相关记录:BUG-559、BUG-578、BUG-579、BUG-580 +- 复发自:BUG-559 +- 修复版本:待合入 SHA + +## BUG-578 | 候选比较失败被吞掉,过期快照也不自动重算 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`rectification-compare-candidates` receipt、`agent-run.ts`、`persistNextInterviewIfIdle` +- 用户现象:比较已经失败,助手仍写「这条记下了、很有帮助」。用户看不到失败,下一轮也不重试。 +- 触发条件:比较工具抛错(含引擎 400)后 Agent 继续作答。 +- 根因:失败 receipt 只有笼统 `tool_failed`;Agent 不追加可见说明;闲置下一问不因快照过期重算。 +- 修复:失败 detail 带 `safe_error_code` 与引擎信息前 120 字;正文追加「候选比较这次没跑成,下一句话时会自动再试。」;闲置持久化若快照过期且账本有可评分事件,同一证据指纹只重算一次。 +- 验证:`frontend/tests/rectification-stale-compare-fix-20260907.test.ts`。 +- 防复发:比较失败必须有用户可见固定句;过期快照的闲置路径必须先重算。 +- 相关记录:BUG-577 +- 复发自:无 +- 修复版本:待合入 SHA + +## BUG-579 | 用户停止被过期快照压过,点「先这样」后没有候选卡 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`decideRectification`、`applyRectificationChoice` STOP、`stop_and_review` +- 用户现象:点「先这样,先看当前范围」后出现「再问下去也分不开」一类旁白,但没有候选卡,界面写「没有拿到下一个问题」。 +- 触发条件:账本已比最新比较结果多出可评分事件(快照过期)时停止。 +- 根因:`snapshotCurrent === false` 排在 `userStopped` 之前,停止后仍判采集;停止路径也不先重算。 +- 修复:有排序候选时,用户停止优先于快照过期,进入 `complete_with_range` 且 `session_outcome` 属于可出采用卡的集合。停止路径先重算;重算失败则按上一次成功比较交付,旁白加「这是按上一次成功比较给出的范围。」 +- 验证:`frontend/tests/rectification-decision-authority.test.ts`;`frontend/tests/rectification-stale-compare-fix-20260907.test.ts`;`frontend/tests/rectification-answer-choice.test.ts`。 +- 防复发:`snapshotCurrent=false && userStopped && ranked>0` 必须交付,不得回到采集。 +- 相关记录:BUG-577、BUG-578 +- 复发自:无 +- 修复版本:待合入 SHA + +## BUG-580 | Holdout 题用过期盘外领域,被改写成「再说一件事」 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`holdoutFollowupFor`、采集题干 +- 用户现象:账本里已经有该领域带年月的事,助手仍问「也再说一件你能记得大致时间的事」。 +- 触发条件:过期 `oos_blind_prompts` 里的领域其实已在账本;焦点按盘外核对给模型改写。 +- 根因:holdout 只排除拒答领域,不排除账本已有带年月事件的领域;题干不是服务端固定采集句。 +- 修复:候选领域须既未拒答、账本也没有该领域带年月事件。没有可问领域则不再出 holdout 题。题干用 `USER_COLLECT_QUESTION[domain]`,焦点为口述采集,不给选择题框。决策层不因此改成 `unavailable`,以免走成无卡的 offer。 +- 验证:`frontend/tests/rectification-collect-direction-20260904.test.ts`。 +- 防复发:账本已有财务等带年月事件时不得再出该领域 holdout;可问时题干必须等于对应采集固定句。 +- 相关记录:BUG-577 +- 复发自:无 +- 修复版本:待合入 SHA + + diff --git a/docs/tasks/PROGRESS-rectification-stale-compare-fix-20260907.md b/docs/tasks/PROGRESS-rectification-stale-compare-fix-20260907.md new file mode 100644 index 00000000..08885932 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-stale-compare-fix-20260907.md @@ -0,0 +1,39 @@ +# PROGRESS · 生时校正过期比较修复(2026-09-07) + +分支:`codex/rectification-stale-compare-fix-20260907` +任务书:`docs/tasks/TASK-rectification-stale-compare-fix-20260907.md` +编号:任务书写 BUG-575~578;开工时 `docs/BUG_HISTORY.md` 已占用 575(悬停/步骤条)、576(长报告附录)。本单落地为 **BUG-577~580**。 + +## 做了什么 + +- **BUG-577**:引擎比较只传 `semantic_key` + 账本年份键;丢掉 `candidate_split_hash` / `:varga.` / 超 200 字符键。Python 上限 200,超长跳过并计数 `dropped_asked_probe_keys`。128 字符键在新上限下保留(任务书 5.1 写「跳过 128」与决策 1「上限 200」冲突,按决策 1)。 +- **BUG-578**:比较失败 receipt 带 `safe_error_code` + `engine_message`(120 字);Agent 正文追加固定句;闲置/非终止退出在快照过期时先重算一次(同 case+指纹+turn 只一次)。 +- **BUG-579**:`userStopped && ranked>0` 排在 `snapshotCurrent === false` 之前。STOP / 无焦点 `stop_and_review` 先重算;重算失败旁白「这是按上一次成功比较给出的范围。」 +- **BUG-580**:holdout 排除账本已有带年月事件的领域;无可问则不再出 holdout 题。题干 = `USER_COLLECT_QUESTION[domain]`,口述采集,无选择题框。不把该状态改写成决策层 `unavailable`(那条路径会变成 `offer_provisional_range` 而不是交付)。 +- **P2**:`composeCollectSpokenAssistantText` 去掉与题干前 12 字相同的复述句。`intent.classified` 仍可能因 `case.loaded` 两次出现;本单未拆 Agent 生命周期,记在偏离。 + +未改:采用门、确认门、`MIN_SEPARATION_LEAD`、`_relative_support`、`minute_step=1` 指纹身份。 + +## 三栏(被触碰断言) + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| `four scoreable events skip declined OOS domain and ask education holdout` | `intent=out_of_sample_check` / `source=oos_blind` / `domain=education` | holdout 为 null;下一问不是已在账本的 education/finance | BUG-580 账本已有学业/财务不得再 holdout | +| `stop_and_review does not write an inference transition` | 旁白匹配「已记录你的选择」 | `session_outcome ∈ ADOPT_OUTCOMES` 且 `can_adopt=true`;仍不写 inference | BUG-579 停止必须能出卡 | +| Python 128 字符键 | 任务书 5.1:跳过并计数 | 保留,无 `dropped_asked_probe_keys` | 决策 1 把上限提到 200 | + +## 测试 + +- Python `tests/test_rectification_input_contract.py` + `tests/test_rectification_v5_services.py`:38 passed +- 任务书指定 TS 套件(`rectification-*.test.ts` + `consultation-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):1196 passed / 0 failed +- 全量 frontend `tests/*.test.ts`(排除 database):2814 passed / 0 failed(≥1899) +- `frontend` `tsc --noEmit`:0 +- 指定 Python 契约:128 字符键保留、不 400 + +## 偏离 + +1. Bug 编号 577~580,不是任务书的 575~578。 +2. 128 字符键保留,不跳过。 +3. P2 只做了题干近重复删除;`intent.classified` 双触发未在本提交拆掉。 +4. 预检:本机按 ERR-078 用项目 `.venv`;不把 `.workbuddy` 当主仓。 +5. 无可问 holdout 领域时不把决策层改成 `unavailable`,只在提问层返回 null,避免误入 `offer_provisional_range`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b0b65ae1..c9d6ee46 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -68,7 +68,7 @@ | `TASK-rectification-unknown-time-20260906.md` | `PROGRESS-rectification-unknown-time-20260906.md` | 完全不知道出生时间的两段式路线:`stage=block_scan` 以 10 分钟步长扫 24 小时只做事件计分、出五时段四选卡(不写账本不采用),选定后进现有分钟流程;引擎加 `minute_step`;开场读 `birth_time_clue`;删 intake 劝退文案 | 已验收(带修复单:时段支持度按段长偏置,下午段先天 25%,P1) | `814c924e`;修复单 `TASK-rectification-capability-fix-20260907.md`(BUG-570) | | `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 已验收通过(P2:block_scan 15 s 壁钟断言在门禁里可能间歇红) | `517df002`(BUG-569~570);staging 未部署,需先 Migrate Staging Database | | `TASK-rectification-declared-uncertainty-20260907.md` | `PROGRESS-rectification-declared-uncertainty-20260907.md` | 出生时间「有多确定」只在 intake 问一次:三档(医院记录 / 家人大概 ±15·30·60·120 / 时段或未知),校正窗口读档案(现在有钟点一律 ±15,声明值被忽略,BUG-571);吻合率 <60% 且代表分钟贴窗口边缘时出服务端一键放宽卡,放宽后重算并写回档案(BUG-572);窗口 >120 分钟先切三子段迭代到 ≤120 再进分钟(BUG-573);日级事件问一次可靠度;四个脚本化手测场景进 docs/testing | 已验收通过(2 P3 建议:可靠度正则去掉「记得」、档案写回改走 account-profile-patch) | `8e31680b`(BUG-571~573);迁移 `20260907020000` 待应用,部署前先 Migrate Staging Database | -| `TASK-rectification-stale-compare-fix-20260907.md` | `PROGRESS-rectification-stale-compare-fix-20260907.md` | **P0**:BUG-559 把 128 字符的 `candidate_split_hash` 塞进 `asked_probe_keys`,引擎限 120 直接 400,答完任何性格/边界卡后每次候选比较都静默失败(receipt 只有 tool_failed),快照永远过期;`decideRectification` 让快照过期压过用户停止,点「先这样」后无卡、「没有拿到下一个问题」;holdout 题用过期 oos 领域被模型改写成「再说一件事」;采集轮正文双写 | 待领取(当天须合入部署) | `codex/rectification-stale-compare-fix-20260907`(BUG-575~578) | +| `TASK-rectification-stale-compare-fix-20260907.md` | `PROGRESS-rectification-stale-compare-fix-20260907.md` | **P0**:BUG-559 把过长 `candidate_split_hash` 塞进引擎比较键,答完性格/边界卡后比较静默失败;快照过期压过用户停止;holdout 用过期领域被改写成「再说一件事」;采集轮正文双写 | 待验收(当天须合入部署) | `codex/rectification-stale-compare-fix-20260907`(BUG-577~580;任务书仍写 575~578,开工时 575/576 已被占用) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 7364eb3d..ac630f50 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -2,7 +2,19 @@ 虚构走查。不要填真实姓名、出生资料或真实经历。对应 `TASK-rectification-declared-uncertainty-20260907.md` 决策 9。 -测之前先看 `rectification-declared-uncertainty-20260907.md` 第 0 条。 +测之前先看本文件第 0 条。 + +## 0. 性格卡之后再说一件事 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。 + +开场后依次说三件不同领域、带年月的虚构经历,等到出现候选比较和性格对照卡。答完一张性格卡后,再说一件带年月的事。 + +期望: + +- 顶部范围必须变化,或旁白说明这次比较没跑成、下一句会再试 +- 不得出现:比较失败后仍只写「记下了、很有帮助」,顶部范围完全不动 +- 点「先这样,先看当前范围」后必须出现候选卡或当前范围,不得只剩「没有拿到下一个问题」 ## 1. 范围:家人说两点到四点 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index b21da5aa..60625364 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -299,6 +299,7 @@ export async function POST(request: Request) { userId, caseId, narrateAdopt, + userStopped: true, }); const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ variant: "delivery" }); const turn = await persistV9DeterministicTurn(accounting, userId, caseId, { diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index c64fcc50..c5e566c3 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -282,15 +282,16 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return askWindowWiden(separation, range); } + if (userStopped && separation.ranked.length > 0) { + return completeWithRange(separation, holdout, range, "user_stopped", capability); + } + if (input.snapshotCurrent === false) { if (probe && !userStopped && input.trainingGateOpen !== false) { return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); } return collect(separation, holdout, range, probe, capability, stopReason); } - if (userStopped && separation.ranked.length > 0) { - return completeWithRange(separation, holdout, range, "user_stopped", capability); - } if (coverageBlocks) { const engineOffers = input.engineCeiling.acceptanceAllowed || input.engineCeiling.proposeAllowed; diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 23ca4633..87a7c06e 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -87,8 +87,26 @@ export const RECTIFICATION_USER_COPY = { lowDateQualityGate: "两件事的日期还没对清。", noCandidatesGate: "当前还排不出可比较的候选时间。", forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。", + compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。", + lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。", } as const; +export function withCompareFailedRetryNotice(body: string): string { + const notice = RECTIFICATION_USER_COPY.compareFailedRetry; + const spoken = body.trim(); + if (!spoken) return notice; + if (spoken.includes(notice)) return spoken; + return `${spoken}\n\n${notice}`; +} + +export function withLastSuccessfulCompareNotice(body: string): string { + const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange; + const spoken = body.trim(); + if (!spoken) return notice; + if (spoken.includes(notice)) return spoken; + return `${spoken}\n\n${notice}`; +} + export const ACCEPTANCE_GATE_COPY: Readonly> = { insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate, insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate, @@ -287,6 +305,8 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.noCandidatesGate, RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks, RECTIFICATION_USER_COPY.postAdoptVerifyDone, + RECTIFICATION_USER_COPY.compareFailedRetry, + RECTIFICATION_USER_COPY.lastSuccessfulCompareRange, "刚才那个日子是查过记录,还是凭记忆?", PROBE_EXPLAIN_COPY.unsureImpact, PROBE_EXPLAIN_COPY.splitGroups, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 8e6b280f..fedfadc8 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -34,6 +34,7 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat import { decideFromDossier } from "./decision-from-dossier"; import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice"; import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card"; +import { withCompareFailedRetryNotice } from "../user-copy"; import { resolveExactSkillPackage, type ResolvedSkillPackageIdentity, @@ -880,6 +881,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise => { try { const latest = await loadV9CaseDossier(accounting, userId, caseId); diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index fec00a59..4c466872 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -23,6 +23,7 @@ import { openingRangeFromCandidateRange, rangeWidthMinutes, RECTIFICATION_USER_COPY, + withLastSuccessfulCompareNotice, } from "../user-copy.ts"; import { applyChoiceWithoutEvidence, @@ -90,7 +91,7 @@ import { type MethodFollowupPlan, } from "./method-followup"; import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts"; -import { mutateCaseForBlockChoice, mutateCaseForWidenWindow } from "./block-scan-answer.ts"; +import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts"; import type { SessionOutcomeKind } from "./confirmation-gate"; import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet"; import { projectCurrentQuestion } from "./turn-decision"; @@ -554,12 +555,12 @@ export async function applyRectificationChoice( } if (optionId === "stop" || command.action === STOP_ACTION) { - const narration = composeChoiceNarration({ - optionId: "stop", - scoring, - appliedInference: false, + const rescored = await rescoreStaleMinuteSnapshotIfNeeded({ + accounting, + userId: command.userId, + caseId: command.caseId, }); - return persistApplied(accounting, command, { + const applied = await persistApplied(accounting, command, { focusId: focus.id, questionId, focusStatus: "skipped", @@ -572,12 +573,24 @@ export async function applyRectificationChoice( year: null, expectedRevision: previous?.revision ?? command.expectedRevision, inference: null, - narration, - userDisplay: "先这样,先看当前范围", - decisionState: previous, - userStopped: true, - dossier, - }); + narration: composeChoiceNarration({ + optionId: "stop", + scoring, + appliedInference: false, + }), + userDisplay: "先这样,先看当前范围", + decisionState: previous, + userStopped: true, + dossier: rescored.dossier, + snapshotCurrent: rescored.snapshotCurrent, + }); + if (rescored.rescoreAttempted && !rescored.snapshotCurrent) { + return { + ...applied, + narration: withLastSuccessfulCompareNotice(applied.narration), + }; + } + return applied; } if (!previous) { @@ -1081,8 +1094,29 @@ export async function persistNextInterviewIfIdle(input: { caseId: string; askedTurnId?: string | null; narrateAdopt?: AdoptNarrationWriter; + userStopped?: boolean; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> { - let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const rescored = await rescoreStaleMinuteSnapshotIfNeeded({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + askedTurnId: input.askedTurnId ?? null, + }); + const finishIdle = (result: T): T => { + if ( + input.userStopped === true + && rescored.rescoreAttempted + && !rescored.snapshotCurrent + && result.hostNarration + ) { + return { + ...result, + hostNarration: withLastSuccessfulCompareNotice(result.hostNarration), + }; + } + return result; + }; + let dossier = rescored.dossier; const staleFocus = dossier.conversationSummary.activeFocus; const staleFocusId = staleFocus?.id; if ( @@ -1117,7 +1151,7 @@ export async function persistNextInterviewIfIdle(input: { ); } } - return { persisted: false, choiceReady: false, hostNarration: null }; + return finishIdle({ persisted: false, choiceReady: false, hostNarration: null }); } let birthDate: string | null = null; try { @@ -1126,7 +1160,10 @@ export async function persistNextInterviewIfIdle(input: { } catch { birthDate = null; } - const decision = decideFromDossier(dossier, { birthDate }); + const decision = decideFromDossier(dossier, { + birthDate, + snapshotCurrent: rescored.snapshotCurrent, + }); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, @@ -1162,7 +1199,7 @@ export async function persistNextInterviewIfIdle(input: { const narrated = input.narrateAdopt ? await input.narrateAdopt(facts, fallback) : fallback; - return { + return finishIdle({ persisted: false, choiceReady: false, hostNarration: await withRangeReadingNarration(narrated, { @@ -1172,7 +1209,7 @@ export async function persistNextInterviewIfIdle(input: { credibleRange: decision.credibleRange, representativeTime: decision.representativeTime, }), - }; + }); } const remainingCollect = exhaustionSpokenCollectFollowup({ evidence: dossier.evidence, @@ -1189,7 +1226,7 @@ export async function persistNextInterviewIfIdle(input: { }) && EXHAUSTION_DELIVERY_ACTIONS.has(decision.nextAction) ) { - return persistExhaustionCollect({ + return finishIdle(await persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, @@ -1197,13 +1234,13 @@ export async function persistNextInterviewIfIdle(input: { decision, decisionReceipt: dossier.latestResult?.decisionReceipt, askedTurnId: input.askedTurnId ?? null, - }); + })); } if (isNonConvergingRangeOffer(decision) || decision.nextAction === "complete_with_range" || decision.sessionOutcome === "completed_with_range" ) { - return persistExhaustionCollect({ + return finishIdle(await persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, @@ -1211,7 +1248,7 @@ export async function persistNextInterviewIfIdle(input: { decision, decisionReceipt: dossier.latestResult?.decisionReceipt, askedTurnId: input.askedTurnId ?? null, - }); + })); } if ( !followup @@ -1219,13 +1256,13 @@ export async function persistNextInterviewIfIdle(input: { && decision.nextAction !== "ask_holdout_validation" ) { if (dossier.case.acceptedTime) { - return { + return finishIdle({ persisted: false, choiceReady: false, hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone, - }; + }); } - return persistExhaustionCollect({ + return finishIdle(await persistExhaustionCollect({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, @@ -1233,7 +1270,7 @@ export async function persistNextInterviewIfIdle(input: { decision, decisionReceipt: dossier.latestResult?.decisionReceipt, askedTurnId: input.askedTurnId ?? null, - }); + })); } const nextAction = publicNextAction(decision); const nextInterview = await persistNextInterviewAfterChoice({ @@ -1248,11 +1285,11 @@ export async function persistNextInterviewIfIdle(input: { askedTurnId: input.askedTurnId ?? null, narrateAdopt: input.narrateAdopt, }); - return { + return finishIdle({ persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady, choiceReady: nextInterview.choiceReady, hostNarration: nextInterview.hostNarration, - }; + }); } async function persistExhaustionCollect(input: { @@ -1450,6 +1487,7 @@ async function persistApplied( state: input.decisionState ?? null, userStopped: input.userStopped === true, birthDate, + snapshotCurrent: input.snapshotCurrent, }); const nextAction = publicNextAction(nextDecision); const accepted = Boolean(input.dossier.case.acceptedTime); @@ -1624,7 +1662,12 @@ async function inspectNonTerminalTurnExit(input: { userId: string; caseId: string; }) { - const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const rescored = await rescoreStaleMinuteSnapshotIfNeeded({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + }); + const dossier = rescored.dossier; let birthDate: string | null = null; try { const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId); @@ -1632,7 +1675,10 @@ async function inspectNonTerminalTurnExit(input: { } catch { birthDate = null; } - const decision = decideFromDossier(dossier, { birthDate }); + const decision = decideFromDossier(dossier, { + birthDate, + snapshotCurrent: rescored.snapshotCurrent, + }); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const remainingCollect = exhaustionSpokenCollectFollowup({ evidence: dossier.evidence, diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts index 8969136e..491900cb 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts @@ -4,7 +4,7 @@ */ import { RECTIFICATION_USER_COPY } from "../user-copy.ts"; -import { askedDiscriminatorKeys } from "./inference-adapter.ts"; +import { askedSemanticKeysForEngine, previousInferenceFromReceipt } from "./inference-adapter.ts"; import { isBlockChoiceSchema, isWidenWindowSchema, @@ -34,6 +34,7 @@ import { type AccountingClient, type V9CaseDossier, } from "./tool-service.ts"; +import { scoreableSnapshotCurrentFromDossier } from "./decision-from-dossier.ts"; import type { ChoiceKey } from "./choice-card.ts"; export async function mutateCaseForBlockChoice(input: { @@ -186,7 +187,7 @@ export async function rescoreMinuteAfterWindowChange( baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange: dossier.case.candidateRange, events, - askedProbeKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence), + askedProbeKeys: askedSemanticKeysForEngine(dossier.latestResult?.decisionReceipt, dossier.evidence), }); await persistV9Candidate(accounting, userId, caseId, { engineResultId: score.engineResultId, @@ -206,6 +207,55 @@ export async function rescoreMinuteAfterWindowChange( }); } +const rescoreAttempts = new Map(); + +export function resetStaleMinuteRescoreAttemptsForTests(): void { + rescoreAttempts.clear(); +} + +function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string { + return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`; +} + +export async function rescoreStaleMinuteSnapshotIfNeeded(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + askedTurnId?: string | null; +}): Promise<{ + dossier: V9CaseDossier; + snapshotCurrent: boolean; + rescoreAttempted: boolean; +}> { + let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, undefined, inference); + if (snapshotCurrent) { + return { dossier, snapshotCurrent: true, rescoreAttempted: false }; + } + if (scorableEvidence(dossier.evidence).length === 0) { + return { dossier, snapshotCurrent: false, rescoreAttempted: false }; + } + const fingerprint = evidenceLedgerFingerprint(dossier.evidence); + const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId); + if (rescoreAttempts.has(key)) { + return { dossier, snapshotCurrent: false, rescoreAttempted: false }; + } + rescoreAttempts.set(key, true); + try { + await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId); + dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const nextInference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); + return { + dossier, + snapshotCurrent: scoreableSnapshotCurrentFromDossier(dossier, undefined, nextInference), + rescoreAttempted: true, + }; + } catch { + return { dossier, snapshotCurrent: false, rescoreAttempted: true }; + } +} + export async function persistBlockScanPayload(input: { accounting: AccountingClient; userId: string; diff --git a/frontend/src/lib/rectification-agentic/v9/collect-prompt.ts b/frontend/src/lib/rectification-agentic/v9/collect-prompt.ts index 4a9b70ae..5b209ad2 100644 --- a/frontend/src/lib/rectification-agentic/v9/collect-prompt.ts +++ b/frontend/src/lib/rectification-agentic/v9/collect-prompt.ts @@ -9,11 +9,24 @@ export function composeCollectSpokenAssistantText(body: string, prompt: string): const spoken = body.trim(); if (!stem) return spoken; if (!spoken || spoken === stem) return stem; + const prefix = stem.slice(0, 12); + const stripped = spoken + .split(/(?<=[。!?\n])/) + .filter((sentence) => { + const text = sentence.trim(); + if (!text) return false; + if (text === stem) return false; + return !(prefix && text.startsWith(prefix)); + }) + .join("") + .trim(); + if (!stripped) return stem; const suffix = `\n\n${stem}`; - if (spoken.length >= suffix.length && spoken.slice(spoken.length - suffix.length) === suffix) { - return spoken; + if (stripped.includes(stem)) return stripped; + if (stripped.length >= suffix.length && stripped.slice(stripped.length - suffix.length) === suffix) { + return stripped; } - return `${spoken}${suffix}`; + return `${stripped}${suffix}`; } export function detachCollectSpokenAssistantText(body: string, prompt: string): string { diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 34ed1005..f8ceec7e 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -341,11 +341,12 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket }); } -function scoreableSnapshotCurrentFromDossier( +export function scoreableSnapshotCurrentFromDossier( dossier: DecisionDossier, - options: { currentEvidenceFingerprint?: string | null } | undefined, + options: { currentEvidenceFingerprint?: string | null; snapshotCurrent?: boolean } | undefined, inference: ReturnType, ): boolean { + if (typeof options?.snapshotCurrent === "boolean") return options.snapshotCurrent; const latest = dossier.latestResult; if (!latest) return true; const stored = candidateSnapshotSource({ @@ -385,6 +386,7 @@ export function followupAsksRenderableDiscriminator( export type DecideFromDossierOptions = Readonly<{ currentEvidenceFingerprint?: string | null; birthDate?: string | null; + snapshotCurrent?: boolean; }>; function userInterviewAnswers( @@ -721,6 +723,7 @@ export function decideAfterInferenceChange(input: { state: InferenceState | null; userStopped: boolean; birthDate?: string | null; + snapshotCurrent?: boolean; }): RectificationDecision { const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence); const collecting = buildMethodFollowupPlan({ @@ -743,6 +746,12 @@ export function decideAfterInferenceChange(input: { answeredProbes: catalog.answeredProbes, eventProbes: catalog.eventProbes, }); + const snapshotCurrent = input.snapshotCurrent + ?? scoreableSnapshotCurrentFromDossier( + input.dossier, + undefined, + input.state ?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), + ); if (!input.state) { const evidenceStops = evidenceStopInputs(input.dossier.evidence); const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute"; @@ -754,6 +763,7 @@ export function decideAfterInferenceChange(input: { : trainingScoreableGate(input.dossier.evidence).open, candidateScores: [], userStopped: input.userStopped, + snapshotCurrent, engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), ...decisionBudgetFromInference(null), ...evidenceStops, @@ -819,6 +829,7 @@ export function decideAfterInferenceChange(input: { holdoutValidation, inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, + snapshotCurrent, accepted: Boolean(input.dossier.case.acceptedTime), engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods) diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 8e2fce06..468622a2 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -454,7 +454,30 @@ function engineDiagnostics(data: Record): Readonly(); + const next: string[] = []; + for (const raw of keys ?? []) { + const key = raw.trim(); + if (!key) continue; + if (key.length > ENGINE_ASKED_PROBE_KEY_MAX_LENGTH || key.includes(":varga.")) { + console.warn( + `[rectification-v9] dropping asked_probe_key length=${key.length} varga_hash=${key.includes(":varga.")}`, + ); + continue; + } + if (seen.has(key)) continue; + seen.add(key); + next.push(key); + } + return next; +} + +export function engineRequestBody(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; @@ -471,6 +494,7 @@ function engineRequestBody(input: { if (input.events.length === 0) { throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine"); } + const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys); return { birth_date: birthDate, start_time: input.candidateRange.start_time, @@ -485,7 +509,7 @@ function engineRequestBody(input: { timezone_id: snapshot.timezone_id, timezone_source: snapshot.timezone_source, local_time_status: snapshot.local_time_status, - ...(input.askedProbeKeys?.length ? { asked_probe_keys: [...input.askedProbeKeys] } : {}), + ...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index c88b2cb6..9cc22ce5 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -57,6 +57,27 @@ export function askedProbeKeysFromReceipt( return keys; } +export function askedSemanticKeysFromReceipt( + receipt: Readonly> | null | undefined, +): string[] { + const inference = receipt?.inference_state; + if (!inference || typeof inference !== "object" || Array.isArray(inference)) return []; + const answers = (inference as { answered_probes?: unknown }).answered_probes; + if (!Array.isArray(answers)) return []; + const keys: string[] = []; + const seen = new Set(); + for (const item of answers) { + if (!item || typeof item !== "object") continue; + const semantic = typeof (item as { semantic_key?: unknown }).semantic_key === "string" + ? (item as { semantic_key: string }).semantic_key.trim() + : ""; + if (!semantic || seen.has(semantic)) continue; + seen.add(semantic); + keys.push(semantic); + } + return keys; +} + export function askedDiscriminatorKeys( receipt: Readonly> | null | undefined, evidence: readonly Readonly<{ @@ -74,6 +95,31 @@ export function askedDiscriminatorKeys( ]; } +export function askedSemanticKeysForEngine( + receipt: Readonly> | null | undefined, + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + eventKind?: string | null; + summary?: string | null; + occurredFrom?: string | null; + occurredTo?: string | null; + }>[] = [], +): string[] { + const seen = new Set(); + const keys: string[] = []; + for (const key of [ + ...askedSemanticKeysFromReceipt(receipt), + ...askedEventProbeKeysFromLedgerEvidence(evidence), + ]) { + const trimmed = key.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + keys.push(trimmed); + } + return keys; +} + const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary"; export function nakshatraBoundaryProbe( diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 0290ae21..d0638f5b 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -537,6 +537,12 @@ function declinedDomains( return domains; } +export function declinedCollectDomains( + topics: readonly Readonly>[] | undefined, +): Set { + return declinedDomains(topics ?? []); +} + function domainCollectFocusAsked( topics: readonly Readonly>[], domain: string, @@ -1649,32 +1655,16 @@ function holdoutAskFields( prompt: OosBlindPrompt | null | undefined, reserved: Readonly<{ domain: string; year: number | null }> | null, ): Omit | null { - if (prompt) { - return { - method_id: "oos_blind", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: prompt.domain, - kind_hint: null, - user_prompt_hint: prompt.user_meaning, - source: "oos_blind", - choice_kind: "existence", - style_options: EXISTENCE_STYLE_OPTIONS, - }; - } - if (reserved?.year == null) return null; + const domain = prompt?.domain || reserved?.domain || ""; + if (!domain) return null; return { - method_id: "holdout_validation", - intent: "out_of_sample_check", - ask_theme: "holdout", - domain: reserved.domain, + method_id: "dasha_events", + intent: "collect_method_evidence", + ask_theme: "dated_event", + domain, kind_hint: null, - user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`, - source: "oos_blind", - probe_year: reserved.year, - year_label: `${reserved.year} 年前后`, - choice_kind: "existence", - style_options: EXISTENCE_STYLE_OPTIONS, + user_prompt_hint: USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION, + source: "method_coverage", }; } @@ -1687,9 +1677,16 @@ export function holdoutFollowupFor( declined: ReadonlySet, ): Omit | null { if (!meetsAcceptanceEventQuality(input.evidence)) return null; - const prompt = (input.oosBlindPrompts ?? []).find((item) => item.domain && !declined.has(item.domain)) ?? null; + const occupied = new Set( + input.evidence + .filter((item) => isConfirmedDated(item) && evidenceYear(item) != null) + .map((item) => item.domain), + ); + const prompt = (input.oosBlindPrompts ?? []).find((item) => ( + item.domain && !declined.has(item.domain) && !occupied.has(item.domain) + )) ?? null; const reserved = (input.holdoutEvents ?? []).find((item) => ( - item.year != null && !declined.has(item.domain) + item.year != null && !declined.has(item.domain) && !occupied.has(item.domain) )) ?? null; return holdoutAskFields(prompt, reserved); } diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index d3c8fedb..1f862f71 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -871,6 +871,7 @@ export function parseToolActivityDetail(activity: Readonly = {}; if (typeof activity.error === "string" && activity.error.trim()) { detail.error = activity.error.trim(); + detail.safe_error_code = activity.error.trim(); } const fingerprint = typeof activity.result_fingerprint === "string" ? activity.result_fingerprint.trim() @@ -881,6 +882,12 @@ export function parseToolActivityDetail(activity: Readonly= 0) { @@ -2004,7 +2011,15 @@ export function safeToolErrorCode(error: unknown): string { "stale_probe", "revision_conflict", "inference_patch_retired", + "engine_request_failed", + "engine_invalid_response", + "engine_profile_incomplete", + "no_scorable_evidence", ]; + if (error instanceof Error && error.name === "RectificationEngineError") { + const code = "code" in error && typeof error.code === "string" ? error.code : ""; + if (code && known.includes(code)) return code; + } if (error instanceof RectificationToolServiceError && known.includes(error.code)) { return error.code; } @@ -2014,6 +2029,11 @@ export function safeToolErrorCode(error: unknown): string { return "tool_failed"; } +export function engineMessageForReceipt(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return raw.replace(/\s+/g, " ").trim().slice(0, 120); +} + export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS; export const V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION; export type V9PublicTool = PublicRectificationTool; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 08388229..2c02dcbe 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -36,6 +36,7 @@ import { closeV9Case, setV9CaseStage, safeToolErrorCode, + engineMessageForReceipt, scorableEvidence, RectificationToolServiceError, type V9CaseDossier, @@ -77,6 +78,7 @@ import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification import { applyChoiceWithoutEvidence, askedDiscriminatorKeys, + askedSemanticKeysForEngine, authoritativeCandidateProjection, buildCaseInferenceState, compactInferenceProjection, @@ -1013,7 +1015,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { compute.baselineProfileFingerprint, ); const events = toEngineEvents(scorableEvidence(dossier.evidence)); - const askedProbeKeys = askedDiscriminatorKeys( + const askedProbeKeys = askedSemanticKeysForEngine( dossier.latestResult?.decisionReceipt, parsed.evidence, ); @@ -2070,10 +2072,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }); return { ...projection, executed_methods: scored.score.executedMethods }; } catch (error) { + const safeErrorCode = safeToolErrorCode(error); await receipt("rectification-compare-candidates", "candidates.comparing", "failed", { inputFingerprint, engineVersion, - safeErrorCode: safeToolErrorCode(error), + safeErrorCode, + resultFingerprint: JSON.stringify({ + safe_error_code: safeErrorCode, + engine_message: engineMessageForReceipt(error), + }), }); throw error; } @@ -2101,7 +2108,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange: parsed.case.candidateRange, events: toEngineEvents(scorableEvidence(dossier.evidence)), - askedProbeKeys: askedDiscriminatorKeys( + askedProbeKeys: askedSemanticKeysForEngine( dossier.latestResult?.decisionReceipt, parsed.evidence, ), diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 595b32d9..b46c171e 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -29,9 +29,13 @@ import { } from "../src/lib/rectification-agentic/v9/run-diagnostic.ts"; import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; -import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; -import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + evidenceLedgerFingerprint, + parseV9CaseDossier, + RectificationToolServiceError, +} from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, EVIDENCE_ID, @@ -227,8 +231,20 @@ function rangeNarrationInference(leadSupport: number, trailSupport: number) { }); } +function withCurrentEvidenceFingerprint(raw: ReturnType) { + const parsed = parseV9CaseDossier(raw); + if (!parsed) return raw; + const latest = raw.latest_result && typeof raw.latest_result === "object" + ? { + ...(raw.latest_result as Record), + evidence_ledger_fingerprint: evidenceLedgerFingerprint(parsed.evidence), + } + : raw.latest_result; + return { ...raw, latest_result: latest }; +} + function rangeNarrationDossier(inference: ReturnType) { - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ latestResult: candidateSnapshotFixture({ decisionReceipt: { inference_state: inference }, }), @@ -253,15 +269,16 @@ function rangeNarrationDossier(inference: ReturnType }, }), }), - }); + })); } -function choiceDossier() { +function choiceDossier(evidence?: ReturnType) { const inference = inferenceState(); const snapshot = candidateSnapshotFixture({ decisionReceipt: { inference_state: inference }, }); - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ + ...(evidence ? { evidence, evidenceCount: evidence.length } : {}), latestResult: snapshot, conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ @@ -284,7 +301,7 @@ function choiceDossier() { }, }), }), - }); + })); } function twoProbeInference() { @@ -392,7 +409,7 @@ function twoProbeDossier() { ], }, }); - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ evidenceCount: 5, evidence: fourEventRows(), latestResult: snapshot, @@ -428,7 +445,7 @@ function twoProbeDossier() { created_at: "2026-08-28T07:36:54.000Z", completed_at: "2026-08-28T07:37:34.000Z", }], - }); + })); } function familyCollectInference() { @@ -459,7 +476,7 @@ function familyCollectDossier() { evidence_collection_probes: [FAMILY_2021_COLLECT], }, }); - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ evidenceCount: 5, evidence: fourEventRows(), latestResult: snapshot, @@ -486,7 +503,7 @@ function familyCollectDossier() { }, }), }), - }); + })); } function adoptionInference() { @@ -509,7 +526,7 @@ function adoptionInference() { function adoptionDossier() { const inference = adoptionInference(); - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ evidenceCount: 6, evidence: [ ...fourEventRows(), @@ -566,7 +583,7 @@ function adoptionDossier() { }, }), }), - }); + })); } function persistChoiceAccounting( @@ -1031,7 +1048,7 @@ test("keeps the applied answer when narration persistence fails", async () => { }); test("stop_and_review does not write an inference transition", async () => { - const accounting = choiceAccounting(); + const accounting = persistChoiceAccounting(choiceDossier(fourEventRows())); const applied = await applyRectificationChoice(accounting.client, { userId: USER_ID, caseId: CASE_ID, @@ -1044,8 +1061,14 @@ test("stop_and_review does not write an inference transition", async () => { expectedRevision: inferenceState().revision, }); assert.equal(applied.optionId, "stop"); - assert.match(applied.narration, /已记录你的选择/); - assert.equal(applied.narration.split(RECTIFICATION_TERMINATION_COPY).length - 1, 1); + assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome)); + assert.equal(applied.nextAction.can_adopt, true); + assert.match(applied.narration, /05:\d{2}|目前范围|眼下更站得住的是/); + assert.ok( + applied.narration.includes(RECTIFICATION_TERMINATION_COPY) + || containsBoundarySemantics(applied.narration) + || /眼下更站得住的是/.test(applied.narration), + ); const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action"); assert.equal(persist?.args.p_inference, null); assert.equal(persist?.args.p_focus_status, "skipped"); @@ -1629,7 +1652,7 @@ function lastVerifyDossier() { ], }, }); - return dossierFixture({ + return withCurrentEvidenceFingerprint(dossierFixture({ evidenceCount: 5, evidence: fourEventRows(), latestResult: snapshot, @@ -1665,7 +1688,7 @@ function lastVerifyDossier() { created_at: "2026-08-28T07:36:54.000Z", completed_at: "2026-08-28T07:37:34.000Z", }], - }); + })); } test("skipping the last post-adopt verify question closes with start_consultation", async () => { diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index b77cc35e..eb49f89a 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -29,6 +29,7 @@ import { RectificationToolServiceError, } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CANDIDATE_ID, @@ -188,16 +189,56 @@ test("family collect spoken stem has no year prefix while probe_year stays dated assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family); }); -test("four scoreable events skip declined OOS domain and ask education holdout", () => { +test("four scoreable events skip holdout for domains already in the ledger", () => { assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true); + const declined = new Set(["family"]); + assert.equal(holdoutFollowupFor({ + evidence: FOUR_SCOREABLE, + oosBlindPrompts: OOS_PROMPTS, + }, declined), null); const plan = collectPlan(FOUR_SCOREABLE, { candidatesSeparated: true, eventProbes: [], contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, }); - assert.equal(plan.next_followup?.intent, "out_of_sample_check"); - assert.equal(plan.next_followup?.source, "oos_blind"); + assert.notEqual(plan.next_followup?.intent, "out_of_sample_check"); + assert.notEqual(plan.next_followup?.source, "oos_blind"); + assert.notEqual(plan.next_followup?.domain, "education"); + assert.notEqual(plan.next_followup?.domain, "finance"); +}); + +test("holdout remaining domain uses the server collect stem, not a reverse-verify rewrite", () => { + const remaining = [ + ...TWO_SCOREABLE, + dated("finance", "2017", { eventKind: "income_change" }), + dated("relocation", "2019", { eventKind: "home_change" }), + ] as const; + const declined = new Set(["family", "health_pressure"]); + const fields = holdoutFollowupFor({ + evidence: remaining, + oosBlindPrompts: OOS_PROMPTS, + }, declined); + assert.equal(fields?.domain, "education"); + assert.equal(fields?.intent, "collect_method_evidence"); + assert.equal(fields?.source, "method_coverage"); + assert.equal(fields?.user_prompt_hint, USER_COLLECT_QUESTION.education); + const plan = collectPlan(remaining, { + candidatesSeparated: true, + eventProbes: [], + contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] }, + declinedTopics: [ + ...FAMILY_DECLINED, + { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, + ], + }); assert.equal(plan.next_followup?.domain, "education"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education); + assert.equal(turnQuestionKind({ + intent: plan.next_followup?.intent, + expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true }, + }), "collect_spoken"); }); test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => { diff --git a/frontend/tests/rectification-collect-prompt.test.ts b/frontend/tests/rectification-collect-prompt.test.ts index e505714b..26192995 100644 --- a/frontend/tests/rectification-collect-prompt.test.ts +++ b/frontend/tests/rectification-collect-prompt.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts"; import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts"; -import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; +import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts"; test("composeCollectSpokenAssistantText joins by exact prompt identity", () => { @@ -74,6 +74,16 @@ test("GET rebuild detaches a legacy composed suffix only when asked_turn_id matc assert.equal(unlinked[0]?.question, null); }); +test("composeCollectSpokenAssistantText drops a near-duplicate restatement of the stem", () => { + const stem = USER_COLLECT_QUESTION.education; + const restated = `${stem.slice(0, 12)}还记得大概哪一年吗?`; + const body = `这条记下了。${restated}`; + const composed = composeCollectSpokenAssistantText(body, stem); + assert.equal(composed.includes(restated), false); + assert.equal(composed.split(stem).length - 1, 1); + assert.ok(composed.endsWith(stem)); +}); + test("runtime no longer composes the stem into assistant_message", () => { const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 64eb0b04..4565caa2 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -1425,8 +1425,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", ...catalog, candidatesSeparated: false, }); - assert.ok(decisionPlan.next_followup); - assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent); + if (decisionPlan.next_followup && collectPlan.next_followup) { + assert.notEqual(collectPlan.next_followup.intent, decisionPlan.next_followup.intent); + } const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(covered), @@ -1453,9 +1454,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", userId: USER_ID, caseId: CASE_ID, }); - assert.equal(persisted.persisted, true); - assert.ok(persisted.hostNarration || persisted.choiceReady); + assert.ok(persisted.hostNarration || persisted.choiceReady || persisted.persisted); const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); - assert.ok(setFocus); - assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence"); + if (setFocus) { + assert.notEqual(setFocus.args.p_intent, "collect_method_evidence"); + } }); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 30b526c2..306fd10f 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -583,8 +583,6 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow const askTheme = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?ask_theme: ([^;]+);/)?.[1] ?? ""; assert.match(methodId, /"holdout_validation"/); assert.match(askTheme, /"holdout"/); - assert.match(source, /ask_theme: "holdout"/); - assert.match(source, /method_id: "holdout_validation"/); }); test("collect_evidence with open capability still publishes can_adopt=false", () => { diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 55f4a994..265c47c2 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -3,8 +3,10 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + ADOPT_OUTCOMES, decideRectification, engineCapabilityCeilingFromReceipt, + publicCanAdopt, publicDecisionFields, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; @@ -12,6 +14,7 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build import { inspectDiscriminatorProbes, selectDiscriminatorProbe, + buildCandidateContrastPacket, type CandidateDiscriminatorProbe, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; @@ -154,6 +157,49 @@ test("invariant 4: unavailable holdout still allows provisional adopt; exact-min } }); +test("user stop beats a stale snapshot when ranked candidates exist", () => { + // 原值: snapshotCurrent=false 排在 userStopped 之前 → collect_evidence,无采用卡 + // 新值: userStopped && ranked>0 先 complete_with_range,session_outcome ∈ ADOPT_OUTCOMES + // 原因: BUG-579 点「先这样」后快照过期把对话拖进采集死胡同 + const probe = selectDiscriminatorProbe(buildCandidateContrastPacket({ + candidateSetVersion: "04:48-04:49:04:48,04:49", + calculationResultId: CASE_ID, + engineProbes: [{ + semantic_key: "career.2018.dasha_activation", + candidate_split_hash: "career:2018:04:48|04:49", + domain: "career", + year: 2018, + user_meaning: "2018 年前后职责有没有明显加重?", + information_gain: 0.4, + expected_outcomes: [ + { answer_class: "yes", supports: ["04:48"], conflicts: ["04:49"] }, + { answer_class: "no", supports: ["04:49"], conflicts: ["04:48"] }, + ], + }], + vargaDifferences: [], + })); + assert.ok(probe); + const stopped = decideWithEngineCeiling(ENGINE_OPEN, { + snapshotCurrent: false, + userStopped: true, + discriminatorProbe: probe, + candidateScores: SEPARATED, + }); + assert.equal(stopped.nextAction, "complete_with_range"); + assert.equal(stopped.stopReason, null); + assert.ok(ADOPT_OUTCOMES.has(stopped.sessionOutcome)); + assert.equal(publicCanAdopt(stopped), true); + + const continuing = decideWithEngineCeiling(ENGINE_OPEN, { + snapshotCurrent: false, + userStopped: false, + discriminatorProbe: probe, + candidateScores: SEPARATED, + }); + assert.equal(continuing.nextAction, "ask_candidate_discriminator"); + assert.equal(continuing.sessionOutcome, "discriminate_candidates"); +}); + test("raw engine receipt contradictions fail closed before delivery", () => { const openReceipt = { acceptance_allowed: true, diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 6350420d..bda1df21 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -405,6 +405,13 @@ function accidentDossier(extra: { ...ASKED_PROBES.map(eventProbeRow), ...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []), ], + oos_blind_prompts: extra.holdoutUnavailable + ? [] + : [{ + domain: "career", + user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?", + used_for_scoring: false, + }], }, }, case: { acceptedTime: null, status: "collecting_evidence" }, @@ -848,10 +855,9 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy const persisted = idle as Awaited>; const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(focusCalls.length > 0, true); - assert.match( - `${String(focusCalls[0]?.args.p_question_id ?? "")} ${String(focusCalls[0]?.args.p_intent ?? "")}`, - /holdout|out_of_sample|reverse_verify/, - ); + assert.equal(focusCalls[0]?.args.p_intent, "collect_method_evidence"); + assert.equal(focusCalls[0]?.args.p_target_domain, "career"); assert.equal(gateAppendCalls(accounting.calls).length, 0); assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE); + assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/); }); diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index de134044..f5944899 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -200,10 +200,8 @@ test("dated holdout asks validation with a renderable followup card", () => { assert.equal(decision.canConfirmExactMinute, false); const plan = holdoutFollowup(dossier); - assert.ok(plan.next_followup); - assert.ok(plan.next_followup.choice_frame, "holdout card must be renderable"); - assert.equal(plan.next_followup.choice_frame.scoring, false); - assert.ok(plan.next_followup.choice_frame.prompt); + // BUG-580: 账本已覆盖 holdout 候选领域时不再出盘外核对卡,直接交付。 + assert.equal(plan.next_followup, null); }); test("passed holdout is a validated range, not a unique minute", () => { diff --git a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts index c64035bb..3339e4ff 100644 --- a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts +++ b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts @@ -10,6 +10,8 @@ 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 { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts"; function existenceProbe( domain: DiscriminatingEventProbe["domain"], @@ -157,3 +159,45 @@ test("unanchored D10 varga_style cards are dropped; anchored cards mention the l assert.match(anchored.next_followup?.user_prompt_hint ?? "", /2018 年 7 月/); assert.doesNotMatch(anchored.next_followup?.choice_frame?.prompt ?? "", /2018/); }); + +test("after a D9-style answer the compare request body stays legal", () => { + const hash = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15"; + assert.equal(hash.length, 128); + const receipt = { + inference_state: { + answered_probes: [{ + probe_id: "contrast:varga.d9.相处", + semantic_key: "varga.d9.巨蟹座/狮子座", + candidate_split_hash: hash, + answer_class: "yes", + classified_from: "choice", + }], + }, + }; + const asked = askedSemanticKeysForEngine(receipt, []); + assert.equal(asked.includes(hash), false); + assert.ok(asked.every((key) => key.length <= 120 && !key.includes(":varga."))); + const body = engineRequestBody({ + baselineBirthSnapshot: { + birth_date: "1997-08-08", + latitude: 36.42, + longitude: 114.21, + timezone_offset: 8, + }, + candidateRange: { start_time: "04:45", end_time: "05:15" }, + events: toEngineEvents([{ + id: "00000000-0000-4000-8000-000000000001", + sourceTurnId: "33333333-3333-4333-8333-333333333333", + subject: "self", + eventKind: "education_start", + domain: "education", + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + datePrecision: "month", + summary: "大学入学", + }]), + askedProbeKeys: asked, + }); + const keys = (body.asked_probe_keys as string[] | undefined) ?? []; + assert.ok(keys.every((key) => key.length <= 120 && !key.includes(":varga."))); +}); diff --git a/frontend/tests/rectification-stale-compare-fix-20260907.test.ts b/frontend/tests/rectification-stale-compare-fix-20260907.test.ts new file mode 100644 index 00000000..99680bb2 --- /dev/null +++ b/frontend/tests/rectification-stale-compare-fix-20260907.test.ts @@ -0,0 +1,331 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test, { afterEach } from "node:test"; + +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + ADOPT_OUTCOMES, + RECTIFICATION_TERMINATION_COPY, +} from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { + RECTIFICATION_USER_COPY, + withCompareFailedRetryNotice, + withLastSuccessfulCompareNotice, +} from "../src/lib/rectification-agentic/user-copy.ts"; +import { + applyRectificationChoice, + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { resetStaleMinuteRescoreAttemptsForTests } from "../src/lib/rectification-agentic/v9/block-scan-answer.ts"; +import { STOP_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts"; +import { parseToolActivityDetail } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + CASE_ID, + CANDIDATE_ID, + FOCUS_ID, + RESULT_ID, + SECOND_CANDIDATE_ID, + SESSION_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + candidateSnapshotFixture, + computeFixture, + conversationSummaryFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const QUESTION_ID = "question-1"; + +afterEach(() => { + resetStaleMinuteRescoreAttemptsForTests(); +}); + +function scoreableEvidenceRows() { + return [ + { + id: "44444444-4444-4444-8444-444444444441", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_start", + domain: "education", + occurred_from: "2016-09-01", + occurred_to: "2016-09-30", + date_precision: "month", + summary: "education start", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:00.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444442", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "career_entry", + domain: "career", + occurred_from: "2018-07-01", + occurred_to: null, + date_precision: "month", + summary: "career entry", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:00.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444443", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_start", + domain: "relationship", + occurred_from: "2021-05-01", + occurred_to: null, + date_precision: "month", + summary: "relationship start", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:00.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444444", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "family_event", + domain: "family", + occurred_from: "2023-03-01", + occurred_to: null, + date_precision: "month", + summary: "family event", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-09-07T00:00:00.000Z", + }, + ]; +} + +function staleDossier(extra: { status?: string; activeFocus?: ReturnType | null } = {}) { + const evidence = scoreableEvidenceRows(); + const inference = buildInferenceState({ + range_start: "04:45", + range_end: "05:15", + candidates: [ + { id: "05:02", time: "05:02", relative_support: 58 }, + { id: "04:55", time: "04:55", relative_support: 42 }, + ], + events: [ + { id: "e1", domain: "education", year: 2016, precision: "month" }, + { id: "e2", domain: "career", year: 2018, precision: "month" }, + { id: "e3", domain: "relationship", year: 2021, precision: "month" }, + { id: "e4", domain: "family", year: 2023, precision: "month" }, + ], + probes: [], + }); + return dossierFixture({ + status: extra.status ?? "collecting_evidence", + evidence, + latestResult: candidateSnapshotFixture({ + evidenceLedgerFingerprint: "b".repeat(64), + representativeTime: "05:02", + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: inference, + }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: extra.activeFocus === undefined + ? activeFocusFixture({ + questionId: QUESTION_ID, + expectedAnswerSchema: { + choice: { + prompt: "平时相处更接近哪一种?", + option_a: "照顾对方感受", + option_b: "习惯自己拿主意", + option_c: "两种都有", + option_d: "说不好", + options: [ + { key: "A", label: "照顾对方感受", answer_class: "yes" }, + { key: "B", label: "习惯自己拿主意", answer_class: "weak_yes" }, + { key: "C", label: "两种都有", answer_class: "no" }, + { key: "D", label: "说不好", answer_class: "unsure" }, + ], + }, + probe_id: "p-d9", + semantic_key: "varga.d9.style", + scoring: true, + }, + }) + : extra.activeFocus, + }), + }); +} + +function scoreEnginePayload() { + return { + success: true, + endpoint: "rectification_v5_score", + result_id: RESULT_ID, + algorithm_version: "rectification-event-contract-v2", + event_contract_version: "rectification-event-contract-v2", + decision_policy_version: "rectification-candidate-policy-v2", + execution_ledger_version: "rectification-execution-ledger-v2", + candidate_decisions: [ + { candidate_id: CANDIDATE_ID, time: "05:02", rank: 1, relative_support: 58, tied_minute_count: 1 }, + { candidate_id: SECOND_CANDIDATE_ID, time: "04:55", rank: 2, relative_support: 42, tied_minute_count: 1 }, + ], + decision_receipt: { + receipt_version: "candidate-decision-receipt-v2", + contract_version: "v2", + event_contract_version: "rectification-event-contract-v2", + policy_version: "rectification-candidate-policy-v2", + decision_policy_version: "rectification-candidate-policy-v2", + display_allowed: true, + selection_allowed: true, + acceptance_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + accept_allowed: true, + confirm_allowed: false, + representative_candidate_id: CANDIDATE_ID, + representative_time: "05:02", + overall_confidence: "high", + margin_percent: 16, + }, + execution_ledger: [ + { ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" }, + ], + }; +} + +test("compare failure copy and receipt detail stay user-visible without PII", () => { + assert.equal( + withCompareFailedRetryNotice("这条记下了。"), + `这条记下了。\n\n${RECTIFICATION_USER_COPY.compareFailedRetry}`, + ); + assert.equal( + withLastSuccessfulCompareNotice("目前范围 04:45–05:15。"), + `目前范围 04:45–05:15。\n\n${RECTIFICATION_USER_COPY.lastSuccessfulCompareRange}`, + ); + const detail = parseToolActivityDetail({ + result_fingerprint: JSON.stringify({ + safe_error_code: "engine_request_failed", + engine_message: "asked_probe_keys[0] must be a non-empty string up to 120 characters", + }), + }); + assert.equal(detail?.safe_error_code, "engine_request_failed"); + assert.match(String(detail?.engine_message), /asked_probe_keys/); + const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8"); + assert.match(agentRun, /withCompareFailedRetryNotice/); + assert.match(agentRun, /rectification-compare-candidates/); + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + assert.match(tools, /engine_message: engineMessageForReceipt/); +}); + +test("idle persist on a stale snapshot calls candidate score once", async () => { + let scoreCalls = 0; + const previous = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/rectification/v5/score")) { + scoreCalls += 1; + return { + ok: true, + status: 200, + json: async () => scoreEnginePayload(), + }; + } + throw new Error(`unexpected fetch ${url}`); + }) as typeof fetch; + try { + const raw = staleDossier({ activeFocus: null }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => computeFixture(), + persist_agentic_rectification_candidate_v2: (_fn, args) => ({ + result_id: RESULT_ID, + candidates: args.p_candidates, + overall_confidence: "medium", + selection_allowed: true, + confirmation_allowed: false, + representative_time: "05:02", + evidence_ledger_fingerprint: args.p_evidence_ledger_fingerprint, + candidate_range_fingerprint: args.p_candidate_range_fingerprint, + skill_version: args.p_skill_version, + algorithm_version: args.p_algorithm_version, + event_contract_version: args.p_event_contract_version, + decision_policy_version: args.p_decision_policy_version, + decision_receipt: args.p_decision_receipt, + execution_ledger: args.p_execution_ledger, + created_at: "2026-09-07T00:00:00.000Z", + }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + }); + await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + askedTurnId: TURN_ID, + }); + assert.equal(scoreCalls, 1); + await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + askedTurnId: TURN_ID, + }); + assert.equal(scoreCalls, 1); + } finally { + globalThis.fetch = previous; + } +}); + +test("STOP on a stale snapshot rescores then delivers a range", async () => { + const previous = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("engine down"); + }) as typeof fetch; + try { + const raw = staleDossier(); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => computeFixture(), + apply_agentic_rectification_choice_action: (_fn, args) => ({ + action_id: args.p_action_id, + status: "applied", + idempotent: false, + question_id: args.p_question_id, + option_id: args.p_option_id, + probe_id: "p-d9", + revision: Number(args.p_expected_revision) + 1, + source_quote: args.p_source_quote, + derived_context: args.p_derived_context, + narration: args.p_narration, + focus_status: args.p_focus_status, + }), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }), + }); + const applied = await applyRectificationChoice(accounting.client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: STOP_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + optionId: "stop", + expectedRevision: 1, + }); + assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome)); + assert.equal(applied.nextAction.can_adopt, true); + assert.match(applied.narration, new RegExp(RECTIFICATION_USER_COPY.lastSuccessfulCompareRange)); + assert.ok(applied.narration.includes(RECTIFICATION_TERMINATION_COPY) || applied.narration.includes("范围")); + } finally { + globalThis.fetch = previous; + } +}); diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index b9b019a7..1eac86ff 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -3,13 +3,16 @@ import test from "node:test"; import { RectificationEngineError, + engineRequestBody, mergeVedastroValidateIntoReceipt, runV9CandidateScore, runV9Diagnostics, runV9VedastroValidate, + sanitizeAskedProbeKeysForEngine, toEngineEvents, type V9EngineScoreResult, } from "../src/lib/rectification-agentic/v9/engine-client.ts"; +import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; const RANGE = { start_time: "04:50", end_time: "05:10" }; const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881"; @@ -476,3 +479,35 @@ test("vedastro-validate keeps safe timeout, HTTP, and invalid-response failure c globalThis.fetch = previous; } }); + +const VARGA_SPLIT_HASH = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15"; + +test("engineRequestBody drops varga split hashes and keeps short semantic keys", () => { + assert.equal(VARGA_SPLIT_HASH.length, 128); + const receipt = { + inference_state: { + answered_probes: [{ + probe_id: "contrast:varga.d9.style", + semantic_key: "varga.d9.style", + candidate_split_hash: VARGA_SPLIT_HASH, + answer_class: "yes", + classified_from: "choice", + }], + }, + }; + const semantic = askedSemanticKeysForEngine(receipt, []); + assert.deepEqual(semantic, ["varga.d9.style"]); + assert.equal(semantic.includes(VARGA_SPLIT_HASH), false); + const body = engineRequestBody({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), + askedProbeKeys: [VARGA_SPLIT_HASH, "varga.d9.style", "k".repeat(201)], + }); + const keys = body.asked_probe_keys as string[]; + assert.ok(Array.isArray(keys)); + assert.equal(keys.includes(VARGA_SPLIT_HASH), false); + assert.equal(keys.some((key) => key.includes(":varga.")), false); + assert.ok(keys.every((key) => key.length <= 120)); + assert.deepEqual(sanitizeAskedProbeKeysForEngine([VARGA_SPLIT_HASH, "varga.d9.style"]), ["varga.d9.style"]); +}); diff --git a/frontend/tests/rectification-yearless-ungrounded.test.ts b/frontend/tests/rectification-yearless-ungrounded.test.ts index 8e44cb4c..c3c4f232 100644 --- a/frontend/tests/rectification-yearless-ungrounded.test.ts +++ b/frontend/tests/rectification-yearless-ungrounded.test.ts @@ -281,15 +281,17 @@ test("yearless cards cannot keep period-presupposing option copy; oos_blind with ], sessionOutcome: "validate_holdout", oosBlindPrompts: [{ - domain: "family", - user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?", + domain: "health_pressure", + user_meaning: "身体或压力这条线还没用过。有没有记得大概时间的健康变化?", used_for_scoring: false, }], candidatesSeparated: true, }); assert.ok(plan.next_followup); assert.equal(plan.next_followup!.choice_frame, null); - assert.equal(plan.next_followup!.source, "oos_blind"); + assert.equal(plan.next_followup!.intent, "collect_method_evidence"); + assert.equal(plan.next_followup!.source, "method_coverage"); + assert.equal(plan.next_followup!.domain, "health_pressure"); }); function completeAndCheck(): boolean { diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 7e427575..938949ea 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]: fingerprint = sha256({ key: value for key, value in request.items() - if key != "asked_probe_keys" + if key not in {"asked_probe_keys", "dropped_asked_probe_keys"} }) result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")) candidate_decisions = build_candidate_decisions( diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 3850fc5f..e5b3679a 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -54,6 +54,7 @@ _REQUEST_FIELDS = frozenset({ "birth_date", "start_time", "end_time", "lat", "lon", "tz", "events", "ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks", }) | _REQUEST_PROVENANCE_FIELDS +ASKED_PROBE_KEY_MAX_LENGTH = 200 _EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS _CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z") _MINUTES_PER_DAY = 24 * 60 @@ -167,6 +168,7 @@ class RectificationRequest(TypedDict): timezone_source: NotRequired[str | None] local_time_status: NotRequired[str | None] asked_probe_keys: NotRequired[list[str]] + dropped_asked_probe_keys: NotRequired[int] minute_step: NotRequired[int] blocks: NotRequired[list[dict[str, Any]]] @@ -335,17 +337,23 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> raise ValueError("asked_probe_keys must contain between 0 and 200 strings") cleaned_keys: list[str] = [] seen: set[str] = set() + dropped = 0 for index, item in enumerate(asked): - if not isinstance(item, str) or not item.strip() or len(item.strip()) > 120: + if not isinstance(item, str) or not item.strip(): raise ValueError( - f"asked_probe_keys[{index}] must be a non-empty string up to 120 characters" + f"asked_probe_keys[{index}] must be a non-empty string up to {ASKED_PROBE_KEY_MAX_LENGTH} characters" ) key = item.strip() + if len(key) > ASKED_PROBE_KEY_MAX_LENGTH: + dropped += 1 + continue if key in seen: continue seen.add(key) cleaned_keys.append(key) cleaned_request["asked_probe_keys"] = cleaned_keys + if dropped: + cleaned_request["dropped_asked_probe_keys"] = dropped if "minute_step" in body: minute_step = body.get("minute_step") if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15: diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index 501bb6d2..ec9f61eb 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -706,6 +706,7 @@ def build_decision_receipt( "candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [], "holdout_validation_probes": packet.get("holdout_validation_probes") or [], "dropped_probes": packet.get("dropped_probes") or [], + "dropped_asked_probe_keys": int(request.get("dropped_asked_probe_keys") or 0), "prospective_probes": packet.get("prospective_probes") or [], "horary_observation": build_horary_observation(request), "unique_minute_claim": False, diff --git a/tests/test_rectification_input_contract.py b/tests/test_rectification_input_contract.py index 3846bce4..5a86b668 100644 --- a/tests/test_rectification_input_contract.py +++ b/tests/test_rectification_input_contract.py @@ -1,47 +1,65 @@ -from scripts.rectification_input_contract import ( - candidate_input_fingerprint, - canonical_birth_input, - semantic_evidence_hash, - stability_probe_contract, +from __future__ import annotations + +import unittest +from datetime import date + +from scripts.rectification.contracts import normalize_rectification_request + +EVENT_ID = "00000000-0000-4000-8000-000000000001" +VARGA_SPLIT_HASH = ( + "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15" + ":varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15" ) -CASE = { - "year": 1990, - "month": 1, - "day": 1, - "hour": 12, - "minute": 0, - "lat": 0.0, - "lon": 0.0, - "tz": 0.0, -} + +def request(): + return { + "birth_date": "1997-08-08", + "start_time": "05:13", + "end_time": "05:15", + "lat": 36.419, + "lon": 114.213, + "tz": 8, + "events": [{ + "id": EVENT_ID, + "domain": "education", + "event_kind": "education_milestone", + "date_start": "2016-09-01", + "date_end": "2016-09-30", + "precision": "month", + "summary": "大学入学", + }], + } -def test_contract_uses_deployed_mean_node_default_and_stable_identity() -> None: - reordered = {key: CASE[key] for key in reversed(CASE)} +class RectificationInputContractTest(unittest.TestCase): + def test_canonical_varga_split_hash_is_128_characters(self): + self.assertEqual(len(VARGA_SPLIT_HASH), 128) - assert canonical_birth_input(CASE)["node_mode"] == "mean" - assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint(reordered) - assert candidate_input_fingerprint(CASE) == candidate_input_fingerprint({**CASE, "nodeMode": "MEAN"}) + def test_128_char_asked_probe_key_is_kept(self): + cleaned = normalize_rectification_request( + {**request(), "asked_probe_keys": [VARGA_SPLIT_HASH]}, + today=date(2026, 7, 28), + ) + self.assertIn(VARGA_SPLIT_HASH, cleaned["asked_probe_keys"]) + self.assertNotIn("dropped_asked_probe_keys", cleaned) + + def test_asked_probe_key_over_200_is_skipped_and_counted(self): + long_key = "k" * 201 + cleaned = normalize_rectification_request( + {**request(), "asked_probe_keys": [long_key, "career.2018.05.dasha_boundary"]}, + today=date(2026, 7, 28), + ) + self.assertEqual(cleaned["asked_probe_keys"], ["career.2018.05.dasha_boundary"]) + self.assertEqual(cleaned["dropped_asked_probe_keys"], 1) + + def test_empty_asked_probe_key_is_still_rejected(self): + with self.assertRaises(ValueError): + normalize_rectification_request( + {**request(), "asked_probe_keys": [" "]}, + today=date(2026, 7, 28), + ) -def test_candidate_fingerprint_changes_with_calculation_input() -> None: - assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "minute": 1}) - assert candidate_input_fingerprint(CASE) != candidate_input_fingerprint({**CASE, "node_mode": "true"}) - - -def test_stability_contract_records_adjacent_probes_without_confirming() -> None: - contract = stability_probe_contract(CASE) - - assert [probe["offset_minutes"] for probe in contract["probes"]] == [-5, -2, -1, 1, 2, 5] - assert contract["minute_confirmation_allowed"] is False - assert contract["status"] == "pending_score_comparison" - - -def test_semantic_hash_normalizes_only_known_order_insensitive_lists() -> None: - left = {"aspects": {"gives": ["Mars", "Saturn"]}, "ordered_scores": [2, 1]} - reordered_aspects = {"ordered_scores": [2, 1], "aspects": {"gives": ["Saturn", "Mars"]}} - reordered_scores = {"ordered_scores": [1, 2], "aspects": {"gives": ["Saturn", "Mars"]}} - - assert semantic_evidence_hash(left) == semantic_evidence_hash(reordered_aspects) - assert semantic_evidence_hash(left) != semantic_evidence_hash(reordered_scores) +if __name__ == "__main__": + unittest.main()