diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0891ef51..d9a4caf2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7569,3 +7569,51 @@ - 复发自:无 - 修复版本:待发布 +## BUG-492 | 消息路径 set-focus 用 Agent 选项覆盖服务端 choice schema,探针不计分 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`rectification-set-focus`、`expectedAnswerSchemaFor`、`projectCurrentQuestion`、method-followup Agent hint +- 用户现象:答完采集题后的区分题没有 A–D,或点选后候选比较不更新。刷新后同一条助手消息里仍可能只有题干没有选项。 +- 触发条件:message 路径 Agent 只传 `spokenPrompt`(或只传 `choice.option_a..d`、或自写 `options[].answer_class`)调用 `rectification-set-focus`,且 `next_followup` 带 `choice_frame`。 +- 根因:`d9404976` 把 set-focus 重新交给 Agent 写题干,但 `execute` 仍用 `parseAgentChoiceCopy(Agent expectedAnswerSchema)` 建 choice。只写 `spokenPrompt` 时 schema=`{prompt}`,投影成采集/空题,idle persist 见 active focus 早退,服务端 choice 永远建不出,该探针不计分。Agent 自写 `answer_class` 则模型拥有映射,违反选项与计分服务端所有。 +- 修复:set-focus 按 `next_followup.choice_frame` 强制 `expectedAnswerSchemaFor` / collect schema,忽略 Agent `choice`。无下一问返回 `no_pending_question`。hint 改为「你只写 spokenPrompt」。同探针再 set-focus 走 prompt 外 identity 幂等。 +- 验证:`rectification-question-ownership` 锁只传 spokenPrompt → 四选项与 `serverOwnedChoiceCopy` 相等、`probe_id` 非空、`kind===choice`;额外 Agent choice 被忽略;采集题 `collect:true`;无下一问不写 focus;第二次 set-focus 幂等且选项不丢。`rectification-question-in-message` 的 choice fixture 补 `options[].answer_class` 并断言 `kind` 与四选项。 +- 防复发:任何路径不得再从 Agent 输入读 `choice`。choice fixture 必须能过 `parseAgentChoiceCopy`。已知边界:用户不答上一题直接发新消息时,旧 focus 仍挂在旧消息、新 turn 无 question(不变量 1 短暂不成立),旧卡仍可点,属可接受。 +- 相关记录:BUG-490、BUG-491 +- 复发自:BUG-490(题目进消息后开放 Agent set-focus,未改 schema 所有权) +- 修复版本:待发布 + +## BUG-493 | 题目列表 RPC 失败时 fail-open,聊天里全部题目消失 + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`listV10ConversationFocuses`、GET `/api/rectification/cases/[caseId]` `question_source`、`rectification-agentic-chat` +- 用户现象:迁移未跑或 RPC 权限错误时,历史消息上的题干和选项全部消失,界面只剩「等待服务端更新」,没有失败提示。 +- 触发条件:`list_agentic_rectification_conversation_focuses` 返回 error 或抛异常。 +- 根因:列表函数失败时静默 `return []`,GET 把空列表当成「没有题」,前端无法区分加载失败与真的缺题。 +- 修复:失败时 `console.warn` 并返回 `{ focuses: [], available: false }`。GET 带 `question_source: "unavailable"|"focus"`。前端文案「题目加载失败,请刷新。」与「正在等待服务端更新」分开。 +- 验证:`rectification-question-ownership` 锁 RPC error → 列表 `available===false`、turns 上 question 全 null、`question_source==="unavailable"`、warn 含 case 与 reason。GET 仍走 200 组装路径,不把列表失败提升为 503。 +- 防复发:题目加载失败不得再吞成空数组。`question_source` 为 `unavailable` 时不得显示「服务端更新中」。 +- 相关记录:BUG-490、BUG-492 +- 复发自:无 +- 修复版本:待发布 + +## BUG-494 | 题干可省略探针年份,嵌入卡又隐藏 why + +- 状态:resolved +- 首次发现:2026-09-02 +- 最近更新:2026-09-02 +- 影响面:`validateSpokenPrompt`、`rectification-set-focus`、`VOICE.md`、嵌入 `RectificationChoiceCard` +- 用户现象:区分题看起来像「你有没有一段认真开始或结束的关系?」,用户看不到是哪一年,答错年份会记到错误探针上。 +- 触发条件:`followup.probe_year` 或 `choice_frame.period` 含年份,但 Agent `spokenPrompt` 不写该年份。嵌入卡 `variant="embedded"` 隐藏 `why`。 +- 根因:`year_mismatch` 只在题干已经写出 19xx/20xx 时比对;缺年份直接放过。 +- 修复:有 `probe_year` 时题干必须包含该年,否则 `year_missing`;无 `probe_year` 但 period 含年份时每个年份都要出现。采集题不加年份要求。VOICE 增加漏写年份坏例。 +- 验证:`rectification-spoken-prompt` 锁 `year_missing` / `year_mismatch` / 通过;嵌入卡测试断言 `question.prompt` 含探针年份(结构断言,不扫正文)。 +- 防复发:区分题题干必须能单独看出年份/期间,不得依赖已隐藏的 `why`。 +- 相关记录:BUG-490、BUG-492 +- 复发自:无 +- 修复版本:待发布 + diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 977d11f3..f75a03a4 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -45,6 +45,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 2023 感情区分 | 2023 年前后,你有没有一段认真开始或结束的关系?(逐字复读模板) | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读整句模板。 | | 2016 升学 | 2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 | | 2019 入职 | 2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 不得把选项字面写进题干。 | +| 2023 感情区分 | 你有没有一段认真开始或结束的关系? | 工作这块记下了。2023 年前后,有没有一段认真开始或结束的关系? | 题干必须写出服务端给的年份;漏写年份用户看不见期间。 | diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 6e2546e3..b422325e 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -10,7 +10,8 @@ import { receiptStatusFromTurn, RectificationToolServiceError, listV10ConversationFocuses, - type ConversationFocus, + questionSourceFromFocusList, + type ConversationFocusList, type V9CaseDossier, } from "@/lib/rectification-agentic/v9/tool-service"; import { choiceCardFromCaseDossier, decideFromDossier, overlayPublicDecision } from "@/lib/rectification-agentic/v9/interview-state"; @@ -72,8 +73,8 @@ export async function GET(request: Request, context: RouteContext) { } }), ); - const focuses = await listV10ConversationFocuses(accounting, user.id, caseId); - return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, focuses })); + const listed = await listV10ConversationFocuses(accounting, user.id, caseId); + return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, listed })); } catch (error) { if (error instanceof RectificationToolServiceError) { const message = error.message; @@ -88,16 +89,17 @@ export async function GET(request: Request, context: RouteContext) { } } -function dossierResponse( +export function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, - options: { fullDetail?: boolean; focuses?: readonly ConversationFocus[] } = {}, + options: { fullDetail?: boolean; listed?: ConversationFocusList } = {}, ) { const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), }); - const turns = attachQuestionsToTurns(dossier.turns, options.focuses ?? []); + const listed = options.listed ?? { focuses: [] as const, available: true }; + const turns = attachQuestionsToTurns(dossier.turns, listed.focuses); return { case: { case_id: dossier.case.caseId, @@ -131,6 +133,7 @@ function dossierResponse( interview: publicDecisionFields(decision), current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus), choice_card: choiceCardFromCaseDossier(dossier), + question_source: questionSourceFromFocusList(listed), }; } diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index bf4c2db0..27a1bb54 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -120,6 +120,12 @@ function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | nul }; } +function questionSourceFromSnapshot(value: unknown): "focus" | "unavailable" | null { + if (value === "unavailable") return "unavailable"; + if (value === "focus") return "focus"; + return null; +} + function RectificationCandidateCards({ result, acceptingCandidateId, @@ -380,6 +386,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [candidateResult, setCandidateResult] = useState(null); const [choiceCard, setChoiceCard] = useState(null); const [currentQuestion, setCurrentQuestion] = useState(null); + const [questionSource, setQuestionSource] = useState<"focus" | "unavailable" | null>(null); const [caseStatus, setCaseStatus] = useState(null); const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); @@ -447,6 +454,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { current_question?: unknown; choice_card?: unknown; turns?: unknown; + question_source?: unknown; case?: { status?: unknown; accepted_time?: unknown; @@ -464,6 +472,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; setCandidateResult(nextCandidate); setCurrentQuestion(nextQuestion); + setQuestionSource(questionSourceFromSnapshot(payload.question_source)); setChoiceCard(nextChoice); setCaseStatus(nextCaseStatus); setCaseSnapshotLoaded(true); @@ -1139,12 +1148,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && message.question.focus_id === currentQuestion.focus_id && !questionIsAnswered(message.question) )); + const questionLoadFailed = questionSource === "unavailable"; const showMissingQuestion = Boolean( caseSnapshotLoaded && resumableCase && !readonly && !busy - && currentQuestion === null, + && currentQuestion === null + && !questionLoadFailed, ); const showUnavailableQuestion = Boolean( caseSnapshotLoaded @@ -1152,7 +1163,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && !readonly && !busy && currentQuestion !== null - && !liveQuestionOnMessages, + && !liveQuestionOnMessages + && !questionLoadFailed, + ); + const showQuestionLoadFailed = Boolean( + caseSnapshotLoaded + && resumableCase + && !readonly + && !busy + && questionLoadFailed, ); const canSend = !busy && !readonly && !regeneratingMessageKey; @@ -1321,11 +1340,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {!conversationAnchor.anchored && ( )} - {(showMissingQuestion || showUnavailableQuestion) && ( + {(showMissingQuestion || showUnavailableQuestion || showQuestionLoadFailed) && (

- {showMissingQuestion - ? "当前没有可回答的问题,正在等待服务端更新。" - : "当前问题暂时无法显示,请等待服务端更新。"} + {showQuestionLoadFailed + ? "题目加载失败,请刷新。" + : showMissingQuestion + ? "当前没有可回答的问题,正在等待服务端更新。" + : "当前问题暂时无法显示,请等待服务端更新。"}

)} ( turn.id === turnId && turn.role === "assistant" diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index e7d5a2d6..39f4bddf 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -71,7 +71,7 @@ export function shouldSkipDiscriminatorFollowup(followup: MethodFollowup): Persi return null; } -function expectedAnswerSchemaFor( +export function expectedAnswerSchemaFor( frame: RectificationChoiceFrame, questionId: string, decisionReceipt: Readonly> | null | undefined, @@ -224,7 +224,7 @@ export async function linkFocusAskedTurn(input: { } } -function collectFocusSchema(followup: MethodFollowup): Record | null { +export function collectFocusSchema(followup: MethodFollowup): Record | null { const prompt = spokenFollowupForUser({ ...followup, choice_frame: null }); if (!prompt) return null; const schema: Record = { @@ -235,6 +235,24 @@ function collectFocusSchema(followup: MethodFollowup): Record | return schema; } +export function serverOwnedExpectedAnswerSchema( + followup: MethodFollowup, + decisionReceipt?: Readonly> | null, +): Record | null { + const frame = followup.choice_frame; + if (frame) { + const schema = expectedAnswerSchemaFor( + frame, + stableFollowupQuestionId(followup), + decisionReceipt, + followup, + ); + if (schema?.choice) return schema; + return collectFocusSchema(spokenCollectFallbackFollowup(followup)); + } + return collectFocusSchema(followup); +} + export function isCollectFocusSchema(schema: Readonly> | null | undefined): boolean { return schema?.[COLLECT_FOCUS_SCHEMA_KEY] === true && typeof schema.prompt === "string"; } diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts index 6660ddf3..2c1e08c9 100644 --- a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts +++ b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts @@ -61,10 +61,23 @@ export function validateSpokenPrompt(input: { if (followup.choice_frame && input.questionId && input.questionId !== stableFollowupQuestionId(followup)) { return { ok: false, error: "invalid_spoken_prompt", reason: "probe_mismatch" }; } - if (followup.probe_year) { - const years = prompt.match(/19\d{2}|20\d{2}/g) ?? []; - if (years.some((year) => Number(year) !== followup.probe_year)) { - return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" }; + if (followup.choice_frame) { + if (followup.probe_year) { + const year = String(followup.probe_year); + const years = prompt.match(/19\d{2}|20\d{2}/g) ?? []; + if (years.some((item) => item !== year)) { + return { ok: false, error: "invalid_spoken_prompt", reason: "year_mismatch" }; + } + if (!prompt.includes(year)) { + return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" }; + } + } else { + const periodYears = followup.choice_frame.period.match(/19\d{2}|20\d{2}/g) ?? []; + for (const year of periodYears) { + if (!prompt.includes(year)) { + return { ok: false, error: "invalid_spoken_prompt", reason: "year_missing" }; + } + } } } } diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 16d5f137..800fd49c 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -1089,28 +1089,51 @@ export async function resolveV10ConversationFocus( }; } +export type ConversationFocusList = Readonly<{ + focuses: readonly ConversationFocus[]; + available: boolean; +}>; + +export function questionSourceFromFocusList(listed: ConversationFocusList): "focus" | "unavailable" { + return listed.available ? "focus" : "unavailable"; +} + export async function listV10ConversationFocuses( accounting: AccountingClient, userId: string, caseId: string, -): Promise { +): Promise { try { const { data, error } = await accounting.rpc( "list_agentic_rectification_conversation_focuses", { p_user_id: userId, p_case_id: caseId }, ); - if (error) return []; + if (error) { + const message = error instanceof Error + ? error.message + : rowText((error as { message?: unknown }).message) ?? String(error); + console.warn( + `[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(new Error(message))}`, + ); + return { focuses: [], available: false }; + } const items = Array.isArray(data) ? data : Array.isArray((data as { value?: unknown } | null)?.value) ? (data as { value: unknown[] }).value : []; - return items.flatMap((item) => { - const focus = parseConversationFocus(item); - return focus ? [focus] : []; - }); - } catch { - return []; + return { + focuses: items.flatMap((item) => { + const focus = parseConversationFocus(item); + return focus ? [focus] : []; + }), + available: true, + }; + } catch (error) { + console.warn( + `[rectification-v9] list focuses failed case=${caseId} reason=${safeToolErrorCode(error)}`, + ); + return { focuses: [], available: false }; } } @@ -1792,6 +1815,7 @@ export function safeToolErrorCode(error: unknown): string { "invalid_choice_copy", "precision_downgrade", "offer_not_allowed", + "no_pending_question", "no_candidate_result", "stale_probe", "revision_conflict", diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts index a9a006f0..6cb1e38c 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts @@ -12,6 +12,7 @@ import type { V9CaseDossier } from "./tool-service"; import { QUESTION_CONTRACT_VERSION } from "./probe-question-contract"; import { RECTIFICATION_SKILL_VERSION } from "./case-status"; import { parseAgentChoiceCopy } from "./choice-card"; +import { isCollectFocusSchema } from "./server-focus"; export const TURN_DECISION_MAX_BYTES = 6 * 1024; export const TURN_DECISION_RECENT_TURNS = 6; @@ -93,6 +94,17 @@ export function projectCurrentQuestion( }; } const collectPrompt = spoken || ""; + if (isCollectFocusSchema(schema) && collectPrompt) { + return { + question_id: focus.questionId ?? null, + focus_id: focus.id ?? null, + probe_id: probeId, + prompt: collectPrompt, + kind: "collect_spoken", + intent: focus.intent, + domain: focus.targetDomain ?? null, + }; + } if (focus.intent === "collect_method_evidence" && collectPrompt && !looksLikeChoiceSchema(schema)) { return { question_id: focus.questionId ?? null, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 2ee2c682..fa0ab108 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -52,7 +52,6 @@ import { } from "@/lib/rectification-agentic/v9/evidence-model"; import { isHoldoutVerificationQuote, - parseAgentChoiceCopy, } from "@/lib/rectification-agentic/v9/choice-card"; import { indistinguishableWidthMinutes } from "@/lib/rectification-agentic/v9/candidate-plateau"; import { @@ -75,12 +74,16 @@ import { buildCaseInferenceState, compactInferenceProjection, previousInferenceFromReceipt, - stampChoiceSchemaWithProbe, } from "@/lib/rectification-agentic/v9/inference-adapter"; import { + COLLECT_FOCUS_SCHEMA_KEY, + collectFocusSchema, isCollectFocusSchema, openQuestionFromPersistedFocus, + persistableFocusDomain, persistServerOwnedFocus, + serverOwnedExpectedAnswerSchema, + stableFollowupQuestionId, } from "@/lib/rectification-agentic/v9/server-focus"; import { validateSpokenPrompt, withSpokenPrompt } from "@/lib/rectification-agentic/v9/spoken-prompt"; import { @@ -1177,7 +1180,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const setFocusTool = createTool({ id: "rectification-set-focus", description: - "在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。新的 focus 会由服务器 supersede 旧 active focus。仅当 method_followup_plan.next_followup 带 choice_frame 时才在 expectedAnswerSchema.choice 写入 option_a、option_b、option_c、option_d。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。", + "在提出至多一个主问题时持久化服务器 ConversationFocus,并用 spokenPrompt 写出题干。questionId 必须使用 current_question.question_id 或 method_followup_plan.next_followup 的稳定 id;targetEvidenceId/domain/kind 只填写实际目标。spokenPrompt 必填:8–120 字单句,可含一个问号,用自己的话问同一个年份/期间和同一个事件家族,不得改年份、不得写选项字面、不得写唯一出生分钟。题干必须写出服务端给你的年份/期间。选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt。新的 focus 会由服务器 supersede 旧 active focus。收集经历不要写 choice。不要外貌/疤痕/钟点。「先这样」由服务器补全,不要改它的含义。", inputSchema: z.object({ caseId: z.string().uuid(), questionId: z.string().trim().min(1).max(160), @@ -1196,58 +1199,34 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { if (input.targetKind && !isEvidenceKind(input.targetKind)) { throw new RectificationToolServiceError("invalid_event_kind"); } - const expectedAnswerSchemaInput = input.expectedAnswerSchema ?? {}; - const hasChoice = expectedAnswerSchemaInput.choice != null; - const choiceCopy = parseAgentChoiceCopy(expectedAnswerSchemaInput); - if (hasChoice && !choiceCopy) { - throw new RectificationToolServiceError("invalid_choice_copy"); - } - const expectedAnswerSchema: Record = { ...expectedAnswerSchemaInput }; - if (choiceCopy) { - expectedAnswerSchema.choice = { - prompt: choiceCopy.prompt, - option_a: choiceCopy.option_a, - option_b: choiceCopy.option_b, - option_c: choiceCopy.option_c, - option_d: choiceCopy.option_d, - }; - } else { - delete expectedAnswerSchema.choice; - } - if (expectedAnswerSchema.choice) { - try { - const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); - Object.assign( - expectedAnswerSchema, - stampChoiceSchemaWithProbe( - expectedAnswerSchema, - previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null), - input.questionId, - ), - ); - } catch { - // Probe identity is only a hint for the C/D write path. - } - } + const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input); let nextFollowup: ReturnType["plan"]["next_followup"] = null; + let decisionReceipt: Readonly> | null = null; try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); const parsed = parseDossierForTools(dossier); if (parsed.latestResult) { nextFollowup = sessionAwareFollowupForParsed(parsed, parsed.latestResult).plan.next_followup; + decisionReceipt = parsed.latestResult.decisionReceipt ?? null; } } catch { nextFollowup = null; } + if (!nextFollowup) { + await receipt("rectification-set-focus", "intent.classified", "failed", { + inputFingerprint, + safeErrorCode: "no_pending_question", + }); + return { ok: false, error: "no_pending_question" }; + } const spoken = validateSpokenPrompt({ spokenPrompt: input.spokenPrompt, followup: nextFollowup, - targetDomain: input.targetDomain ?? nextFollowup?.domain ?? null, + targetDomain: input.targetDomain ?? nextFollowup.domain ?? null, questionId: input.questionId, }); if (!spoken.ok) { spokenPromptFailures += 1; - const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input); await receipt("rectification-set-focus", "intent.classified", "failed", { inputFingerprint, safeErrorCode: "invalid_spoken_prompt", @@ -1259,16 +1238,26 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { retryable: spokenPromptFailures < 2, }; } - Object.assign(expectedAnswerSchema, withSpokenPrompt(expectedAnswerSchema, spoken.prompt)); - const inputFingerprint = canonicalToolInputFingerprint("rectification-set-focus", input); + let persistFollowup = nextFollowup; + let expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt); + if (persistFollowup.choice_frame && !expectedAnswerSchema?.choice) { + persistFollowup = spokenCollectFallbackFollowup(persistFollowup); + expectedAnswerSchema = collectFocusSchema(persistFollowup); + } + if (!expectedAnswerSchema) { + expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true }; + } + expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spoken.prompt); await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint }); try { const result = await setV10ConversationFocus(accounting, userId, input.caseId, { - questionId: input.questionId, - intent: input.intent, - targetEvidenceId: input.targetEvidenceId ?? null, - targetDomain: input.targetDomain ?? null, - targetKind: input.targetKind ?? null, + questionId: stableFollowupQuestionId(persistFollowup), + intent: persistFollowup.intent, + targetEvidenceId: null, + targetDomain: persistFollowup.choice_frame + ? persistFollowup.domain + : persistableFocusDomain(persistFollowup.domain), + targetKind: null, expectedAnswerSchema, askedTurnId: turnId, }); diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts index 6bdbf151..75811371 100644 --- a/frontend/tests/rectification-question-in-message.test.ts +++ b/frontend/tests/rectification-question-in-message.test.ts @@ -23,6 +23,28 @@ import { const OTHER_TURN = "44444444-4444-4444-8444-444444444444"; +const EXISTENCE_OPTIONS = [ + { key: "A" as const, label: "明确发生且时间吻合", answer_class: "yes" }, + { key: "B" as const, label: "发生过但时间偏了", answer_class: "weak_yes" }, + { key: "C" as const, label: "没有这回事", answer_class: "no" }, + { key: "D" as const, label: "这段记不清楚", answer_class: "unsure" }, +]; + +function choiceSchema(prompt: string) { + return { + prompt, + probe_id: "probe:test", + choice: { + prompt, + option_a: EXISTENCE_OPTIONS[0]!.label, + option_b: EXISTENCE_OPTIONS[1]!.label, + option_c: EXISTENCE_OPTIONS[2]!.label, + option_d: EXISTENCE_OPTIONS[3]!.label, + options: EXISTENCE_OPTIONS, + }, + }; +} + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); const row = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8"); const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); @@ -44,8 +66,9 @@ test("question-on-turn migration adds asked_turn_id and answer_option", () => { }); test("GET turns attach question by asked_turn_id and never by timestamps", () => { - assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, options\.focuses/); + assert.match(casesRoute, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/); assert.match(casesRoute, /question: turn\.question/); + assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); assert.match(attach, /if \(!focus\.askedTurnId\) continue/); assert.doesNotMatch(attach, /askedAt|createdAt/); @@ -111,6 +134,8 @@ test("one assistant article owns the stem and options; the slot class is gone", assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/); assert.match(chat, /role="status"/); assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/); + assert.match(chat, /题目加载失败,请刷新/); + assert.match(chat, /question_source/); }); test("agent toolbag includes set-focus so spokenPrompt can run", () => { @@ -129,16 +154,7 @@ test("message-path idle persist links a pre-built choice focus to the current tu intent: "distinguish_candidates", questionId: "d9:relationship:2023", targetDomain: "relationship", - expectedAnswerSchema: { - prompt, - choice: { - prompt, - option_a: "明确发生且时间吻合", - option_b: "发生过但时间偏了", - option_c: "没有这回事", - option_d: "记不清", - }, - }, + expectedAnswerSchema: choiceSchema(prompt), }), }), }), @@ -176,16 +192,6 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", () const collectPrompt = GENERIC_COLLECT_QUESTION; const answeredPrompt = "2015 年前后,有没有搬家或换过主要居住地?"; const livePrompt = "2014 年前后,有没有升学、转学或换学习环境?"; - const choiceSchema = (prompt: string) => ({ - prompt, - choice: { - prompt, - option_a: "明确发生且时间吻合", - option_b: "发生过但时间偏了", - option_c: "没有这回事", - option_d: "记不清", - }, - }); const attached = attachQuestionsToTurns( [ { id: TURN_ID, role: "assistant" as const, text: "你好,可以慢慢说。", status: "completed" }, @@ -242,9 +248,15 @@ test("refresh rebuilds answered and live questions from asked_turn_id only", () ); assert.equal(attached[0]?.question?.kind, "collect_spoken"); assert.equal(attached[0]?.question?.status, "resolved"); + assert.equal(attached[1]?.question?.kind, "choice"); + assert.equal(attached[1]?.question?.options?.length, 4); assert.equal(attached[1]?.question?.answer_option, "A"); assert.equal(attached[1]?.question?.prompt, answeredPrompt); + assert.ok(attached[1]?.question?.prompt.includes("2015")); + assert.equal(attached[2]?.question?.kind, "choice"); + assert.equal(attached[2]?.question?.options?.length, 4); assert.equal(attached[2]?.question?.prompt, livePrompt); + assert.ok(attached[2]?.question?.prompt.includes("2014")); assert.equal(attached[2]?.question?.answer_option, null); assert.equal(attached[2]?.text, "2016 年入学记下了。"); }); diff --git a/frontend/tests/rectification-question-ownership.test.ts b/frontend/tests/rectification-question-ownership.test.ts new file mode 100644 index 00000000..4366d467 --- /dev/null +++ b/frontend/tests/rectification-question-ownership.test.ts @@ -0,0 +1,643 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { buildChoiceFrame, parseAgentChoiceCopy, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + buildMethodFollowupPlan, + type MethodFollowup, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + decideFromDossier, + rectificationFollowupCatalog, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + expectedAnswerSchemaFor, + serverOwnedExpectedAnswerSchema, + stableFollowupQuestionId, +} from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { + attachQuestionsToTurns, + parseTurnQuestion, +} from "../src/lib/rectification-agentic/v9/turn-question.ts"; +import { + listV10ConversationFocuses, + parseV9CaseDossier, + questionSourceFromFocusList, +} from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { + internalObservationsFromWindowScan, + windowScanFromDecisionReceipt, +} from "../src/lib/rectification-agentic/v9/varga-observations.ts"; +import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; +import { + CASE_ID, + EVIDENCE_ID, + FOCUS_ID, + TURN_ID, + USER_ID, + activeFocusFixture, + candidateSnapshotFixture, + conversationSummaryFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +type ExecutableTool = { + execute(input: unknown): Promise; +}; + +const STYLE_OPTIONS = [ + { label: "明确发生且时间吻合", answer_class: "yes" as const }, + { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, + { label: "明确没有发生", answer_class: "no" as const }, + { label: "这段记不清楚", answer_class: "unsure" as const }, +]; + +const RELOCATION_2015 = { + id: "p-reloc-2015", + semantic_key: "relocation.2015.05.dasha_boundary", + candidate_split_hash: "reloc-2015-split", + domain: "relocation", + year: 2015, + month: 5, + question: "2015 年 5 月前后有没有搬家或长期住到外地?", + candidate_ids: ["05:00", "05:10"], + expected_outcomes: [ + { answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:10"] }, + { answer_class: "no" as const, supports: ["05:10"], conflicts: ["05:00"] }, + { answer_class: "unsure" as const, supports: [], conflicts: [] }, + ], + information_gain: 0.87, + source: "dasha_boundary", +}; + +function eventProbeFromConflict(probe: typeof RELOCATION_2015) { + return { + year: probe.year, + year_label: `${probe.year} 年 ${probe.month} 月前后`, + month: probe.month, + domain: probe.domain, + event_family: "搬家或长期住到外地", + source: probe.source, + tracks: ["vimshottari", "narayana"], + tracks_agree: false, + unique_minute_claim: false, + user_meaning: probe.question, + role: "distinguish", + phase: "candidate_discriminator", + information_gain: probe.information_gain, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + candidate_ids: probe.candidate_ids, + expected_outcomes: probe.expected_outcomes, + style_options: STYLE_OPTIONS, + choice_kind: "existence" as const, + }; +} + +function coverageEvidence() { + return [ + evidenceRow(EVIDENCE_ID, "career_entry", "career", "2020-04-01", "month", "2020 年 4 月开始实习"), + evidenceRow("44444444-4444-4444-8444-444444444445", "education_start", "education", "2016-09-01", "month", "2016 年入学"), + evidenceRow("44444444-4444-4444-8444-444444444446", "relationship_start", "relationship", "2024-05-01", "month", "2024 年 5 月认识一位女生"), + evidenceRow("44444444-4444-4444-8444-444444444447", "family_event", "family", "2021-03-01", "month", "2021 年家里有人住院"), + ]; +} + +function evidenceRow( + id: string, + eventKind: string, + domain: string, + occurredFrom: string, + datePrecision: string, + summary: string, +) { + return { + id, + source_turn_id: TURN_ID, + subject: "self", + event_kind: eventKind, + domain, + occurred_from: occurredFrom, + occurred_to: occurredFrom, + date_precision: datePrecision, + summary, + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-28T07:35:45.000Z", + }; +} + +function distinguishDossier(activeFocus: unknown = null) { + const inference = buildInferenceState({ + range_start: "04:45", + range_end: "05:15", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 16 }, + { id: "05:10", time: "05:10", relative_support: 14 }, + ], + events: [ + { id: "e1", domain: "career", year: 2020, precision: "month" }, + { id: "e2", domain: "education", year: 2016, precision: "month" }, + { id: "e3", domain: "relationship", year: 2024, precision: "month" }, + { id: "e4", domain: "family", year: 2021, precision: "month" }, + ], + probes: [RELOCATION_2015], + }); + return dossierFixture({ + evidenceCount: 4, + evidence: coverageEvidence(), + latestResult: candidateSnapshotFixture({ + decisionReceipt: { + inference_state: inference, + discriminating_event_probes: [eventProbeFromConflict(RELOCATION_2015)], + }, + }), + conversationSummary: conversationSummaryFixture({ activeFocus }), + }); +} + +function nextFollowupFromRaw(raw: unknown): MethodFollowup | null { + const parsed = parseV9CaseDossier(raw); + assert.ok(parsed); + const decision = decideFromDossier(parsed); + const catalog = rectificationFollowupCatalog(parsed.latestResult, parsed.evidence); + const observations = internalObservationsFromWindowScan( + windowScanFromDecisionReceipt(parsed.latestResult?.decisionReceipt ?? null), + ); + return buildMethodFollowupPlan({ + evidence: parsed.evidence, + activeFocus: parsed.conversationSummary.activeFocus, + declinedTopics: parsed.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: parsed.conversationSummary.declinedSkippedTopics, + observations, + sessionOutcome: decision.sessionOutcome, + ...catalog, + birthDate: null, + accepted: Boolean(parsed.case.acceptedTime), + candidatesSeparated: decision.separation.sufficient, + holdoutValidation: decision.holdoutValidation, + }).next_followup; +} + +function identityMinusPrompt(schema: Record) { + const next = { ...schema }; + delete next.prompt; + if (next.choice && typeof next.choice === "object" && !Array.isArray(next.choice)) { + const choice = { ...(next.choice as Record) }; + delete choice.prompt; + next.choice = choice; + } + return next; +} + +function setFocusHandler(store: { focus: ReturnType | null }) { + return (_fn: string, args: Record) => { + const incoming = (args.p_expected_answer_schema as Record) ?? {}; + const existing = store.focus; + const sameIdentity = Boolean( + existing + && existing.question_id === args.p_question_id + && existing.intent === args.p_intent + && JSON.stringify(identityMinusPrompt(existing.expected_answer_schema as Record)) + === JSON.stringify(identityMinusPrompt(incoming)), + ); + store.focus = { + ...activeFocusFixture({ + id: existing && sameIdentity ? existing.id : FOCUS_ID, + questionId: String(args.p_question_id), + intent: String(args.p_intent), + targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null, + targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null, + targetKind: args.p_target_kind == null ? null : String(args.p_target_kind), + expectedAnswerSchema: incoming, + askedTurnId: args.p_asked_turn_id ? String(args.p_asked_turn_id) : null, + }), + }; + return { focus: store.focus, idempotent: sameIdentity }; + }; +} + +function toolsFor(accounting: ReturnType) { + return createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); +} + +function discriminatorFollowup(): MethodFollowup { + const frame = buildChoiceFrame({ + method_id: "dasha_events", + ask_theme: "dated_event", + domain: "education", + user_prompt_hint: "ask", + }, { + probes: [{ + year: 2016, + year_label: "2016 年前后", + domain: "education", + event_family: "升学结果或学习环境出现明显变化", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "2016 年前后升学结果或学习环境出现明显变化", + role: "distinguish", + information_gain: 0.4, + semantic_key: "education:2016", + style_options: STYLE_OPTIONS, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + }], + }); + assert.ok(frame); + return { + method_id: "dasha_events", + intent: "distinguish_candidates", + ask_theme: "dated_event", + domain: "education", + kind_hint: null, + user_prompt_hint: "ask", + must_not_label: false, + choice_frame: frame, + source: "event_probe", + information_gain: 0.4, + semantic_key: "education:2016", + probe_year: 2016, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + }; +} + +test("server-owned schema matches choice_frame copy and ignores Agent choice", () => { + const followup = discriminatorFollowup(); + const copy = serverOwnedChoiceCopy(followup.choice_frame!); + assert.ok(copy); + const schema = expectedAnswerSchemaFor( + followup.choice_frame!, + stableFollowupQuestionId(followup), + null, + followup, + ); + assert.ok(schema?.choice); + const parsed = parseAgentChoiceCopy(schema); + assert.deepEqual(parsed?.options, copy.options); + const viaWrapper = serverOwnedExpectedAnswerSchema(followup, null); + assert.deepEqual( + parseAgentChoiceCopy(viaWrapper)?.options, + copy.options, + ); +}); + +test("Agent set-focus with only spokenPrompt persists server choice options", async () => { + const raw = distinguishDossier(); + const followup = nextFollowupFromRaw(raw); + assert.ok(followup?.choice_frame, JSON.stringify({ + intent: followup?.intent, + source: followup?.source, + method_id: followup?.method_id, + })); + const questionId = stableFollowupQuestionId(followup); + const copy = serverOwnedChoiceCopy(followup.choice_frame); + assert.ok(copy); + const store: { focus: ReturnType | null } = { focus: null }; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + set_agentic_rectification_conversation_focus: setFocusHandler(store), + }); + const tools = toolsFor(accounting); + const spokenPrompt = "记下了。2015 年前后,有没有搬家或长期住到外地?"; + const first = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute({ + caseId: CASE_ID, + questionId, + intent: "distinguish_candidates", + spokenPrompt, + }) as Record; + const withAgentChoice = await (tools["rectification-set-focus"] as unknown as ExecutableTool>).execute({ + caseId: CASE_ID, + questionId, + intent: "distinguish_candidates", + spokenPrompt, + expectedAnswerSchema: { + choice: { + prompt: "伪造题干", + option_a: "模型自编A", + option_b: "模型自编B", + option_c: "模型自编C", + option_d: "模型自编D", + options: [ + { key: "A", label: "模型自编A", answer_class: "no" }, + { key: "B", label: "模型自编B", answer_class: "yes" }, + { key: "C", label: "模型自编C", answer_class: "unsure" }, + { key: "D", label: "模型自编D", answer_class: "weak_yes" }, + ], + }, + }, + }) as Record; + assert.equal(first.error, undefined); + assert.equal(withAgentChoice.error, undefined); + const firstSchema = first.expected_answer_schema as Record; + const secondSchema = withAgentChoice.expected_answer_schema as Record; + const firstCopy = parseAgentChoiceCopy(firstSchema); + assert.ok(firstCopy); + assert.deepEqual(firstCopy.options, copy.options); + assert.equal(typeof firstSchema.probe_id, "string"); + assert.ok(String(firstSchema.probe_id).length > 0); + assert.equal(projectCurrentQuestion({ + id: String(first.focus_id), + questionId: String(first.question_id), + intent: String(first.intent), + targetDomain: typeof first.target_domain === "string" ? first.target_domain : null, + expectedAnswerSchema: firstSchema, + })?.kind, "choice"); + assert.deepEqual(identityMinusPrompt(firstSchema), identityMinusPrompt(secondSchema)); + assert.equal(firstCopy.option_a, copy.option_a); + assert.notEqual(firstCopy.option_a, "模型自编A"); + const attached = attachQuestionsToTurns( + [{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }], + [{ + id: String(first.focus_id), + caseId: CASE_ID, + questionId: String(first.question_id), + intent: String(first.intent), + targetEvidenceId: null, + targetDomain: typeof first.target_domain === "string" ? first.target_domain : null, + targetKind: null, + expectedAnswerSchema: firstSchema, + status: "active", + askedAt: "2026-09-02T00:00:00.000Z", + resolvedAt: null, + askedTurnId: TURN_ID, + answerOption: null, + }], + ); + assert.equal(attached[0]?.question?.kind, "choice"); + assert.equal(attached[0]?.question?.options?.length, 4); +}); + +test("collect set-focus writes collect:true even if Agent sends choice", async () => { + const raw = dossierFixture({ latestResult: candidateSnapshotFixture() }); + const followup = nextFollowupFromRaw(raw); + assert.equal(followup?.choice_frame ?? null, null); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + set_agentic_rectification_conversation_focus: setFocusHandler({ focus: null }), + }); + const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool>).execute({ + caseId: CASE_ID, + questionId: followup ? stableFollowupQuestionId(followup) : "collect:relationship:collect_method_evidence", + intent: "collect_method_evidence", + spokenPrompt: "你还记得哪年上的大学吗?", + expectedAnswerSchema: { + choice: { + prompt: "伪造采集", + option_a: "模型自编A", + option_b: "模型自编B", + option_c: "模型自编C", + option_d: "模型自编D", + }, + }, + }) as Record; + assert.equal(result.error, undefined); + const schema = result.expected_answer_schema as Record; + assert.equal(schema.collect, true); + assert.equal(schema.choice, undefined); + assert.equal(projectCurrentQuestion({ + id: String(result.focus_id), + questionId: String(result.question_id), + intent: String(result.intent), + expectedAnswerSchema: schema, + })?.kind, "collect_spoken"); +}); + +test("set-focus returns no_pending_question when there is no next followup", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture(), + }); + const result = await (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool>).execute({ + caseId: CASE_ID, + questionId: "career-month-question", + intent: "clarify_event_date", + spokenPrompt: "你还记得哪年上的大学吗?", + }) as Record; + assert.deepEqual(result, { ok: false, error: "no_pending_question" }); + assert.equal( + accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"), + false, + ); +}); + +test("second set-focus on the same choice probe is idempotent and keeps options", async () => { + const raw = distinguishDossier(); + const followup = nextFollowupFromRaw(raw); + assert.ok(followup?.choice_frame); + const questionId = stableFollowupQuestionId(followup); + const store: { focus: ReturnType | null } = { focus: null }; + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + set_agentic_rectification_conversation_focus: setFocusHandler(store), + }); + const execute = (toolsFor(accounting)["rectification-set-focus"] as unknown as ExecutableTool>).execute; + const first = await execute({ + caseId: CASE_ID, + questionId, + intent: "distinguish_candidates", + spokenPrompt: "记下了。2015 年前后,有没有搬家或长期住到外地?", + }) as Record; + const second = await execute({ + caseId: CASE_ID, + questionId, + intent: "distinguish_candidates", + spokenPrompt: "工作记下了。2015 年 5 月前后,还记不记得搬过家?", + }) as Record; + assert.equal(first.focus_id, second.focus_id); + assert.equal(second.idempotent, true); + assert.deepEqual( + parseAgentChoiceCopy(first.expected_answer_schema as Record)?.options, + parseAgentChoiceCopy(second.expected_answer_schema as Record)?.options, + ); +}); + +test("list focuses RPC error marks GET questions unavailable and warns", async () => { + const warns: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warns.push(args.map(String).join(" ")); + }; + try { + const accounting = fakeAccounting({ + list_agentic_rectification_conversation_focuses: () => { + throw new Error("agentic_rectification_focus_not_found"); + }, + }); + const listed = await listV10ConversationFocuses(accounting.client, USER_ID, CASE_ID); + assert.equal(listed.available, false); + assert.deepEqual(listed.focuses, []); + assert.equal(questionSourceFromFocusList(listed), "unavailable"); + assert.match(warns.join("\n"), /list focuses failed/); + assert.match(warns.join("\n"), /focus_not_found/); + const attached = attachQuestionsToTurns( + [ + { id: TURN_ID, role: "assistant", text: "你好。", status: "completed" }, + { id: "44444444-4444-4444-8444-444444444444", role: "assistant", text: "记下了。", status: "completed" }, + ], + listed.focuses, + ); + assert.ok(attached.every((turn) => turn.question === null)); + } finally { + console.warn = original; + } + const casesRoute = readFileSync(new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8"); + assert.match(casesRoute, /question_source: questionSourceFromFocusList\(listed\)/); + assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/); +}); + +test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => { + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(chat, /question: parseTurnQuestion\(turn\.question\)/); + const collect = (id: string, prompt: string, status: "active" | "resolved") => ({ + id, + caseId: CASE_ID, + questionId: `collect:${id}:collect_method_evidence`, + intent: "collect_method_evidence", + targetEvidenceId: null, + targetDomain: "relationship", + targetKind: null, + expectedAnswerSchema: { prompt, collect: true }, + status, + askedAt: "2026-09-02T00:00:00.000Z", + resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null, + askedTurnId: id, + answerOption: null, + }); + const choice = ( + id: string, + prompt: string, + year: string, + status: "active" | "resolved", + answer: "A" | "B" | "C" | "D" | null, + ) => ({ + id, + caseId: CASE_ID, + questionId: `probe:${year}`, + intent: "distinguish_candidates", + targetEvidenceId: null, + targetDomain: "relocation", + targetKind: null, + expectedAnswerSchema: { + prompt, + probe_id: `probe:${year}`, + choice: { + prompt, + option_a: STYLE_OPTIONS[0]!.label, + option_b: STYLE_OPTIONS[1]!.label, + option_c: STYLE_OPTIONS[2]!.label, + option_d: STYLE_OPTIONS[3]!.label, + options: STYLE_OPTIONS.map((option, index) => ({ + key: (["A", "B", "C", "D"] as const)[index]!, + label: option.label, + answer_class: option.answer_class, + })), + }, + }, + status, + askedAt: "2026-09-02T00:00:00.000Z", + resolvedAt: status === "resolved" ? "2026-09-02T00:01:00.000Z" : null, + askedTurnId: id, + answerOption: answer, + }); + const opening = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; + const collectTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2"; + const collectThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3"; + const tapOne = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4"; + const tapTwo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5"; + const tapThree = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa6"; + const typed = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa7"; + const reverse = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa8"; + const turns = [ + { id: opening, role: "assistant" as const, text: "从你最容易想起来的开始。", status: "completed" }, + { id: collectTwo, role: "assistant" as const, text: "工作记下了。", status: "completed" }, + { id: collectThree, role: "assistant" as const, text: "感情这块也记下了。", status: "completed" }, + { id: tapOne, role: "assistant" as const, text: "接下来对一下这段经历。", status: "completed" }, + { id: tapTwo, role: "assistant" as const, text: "候选比较更新了。", status: "completed" }, + { id: tapThree, role: "assistant" as const, text: "再看一件相近的。", status: "completed" }, + { id: typed, role: "assistant" as const, text: "也可以打字回答。", status: "completed" }, + { id: reverse, role: "assistant" as const, text: "采用之后再核对一件前事。", status: "completed" }, + ]; + const attached = attachQuestionsToTurns(turns, [ + collect(opening, "从你最容易想起来的开始就好,记得大概年份就行。", "resolved"), + collect(collectTwo, "钱的方面,还记得哪年收入明显变过吗?", "resolved"), + collect(collectThree, "家里有没有结婚、添丁或住院这类事?", "resolved"), + choice(tapOne, "2015 年前后,有没有搬家或长期住到外地?", "2015", "resolved", "A"), + choice(tapTwo, "2014 年前后,有没有升学、转学或换学习环境?", "2014", "resolved", "B"), + choice(tapThree, "2018 年前后,有没有换过工作?", "2018", "resolved", "C"), + choice(typed, "2023 年前后,有没有一段认真开始或结束的关系?", "2023", "resolved", null), + { + id: reverse, + caseId: CASE_ID, + questionId: "reverse:family:2021", + intent: "reverse_verify", + targetEvidenceId: null, + targetDomain: "family", + targetKind: null, + expectedAnswerSchema: { + prompt: "2021 年前后,家里有没有结婚、添丁或住院?", + collect: true, + }, + status: "active", + askedAt: "2026-09-02T00:00:00.000Z", + resolvedAt: null, + askedTurnId: reverse, + answerOption: null, + }, + ]); + const shapes = attached.map((turn) => { + const question = parseTurnQuestion(turn.question); + return { + id: turn.id, + kind: question?.kind ?? null, + focus_id: question?.focus_id ?? null, + answer_option: question?.answer_option ?? null, + status: question?.status ?? null, + options: question?.options?.length ?? 0, + }; + }); + assert.deepEqual(shapes, [ + { id: opening, kind: "collect_spoken", focus_id: opening, answer_option: null, status: "resolved", options: 0 }, + { id: collectTwo, kind: "collect_spoken", focus_id: collectTwo, answer_option: null, status: "resolved", options: 0 }, + { id: collectThree, kind: "collect_spoken", focus_id: collectThree, answer_option: null, status: "resolved", options: 0 }, + { id: tapOne, kind: "choice", focus_id: tapOne, answer_option: "A", status: "resolved", options: 4 }, + { id: tapTwo, kind: "choice", focus_id: tapTwo, answer_option: "B", status: "resolved", options: 4 }, + { id: tapThree, kind: "choice", focus_id: tapThree, answer_option: "C", status: "resolved", options: 4 }, + { id: typed, kind: "choice", focus_id: typed, answer_option: null, status: "resolved", options: 4 }, + { id: reverse, kind: "reverse_verify", focus_id: reverse, answer_option: null, status: "active", options: 0 }, + ]); + assert.ok(attached[3]?.question?.prompt.includes("2015")); +}); + +test("set-focus description no longer tells the Agent to write option_a", () => { + const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8"); + const hints = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8"); + assert.match(tools, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/); + assert.match(tools, /题干必须写出服务端给你的年份\/期间/); + assert.doesNotMatch(tools, /写入 option_a/); + assert.doesNotMatch(tools, /parseAgentChoiceCopy\(expectedAnswerSchemaInput\)/); + assert.match(hints, /选项、计分由服务端按 choice_frame 写入,你只写 spokenPrompt/); + assert.doesNotMatch(hints, /不要写 expectedAnswerSchema\.choice/); +}); diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index 091ab327..3f679837 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -147,6 +147,7 @@ test("missing current_question is explicit only for resumable cases", () => { assert.match(chat, /const showMissingQuestion = Boolean\([\s\S]*caseSnapshotLoaded[\s\S]*resumableCase[\s\S]*currentQuestion === null/); assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/); assert.match(chat, /当前没有可回答的问题,正在等待服务端更新/); + assert.match(chat, /题目加载失败,请刷新/); assert.match(chat, /readonly && \(/); assert.doesNotMatch(chat, /showMissingQuestion[\s\S]*caseStatus.*TERMINAL/); }); diff --git a/frontend/tests/rectification-spoken-prompt.test.ts b/frontend/tests/rectification-spoken-prompt.test.ts index 90c81f08..15eb46c2 100644 --- a/frontend/tests/rectification-spoken-prompt.test.ts +++ b/frontend/tests/rectification-spoken-prompt.test.ts @@ -66,6 +66,13 @@ test("spokenPrompt rejects a different year or domain than the server probe", () questionId: "d9_relationship:relationship:2023", }); assert.equal(yearMismatch.ok ? null : yearMismatch.reason, "year_mismatch"); + const yearMissing = validateSpokenPrompt({ + spokenPrompt: "你有没有一段认真开始或结束的关系?", + followup: probe, + targetDomain: "relationship", + questionId: "d9_relationship:relationship:2023", + }); + assert.equal(yearMissing.ok ? null : yearMissing.reason, "year_missing"); const domainMismatch = validateSpokenPrompt({ spokenPrompt: "2023 年前后,你有没有换过工作?", followup: probe, @@ -80,3 +87,31 @@ test("spokenPrompt rejects a different year or domain than the server probe", () questionId: "d9_relationship:relationship:2023", }).ok, true); }); + +test("spokenPrompt requires each year in a period when probe_year is empty", () => { + const ranged = followup({ + probe_year: undefined, + choice_frame: { + ...followup().choice_frame!, + period: "2014–2016 年前后", + }, + }); + const questionId = "d9:relationship:2023"; + const missing = validateSpokenPrompt({ + spokenPrompt: "你有没有升学或换过学习环境?", + followup: ranged, + questionId, + }); + assert.equal(missing.ok ? null : missing.reason, "year_missing"); + assert.equal(validateSpokenPrompt({ + spokenPrompt: "2014 到 2016 年前后,有没有升学或换过学习环境?", + followup: ranged, + questionId, + }).ok, true); + const collect = followup({ choice_frame: null, probe_year: 2023 }); + assert.equal(validateSpokenPrompt({ + spokenPrompt: "你还记得哪年入的职吗?", + followup: collect, + targetDomain: "relationship", + }).ok, true); +}); diff --git a/frontend/tests/rectification-v10-conversation-focus.test.ts b/frontend/tests/rectification-v10-conversation-focus.test.ts index 3952736a..577811e6 100644 --- a/frontend/tests/rectification-v10-conversation-focus.test.ts +++ b/frontend/tests/rectification-v10-conversation-focus.test.ts @@ -112,8 +112,18 @@ test("set/resolve focus services preserve the exact durable RPC contract", async test("set/resolve focus tools return safe projections and never infer the active focus", async () => { const accounting = fakeAccounting({ ...receiptHandlers, - set_agentic_rectification_conversation_focus: () => ({ - focus: activeFocusFixture({ targetEvidenceId: EVIDENCE_ID }), + get_agentic_rectification_case_dossier: () => dossierFixture({ + latestResult: candidateSnapshotFixture(), + }), + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: activeFocusFixture({ + questionId: String(args.p_question_id), + intent: String(args.p_intent), + targetEvidenceId: args.p_target_evidence_id ? String(args.p_target_evidence_id) : null, + targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : null, + targetKind: args.p_target_kind == null ? null : String(args.p_target_kind), + expectedAnswerSchema: (args.p_expected_answer_schema as Record) ?? {}, + }), idempotent: false, }), resolve_agentic_rectification_conversation_focus: (_fn, args) => ({ @@ -129,23 +139,15 @@ test("set/resolve focus tools return safe projections and never infer the active caseId: CASE_ID, questionId: "career-month-question", intent: "clarify_event_date", - spokenPrompt: "2016年前后,你是哪年上的大学?", - targetEvidenceId: EVIDENCE_ID, - targetDomain: "career", - targetKind: "career_entry", - }); - assert.deepEqual(setResult, { - focus_id: FOCUS_ID, - question_id: "question-1", - intent: "confirm_revision", - target_evidence_id: EVIDENCE_ID, - target_domain: "career", - target_kind: "career_entry", - expected_answer_schema: {}, - status: "active", - asked_at: "2026-08-12T10:00:05.000Z", - idempotent: false, - }); + spokenPrompt: "你还记得哪年上的大学吗?", + }) as Record; + assert.equal(setResult.ok, undefined); + assert.equal(setResult.focus_id, FOCUS_ID); + assert.equal(setResult.error, undefined); + const schema = setResult.expected_answer_schema as Record; + assert.equal(schema.collect, true); + assert.equal(typeof schema.prompt, "string"); + assert.equal(schema.choice, undefined); const resolved = await (tools["rectification-resolve-focus"] as unknown as ExecutableTool>).execute({ caseId: CASE_ID, diff --git a/frontend/tests/rectification-v9-regenerate.test.ts b/frontend/tests/rectification-v9-regenerate.test.ts index a9f22761..abc4eec1 100644 --- a/frontend/tests/rectification-v9-regenerate.test.ts +++ b/frontend/tests/rectification-v9-regenerate.test.ts @@ -75,6 +75,7 @@ function validAccounting() { return fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ turns: completedTurns() }), + list_agentic_rectification_conversation_focuses: () => [], insert_agentic_rectification_skill_run_receipt: () => ({ receipt_id: "99999999-9999-4999-8999-999999999999", }), @@ -214,6 +215,7 @@ test("only the latest completed Assistant turn can be regenerated", async () => const accounting = fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ turns }), + list_agentic_rectification_conversation_focuses: () => [], }); const { agent, calls } = fakeAgent(); @@ -237,6 +239,7 @@ test("missing or empty completed Assistant text is rejected", async () => { created_at: "2026-08-13T01:00:00.000Z", }], }), + list_agentic_rectification_conversation_focuses: () => [], }); const { agent, calls } = fakeAgent();