fix(rectification): re-ask superseded collect ids instead of fake delivery (BUG-644/645)

A superseded collect row still occupied the unique question id, so the last
"没有" skipped the unasked domain and spoke delivery copy while can_adopt stayed false.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 21:03:12 +08:00
co-authored by Cursor
parent d40f2032fc
commit a3a51c32c2
11 changed files with 621 additions and 79 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-10 — 采集题被系统顶替后仍会再问;没有可采用结果时助手不再用交付口吻收尾
生时校正里,如果系统先为某一领域落下采集题、随后又被别的题顶替,原来的题号仍会挡住再问。现在撞号会改用带后缀的题号继续问;没有可采用结果时,助手不再说「这次给出的范围」却不出卡。Skill 版本不变。
## 2026-09-10 — 已记学业后仍可问发挥;同段候选的双轨不再互相打架;宫位表跟卡片分钟对齐
已经记下某年学业后,仍可能再问那次发挥得怎么样。主限和分盘大运落在同一段候选里,不再当成互相打架。区间宽度按整段候选来算,不再只看各段代表分钟。宫位表和「已按该分钟重算」跟卡片上的代表分钟对齐。Skill 版本不变。
+32
View File
@@ -9956,3 +9956,35 @@
- 相关记录:BUG-593
- 复发自:无
- 修复版本:待发布
## BUG-644 | 被顶替的采集题号永久占位,落库撞号后当成已问过并断流
- 状态:resolved
- 首次发现:2026-09-10
- 最近更新:2026-09-10
- 影响面:`persistCollectFocus`、`persistExhaustionCollect`、`set_v10_conversation_focus`
- 用户现象:同一轮里分两次记下两件经历后,再答完各领域采集题,最后一条线回「没有」,助手改口说「这次给出的范围…」,下面是「没有拿到下一个问题。」感情采集题从未出现过。
- 触发条件:一轮里 `record-evidence-batch` 被调两次,第一次落下的采集焦点被第二次顶替成 `superseded`;之后计划层仍要问那个领域。
- 根因:RPC 把旧焦点改成 `superseded` 而不删行,`unique (case_id, question_id)` 仍占着原题号。计划层看不到 superseded,`collect_retry` 为 false,`persistCollectFocus` 撞号直接 `duplicate_focus`。`persistExhaustionCollect` 把 `duplicate_focus` 当成「这题已经问过并关掉了」,跳过口语采集走进交付旁白。
- 修复:采集题撞号一律试 `:next` / `:next2` / `:next3`;`collect_retry` 只换重问文案。`duplicate_focus` 与其它落库失败一样返回采集口语或中间态范围句,并在 `rectification_exhaustion_collect` 日志写下 `persist_status` 与题号。点选探针题号不加后缀。
- 验证:`frontend/tests/rectification-superseded-focus.test.ts`、`frontend/tests/rectification-server-focus.test.ts`。
- 防复发:采集题撞号不得因 `collect_retry` 为 false 就放弃换号;`duplicate_focus` 不得落到 adopt 分支。
- 相关记录:BUG-462、BUG-520、BUG-525、BUG-559、BUG-626、BUG-627、BUG-633、BUG-645
- 复发自:BUG-462、BUG-626(题号唯一约束叠上顶替不删行)
- 修复版本:待发布
## BUG-645 | 出口旁白说「这次给出的范围」却没有卡、公开 can_adopt 为 false
- 状态:resolved
- 首次发现:2026-09-10
- 最近更新:2026-09-10
- 影响面:`persistExhaustionCollect`、`persistNextInterviewAfterChoice`、`POST /api/rectification/agent` 非收敛区间分支、`deliveryNarrationAllowed`
- 用户现象:助手正文是交付三句(「这次给出的范围…代表性候选」),公开 `can_adopt=false`、没有对照卡,下面是「没有拿到下一个问题。」
- 触发条件:内部 `decision.canAdopt` 为真,但 `session_outcome=collect_evidence`,公开 `can_adopt` 仍为 false;穷尽采集落库失败后走进 adopt 旁白。
- 根因:交付旁白只看内部 `canAdopt`。公开 `can_adopt` 只在采用结果态为真,采集期即使内部可采纳也不出卡。
- 修复:新增 `deliveryNarrationAllowed`:必须 `publicCanAdopt` 为真,或 `selection_allowed` 且下一动作为 `offer_provisional_range`。三处调用在不允许时改用中间态范围句加当前采集题。
- 验证:`frontend/tests/rectification-superseded-focus.test.ts`、`frontend/tests/agent-voice-copy-contract.test.ts`、`frontend/tests/rectification-delivery-report-facts.test.ts`、`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`。
- 防复发:`can_adopt=false` 时源码合同禁止交付句路径;公开不能采用时不得输出「这次给出的范围」。
- 相关记录:BUG-644、BUG-626、BUG-627
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,54 @@
# PROGRESS · 被顶替的采集题号永久占位(2026-09-10)
工作树:`.worktrees/rectification-superseded-focus-duplicate-20260910`
分支:`codex/rectification-superseded-focus-duplicate-20260910`
任务书:`docs/tasks/TASK-rectification-superseded-focus-duplicate-20260910.md`
基线:任务书写 `origin/staging` @ `82eb3b79`;开工接到最新 `origin/staging` @ `d40f2032`(含 BUG-635~640 落地后的任务书 rebase)。
## 开工回执
- 编号 **BUG-644**(P0)/ **BUG-645**(P1)。`docs/BUG_HISTORY.md` 当时最大已发布号是 640;641~643 仍由采集语义单占用,本单按任务书从 644 起。
- 顺序:采集撞号必换号 → `duplicate_focus` 不落 adopt → 交付口吻守卫 → 记录。T4 延后。
- 未改 Skill、引擎、迁移。
## 已完成
- **T1**:`persistCollectFocus` 撞号依次试 `:next` / `:next2` / `:next3`;`collect_retry` 只换重问文案。点选探针路径仍一次撞号即 `duplicate_focus`。
- **T2**:`persistExhaustionCollect` 删除「duplicate_focus = 已问过」特判;失败时返回采集口语或中间态范围句;日志加 `persist_status`、`question_id`。
- **T3**:`deliveryNarrationAllowed`;`persistNextInterviewAfterChoice` skip、`persistExhaustionCollect` adopt、路由 `isNonConvergingRangeOffer` 三处共用。不允许时不用交付句。
- **T5**:BUG-644/645、CHANGELOG、走查清单、本文件。Skill 版本不变(`10.0.21`)。
## T4 延后
同轮多次 batch 的 `persistPlanFocus` 先 resolve 再新建,只减少 superseded 行,不是闭合点。`resolve(status="superseded")` 也不是现有 resolve RPC 的合法状态;unique `(case_id, question_id)` 即使改成 resolved 仍占题号。T1 换号已闭合再问。
## 验收命令(实测)
工作树相对 `origin/staging` @ `d40f2032`。
| 命令 | 结果 |
| --- | --- |
| `frontend` `tsx --test` 任务书相关文件(superseded-focus / server-focus / spoken-collect / exhaustion-exit / delivery-report-facts / agent-voice / skipped-health) | **79/79 pass** |
| 追加 `answer-choice` / `range-offer-deadend` / `adopt-narration` / `collect-direction` | **79/79 pass** |
| `frontend` `./node_modules/.bin/tsc --noEmit` | **0 error** |
| `frontend` 对改动文件 `eslint` | **0 error** |
| `git diff --check` | 待提交前再跑 |
相关套件基线(staging 上 `test(` 条数)71;本单新增 `rectification-superseded-focus.test.ts` 并给 copy-contract 加 1 条,总数高于基线。
### 既有断言改动
- `rectification-server-focus.test.ts` `collect focus unique conflict is duplicate_focus unless collect_retry`:原值 duplicate_focus / RPC 一次 / 新值 `:next` 成功、原文案 / 原因:BUG-644 被顶替题号必须能再问。
- 同文件探针 `duplicate focus conflict does not throw`:补 `writes=1` 且题号不含 `:next`。原因:BUG-559,点选探针不得加后缀。
## 环境缺口
- 无登录态:`docs/testing/rectification-superseded-focus-duplicate-20260910.md` 真人走查未做,不得写通过。
- 未跑 `next build`,未核 `/` 是否仍 Static、首屏 gzip ±2%。
- 未推 staging,未核 `/api/health` 的 `deployment.gitCommit`。
## 未做
- T4 同轮多次 batch 计划焦点。
- 未提升 `main`。Skill 未升版。
- 未实现 BUG-641~643。
@@ -0,0 +1,28 @@
# 生时校正 · 被顶替的采集题仍能再问(2026-09-10)
对应 BUG-644 / BUG-645。不要把真实案例里的姓名、地点、精确出生资料或 Case id 写进记录。
自动化已锁:`frontend/tests/rectification-superseded-focus.test.ts`(撞号换号、四次撞号、`duplicate_focus` 不交付、三处守卫)、`frontend/tests/rectification-server-focus.test.ts`(无 `collect_retry` 也换号;探针不加后缀)、`frontend/tests/agent-voice-copy-contract.test.ts`(`can_adopt=false` 不得走交付句)。
## 0. 确认测的是新版本
浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 是否等于本单合入 staging 后、含代码改动的提交。不一致 = 先别测。
## 1. 一轮里分两句说两件学业经历,走完采集后回「没有」(P0)
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。虚构经历即可。
1. 开场后在**同一轮**里先说一件带年月的学业经历,等助手开始记;再立刻补第二件不同年月的学业经历(诱发两次 batch)。
2. 按提示走完家人 / 财务 / 搬家 / 健康等采集题;能跳过的打字「没有」。
3. 不要主动说感情经历。
- ✅ 预期:各领域采集问完后,会出现感情采集题,而不是「没有拿到下一个问题。」
- ✅ 预期:未出现可采用卡时,助手不得说「这次给出的范围」或「代表性候选」却不出卡。
- ❌ 失败:最后一条线答「没有」后直接交付三句、下面缺口文案、顶部还在核对。
## 2. 带年月的感情经历仍可直接写入(绕行)
若第 1 节仍复现,直接打一句带年月的感情经历(例如「某年某月开始交往」)。
- ✅ 预期:batch 写入并重算,不依赖那道被挡住的采集题。
- ✅ 或点「先这样」:`selection_allowed=true` 时应出区间卡,而不是只有交付口吻。
@@ -45,7 +45,7 @@ import {
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { deliveryNarrationAllowed, isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import { RECTIFICATION_USER_COPY } from "@/lib/rectification-agentic/user-copy";
import { parseDeclaredBirthWindow } from "@/lib/rectification-agentic/v9/declared-window-utterance";
import {
@@ -607,7 +607,15 @@ export async function POST(request: Request) {
caseId,
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction);
const intermediate = nonConvergingRangeNarration({
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
variant: "intermediate",
});
const assistantMessage = allowDelivery
? (idle.hostNarration || nonConvergingRangeNarration(decision))
: ((idle.terminalNote ? "" : idle.hostNarration) || intermediate);
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
@@ -140,6 +140,20 @@ export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt"
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
}
/**
* Delivery copy (`这次给出的范围` / representative-candidate close) is only
* allowed when the public adopt flag is on, or the session is explicitly
* offering a selectable range. Internal `canAdopt` during collect is not enough.
*/
export function deliveryNarrationAllowed(
decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome" | "nextAction" | "selectionAllowed">,
nextAction?: string | null,
): boolean {
if (publicCanAdopt(decision)) return true;
const action = nextAction ?? decision.nextAction;
return decision.selectionAllowed === true && action === "offer_provisional_range";
}
export type CompletionStatus =
| "provisional_range_user_stopped"
| "validated_range"
@@ -9,6 +9,7 @@
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { nextProbe } from "../core/build-state";
import {
deliveryNarrationAllowed,
engineCapabilityCeilingFromReceipt,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
@@ -831,7 +832,7 @@ export async function persistNextInterviewAfterChoice(input: {
sessionOutcome: input.nextAction.session_outcome,
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
})) {
}) && deliveryNarrationAllowed(decision, input.nextAction.type)) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
dossier: liveDossier,
@@ -1291,7 +1292,7 @@ export async function persistNextInterviewIfIdle(input: {
sessionOutcome: decision.sessionOutcome,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
})) {
}) && deliveryNarrationAllowed(decision, decision.nextAction)) {
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = adoptHostNarration({
dossier,
@@ -1432,27 +1433,8 @@ async function persistExhaustionCollect(input: {
answers: inference?.answered_probes.length ?? 0,
plateau: plateauRounds,
};
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: followup?.domain ?? null,
next_source: followup?.source ?? null,
}));
let persistStatus: string | null = null;
let persistQuestionId: string | null = null;
if (followup) {
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
@@ -1469,6 +1451,31 @@ async function persistExhaustionCollect(input: {
followup,
askedTurnId: input.askedTurnId ?? null,
});
persistStatus = persistedFocus.status;
persistQuestionId = persistedFocus.questionId;
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: followup.domain ?? null,
next_source: followup.source ?? null,
persist_status: persistStatus,
question_id: persistQuestionId,
}));
const persisted = Boolean(spoken) && (
persistedFocus.status === "created" || persistedFocus.status === "already_open"
);
@@ -1480,22 +1487,48 @@ async function persistExhaustionCollect(input: {
focus: persistedFocus.focus,
};
}
// duplicate_focus: this ask is already closed; fall through to adopt/gate.
// Other persist misses still return the spoken collect so mid-session
// turns keep a next question instead of a terminal gate.
if (persistedFocus.status !== "duplicate_focus") {
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
// duplicate_focus is not "already asked". Keep the spoken collect or the
// intermediate range sentence instead of falling through to adopt.
const hostNarration = (spoken ?? "").trim() || (range ?? "").trim();
if (hostNarration) {
return {
persisted: false,
choiceReady: false,
hostNarration,
focus: persistedFocus.focus,
};
}
} else {
console.warn(JSON.stringify({
event: "rectification_exhaustion_collect",
case_id: input.caseId,
can_adopt: decision.canAdopt,
ceiling: {
acceptance: ceiling.acceptanceAllowed,
selection: ceiling.selectionAllowed,
propose: ceiling.proposeAllowed,
},
training_gate: {
count: trainingGate.trainingCount,
domains: trainingGate.trainingDomainCount,
open: trainingGate.open,
},
stop_class: decision.stopReason ?? decision.sessionOutcome,
ranked_count: decision.separation.ranked.length,
probe_key: decision.probe?.semanticKey ?? null,
budget,
next_domain: null,
next_source: null,
persist_status: persistStatus,
question_id: persistQuestionId,
}));
}
if (ceiling.acceptanceAllowed && trainingGate.open && decision.canAdopt) {
if (
ceiling.acceptanceAllowed
&& trainingGate.open
&& decision.canAdopt
&& deliveryNarrationAllowed(decision, decision.nextAction)
) {
const adopted = {
...decision,
stopReason: decision.stopReason ?? "probe_pool_exhausted",
@@ -186,6 +186,14 @@ export function isRenderableChoiceOpenQuestion(
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
export const COLLECT_FOCUS_RETRY_SUFFIX = "next";
/** Suffixes tried after a collect `question_id` unique conflict. Probe ids must not use these. */
export const COLLECT_FOCUS_RETRY_SUFFIXES = ["next", "next2", "next3"] as const;
export function collectFocusRetryQuestionIds(questionId: string): string[] {
return COLLECT_FOCUS_RETRY_SUFFIXES
.map((suffix) => `${questionId}:${suffix}`.slice(0, 160))
.filter((id) => id !== questionId);
}
const PERSISTABLE_FOCUS_DOMAINS = new Set([
"education",
@@ -356,41 +364,31 @@ async function persistCollectFocus(input: {
prompt: null,
};
}
if (input.followup.collect_retry !== true) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
const retryId = `${questionId}:${COLLECT_FOCUS_RETRY_SUFFIX}`.slice(0, 160);
if (retryId === questionId) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId,
prompt,
};
}
try {
return await insertFocus(retryId);
} catch (retryError) {
if (isFocusIdempotencyConflict(retryError)) {
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId: retryId,
prompt,
};
// Superseded rows still occupy the unique (case_id, question_id). Always
// try suffix ids so an unasked collect can be asked again. collect_retry
// only switches copy, it does not gate the suffix.
let lastId = questionId;
for (const retryId of collectFocusRetryQuestionIds(questionId)) {
lastId = retryId;
try {
return await insertFocus(retryId);
} catch (retryError) {
if (!isFocusIdempotencyConflict(retryError)) {
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
}
return {
status: "skipped",
focus: input.activeFocus,
questionId: null,
prompt: null,
};
}
return {
status: "duplicate_focus",
focus: input.activeFocus,
questionId: lastId,
prompt,
};
}
}
@@ -66,6 +66,25 @@ test("delivery and adopt turns keep representative-minute boundary semantics", (
assert.match(after.intermediateRange, /05:00–05:07|7 分钟/);
});
test("can_adopt false must not use the delivery range sentence", () => {
const decision = readFileSync(
new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url),
"utf8",
);
const answer = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
const route = readFileSync(
new URL("../src/app/api/rectification/agent/route.ts", import.meta.url),
"utf8",
);
assert.match(decision, /export function deliveryNarrationAllowed/);
assert.match(answer, /deliveryNarrationAllowed\(decision,/);
assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/);
});
test("accident-case after copy is rebuilt from the shared module", () => {
const after = accidentCaseHardcodedTurns(ACCIDENT_SHAPE);
assert.equal(after.openingCollect, GENERIC_COLLECT_QUESTION);
@@ -22,6 +22,7 @@ import {
import {
GENERIC_COLLECT_QUESTION,
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import type { EventProbeStyleOption } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { fakeAccounting, CASE_ID, FOCUS_ID, USER_ID, activeFocusFixture } from "./rectification-v9-test-support.ts";
@@ -358,8 +359,10 @@ test("an unmatched scoring identity is skipped instead of being synthesized", as
test("duplicate focus conflict does not throw", async () => {
const followup = discriminatorFollowup({ source: "precision_stage", information_gain: 0.2 });
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
writes += 1;
throw new Error("agentic_rectification_focus_idempotency_conflict");
},
});
@@ -372,6 +375,8 @@ test("duplicate focus conflict does not throw", async () => {
followup,
});
assert.equal(result.status, "duplicate_focus");
assert.equal(writes, 1);
assert.equal(result.questionId?.includes(":next"), false);
});
test("contrast probe is not replaced by an already-answered education quality probe", async () => {
@@ -785,10 +790,10 @@ test("collect persist maps occupation to other and health_pressure to health", a
assert.equal(healthWrite?.args.p_target_domain, "health");
});
test("collect focus unique conflict is duplicate_focus unless collect_retry", async () => {
// 原值: 任何撞 id 都 :next
// 新值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次
// 原因: BUG-591 决策 3;撞 id 本身就是这题问过了
test("collect focus unique conflict retries with a :next question id", async () => {
// 原值: 仅 collect_retry 才 :next;否则 duplicate_focus、RPC 一次
// 新值: 采集题撞号一律试 :next / :next2 / :next3;collect_retry 只换文案
// 原因: BUG-644 被顶替的采集题号仍占 unique,没问过也必须能再问
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
@@ -805,9 +810,14 @@ test("collect focus unique conflict is duplicate_focus unless collect_retry", as
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "duplicate_focus");
assert.equal(writes, 1);
assert.equal(persisted.questionId, "collect:relationship:collect_method_evidence");
assert.equal(persisted.status, "created");
assert.equal(writes, 2);
assert.equal(
persisted.questionId,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
assert.notEqual(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
});
test("collect_retry unique conflict still retries with a :next question id", async () => {
@@ -833,6 +843,7 @@ test("collect_retry unique conflict still retries with a :next question id", asy
persisted.questionId,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
const retryWrite = accounting.calls.at(-1);
assert.equal(
retryWrite?.args.p_question_id,
@@ -0,0 +1,340 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
deliveryNarrationAllowed,
publicCanAdopt,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { exhaustionSpokenCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIXES,
collectFocusRetryQuestionIds,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
const BASE_QUESTION = "collect:relationship:collect_method_evidence";
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
...overrides,
};
}
function focusRowFromArgs(args: Record<string, unknown>) {
return {
id: "abababab-abab-4bab-8bab-abababababab",
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-10T00:00:00.000Z",
resolved_at: null,
idempotent: false,
};
}
function datedEvidence() {
return [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2022-06-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2021-11-01", occurredTo: null, eventKind: "health_episode" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
] as const;
}
function rpcEvidence(item: ReturnType<typeof datedEvidence>[number]) {
return {
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-10T00:00:00.000Z",
};
}
function relationshipOpenDossier() {
const ledger = datedEvidence();
const evidence = ledger.map((item) => rpcEvidence(item));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(ledger as never),
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-10T00:00:00.000Z",
},
});
}
function warnLines(run: () => Promise<unknown>) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
};
return Promise.resolve()
.then(run)
.then((result) => ({ result, lines }))
.finally(() => {
console.warn = original;
});
}
test("collect focus retries next then next2 then next3 before duplicate_focus", async () => {
assert.deepEqual(COLLECT_FOCUS_RETRY_SUFFIXES, ["next", "next2", "next3"]);
assert.deepEqual(
collectFocusRetryQuestionIds(BASE_QUESTION),
[`${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`, `${BASE_QUESTION}:next3`],
);
const blocked = new Set([BASE_QUESTION, `${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`]);
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
const id = String(args.p_question_id);
if (blocked.has(id)) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
});
test("collect focus four collisions return duplicate_focus", async () => {
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
writes += 1;
throw new Error("agentic_rectification_focus_idempotency_conflict");
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "duplicate_focus");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
});
test("collect_retry only changes copy, not whether a suffix is tried", async () => {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
if (args.p_question_id === BASE_QUESTION) {
throw new Error("agentic_rectification_focus_idempotency_conflict");
}
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({ collect_retry: true }),
});
assert.equal(persisted.status, "created");
assert.equal(persisted.questionId, `${BASE_QUESTION}:next`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
});
test("probe question ids do not receive collect suffixes", () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "education",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: "education:2016",
candidate_split_hash: "education:2016",
};
const questionId = stableFollowupQuestionId(followup);
assert.match(questionId, /^probe:/);
assert.equal(questionId.includes(":next"), false);
});
test("deliveryNarrationAllowed requires public can_adopt or a selectable range offer", () => {
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "collect_evidence",
nextAction: "ask_fact_collection",
selectionAllowed: true,
}), false);
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "adopt_representative",
nextAction: "ready_to_adopt",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "provisional_range",
nextAction: "offer_provisional_range",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "collect_evidence",
nextAction: "offer_provisional_range",
selectionAllowed: false,
}), false);
});
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
const raw = relationshipOpenDossier();
const remaining = exhaustionSpokenCollectFollowup({
evidence: datedEvidence(),
declinedTopics: [],
});
assert.equal(remaining?.domain, "relationship");
const mapped = {
case: {
caseId: CASE_ID,
sessionId: "22222222-2222-4222-8222-222222222222",
status: "collecting_evidence",
candidateRange: { start_time: "04:50", end_time: "05:10" },
stage: "minute",
acceptedTime: null,
confirmedTime: null,
reportedBirthTime: null,
birthTimeSource: null,
blockScan: null,
},
evidence: datedEvidence(),
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [],
},
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(datedEvidence() as never),
}),
};
const decision = decideFromDossier(mapped as never, { birthDate: "1998-03-15" });
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(publicCanAdopt(decision), false);
assert.equal(deliveryNarrationAllowed(decision, decision.nextAction), false);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const { result, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const idle = result as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.match(idle.hostNarration ?? "", /感情/);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出的范围/);
assert.notEqual(idle.terminalNote, true);
const exhaustion = lines
.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
})
.find((item) => item?.event === "rectification_exhaustion_collect");
assert.equal(exhaustion?.persist_status, "duplicate_focus");
assert.equal(typeof exhaustion?.question_id, "string");
});
test("delivery copy is gated at the three persist sites", () => {
const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const decision = readFileSync(
new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url),
"utf8",
);
assert.match(decision, /export function deliveryNarrationAllowed/);
assert.match(answer, /deliveryNarrationAllowed\(decision, input\.nextAction\.type\)/);
assert.match(answer, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/);
assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
});