fix(rectification): keep choice stems visible and align overlay adopt with the engine
Independent Staging Quality Gate / validate (push) Successful in 7m44s
Independent Staging Quality Gate / publish (push) Successful in 1m48s

Walkthrough polish: fail-closed empty D9 prompts, stop asserting the next question is on screen, vary same-domain collect copy, switch to reverse-verify after adopt, and let coverage route interview without blocking can_adopt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-02 13:11:22 +08:00
co-authored by Cursor
parent bd79689f66
commit aab3ad4b84
22 changed files with 1019 additions and 82 deletions
+78
View File
@@ -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 挡采用)
- 修复版本:待发布
+2
View File
@@ -30,11 +30,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 |
| 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 |
| 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 |
| 界面上继续有下一问。 | 接下来我们继续。 | 正文不得断言界面当前有没有题;题干在问题槽,过渡用中性句。 |
| ## 统一参数与原始结构(用户刚问「我婚姻怎么样」) | 先用几句口语答婚姻方向,再进入统一参数与原始结构。 | 骨架仍在,开场先回人。 |
## 红线(语气改不了这些)
- 不得虚构星盘事实或唯一出生分钟。
- 问题槽仍由服务端 focus 唯一所有;正文不得出题、复述或改写题干。
- 正文不得断言界面当前状态(「界面上有/出现了下一问」);过渡用「接下来我们继续」这类中性句。
- 交付/采用轮必须出现「不是已确认的唯一出生分钟」这一语义。
- 技法审计表仍在本命回答文末。
@@ -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<string, unknown>;
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,
+8
View File
@@ -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);
@@ -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 (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
@@ -1185,9 +1186,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{isLatestMessage && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}`}
card={choiceCard}
pending={busy}
disabled={readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{collectSpokenPrompt && !showLiveChoiceCard && !readonly && (
<p id={collectSpokenPromptId} className="rectification-question-slot__prompt">
{collectSpokenPrompt}
</p>
)}
{showMissingQuestion && (
<p className="rectification-question-slot__status" role="status">
当前没有可回答的问题,正在等待服务端更新。
</p>
)}
{showUnavailableQuestion && (
<p className="rectification-question-slot__status" role="status">
当前问题暂时无法显示,请等待服务端更新。
</p>
)}
</section>
)}
</div>
);
})}
{messages.length === 0 && (
<section className="rectification-question-slot" aria-label="当前问题">
{showLiveChoiceCard && choiceCard && (
<RectificationChoiceCard
@@ -1216,6 +1248,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</p>
)}
</section>
)}
{savedTime && savedStatus === "confirmed" && (
<p className="rectification-saved" role="status">
已确认校正时间:{savedTime}
@@ -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) {
@@ -39,9 +39,23 @@ export const USER_COLLECT_QUESTION: Readonly<Record<string, string>> = {
other: "也可以再说一件你记得大概时间的事。",
};
/** Second phrasing when that domain's collect focus was already established and not declined. */
export const USER_COLLECT_QUESTION_RETRY: Readonly<Record<string, string>> = {
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;
}
@@ -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<typeof publicNextAction>;
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;
}
@@ -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,
@@ -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<Record<string, unknown>>[],
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<Record<string, unknown>>[],
): 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,
@@ -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<PersistServerFocusResult> {
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 (
@@ -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);
+1 -1
View File
@@ -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 月上大学,记下了——这类有明确月份的节点对校正特别有用。」
+28 -5
View File
@@ -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,
@@ -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, /接下来请点选下面这一问/);
@@ -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);
});
@@ -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<string, unknown> | 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");
@@ -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,
@@ -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" });
@@ -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 () => {
@@ -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, /<RectificationChoiceCard/);
assert.match(chat, /onSelect=\{submitChoice\}/);
@@ -0,0 +1,602 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildCandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { decideRectification } 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 { isStalePreAdoptFocus } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import {
buildChoiceFrame,
parseRectificationChoiceCard,
serverOwnedChoiceCopy,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
spokenFollowupForUser,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { completeStyleOptions } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import {
openQuestionFromPersistedFocus,
persistServerOwnedFocus,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { d9StyleLabel } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts";
import {
CASE_ID,
FOCUS_ID,
OPEN_ENGINE_CAPABILITY_CEILING,
USER_ID,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const D9_QUESTION = "亲密关系里,你更接近哪一种相处方式?";
const WALKTHROUGH_EVIDENCE = [
{
status: "confirmed" as const,
domain: "education",
datePrecision: "year" as const,
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed" as const,
domain: "relationship",
datePrecision: "year" as const,
occurredFrom: "2018-01-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
status: "confirmed" as const,
domain: "career",
datePrecision: "year" as const,
occurredFrom: "2019-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
status: "confirmed" as const,
domain: "family",
datePrecision: "year" as const,
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "family_event",
},
];
function d9WalkthroughPacket() {
return buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:04"],
transitions: [
{ layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" },
],
});
}
function d9StyleOptions() {
const completed = completeStyleOptions({
choiceKind: "varga_style",
styleOptions: [
{ sign: "巨蟹座", label: d9StyleLabel("巨蟹座"), answer_class: "yes" },
{ sign: "狮子座", label: d9StyleLabel("狮子座"), answer_class: "weak_yes" },
],
});
assert.equal(completed.ok, true);
if (!completed.ok) throw new Error("D9 style options");
return completed.options;
}
function d9Followup(frame: NonNullable<ReturnType<typeof buildChoiceFrame>>): 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<string, unknown>) {
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<string, unknown>,
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<typeof parseRectificationChoiceCard> | ReturnType<typeof choiceCardFromCaseDossier>;
question: ReturnType<typeof projectCurrentQuestion>;
}) {
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);
});