fix(rectification): keep compare requests valid after style cards (BUG-577–580)
Engine asked_probe_keys no longer include varga split hashes that 400 the scorer, failed compares become visible and retry, user stop can still deliver a range on a stale snapshot, and holdout no longer reasks domains already in the ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-07 — 性格卡答完后比较不再静默失败,停止时仍能给出当前范围
|
||||
|
||||
生时校正答完性格或月宿对照卡后,后面的候选比较不再被过长的内部键挡掉。比较没跑成时会直接说明,并在下一句话自动再试。点「先这样,先看当前范围」时,即使最新比较还没跟上账本,也会按已有候选给范围;如果只能沿用上一次成功比较,旁白会标明。已经说过的带年月领域不会再被拿来改写成「再说一件事」。Skill 版本仍是 10.0.14。
|
||||
|
||||
## 2026-09-07 — 出生时间有多确定只问一次,校正按你选的范围去比
|
||||
|
||||
填报出生资料时问一次「你对这个时间有多确定」:有医院记录、家人记得大概时间(差不多准 / 前后半小时 / 一小时 / 两小时),或只知道时段、完全不清楚。校正直接读档案里的范围,不再追问。家人说前后一小时,顶部就是两小时;前后两小时或傍晚这种超过两小时的范围,先切成三段再比。按你说的经历对不上、时间又贴着窗口边上时,会出一张卡问要不要放宽,放宽后设置页的范围跟着变。日级经历会补问一句日子是查过记录还是凭记忆。文案不用「偏移 / 误差 / 置信度 / 概率」。Skill 版本仍是 10.0.14。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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`。
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
虚构走查。不要填真实姓名、出生资料或真实经历。对应 `TASK-rectification-declared-uncertainty-20260907.md` 决策 9。
|
||||
|
||||
测之前先看 `rectification-declared-uncertainty-20260907.md` 第 0 条。
|
||||
测之前先看本文件第 0 条。
|
||||
|
||||
## 0. 性格卡之后再说一件事
|
||||
|
||||
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
|
||||
|
||||
开场后依次说三件不同领域、带年月的虚构经历,等到出现候选比较和性格对照卡。答完一张性格卡后,再说一件带年月的事。
|
||||
|
||||
期望:
|
||||
|
||||
- 顶部范围必须变化,或旁白说明这次比较没跑成、下一句会再试
|
||||
- 不得出现:比较失败后仍只写「记下了、很有帮助」,顶部范围完全不动
|
||||
- 点「先这样,先看当前范围」后必须出现候选卡或当前范围,不得只剩「没有拿到下一个问题」
|
||||
|
||||
## 1. 范围:家人说两点到四点
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
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,
|
||||
|
||||
@@ -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<V9Agen
|
||||
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
|
||||
}
|
||||
|
||||
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed") {
|
||||
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
|
||||
}
|
||||
|
||||
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
|
||||
try {
|
||||
const latest = await loadV9CaseDossier(accounting, userId, caseId);
|
||||
|
||||
@@ -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 = <T extends { hostNarration: string | null }>(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,
|
||||
|
||||
@@ -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<string, true>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof previousInferenceFromReceipt>,
|
||||
): 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)
|
||||
|
||||
@@ -454,7 +454,30 @@ function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<strin
|
||||
return record(data.diagnostics) ?? {};
|
||||
}
|
||||
|
||||
function engineRequestBody(input: {
|
||||
export const ENGINE_ASKED_PROBE_KEY_MAX_LENGTH = 200;
|
||||
|
||||
export function sanitizeAskedProbeKeysForEngine(
|
||||
keys: readonly string[] | null | undefined,
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
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<Record<string, unknown>>;
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ export function askedProbeKeysFromReceipt(
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function askedSemanticKeysFromReceipt(
|
||||
receipt: Readonly<Record<string, unknown>> | 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<string>();
|
||||
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<Record<string, unknown>> | null | undefined,
|
||||
evidence: readonly Readonly<{
|
||||
@@ -74,6 +95,31 @@ export function askedDiscriminatorKeys(
|
||||
];
|
||||
}
|
||||
|
||||
export function askedSemanticKeysForEngine(
|
||||
receipt: Readonly<Record<string, unknown>> | 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<string>();
|
||||
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(
|
||||
|
||||
@@ -537,6 +537,12 @@ function declinedDomains(
|
||||
return domains;
|
||||
}
|
||||
|
||||
export function declinedCollectDomains(
|
||||
topics: readonly Readonly<Record<string, unknown>>[] | undefined,
|
||||
): Set<string> {
|
||||
return declinedDomains(topics ?? []);
|
||||
}
|
||||
|
||||
function domainCollectFocusAsked(
|
||||
topics: readonly Readonly<Record<string, unknown>>[],
|
||||
domain: string,
|
||||
@@ -1649,32 +1655,16 @@ function holdoutAskFields(
|
||||
prompt: OosBlindPrompt | null | undefined,
|
||||
reserved: Readonly<{ domain: string; year: number | null }> | null,
|
||||
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | 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<string>,
|
||||
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | 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);
|
||||
}
|
||||
|
||||
@@ -871,6 +871,7 @@ export function parseToolActivityDetail(activity: Readonly<Record<string, unknow
|
||||
const detail: Record<string, unknown> = {};
|
||||
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<Record<string, unknow
|
||||
if (typeof parsed.reason === "string" && parsed.reason.trim()) {
|
||||
detail.reason = parsed.reason.trim();
|
||||
}
|
||||
if (typeof parsed.safe_error_code === "string" && parsed.safe_error_code.trim()) {
|
||||
detail.safe_error_code = parsed.safe_error_code.trim();
|
||||
}
|
||||
if (typeof parsed.engine_message === "string" && parsed.engine_message.trim()) {
|
||||
detail.engine_message = parsed.engine_message.trim().slice(0, 120);
|
||||
}
|
||||
for (const key of ["engine_compare_ms", "vedastro_validate_ms", "persist_ms"] as const) {
|
||||
const value = parsed[key];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<typeof dossierFixture>) {
|
||||
const parsed = parseV9CaseDossier(raw);
|
||||
if (!parsed) return raw;
|
||||
const latest = raw.latest_result && typeof raw.latest_result === "object"
|
||||
? {
|
||||
...(raw.latest_result as Record<string, unknown>),
|
||||
evidence_ledger_fingerprint: evidenceLedgerFingerprint(parsed.evidence),
|
||||
}
|
||||
: raw.latest_result;
|
||||
return { ...raw, latest_result: latest };
|
||||
}
|
||||
|
||||
function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>) {
|
||||
return dossierFixture({
|
||||
return withCurrentEvidenceFingerprint(dossierFixture({
|
||||
latestResult: candidateSnapshotFixture({
|
||||
decisionReceipt: { inference_state: inference },
|
||||
}),
|
||||
@@ -253,15 +269,16 @@ function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function choiceDossier() {
|
||||
function choiceDossier(evidence?: ReturnType<typeof fourEventRows>) {
|
||||
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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof persistNextInterviewIfIdle>>;
|
||||
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 ?? "", /入职|换工作|工作/);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.")));
|
||||
});
|
||||
|
||||
@@ -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<typeof activeFocusFixture> | 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;
|
||||
}
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user