diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b075ff9..4cb3bf7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-10 — 采集题被系统顶替后仍会再问;没有可采用结果时助手不再用交付口吻收尾 + +生时校正里,如果系统先为某一领域落下采集题、随后又被别的题顶替,原来的题号仍会挡住再问。现在撞号会改用带后缀的题号继续问;没有可采用结果时,助手不再说「这次给出的范围」却不出卡。Skill 版本不变。 + + ## 2026-09-10 — 已记学业后仍可问发挥;同段候选的双轨不再互相打架;宫位表跟卡片分钟对齐 已经记下某年学业后,仍可能再问那次发挥得怎么样。主限和分盘大运落在同一段候选里,不再当成互相打架。区间宽度按整段候选来算,不再只看各段代表分钟。宫位表和「已按该分钟重算」跟卡片上的代表分钟对齐。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 39775354..8a78b57d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-superseded-focus-duplicate-20260910.md b/docs/tasks/PROGRESS-rectification-superseded-focus-duplicate-20260910.md new file mode 100644 index 00000000..ace88663 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-superseded-focus-duplicate-20260910.md @@ -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。 diff --git a/docs/testing/rectification-superseded-focus-duplicate-20260910.md b/docs/testing/rectification-superseded-focus-duplicate-20260910.md new file mode 100644 index 00000000..2aeb22d8 --- /dev/null +++ b/docs/testing/rectification-superseded-focus-duplicate-20260910.md @@ -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` 时应出区间卡,而不是只有交付口吻。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 5adabf9b..29fccca8 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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, diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index c5e566c3..f74dccd6 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -140,6 +140,20 @@ export function publicCanAdopt(decision: Pick, + 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" diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 334f7d1a..245c4774 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -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", diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 77250bf6..b6eaf5cb 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -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, + }; } } diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 7a41ab97..61671ef8 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -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); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index bf084f3b..48481bfc 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -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, diff --git a/frontend/tests/rectification-superseded-focus.test.ts b/frontend/tests/rectification-superseded-focus.test.ts new file mode 100644 index 00000000..1160db84 --- /dev/null +++ b/frontend/tests/rectification-superseded-focus.test.ts @@ -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 { + 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) { + 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[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) { + 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>; + 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; + } 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\)/); +});