From 8221c6121b008b83d1b339248d13bad1db6bd0d7 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 14 Sep 2026 01:51:55 +0800 Subject: [PATCH] fix(rectification): restore targeted collect cards from spoken focus (BUG-673) Keep targeted existence questions as A-D cards. Recover collect-schema stock by question-id prefix, surface the stem when persist fails, and send spoken targeted existence to the repair exit instead of a naked prompt. --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 16 + ...-targeted-collect-spoken-focus-20260913.md | 30 ++ docs/tasks/README.md | 2 +- ...-targeted-collect-spoken-focus-20260913.md | 19 ++ .../components/rectification-agentic-chat.tsx | 1 + .../rectification-agentic/v9/answer-choice.ts | 1 + .../v9/collection-question-pool.ts | 26 ++ .../v9/method-followup.ts | 114 ++++++- .../rectification-agentic/v9/server-focus.ts | 79 ++++- .../rectification-agentic/v9/spoken-prompt.ts | 3 + .../rectification-agentic/v9/turn-decision.ts | 29 +- .../src/lib/rectification-surface-state.ts | 14 +- frontend/src/mastra/rectification-v9-tools.ts | 34 +- .../tests/rectification-surface-state.test.ts | 19 ++ ...cation-targeted-card-live-20260913.test.ts | 11 + ...ted-spoken-focus-recovery-20260913.test.ts | 308 ++++++++++++++++++ 17 files changed, 673 insertions(+), 38 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md create mode 100644 docs/testing/rectification-targeted-collect-spoken-focus-20260913.md create mode 100644 frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 875c4e39..03f394e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-13 — 定向补事存在题不再落成口述裸题 + +生时校正里,定向补事「有没有这件事」必须是四点选。若焦点被写成口述采集,刷新后不再只剩一行没有卡片的题干,会复原成可点的卡,或给出「接着问」修复入口。答完上一题后,下一问如果没落库,题干会写进本轮旁白,不会把计划中的 D9 题悄悄吞掉。年份追问仍是口述。Skill 版本不变。 + + ## 2026-09-13 — 健康与职业别名在比较时归并成同一领域 生时校正里,账本记的「健康」和题库里的「健康压力」是同一条线:跳过或已经报过健康后,不会再问同一年的健康核对题,盘外提示也不会再说「还没用过健康」。职业题虽然落库写成「其他」,拒答后也不会在事业还没覆盖时再问一遍职业。比较只走一处归并,数据库字段不改。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3d09fadb..25721894 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10468,3 +10468,19 @@ - 相关记录:BUG-590、BUG-626、BUG-586、BUG-641 - 复发自:BUG-590 决策 4(occupied 已归并,其余站点未归并) - 修复版本:待发布 + +## BUG-673 | 定向补事存在题被存成口述焦点,裸题顶掉卡片 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`serverOwnedExpectedAnswerSchema`、`persistServerOwnedFocusCore`、`persistSkippedCollectFocus`、`rectification-set-focus`、`buildMethodFollowupPlan` 承接焦点、`projectCurrentQuestion`、`interviewChoiceCardUnavailable` +- 用户现象:答完带年月题后出现一行没有头像、没有 A/B/C/D 的粗体裸题(如「家里添过丁或长辈住过院吗?」),无法点选;本轮计划的 D9 关系题消失。 +- 触发条件:定向存在题以采集型 schema(`collect: true`、`collect_kind: targeted:*`)落进活动焦点;`choice.applied` 的 `nextInterviewPersisted=true` 让前端不再追一轮。 +- 根因:写入侧在 `expectedAnswerSchemaFor` 没产出 `.choice` 时把采集意图降成口述 schema;复原侧只认 `targeted_collect === true` 章,拦不住采集型 schema。BUG-670 的识别条件覆盖不到这条不经模型的路径。 +- 修复:定向存在题禁止降级成采集型 schema,重建不出 frame 就不写焦点。承接焦点与 GET 投影按题号前缀识别口述定向存在题并重建四点选。焦点落库失败时 `persistNextInterviewAfterChoice` 显式 `persisted: false`,题干进本轮旁白。`persistSkippedCollectFocus` resolve 失败重试一次,不得留下 `status=active`。前端把口述形态的定向存在题当缺卡,走 repair-exit。年份阶段 `collect:targeted::year` 仍是口述。 +- 验证:`frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts`;`rectification-targeted-card-live-20260913.test.ts`、`rectification-surface-state.test.ts` 补口述定向存在题。 +- 防复发:定向补事存在题的焦点 schema 只允许点选形态;识别定向存在题以题号前缀为准,不得只认 `targeted_collect` 字段。不得把 `persisted_question` 块画成卡片来绕过数据层。 +- 相关记录:BUG-670、BUG-669、BUG-671、BUG-661 +- 复发自:BUG-670 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md b/docs/tasks/PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md new file mode 100644 index 00000000..f3146aca --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md @@ -0,0 +1,30 @@ +# 进度 · 定向补事口述焦点复原(2026-09-13) + +## 范围 + +- 分支:`codex/rectification-targeted-collect-spoken-focus-20260913`(基于 `origin/staging` @ `9a943cc2`) +- 任务单:`docs/tasks/TASK-rectification-targeted-collect-spoken-focus-20260913.md` +- BUG-673(复发自 BUG-670);最大号核对时为 672 +- 不改数据库、迁移、`deploy/**`、workflow、`page.tsx` + +## 完成 + +- T1 写入侧:`serverOwnedExpectedAnswerSchema` / `persistServerOwnedFocusCore` 对定向存在题先 `rebuildTargetedCollectExistenceFollowup`;重建失败返回 null / skipped,不得 `collectFocusSchema`。`rectification-set-focus` 同一条件不降级,spokenPrompt 不替换卡片题干。年份阶段 `collect:targeted::year` 仍是口述。 +- T2 复原侧:承接焦点与 GET `choice_card` 按题号前缀识别采集型 schema,用 `schema.prompt` 重建 frame。`projectCurrentQuestion` 同条件投影 `kind: "choice"`。`:next` 后缀同样能复原。 +- T3 `persistNextInterviewAfterChoice` 尾部显式 `persisted: false`;`keptNextQuestion` 为假时 `next_user_action` 不再引用未落库的题。`persistSkippedCollectFocus` 对定向存在题写点选 schema,resolve 失败重试一次,返回值不得为 `active`。 +- T4 `interviewChoiceCardUnavailable` 增加 `questionId`:口述形态的定向存在题走 unavailable + repair-exit;普通口述与 year 阶段不误伤。 +- T5 BUG-673、CHANGELOG、本文件、`docs/testing/rectification-targeted-collect-spoken-focus-20260913.md`。 + +## 让步 + +- 任务书 T4 写「questionId 以 `collect:targeted:` 开头」一律判不可用。年份追问 `collect:targeted::year` 本来就是口述,若按字面会误伤。实现只把存在题(可带 `:next`)当缺卡。 + +## 验收 + +- `tsc --noEmit`:0 错 +- ESLint(改动文件):0 error;既有 unused-vars warning 未新增 +- 新测试 `rectification-targeted-spoken-focus-recovery-20260913.test.ts`:10/10 +- 相关回归:`rectification-server-focus` / `question-ownership` / `answer-choice` / `spoken-prompt` 69/69;`targeted-card-live` + `surface-state` 通过 +- 本机 `npm test`:2948 tests,2775 pass / 159 fail / 14 skip。失败项是 Windows CRLF、无 Docker、bash 脚本合同(Caddy `\n` vs `\r\n`、deploy/*.sh、database-*),与本单无关。新增测试只增加通过数。 +- `next build`:Compile + TypeScript 通过;collect page data 在本机 Windows 因 `EPERM symlink` 与 Skill 10.0.26 SHA 校验失败(未改 `skills/`)。`/` 是否仍 `○ Static` 未在本机核对。 +- 真机存量会话复验见 `docs/testing/rectification-targeted-collect-spoken-focus-20260913.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6f6a5c5b..8854d6bb 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -184,7 +184,7 @@ | `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` | -| `TASK-rectification-targeted-collect-spoken-focus-20260913.md` | `PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md` | **P0**:定向补事题以采集型 schema 存进焦点,前端只能裸画一行题干(无头像无卡无修复入口),本轮计划的 D9 题被静默吞掉;BUG-670 的识别条件只认 `targeted_collect` 章,复原不了(BUG-673,复发自 BUG-670) | 待执行 | `codex/rectification-targeted-collect-spoken-focus-20260913` | +| `TASK-rectification-targeted-collect-spoken-focus-20260913.md` | `PROGRESS-rectification-targeted-collect-spoken-focus-20260913.md` | **P0**:定向补事题以采集型 schema 存进焦点,前端只能裸画一行题干(无头像无卡无修复入口),本轮计划的 D9 题被静默吞掉;BUG-670 的识别条件只认 `targeted_collect` 章,复原不了(BUG-673,复发自 BUG-670) | 待验收 | `codex/rectification-targeted-collect-spoken-focus-20260913` | | `TASK-rectification-house-lord-gochara-research-20260913.md` | `PROGRESS-rectification-house-lord-gochara-research-20260913.md` | 研究单:宫主触发与木星/土星过运(合冲本命宫主、罗睺紧密合、年精度、用于 block 选上升)四种放宽,20 例公开 AA 离线量 block 层与 minute 层两组指标;引擎里已有宫主/功能吉凶/受控过运,只量缺的四条 | 待验收(无收益,关闭;不立实现单) | `codex/rectification-house-lord-gochara-research-20260913` | diff --git a/docs/testing/rectification-targeted-collect-spoken-focus-20260913.md b/docs/testing/rectification-targeted-collect-spoken-focus-20260913.md new file mode 100644 index 00000000..6c77d4ea --- /dev/null +++ b/docs/testing/rectification-targeted-collect-spoken-focus-20260913.md @@ -0,0 +1,19 @@ +# 定向补事口述焦点复原:真机清单(2026-09-13) + +分支 `codex/rectification-targeted-collect-spoken-focus-20260913`,任务书 `docs/tasks/TASK-rectification-targeted-collect-spoken-focus-20260913.md`。 + +本环境无 staging 登录态与该会话数据库/日志,**线上那条存量家庭焦点是否已复原成卡片只能由产品负责人在真机复验**。自动化锁的是写入、承接、投影与缺卡修复入口。 + +## 存量会话 + +进入当时那个校正会话,刷新页面。 + +- [ ] 「家里添过丁或长辈住过院吗?」以四选一卡出现(有过这件事 / 没有发生过 / 记不太清楚 / 这条先跳过),不再是没有头像、没有选项的裸题。 +- [ ] 若仍没有卡,应出现「接着问」修复入口,而不是停在「再说一件带年月的事就能继续」。 + +## 新路径 + +再走一遍「答完带年月题 → 定向补事」。 + +- [ ] 下一问是点选卡,不是口述裸题。 +- [ ] 点 C「明确没有发生」后,下一问(含 D9)要么是可点卡,要么题干出现在本轮旁白里,不会整题消失。 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 19fd4608..718f9fc8 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1551,6 +1551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const deadChoice = interviewChoiceCardUnavailable({ questionKind: currentQuestion?.kind, hasChoiceCard: Boolean(choiceCard), + questionId: currentQuestion?.question_id, }); const liveQuestionOnMessages = Boolean( latestSettledAssistant diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 5daf4a52..c0e88edb 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -1173,6 +1173,7 @@ export async function persistNextInterviewAfterChoice(input: { return { hostNarration: spoken, choiceReady: false, + persisted: false, followup, }; } diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index c36d2c19..313f4c18 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -640,6 +640,32 @@ export function isTargetedCollectFollowup(followup: { || hint.startsWith("targeted:"); } +export function isTargetedCollectExistenceFollowup(followup: { + collection_key?: string; + kind_hint?: string | null; +} | null | undefined): boolean { + if (!followup) return false; + const ref = parseTargetedCollectQuestionId(followup.collection_key) + ?? parseTargetedCollectKind(followup.kind_hint); + return ref?.stage === "existence"; +} + +export function isTargetedCollectExistenceFocus(focus: { + questionId?: string | null; + targetKind?: string | null; + expectedAnswerSchema?: Readonly> | null; +} | null | undefined): boolean { + if (!focus) return false; + const schema = focus.expectedAnswerSchema; + const schemaKind = schema && typeof schema.collect_kind === "string" + ? schema.collect_kind + : null; + const ref = parseTargetedCollectQuestionId(focus.questionId) + ?? parseTargetedCollectKind(focus.targetKind) + ?? parseTargetedCollectKind(schemaKind); + return ref?.stage === "existence"; +} + function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet { const declined = new Set(); for (const topic of topics) { diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index d2f43f73..f29027cf 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -102,6 +102,10 @@ import { targetedCollectPool, targetedCollectQuestionId, TARGETED_YEAR_PROMPT, + isTargetedCollectExistenceFollowup, + isTargetedCollectExistenceFocus, + parseTargetedCollectQuestionId, + parseTargetedCollectKind, type CollectKind, type CollectionPoolItem, } from "./collection-question-pool.ts"; @@ -655,13 +659,56 @@ function targetedCollectDomainFromFocus(focus: { return match?.[1] ?? null; } -function targetedCollectMethodId(domain: string | null): MethodFollowup["method_id"] { +export function targetedCollectMethodId(domain: string | null): MethodFollowup["method_id"] { if (domain && domain in PROBE_METHOD_ID) { return PROBE_METHOD_ID[domain as keyof typeof PROBE_METHOD_ID]; } return "dasha_events"; } +export function rebuildTargetedCollectExistenceFrame(input: { + questionId?: string | null; + domain?: string | null; + kindHint?: string | null; + prompt?: string | null; +}): RectificationChoiceFrame | null { + const ref = parseTargetedCollectQuestionId(input.questionId) + ?? parseTargetedCollectKind(input.kindHint); + if (ref?.stage !== "existence") return null; + const questionId = (input.questionId ?? "").trim() || targetedCollectQuestionId(ref.domain); + const prompt = (input.prompt ?? "").trim(); + if (!questionId || !prompt) return null; + return buildTargetedCollectExistenceFrame({ + questionId, + methodId: targetedCollectMethodId(input.domain ?? ref.domain), + prompt, + }); +} + +export function rebuildTargetedCollectExistenceFollowup( + followup: MethodFollowup, +): MethodFollowup | null { + if (!isTargetedCollectExistenceFollowup(followup)) return null; + const prompt = followup.spoken_prompt?.trim() + || followup.choice_frame?.prompt?.trim() + || followup.user_prompt_hint?.trim() + || ""; + const frame = rebuildTargetedCollectExistenceFrame({ + questionId: followup.collection_key ?? followup.choice_frame?.question_id, + domain: followup.domain, + kindHint: followup.kind_hint, + prompt, + }); + if (!frame) return null; + return { + ...followup, + choice_frame: frame, + choice_kind: "existence", + collection_key: followup.collection_key ?? frame.question_id, + spoken_prompt: followup.spoken_prompt ?? frame.prompt, + }; +} + function contrastFollowupDomain( domain: string | null, ): keyof typeof REVERSE_VERIFY_THEME { @@ -2231,15 +2278,23 @@ export function buildMethodFollowupPlan(input: { const targetedCopy = isTargetedCollectChoiceSchema(focus.expectedAnswerSchema) ? parseAgentChoiceCopy(focus.expectedAnswerSchema) : null; - if (focus.intent === "collect_method_evidence" && targetedCopy) { + const spokenTargetedPrompt = !targetedCopy + && focus.intent === "collect_method_evidence" + && isTargetedCollectExistenceFocus(focus) + && focus.expectedAnswerSchema?.collect === true + && typeof focus.expectedAnswerSchema.prompt === "string" + ? focus.expectedAnswerSchema.prompt.trim() + : ""; + if (focus.intent === "collect_method_evidence" && (targetedCopy || spokenTargetedPrompt)) { const questionId = focus.questionId?.trim() ?? ""; const domain = targetedCollectDomainFromFocus(focus); const methodId = targetedCollectMethodId(domain); + const prompt = targetedCopy?.prompt ?? spokenTargetedPrompt; const frame = questionId ? buildTargetedCollectExistenceFrame({ questionId, methodId, - prompt: targetedCopy.prompt, + prompt, }) : null; if (frame) { @@ -2259,7 +2314,7 @@ export function buildMethodFollowupPlan(input: { choice_kind: "existence", source: "active_focus", collection_key: questionId, - spoken_prompt: targetedCopy.prompt, + spoken_prompt: prompt, invite_more_once: false, }, deferred_followup: null, @@ -3038,19 +3093,48 @@ export function projectRectificationChoiceCard( // from the persisted copy even when the plan recast has no frame, and even // when persist added :next to the question id. const persistedTargetedCard = (): RectificationChoiceCard | null => { - if (!isTargetedCollectChoiceSchema(schema) || !schemaCopy) return null; const questionId = input.activeFocus?.questionId?.trim() ?? ""; - return choiceCardFromPersistedTargetedCopy({ - copy: schemaCopy, - questionId, - methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({ + if (isTargetedCollectChoiceSchema(schema) && schemaCopy) { + return choiceCardFromPersistedTargetedCopy({ + copy: schemaCopy, questionId, - targetDomain: input.activeFocus?.targetDomain, - })), - probeId: schemaProbeId, - caseRevision: input.caseRevision ?? null, - focusId, - }); + methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({ + questionId, + targetDomain: input.activeFocus?.targetDomain, + })), + probeId: schemaProbeId, + caseRevision: input.caseRevision ?? null, + focusId, + }); + } + if ( + input.activeFocus?.intent === "collect_method_evidence" + && isTargetedCollectExistenceFocus(input.activeFocus) + && schema + && schema.collect === true + && typeof schema.prompt === "string" + ) { + const frame = rebuildTargetedCollectExistenceFrame({ + questionId, + domain: input.activeFocus.targetDomain, + kindHint: collectKindFromFocus(input.activeFocus), + prompt: schema.prompt, + }); + const copy = frame ? serverOwnedChoiceCopy(frame) : null; + if (!copy) return null; + return choiceCardFromPersistedTargetedCopy({ + copy, + questionId, + methodId: targetedCollectMethodId(targetedCollectDomainFromFocus({ + questionId, + targetDomain: input.activeFocus.targetDomain, + })), + probeId: schemaProbeId, + caseRevision: input.caseRevision ?? null, + focusId, + }); + } + return null; }; const targetedCard = persistedTargetedCard(); if (targetedCard) return targetedCard; diff --git a/frontend/src/lib/rectification-agentic/v9/server-focus.ts b/frontend/src/lib/rectification-agentic/v9/server-focus.ts index fec33e47..49478aaf 100644 --- a/frontend/src/lib/rectification-agentic/v9/server-focus.ts +++ b/frontend/src/lib/rectification-agentic/v9/server-focus.ts @@ -10,7 +10,14 @@ import { previousInferenceFromReceipt, withNakshatraBoundaryProbe, } from "./inference-adapter"; -import { spokenFollowupForUser, spokenCollectFallbackFollowup, collectQuestionDomain, type MethodFollowup } from "./method-followup"; +import { + spokenFollowupForUser, + spokenCollectFallbackFollowup, + collectQuestionDomain, + rebuildTargetedCollectExistenceFollowup, + type MethodFollowup, +} from "./method-followup"; +import { isTargetedCollectExistenceFollowup } from "./collection-question-pool"; import { USER_COLLECT_QUESTION } from "../user-copy"; import { refinementFromDecisionReceipt } from "./refinement-packet"; import { @@ -338,18 +345,23 @@ export function serverOwnedExpectedAnswerSchema( followup: MethodFollowup, decisionReceipt?: Readonly> | null, ): Record | null { - const frame = followup.choice_frame; + const targeted = isTargetedCollectExistenceFollowup(followup) + ? rebuildTargetedCollectExistenceFollowup(followup) ?? followup + : followup; + const frame = targeted.choice_frame; if (frame) { const schema = expectedAnswerSchemaFor( frame, - stableFollowupQuestionId(followup), + stableFollowupQuestionId(targeted), decisionReceipt, - followup, + targeted, ); if (schema?.choice) return schema; + if (isTargetedCollectExistenceFollowup(targeted)) return null; if (followup.intent !== "collect_method_evidence") return null; return collectFocusSchema(spokenCollectFallbackFollowup(followup)); } + if (isTargetedCollectExistenceFollowup(followup)) return null; return collectFocusSchema(followup); } @@ -519,7 +531,11 @@ async function persistServerOwnedFocusCore(input: { followup: MethodFollowup | null; askedTurnId?: string | null; }): Promise { - const followup = input.followup; + const followup = input.followup + ? (isTargetedCollectExistenceFollowup(input.followup) + ? rebuildTargetedCollectExistenceFollowup(input.followup) ?? input.followup + : input.followup) + : null; const frame = followup?.choice_frame ?? null; if (!followup) { return { @@ -529,6 +545,14 @@ async function persistServerOwnedFocusCore(input: { prompt: null, }; } + if (isTargetedCollectExistenceFollowup(followup) && !frame) { + return { + status: "skipped", + focus: input.activeFocus, + questionId: null, + prompt: null, + }; + } if (!frame) { if (followup.intent === "distinguish_candidates") { return { @@ -648,24 +672,45 @@ export async function persistSkippedCollectFocus(input: { caseId: string; followup: MethodFollowup; }): Promise { - const schema = collectFocusSchema(input.followup); - if (!schema) return null; - const questionId = stableFollowupQuestionId(input.followup); + const targeted = isTargetedCollectExistenceFollowup(input.followup) + ? rebuildTargetedCollectExistenceFollowup(input.followup) + : null; + const followup = targeted ?? input.followup; + const schema = targeted + ? serverOwnedExpectedAnswerSchema(targeted, null) + : collectFocusSchema(followup); + if (!schema || (targeted && !schema.choice)) return null; + const questionId = stableFollowupQuestionId(followup); try { const result = await setV10ConversationFocus(input.accounting, input.userId, input.caseId, { questionId, - intent: input.followup.intent, - targetEvidenceId: input.followup.date_reliability_evidence_id ?? null, - targetDomain: persistableFocusDomain(input.followup.domain) - ?? persistableFocusDomain(collectQuestionDomain(input.followup.domain)), - targetKind: collectFocusTargetKind(input.followup), + intent: followup.intent, + targetEvidenceId: followup.date_reliability_evidence_id ?? null, + targetDomain: persistableFocusDomain(followup.domain) + ?? persistableFocusDomain(collectQuestionDomain(followup.domain)), + targetKind: collectFocusTargetKind(followup), expectedAnswerSchema: schema, }); if (!result.focus.id) return result.focus; - await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { - focusId: result.focus.id, - status: "skipped", - }); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await resolveV10ConversationFocus(input.accounting, input.userId, input.caseId, { + focusId: result.focus.id, + status: "skipped", + }); + return { ...result.focus, status: "skipped" }; + } catch (error) { + if (attempt === 1) { + console.warn(JSON.stringify({ + event: "rectification_skipped_collect_focus_resolve_failed", + case_id: input.caseId, + question_id: questionId, + reason: safeToolErrorCode(error), + })); + return { ...result.focus, status: "skipped" }; + } + } + } return { ...result.focus, status: "skipped" }; } catch { return null; diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts index 933e7b7d..99f3e22c 100644 --- a/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts +++ b/frontend/src/lib/rectification-agentic/v9/spoken-prompt.ts @@ -100,6 +100,9 @@ export function withSpokenPrompt( schema: Readonly>, spokenPrompt: string, ): Record { + if (schema.targeted_collect === true) { + return { ...schema, spoken_prompt: spokenPrompt }; + } const next: Record = { ...schema, prompt: spokenPrompt }; const choice = schema.choice; if (choice && typeof choice === "object" && !Array.isArray(choice)) { diff --git a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts index 57bd0e93..99a0fd5e 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-decision.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-decision.ts @@ -12,8 +12,10 @@ import { collectionProgressFromReceipt } from "./evidence-model"; 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 { parseAgentChoiceCopy, serverOwnedChoiceCopy } from "./choice-card"; import { isCollectFocusSchema } from "./server-focus"; +import { isTargetedCollectExistenceFocus } from "./collection-question-pool"; +import { rebuildTargetedCollectExistenceFrame } from "./method-followup"; export const TURN_DECISION_MAX_BYTES = 6 * 1024; export const TURN_DECISION_RECENT_TURNS = 6; @@ -95,6 +97,31 @@ export function projectCurrentQuestion( }; } const collectPrompt = spoken || ""; + if ( + focus.intent === "collect_method_evidence" + && isTargetedCollectExistenceFocus(focus) + && isCollectFocusSchema(schema) + && collectPrompt + ) { + const frame = rebuildTargetedCollectExistenceFrame({ + questionId: focus.questionId, + domain: focus.targetDomain, + kindHint: typeof schema?.collect_kind === "string" ? schema.collect_kind : null, + prompt: collectPrompt, + }); + const rebuilt = frame ? serverOwnedChoiceCopy(frame) : null; + if (rebuilt) { + return { + question_id: focus.questionId ?? null, + focus_id: focus.id ?? null, + probe_id: probeId, + prompt: rebuilt.prompt, + kind: "choice", + intent: focus.intent, + domain: focus.targetDomain ?? null, + }; + } + } if (isCollectFocusSchema(schema) && collectPrompt) { return { question_id: focus.questionId ?? null, diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 14e034b7..afc2b8e3 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -350,10 +350,22 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn export function interviewChoiceCardUnavailable(input: Readonly<{ questionKind?: string | null; hasChoiceCard: boolean; + questionId?: string | null; }>): boolean { // A choice question without a GET card is a dead tap target, not a // collect-wait. The repair-exit path must take over. - return input.questionKind === "choice" && !input.hasChoiceCard; + if (input.questionKind === "choice" && !input.hasChoiceCard) return true; + // Spoken targeted existence is the same dead end: the stem is a card + // question stored as collect_spoken. Year-stage targeted questions stay spoken. + const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, ""); + if ( + input.questionKind === "collect_spoken" + && !input.hasChoiceCard + && /^collect:targeted:[a-z_]+$/.test(questionId) + ) { + return true; + } + return false; } export function interviewCollectWaiting(input: Readonly<{ diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 63fd156d..f708b9e6 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -77,8 +77,10 @@ import { buildNextUserAction, spokenCollectFallbackFollowup, collectQuestionDomain, + rebuildTargetedCollectExistenceFollowup, tieBreakPersonalityAvailable, } from "@/lib/rectification-agentic/v9/method-followup"; +import { isTargetedCollectExistenceFollowup } from "@/lib/rectification-agentic/v9/collection-question-pool"; import { dashaAgreementAmongActive, refinementFromDecisionReceipt } from "@/lib/rectification-agentic/v9/refinement-packet"; import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel"; import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification-label"; @@ -1300,13 +1302,39 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { && persistFollowup.choice_frame && !expectedAnswerSchema?.choice ) { - persistFollowup = spokenCollectFallbackFollowup(persistFollowup); - expectedAnswerSchema = collectFocusSchema(persistFollowup); + if (isTargetedCollectExistenceFollowup(persistFollowup)) { + const rebuilt = rebuildTargetedCollectExistenceFollowup(persistFollowup); + if (rebuilt) { + persistFollowup = rebuilt; + expectedAnswerSchema = serverOwnedExpectedAnswerSchema(persistFollowup, decisionReceipt); + } + if (!expectedAnswerSchema?.choice) { + await receipt("rectification-set-focus", "intent.classified", "failed", { + inputFingerprint, + safeErrorCode: "invalid_choice_schema", + }); + return { ok: false, error: "invalid_choice_schema" }; + } + } else { + persistFollowup = spokenCollectFallbackFollowup(persistFollowup); + expectedAnswerSchema = collectFocusSchema(persistFollowup); + } } if (!expectedAnswerSchema) { + if (isTargetedCollectExistenceFollowup(persistFollowup)) { + await receipt("rectification-set-focus", "intent.classified", "failed", { + inputFingerprint, + safeErrorCode: "invalid_choice_schema", + }); + return { ok: false, error: "invalid_choice_schema" }; + } expectedAnswerSchema = { [COLLECT_FOCUS_SCHEMA_KEY]: true }; } - expectedAnswerSchema = withSpokenPrompt(expectedAnswerSchema, spokenText); + expectedAnswerSchema = persistFollowup.choice_frame + && isTargetedCollectExistenceFollowup(persistFollowup) + && expectedAnswerSchema.choice + ? { ...expectedAnswerSchema, spoken_prompt: spokenText } + : withSpokenPrompt(expectedAnswerSchema, spokenText); await receipt("rectification-set-focus", "intent.classified", "started", { inputFingerprint }); const result = await setV10ConversationFocus(accounting, userId, input.caseId, { questionId: stableFollowupQuestionId(persistFollowup), diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index 494ad7ac..7a054dd2 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -132,6 +132,25 @@ test("question gap: a choice question without a card is unavailable, not collect assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true); assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false); assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false); + // 原值: collect_spoken 一律不是 deadChoice + // 新值: collect:targeted: 存在题的口述形态也是 deadChoice;year 与普通口述仍不是 + // 原因: BUG-673 定向存在题被存成 collect_spoken 时必须走修复入口 + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:targeted:family", + }), + true, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:other:collect_method_evidence", + }), + false, + ); }); test("readonly range copy never says 收窄 or 才会变", () => { diff --git a/frontend/tests/rectification-targeted-card-live-20260913.test.ts b/frontend/tests/rectification-targeted-card-live-20260913.test.ts index 34d6c020..722c39d5 100644 --- a/frontend/tests/rectification-targeted-card-live-20260913.test.ts +++ b/frontend/tests/rectification-targeted-card-live-20260913.test.ts @@ -136,6 +136,17 @@ test("a choice question without a GET card is the repair path, not collect waiti assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true); assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false); assert.equal(interviewChoiceCardUnavailable({ questionKind: "collect_spoken", hasChoiceCard: false }), false); + // 原值: collect_spoken 不是 deadChoice + // 新值: collect:targeted: 存在题的口述形态是 deadChoice + // 原因: BUG-673 复发路径不经过 kind=choice + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:targeted:family", + }), + true, + ); assert.equal( interviewCollectWaiting({ sessionOutcome: "collect_evidence", diff --git a/frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts b/frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts new file mode 100644 index 00000000..b40b5603 --- /dev/null +++ b/frontend/tests/rectification-targeted-spoken-focus-recovery-20260913.test.ts @@ -0,0 +1,308 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + TARGETED_COLLECT_KEEP_HINT, + TARGETED_COLLECT_OPTION_A, + TARGETED_COLLECT_OPTION_B, + TARGETED_COLLECT_OPTION_C, + TARGETED_COLLECT_OPTION_D, + buildTargetedCollectExistenceFrame, + serverOwnedChoiceCopy, +} from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + buildMethodFollowupPlan, + projectRectificationChoiceCard, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + persistSkippedCollectFocus, + persistServerOwnedFocus, + serverOwnedExpectedAnswerSchema, +} from "../src/lib/rectification-agentic/v9/server-focus.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { + interviewChoiceCardUnavailable, + rectificationQuestionGapState, +} from "../src/lib/rectification-surface-state.ts"; +import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { CASE_ID, FOCUS_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts"; + +const FAMILY_PROMPT = "家里添过丁或长辈住过院吗?"; +const DATED_EVIDENCE = [ + { + status: "confirmed", + domain: "education", + datePrecision: "month", + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + }, + { + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + }, +] as const; + +function spokenTargetedSchema(prompt = FAMILY_PROMPT) { + return { + collect: true, + prompt, + collect_kind: "targeted:family", + }; +} + +function spokenTargetedFocus(questionId: string) { + return { + id: FOCUS_ID, + questionId, + intent: "collect_method_evidence" as const, + targetDomain: "family", + targetKind: "targeted:family", + expectedAnswerSchema: spokenTargetedSchema(), + }; +} + +function targetedFollowup(overrides: Partial = {}): MethodFollowup { + const frame = buildTargetedCollectExistenceFrame({ + questionId: "collect:targeted:family", + methodId: "relatives", + prompt: FAMILY_PROMPT, + }); + assert.ok(frame); + return { + method_id: "relatives", + intent: "collect_method_evidence", + ask_theme: "family_event", + domain: "family", + kind_hint: "targeted:family", + user_prompt_hint: TARGETED_COLLECT_KEEP_HINT, + must_not_label: false, + choice_frame: frame, + choice_kind: "existence", + source: "method_coverage", + collection_key: "collect:targeted:family", + spoken_prompt: FAMILY_PROMPT, + ...overrides, + }; +} + +function focusRow(args: Record) { + return { + 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 ?? null, + target_domain: args.p_target_domain ?? null, + target_kind: args.p_target_kind ?? null, + expected_answer_schema: args.p_expected_answer_schema, + status: "active", + asked_at: "2026-09-13T00:00:00.000Z", + resolved_at: null, + asked_turn_id: args.p_asked_turn_id ?? null, + }; +} + +test("spoken targeted existence focus recasts to a scoring-false A–D card", () => { + const plan = buildMethodFollowupPlan({ + evidence: DATED_EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + remainingLayers: ["d9", "d10", "d2", "d11"], + remainingSplitTimes: ["04:48", "05:07"], + remainingCandidateCount: 5, + activeFocus: spokenTargetedFocus("collect:targeted:family"), + }); + const frame = plan.next_followup?.choice_frame; + assert.ok(frame); + assert.equal(frame.question_id, "collect:targeted:family"); + assert.equal(frame.scoring, false); + assert.equal(frame.option_a_hint, TARGETED_COLLECT_OPTION_A); + assert.equal(frame.option_b_hint, TARGETED_COLLECT_OPTION_B); + assert.equal(frame.neither_label, TARGETED_COLLECT_OPTION_C); + assert.equal(frame.unsure_label, TARGETED_COLLECT_OPTION_D); + assert.equal(plan.next_followup?.user_prompt_hint, TARGETED_COLLECT_KEEP_HINT); +}); + +test("spoken targeted existence with :next suffix also recasts", () => { + const plan = buildMethodFollowupPlan({ + evidence: DATED_EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + remainingLayers: ["d9", "d10"], + remainingSplitTimes: ["04:48", "05:07"], + remainingCandidateCount: 4, + activeFocus: spokenTargetedFocus("collect:targeted:family:next"), + }); + assert.equal(plan.next_followup?.choice_frame?.question_id, "collect:targeted:family:next"); + assert.equal(plan.next_followup?.choice_frame?.scoring, false); +}); + +test("projectCurrentQuestion turns spoken targeted existence into kind=choice", () => { + const question = projectCurrentQuestion(spokenTargetedFocus("collect:targeted:family")); + assert.equal(question?.kind, "choice"); + assert.equal(question?.prompt, FAMILY_PROMPT); + const copy = serverOwnedChoiceCopy( + buildTargetedCollectExistenceFrame({ + questionId: "collect:targeted:family", + methodId: "relatives", + prompt: FAMILY_PROMPT, + })!, + ); + assert.ok(copy); + assert.equal(copy.options.length, 4); +}); + +test("GET projects a live card from a spoken targeted existence schema", () => { + const card = projectRectificationChoiceCard({ + evidence: DATED_EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + remainingLayers: ["d9", "d10"], + remainingSplitTimes: ["04:48", "05:07"], + remainingCandidateCount: 4, + activeFocus: spokenTargetedFocus("collect:targeted:family:next"), + }); + assert.ok(card); + assert.equal(card.question_id, "collect:targeted:family:next"); + assert.equal(card.focus_id, FOCUS_ID); + assert.equal(card.scoring, false); + assert.equal(card.options.length, 4); + assert.equal(card.options[0]?.label, TARGETED_COLLECT_OPTION_A); +}); + +test("serverOwnedExpectedAnswerSchema never writes collect schema for targeted existence", () => { + const broken = targetedFollowup({ + choice_frame: { + ...buildTargetedCollectExistenceFrame({ + questionId: "collect:targeted:family", + methodId: "relatives", + prompt: FAMILY_PROMPT, + })!, + option_a_hint: "有过这件事", + option_b_hint: "有过这件事", + }, + }); + const schema = serverOwnedExpectedAnswerSchema(broken, null); + assert.ok(schema?.choice); + assert.equal(schema.targeted_collect, true); + assert.notEqual(schema.collect, true); + const empty = serverOwnedExpectedAnswerSchema(targetedFollowup({ + choice_frame: null, + spoken_prompt: "", + user_prompt_hint: "", + }), null); + assert.equal(empty, null); +}); + +test("persistServerOwnedFocus writes targeted existence as a choice schema", async () => { + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: focusRow(args), + idempotent: false, + }), + }); + const result = await persistServerOwnedFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + activeFocus: null, + decisionReceipt: null, + followup: targetedFollowup({ choice_frame: null }), + }); + assert.equal(result.status, "created"); + assert.equal(result.focus?.expectedAnswerSchema.targeted_collect, true); + assert.ok(result.focus?.expectedAnswerSchema.choice); + assert.notEqual(result.focus?.expectedAnswerSchema.collect, true); +}); + +test("persistSkippedCollectFocus retries resolve and never returns an active focus", async () => { + let resolves = 0; + const accounting = fakeAccounting({ + set_agentic_rectification_conversation_focus: (_fn, args) => ({ + focus: focusRow(args), + idempotent: false, + }), + resolve_agentic_rectification_conversation_focus: () => { + resolves += 1; + throw new Error("resolve failed"); + }, + }); + const focus = await persistSkippedCollectFocus({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + followup: targetedFollowup(), + }); + assert.ok(focus); + assert.equal(focus.status, "skipped"); + assert.notEqual(focus.status, "active"); + assert.equal(resolves, 2); + assert.equal(focus.expectedAnswerSchema.targeted_collect, true); + assert.ok(focus.expectedAnswerSchema.choice); +}); + +test("spoken targeted existence without a GET card is unavailable; ordinary spoken collect is not", () => { + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:targeted:family", + }), + true, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:targeted:family:next", + }), + true, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:targeted:family:year", + }), + false, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "collect:other:collect_method_evidence", + }), + false, + ); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: false, + questionLoadFailed: true, + questionPersisted: false, + collectWaiting: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: 0, + }), + "unavailable", + ); +}); + +test("persistNextInterviewAfterChoice tail names persisted false", () => { + const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + assert.match(source, /hostNarration: spoken,\s*choiceReady: false,\s*persisted: false,/); + assert.match(source, /nextFollowup: keptNextQuestion \? nextFollowup : null/); +}); + +test("chat passes questionId into the dead-choice helper", () => { + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(chat, /interviewChoiceCardUnavailable\(\{[\s\S]*questionId: currentQuestion\?\.question_id/); +});