From 192845ce2f84e970dea1b8c0957b84fd29e3955b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 13 Sep 2026 21:50:35 +0800 Subject: [PATCH] fix(rectification): keep targeted collect cards tappable after snapshot reread (BUG-669~671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET dropped the A–D card when the recast had no choice_frame, so a refresh left a disabled card and a collect-wait. Project from the persisted copy, rebuild the keep frame, and send a dead choice to repair-exit. Co-authored-by: Cursor --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 48 +++++ ...ctification-targeted-card-dead-20260913.md | 36 ++++ docs/tasks/README.md | 2 +- .../rectification-scenarios-20260907.md | 2 + .../components/rectification-agentic-chat.tsx | 26 ++- .../rectification-agentic/v9/choice-card.ts | 40 +++++ .../v9/interview-state.ts | 3 +- .../v9/method-followup.ts | 92 +++++++++- .../src/lib/rectification-surface-state.ts | 9 + .../tests/rectification-surface-state.test.ts | 7 + ...cation-targeted-card-live-20260913.test.ts | 167 ++++++++++++++++++ 12 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-targeted-card-dead-20260913.md create mode 100644 frontend/tests/rectification-targeted-card-live-20260913.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 827759b0..08f44241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-13 — 定向补事点选卡刷新后仍可点 + +生时校正里,带年月题问完后的定向补事卡刷新页面后仍可点,不会停成灰选项,也不会只剩「再说一件带年月的事就能继续」。题干和选项仍由服务端写,模型不得改写成口述题。若选择题没有卡,会出现可点的修复入口。Skill 版本不变。 + + ## 2026-09-13 — 六道带年月题问完后,刷新还能再问更细的换运题 生时校正里,带年月选择题问完后,会按还剩下的候选再出一批带年月题。这一批现在能用更短的同年换运间隔(30 天),也会看第三级换运和 D9/D10 上升的分盘大运。问不出时仍一条一条问定向补事,不会停在「没有拿到下一个问题」。首轮出题规则不变。Skill 版本不变(10.0.25)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1c98b4f0..830d0089 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10356,3 +10356,51 @@ - 相关记录:BUG-653、BUG-654、BUG-661、BUG-664 - 复发自:BUG-653(刷新只复用首轮边界层) - 修复版本:待发布 + +## BUG-669 | 定向补事快照投影拿不到 choice_card,刷新后卡点不动 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`projectRectificationChoiceCard`、GET `/api/rectification/cases/[caseId]` `choice_card`、内嵌 `RectificationChoiceCard` +- 用户现象:六道带年月题后出现定向补事四点选;第一次能答,刷新或再拉快照后卡看得见、选项是灰的。 +- 触发条件:活动焦点为 `collect:targeted:`(含 persist 撞号后的 `:next`),schema 为服务端四点选且 `targeted_collect: true`。 +- 根因:`buildMethodFollowupPlan` 承接 `collect_method_evidence` 时不重建 `choice_frame`。`projectRectificationChoiceCard` 只把 `persistedVerifyCard()` 留给 `reverse_verify` / `out_of_sample_check`,无 frame 就返回 null。`projectCurrentQuestion` 仍给出 `kind: "choice"`,前端 `liveQuestion` 要求 GET 卡 `focus_id` 一致,于是 `disabled={!liveQuestion}`。`:next` 还会被「questionId 与 frame 全等」挡掉。 +- 修复:活动焦点 schema 满足 `targeted_collect === true` 且能解析出四点选时,直接按持久化文案投影 `choice_card`(`question_id` / `focus_id` 用焦点的、`scoring: false`、`stop_label`「这题跳过」),不依赖计划层 frame,也不做 questionId 全等比较。 +- 验证:`frontend/tests/rectification-targeted-card-live-20260913.test.ts`;`:next` 同样出卡。 +- 防复发:定向存在题的 GET 卡必须能从已持久化 schema 投影;不得只用计划层重建 frame 当唯一身份。区分题 `semantic_key` / `candidate_split_hash` 全等判定不得放宽(BUG-559 / BUG-581)。 +- 相关记录:BUG-661、BUG-654、BUG-591、BUG-510、BUG-498 +- 复发自:BUG-661(采集焦点 + 点选卡是新组合,落入 BUG-510 只修了核对题的洞) +- 修复版本:待发布 + +## BUG-670 | 承接焦点分支让模型把定向题改写成口述题 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`buildMethodFollowupPlan` 承接当前焦点分支、`user_prompt_hint` +- 用户现象:定向卡出现前先冒出一道 UI 不一样的口述题,回答后才变成点选卡。 +- 触发条件:活动焦点是定向存在题,计划层 `method_id: "active_focus"` 且 `choice_frame` 为空,提示词要求模型用 `spokenPrompt` 自己写题干。 +- 根因:承接分支只为 `reverse_verify` / `out_of_sample_check` / `distinguish_candidates` 重建点选。定向补事是史上第一个「采集焦点 + 点选卡」。 +- 修复:识别 `targeted_collect` schema 时用 `buildTargetedCollectExistenceFrame` 加持久化文案重建 `choice_frame`;`user_prompt_hint` 改为「照发服务端卡片,不要自己写题干。」 +- 验证:`frontend/tests/rectification-targeted-card-live-20260913.test.ts` 活动焦点为定向存在题时 `next_followup.choice_frame` 非空且 `question_id` 等于焦点题号。 +- 防复发:定向存在题的题干与选项仍是服务端自有;模型不得改写。不得因此放宽区分题身份校验。 +- 相关记录:BUG-669、BUG-661、BUG-559、BUG-581 +- 复发自:BUG-661 +- 修复版本:待发布 + +## BUG-671 | 选择题无卡时不得停在采集等待态 + +- 状态:resolved +- 首次发现:2026-09-13 +- 最近更新:2026-09-13 +- 影响面:`rectification-agentic-chat` 问题缺口、`interviewChoiceCardUnavailable`、`repair-exit` +- 用户现象:卡点不动时,时间轴写「再说一件带年月的事就能继续」,没有修复入口。刷新页面后选项依旧是灰的。 +- 触发条件:`current_question.kind === "choice"` 且 GET `choice_card` 为空。 +- 根因:有持久化选择题时走 `persisted_question` / 采集等待,不进 `unavailable`。刷新只重取同一份缺卡快照,救不回来。未答的死卡还会用消息里的选项画成 disabled。 +- 修复:选择题缺卡时不记为采集等待、不记为已持久化可见题,直接进现有 `unavailable` + `repair-exit`(「接着问」);未答的死卡不再画成 disabled 点选。 +- 验证:`frontend/tests/rectification-targeted-card-live-20260913.test.ts`;表面合同锁 `deadChoice`、`unansweredDeadChoice` 与 repair-exit 路径。 +- 防复发:`kind=choice` 且无 `choice_card` 不得静默停在「再说一件带年月的事」,也不得画看不见的可点卡。BUG-669 修好后这条是兜底。 +- 相关记录:BUG-669、BUG-627、BUG-652、BUG-510 +- 复发自:BUG-669 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-targeted-card-dead-20260913.md b/docs/tasks/PROGRESS-rectification-targeted-card-dead-20260913.md new file mode 100644 index 00000000..4cca58dc --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-targeted-card-dead-20260913.md @@ -0,0 +1,36 @@ +# 进度 · 定向补事卡刷新后点不动(P0,2026-09-13) + +## 范围 + +- 工作树:`.worktrees/rectification-targeted-card-dead-20260913`(未改原仓脏工作区 `codex/minute-rectification-p0`) +- 分支:`codex/rectification-targeted-card-dead-20260913` tracking `origin/staging` @ `3c75643b`(含任务书;代码基线仍是已部署的 `ab1ade59`) +- 任务单:`docs/tasks/TASK-rectification-targeted-card-dead-20260913.md` +- BUG-669:GET 按持久化文案出定向卡 +- BUG-670:承接焦点重建 `choice_frame`,禁止模型改写题干 +- BUG-671:`choice` 无卡时走 repair-exit,不画灰卡、不进采集等待 +- 不改 `SCORE_DELTA`、确认门、区分题 `semantic_key` / `candidate_split_hash` 全等 +- Skill 版本不变(10.0.25) + +## 完成 + +- T1:`projectRectificationChoiceCard` 在 `targeted_collect` 四点选 schema 上直接投影,`:next` 同样出卡,`scoring: false`,`stop_label`「这题跳过」,不用计划层 frame,也不做 questionId 全等 +- T2:`buildMethodFollowupPlan` 承接 `collect_method_evidence` 定向存在题时用 `buildTargetedCollectExistenceFrame` 重建 frame,hint 为「照发服务端卡片,不要自己写题干。」 +- T3:`interviewChoiceCardUnavailable`;聊天把死卡当成 `unavailable` + 「接着问」;未答死卡不再以 disabled 点选画出 +- T4:`frontend/tests/rectification-targeted-card-live-20260913.test.ts`;BUG-669~671;场景 0d 加刷新仍可点 / 不得看得见点不动 + +## 验收(本机,未部署) + +- `cd frontend && ./node_modules/.bin/tsc --noEmit`:0 错 +- `cd frontend && npm run lint`:0 error / 120 warning(既有) +- 相关 TAP fail=0:`rectification-targeted-card-live-20260913` + `rectification-targeted-collect-cards-20260913` + `rectification-question-ownership` + `rectification-v9-contracts` + `rectification-surface-state` + `rectification-choice-card`:**82 / 82**(新文件 4/4) +- 全量 `cd frontend && ./node_modules/.bin/tsx --test tests/*.test.ts tests/*.test.tsx`:tests **3144**(≥ `ab1ade59` 实测 3139);pass 3139 / fail 5 / skipped 0。5 条失败均为并行 Docker 夹具:槽位超时或 `database migration failed`(`database-admin-identity`、admin customer reset、`rectification-pr4-database`、`rectification-v9-database` 两条),本单未改迁移/后台。 +- `cd frontend && ./node_modules/.bin/next build --webpack`:退出 0;`/` 仍 `○ Static`。默认 Turbopack 仍不适配 worktree 外 `node_modules` 软链。standalone 复制 `rishi-ai-mcp/.cursor|.vscode` 有 EPERM 警告,不影响路由表。 +- 首屏 `rootMainFiles` gzip-9:**130934 B** vs `ab1ade59` 的 130872 B(**+62 B / +0.047%**,±2% 内) +- 预检:`pre_work_check.py` 一次因 fragment_scan 90s / focused_tests 45s 超时失败;`remote_visibility=verified`,`external_engine_adapters_ok=true`。随后用主仓 `.venv` 跑 focused pytest(不含整机 fragment scan)20 passed。 +- 未做登录态真机场景 0d(无本地会话) + +## 未做 + +- 未 commit / 未 push +- 未部署、未真机刷新走查 +- 未执行 `TASK-rectification-tie-break-entry-fix-20260913.md`(按串行排在本单之后) diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 9d3fe979..1e4b31b0 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -182,7 +182,7 @@ | `TASK-rectification-tie-break-entry-fix-20260913.md` | — | 修复单:参考题入口只读 `window_scan` 标志位 → 两道答完后仍显示、再点弹「现在没有可答的参考题」;并按产品拍板改成点一次连出 D9+D10(BUG-666~668) | 待执行 | `codex/rectification-tie-break-entry-fix-20260913` | -| `TASK-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-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` | ## 命名与归档 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 8c8ce60b..f7bd1a84 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -62,6 +62,8 @@ - 四条线都点「没有」后出「目前范围」卡;卡下有「还能再收窄」 - 不点「再答两道参考题微调排序」时不得出现 D9/D10 性格对照卡 - 点了该入口后才出现性格对照卡;答完后头名可变,但 `can_adopt` 不变、没有淘汰 +- 答完一张定向卡后刷新页面,卡片仍可点 +- 整轮不得出现看得见点不动的卡 - 全程不出现「这次给出」「最终」 ## 1. 范围:家人说两点到四点 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index d632d28d..ba6ffd34 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -68,6 +68,7 @@ import { searchWindowFromSnapshot, caseStageFromSnapshot, interviewCollectWaiting, + interviewChoiceCardUnavailable, interviewStopReasonFromSnapshot, interviewSessionOutcomeFromSnapshot, type RectificationCaseSnapshotPayload, @@ -1482,6 +1483,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { latestLiveQuestion && latestLiveQuestion.options?.length === 4 && currentQuestion?.focus_id === latestLiveQuestion.focus_id + && choiceCard + && choiceCard.focus_id === latestLiveQuestion.focus_id && (latestLiveQuestion.kind === "choice" || latestLiveQuestion.kind === "reverse_verify") && !busy && !readonly @@ -1541,12 +1544,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? currentQuestion.prompt : null; const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); + const deadChoice = interviewChoiceCardUnavailable({ + questionKind: currentQuestion?.kind, + hasChoiceCard: Boolean(choiceCard), + }); const liveQuestionOnMessages = Boolean( latestSettledAssistant && latestSettledAssistant.question && currentQuestion && latestSettledAssistant.question.focus_id === currentQuestion.focus_id - && !questionIsAnswered(latestSettledAssistant.question), + && !questionIsAnswered(latestSettledAssistant.question) + && !deadChoice, ); // The gap between a settled turn and its next question has two visible // states: `preparing` (one live timeline row, timed refetches) and @@ -1562,11 +1570,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const questionGap = rectificationQuestionGapState({ liveQuestionVisible: liveQuestionOnMessages, questionMissing: currentQuestion === null, - questionLoadFailed: questionSource === "unavailable", - questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus"), + questionLoadFailed: questionSource === "unavailable" || deadChoice, + questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime, nextUserActionId, - collectWaiting, + collectWaiting: collectWaiting && !deadChoice, busy, readonly, regenerating: regeneratingMessageKey !== null, @@ -1765,7 +1773,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ) ), ); - const embeddedCard = question ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) : null; + const unansweredDeadChoice = Boolean( + question + && !questionIsAnswered(question) + && !liveQuestion + && (question.kind === "choice" || question.kind === "reverse_verify") + ); + const embeddedCard = question && !unansweredDeadChoice + ? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null) + : null; const afterAnswer = question && displayedMessage.state === "settled" ? (
diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index b7b9ebce..3b5a42b8 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -32,6 +32,7 @@ export const TARGETED_COLLECT_OPTION_B = "没有发生过"; export const TARGETED_COLLECT_OPTION_C = "记不太清楚"; export const TARGETED_COLLECT_OPTION_D = "这条先跳过"; export const TARGETED_COLLECT_WHY_USER = "答有的话再说大概年月,没有或记不清就问下一条。"; +export const TARGETED_COLLECT_KEEP_HINT = "照发服务端卡片,不要自己写题干。"; export const FORBIDDEN_CHOICE_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/; export const FOCUS_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -533,6 +534,12 @@ export function serverOwnedChoiceCopy(frame: RectificationChoiceFrame): AgentCho }; } +export function isTargetedCollectChoiceSchema(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return (value as { targeted_collect?: unknown }).targeted_collect === true + && parseAgentChoiceCopy(value) !== null; +} + export function parseAgentChoiceCopy(value: unknown): AgentChoiceCopy | null { if (!value || typeof value !== "object") return null; const row = value as Record; @@ -660,6 +667,39 @@ export function choiceCardFromPersistedVerifyCopy(input: { }; } +export function choiceCardFromPersistedTargetedCopy(input: { + copy: AgentChoiceCopy; + questionId: string; + methodId: string; + 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: false, + probe_id: input.probeId, + case_revision: input.caseRevision, + focus_id: input.focusId, + why_user: TARGETED_COLLECT_WHY_USER, + answer_impact: emptyAnswerImpact(), + 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 e5e42336..9f24f5a3 100644 --- a/frontend/src/lib/rectification-agentic/v9/interview-state.ts +++ b/frontend/src/lib/rectification-agentic/v9/interview-state.ts @@ -3,7 +3,8 @@ * * 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). + * out-of-sample / targeted-collect A–D schema (even if the recast has no + * choice_frame, and even if persist added a `:next` suffix). * 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 377103c9..530f7c07 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -73,11 +73,14 @@ import { buildChoiceFrame, isPersistedFocusId, parseAgentChoiceCopy, + isTargetedCollectChoiceSchema, preferConcreteChoicePrompt, mergeChoiceCard, choiceCardFromPersistedVerifyCopy, + choiceCardFromPersistedTargetedCopy, serverOwnedChoiceCopy, buildTargetedCollectExistenceFrame, + TARGETED_COLLECT_KEEP_HINT, type RectificationChoiceCard, type RectificationChoiceFrame, } from "./choice-card.ts"; @@ -634,6 +637,24 @@ const PROBE_METHOD_ID = { health_pressure: "d30_health", } as const; +function targetedCollectDomainFromFocus(focus: { + questionId?: string | null; + targetDomain?: string | null; +}): string | null { + if (typeof focus.targetDomain === "string" && focus.targetDomain.trim()) { + return focus.targetDomain.trim(); + } + const match = /^collect:targeted:([^:]+)/.exec(focus.questionId?.trim() ?? ""); + return match?.[1] ?? null; +} + +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"; +} + function contrastFollowupDomain( domain: string | null, ): keyof typeof REVERSE_VERIFY_THEME { @@ -2214,6 +2235,49 @@ export function buildMethodFollowupPlan(input: { const acceptedKeepHint = input.accepted ? "当前排盘已采用该时间。现在按该分钟核对。" : ""; + const targetedCopy = isTargetedCollectChoiceSchema(focus.expectedAnswerSchema) + ? parseAgentChoiceCopy(focus.expectedAnswerSchema) + : null; + if (focus.intent === "collect_method_evidence" && targetedCopy) { + const questionId = focus.questionId?.trim() ?? ""; + const domain = targetedCollectDomainFromFocus(focus); + const methodId = targetedCollectMethodId(domain); + const frame = questionId + ? buildTargetedCollectExistenceFrame({ + questionId, + methodId, + prompt: targetedCopy.prompt, + }) + : null; + if (frame) { + return { + methods, + next_followup: { + method_id: methodId, + intent: "collect_method_evidence", + ask_theme: domain && domain in REVERSE_VERIFY_THEME + ? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME] + : "dated_event", + domain, + kind_hint: collectKindFromFocus(focus), + user_prompt_hint: TARGETED_COLLECT_KEEP_HINT, + must_not_label: false, + choice_frame: frame, + choice_kind: "existence", + source: "active_focus", + collection_key: questionId, + spoken_prompt: targetedCopy.prompt, + invite_more_once: false, + }, + deferred_followup: null, + session_outcome: sessionOutcome ?? "collect_evidence", + stop_domain_rotation: true, + do_not_poll: DO_NOT_POLL, + not_in_rotation: NOT_IN_ROTATION, + dropped_probes: rankedCatalog.dropped, + }; + } + } const keepNext = keepAcceptedFocus ? makeFollowup((() => { const parsedId = parsePersistedFollowupQuestionId(focus.questionId); @@ -2947,15 +3011,15 @@ export function projectRectificationChoiceCard( const schemaCopy = parseAgentChoiceCopy(schema); const intent = input.activeFocus?.intent ?? ""; const verifyOnly = intent === "reverse_verify" || intent === "out_of_sample_check"; + const schemaProbeId = schema && typeof schema === "object" && typeof (schema as { probe_id?: unknown }).probe_id === "string" + ? (schema as { probe_id: string }).probe_id + : null; // 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, @@ -2965,11 +3029,31 @@ export function projectRectificationChoiceCard( methodId: parsed?.method_id ?? (intent === "out_of_sample_check" ? "oos_blind" : "reverse_verify"), scoring: parsed?.scoring !== false, - probeId, + probeId: schemaProbeId, caseRevision: input.caseRevision ?? null, focusId, }); }; + // Targeted collect is the first collect + A–D combination. GET must project + // 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({ + questionId, + targetDomain: input.activeFocus?.targetDomain, + })), + probeId: schemaProbeId, + caseRevision: input.caseRevision ?? null, + focusId, + }); + }; + const targetedCard = persistedTargetedCard(); + if (targetedCard) return targetedCard; const followup = plan.next_followup ?? plan.deferred_followup ?? null; if (!followup) return persistedVerifyCard(); const frame = followup.choice_frame; diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index d1dd2f2b..14e034b7 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -347,6 +347,15 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn return null; } +export function interviewChoiceCardUnavailable(input: Readonly<{ + questionKind?: string | null; + hasChoiceCard: boolean; +}>): 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; +} + export function interviewCollectWaiting(input: Readonly<{ stopReason?: string | null; sessionOutcome?: string | null; diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index f5db0bfd..494ad7ac 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -15,6 +15,7 @@ import { rectificationQuestionGapState, rectificationQuestionRetryActive, interviewCollectWaiting, + interviewChoiceCardUnavailable, interviewStopReasonFromSnapshot, RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, rectificationReadonlyRangeCopy, @@ -127,6 +128,12 @@ test("question gap: a persisted focus question is shown instead of preparing", ( assert.equal(rectificationQuestionRetryActive("persisted_question"), false); }); +test("question gap: a choice question without a card is unavailable, not collect waiting", () => { + 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); +}); + test("readonly range copy never says 收窄 or 才会变", () => { assert.equal( rectificationReadonlyRangeCopy({ diff --git a/frontend/tests/rectification-targeted-card-live-20260913.test.ts b/frontend/tests/rectification-targeted-card-live-20260913.test.ts new file mode 100644 index 00000000..34d6c020 --- /dev/null +++ b/frontend/tests/rectification-targeted-card-live-20260913.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + CHOICE_SKIP_QUESTION_LABEL, + TARGETED_COLLECT_KEEP_HINT, + TARGETED_COLLECT_OPTION_A, + TARGETED_COLLECT_OPTION_B, + TARGETED_COLLECT_OPTION_C, + TARGETED_COLLECT_OPTION_D, + parseRectificationChoiceCard, +} from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + buildMethodFollowupPlan, + projectRectificationChoiceCard, +} from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; +import { + interviewChoiceCardUnavailable, + interviewCollectWaiting, + rectificationQuestionGapState, +} from "../src/lib/rectification-surface-state.ts"; + +const FOCUS_ID = "abababab-abab-4bab-8bab-abababababab"; +const TARGETED_PROMPT = "收入明显变过或有过大笔进出吗?"; +const TARGETED_COPY = { + prompt: TARGETED_PROMPT, + option_a: TARGETED_COLLECT_OPTION_A, + option_b: TARGETED_COLLECT_OPTION_B, + option_c: TARGETED_COLLECT_OPTION_C, + option_d: TARGETED_COLLECT_OPTION_D, + options: [ + { key: "A" as const, label: TARGETED_COLLECT_OPTION_A, answer_class: "yes" as const }, + { key: "B" as const, label: TARGETED_COLLECT_OPTION_B, answer_class: "no" as const }, + { key: "C" as const, label: TARGETED_COLLECT_OPTION_C, answer_class: "unsure" as const }, + { key: "D" as const, label: TARGETED_COLLECT_OPTION_D, answer_class: "weak_yes" as const }, + ], +}; + +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 targetedSchema() { + return { + targeted_collect: true, + collect_kind: "targeted:finance", + scoring: false, + choice: TARGETED_COPY, + prompt: TARGETED_PROMPT, + }; +} + +function targetedFocus(questionId: string) { + return { + id: FOCUS_ID, + questionId, + intent: "collect_method_evidence", + targetDomain: "finance", + targetKind: "targeted:finance", + expectedAnswerSchema: targetedSchema(), + }; +} + +function project(questionId: string) { + return projectRectificationChoiceCard({ + evidence: DATED_EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + remainingLayers: ["d9", "d10", "d2", "d11"], + remainingSplitTimes: ["04:48", "05:07"], + remainingCandidateCount: 5, + activeFocus: targetedFocus(questionId), + }); +} + +test("GET projects a live targeted existence card from the persisted copy", () => { + const card = project("collect:targeted:finance"); + const question = projectCurrentQuestion(targetedFocus("collect:targeted:finance")); + assert.ok(card, "targeted existence must stay tappable after a snapshot reread"); + assert.equal(card.focus_id, FOCUS_ID); + assert.equal(card.question_id, "collect:targeted:finance"); + assert.equal(card.prompt, TARGETED_PROMPT); + assert.equal(card.options.length, 4); + assert.equal(card.options[0]?.label, TARGETED_COLLECT_OPTION_A); + assert.equal(card.scoring, false); + assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL); + assert.equal(card.skip_this_probe, undefined); + assert.equal(question?.kind, "choice"); + assert.equal(card.focus_id, question?.focus_id); + assert.equal(parseRectificationChoiceCard(card)?.focus_id, FOCUS_ID); +}); + +test("GET still projects when persist added a :next suffix", () => { + const card = project("collect:targeted:finance:next"); + assert.ok(card, ":next must not be the identity of a targeted card"); + assert.equal(card.question_id, "collect:targeted:finance:next"); + assert.equal(card.focus_id, FOCUS_ID); + assert.equal(card.scoring, false); + assert.equal(card.options.length, 4); +}); + +test("recast keep rebuilds the targeted choice_frame and does not ask the model to rewrite the stem", () => { + const plan = buildMethodFollowupPlan({ + evidence: DATED_EVIDENCE, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + remainingLayers: ["d9", "d10", "d2", "d11"], + remainingSplitTimes: ["04:48", "05:07"], + remainingCandidateCount: 5, + activeFocus: targetedFocus("collect:targeted:relocation:next"), + }); + const followup = plan.next_followup; + assert.ok(followup?.choice_frame, "keep must rebuild the A–D frame"); + assert.equal(followup.choice_frame.question_id, "collect:targeted:relocation:next"); + assert.equal(followup.choice_frame.scoring, false); + assert.equal(followup.user_prompt_hint, TARGETED_COLLECT_KEEP_HINT); + assert.doesNotMatch(followup.user_prompt_hint, /spokenPrompt 写出题干/); +}); + +test("a choice question without a GET card is the repair path, not collect waiting", () => { + 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); + assert.equal( + interviewCollectWaiting({ + sessionOutcome: "collect_evidence", + questionMissing: false, + }), + 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", + ); + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(chat, /interviewChoiceCardUnavailable/); + assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/); + assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/); + assert.match(chat, /unansweredDeadChoice/); +});