fix(rectification): keep an open out-of-sample A–D card tappable
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:
@@ -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
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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 A–D 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
|
||||
// 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<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 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user