diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e717d01e..590caade 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7378,3 +7378,81 @@ - 相关记录:BUG-464(消息按需加载引入的首屏消息等待,流式轮已把当前会话消息前移到揭幕前)、BUG-470(effect 内 setState 规则) - 复发自:无 - 修复版本:待发布 + +## BUG-480 | 第一道 D9 选择卡只有 A-D、没有题干 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`persistServerOwnedFocus`、`persistPlanFocus`、`finalizeSuccessfulTurnExit`、选择题 `choice_frame.prompt` +- 用户现象:走查第一道 D9 相处方式卡只有 A–D,题干不在卡片上,也不在 turn 历史里。后续点选题走 answer_choice 路径则正常。 +- 触发条件:message/agent 路径首次为 `distinguish_candidates` 建 focus;`serverOwnedChoiceCopy` 因空 prompt/period 返回 null,或 turn-exit 只在 idle 新建 focus 时写题干 turn。 +- 根因:copy 为 null 时仍可能留下不可渲染 choice;agent `persistPlanFocus` 没有 spoken collect 回退;turn-exit 在 agent 已建 focus 时跳过题干持久化。 +- 修复:copy 为 null 的区分题 fail-closed,回退为 `spokenCollectFallbackFollowup`,不建空题干 choice。agent 路径同样回退。turn-exit 只要当前问题有 prompt 就按既有结构规则写题干 turn。D9 `varga_style` 优先接上探针问句。不改 Python 引擎,不 bump Skill。 +- 验证:`rectification-walkthrough-polish` D9 不变量与 unwired 回退;`rectification-server-focus`;`agent-voice-copy-contract`。未做登录后的真实页面点选。 +- 防复发:任何可渲染 `choice_card` 必须有非空 prompt;不得再把空 prompt 的 choice focus 当成功创建。agent 与 idle 创建链都要走同一 fail-closed。 +- 相关记录:BUG-460、BUG-461、BUG-469、BUG-471 +- 复发自:BUG-471(可见 legend 修好后,空 prompt 创建链仍会丢题干) +- 修复版本:待发布 + +## BUG-481 | 正文说「界面上有下一问」时问题槽还是空的 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`agentic-rectification` 指令、`VOICE.md`、流结束后 `loadCaseSnapshot` +- 用户现象:助手说界面上已有下一问,用户当下看不到题干。题干 turn 在流结束后的 turn-exit 才写入。 +- 触发条件:Agent 正文断言界面当前状态;前端在 `done` 后才刷新 case 快照。 +- 根因:指令允许「过渡到界面上的下一步」;模型把尚未刷新的槽位说成已经出现。 +- 修复:指令改为中性过渡(「接下来我们继续」),明确禁止断言界面当前状态。时序沿用已有 `await loadCaseSnapshot()`,不新增 `question.ready` 事件。 +- 验证:`agent-voice-copy-contract`;`rectification-walkthrough-polish` 指令合同。未做登录后的真实页面点选。 +- 防复发:正文不得写「界面上有/出现了下一问」。题干真源仍是服务端问题槽。 +- 相关记录:BUG-441、BUG-449、BUG-471 +- 复发自:BUG-471(直播看不到题干时,模型仍被要求过渡到「界面上的下一步」) +- 修复版本:待发布 + +## BUG-482 | 同域采集问句逐字复读 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`USER_COLLECT_QUESTION_RETRY`、`spokenFollowupForUser`、`buildMethodFollowupPlan` +- 用户现象:「感情这边,还记得哪年认真在一起、分开,或结婚吗?」在历史里逐字出现两次。第一次用工作事件回答后重问正确,但措辞完全一样。 +- 触发条件:该域 collect focus 已关闭且未被拒答,覆盖仍未完成,下一问再次采集同一域。 +- 根因:采集 copy 每域只有一句;没有用结构化「已问过」状态切换第二措辞。 +- 修复:每域增加 retry 变体。`closedCollectFocuses` 里该域 collect 已建立且 status 不是 declined 时设 `collect_retry`。不做正文字符串匹配。 +- 验证:`rectification-walkthrough-polish` 同域 retry;`agent-voice-copy-contract` 可见文案词表。未做登录后的真实页面点选。 +- 防复发:同域重问必须换第二措辞;判定只用 focus 结构化状态,不读正文。 +- 相关记录:BUG-028、BUG-087 +- 修复版本:待发布 + +## BUG-483 | 采用后仍挂着采用前的采集题,没有切入前事核对 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`persistNextInterviewIfIdle`、accept 路由、`.rectification-question-slot` 渲染位置 +- 用户现象:采用 04:53 成功后,当前问题仍是采用前的财务采集,显示在候选卡下方、脱离对话流。预期是最多两件前事核对。 +- 触发条件:accept RPC 成功,active focus 仍是 collect;idle 见任何 activeFocus 就直接返回。 +- 根因:`buildMethodFollowupPlan` 在 accepted 时会出 reverse_verify,但 idle 被旧 collect 挡住;accept 成功后也不替换 focus。问题槽是消息列表的兄弟节点。 +- 修复:`accepted_time` 存在且当前 focus 不是 reverse_verify/oos 时,先 skipped 关闭再 persist 下一问。accept 成功后调用同一 idle。问题槽改到最新一条消息内、候选卡之后。GET 不作为主变更路径。 +- 验证:`rectification-walkthrough-polish` 采用后不变量;`rectification-eight-method` reverse_verify;`rectification-spoken-collect` 槽位合同。未做登录后的真实页面点选。 +- 防复发:`accepted_time` 非空时 `current_question` 不得仍是采用前 collect。问题槽必须留在对话流内。 +- 相关记录:BUG-117、BUG-120 +- 修复版本:待发布 + +## BUG-484 | overlay `can_adopt` 与引擎出牌/采用不一致 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`deliveryCapability`、GET overlay `can_adopt`、offer/accept 可执行性 +- 用户现象:职业从未被问,overlay 判 `can_adopt:false`,但 offer 工具与引擎 `accept_allowed=true` 仍出牌且采用成功。 +- 触发条件:blocking coverage 未齐(含 occupation 路由缺口),引擎 ceiling 已放行采用。 +- 根因:`deliveryCapability` 把 `methodCoverageAll` 和训练门绑进 `locallySelectable`。BUG-472 已把 occupation 移出 blocking 集合,其余 coverage 仍挡 overlay 采用。 +- 修复:方案 1(对齐引擎)。coverage 从采用门移除,只保留为问询路由优先级。训练门 3/2、`keep_collecting` 证据下限、引擎 ceiling、确认门不变。`canAdopt` 为真且 nextAction 已是出牌/采用时,仍持久化 `deferred_followup` 里的剩余区分题与未覆盖 blocking collect;点选回执不得用采用口播覆盖已写入的下一题题干。职业/财务/已覆盖层的耗尽采集在出牌态仍跳过。不 bump Skill 10.0.14;「职业挡出牌」的 skill 文本偏差记在本条,下次 skill 版本再改。 +- 验证:`rectification-walkthrough-polish` overlay 与引擎对齐;`rectification-occupation-coverage-exit`;`rectification-decide-next-action`;`rectification-range-offer-deadend`;`rectification-answer-choice` 点选后仍持久化下一张带年区分卡与家人采集;`rectification-decision-authority` 非终态出口仍等待家人采集写入。`./node_modules/.bin/tsc --noEmit` 与四组测试见当轮记录。未做登录后的真实页面点选。 +- 防复发:overlay `can_adopt` 必须与 offer/accept 实际可执行性一致。不得把 coverage 重新写回采用门。不得放宽确认门。不得因为 `canAdopt` 就跳过剩余区分题或未覆盖的 dasha/D9/D10/家人采集。点选回执在下一题已写入时不得改写成采用口播。 +- 相关记录:BUG-453、BUG-463、BUG-472 +- 复发自:BUG-472(occupation 移出 blocking 后,deliveryCapability 仍用 coverageComplete 挡采用) +- 修复版本:待发布 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 27c46ab5..4f4e818a 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -30,11 +30,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 | +| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在问题槽,过渡用中性句。 | | ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 | ## 红线(语气改不了这些) - 不得虚构星盘事实或唯一出生分钟。 - 问题槽仍由服务端 focus 唯一所有;正文不得出题、复述或改写题干。 +- 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。 - 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。 - 技法审计表仍在本命回答文末。 diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index a8e3cae0..de1112f1 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -122,6 +122,18 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 }); } const result = row as Record; + try { + const { persistNextInterviewIfIdle } = await import( + "@/lib/rectification-agentic/v9/answer-choice" + ); + await persistNextInterviewIfIdle({ + accounting, + userId: user.id, + caseId, + }); + } catch { + // Accept already committed; the next successful turn-exit will replace the stale collect. + } return NextResponse.json({ ok: true, saved_time: result.saved_time, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5ba01b09..d498258c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2905,6 +2905,14 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin-block: var(--space-3); margin-inline-start: var(--assistant-content-inset); } +.rectification-message-wrap .rectification-question-slot { + width: calc(100% - var(--assistant-content-inset)); + margin-inline-start: var(--assistant-content-inset); +} +.rectification-message-wrap .rectification-question-slot > .rectification-choice-card { + width: 100%; + margin-inline-start: 0; +} .rectification-question-slot__prompt { margin: 0; color: var(--color-ink); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index dec4fe93..71bbf121 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1142,6 +1142,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const vargaSentence = !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; + const isLatestMessage = message.renderKey === messages[messages.length - 1]?.renderKey; return (
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && ( @@ -1185,9 +1186,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onStop={submitStop} /> )} + {isLatestMessage && ( +
+ {showLiveChoiceCard && choiceCard && ( + + )} + {collectSpokenPrompt && !showLiveChoiceCard && !readonly && ( +

+ {collectSpokenPrompt} +

+ )} + {showMissingQuestion && ( +

+ 当前没有可回答的问题,正在等待服务端更新。 +

+ )} + {showUnavailableQuestion && ( +

+ 当前问题暂时无法显示,请等待服务端更新。 +

+ )} +
+ )}
); })} + {messages.length === 0 && (
{showLiveChoiceCard && choiceCard && ( )}
+ )} {savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 356dfc80..70056c39 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -205,10 +205,10 @@ function deliveryCapability(input: { holdout: HoldoutValidationStatus; engineCeiling: EngineCapabilityCeiling; confirmationAllowed: boolean; - coverageComplete: boolean; + trainingGateOpen: boolean; }): DeliveryCapability { const locallySelectable = input.separation.ranked.length > 0 - && input.coverageComplete + && input.trainingGateOpen && input.stopClass?.kind !== "keep_collecting"; return { canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed, @@ -246,7 +246,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, engineCeiling: input.engineCeiling, confirmationAllowed, - coverageComplete: input.methodCoverageAll && input.trainingGateOpen !== false, + trainingGateOpen: input.trainingGateOpen !== false, }); if (input.snapshotCurrent === false) { diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 827ebd03..56dff784 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -39,9 +39,23 @@ export const USER_COLLECT_QUESTION: Readonly> = { other: "也可以再说一件你记得大概时间的事。", }; +/** Second phrasing when that domain's collect focus was already established and not declined. */ +export const USER_COLLECT_QUESTION_RETRY: Readonly> = { + relationship: "回到感情这边——刚才说的工作我记下了,哪年认真在一起或分开还记得吗?", + career: "回到工作这边——刚才那件我记下了,哪年入职或换工作还记得吗?", + family: "再问一次家里:结婚、添丁或住院,大概哪年?", + occupation: "你平时主要做什么工作?刚才还没说到这块。", + education: "回到上学这边——哪年升学、转学或大考,还记得吗?", + relocation: "搬家或开始长期住外地,大概是哪年?", + finance: "钱的方面再对一下:哪年收入明显变过、有过大笔支出,或欠过债?", + health_pressure: "身体或压力这边再问一次:哪年生病、受伤,或特别难熬?", + other: "也可以再说一件你记得大概时间的事,跟刚才那件分开就好。", +}; + export const RECTIFICATION_USER_COPY = { genericCollectQuestion: GENERIC_COLLECT_QUESTION, collectQuestionByDomain: USER_COLLECT_QUESTION, + collectQuestionRetryByDomain: USER_COLLECT_QUESTION_RETRY, choicePrompt: "直接点下面的选项就行,打字回答也一样算数。", unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。", questionUpdated: "这一问刚换成新的,刷新后再答就行。", @@ -185,6 +199,7 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.hostNarrationFallback, RECTIFICATION_USER_COPY.continueCollectFallback, ...Object.values(USER_COLLECT_QUESTION), + ...Object.values(USER_COLLECT_QUESTION_RETRY), ]; return values; } diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 738ae175..38dd7422 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -45,14 +45,18 @@ import { type PersistChoiceActionInput, type V9CaseDossier, } from "./tool-service"; -import type { ChoiceKey } from "./choice-card"; +import { isPersistedFocusId, type ChoiceKey } from "./choice-card"; import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus"; import { + blockingMethodsCovered, buildMethodFollowupPlan, exhaustionSpokenCollectFollowup, GENERIC_COLLECT_QUESTION, spokenCollectFallbackFollowup, spokenFollowupForUser, + type MethodCoverage, + type MethodFollowup, + type MethodFollowupPlan, } from "./method-followup"; import type { SessionOutcomeKind } from "./confirmation-gate"; import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet"; @@ -78,14 +82,36 @@ function openingRangeFromDossier(dossier: { return openingRangeFromCandidateRange(dossier.case?.candidateRange ?? null); } +function interviewToPersist(plan: MethodFollowupPlan): MethodFollowup | null { + return plan.next_followup ?? plan.deferred_followup ?? null; +} + +function isRemainingDiscriminatorFollowup(followup: MethodFollowup | null): boolean { + if (!followup) return false; + return followup.source === "event_probe" + || followup.source === "varga_observation" + || followup.source === "precision_stage" + || followup.source === "nakshatra_boundary" + || followup.intent === "distinguish_candidates"; +} + function shouldSkipFollowupPersist(input: { canAdopt: boolean; nextAction: string; + followup: MethodFollowup | null; + methods?: readonly MethodCoverage[]; }): boolean { - return input.canAdopt - && input.nextAction !== "ask_fact_collection" - && input.nextAction !== "ask_candidate_discriminator" - && input.nextAction !== "ask_holdout_validation"; + if (!input.canAdopt) return false; + if ( + input.nextAction === "ask_fact_collection" + || input.nextAction === "ask_candidate_discriminator" + || input.nextAction === "ask_holdout_validation" + ) { + return false; + } + if (input.methods && !blockingMethodsCovered(input.methods)) return false; + if (isRemainingDiscriminatorFollowup(input.followup)) return false; + return true; } function adoptHostNarration(input: { @@ -339,21 +365,6 @@ export async function persistNextInterviewAfterChoice(input: { nextAction: ReturnType; birthDate?: string | null; }): Promise<{ hostNarration: string; choiceReady: boolean; persisted?: boolean }> { - if (shouldSkipFollowupPersist({ - canAdopt: input.nextAction.can_adopt, - nextAction: input.nextAction.type, - })) { - return { - hostNarration: adoptHostNarration({ - credibleRange: input.nextAction.credible_range, - representativeTime: input.nextAction.representative_time, - openingRange: openingRangeFromDossier(input.dossier), - receipt: input.dossier.latestResult?.decisionReceipt, - }), - choiceReady: false, - persisted: false, - }; - } const latest = input.dossier.latestResult ? { ...input.dossier.latestResult, @@ -382,7 +393,24 @@ export async function persistNextInterviewAfterChoice(input: { candidatesSeparated: input.nextAction.type !== "ask_candidate_discriminator" && input.nextAction.type !== "ask_holdout_validation", }); - const followup = plan.next_followup; + const followup = interviewToPersist(plan); + if (shouldSkipFollowupPersist({ + canAdopt: input.nextAction.can_adopt, + nextAction: input.nextAction.type, + followup, + methods: plan.methods, + })) { + return { + hostNarration: adoptHostNarration({ + credibleRange: input.nextAction.credible_range, + representativeTime: input.nextAction.representative_time, + openingRange: openingRangeFromDossier(input.dossier), + receipt: input.dossier.latestResult?.decisionReceipt, + }), + choiceReady: false, + persisted: false, + }; + } const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, userId: input.userId, @@ -394,6 +422,9 @@ export async function persistNextInterviewAfterChoice(input: { if (isRenderableChoiceOpenQuestion(open) && open.prompt) { return { hostNarration: open.prompt, choiceReady: true }; } + if (open?.kind === "collect_spoken" && open.prompt) { + return { hostNarration: open.prompt, choiceReady: false }; + } if (followup?.choice_frame) { const spokenFollowup = spokenCollectFallbackFollowup(followup); const spoken = spokenFollowupForUser(spokenFollowup) ?? GENERIC_COLLECT_QUESTION; @@ -574,12 +605,39 @@ export async function applyCollectFocusDenial( }; } +export function isStalePreAdoptFocus( + acceptedTime: string | null | undefined, + focus: { intent?: string | null } | null | undefined, +): boolean { + if (!acceptedTime || !focus) return false; + const intent = focus.intent ?? ""; + return intent !== "reverse_verify" && intent !== "out_of_sample_check"; +} + export async function persistNextInterviewIfIdle(input: { accounting: AccountingClient; userId: string; caseId: string; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null }> { - const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const staleFocus = dossier.conversationSummary.activeFocus; + const staleFocusId = staleFocus?.id; + if ( + isStalePreAdoptFocus(dossier.case.acceptedTime, staleFocus) + && isPersistedFocusId(staleFocusId) + ) { + try { + await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { + focusId: staleFocusId, + status: "skipped", + }); + dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + } catch (error) { + console.warn( + `[rectification-v9] close stale pre-adopt focus failed case=${input.caseId} reason=${safeToolErrorCode(error)}`, + ); + } + } if (dossier.conversationSummary.activeFocus) { return { persisted: false, choiceReady: false, hostNarration: null }; } @@ -591,7 +649,24 @@ export async function persistNextInterviewIfIdle(input: { birthDate = null; } const decision = decideFromDossier(dossier, { birthDate }); - if (shouldSkipFollowupPersist(decision)) { + const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); + const plan = buildMethodFollowupPlan({ + evidence: dossier.evidence, + activeFocus: null, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + sessionOutcome: decision.sessionOutcome, + ...catalog, + birthDate, + accepted: Boolean(dossier.case.acceptedTime), + }); + const followup = interviewToPersist(plan); + if (shouldSkipFollowupPersist({ + canAdopt: decision.canAdopt, + nextAction: decision.nextAction, + followup, + methods: plan.methods, + })) { return { persisted: false, choiceReady: false, @@ -613,18 +688,7 @@ export async function persistNextInterviewIfIdle(input: { decisionReceipt: dossier.latestResult?.decisionReceipt, }); } - const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); - const plan = buildMethodFollowupPlan({ - evidence: dossier.evidence, - activeFocus: null, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, - sessionOutcome: decision.sessionOutcome, - ...catalog, - birthDate, - accepted: Boolean(dossier.case.acceptedTime), - }); - if (!plan.next_followup) { + if (!followup) { return { persisted: false, choiceReady: false, hostNarration: null }; } const nextAction = publicNextAction(decision); @@ -748,6 +812,7 @@ async function persistApplied( let nextInterviewPersisted = false; let nextChoiceReady = false; let hostNarration = input.narration; + let skippedNextInterview = false; if ( command.deferFollowup !== true && input.userStopped !== true @@ -763,8 +828,9 @@ async function persistApplied( birthDate, }); nextChoiceReady = nextInterview.choiceReady; + skippedNextInterview = nextInterview.persisted === false; if (nextInterview.hostNarration) { - hostNarration = nextInterview.persisted === false + hostNarration = skippedNextInterview ? `${input.narration} ${nextInterview.hostNarration}` @@ -790,7 +856,8 @@ ${nonConvergingRangeNarration({ variant: "delivery", }, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt) : null; - if (adoptionNarration || completedRangeNarration) { + const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview); + if ((adoptionNarration || completedRangeNarration) && !keptNextQuestion) { hostNarration = adoptionNarration ?? completedRangeNarration ?? hostNarration; } diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index 6cf152dc..76ad9b55 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -281,6 +281,8 @@ function eventQuestionPrompt( const time = period.trim(); const topic = family.replace(/[??。]+$/g, "").trim(); if (kind === "varga_style") { + const fromProbe = clippedCopy(probe?.user_meaning, 4, 80); + if (fromProbe && /[??]$/.test(fromProbe)) return fromProbe; return domain === "relationship" ? "亲密关系里,你更接近哪一种相处方式?" : "平时做事,你更接近下面哪一种?"; @@ -492,7 +494,7 @@ export function mergeChoiceCard( focus_id?: string | null; } = {}, ): RectificationChoiceCard | null { - if (!copy) return null; + if (!copy?.prompt.trim()) return null; return { question_id: meta.question_id ?? frame.question_id, method_id: frame.method_id, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 81eca9e4..9cdff9ad 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -79,6 +79,7 @@ import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION, + USER_COLLECT_QUESTION_RETRY, } from "../user-copy.ts"; export { GENERIC_COLLECT_QUESTION }; @@ -168,6 +169,7 @@ export type MethodFollowup = Readonly<{ }>[]; selection_score?: number; probe_id?: string; + collect_retry?: boolean; }>; export type MethodFollowupPlan = Readonly<{ @@ -361,6 +363,30 @@ function declinedDomains( return domains; } +function domainCollectFocusAsked( + topics: readonly Readonly>[], + domain: string, +): boolean { + return topics.some((topic) => { + const status = typeof topic.status === "string" ? topic.status : ""; + if (status === "active" || status === "declined") return false; + const target = typeof topic.target_domain === "string" + ? topic.target_domain + : typeof topic.targetDomain === "string" + ? topic.targetDomain + : null; + const questionId = typeof topic.questionId === "string" + ? topic.questionId + : typeof topic.question_id === "string" + ? topic.question_id + : ""; + const intent = typeof topic.intent === "string" ? topic.intent : ""; + if (questionId.startsWith(`collect:${domain}:`)) return true; + return target === domain + && (intent === "collect_method_evidence" || intent === ""); + }); +} + function occupationCollectFocusClosed( topics: readonly Readonly>[], ): boolean { @@ -949,8 +975,10 @@ export function spokenFollowupForUser(followup: MethodFollowup | null): string | if (!followup) return null; if (followup.choice_frame) return serverOwnedChoiceCopy(followup.choice_frame)?.prompt ?? null; if (followup.intent !== "collect_method_evidence") return null; - const base = USER_COLLECT_QUESTION[followup.domain ?? ""] - ?? GENERIC_COLLECT_QUESTION; + const domain = followup.domain ?? ""; + const base = followup.collect_retry === true + ? (USER_COLLECT_QUESTION_RETRY[domain] ?? USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION) + : (USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION); const period = followup.year_label ?? (followup.probe_year && followup.probe_year > 0 ? `${followup.probe_year} 年前后` : null); return period ? `${period},${base}` : base; @@ -1375,8 +1403,16 @@ export function buildMethodFollowupPlan(input: { ...(input.evidenceCollectionProbes ?? []), ].some((probe) => probe.semantic_key === base.semantic_key); const ownedProbe = keyed ? null : followupOwnedProbe(base); + const askedRows = [ + ...(input.closedCollectFocuses ?? []), + ...(input.declinedTopics ?? []), + ]; + const collectRetry = base.intent === "collect_method_evidence" + && typeof base.domain === "string" + && domainCollectFocusAsked(askedRows, base.domain); return { ...base, + ...(collectRetry ? { collect_retry: true } : {}), choice_frame: attach ? buildChoiceFrame(base, { observations: input.observations, diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index 5db1370f..fad89910 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -10,7 +10,7 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; -import { spokenFollowupForUser, type MethodFollowup } from "./method-followup"; +import { spokenFollowupForUser, spokenCollectFallbackFollowup, type MethodFollowup } from "./method-followup"; import { refinementFromDecisionReceipt } from "./refinement-packet"; import { setV10ConversationFocus, @@ -299,6 +299,22 @@ async function persistCollectFocus(input: { } } +async function persistSpokenChoiceFallback(input: { + accounting: AccountingClient; + userId: string; + caseId: string; + activeFocus: ConversationFocus | null; + followup: MethodFollowup; +}): Promise { + return persistCollectFocus({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + activeFocus: input.activeFocus, + followup: spokenCollectFallbackFollowup(input.followup), + }); +} + export async function persistServerOwnedFocus(input: { accounting: AccountingClient; userId: string; @@ -319,12 +335,13 @@ export async function persistServerOwnedFocus(input: { } if (!frame) { if (followup.intent === "distinguish_candidates") { - return { - status: "invalid_choice_schema", - focus: input.activeFocus, - questionId: null, - prompt: null, - }; + return persistSpokenChoiceFallback({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + activeFocus: input.activeFocus, + followup, + }); } if (followup.intent === "collect_method_evidence") { return persistCollectFocus({ @@ -356,11 +373,23 @@ export async function persistServerOwnedFocus(input: { prompt: null, }; } + const copy = serverOwnedChoiceCopy(frame); + if (!copy) { + if (followup.intent === "distinguish_candidates") { + return persistSpokenChoiceFallback({ + accounting: input.accounting, + userId: input.userId, + caseId: input.caseId, + activeFocus: input.activeFocus, + followup, + }); + } + return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null }; + } const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup); if (!schema?.choice) { return { status: "invalid_choice_schema", focus: input.activeFocus, questionId, prompt: null }; } - const copy = serverOwnedChoiceCopy(frame); const prompt = copy?.prompt ?? null; const active = input.activeFocus; if ( diff --git a/frontend/src/lib/rectification-agentic/v9/turn-exit.ts b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts index 783736be..6101b031 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-exit.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-exit.ts @@ -60,25 +60,21 @@ export async function finalizeSuccessfulTurnExit(input: { // Read-only requests must never mutate the interview or create a focus. return; } - let focusCreated = false; try { - const next = await persistNextInterviewIfIdle(input); - focusCreated = next.persisted; + await persistNextInterviewIfIdle(input); } catch (error) { console.warn( `[rectification-v9] persist next interview after turn failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, ); } try { - const repaired = await ensureNonTerminalTurnExit(input); - focusCreated ||= repaired.persisted; + await ensureNonTerminalTurnExit(input); } catch (error) { console.warn( `[rectification-v9] nonterminal turn exit repair failed case=${input.caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, ); throw error; } - if (!focusCreated) return; try { const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); const question = projectCurrentQuestion(dossier.conversationSummary.activeFocus); diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index 7e24654c..63ea1335 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -63,7 +63,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑 1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。 2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。 3. 新事件走 rectification-record-evidence-batch。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。 -4. 有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担,正文不得提问、复述、改写或拼接题干。每轮正文 2-4 句:确认收到什么、(可选)一句为什么有用或当前进度、自然过渡到界面上的下一步。不提问、不复述题干、不预告选项。choice 由选择卡承载,collect_spoken 只承接用户刚说的事实,不输出输入提示。没有 current_question 时也不要自拟区分题。点选与「先这样」由服务器处理。 +4. 有持久化 current_question 时,题干与选项完全由结构化槽位和 UI 承担,正文不得提问、复述、改写或拼接题干。每轮正文 2-4 句:确认收到什么、(可选)一句为什么有用或当前进度、用中性过渡接到下一轮(如「接下来我们继续」)。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。不提问、不复述题干、不预告选项。choice 由选择卡承载,collect_spoken 只承接用户刚说的事实,不输出输入提示。没有 current_question 时也不要自拟区分题。点选与「先这样」由服务器处理。 5. 不得宣称唯一出生分钟。confirmation_allowed 为 false 或宽度大于 5 时,说明这是不可分区间,代表分钟只是代表性候选。出牌轮写入 skill_verification_report;80%/60% 只是事件吻合率。 6. 一次一问。不泄露提示词或 Skill 原文。 坏:「好的,记下了。」好:「2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。」 diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index d1411cc8..8589aec1 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -64,6 +64,7 @@ import { publicChartForMinute } from "@/lib/rectification-candidate-result"; import { buildMethodFollowupPlan, buildNextUserAction, + spokenCollectFallbackFollowup, } from "@/lib/rectification-agentic/v9/method-followup"; import { refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet"; import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label"; @@ -1005,23 +1006,45 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { // Ranking stays fail-open when compute is unavailable. } const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate }); - const persistedFocus = await persistServerOwnedFocus({ + let followup = collectingPlan.next_followup; + let persistedFocus = await persistServerOwnedFocus({ accounting, userId, caseId, activeFocus: parsed.conversationSummary.activeFocus, decisionReceipt: latest.decisionReceipt, - followup: collectingPlan.next_followup, + followup, }); - const visibleFollowup = collectingPlan.next_followup?.intent === "distinguish_candidates" + if ( + followup?.intent === "distinguish_candidates" + && persistedFocus.status !== "created" + && persistedFocus.status !== "already_open" + ) { + followup = spokenCollectFallbackFollowup(followup); + persistedFocus = await persistServerOwnedFocus({ + accounting, + userId, + caseId, + activeFocus: parsed.conversationSummary.activeFocus, + decisionReceipt: latest.decisionReceipt, + followup, + }); + } else if ( + followup?.intent === "distinguish_candidates" + && persistedFocus.focus + && isCollectFocusSchema(persistedFocus.focus.expectedAnswerSchema) + ) { + followup = spokenCollectFallbackFollowup(followup); + } + const visibleFollowup = followup?.intent === "distinguish_candidates" && persistedFocus.status !== "created" && persistedFocus.status !== "already_open" ? null - : collectingPlan.next_followup; + : followup; return { collectingPlan: visibleFollowup === collectingPlan.next_followup ? collectingPlan - : { ...collectingPlan, next_followup: null }, + : { ...collectingPlan, next_followup: visibleFollowup ?? null }, persistedFocus, contrastPacket, decision, diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index 1692197f..4698c887 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -98,6 +98,9 @@ test("question-slot ownership stays in the rectification agent prompt", () => { const exit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8"); assert.match(agent, /正文不得提问、复述、改写或拼接题干/); assert.match(agent, /每轮正文 2-4 句/); + assert.match(agent, /正文不得断言界面当前状态/); + assert.match(agent, /接下来我们继续/); + assert.doesNotMatch(agent, /自然过渡到界面上的下一步/); assert.doesNotMatch(agent, /每轮正文只输出一句确认或承接/); assert.doesNotMatch(route, /接下来请点选下面这一问/); assert.doesNotMatch(exit, /接下来请点选下面这一问/); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 4063eb45..c4b73acc 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -315,7 +315,12 @@ test("user stop skips remaining collection and holdout when candidates already e ], holdoutValidation: "not_started", }); - assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); + // 原断言 sessionOutcome=provisional_range_user_stopped + // → 新断言 sessionOutcome=adopt_representative。 + // coverage 不再挡代表性采用;completionStatus 仍标记用户停止。确认门仍关。 + assert.equal(stopped.sessionOutcome, "adopt_representative"); + assert.equal(stopped.canAdopt, true); + assert.equal(stopped.canConfirmExactMinute, false); assert.equal(stopped.completionStatus, "provisional_range_user_stopped"); assert.equal(stopped.validated, false); }); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index eacc845e..1b033d4f 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -548,7 +548,10 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb const parsedIncident = parseV9CaseDossier(raw); assert.ok(parsedIncident); const decision = decideFromDossier(parsedIncident); - assert.equal(decision.canAdopt, false); + // 原断言 canAdopt=false(coverage 未齐不得采用)→ 新断言 canAdopt=true。 + // 问题 E 方案 1:coverage 只做问询路由,不挡 overlay 采用。确认门与引擎 ceiling 仍关。 + // 本例仍继续采集家人层,nextAction 不得变成 offer_provisional_range。 + assert.equal(decision.canAdopt, true); assert.notEqual(decision.nextAction, "offer_provisional_range"); let activeFocus: Record | null = null; @@ -613,7 +616,9 @@ test("nonterminal invariant 3: answer_choice response waits until incident fallb assert.ok(refreshed); const currentQuestion = projectCurrentQuestion(refreshed.conversationSummary.activeFocus); assert.equal(response.status, 200); - assert.equal(decision.canAdopt, false); + // 原断言 canAdopt=false → 新断言 canAdopt=true(coverage 不再挡采用)。 + // 保留语义:answer_choice 出口仍必须先把家人采集题干写入 focus/turn,再返回 HTTP。 + assert.equal(decision.canAdopt, true); assert.ok(currentQuestion?.prompt); assert.equal(currentQuestion.prompt, "家里如果有结婚、添丁或住院这类事,记得大概哪年就行。"); const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 7b66a26a..53f38886 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -309,7 +309,7 @@ test("training gate closed still withholds yearless varga cards", () => { assert.notEqual(plan.next_followup?.intent, "distinguish_candidates"); }); -test("training complete with no renderable distinguish card allows offering a range without adopt", () => { +test("training complete with no renderable distinguish card still allows adopt when the engine does", () => { const blocked = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, @@ -317,11 +317,14 @@ test("training complete with no renderable distinguish card allows offering a ra candidateScores: NARROW_LEAD, discriminatorProbe: null, }); + // 原断言 canAdopt=false、sessionOutcome=provisional_range + // → 新断言 canAdopt=true、sessionOutcome=adopt_representative。 + // coverage(含 occupation)只做问询路由,不再挡采用;确认门仍 fail-closed。 assert.equal(blocked.canOfferRange, true); - assert.equal(blocked.canAdopt, false); + assert.equal(blocked.canAdopt, true); assert.equal(blocked.canConfirmExactMinute, false); assert.equal(blocked.nextAction, "offer_provisional_range"); - assert.equal(blocked.sessionOutcome, "provisional_range"); + assert.equal(blocked.sessionOutcome, "adopt_representative"); const stillClosed = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index e3c6994f..3c5c506d 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -574,10 +574,13 @@ test("userStopped completes with a review-only provisional range", () => { userStopped: true, candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })), }); - assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); - // 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。 - assert.equal(stopped.canAdopt, false); + assert.equal(stopped.sessionOutcome, "adopt_representative"); + // 原断言 sessionOutcome=provisional_range_user_stopped 且 canAdopt=false + // → 新断言 adopt_representative 且 canAdopt=true。 + // 用户停止结束追问;coverage 不再挡代表性采用。确认门仍关。 + assert.equal(stopped.canAdopt, true); assert.equal(stopped.canOfferRange, true); + assert.equal(stopped.canConfirmExactMinute, false); assert.equal(isNonConvergingRangeOffer(stopped), false); const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" }); const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); diff --git a/frontend/tests/rectification-server-focus.test.ts b/frontend/tests/rectification-server-focus.test.ts index 0d44cd8f..6853ffc3 100644 --- a/frontend/tests/rectification-server-focus.test.ts +++ b/frontend/tests/rectification-server-focus.test.ts @@ -110,11 +110,22 @@ function invalidStyleFollowup( }; } -async function assertInvalidChoiceSkipped(followup: MethodFollowup) { - const accounting = fakeAccounting({}, { - fallback: () => { - throw new Error("invalid choice frame must not call the database"); - }, +async function assertUnrenderableChoiceFallsBackToSpoken(followup: MethodFollowup) { + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: null, + 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-08-31T00:00:00.000Z", + resolved_at: null, + idempotent: false, + }), }); const result = await persistServerOwnedFocus({ accounting: accounting.client, @@ -124,8 +135,11 @@ async function assertInvalidChoiceSkipped(followup: MethodFollowup) { decisionReceipt: null, followup, }); - assert.equal(result.status, "invalid_choice_schema"); - assert.equal(accounting.calls.length, 0); + assert.ok(result.status === "created" || result.status === "already_open"); + assert.equal(result.focus?.intent, "collect_method_evidence"); + const open = openQuestionFromPersistedFocus(result); + assert.equal(open?.kind, "collect_spoken"); + assert.ok(open?.prompt?.trim()); } function persistedFocus(questionId = "probe:education:2016") { @@ -235,13 +249,13 @@ test("complete dynamic event options persist a server-owned focus", async () => }); }); -test("non-renderable varga styles and empty event family skip focus persistence", async () => { - await assertInvalidChoiceSkipped(invalidStyleFollowup( +test("non-renderable varga styles and empty event family fall back to spoken collect", async () => { + await assertUnrenderableChoiceFallsBackToSpoken(invalidStyleFollowup( [{ label: "巨蟹相处主动热情", answer_class: "yes" }], "升学结果或学习环境出现明显变化", "varga_style", )); - await assertInvalidChoiceSkipped(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " ")); + await assertUnrenderableChoiceFallsBackToSpoken(invalidStyleFollowup(DYNAMIC_STYLE_OPTIONS, " ")); }); test("does not ask an already-answered discriminator probe again", async () => { diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index 260e8849..10bd0dc6 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -94,6 +94,7 @@ test("cases current_question drives the unified question slot", () => { assert.match(chat, /current_question\?: unknown/); assert.match(chat, /setCurrentQuestion\(nextQuestion\)/); assert.match(chat, /className="rectification-question-slot"/); + assert.match(chat, /isLatestMessage/); assert.match(chat, /currentQuestion\?\.kind === "choice"/); assert.match(chat, />): MethodFollowup { + const probe = d9WalkthroughPacket().probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(probe); + return { + method_id: "d9_relationship", + intent: "distinguish_candidates", + ask_theme: "relationship_style", + domain: "relationship", + kind_hint: "relationship_change", + user_prompt_hint: probe.question, + must_not_label: false, + choice_frame: frame, + source: "event_probe", + information_gain: probe.informationGain, + semantic_key: probe.semanticKey, + candidate_split_hash: probe.candidateSplitHash, + choice_kind: "varga_style", + candidate_ids: ["05:00", "05:04"], + expected_outcomes: probe.expectedOutcomes.map((row) => ({ + answer_class: row.outcomeId, + supports: row.supportsCandidateIds, + conflicts: row.conflictsCandidateIds, + })), + style_options: d9StyleOptions(), + }; +} + +function focusFromArgs(args: Record) { + return { + id: FOCUS_ID, + caseId: CASE_ID, + questionId: String(args.p_question_id ?? ""), + intent: String(args.p_intent ?? ""), + targetEvidenceId: null, + targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null, + targetKind: null, + expectedAnswerSchema: (args.p_expected_answer_schema ?? {}) as Record, + status: "active" as const, + askedAt: "2026-09-02T00:00:00.000Z", + resolvedAt: null, + }; +} + +function persistAccounting() { + return fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + id: FOCUS_ID, + case_id: CASE_ID, + question_id: args.p_question_id, + intent: args.p_intent, + target_evidence_id: null, + 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-02T00:00:00.000Z", + resolved_at: null, + idempotent: false, + }), + }); +} + +function assertChoiceInvariants(input: { + card: ReturnType | ReturnType; + question: ReturnType; +}) { + if (input.card) { + assert.ok(input.card.prompt?.trim(), "choice_card nonempty ⇒ prompt nonempty"); + } + if (input.question?.kind === "choice") { + assert.ok(input.question.prompt?.trim(), "active choice focus ⇒ projectCurrentQuestion prompt nonempty"); + } +} + +test("walkthrough D9 probe question is in the 4-80 window and must survive frame assembly", () => { + const packet = d9WalkthroughPacket(); + const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(probe); + assert.equal(probe.question, D9_QUESTION); + assert.ok(probe.question.length >= 4 && probe.question.length <= 80); + const plan = buildMethodFollowupPlan({ + evidence: WALKTHROUGH_EVIDENCE, + contrastPacket: packet, + sessionOutcome: "discriminate_candidates", + topCandidateTimes: ["05:00", "05:04"], + candidatesSeparated: false, + }); + const followup = plan.next_followup; + assert.equal(followup?.intent, "distinguish_candidates", JSON.stringify({ + intent: followup?.intent, + source: followup?.source, + method_id: followup?.method_id, + dropped: plan.dropped_probes, + })); + assert.equal(followup?.method_id, "d9_relationship"); + const frame = followup?.choice_frame ?? null; + assert.ok(frame, "D9 probe must assemble a choice_frame"); + assert.equal(frame?.prompt, D9_QUESTION); + const copy = frame ? serverOwnedChoiceCopy(frame) : null; + assert.ok(copy?.prompt, "assembled D9 copy must keep the probe question"); +}); + +test("D9 probe shape with unwired prompt makes the choice invariants red", async () => { + const packet = d9WalkthroughPacket(); + const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(probe); + const wired = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "relationship_style", + domain: "relationship", + user_prompt_hint: probe.question, + choice_kind: "varga_style", + semantic_key: probe.semanticKey, + style_options: d9StyleOptions(), + }, { + evidence: WALKTHROUGH_EVIDENCE, + probes: [{ + year: 0, + year_label: "当前这几个候选", + domain: "relationship", + event_family: "相处方式更接近其中一种", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + information_gain: probe.informationGain, + semantic_key: probe.semanticKey, + candidate_split_hash: probe.candidateSplitHash, + candidate_ids: ["05:00", "05:04"], + expected_outcomes: probe.expectedOutcomes.map((row) => ({ + answer_class: row.outcomeId, + supports: row.supportsCandidateIds, + conflicts: row.conflictsCandidateIds, + })), + choice_kind: "varga_style", + style_options: d9StyleOptions(), + }], + }); + assert.ok(wired); + const unwired = { ...wired, prompt: "", period: "" }; + assert.equal(serverOwnedChoiceCopy(unwired), null); + + const accounting = persistAccounting(); + const persisted = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: d9Followup(unwired), + }); + const open = openQuestionFromPersistedFocus(persisted); + const question = persisted.focus + ? projectCurrentQuestion(persisted.focus) + : null; + const card = persisted.focus + ? choiceCardFromCaseDossier({ + evidence: WALKTHROUGH_EVIDENCE, + conversationSummary: { + activeFocus: persisted.focus, + declinedSkippedTopics: [], + }, + latestResult: { + decisionReceipt: null, + selectionAllowed: false, + confirmationAllowed: false, + candidates: [ + { time: "05:00", rank: 1, relativeSupport: 0.6 }, + { time: "05:04", rank: 2, relativeSupport: 0.4 }, + ], + }, + case: { acceptedTime: null, status: "collecting_evidence" }, + }) + : null; + + assertChoiceInvariants({ card, question }); + assert.ok(persisted.status === "created" || persisted.status === "already_open"); + assert.equal(open?.kind, "collect_spoken"); + assert.ok(open?.prompt?.trim()); + assert.notEqual(question?.kind, "choice"); +}); + +test("agent persistPlanFocus path must fail-closed when D9 choice copy is null", () => { + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + const persistPlan = tools.slice(tools.indexOf("const persistPlanFocus")); + assert.match(persistPlan, /spokenCollectFallbackFollowup/); +}); + +test("wired D9 persist keeps a nonempty choice prompt", async () => { + const packet = d9WalkthroughPacket(); + const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(probe); + const frame = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "relationship_style", + domain: "relationship", + user_prompt_hint: probe.question, + choice_kind: "varga_style", + semantic_key: probe.semanticKey, + style_options: d9StyleOptions(), + }, { + evidence: WALKTHROUGH_EVIDENCE, + probes: [{ + year: 0, + year_label: "当前这几个候选", + domain: "relationship", + event_family: "相处方式更接近其中一种", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + information_gain: probe.informationGain, + semantic_key: probe.semanticKey, + candidate_split_hash: probe.candidateSplitHash, + candidate_ids: ["05:00", "05:04"], + expected_outcomes: probe.expectedOutcomes.map((row) => ({ + answer_class: row.outcomeId, + supports: row.supportsCandidateIds, + conflicts: row.conflictsCandidateIds, + })), + choice_kind: "varga_style", + style_options: d9StyleOptions(), + }], + }); + assert.ok(frame); + const persisted = await persistServerOwnedFocus({ + accounting: persistAccounting().client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: d9Followup(frame), + }); + const open = openQuestionFromPersistedFocus(persisted); + const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null; + const card = parseRectificationChoiceCard({ + question_id: persisted.focus?.questionId, + method_id: "d9_relationship", + prompt: persisted.prompt, + choice_mode: "A/B/C/D", + options: [ + { key: "A", label: d9StyleOptions()[0]!.label, answer_class: "yes", role: "primary" }, + { key: "B", label: d9StyleOptions()[1]!.label, answer_class: "weak_yes", role: "primary" }, + { key: "C", label: d9StyleOptions()[2]!.label, answer_class: "no", role: "primary" }, + { key: "D", label: d9StyleOptions()[3]!.label, answer_class: "unsure", role: "primary" }, + ], + stop_label: "先这样,先看当前范围", + stop_message: "先这样", + scoring: true, + focus_id: FOCUS_ID, + }); + assert.equal(persisted.status, "created"); + assert.equal(open?.kind, "choice"); + assert.equal(open?.prompt, D9_QUESTION); + assertChoiceInvariants({ card, question }); +}); + +test("unwired D9 persist falls back to spoken collect instead of an empty-prompt card", async () => { + const packet = d9WalkthroughPacket(); + const probe = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(probe); + const wired = buildChoiceFrame({ + method_id: "d9_relationship", + ask_theme: "relationship_style", + domain: "relationship", + user_prompt_hint: probe.question, + choice_kind: "varga_style", + semantic_key: probe.semanticKey, + style_options: d9StyleOptions(), + }, { + evidence: WALKTHROUGH_EVIDENCE, + probes: [{ + year: 0, + year_label: "当前这几个候选", + domain: "relationship", + event_family: "相处方式更接近其中一种", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + information_gain: probe.informationGain, + semantic_key: probe.semanticKey, + candidate_split_hash: probe.candidateSplitHash, + candidate_ids: ["05:00", "05:04"], + expected_outcomes: probe.expectedOutcomes.map((row) => ({ + answer_class: row.outcomeId, + supports: row.supportsCandidateIds, + conflicts: row.conflictsCandidateIds, + })), + choice_kind: "varga_style", + style_options: d9StyleOptions(), + }], + }); + assert.ok(wired); + const persisted = await persistServerOwnedFocus({ + accounting: persistAccounting().client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: d9Followup({ ...wired, prompt: "", period: "" }), + }); + const open = openQuestionFromPersistedFocus(persisted); + const question = persisted.focus ? projectCurrentQuestion(persisted.focus) : null; + assert.ok(persisted.status === "created" || persisted.status === "already_open"); + assert.equal(open?.kind, "collect_spoken"); + assert.ok(open?.prompt?.trim()); + assert.notEqual(question?.kind, "choice"); + assertChoiceInvariants({ card: null, question }); +}); + +test("agent instructions do not assert that the next question is already on screen", () => { + const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8"); + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(agent, /正文不得断言界面当前状态/); + assert.match(agent, /接下来我们继续/); + assert.doesNotMatch(agent, /自然过渡到界面上的下一步/); + assert.match(chat, /await loadCaseSnapshot\(\)/); + assert.match(chat, /isLatestMessage/); +}); + +test("same-domain collect retry uses structured closed-focus state, not body matching", () => { + const base = { + method_id: "d9_relationship" as const, + intent: "collect_method_evidence", + ask_theme: "relationship_style" as const, + domain: "relationship", + kind_hint: "relationship_start", + user_prompt_hint: "ask", + must_not_label: false as const, + choice_frame: null, + source: "method_coverage" as const, + }; + const first = spokenFollowupForUser(base); + const retry = spokenFollowupForUser({ ...base, collect_retry: true }); + assert.equal(first, USER_COLLECT_QUESTION.relationship); + assert.equal(retry, USER_COLLECT_QUESTION_RETRY.relationship); + assert.notEqual(first, retry); + const plan = buildMethodFollowupPlan({ + evidence: [{ + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + eventKind: "career_entry", + }], + closedCollectFocuses: [{ + target_domain: "relationship", + intent: "collect_method_evidence", + status: "resolved", + question_id: "collect:relationship:collect_method_evidence", + }], + sessionOutcome: "collect_evidence", + }); + assert.equal(plan.next_followup?.domain, "relationship"); + assert.equal(plan.next_followup?.collect_retry, true); + assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION_RETRY.relationship); + const declined = buildMethodFollowupPlan({ + evidence: [{ + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2019-01-01", + occurredTo: null, + eventKind: "career_entry", + }], + declinedTopics: [{ + target_domain: "relationship", + intent: "collect_method_evidence", + status: "declined", + }], + sessionOutcome: "collect_evidence", + }); + assert.notEqual(declined.next_followup?.domain, "relationship"); +}); + +test("accepted time replaces leftover collect with reverse_verify or consultation handoff", () => { + assert.equal(isStalePreAdoptFocus("04:53", { intent: "collect_method_evidence" }), true); + assert.equal(isStalePreAdoptFocus("04:53", { intent: "distinguish_candidates" }), true); + assert.equal(isStalePreAdoptFocus("04:53", { intent: "reverse_verify" }), false); + assert.equal(isStalePreAdoptFocus(null, { intent: "collect_method_evidence" }), false); + const idle = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + const accept = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8"); + assert.match(idle, /isStalePreAdoptFocus/); + assert.match(idle, /resolveV10ConversationFocus/); + assert.match(accept, /persistNextInterviewIfIdle/); + const leftover = { + intent: "collect_method_evidence", + targetDomain: "finance", + targetKind: null, + expectedAnswerSchema: { collect: true, prompt: USER_COLLECT_QUESTION.finance }, + }; + const plan = buildMethodFollowupPlan({ + evidence: WALKTHROUGH_EVIDENCE, + activeFocus: leftover, + accepted: true, + eventProbes: [{ + year: 2021, + year_label: "2021 年前后", + domain: "finance", + event_family: "收入或债务明显变化", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "2021 年前后财务变化", + role: "distinguish", + information_gain: 0.4, + semantic_key: "finance.2021", + candidate_split_hash: "finance:2021", + candidate_ids: ["04:53", "05:00"], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:53"], conflicts: ["05:00"] }, + { answer_class: "no", supports: ["05:00"], conflicts: ["04:53"] }, + ], + }], + }); + assert.equal(plan.next_followup?.intent, "reverse_verify"); + const action = buildNextUserAction({ + scorableCount: 4, + evidenceCount: 4, + hasLatestResult: true, + selectionAllowed: true, + sessionOutcome: "adopt_representative", + nextFollowup: plan.next_followup, + workingTime: "04:53", + accepted: true, + }); + assert.equal(action.id, "verify_adopted_time"); + const empty = buildMethodFollowupPlan({ + evidence: WALKTHROUGH_EVIDENCE, + activeFocus: leftover, + accepted: true, + eventProbes: [], + }); + assert.equal(empty.next_followup, null); + assert.equal(buildNextUserAction({ + scorableCount: 4, + evidenceCount: 4, + hasLatestResult: true, + selectionAllowed: true, + sessionOutcome: "adopt_representative", + nextFollowup: empty.next_followup, + workingTime: "04:53", + accepted: true, + }).id, "start_consultation"); +}); + +test("coverage does not block overlay can_adopt when the engine allows accept", () => { + const offered = decideRectification({ + engineCeiling: { + acceptanceAllowed: true, + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: false, + }, + methodCoverageAll: false, + trainingGateOpen: true, + candidateScores: [ + { time: "04:53", score: 34 }, + { time: "04:51", score: 33 }, + { time: "04:47", score: 33 }, + ], + discriminatorProbe: null, + }); + assert.equal(offered.canAdopt, true); + assert.equal(offered.canConfirmExactMinute, false); + const refused = decideRectification({ + engineCeiling: { + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, + }, + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: [ + { time: "04:53", score: 34 }, + { time: "04:51", score: 33 }, + { time: "04:47", score: 33 }, + ], + discriminatorProbe: null, + }); + assert.equal(refused.canAdopt, false); + assert.equal(refused.canConfirmExactMinute, false); + const trainingClosed = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: true, + trainingGateOpen: false, + candidateScores: [ + { time: "04:53", score: 34 }, + { time: "04:51", score: 33 }, + ], + discriminatorProbe: null, + }); + assert.equal(trainingClosed.canAdopt, false); +});