fix(rectification): show spoken collect questions in the chat UI
Independent Staging Quality Gate / validate (push) Successful in 10m32s
Independent Staging Quality Gate / publish (push) Successful in 8m46s

Spoken collect prompts lived only in GET current_question. The chat never
parsed that field, Agent projections returned null after evidence writes,
and active_focus followups collapsed the questionId. Render the parsed
prompt, keep choiceReady on real cards, and give collect focuses a stable
domain-scoped id.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-30 00:51:14 +08:00
parent 3a0319694e
commit f5c02924c9
13 changed files with 452 additions and 15 deletions
+16
View File
@@ -6751,3 +6751,19 @@
- 复发自:无
- 修复版本:待发布
## BUG-441 | 口述采集题已落库但界面看不到,助手只说看下面这一问
- 状态:resolved
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:GET `current_question``applyCaseSnapshot``openQuestionFromPersistedFocus``agentVisibleLatestProjection``stableFollowupQuestionId`、POST `/api/rectification/agent` 出卡判定、Agent 系统提示第 4 条
- 用户现象:助手正文只写「接下来看下面这一问。」,界面没有任何问题。GET 里 `choice_card` 为 null`current_question` 已是合法口述采集题(`kind=collect_spoken`、prompt 非空),问题只存在于数据库和 API。
- 触发条件:方法覆盖回落到不带 choice_frame 的口述采集焦点;Agent 按选择卡承接措辞说话;前端只解析 `choice_card`
- 根因:三段同时成立。(1) 前端从不渲染 `current_question``applyCaseSnapshot` 只解析 `latest_result` / `choice_card` / `case``kind=collect_spoken` 在界面上没有承载位置。(2) 面向 Agent 的两套投影自相矛盾:`projectTurnDecision` 给出 `current_question.kind=collect_spoken` 与 prompt;记完证据后的 `agentVisibleLatestProjection` 取自 `openQuestionFromPersistedFocus`,后者对 collect focus 一律返回 null。Agent 收到打架信号,退回「卡片会显示」的措辞。(3) `stableFollowupQuestionId` 在 followup 既无 semantic_key 也无 probe_year 时退到 `${method_id}:${ask_theme}``persistPlanFocus` 传入的 active_focus 计划正好两者都没有,questionId 退化成 `active_focus:active_focus`
- 修复:P0:新增 fail-closed `parseRectificationSpokenCollect`,只接受 `kind=collect_spoken` 且 prompt 通过既有裁剪规则的对象。`applyCaseSnapshot` 解析 `current_question``choice_card` 为空且非 busy/readonly/regenerating 时,在最后一条助手消息下用现有选择卡轻量样式展示题干,不出 A/B/C/D。消失由服务端 `current_question` 变 null 驱动,不用正文文本判断是否已问过。P1:`openQuestionFromPersistedFocus` 对 collect focus 返回带 `kind=collect_spoken` 的同形结果;`choiceReady` / 出卡判定改为 `isRenderableChoiceOpenQuestion`,只对 `kind=choice` 为真,collect 不得触发「请点选」。`current_probe``inference.next_probe` 仍只对可渲染选择题放行。系统提示改为口述题干由界面提示条展示,正文不复述;「请点选」「看下面这一问」只允许在有选择卡时使用。P2:collect 在无 semantic_key / probe_year 时用 `collect:<domain>:<intent>``active_focus:active_focus` 不得与新 id 互相 already_open。Skill 保持 10.0.13,未放宽 confirmation gate。
- 验证:`frontend/tests/rectification-spoken-collect.test.ts` 锁解析器 fail-closed、卡片优先不渲染口述条、collect openQuestion 不为卡片、probe 在 collect 下为空、active_focus 派生 questionId 带 domain。BUG-440 collect-stall 全绿,D12 同领域计分卡仍优先于家人口述题。`npx tsc --noEmit` 通过;eslint 改动文件 0 error。指定套件 `tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 679 项、0 失败。全量 `tests/*.test.ts` 2273 项、0 失败。失败集合未扩大到产品逻辑。
- 防复发:服务端落下的问题必须有可见承载,不得只靠 Agent 正文。同一焦点在所有面向 Agent 的投影里形态必须一致。「请点选」类措辞只允许在有选择卡时使用。不得用正文文本判断助手有没有问出来。collect questionId 不得退化为 `active_focus:active_focus`
- 相关记录:BUG-404、BUG-411、BUG-432、BUG-437、BUG-440
- 复发自:BUG-432(无选择卡的问题曾要求由 Agent 正文问出,前端仍不渲染 `current_question`
- 修复版本:待发布
@@ -32,7 +32,7 @@ import {
shouldDeclineCollectFocus,
shouldContinueAgentForDatedEvent,
} from "@/lib/rectification-agentic/v9/turn-intent-classifier";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema } from "@/lib/rectification-agentic/v9/server-focus";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
export const runtime = "nodejs";
@@ -451,7 +451,7 @@ export async function POST(request: Request) {
followup: plan.next_followup,
});
const open = openQuestionFromPersistedFocus(persisted);
if (open && persisted.focus && open.unrenderable !== true) {
if (open && persisted.focus && isRenderableChoiceOpenQuestion(open)) {
let classified = null;
try {
classified = await classifyRectificationTurnIntent(selectedModel, {
+7
View File
@@ -2902,6 +2902,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.rectification-spoken-collect-prompt {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
line-height: 1.55;
}
.rectification-candidates-heading { display: grid; gap: 5px; }
.rectification-candidates-heading strong { font-size: var(--type-title-sm); font-family: var(--font-display); font-weight: 600; letter-spacing: -.2px; }
.rectification-candidates-heading span,
@@ -59,8 +59,10 @@ import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agen
import {
isPersistedFocusId,
parseRectificationChoiceCard,
parseRectificationSpokenCollect,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
type RectificationSpokenCollect,
} from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
@@ -286,6 +288,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [choiceCard, setChoiceCard] = useState<ChoiceCardModel | null>(null);
const [spokenCollect, setSpokenCollect] = useState<RectificationSpokenCollect | null>(null);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
@@ -388,15 +391,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const applyCaseSnapshot = useCallback((payload: {
latest_result?: unknown;
choice_card?: unknown;
current_question?: unknown;
case?: { accepted_time?: unknown; confirmed_time?: unknown };
} | null) => {
if (!payload) return;
const nextCandidate = parseRectificationCandidateResult(payload.latest_result);
const nextChoice = parseRectificationChoiceCard(payload.choice_card);
const nextSpoken = parseRectificationSpokenCollect(payload.current_question);
const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null;
const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null;
setCandidateResult(nextCandidate);
setChoiceCard(nextChoice);
setSpokenCollect(nextSpoken);
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1047,6 +1053,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
choiceCardsOpen.current = showLiveChoiceCard;
updateFollowState();
}, [showLiveChoiceCard, updateFollowState]);
const showLiveSpokenCollect = Boolean(
!choiceCard
&& spokenCollect
&& liveChoiceHost
&& !busy
&& !readonly
&& regeneratingMessageKey === null,
);
const showSelectionCards = Boolean(
candidateResult?.selectionAllowed
&& offeredSelectionOnce
@@ -1061,6 +1075,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const liveSpokenMessageKey = showLiveSpokenCollect && liveChoiceHost
? liveChoiceHost.renderKey
: undefined;
const canSend = !busy && !readonly && !regeneratingMessageKey;
function submitChoice(key: ChoiceKey) {
@@ -1128,6 +1145,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey
? choiceCard
: null;
const liveSpoken = showLiveSpokenCollect && message.renderKey === liveSpokenMessageKey
? spokenCollect
: null;
const visibleChoiceCard = settledChoice?.card ?? liveChoice;
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
@@ -1180,6 +1200,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onStop={submitStop}
/>
)}
{liveSpoken && (
<section className="rectification-choice-card" aria-label="口述采集题">
<p className="rectification-spoken-collect-prompt">{liveSpoken.prompt}</p>
</section>
)}
</div>
);
})}
@@ -40,7 +40,7 @@ import {
type V9CaseDossier,
} from "./tool-service";
import type { ChoiceKey } from "./choice-card";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus } from "./server-focus";
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isRenderableChoiceOpenQuestion } from "./server-focus";
import {
buildMethodFollowupPlan,
spokenCollectFallbackFollowup,
@@ -318,7 +318,7 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (open && open.unrenderable !== true) {
if (isRenderableChoiceOpenQuestion(open)) {
return { hostNarration: "接下来请点选下面这一问。", choiceReady: true };
}
if (followup?.choice_frame) {
@@ -516,6 +516,30 @@ export function choiceCardUserMessage(
return card.scoring ? line : `${HOLDOUT_MESSAGE_PREFIX}${line}`;
}
export type RectificationSpokenCollect = Readonly<{
kind: "collect_spoken";
question_id: string | null;
prompt: string;
domain: string | null;
}>;
export function parseRectificationSpokenCollect(value: unknown): RectificationSpokenCollect | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
if (row.kind !== "collect_spoken") return null;
if (row.choice_mode === CHOICE_MODE || Array.isArray(row.options) || row.choice) return null;
const prompt = clippedProbeLabel(row.prompt, 2, 400);
if (!prompt) return null;
return {
kind: "collect_spoken",
question_id: typeof row.question_id === "string" && row.question_id.trim()
? row.question_id.trim()
: null,
prompt,
domain: typeof row.domain === "string" && row.domain.trim() ? row.domain.trim() : null,
};
}
export function parseRectificationChoiceCard(value: unknown): RectificationChoiceCard | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
@@ -39,6 +39,12 @@ export function stableFollowupQuestionId(followup: MethodFollowup): string {
if (followup.probe_year && followup.domain) {
return `${followup.method_id}:${followup.domain}:${followup.probe_year}`.slice(0, 160);
}
if (followup.intent === "collect_method_evidence" && !followup.choice_frame) {
const domain = followup.domain && followup.domain !== "active_focus"
? followup.domain
: "unknown";
return `collect:${domain}:${followup.intent}`.slice(0, 160);
}
return (followup.choice_frame?.question_id ?? `${followup.method_id}:${followup.ask_theme}`).slice(0, 160);
}
@@ -98,25 +104,49 @@ function expectedAnswerSchemaFor(
return state && stamped.scoring !== false && !schemaProbeId(stamped) ? null : stamped;
}
export function openQuestionFromPersistedFocus(result: PersistServerFocusResult): {
export type PersistedOpenQuestion = Readonly<{
question_id: string | null;
prompt: string | null;
status: PersistServerFocusStatus;
kind: "choice" | "collect_spoken";
focus_id?: string | null;
probe_id?: string | null;
intent?: string;
domain?: string | null;
unrenderable?: true;
reason?: string;
} | null {
}>;
export function openQuestionFromPersistedFocus(result: PersistServerFocusResult): PersistedOpenQuestion | null {
if (
(result.status !== "created" && result.status !== "already_open")
|| !result.focus
|| !isPersistedFocusId(result.focus.id)
|| result.focus.questionId !== result.questionId
) return null;
if (!result.prompt || !parseAgentChoiceCopy(result.focus.expectedAnswerSchema)) {
if (result.focus.expectedAnswerSchema?.collect === true) return null;
const schema = result.focus.expectedAnswerSchema;
if (isCollectFocusSchema(schema) && schema) {
const prompt = (typeof result.prompt === "string" && result.prompt.trim()
? result.prompt.trim()
: typeof schema.prompt === "string" ? schema.prompt.trim() : "");
if (!prompt) return null;
return {
question_id: result.questionId,
prompt,
status: result.status,
kind: "collect_spoken",
focus_id: result.focus.id,
probe_id: typeof schema.probe_id === "string" ? schema.probe_id : null,
intent: result.focus.intent,
domain: result.focus.targetDomain ?? null,
};
}
if (!result.prompt || !parseAgentChoiceCopy(schema)) {
return {
question_id: result.questionId,
prompt: null,
status: result.status,
kind: "choice",
unrenderable: true,
reason: "invalid_choice_schema",
};
@@ -125,9 +155,16 @@ export function openQuestionFromPersistedFocus(result: PersistServerFocusResult)
question_id: result.questionId,
prompt: result.prompt,
status: result.status,
kind: "choice",
};
}
export function isRenderableChoiceOpenQuestion(
open: PersistedOpenQuestion | null | undefined,
): open is PersistedOpenQuestion & { kind: "choice" } {
return Boolean(open && open.kind === "choice" && open.unrenderable !== true);
}
export const COLLECT_FOCUS_SCHEMA_KEY = "collect";
function collectFocusSchema(followup: MethodFollowup): Record<string, unknown> | null {
@@ -164,7 +201,12 @@ async function persistCollectFocus(input: {
const questionId = stableFollowupQuestionId(input.followup);
const prompt = typeof schema.prompt === "string" ? schema.prompt : null;
const active = input.activeFocus;
if (active && active.questionId === questionId && isCollectFocusSchema(active.expectedAnswerSchema)) {
if (
active
&& active.questionId === questionId
&& active.questionId !== "active_focus:active_focus"
&& isCollectFocusSchema(active.expectedAnswerSchema)
) {
return { status: "already_open", focus: active, questionId: active.questionId, prompt };
}
try {
+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.kind=choice / kind=collect_spoken
4. current_question.kind=choice / 使kind=collect_spoken
5. confirmation_allowed false 5 skill_verification_report80%/60%
6. Skill `;
@@ -484,6 +484,7 @@ function agentVisibleLatestProjection(
question_id: extras.openQuestion.question_id,
prompt: extras.openQuestion.prompt,
status: extras.openQuestion.status,
kind: extras.openQuestion.kind,
unrenderable: true,
reason: extras.openQuestion.reason ?? "invalid_choice_schema",
}
@@ -491,16 +492,23 @@ function agentVisibleLatestProjection(
question_id: extras.openQuestion.question_id,
prompt: extras.openQuestion.prompt,
status: extras.openQuestion.status,
kind: extras.openQuestion.kind,
intent: extras.openQuestion.intent,
domain: extras.openQuestion.domain,
focus_id: extras.openQuestion.focus_id,
probe_id: extras.openQuestion.probe_id,
}
: null;
const compactInference = inference && typeof inference === "object" && !Array.isArray(inference)
? inference as Record<string, unknown>
: null;
const renderableChoice = extras.openQuestion?.kind === "choice"
&& extras.openQuestion.unrenderable !== true;
return {
...rest,
current_question: currentQuestion,
current_probe: null,
inference_state: currentQuestion && !currentQuestion.unrenderable && compactInference
inference_state: renderableChoice && compactInference
? compactInference
: compactInference
? { ...compactInference, next_probe: null }
@@ -633,7 +633,7 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(strategy, /没有更多事件/);
assert.match(strategy, /(?:无需|不要求)结束、暂停或保存进度/);
assert.match(strategy, /不要一进场就出 A\/B\/C\/D/);
assert.match(agent, /没有持久化选择题时,用自然语言问一件带大概年份的经历/);
assert.match(agent, /没有持久化当前问题时,用自然语言问一件带大概年份的经历/);
});
test("clear current-turn events go through the batch evidence service", () => {
@@ -472,7 +472,7 @@ test("agent message path no longer returns the discriminator dead-end copy", ()
assert.match(fastPath, /DISCRIMINATOR_EXHAUSTED_NARRATION/);
assert.match(fastPath, /!plan\.next_followup/);
assert.ok(fastPath.includes("ask_candidate_discriminator"));
assert.ok(fastPath.includes("open.unrenderable !== true"));
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({"));
});
@@ -158,6 +158,7 @@ test("only a successfully persisted focus can expose its discriminator question"
question_id: focus.questionId,
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
status,
kind: "choice",
});
}
});
@@ -481,7 +482,10 @@ test("spoken collect followup persists a collect focus without a choice card", a
assert.equal(result.focus?.expectedAnswerSchema.collect, true);
assert.equal(result.focus?.expectedAnswerSchema.choice, undefined);
assert.match(String(result.focus?.expectedAnswerSchema.prompt ?? ""), /2021/);
assert.equal(openQuestionFromPersistedFocus(result), null);
const openCollect = openQuestionFromPersistedFocus(result);
assert.equal(openCollect?.kind, "collect_spoken");
assert.match(openCollect?.prompt ?? "", /2021/);
assert.notEqual(openCollect?.unrenderable, true);
});
test("degraded spoken collect does not keep discriminator identity or block a later card", async () => {
@@ -532,7 +536,10 @@ test("degraded spoken collect does not keep discriminator identity or block a la
assert.equal(collected.focus?.expectedAnswerSchema.semantic_key, undefined);
assert.doesNotMatch(String(collected.focus?.expectedAnswerSchema.prompt ?? ""), /2016/);
assert.equal(collected.focus?.expectedAnswerSchema.prompt, spoken);
assert.equal(openQuestionFromPersistedFocus(collected), null);
const openCollect = openQuestionFromPersistedFocus(collected);
assert.equal(openCollect?.kind, "collect_spoken");
assert.equal(openCollect?.prompt, spoken);
assert.notEqual(openCollect?.unrenderable, true);
const card = await persistServerOwnedFocus({
accounting: accounting.client,
@@ -0,0 +1,308 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
parseRectificationChoiceCard,
parseRectificationSpokenCollect,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
isRenderableChoiceOpenQuestion,
openQuestionFromPersistedFocus,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { spokenCollectFallbackFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
FOCUS_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
import { parseV9CaseDossier as parseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
const RELATIONSHIP_PROMPT = "还记得别的带年份的感情变化吗?比如开始认真交往、分手或结婚。";
const COLLECT_GET_QUESTION = {
question_id: "active_focus:active_focus",
kind: "collect_spoken",
intent: "collect_method_evidence",
domain: "relationship",
prompt: RELATIONSHIP_PROMPT,
};
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "active_focus",
intent: "collect_method_evidence",
ask_theme: "active_focus",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "active_focus",
...overrides,
};
}
function collectPersistResult(overrides: {
questionId?: string;
prompt?: string;
schema?: Record<string, unknown>;
domain?: string | null;
} = {}) {
const questionId = overrides.questionId ?? "collect:relationship:collect_method_evidence";
const prompt = overrides.prompt ?? RELATIONSHIP_PROMPT;
return {
status: "created" as const,
questionId,
prompt,
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId,
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: overrides.domain ?? "relationship",
targetKind: null,
expectedAnswerSchema: overrides.schema ?? {
prompt,
collect: true,
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
},
};
}
test("skill version stays 10.0.13 for the spoken-collect visibility fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("spoken collect parser accepts collect_spoken GET payloads and fail-closes otherwise", () => {
const parsed = parseRectificationSpokenCollect(COLLECT_GET_QUESTION);
assert.equal(parsed?.kind, "collect_spoken");
assert.equal(parsed?.prompt, RELATIONSHIP_PROMPT);
assert.equal(parsed?.domain, "relationship");
assert.equal(parseRectificationSpokenCollect({
...COLLECT_GET_QUESTION,
kind: "choice",
}), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
prompt: " ",
domain: "relationship",
}), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
domain: "relationship",
}), null);
assert.equal(parseRectificationSpokenCollect(null), null);
assert.equal(parseRectificationSpokenCollect({
kind: "collect_spoken",
prompt: RELATIONSHIP_PROMPT,
choice_mode: "A/B/C/D",
options: [{ key: "A" }],
}), null);
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("spoken collect prompt bar is not rendered when a choice card is present", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(chat, /parseRectificationSpokenCollect/);
assert.match(chat, /payload\.current_question/);
assert.match(chat, /showLiveSpokenCollect = Boolean\(/);
assert.match(chat, /!choiceCard/);
assert.match(chat, /spokenCollect/);
const render = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
assert.match(render, /showLiveSpokenCollect/);
assert.match(render, /showLiveChoiceCard/);
const spokenBlock = render.slice(render.indexOf("{liveSpoken &&"));
assert.match(spokenBlock, /rectification-spoken-collect-prompt/);
assert.doesNotMatch(spokenBlock, /onSelect=\{submitChoice\}|submitStop|A\/B\/C\/D/);
assert.doesNotMatch(chat, /message\.text\.(?:includes|match|search)\(/);
});
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
const collect = openQuestionFromPersistedFocus(collectPersistResult());
assert.equal(collect?.kind, "collect_spoken");
assert.equal(collect?.prompt, RELATIONSHIP_PROMPT);
assert.equal(collect?.unrenderable, undefined);
assert.equal(isRenderableChoiceOpenQuestion(collect), false);
const choiceFocus = {
status: "created" as const,
questionId: "probe:education.2016",
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
focus: {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "probe:education.2016",
intent: "distinguish_candidates",
targetEvidenceId: null,
targetDomain: "education",
targetKind: null,
expectedAnswerSchema: {
choice: {
prompt: "2016 年前后 · 升学结果或学习环境出现明显变化",
option_a: "明确发生且时间吻合",
option_b: "发生过但程度较弱",
option_c: "明确没有发生",
option_d: "这段记不清楚",
options: [
{ key: "A", label: "明确发生且时间吻合", answer_class: "yes" },
{ key: "B", label: "发生过但程度较弱", answer_class: "weak_yes" },
{ key: "C", label: "明确没有发生", answer_class: "no" },
{ key: "D", label: "这段记不清楚", answer_class: "unsure" },
],
},
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
},
};
const choice = openQuestionFromPersistedFocus(choiceFocus);
assert.equal(choice?.kind, "choice");
assert.equal(choice?.prompt, choiceFocus.prompt);
assert.notEqual(choice?.unrenderable, true);
assert.equal(isRenderableChoiceOpenQuestion(choice), true);
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const nextInterview = answerChoice.slice(
answerChoice.indexOf("export async function persistNextInterviewAfterChoice"),
answerChoice.indexOf("async function persistFocusAfterChoice"),
);
assert.match(nextInterview, /isRenderableChoiceOpenQuestion/);
assert.match(nextInterview, /接下来请点选下面这一问/);
assert.ok(nextInterview.indexOf("isRenderableChoiceOpenQuestion") < nextInterview.indexOf("接下来请点选下面这一问"));
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.match(route, /isRenderableChoiceOpenQuestion/);
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
});
test("collect focus keeps current_probe and inference.next_probe null; choice focus does not", () => {
const collectDossier = parseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: { inference_state: { next_probe: { id: "should-hide" } } },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
}),
}),
}));
assert.ok(collectDossier);
const collectProjection = projectTurnDecision(collectDossier);
assert.equal((collectProjection.current_question as { kind?: string } | null)?.kind, "collect_spoken");
assert.equal(collectProjection.current_probe ?? null, null);
assert.equal((collectProjection.inference as { next_probe?: unknown } | null)?.next_probe ?? null, null);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const projection = tools.slice(
tools.indexOf("function agentVisibleLatestProjection"),
tools.indexOf("question_contract: {"),
);
assert.match(projection, /kind:\s*extras\.openQuestion\.kind|kind: extras\.openQuestion\.kind/);
assert.match(projection, /kind === "choice"/);
assert.match(projection, /next_probe: null/);
assert.match(projection, /current_probe: null/);
});
test("active_focus collect followups get a domain-stable questionId, not active_focus:active_focus", async () => {
const followup = collectFollowup();
assert.equal(stableFollowupQuestionId(followup).includes("active_focus:active_focus"), false);
assert.match(stableFollowupQuestionId(followup), /^collect:relationship:collect_method_evidence$/);
const degraded = spokenCollectFallbackFollowup({
...followup,
method_id: "dasha_events",
ask_theme: "dated_event",
domain: "education",
semantic_key: "education:2016",
probe_year: 2016,
});
assert.equal(stableFollowupQuestionId(degraded).includes("education:2016"), false);
assert.match(stableFollowupQuestionId(degraded), /^collect:education:collect_method_evidence$/);
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: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const stale = {
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "active_focus:active_focus",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: {
prompt: RELATIONSHIP_PROMPT,
collect: true,
},
status: "active" as const,
askedAt: "2026-08-30T00:00:00.000Z",
resolvedAt: null,
};
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: stale,
decisionReceipt: null,
followup,
});
assert.equal(persisted.status, "created");
assert.notEqual(persisted.questionId, "active_focus:active_focus");
assert.match(persisted.questionId ?? "", /^collect:relationship:collect_method_evidence$/);
assert.equal(persisted.status, "created");
const open = openQuestionFromPersistedFocus(persisted);
assert.equal(open?.kind, "collect_spoken");
});
test("agent prompt forbids 请点选 bridging unless a choice card exists", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const prompt = agent.slice(
agent.indexOf("const agenticRectificationInstructions"),
agent.indexOf("export function getRectificationV9Agent"),
);
assert.match(prompt, /界面提示条/);
assert.match(prompt, /不复述题干/);
assert.match(prompt, /请点选/);
assert.match(prompt, /选择卡/);
assert.doesNotMatch(prompt, /口述采集题(无选择卡,kind=collect_spoken)必须由你在正文里问出来/);
});