diff --git a/CHANGELOG.md b/CHANGELOG.md index 467a643c..a06f2229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 分数接近时先问两道参考题,再给出范围卡 + +生时校正里,前两名只差一分或打平时,会先连着问两道参考题(相处方式、做事风格),再给范围卡。点「再答两道参考题」后,题会出现在对话里。范围卡不再列出你已经说过没有或跳过的线。Skill 版本不变。 + + ## 2026-09-14 — 并列不等于可以结束:还有题就继续问,区间没收窄不说可采用 生时校正里,几个候选分数打平不再一上来就宣布「可采用」。还有带年月的区分题或定向补事时继续问。都问完了才给区间;如果区间还是一开始那一段,只给范围、不推荐代表分钟。交付之后如果还留着一张画不出的题,不再印「没有拿到下一个问题」。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 13861e12..0b748ca1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10661,3 +10661,51 @@ - 复发自:BUG-682(`delivered` 终态要求无题,死卡把会话钉在问题态) - 修复版本:待发布 - 证据缺口:同一会话 GET 的 `current_question`、`choice_card`、`question_source`,以及最后一条助手 turn 的 `offer_result_id`。缺这四项之前不得臆断写入路径。 + +## BUG-685 | 点「再答两道参考题」服务端成功,对话区没有任何变化 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`requestTieBreak`、`loadCaseSnapshot`、`applySnapshotTurnsToMessages`、`rectificationQuestionGapState` +- 用户现象:点「再答两道参考题微调排序」后 POST 返回 `ok: true`、`choiceReady: true`,界面仍是原来的范围卡,新参考题看不见。 +- 触发条件:范围卡在场时点参考题入口;服务端已写入助手 turn 和活动焦点。 +- 根因:`requestTieBreak` 调用不带合并回调的 `loadCaseSnapshot()`,新 turn 不进 `messages`。交付卡在场使缺口为 `idle`,`persisted_question` 也不会兜底画这道题。 +- 修复:`requestTieBreak` 用与 `submitChoice` 相同的 `mergeTurnQuestions` / `appendUnseenAssistantTurns`。未答焦点优先于「等读者点卡」的 idle。 +- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`。 +- 防复发:服务端返回 ok 而界面无变化,一律视为缺陷。任何新写 turn 的前端动作必须把 turn 并进对话区。 +- 相关记录:BUG-663、BUG-666、BUG-667 +- 复发自:BUG-663(入口只写焦点,前端刷新不合并 turn) +- 修复版本:待发布 + +## BUG-686 | 平局直接出卡,参考题留成卡下可选按钮 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`decideRectification` `heldForTieBreak`、`persistNextInterviewAfterChoice` / `persistNextInterviewIfIdle`、范围卡按钮 +- 用户现象:带年月题和定向补事问完后,前两名 16/16 并列,直接出范围卡让人在 26%/26%/20% 里选;参考题要自己点按钮。 +- 触发条件:前两名 posterior 分差 ≤ 1,D9/D10 参考题仍可用且未用过。 +- 根因:性格题被设计成出卡后的可选入口,而不是出卡前的必经步骤。 +- 修复:产品拍板方案 A(2026-09-14)。分差 ≤ 1 且参考题可用时先连问最多两道 `varga_style`(仍只 ±1、不淘汰),两道用完再出卡。仍平时卡上写「这两分钟按现有信息分不开,参考题已经用过。」用过之后不再显示入口按钮。 +- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`:16/16 不交付且下一问是 `varga_style`;两道用完才交付且按钮关闭;lead=2 不插参考题。 +- 防复发:不得把性格题改成淘汰候选。自动进入参考题时不得再写第二条交付消息。 +- 相关记录:BUG-663、BUG-667、BUG-629、BUG-597 +- 复发自:BUG-663(入口做成出卡后可选) +- 修复版本:待发布 + +## BUG-687 | 交付文案把已经拒答的线又列一遍 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`rangeNarrowHint` +- 用户现象:结婚、收入、家人都答过「没有/跳过」,范围卡仍写「能把它们分开的是这几条线:哪年结婚或订婚、哪年收入明显变过、家里哪年添丁」。 +- 触发条件:定向补事已拒答或已关闭,随后交付区间。 +- 根因:`rangeNarrowHint` 调 `targetedCollectPool` 时传入空的 `declinedTopics`,有意忽略拒答。 +- 修复:传入真实 `declinedTopics`。一条都不剩时写「能问的都问完了」,不再暗示还有没问的线。 +- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`。 +- 防复发:交付文案不得列出已拒答或已回答的线。 +- 相关记录:BUG-661、BUG-662 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-tiebreak-before-card-20260914.md b/docs/tasks/PROGRESS-rectification-tiebreak-before-card-20260914.md new file mode 100644 index 00000000..fd8e97a0 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-tiebreak-before-card-20260914.md @@ -0,0 +1,36 @@ +# 进度 · 平局先问参考题再出卡(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-tiebreak-before-card-20260914`(基于 `origin/staging` @ `a6381e02`) +- 任务单:`docs/tasks/TASK-rectification-tiebreak-before-card-20260914.md` +- BUG-685 / 686 / 687 resolved +- 任务 1–4 均做完,没有走让步 + +## 完成 + +- T1:`requestTieBreak` 成功路径用 `applySnapshotTurnsToMessages` 把新 turn 并进对话。缺口里未答焦点优先于「等读者点卡」。`refetchQuestion` 与 repair 失败回拉同样合并(服务端可能已写 turn)。 +- T2:`pendingTieBreak && lead ≤ 1` 时 `heldForTieBreak`,先持久化 D9/D10 `varga_style`(沿用 `tie_break_round`),用完再交付。仍平时卡上写「参考题已经用过」,按钮随 `tie_break_available` 收起。 +- T3:`rangeNarrowHint` 传入真实拒答;空池写「能问的都问完了」。 +- T4:`tieBreakGateInput` 供 GET 与 POST 共用。GET 本轮仍不另载 `birthDate`(与原先一致);POST 继续从 compute 读取。 + +## `loadCaseSnapshot` 逐处 + +| 位置 | 回调 | 依据 | +| --- | --- | --- | +| `send` 成功/失败 | 有(只 merge 已有消息上的题) | 模型 turn 已在 `messages` | +| `submitChoice` | 有 merge + append | 结构化选择后可能多出助手 turn | +| 采用成功后 | 无 | 随后 `send` 写采用旁白;快照只刷新已采用态 | +| 参考题 409 | 无 | 没有新题,只重拉以藏按钮 | +| 参考题成功 | 有 | BUG-685 | +| `refetchQuestion` | 有 | 准备缺口期间服务端可能已写下下一问 | +| repair-exit 失败回拉 | 有 | 修复失败后快照里可能已有新 turn | + +## 验收数字 + +- `tsc --noEmit`:0 错 +- `npm run lint`:0 error / 120 warning(既有,未顺手改) +- 定向:`rectification-tiebreak-before-card-20260914.test.ts` 8/8;tie-break-entry、collection-question-pool、delivery-vs-collect、tied-first、surface-state、probe-pool-exhausted T4、targeted-collect 相关全过 +- 全量 `npm test`:3011 / pass 2843 / fail 154 / skip 14(含 T4 旧断言,已改为「能问的都问完了」后定向 20/20)。失败条数与 Windows 基线 153 同型(CRLF、Skill SHA、Docker/DB、无 symlink)。本轮新测 8 条全过。 +- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction。Linux 门禁会在树内安装后构建。 +- 浏览器级验收:无 staging 登录态,清单见 `docs/testing/rectification-tiebreak-before-card-20260914.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e7eec8da..e900747a 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-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待执行 | `codex/rectification-tiebreak-before-card-20260914` | +| `TASK-rectification-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待验收 | `codex/rectification-tiebreak-before-card-20260914` | | `TASK-rectification-tied-first-premature-delivery-fix-20260914.md` | `PROGRESS-rectification-tied-first-fix-20260914.md` | **P0 回归修复单**:BUG-680 的 `tied_first` 早退被实现到 `coverageBlocks` 之前且无 `probe` 守卫 → 新案子答两题、区间还是整个开局窗口就宣布 `can_adopt`/代表分钟(BUG-683);交付态下仍出现死卡致 `delivered` 终态不触发、印「没有拿到下一个问题」(BUG-684 investigating) | 待验收 | `codex/rectification-tied-first-fix-20260914` | diff --git a/docs/testing/rectification-tiebreak-before-card-20260914.md b/docs/testing/rectification-tiebreak-before-card-20260914.md new file mode 100644 index 00000000..1cd89d12 --- /dev/null +++ b/docs/testing/rectification-tiebreak-before-card-20260914.md @@ -0,0 +1,19 @@ +# 平局先问参考题:真机清单(2026-09-14) + +分支 `codex/rectification-tiebreak-before-card-20260914`。本环境无 staging 登录态。 + +## BUG-685 + +- [ ] 范围卡上点「再答两道参考题微调排序」后,对话区立刻出现助手消息和可点的参考题,不是什么都没变。 + +## BUG-686 + +- [ ] 前两名分数打平或只差一分、参考题还没用过时:先连着出两道参考题(相处方式、做事风格),再出范围卡。 +- [ ] 两道答完后才出范围卡;卡上不再有「再答两道参考题微调排序」。 +- [ ] 若仍分不开,卡上有一句「这两分钟按现有信息分不开,参考题已经用过。」 +- [ ] 同一轮不会出现两条交付结论。 + +## BUG-687 + +- [ ] 范围卡文案不再出现已经答过「没有/跳过」的线(结婚、收入、添丁等)。 +- [ ] 能问的线都关闭时,写「能问的都问完了」,不暗示还有没问的线。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3872c31f..5e84926d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -234,7 +234,7 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | -| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a “还能再收窄” caption under the title, then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold | enabled | +| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines (or “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. A close lead asks the two personality cards before this card appears. | enabled | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible | | `confirmed` | “已确认校正时间:HH:MM” | enabled | | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 5b2d28d4..76cc688f 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -120,13 +120,17 @@ import { interviewQuestionBlocksAdoptOffer, nextSelectionCardLock, parseTurnQuestion, - persistedOfferFromTurn, questionIsAnswered, resolveSelectionCardMessageKey, type SelectionCardLock, type TurnQuestion, } from "@/lib/rectification-agentic/v9/turn-question"; import { Button } from "@/components/ui/button"; +import { + appendUnseenAssistantTurns, + applySnapshotTurnsToMessages, + mergeTurnQuestions, +} from "@/lib/rectification-snapshot-messages"; type PersistedTurn = Readonly<{ id: string; @@ -245,33 +249,6 @@ type RenderMessage = ChatMessageView & { candidateOffer?: Readonly<{ resultId: string }>; }; -function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { - const byId = new Map(); - for (const item of turns) { - if (!item || typeof item !== "object") continue; - const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown }; - if (typeof turn.id !== "string") continue; - byId.set(turn.id, { - question: parseTurnQuestion(turn.question), - offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null, - }); - } - return current.map((message) => { - if (!message.turnId || !byId.has(message.turnId)) return message; - const next = byId.get(message.turnId); - const question = next?.question ?? undefined; - return { - ...message, - question: question ?? undefined, - candidateOffer: persistedOfferFromTurn( - next?.offerResultId, - message.candidateOffer, - true, - ), - }; - }); -} - function markQuestionAnswered( current: RenderMessage[], focusId: string, @@ -295,16 +272,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl return Array.isArray(payload?.turns) ? payload.turns : []; } -function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] { - const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : [])); - const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => ( - message.role === "assistant" - && message.turnId - && !known.has(message.turnId) - )); - return extras.length ? [...current, ...extras] : current; -} - function choiceCardFromQuestion( question: TurnQuestion, live: ChoiceCardModel | null, @@ -1194,7 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey); const withHistory = appendUnseenAssistantTurns( mergeTurnQuestions(withoutPlaceholder, turns), - turns, + messagesFromTurns(turns as readonly PersistedTurn[]), ); if (withHistory.length > withoutPlaceholder.length) return withHistory; return [ @@ -1396,7 +1363,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (!response.ok || payload?.ok !== true) { throw new Error(payload?.error || payload?.message || "暂时无法开始参考题"); } - await loadCaseSnapshot(); + await loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法开始参考题"); } finally { @@ -1579,7 +1552,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { questionMissing: currentQuestion === null, questionLoadFailed: questionSource === "unavailable" || deadChoice, questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice, - offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime, + offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null, nextUserActionId, collectWaiting: collectWaiting && !deadChoice, sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, @@ -1622,7 +1595,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, }); async function refetchQuestion() { - await loadCaseSnapshot(); + await loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); } async function repairQuestion() { if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return; @@ -1650,7 +1629,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); if (!recovered) setQuestionRepairAttempts((current) => current + 1); } else { - await loadCaseSnapshot(); + await loadCaseSnapshot((turns) => { + setMessages((current) => applySnapshotTurnsToMessages( + current, + turns, + (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), + )); + }); setQuestionRepairAttempts((current) => current + 1); } } catch { diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index 443bef5c..3aa8b376 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -59,6 +59,9 @@ export function RectificationRangeDelivery({ {RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry} ) : null} + {delivery?.tie_break_note ? ( +

{delivery.tie_break_note}

+ ) : null} {sharedTraits.length > 0 ? (