fix(rectification): keep an open out-of-sample A–D card tappable
Independent Staging Quality Gate / validate (push) Successful in 11m52s
Independent Staging Quality Gate / publish (push) Successful in 1m46s

GET was dropping choice_card when the recast had no choice_frame, so the
embedded options rendered disabled. Replay the persisted reverse-verify /
out-of-sample schema instead of silencing the live question.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 15:30:49 +08:00
parent 74aaa1ba99
commit 591ffd21a1
6 changed files with 223 additions and 6 deletions
+16
View File
@@ -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` 画出 AD,但 `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
- 修复版本:待发布
@@ -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);
}
@@ -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 AD schema (even if the recast has no choice_frame).
* Card identity is the persisted focus UUID plus the inference revision.
*/
@@ -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
// AD 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<string, unknown>
: null;
@@ -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\)/);
@@ -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 AD 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);
});