diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e6630895..974124d3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7856,3 +7856,19 @@ - 相关记录:BUG-505 - 复发自:无 - 修复版本:待发布 + +## BUG-510 | 采集阶段盘外核对选择题画出 A–D 却点不了 + +- 状态:resolved +- 首次发现:2026-09-03 +- 最近更新:2026-09-03 +- 影响面:`projectRectificationChoiceCard`、GET `/api/rectification/cases/[caseId]` `choice_card`、`rectification-agentic-chat` 内嵌卡 +- 用户现象:助手记下带月份的职场事后,下面出现「某年前后,有没有开始一段认真关系、分手或结婚?」和 A–D,点任何一项都没有反应。`current_question` 已是选择题,`choice_card` 为 null。 +- 触发条件:采集阶段已有带年份事件,下一问是 `intent=out_of_sample_check` 的四点选存在性题;schema 有合法 A–D,但没有 `probe_year`。该回合未调用 `rectification-set-focus`,题目由 idle persist 挂到最后一条助手消息。`selection_allowed` 仍为 false(采用门,不是点选门)。 +- 根因:keep 路径 `forceChoice` 会尝试重铸 `choice_frame`。缺 `probe_year` 时 `hypothesisFor` 没有具体时期,frame 为空,GET 按设计把 `choice_card` 置空。界面仍从 turn 上的 `question.options` 画出 A–D,但 `liveQuestion` 要求 GET 卡与 `focus_id` 一致,`disabled={!liveQuestion}`,`submitChoice` 在没有 `choiceCard` 时直接 return。 +- 修复:`projectRectificationChoiceCard` 在重建 followup 没有 `choice_frame` 时,若当前焦点已是 `reverse_verify` / `out_of_sample_check` 且 schema 能解析出四点选,按已持久化文案投影 `choice_card`。口述采集 schema 仍不发卡。未放宽区分题身份校验,未打开 `selection_allowed`。 +- 验证:`rectification-choice-card` 锁定无 `probe_year` 的盘外存在性卡仍可投影、口述采集盘外题仍不发卡;`rectification-agentic-entry` 锁内嵌卡 `disabled={!liveQuestion}` 且点选要求 GET 卡 `focus_id` 一致。 +- 防复发:GET 已有合法持久化 reverse_verify / out_of_sample_check 四点选 schema 时,不得因重建缺 `choice_frame` 把 `choice_card` 置空。界面画出 A–D 时 GET 必须给出可点的卡。不得把 `selection_allowed` 当成 A–D 点选门。不得从题干正则反推年份。 +- 相关记录:BUG-416、BUG-431、BUG-498 +- 复发自:BUG-431 +- 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index 6d5a4848..bb922665 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -524,6 +524,38 @@ export function mergeChoiceCard( }; } +export function choiceCardFromPersistedVerifyCopy(input: { + copy: AgentChoiceCopy; + questionId: string; + methodId: string; + scoring: boolean; + probeId: string | null; + caseRevision: number | null; + focusId: string; +}): RectificationChoiceCard | null { + if (!input.copy.prompt.trim() || !input.questionId.trim()) return null; + if (!isPersistedFocusId(input.focusId)) return null; + return { + question_id: input.questionId, + method_id: input.methodId, + prompt: input.copy.prompt, + why: "", + varga: null, + choice_mode: CHOICE_MODE, + options: input.copy.options.map((option) => ({ + ...option, + role: "primary" as const, + })), + stop_label: CHOICE_SKIP_QUESTION_LABEL, + stop_message: CHOICE_SKIP_QUESTION_MESSAGE, + scoring: input.scoring, + probe_id: input.probeId, + case_revision: input.caseRevision, + focus_id: input.focusId, + choice_kind: "existence", + }; +} + export function isHoldoutVerificationQuote(quote: string): boolean { return quote.includes(HOLDOUT_MESSAGE_PREFIX); } diff --git a/frontend/src/lib/rectification-agentic/v9/interview-state.ts b/frontend/src/lib/rectification-agentic/v9/interview-state.ts index 4a2ea82b..92596855 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -1,8 +1,9 @@ /** * Public interview projection for the Case GET payload. * - * The tap card is shown only when the follow-up is a discriminator - * (candidates already diverge or holdout) and the Agent wrote choice copy. + * The tap card is shown when the rebuilt follow-up has a matching + * discriminator frame, or when the open focus is already a reverse-verify / + * out-of-sample A–D schema (even if the recast has no choice_frame). * Card identity is the persisted focus UUID plus the inference revision. */ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 0ed459e0..5855c822 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -71,6 +71,7 @@ import { parseAgentChoiceCopy, preferConcreteChoicePrompt, mergeChoiceCard, + choiceCardFromPersistedVerifyCopy, serverOwnedChoiceCopy, type RectificationChoiceCard, type RectificationChoiceFrame, @@ -2194,11 +2195,37 @@ export function projectRectificationChoiceCard( ? input.activeFocus.id.trim() : ""; if (!isPersistedFocusId(focusId)) return null; - const followup = plan.next_followup ?? plan.deferred_followup ?? null; - if (!followup) return null; - const frame = followup.choice_frame; - if (!frame) return null; const schema = input.activeFocus?.expectedAnswerSchema ?? null; + const schemaCopy = parseAgentChoiceCopy(schema); + const intent = input.activeFocus?.intent ?? ""; + const verifyOnly = intent === "reverse_verify" || intent === "out_of_sample_check"; + // Recast can drop choice_frame when the schema has no probe_year. The open + // A–D schema is still the live question; GET must not silence it. + const persistedVerifyCard = (): RectificationChoiceCard | null => { + if (!verifyOnly || !schemaCopy) return null; + const parsed = parsePersistedFollowupQuestionId(input.activeFocus?.questionId); + const questionId = input.activeFocus?.questionId?.trim() ?? ""; + const probeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string" + ? (schema as { probe_id: string }).probe_id + : null; + return choiceCardFromPersistedVerifyCopy({ + copy: { + ...schemaCopy, + prompt: overlayChoicePromptFromSpoken(schemaCopy.prompt, input.latestAssistantText), + }, + questionId, + methodId: parsed?.method_id + ?? (intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify"), + scoring: parsed?.scoring !== false, + probeId, + caseRevision: input.caseRevision ?? null, + focusId, + }); + }; + const followup = plan.next_followup ?? plan.deferred_followup ?? null; + if (!followup) return persistedVerifyCard(); + const frame = followup.choice_frame; + if (!frame) return persistedVerifyCard(); const schemaRow = schema && typeof schema === "object" && !Array.isArray(schema) ? schema as Record : null; diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index c1a24c92..8bdf714d 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -593,6 +593,8 @@ test("time-selection cards use server adoption state and stay mutually exclusive assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/); assert.doesNotMatch(chat, /choiceCardUserMessage/); assert.match(caseRoute, /choice_card: choiceCardFromCaseDossier/); + assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === question\.focus_id/); + assert.match(chat, /disabled=\{!liveQuestion\}/); assert.match(caseRoute, /current_question: projectCurrentQuestion/); assert.match(caseRoute, /overlayPublicDecision/); assert.match(caseRoute, /const fields = publicDecisionFields\(decision\)/); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index 699e431f..81d3bebd 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -1524,3 +1524,142 @@ test("GET keeps hiding the card when the persisted split belongs to another prob ); assert.equal(card, null); }); + +test("GET still renders an open out-of-sample existence card without probe_year", () => { + const oosCopy = { + prompt: "2020 年前后,有没有开始一段认真关系、分手或结婚?", + option_a: DYNAMIC_STYLE_OPTIONS[0].label, + option_b: DYNAMIC_STYLE_OPTIONS[1].label, + option_c: DYNAMIC_STYLE_OPTIONS[2].label, + option_d: DYNAMIC_STYLE_OPTIONS[3].label, + options: DYNAMIC_STYLE_OPTIONS.map((option, index) => ({ + key: (["A", "B", "C", "D"] as const)[index]!, + label: option.label, + answer_class: option.answer_class, + })), + }; + const card = choiceCardFromCaseDossier({ + evidence: [{ + id: "ev-education", + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: "2016-01-01", + eventKind: "education_start", + summary: "2016 年开始上大学", + }, { + id: "ev-career-entry", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-04-01", + occurredTo: null, + eventKind: "career_entry", + summary: "2020年4月开始实习", + }, { + id: "ev-career-exit", + status: "confirmed", + domain: "career", + datePrecision: "month", + occurredFrom: "2020-10-01", + occurredTo: null, + eventKind: "career_exit", + summary: "2020年10月实习离职", + }], + conversationSummary: { + activeFocus: { + id: FOCUS_ID, + questionId: "oos_blind:relationship_style:score", + intent: "out_of_sample_check", + targetDomain: "relationship", + targetKind: null, + expectedAnswerSchema: { + choice: oosCopy, + }, + }, + declinedSkippedTopics: [], + }, + latestResult: { + resultId: "result-oos-open", + selectionAllowed: false, + confirmationAllowed: false, + candidates: [ + { time: "05:12", rank: 1, relativeSupport: 15, tiedMinuteCount: 1 }, + { time: "05:14", rank: 2, relativeSupport: 15, tiedMinuteCount: 1 }, + ], + decisionReceipt: { + selection_allowed: false, + propose_allowed: false, + confirmation_allowed: false, + precision_stage: { current: "d9_refine", can_stop: true }, + discriminating_event_probes: [], + oos_blind_prompts: [{ + domain: "relationship", + user_meaning: "校时还没用过感情这条线。有没有一件没提过、但记得大概时间的关系变化?", + used_for_scoring: false, + }], + inference_state: { + algorithm_version: "rectification-inference-v1", + candidate_set_id: "04:45-05:15:05:12,05:14", + revision: 1, + phase: "event_collection", + result_status: "insufficient_evidence", + range_start: "04:45", + range_end: "05:15", + candidates: [ + { id: "05:12", time: "05:12", rank: 1, status: "equivalent", prior_score: 15, posterior_score: 15, probability: 0.5, cluster_range: ["05:12", "05:14"], strong_conflict_count: 0 }, + { id: "05:14", time: "05:14", rank: 2, status: "equivalent", prior_score: 15, posterior_score: 15, probability: 0.5, cluster_range: ["05:12", "05:14"], strong_conflict_count: 0 }, + ], + events: [ + { id: "ev-education", year: 2016, usage: "training", domain: "education", precision: "year" }, + { id: "ev-career-entry", year: 2020, usage: "training", domain: "career", precision: "month" }, + { id: "ev-career-exit", year: 2020, usage: "holdout", domain: "career", precision: "month" }, + ], + probes: [], + answered_probes: [], + rounds: [], + entropy: 1, + representative_time: "05:12", + credible_range: ["05:12", "05:14"], + }, + }, + }, + case: { acceptedTime: null, status: "collecting_evidence" }, + turns: [{ + role: "assistant", + text: "2020 年 4 月开始实习、同年 10 月离职,两件都记下了。接下来我们继续。", + }], + }); + assert.ok(card, "an already-open A–D out-of-sample card must stay tappable"); + assert.equal(card.focus_id, FOCUS_ID); + assert.equal(card.question_id, "oos_blind:relationship_style:score"); + assert.equal(card.prompt, oosCopy.prompt); + assert.equal(card.options.length, 4); + assert.equal(card.options[0]?.label, "明确发生且时间吻合"); + assert.equal(parseRectificationChoiceCard(card)?.focus_id, FOCUS_ID); +}); + +test("GET does not mint a tap card from an out-of-sample spoken collect focus", () => { + const card = projectRectificationChoiceCard({ + evidence: [{ + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + }], + activeFocus: { + id: FOCUS_ID, + questionId: "oos_blind:holdout", + intent: "out_of_sample_check", + targetDomain: "career", + targetKind: null, + expectedAnswerSchema: { + collect: true, + prompt: "上大学之外,你还记得第一份正式工作大概是从哪一年开始干的吗?", + }, + }, + }); + assert.equal(card, null); +});