fix(rectification): keep dated occupation answers scoreable (BUG-649/650)
Independent Staging Quality Gate / validate (push) Successful in 13m26s
Independent Staging Quality Gate / publish (push) Successful in 9m33s

Occupation collect was wiping year-month into an unscored note, and idle gap copy never joined the evidence turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-11 11:49:52 +08:00
co-authored by Cursor
parent e36bda53fc
commit 1fa994ea63
13 changed files with 1368 additions and 45 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-11 — 职业题答出的年月会记成可评分的工作经历
生时校正问「你平时主要做什么工作?」时,如果一并说出开始年月,会同时记下职业备注和一件可评分的入职经历,不再把年月抹掉。只说工种、没有年月时,仍只记备注、不拿它凑三件。开始年份改由毕业后第一份工作这类锚定题来问。Skill 版本不变。
## 2026-09-10 — 生时校正先等你说完,再从你说过的事往下问;三件就能开始筛
生时校正开场后先请你说记得的经历,再说一批就问「还有吗」。你说「没有了」之后,才从你已经提到的年份往下追(例如毕业那年之后的第一份工作),不再用生日推「某年前后」。三件带月、两类经历就可以开始筛;第四件起才留一件作对照。材料不够时只写还缺哪类具体事,输入框仍开放,不会说「做不了」。Skill 10.0.23。
+32
View File
@@ -10084,3 +10084,35 @@
- 相关记录:BUG-642、BUG-546、BUG-641、BUG-646
- 复发自:BUG-642(年份线索口径错误)
- 修复版本:`dd8f35f7`
## BUG-649 | 职业采集题答出的年月被抹成不计分备注
- 状态:resolved
- 首次发现:2026-09-11
- 最近更新:2026-09-11
- 影响面:`applyOccupationCollectLedgerNorm``rectification-v9-tools` batch/propose、职业采集焦点
- 用户现象:职业题被问到开始年份并答出年月后,助手只回「记下了」,训练门仍关,账本只有不计分的职业备注。
- 触发条件:职业采集焦点下回答带年月的工作开始(例如某年某月开始做某一行)。
- 根因:BUG-442 P0 把职业焦点下任何 career/occupation 条目一律改成 `occupation_note` 且精度 `unknown`、日期置空。`occupation_note` 不计训练门。batch/propose 又用 `??` 把归一后的 null 日期回落成原日期,出现「unknown 却带日期」的账本行。
- 修复:无年月仍只写 `occupation_note`(覆盖判定仍不读正文、不用关键词、不依赖模型 domain)。带年月时另记一条 `domain=career` 主评分事件,kind 沿用模型给出的 career kind,否则 `career_entry`。归一后的 null 不再用 `??` 回落。职业通用题不问年份;开始年份走 career 锚定题。
- 验证:`frontend/tests/rectification-occupation-dated-answer-20260911.test.ts``frontend/tests/rectification-replay-20260911.test.ts``frontend/tests/rectification-collection-question-pool.test.ts`
- 防复发:职业焦点下无日期仍不得把 `occupation_note` 算进 3 件;带日期必须另有 scoreable career 行;源码合同禁止 `?? item.occurredFrom`
- 相关记录:BUG-442、BUG-356、BUG-389、BUG-646、BUG-647、BUG-648、BUG-650
- 复发自:BUG-442(职业覆盖归一过宽)
- 修复版本:待发布
## BUG-650 | 工具轮结束时精确缺口句未并入本轮正文
- 状态:resolved
- 首次发现:2026-09-11
- 最近更新:2026-09-11
- 影响面:`persistNextInterviewIfIdle``composeIdleGapIntoSpoken``agent-run` 证据轮出口
- 用户现象:训练门仍关、收集池已空时,工具轮结束只见「记下了」,没有精确缺口句,客户端像停住。
- 触发条件:证据轮有 `turnId`(因此不会走 `persistExhaustionGateTurn`),且 idle 路径写出了精确缺口。
- 根因:BUG-596 禁止再开第二条门槛 turn。缺口只停在 idle 的 `hostNarration`,没有并入本轮 `p_assistant_message`
- 修复:证据轮在 `trimSpokenTurnForInterview` 之后,若 idle 缺口含「现在记下的是 / 就能开始筛」,把缺口接到同一条复述后面。交付旁白不会被误接。
- 验证:`frontend/tests/rectification-replay-20260911.test.ts` 无日期职业变体;`rectification-v9-agent.test.ts` 交付轮仍裁成三句。
- 防复发:工具轮出口的缺口必须出现在本轮正文;不得为缺口再开一条 turn。
- 相关记录:BUG-646、BUG-596、BUG-649
- 复发自:BUG-596(禁止第二条门槛 turn 后缺口没并入正文)
- 修复版本:待发布
@@ -0,0 +1,47 @@
# PROGRESS · 职业题答出的年月不得被抹掉(2026-09-11)
工作树:`.worktrees/rectification-occupation-dated-answer-20260911`
分支:`codex/rectification-occupation-dated-answer-20260911`
任务书:`.worktrees/staging-docs/docs/tasks/TASK-rectification-occupation-dated-answer-20260911.md`(未推)
基线:`origin/staging` @ `e36bda53`(代码 `dd8f35f7`Skill 10.0.23BUG-646648
编号:开工时最大号 **BUG-648**;本单 **BUG-649**,工具轮缺口并入正文为 **BUG-650**
## 已完成
- **T1 / BUG-649**`applyOccupationCollectLedgerNorm` 在职业采集焦点下:无年月仍只写 `occupation_note`;带 `occurredFrom`/`occurredTo` 且精度非 unknown 时另写 `career` 主评分行(模型 career kind 沿用,否则 `career_entry`)。`rectification-v9-tools` batch/propose 按归一结果逐条写入,删除 `?? item.occurredFrom` 回落。覆盖判定仍不读正文、不用关键词、不依赖模型 domain。
- **T2**:通用职业题仍是「你平时主要做什么工作?」;毕业后第一份工作锚定题 `targetDomain=career``isOccupationCollectFocus` 为 false。系统提示已加「职业题不得自行追加年份;要问年份走 career 锚定题」。
- **T3**`rectification-replay-20260911.test.ts` 回放脱敏序列。两件学业后无生日「年前后」;四件带月 → holdout 1、训练 3、≥2 类、训练门开、下一问区分选择题且可渲染。无日期职业变体:训练门关、idle 缺口句、本轮正文含缺口、`collect_waiting`
- **T3 / BUG-650**`persistExhaustionGateTurn` 在已有 `turnId` 时不会跑。证据轮把精确缺口接到同一条复述后;交付旁白(无「现在记下的是 / 就能开始筛」)不拼接。
- **T4**BUG-649/650、CHANGELOG、本文件、走查第 22 条。Skill 10.0.23 未升。未改 `MIN_ACCEPTANCE_*` / 确认门 / 引擎计分。
## 三栏(既有断言)
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `applyOccupationCollectLedgerNorm` 带年月职业回答 | 一律 `occupation_note` + unknown + 日期 null | noteunknown/null+ career 主评分行 | 产品收窄 BUG-442 P0 |
| `occupation_note` 是否计入训练 3 件 | 不计 | 仍不计;计入的是 `career_entry` 等 | BUG-356 / 389 / 442 |
| batch/propose 日期 | `normalized?.occurredFrom ?? item.occurredFrom` | 归一后的 null 就是 null | 账本 unknown 却带日期 |
| 通用职业题 | 「你平时主要做什么工作?」 | 仍不问哪年 | T2 |
| 毕业后第一份工作焦点 | 用户年份锚定 | `domain=career`,不是 `collect:occupation:` | 问年份走锚定 |
| 证据轮工具出口缺口 | 只在 idle `hostNarration` | 并入本轮 `p_assistant_message` | BUG-650 |
## 验收
工作树内 `frontend/node_modules` 链到主仓以便跑 tsx。未部署;staging `/api/health` 仍是旧 SHA 时不要用真机当本单已上线。未 commit、未 push。
| 命令 | 结果 |
| --- | --- |
| `cd frontend && npx tsc --noEmit` | **0 error** |
| `cd frontend && npm run lint` | **0 error** / 119 warning(既有) |
| 相关 7 文件 `npx tsx --test --test-force-exit` | **95 pass / 0 fail**(含本单 2 个新文件) |
| `npm test` 全量 glob | 3119 / pass 3113 / **fail 6**:全是 Docker/Postgres 争用(migration failed、connection terminated、本机 socket 无 postgres),与本单无关。安静后未再全量重跑。 |
| `.venv/bin/python -m pytest tests/test_candidate_discriminator_contract.py -q` | **8 passed** |
| `run_quality_gate.py --profile quick` 第一次 | pytest **730 passed / 1 failed**`test_block_scan_seven_events_finishes_within_fifteen_seconds` 在与全量 npm test 并行时 18.2s > 15s |
| 同上测单独重跑 | **1 passed**3.9s |
| `run_quality_gate.py --profile quick --skip-frontend-runtime` | **Quality gate passed.** pytest **731 passed / 1 skipped / 0 fail**451s)。前端 tsc/lint/相关测试/webpack 构建已在门外单独跑。 |
| `cd frontend && ./node_modules/.bin/next build --webpack` | 退出 0;默认 Turbopack 仍拒 worktree 外 `node_modules` 软链。`/``○` Static。首页 JS gzip`index.html` 引用脚本去重后 `gzipSync`**572018 B / 18 chunks**。相对 `origin/staging` 未改 `page.tsx` / `layout.tsx` / `globals.css`,首屏源码差为 0。 |
| `git diff --check` | 干净 |
新增测试:**+18**dated-answer 9、replay 7、collection-pool 2);`occupation-coverage-exit` 未改断言。Skill 10.0.23 未改。
环境缺口:无登录态,走查第 22 条未做。部署后须先打开绑定 10.0.22 的历史校正(BUG-621),再按本案序列真机走一遍,第四件带月说完必须出选择题。
@@ -286,3 +286,20 @@
- 记下后重算,继续选择题或更新卡片
- 不得把流程关掉,不得说「做不了」
## 22. 职业题答年月
资料与开场同第 19 条。虚构经历:
1. 2016 年 9 月上大学
2. 2020 年 6 月毕业
3. 对「还有吗」回「没有了」
4. 若问到健康,答 2024 年 10 月一次身体事故
5. 职业题只问平时做什么;若被问到开始年份,那必须是毕业后工作的锚定题。职业题下答「2024 年 4 月开始做程序员」
期望:
- 职业描述仍覆盖职业方法;带年月的那件记成可评分的工作经历,不能只剩日期不明的备注
- 不得出现生日推出来的「某年前后」采集题
- 四件带月经历后应出现选择题,不得停在 collecting_evidence 且没有下一问
- 若职业只答工种、没有年月:训练门关时正文含精确缺口(记下的是…就能开始筛),例子来自工作/感情/家里,不说「领域」「做不了」「还差 N 件」,输入框仍开放
@@ -44,7 +44,7 @@ import {
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
} from "../user-copy";
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview } from "./collect-prompt";
import { stripQuestionSentences, stripVerbalWindowChange, trimSpokenTurnForInterview, composeIdleGapIntoSpoken } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
@@ -650,6 +650,9 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
if (action === "evidence") {
spokenAnswer = trimSpokenTurnForInterview(answerText, interviewIdle?.terminalNote === true);
if (interviewIdle?.terminalNote && interviewIdle.hostNarration) {
spokenAnswer = composeIdleGapIntoSpoken(spokenAnswer, interviewIdle.hostNarration);
}
if (spokenAnswer !== answerText) {
await emit({ type: "answer.delta", text: spokenAnswer, replace: true });
}
@@ -100,6 +100,20 @@ export function composeCollectSpokenAssistantText(body: string, prompt: string):
return `${stripped}${suffix}`;
}
/** Idle-path gap copy joins the existing evidence recap instead of a second turn. */
export function composeIdleGapIntoSpoken(body: string, gap: string): string {
const spoken = body.trim();
const gapText = gap.trim();
if (!gapText) return spoken;
if (!/就能开始筛|现在记下的是/.test(gapText)) return spoken;
if (!spoken) return gapText;
if (spoken.includes(gapText) || spoken.includes("就能开始筛") || spoken.includes("现在记下的是")) {
return spoken;
}
const prefix = /[!?]$/u.test(spoken) ? spoken : `${spoken}`;
return `${prefix}${gapText}`;
}
export function detachCollectSpokenAssistantText(body: string, prompt: string): string {
const stem = prompt.trim();
const spoken = body.trim();
@@ -377,7 +377,33 @@ export function isOccupationCollectFocus(
|| collectQuestion;
}
/** When the answered focus is occupation collect, do not trust the model domain. */
/** Career kinds that stay scoreable when an occupation collect answer also has a date. */
export const OCCUPATION_COLLECT_CAREER_KINDS: ReadonlySet<EvidenceKind> = new Set([
"career_entry",
"career_change",
"promotion",
"career_pressure",
"career_exit",
"business_start",
]);
function occupationCollectAnswerIsDated(item: {
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
}): boolean {
return item.datePrecision !== "unknown" && Boolean(item.occurredFrom || item.occurredTo);
}
function careerKindForOccupationCollect(kind: EvidenceKind): EvidenceKind {
return OCCUPATION_COLLECT_CAREER_KINDS.has(kind) ? kind : "career_entry";
}
/**
* Occupation collect still covers the method with an undated note and must not
* trust the model domain. A dated answer also keeps a scoreable career row.
* Coverage still keys off the closed occupation focus / note, never body text.
*/
export function applyOccupationCollectLedgerNorm<T extends {
domain: string;
eventKind: EvidenceKind;
@@ -386,15 +412,27 @@ export function applyOccupationCollectLedgerNorm<T extends {
occurredTo: string | null;
}>(focus: OccupationCollectFocus | null | undefined, items: readonly T[]): T[] {
if (!isOccupationCollectFocus(focus)) return [...items];
return items.map((item) => {
if (item.domain !== "career" && item.domain !== "occupation") return item;
return {
const next: T[] = [];
for (const item of items) {
if (item.domain !== "career" && item.domain !== "occupation") {
next.push(item);
continue;
}
next.push({
...item,
domain: "occupation",
eventKind: "occupation_note" as EvidenceKind,
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
};
});
});
if (occupationCollectAnswerIsDated(item)) {
next.push({
...item,
domain: "career",
eventKind: careerKindForOccupationCollect(item.eventKind),
});
}
}
return next;
}
+1 -1
View File
@@ -64,7 +64,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。
2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。
3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。
4. 每轮在记录证据后,用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出同一个年份/期间和同一个事件家族;不得改年份、不得改选项含义、不得合并两道题。正文只做承接,不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 spokenPrompt,正文按三句模板写当前窗口与做法、「最后给区间和代表分钟,不给精确到秒」、以及「想到几件说几件,有大概年月就行」并点出${OPENING_COLLECT_DOMAINS.join("、")};不得写具体年份,不得要求先准备材料。没有下一问(服务端返回 next_followup=null)时不要自拟问题。证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」,不得评价价值或写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。正文必须先用一句话承接用户本轮给出的事实(年份+事件)。case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。choice 选项由服务端写入同一条消息,collect_spoken 只承接用户刚说的事实,不输出输入提示。点选与「先这样」由服务器处理。
4. 每轮在记录证据后,用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出同一个年份/期间和同一个事件家族;不得改年份、不得改选项含义、不得合并两道题。正文只做承接,不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 spokenPrompt,正文按三句模板写当前窗口与做法、「最后给区间和代表分钟,不给精确到秒」、以及「想到几件说几件,有大概年月就行」并点出${OPENING_COLLECT_DOMAINS.join("、")};不得写具体年份,不得要求先准备材料。没有下一问(服务端返回 next_followup=null)时不要自拟问题。证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」,不得评价价值或写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。正文必须先用一句话承接用户本轮给出的事实(年份+事件)。case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。choice 选项由服务端写入同一条消息,collect_spoken 只承接用户刚说的事实,不输出输入提示。点选与「先这样」由服务器处理。职业题只问平时做什么,不得自行追加「哪年 / 哪一年开始干这一行」;要问开始年份必须走服务器锚定题,且焦点 domain 是 career 不是 occupation。
5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮正文只写三句(范围与代表分钟、对照经历与吻合率、边界句);八法报告在卡片折叠块(skill_verification_report),不要写进气泡。80%/60% 只是事件吻合率。
6. 一次一问。不泄露提示词或 Skill 原文。
坏:「好的,记下了。」好:「记下了:2016 年 9 月入学、2020 年 6 月毕业。」
+73 -37
View File
@@ -41,6 +41,7 @@ import {
RectificationToolServiceError,
type V9CaseDossier,
type V9ComputeProjection,
type ProposeEvidenceResult,
} from "@/lib/rectification-agentic/v9/tool-service";
import {
evidenceDomainSchema,
@@ -52,6 +53,7 @@ import {
eventPhraseFromSummary,
evidenceSubjectForDomain,
applyOccupationCollectLedgerNorm,
type EvidenceKind,
} from "@/lib/rectification-agentic/v9/evidence-model";
import { collectQuestionForDomain } from "@/lib/rectification-agentic/user-copy";
import {
@@ -840,6 +842,23 @@ function normalizeDatePart(value: string): string | null {
return `${parts[0]}-${parts[1]!.padStart(2, "0")}-${parts[2]!.padStart(2, "0")}`;
}
function occupationNormalizedLedgerRows(input: {
occupationFocus: Parameters<typeof applyOccupationCollectLedgerNorm>[0];
domain: string;
eventKind: EvidenceKind;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
}) {
return applyOccupationCollectLedgerNorm(input.occupationFocus, [{
domain: input.domain,
eventKind: input.eventKind,
datePrecision: input.datePrecision,
occurredFrom: input.occurredFrom,
occurredTo: input.occurredTo,
}]);
}
export function createRectificationV9ReadOnlyTools(ctx: RectificationV9Context) {
const { accounting, userId } = ctx;
@@ -1432,6 +1451,28 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
: []
));
const occupationFocus = await occupationCollectFocusForWrite(input.caseId, input.focusId ?? null);
const writeItems = scoringItems.flatMap(({ item, quote, quoteStart, quoteEnd }) => {
const rows = occupationNormalizedLedgerRows({
occupationFocus,
domain: item.domain,
eventKind: item.proposedKind as EvidenceKind,
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null,
occurredTo: item.occurredTo ? normalizeDatePart(item.occurredTo) : null,
});
return rows.map((normalized) => ({
quote,
quoteStart,
quoteEnd,
subject: evidenceSubjectForDomain(normalized.domain, item.subject),
eventKind: normalized.eventKind as Parameters<typeof recordV10EvidenceBatch>[5][number]["eventKind"],
domain: normalized.domain,
occurredFrom: normalized.occurredFrom,
occurredTo: normalized.occurredTo,
datePrecision: normalized.datePrecision,
summary: item.summary,
}));
});
const result = scoringItems.length === 0
? {
items: [...mismatchResults, ...holdoutResults],
@@ -1446,32 +1487,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
input.caseId,
turnId,
input.focusId ?? null,
scoringItems.map(({ item, quote, quoteStart, quoteEnd }) => {
const [normalized] = applyOccupationCollectLedgerNorm(occupationFocus, [{
domain: item.domain,
eventKind: item.proposedKind as Parameters<typeof recordV10EvidenceBatch>[5][number]["eventKind"],
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null,
occurredTo: item.occurredTo ? normalizeDatePart(item.occurredTo) : null,
}]);
return {
quote,
quoteStart,
quoteEnd,
subject: evidenceSubjectForDomain(normalized?.domain ?? item.domain, item.subject),
eventKind: (normalized?.eventKind ?? item.proposedKind) as Parameters<typeof recordV10EvidenceBatch>[5][number]["eventKind"],
domain: normalized?.domain ?? item.domain,
occurredFrom: normalized?.occurredFrom ?? (item.occurredFrom ? normalizeDatePart(item.occurredFrom) : null),
occurredTo: normalized?.occurredTo ?? (item.occurredTo ? normalizeDatePart(item.occurredTo) : null),
datePrecision: normalized?.datePrecision ?? item.datePrecision,
summary: item.summary,
};
}),
writeItems,
).then((recorded) => ({
items: [
...recorded.items.map((item, offset) => ({
...item,
index: scoringItems[offset]?.index ?? item.index,
index: writeItems[offset] ? offset : item.index,
})),
...holdoutResults,
...mismatchResults,
@@ -1506,13 +1527,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const rescore = result.acceptedCount > 0
? await autoRescoreAfterEvidenceChange(input.caseId)
: { status: "skipped" as const, executedMethods: [] as const, errorCode: null, cached: false, openQuestion: null };
const acceptedRecaps = scoringItems.flatMap(({ item }, offset) => {
const acceptedRecaps = writeItems.flatMap((item, offset) => {
const recorded = result.items[offset];
if (!recorded || recorded.outcome !== "accepted") return [];
if (item.datePrecision === "unknown") return [];
const label = displayDateLabel(
item.datePrecision,
item.occurredFrom ?? null,
item.occurredTo ?? null,
item.occurredFrom,
item.occurredTo,
);
const phrase = eventPhraseFromSummary(item.summary);
if (!label && !phrase) return [];
@@ -1602,24 +1624,38 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
const occurredFrom = input.occurredFrom ? normalizeDatePart(input.occurredFrom) : null;
const occurredTo = input.occurredTo ? normalizeDatePart(input.occurredTo) : null;
const occupationFocus = await occupationCollectFocusForWrite(input.caseId);
const [normalized] = applyOccupationCollectLedgerNorm(occupationFocus, [{
const rows = occupationNormalizedLedgerRows({
occupationFocus,
domain: input.domain,
eventKind: input.proposedKind,
datePrecision: input.datePrecision,
occurredFrom,
occurredTo,
}]);
const result = await proposeV9Evidence(accounting, userId, input.caseId, {
sourceTurnId: turnId,
quote: input.quote,
subject: evidenceSubjectForDomain(normalized?.domain ?? input.domain, input.subject),
eventKind: normalized?.eventKind ?? input.proposedKind,
domain: normalized?.domain ?? input.domain,
occurredFrom: normalized?.occurredFrom ?? occurredFrom,
occurredTo: normalized?.occurredTo ?? occurredTo,
datePrecision: normalized?.datePrecision ?? input.datePrecision,
summary: input.summary,
});
const proposed: ProposeEvidenceResult[] = [];
for (const normalized of rows) {
proposed.push(await proposeV9Evidence(accounting, userId, input.caseId, {
sourceTurnId: turnId,
quote: input.quote,
subject: evidenceSubjectForDomain(normalized.domain, input.subject),
eventKind: normalized.eventKind,
domain: normalized.domain,
occurredFrom: normalized.occurredFrom,
occurredTo: normalized.occurredTo,
datePrecision: normalized.datePrecision,
summary: input.summary,
}));
}
const preferredIndex = rows.findIndex((row, index) => (
row.eventKind !== "occupation_note" && proposed[index]?.outcome === "accepted"
));
const result = proposed[preferredIndex >= 0 ? preferredIndex : 0] ?? {
evidenceId: null,
status: "rejected",
outcome: "rejected" as const,
errorCode: "invalid_item",
idempotent: false,
};
await receipt("rectification-propose-evidence", "evidence.proposed", "completed", {
inputFingerprint,
resultFingerprint: hashResult(result),
@@ -9,6 +9,10 @@ import {
moreCollectHint,
preciseGapNarration,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts";
import { isOccupationCollectFocus } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { followupFromPoolItem } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
const educationStart = {
status: "confirmed",
@@ -101,3 +105,29 @@ test("more-collect hint after delivery never uses banned collect-flow phrases",
assert.match(hint, /范围还能再收一截/);
assertNoBanned(hint);
});
test("generic occupation collect does not ask for a year", () => {
assert.match(USER_COLLECT_QUESTION.occupation, /你平时主要做什么工作/);
assert.doesNotMatch(USER_COLLECT_QUESTION.occupation, /哪年|哪一年|年份/);
assert.doesNotMatch(USER_COLLECT_QUESTION_RETRY.occupation, /哪年|哪一年|年份/);
});
test("graduation job follow-up is a career anchor, not occupation collect", () => {
const asked = new Set<string>();
const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked);
const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020");
assert.ok(job);
assert.equal(job?.domain, "career");
assert.match(job?.prompt ?? "", /2020 年毕业后第一份工作/);
const followup = followupFromPoolItem(job!);
assert.equal(followup.domain, "career");
const questionId = stableFollowupQuestionId(followup);
assert.equal(questionId, "collect:anchor:education_completion:2020");
assert.equal(questionId.startsWith("collect:occupation:"), false);
assert.equal(isOccupationCollectFocus({
intent: followup.intent,
targetDomain: followup.domain,
targetKind: followup.kind_hint,
questionId,
}), false);
});
@@ -0,0 +1,446 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
applyOccupationCollectLedgerNorm,
isOccupationCollectFocus,
isPrimaryScoreableEvidence,
trainingScoreableGate,
type EvidenceKind,
} from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
CANDIDATE_ID,
FOCUS_ID,
SECOND_CANDIDATE_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const OCCUPATION_FOCUS = {
intent: "collect_method_evidence" as const,
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: "collect:occupation:collect_method_evidence",
};
const NOTE_ID = "44444444-4444-4444-8444-444444444451";
const CAREER_ID = "44444444-4444-4444-8444-444444444452";
const EDUCATION_ID = "44444444-4444-4444-8444-444444444441";
const ENGINE_SCORE = {
success: true,
endpoint: "rectification_v5_score",
result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62",
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: "04:50", rank: 1, relative_support: 57, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:51", rank: 2, relative_support: 25, tied_minute_count: 2 },
],
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: "04:50",
overall_confidence: "high",
margin_percent: 42.5,
gates: {
event_quality: { scoreable_event_count: 3, minimum: 3 },
domain_diversity: { domains: ["education", "career"], count: 2, minimum: 2 },
},
},
execution_ledger: [
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
],
diagnostics: {
window_scan: {
scanned: true,
confirmation_allowed: false,
unique_minute_claim: false,
d9_lagna_count: 2,
d10_lagna_count: 1,
d9_candidates_differ: true,
d10_candidates_differ: false,
d9_sign_names: ["白羊座", "天蝎"],
},
},
};
function stubEngine(response: unknown) {
const previous = globalThis.fetch;
globalThis.fetch = (async () => ({
ok: true,
status: 200,
json: async () => response,
})) as unknown as typeof fetch;
return () => {
globalThis.fetch = previous;
};
}
const educationEvidence = {
id: EDUCATION_ID,
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: "2016年9月上大学",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
};
function datedOccupationInput(kind: EvidenceKind = "career_entry") {
return {
domain: "career" as const,
eventKind: kind,
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
summary: "2024年4月开始做程序员",
quote: "2024 年 4 月开始做程序员",
subject: "self" as const,
};
}
test("occupation collect focus is identified from question id, not body text", () => {
assert.equal(isOccupationCollectFocus(OCCUPATION_FOCUS), true);
assert.equal(isOccupationCollectFocus({
intent: "collect_method_evidence",
targetDomain: "career",
targetKind: "anchor:education_completion:2020",
questionId: "collect:anchor:education_completion:2020",
}), false);
});
test("dated occupation collect writes occupation_note plus a scoreable career_entry", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [datedOccupationInput()]);
assert.equal(remapped.length, 2);
assert.equal(remapped[0]?.domain, "occupation");
assert.equal(remapped[0]?.eventKind, "occupation_note");
assert.equal(remapped[0]?.datePrecision, "unknown");
assert.equal(remapped[0]?.occurredFrom, null);
assert.equal(remapped[0]?.occurredTo, null);
assert.equal(remapped[1]?.domain, "career");
assert.equal(remapped[1]?.eventKind, "career_entry");
assert.equal(remapped[1]?.datePrecision, "month");
assert.equal(remapped[1]?.occurredFrom, "2024-04-01");
assert.equal(isPrimaryScoreableEvidence({
status: "confirmed",
domain: remapped[0]!.domain,
datePrecision: remapped[0]!.datePrecision,
occurredFrom: remapped[0]!.occurredFrom,
occurredTo: remapped[0]!.occurredTo,
eventKind: remapped[0]!.eventKind,
}), false);
assert.equal(isPrimaryScoreableEvidence({
status: "confirmed",
domain: remapped[1]!.domain,
datePrecision: remapped[1]!.datePrecision,
occurredFrom: remapped[1]!.occurredFrom,
occurredTo: remapped[1]!.occurredTo,
eventKind: remapped[1]!.eventKind,
}), true);
});
test("undated occupation collect still writes only occupation_note", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career",
eventKind: "career_entry" as const,
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
summary: "程序员",
}]);
assert.equal(remapped.length, 1);
assert.equal(remapped[0]?.eventKind, "occupation_note");
assert.equal(remapped[0]?.occurredFrom, null);
});
test("model career kinds other than career_entry are kept on the dated row", () => {
const remapped = applyOccupationCollectLedgerNorm(
OCCUPATION_FOCUS,
[datedOccupationInput("career_change")],
);
assert.equal(remapped[1]?.eventKind, "career_change");
});
test("unknown precision with leftover dates still writes only the note", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career",
eventKind: "career_entry" as const,
datePrecision: "unknown" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
}]);
assert.equal(remapped.length, 1);
assert.equal(remapped[0]?.occurredFrom, null);
assert.equal(remapped[0]?.datePrecision, "unknown");
});
test("occupation-norm null dates are not restored by a ?? fallback in tools", () => {
const source = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /normalized\?\.occurredFrom \?\?/);
assert.doesNotMatch(source, /normalized\?\.occurredTo \?\?/);
assert.doesNotMatch(source, /\?\? item\.occurredFrom/);
assert.doesNotMatch(source, /\?\? occurredFrom/);
assert.match(source, /occupationNormalizedLedgerRows/);
});
test("dated occupation collect raises the training gate by one career event", () => {
const education = [{
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
}, {
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
}];
const before = trainingScoreableGate(education);
assert.equal(before.trainingCount, 2);
assert.equal(before.open, false);
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [datedOccupationInput()]);
const after = trainingScoreableGate([
...education,
...remapped.map((item) => ({
status: "confirmed" as const,
domain: item.domain,
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom,
occurredTo: item.occurredTo,
eventKind: item.eventKind,
})),
]);
assert.equal(after.trainingCount, before.trainingCount + 1);
assert.equal(after.trainingDomainCount, 2);
assert.equal(after.open, true);
});
test("record-evidence-batch writes note plus career_entry and rescores", async () => {
const restore = stubEngine(ENGINE_SCORE);
let collectFocusResolved = false;
try {
const quote = "2024 年 4 月开始做程序员";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence],
latestResult: null,
conversationSummary: conversationSummaryFixture({
activeFocus: collectFocusResolved
? null
: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: OCCUPATION_FOCUS.questionId,
expectedAnswerSchema: { collect: true, prompt: "你平时主要做什么工作?" },
}),
}),
}),
get_agentic_rectification_case_compute: () => computeFixture(),
record_agentic_rectification_evidence_batch: (_fn, args) => {
const items = Array.isArray(args.p_items) ? args.p_items as Array<Record<string, unknown>> : [];
return {
items: items.map((item, index) => ({
index,
outcome: "accepted",
evidence_id: item.event_kind === "occupation_note" ? NOTE_ID : CAREER_ID,
status: "confirmed",
idempotent: false,
clarification_fields: [],
error_code: null,
})),
accepted_count: items.length,
needs_clarification_count: 0,
rejected_count: 0,
focus_id: FOCUS_ID,
};
},
resolve_agentic_rectification_conversation_focus: () => {
collectFocusResolved = true;
return {
focus_id: FOCUS_ID,
status: "resolved",
evidence_id: NOTE_ID,
idempotent: false,
};
},
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
persist_agentic_rectification_candidate_v2: () => ({
...candidateSnapshotFixture({
representativeTime: "04:50",
selectionAllowed: true,
confirmationAllowed: false,
decisionReceipt: {
gates: {
event_quality: { scoreable_event_count: 3, minimum: 3 },
domain_diversity: { domains: ["education", "career"], count: 2, minimum: 2 },
},
},
}),
cached: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: quote,
accounting: accounting.client as never,
});
const result = await (tools["rectification-record-evidence-batch"] as unknown as {
execute(input: unknown): Promise<{
accepted_count: number;
accepted_recaps: Array<{ display_date_label?: string }>;
rescore: { status: string; executed_methods: string[] };
}>;
}).execute({
caseId: CASE_ID,
focusId: FOCUS_ID,
items: [{
quote,
proposedKind: "career_entry",
subject: "self",
domain: "career",
datePrecision: "month",
occurredFrom: "2024-04",
summary: "2024年4月开始做程序员",
}],
});
const write = accounting.calls.find((call) => call.fn === "record_agentic_rectification_evidence_batch");
const items = (write?.args.p_items ?? []) as Array<Record<string, unknown>>;
assert.equal(items.length, 2);
assert.equal(items[0]?.event_kind, "occupation_note");
assert.equal(items[0]?.domain, "occupation");
assert.equal(items[0]?.date_precision, "unknown");
assert.equal(items[0]?.occurred_from, null);
assert.equal(items[1]?.event_kind, "career_entry");
assert.equal(items[1]?.domain, "career");
assert.equal(items[1]?.date_precision, "month");
assert.equal(items[1]?.occurred_from, "2024-04-01");
assert.equal(result.accepted_count, 2);
assert.equal(result.accepted_recaps.some((item) => item.display_date_label === "2024-04"), true);
assert.ok(result.rescore.executed_methods.includes("d1-rashi"));
} finally {
restore();
}
});
test("undated occupation collect batch writes only the note", async () => {
const quote = "程序员";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
evidence: [educationEvidence],
latestResult: null,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: OCCUPATION_FOCUS.questionId,
expectedAnswerSchema: { collect: true, prompt: "你平时主要做什么工作?" },
}),
}),
}),
record_agentic_rectification_evidence_batch: (_fn, args) => {
const items = Array.isArray(args.p_items) ? args.p_items as Array<Record<string, unknown>> : [];
return {
items: items.map((item, index) => ({
index,
outcome: "accepted",
evidence_id: NOTE_ID,
status: "confirmed",
idempotent: false,
clarification_fields: [],
error_code: null,
})),
accepted_count: items.length,
needs_clarification_count: 0,
rejected_count: 0,
focus_id: FOCUS_ID,
};
},
resolve_agentic_rectification_conversation_focus: () => ({
focus_id: FOCUS_ID,
status: "resolved",
evidence_id: NOTE_ID,
idempotent: false,
}),
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
userMessage: quote,
accounting: accounting.client as never,
});
await (tools["rectification-record-evidence-batch"] as unknown as {
execute(input: unknown): Promise<unknown>;
}).execute({
caseId: CASE_ID,
focusId: FOCUS_ID,
items: [{
quote,
proposedKind: "occupation_note",
subject: "self",
domain: "occupation",
datePrecision: "unknown",
summary: "程序员",
}],
});
const write = accounting.calls.find((call) => call.fn === "record_agentic_rectification_evidence_batch");
const items = (write?.args.p_items ?? []) as Array<Record<string, unknown>>;
assert.equal(items.length, 1);
assert.equal(items[0]?.event_kind, "occupation_note");
assert.equal(items[0]?.occurred_from, null);
});
@@ -0,0 +1,653 @@
import assert from "node:assert/strict";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { composeIdleGapIntoSpoken } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import {
COLLECT_KIND_ORDER,
anchoredFollowups,
collectionQuestionPool,
preciseGapNarration,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { applyOccupationCollectLedgerNorm, trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { buildMethodFollowupPlan, followupFromPoolItem } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { parseAgentChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
interviewCollectWaiting,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const TIMES = [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
] as const;
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
const SCORES: Record<string, number> = {
"04:47": 16,
"04:51": 20,
"04:53": 16,
"04:59": 10,
"05:00": 4,
"05:07": 3,
"05:12": 2,
"05:14": 1,
"05:15": 1,
};
const PROBABILITY: Record<string, number> = {
"04:47": 0.25,
"04:51": 0.4,
"04:53": 0.25,
"04:59": 0.1,
};
const educationStart = {
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
};
const educationEnd = {
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "2020年6月毕业",
};
const health2024 = {
id: "e-health-2024",
status: "confirmed" as const,
domain: "health_pressure",
datePrecision: "month" as const,
occurredFrom: "2024-10-01",
occurredTo: "2024-10-31",
eventKind: "self_health_event",
summary: "2024年10月一次身体事故",
};
const career2024 = {
id: "e-career-2024",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2024年4月开始做程序员",
};
const occupationNote = {
id: "e-occupation-note",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "程序员",
};
const inviteDeclinedTopic = {
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
};
const OCCUPATION_FOCUS = {
intent: "collect_method_evidence" as const,
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: "collect:occupation:collect_method_evidence",
};
function askedPoolTopics() {
return [
inviteDeclinedTopic,
{
target_domain: "career",
status: "resolved",
intent: "collect_method_evidence",
questionId: "collect:anchor:education_completion:2020",
target_kind: "anchor:education_completion:2020",
},
{
target_domain: "relocation",
status: "resolved",
intent: "collect_method_evidence",
questionId: "collect:anchor:education_start:2016",
target_kind: "anchor:education_start:2016",
},
...COLLECT_KIND_ORDER.flatMap((kind) => [
{
target_domain: kind,
status: "resolved",
intent: "collect_method_evidence",
questionId: `collect:anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
target_kind: `anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
},
{
target_domain: kind,
status: "resolved",
intent: "collect_method_evidence",
questionId: `collect:generic:${kind}`,
target_kind: `generic:${kind}`,
},
]),
];
}
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
question: string;
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ASKED_PROBES = [
existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作",
}),
];
const LEFTOVER_PROBE = existenceProbe({
key: "career.2021.04.dasha_boundary",
domain: "career",
year: 2021,
question: "2021 年 4 月前后有没有入职或换工作",
});
function liveState(extraProbes: readonly ConflictProbe[] = []) {
const probes = [...ASKED_PROBES, ...extraProbes];
const rankedActive = [...ACTIVE].sort((left, right) => (
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|| left.localeCompare(right)
));
const candidates = TIMES.map((time, index) => {
const eliminated = ELIMINATED.has(time);
const activeRank = (rankedActive as readonly string[]).indexOf(time);
return {
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
status: eliminated ? "eliminated" as const : "active" as const,
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
strong_conflict_count: eliminated ? 3 : 0,
};
});
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: health2024.id, domain: "health_pressure", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: career2024.id, domain: "career", year: 2024, precision: "month" as const, usage: "holdout" as const },
],
probes,
answered_probes: ASKED_PROBES.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "no" as const,
classified_from: "choice" as const,
})),
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "04:51",
credible_range: ["04:47", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded);
return loaded;
}
function eventProbeRow(probe: ConflictProbe) {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain,
event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain,
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
};
}
function fourEventDossier(): DecisionDossier {
const evidence = [educationStart, educationEnd, health2024, career2024, occupationNote];
const state = liveState([LEFTOVER_PROBE]);
const fingerprint = evidenceLedgerFingerprint(evidence as never);
return {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [inviteDeclinedTopic],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: false,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: false,
acceptance_allowed: false,
propose_allowed: false,
selection_allowed: false,
confirmation_allowed: false,
acceptance_reasons: ["insufficient_events"],
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
eventProbeRow(LEFTOVER_PROBE),
],
oos_blind_prompts: [],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture> } = {}) {
const evidence = decision.evidence.map((item) => ({
id: item.id ?? "e-unknown",
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: decision.latestResult?.selectionAllowed ?? true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? [],
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: extra.activeFocus ?? null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-11T00:00:00.000Z",
},
});
}
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture>;
allowFocus?: boolean;
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: extra.allowFocus
? (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
})
: (_fn, args) => {
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
},
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
}
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
const streamResult = {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
return {
stream: async () => streamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
test("skill version stays 10.0.23", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
});
test("two education events do not spawn birth-year reverse questions", () => {
const pool = collectionQuestionPool([educationStart, educationEnd]);
const prompts = pool.map((item) => item.prompt).join("\n");
assert.equal(pool[0]?.kind, "invite");
assert.doesNotMatch(prompts, /年前后/);
const plan = buildMethodFollowupPlan({
evidence: [educationStart, educationEnd],
birthDate: "1997-08-08",
});
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /年前后/);
assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /年前后/);
});
test("after declining invite, the next follow-up is the user-year career anchor", () => {
const asked = new Set<string>();
const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked);
const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020");
assert.ok(job);
assert.equal(job?.domain, "career");
const followup = followupFromPoolItem(job!);
assert.equal(followup.domain, "career");
assert.match(followup.user_prompt_hint, /2020 年毕业后第一份工作/);
const afterDecline = collectionQuestionPool(
[educationStart, educationEnd],
[inviteDeclinedTopic],
);
assert.doesNotMatch(afterDecline.map((item) => item.prompt).join("\n"), /年前后/);
assert.equal(afterDecline[0]?.domain, "career");
});
test("dated occupation answer plus the replay ledger opens the training gate with one holdout", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career" as const,
eventKind: "career_entry" as const,
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
}]);
const evidence = [
educationStart,
educationEnd,
health2024,
...remapped.map((item, index) => ({
id: index === 0 ? "e-occupation-note" : "e-career-2024",
status: "confirmed" as const,
domain: item.domain,
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom,
occurredTo: item.occurredTo,
eventKind: item.eventKind,
summary: index === 0 ? "程序员" : "2024年4月开始做程序员",
})),
];
const gate = trainingScoreableGate(evidence);
assert.equal(gate.holdoutCount, 1);
assert.equal(gate.trainingCount, 3);
assert.ok(gate.trainingDomainCount >= 2);
assert.equal(gate.open, true);
assert.equal(gate.holdoutStatus, "reserved");
});
test("four dated month events persist a leftover discriminator card", async () => {
resetDeliveryTurnGuardForTests();
const dossier = fourEventDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.nextAction, "ask_candidate_discriminator");
const accounting = idleHandlers(dossier, { allowFocus: true });
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
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.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates");
const schema = focusCalls[0]?.args.p_expected_answer_schema;
const copy = parseAgentChoiceCopy(schema);
assert.ok(copy, "persisted focus must carry a choice schema");
assert.equal(isRenderableChoiceOpenQuestion({
question_id: String(focusCalls[0]?.args.p_question_id ?? ""),
prompt: copy.prompt,
status: "created",
kind: "choice",
focus_id: FOCUS_ID,
probe_id: typeof (schema as { probe_id?: unknown } | undefined)?.probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null,
}), true);
assert.equal(persisted.choiceReady, true);
});
test("idle gap copy joins the evidence recap instead of opening a second turn", () => {
const joined = composeIdleGapIntoSpoken("记下了。", "现在记下的是2016 年 9 月上大学和2020 年 6 月毕业。再来一件不是上学的、记得大概年月的事就能开始筛,比如第一份工作、谈恋爱或结婚。");
assert.match(joined, /^记下了。现在记下的是/);
assert.match(joined, /就能开始筛/);
assert.equal(composeIdleGapIntoSpoken(joined, "现在记下的是重复。"), joined);
assert.equal(
composeIdleGapIntoSpoken("这次给出的范围 04:4904:53。", "这次给出的范围 04:4904:53。"),
"这次给出的范围 04:4904:53。",
);
});
test("undated occupation answer keeps the training gate closed and writes a precise gap", async () => {
resetDeliveryTurnGuardForTests();
const evidence = [educationStart, educationEnd, occupationNote];
const declinedSkippedTopics = askedPoolTopics();
assert.equal(collectionQuestionPool(evidence, declinedSkippedTopics).length, 0);
const gap = preciseGapNarration(evidence, declinedSkippedTopics);
assert.match(gap, /现在记下的是/);
assert.match(gap, /就能开始筛/);
assert.doesNotMatch(gap, /领域|做不了|还差 \d+ 件/);
const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里"].filter((token) => gap.includes(token));
assert.ok(careerOrLoveOrFamily.length >= 2, gap);
const dossier: DecisionDossier = {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declinedSkippedTopics,
},
latestResult: null,
case: { acceptedTime: null, status: "collecting_evidence" },
};
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(trainingScoreableGate(evidence).open, false);
assert.equal(interviewCollectWaiting({
stopReason: decision.stopReason,
sessionOutcome: decision.sessionOutcome,
questionMissing: true,
}), true);
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: true,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
}), "collect_waiting");
const accounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(persisted.terminalNote, true);
assert.match(persisted.hostNarration ?? "", /现在记下的是/);
assert.match(persisted.hostNarration ?? "", /就能开始筛/);
assert.doesNotMatch(persisted.hostNarration ?? "", /领域|做不了|还差 \d+ 件/);
const agentAccounting = idleHandlers(dossier);
const result = await runV9AgentTurn({
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "程序员",
modelName: "gpt-4o-mini",
accounting: agentAccounting.client,
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: async () => {},
buildAgent: async () => fakeAgentStream([
{ type: "start" },
{ type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } },
{ type: "tool-result", payload: { toolName: "skill" } },
{ type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } },
{ type: "tool-result", payload: { toolName: "rectification-read-case" } },
{ type: "text-delta", payload: { text: "记下了。" } },
{ type: "finish" },
]) as never,
});
assert.equal(result.ok, true);
assert.match(result.answerText, /现在记下的是/);
assert.match(result.answerText, /就能开始筛/);
assert.doesNotMatch(result.answerText, /领域|做不了|还差 \d+ 件/);
const finalized = agentAccounting.calls.find((item) => item.fn === "finalize_agentic_rectification_turn");
const appended = agentAccounting.calls.find((item) => (
item.fn === "append_agentic_rectification_turn"
&& typeof item.args.p_assistant_message === "string"
&& String(item.args.p_assistant_message).includes("就能开始筛")
));
assert.ok(finalized || appended, "gap copy must land on the evidence turn");
});
@@ -78,6 +78,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
// 旧:每轮正文 2-4 句 → 新:证据轮正文只写一句复述 → BUG-606 决策 3
assert.match(prompt, /证据轮正文只写一句复述/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /职业题只问平时做什么/);
assert.match(prompt, /焦点 domain 是 career 不是 occupation/);
assert.match(prompt, /collection_progress/);
assert.match(prompt, /不得写「范围在收窄」/);
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);