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:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-10 — 采集题被系统顶替后仍会再问;没有可采用结果时助手不再用交付口吻收尾
|
||||
|
||||
生时校正里,如果系统先为某一领域落下采集题、随后又被别的题顶替,原来的题号仍会挡住再问。现在撞号会改用带后缀的题号继续问;没有可采用结果时,助手不再说「这次给出的范围」却不出卡。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-10 — 已记学业后仍可问发挥;同段候选的双轨不再互相打架;宫位表跟卡片分钟对齐
|
||||
|
||||
已经记下某年学业后,仍可能再问那次发挥得怎么样。主限和分盘大运落在同一段候选里,不再当成互相打架。区间宽度按整段候选来算,不再只看各段代表分钟。宫位表和「已按该分钟重算」跟卡片上的代表分钟对齐。Skill 版本不变。
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user