diff --git a/CHANGELOG.md b/CHANGELOG.md index 2892a92d..467a643c 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 e15ba23f..13861e12 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10628,3 +10628,36 @@ - 相关记录:BUG-680、BUG-681 - 复发自:无 - 修复版本:待发布 + +## BUG-683 | 并列早退在还有探针可问时就把第二题判成可采用 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`decideRectification` 的 `tied_first` 早退、`completeWithRange` 未收窄窗口的采用闸门 +- 用户现象:新建校正只答两轮采集,助手已经在问第三道带年月区分题,界面却宣布可采用、代表分钟,可信区间仍是开局窗口(如 04:45–05:15)。 +- 触发条件:前两名(或前三名)分数完全并列;`stop_reason=tied_first`;仍有带年月探针或定向补事未问完。 +- 根因:复发自 BUG-680 的修复。上一单 D2 写的是把 `tied_first` 提到 `!separation.sufficient` 分支里、`stillNeedNarrowing` 之前;实现把它放到了 `coverageBlocks` 之前、整个不足分支之外,连「还有探针」和「方法覆盖未达标」都绕过。配套测试传入 `discriminatorProbe: null`,实现却没有这个守卫,测试与实现语义不一致,所以回归没被拦住。并列在采集早期只是分数还没拉开,不是「无论如何都问不下去」。 +- 修复:`tied_first` 早退必须同时 `probe === null` 且 `targetedCollectExhausted !== false`。`refreshExhausted` 不参与该判断。交付时若 `credible_range` 等于开局 `case.candidateRange`,`can_adopt` 为假,`session_outcome` 只允许 `completed_with_range` / `provisional_range`,不得宣布代表分钟可采用。 +- 验证:`frontend/tests/rectification-tied-first-fix-20260914.test.ts`;`rectification-delivery-vs-collect-20260914.test.ts` 成对用例(有探针不交付 / 无探针且定向补事问完才交付)。 +- 防复发:`tied_first` 交付测试必须成对——有带年月探针时不交付,无探针且定向补事问完时才交付。不得把开局窗口原样宣布为可采用。不得为了让并列局面收尾而放宽 `can_adopt`。 +- 相关记录:BUG-680、BUG-681、BUG-682、BUG-674 +- 复发自:BUG-680(修复实现比任务书更宽,测试用空探针锁的是「没有探针才交付」,实现无此守卫) +- 修复版本:待发布 + +## BUG-684 | 交付态下死卡仍把缺口打成「没有拿到下一个问题」 + +- 状态:investigating +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`interviewDeliveredGap`、`rectificationQuestionGapState`、`rectification-agentic-chat.tsx` +- 用户现象:决策已是 `complete_with_range` / `tied_first`,屏幕上仍有一道没有可点选项的题,并印「没有拿到下一个问题。」+「接着问」。 +- 触发条件:交付 outcome 或 `stop_reason=tied_first`,同时 `current_question` 非空且 `interviewChoiceCardUnavailable` 为真。 +- 根因:待取证。已确认 `interviewDeliveredGap` 原先要求 `questionMissing === true`,有题时 `delivered` 不触发,死卡走 `questionLoadFailed` → `unavailable`。写入路径尚未钉死。 +- 修复:本轮只做兜底与取证。`questionMissing || deadChoice` 时允许进入 `delivered`;死卡且处于交付态打 `rectification_delivered_state_dead_choice`(`session_outcome`、`stop_reason`、`question_id`、`has_choice_card`)。非交付态死卡仍 `unavailable`,不误伤 BUG-671。 +- 验证:`frontend/tests/rectification-tied-first-fix-20260914.test.ts` 锁定交付+死卡 → `delivered`,采集+死卡 → `unavailable`。 +- 防复发:交付态不得再出现「没有拿到下一个问题」。根因确认前不得把本条标为 resolved。 +- 相关记录:BUG-682、BUG-674、BUG-671、BUG-683 +- 复发自:BUG-682(`delivered` 终态要求无题,死卡把会话钉在问题态) +- 修复版本:待发布 +- 证据缺口:同一会话 GET 的 `current_question`、`choice_card`、`question_source`,以及最后一条助手 turn 的 `offer_result_id`。缺这四项之前不得臆断写入路径。 diff --git a/docs/tasks/PROGRESS-rectification-tied-first-fix-20260914.md b/docs/tasks/PROGRESS-rectification-tied-first-fix-20260914.md new file mode 100644 index 00000000..da63c314 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-tied-first-fix-20260914.md @@ -0,0 +1,30 @@ +# 进度 · 并列早退把第二题就判成可采用(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-tied-first-fix-20260914`(基于 `origin/staging` @ `e42a62df`) +- 任务单:`docs/tasks/TASK-rectification-tied-first-premature-delivery-fix-20260914.md` +- BUG-683 resolved;BUG-684 investigating(兜底 + 日志,缺四项 GET 证据) +- 任务 1/2/3 均做完,没有走让步 + +## 完成 + +- T1:`tied_first` 早退加 `!probe && targetedCollectExhausted !== false`。有带年月探针时走判别;定向补事未问完时继续采集。`refreshExhausted` 不参与。 +- T2:`case.candidateRange` 传入决策。交付时区间等于开局窗口则 `can_adopt=false`,`session_outcome` 只给 `completed_with_range` / `provisional_range`。收窄过的维持原行为。 +- T3:`interviewDeliveredGap` 把 `questionMissing || deadChoice` 视为可进入 `delivered`;交付态死卡打 `rectification_delivered_state_dead_choice`。非交付死卡仍 `unavailable`。 +- T4:BUG_HISTORY、CHANGELOG、DESIGN、真机清单。 + +## 断言三栏(既有用例) + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-delivery-vs-collect-20260914.test.ts` 并列交付 | `targetedCollectExhausted: false` 仍 `complete_with_range` | `targetedCollectExhausted: true` 才交付 | BUG-683:并列不是「还有题也结束」 | + +## 验收数字 + +- `./node_modules/.bin/tsc --noEmit`:0 错 +- `npm run lint`:0 error / 120 warning(既有,未顺手改) +- 定向:`rectification-tied-first-fix-20260914.test.ts` 7/7;`rectification-delivery-vs-collect-20260914.test.ts` 6/6;convergence-budget 14/14;surface-state、decide-next-action、decision-authority、probe-pool-exhausted、candidate-result、targeted-card-live 合计 79/79 +- 全量 `npm test`:3003 / pass 2836 / fail 153 / skip 14。失败条数与上一轮 Windows 基线 153 一致;本轮多 7 条新测全部通过。153 条是 CRLF、Skill SHA、Docker/DB、无 symlink 权限,不是本单回归。Linux 门禁基线仍是任务书写的 27 条缺口。 +- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction。未在本树 `npm ci`。Linux 门禁会在树内安装后构建。未测 `/` Static 与首屏 gzip。 +- 浏览器级验收:本环境无 staging 登录态,条目写在 `docs/testing/rectification-delivery-vs-collect-20260914.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 9055f0ad..4037e7c6 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-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` | +| `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` | | `TASK-rectification-delivery-vs-collect-split-20260914.md` | `PROGRESS-rectification-delivery-vs-collect-20260914.md` | **P0**:并列到顶(tied_first)时 POST 播了交付结论、GET 又算回 collect_evidence——刷新尝试落库失败仍回报「已尝试」+ `stillNeedNarrowing` 压过 exhausted(BUG-680);交付话术闸门与交付卡闸门不一致,`completed_with_range`/`provisional_range` 能说不能画(BUG-681);缺口状态机没有终态,兜底印「没有拿到下一个问题」(BUG-682) | 待验收 | `codex/rectification-delivery-vs-collect-20260914` | diff --git a/docs/testing/rectification-delivery-vs-collect-20260914.md b/docs/testing/rectification-delivery-vs-collect-20260914.md index 397d2651..db2ea7f5 100644 --- a/docs/testing/rectification-delivery-vs-collect-20260914.md +++ b/docs/testing/rectification-delivery-vs-collect-20260914.md @@ -14,3 +14,10 @@ - [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。 - [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。 + +## BUG-683 / 684(并列早退回归) + +新建一次校正,只答两轮采集(例如上学 / 毕业,实习 / 离职)。 + +- [ ] 答完两题后不得出现「可采用」或推荐的代表分钟;范围应仍在收窄中,并继续出带年月区分题或定向补事。 +- [ ] 若助手已经给出区间、界面却还挂着一张画不出选项的题,不得印「没有拿到下一个问题。」+「接着问」。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ef362936..3872c31f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -231,7 +231,7 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, retries left | one timeline live row “正在准备下一个问题…”, refetching on a 2s timer up to two retries | enabled | | `question-gap`, retries spent | “没有拿到下一个问题。” and a 44px “接着问” | enabled | | `question-gap`, collect waiting | no “没有拿到下一个问题”; the last assistant line already has the precise gap | enabled, placeholder “再说一件带年月的事” | -| `question-gap`, delivered | no current question 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 | +| `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 | diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index ba980cfc..5b2d28d4 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1584,6 +1584,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { collectWaiting: collectWaiting && !deadChoice, sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, stopReason: interviewStopReason, + deadChoice, + questionId: currentQuestion?.question_id ?? null, + hasChoiceCard: Boolean(choiceCard), busy, readonly, regenerating: regeneratingMessageKey !== null, diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index b69a8d27..eaadf21a 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -226,6 +226,8 @@ export type DecideRectificationInput = Readonly<{ windowWidenSuggested?: boolean; refreshExhausted?: boolean; targetedCollectExhausted?: boolean; + /** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */ + openingCandidateRange?: readonly [string, string] | null; }>; function classifyStop( @@ -316,18 +318,23 @@ export function decideRectification(input: DecideRectificationInput): Rectificat confirmationAllowed, trainingGateOpen: input.trainingGateOpen !== false, }); + const rangeDeliveryCapability = withholdAdoptOnUnnarrowedRange( + capability, + range, + input.openingCandidateRange, + ); if (input.windowWidenSuggested === true && input.trainingGateOpen !== false && !userStopped) { return askWindowWiden(separation, range); } if (userStopped && separation.ranked.length > 0) { - return completeWithRange(separation, holdout, range, "user_stopped", capability); + return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability); } if (input.snapshotCurrent === false) { if (probe && !userStopped && input.trainingGateOpen !== false) { - return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); + return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability, stopReason); } return collect(separation, holdout, range, probe, capability, stopReason); } @@ -336,8 +343,17 @@ export function decideRectification(input: DecideRectificationInput): Rectificat && stopClass.reason === "tied_first" && input.trainingGateOpen !== false && separation.ranked.length > 0 + && !probe + && input.targetedCollectExhausted !== false ) { - return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + return completeWithRange( + separation, + holdout, + range, + "exhausted", + rangeDeliveryCapability, + stopClass.reason, + ); } if (coverageBlocks) { const engineOffers = input.engineCeiling.acceptanceAllowed @@ -351,7 +367,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat && engineOffers && !narrowingOpen ) { - return offerRangeWithoutAdopt(separation, holdout, range, capability); + return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability); } return collect( separation, @@ -366,11 +382,11 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return collect(separation, holdout, range, probe, capability, stopClass.reason); } if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") { - return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } if (!separation.sufficient) { if (probe) { - return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); + return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability, stopReason); } if (holdout === "not_started" && !capability.canAdopt) { return holdoutValidation(separation, range, capability); @@ -384,23 +400,23 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason); } if (stopClass?.kind === "exhausted") { - return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } - if (capability.canAdopt && input.methodCoverageAll) { + if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) { return finish("adopt_representative", { input, separation, holdout, range, probe: null, - capability, + capability: rangeDeliveryCapability, stopReason: "probe_pool_exhausted", }); } - return completeWithRange(separation, holdout, range, "offer", capability); + return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability); } if (stopClass?.kind === "exhausted") { - return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } if (input.accepted) { return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", { @@ -409,7 +425,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - capability, + capability: rangeDeliveryCapability, }); } if (input.datedMethodCollectOpen === true && !input.userStopped) { @@ -422,7 +438,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - capability, + capability: rangeDeliveryCapability, }); } if (holdout === "not_started" && !capability.canAdopt) { @@ -430,12 +446,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } if (holdout === "failed") { if (probe) { - return discriminateOrExhaust(input, separation, holdout, range, probe, capability); + return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability); } - return completeWithRange(separation, holdout, range, "exhausted", capability); + return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability); } if (holdout === "unavailable") { - return offerRangeWithoutAdopt(separation, holdout, range, capability); + return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability); } return finish("adopt_representative", { input, @@ -443,7 +459,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - capability, + capability: rangeDeliveryCapability, }); } @@ -549,6 +565,34 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean { return input.refreshExhausted === false || input.targetedCollectExhausted === false; } +function clockMinute(value: string): string { + const match = /^(\d{1,2}):(\d{2})/.exec(value.trim()); + if (!match) return value.trim().slice(0, 5); + return `${match[1].padStart(2, "0")}:${match[2]}`; +} + +export function rangeMatchesOpeningWindow( + range: readonly [string, string] | null | undefined, + opening: readonly [string, string] | null | undefined, +): boolean { + if (!range || !opening) return false; + return clockMinute(range[0]) === clockMinute(opening[0]) + && clockMinute(range[1]) === clockMinute(opening[1]); +} + +function withholdAdoptOnUnnarrowedRange( + capability: DeliveryCapability, + range: readonly [string, string] | null, + opening: readonly [string, string] | null | undefined, +): DeliveryCapability { + if (!rangeMatchesOpeningWindow(range, opening)) return capability; + return { + ...capability, + canAdopt: false, + canConfirmExactMinute: false, + }; +} + function waitToNarrowCapability(capability: DeliveryCapability): DeliveryCapability { return { ...capability, @@ -742,10 +786,13 @@ function finish( : input.holdout === "passed" ? "validated_range" : fallbackOutcome; + const sessionOutcome = !input.capability.canAdopt && kind === "adopt_representative" + ? "completed_with_range" + : kind; return { phase: "completed", - nextAction: "ready_to_adopt", - sessionOutcome: kind, + nextAction: sessionOutcome === "completed_with_range" ? "complete_with_range" : "ready_to_adopt", + sessionOutcome, resultStatus: kind === "awaiting_confirmation" || kind === "exact_minute_confirmed" ? "converged" : "completed_with_range", diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 4c4f4cd0..23db17b6 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -60,6 +60,7 @@ import { } from "./collection-question-pool.ts"; import { evidenceLedgerFingerprint } from "./tool-service"; import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts"; +import { openingRangeFromCandidateRange } from "../user-copy.ts"; import { representativeNearWindowEdge, } from "./search-window.ts"; @@ -876,6 +877,7 @@ export function decideFromDossier( options?.currentEvidenceFingerprint ?? evidenceLedgerFingerprint(dossier.evidence as never), ), windowWidenSuggested, + openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange), ...narrowingExhaustion(dossier, inference, options, catalog), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), @@ -944,6 +946,7 @@ export function decideAfterInferenceChange(input: { input.dossier, evidenceLedgerFingerprint(input.dossier.evidence as never), ), + openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange), }); } const training = input.state.events.filter((item) => item.usage === "training"); @@ -1020,6 +1023,7 @@ export function decideAfterInferenceChange(input: { input.dossier, evidenceLedgerFingerprint(input.dossier.evidence as never), ), + openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange), ...narrowingExhaustion(input.dossier, input.state, undefined, catalog), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 385f3610..40ac4a4d 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -294,6 +294,10 @@ export type RectificationQuestionGapInput = Readonly<{ collectWaiting?: boolean; sessionOutcome?: string | null; stopReason?: string | null; + /** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */ + deadChoice?: boolean; + questionId?: string | null; + hasChoiceCard?: boolean; busy: boolean; readonly: boolean; regenerating: boolean; @@ -384,14 +388,35 @@ export function interviewChoiceCardUnavailable(input: Readonly<{ return false; } +let lastDeliveredDeadChoiceLog = ""; + export function interviewDeliveredGap(input: Readonly<{ questionMissing: boolean; sessionOutcome?: string | null; stopReason?: string | null; + deadChoice?: boolean; + questionId?: string | null; + hasChoiceCard?: boolean; }>): boolean { - if (!input.questionMissing) return false; - if (sessionOutcomeAllowsDelivery(input.sessionOutcome)) return true; - return input.stopReason === "tied_first" || input.stopReason === "user_uncertainty_too_high"; + const questionGone = input.questionMissing || input.deadChoice === true; + if (!questionGone) return false; + const delivered = sessionOutcomeAllowsDelivery(input.sessionOutcome) + || input.stopReason === "tied_first" + || input.stopReason === "user_uncertainty_too_high"; + if (delivered && input.deadChoice === true) { + const payload = JSON.stringify({ + event: "rectification_delivered_state_dead_choice", + session_outcome: input.sessionOutcome ?? null, + stop_reason: input.stopReason ?? null, + question_id: input.questionId ?? null, + has_choice_card: input.hasChoiceCard === true, + }); + if (payload !== lastDeliveredDeadChoiceLog) { + lastDeliveredDeadChoiceLog = payload; + console.warn(payload); + } + } + return delivered; } export function interviewCollectWaiting(input: Readonly<{ diff --git a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts index e7a0aa7e..66e81f0e 100644 --- a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts +++ b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts @@ -211,6 +211,9 @@ function tiedRpcDossier() { } test("tied first place delivers a range even when narrowing flags are still open", () => { + // 原值: targetedCollectExhausted: false 仍 complete_with_range + // 新值: targetedCollectExhausted: true 才交付;false 时继续定向补事 + // 原因: BUG-683 并列早退缺 probe / 定向补事守卫,新案子答两题就被判可采用 const decision = decideRectification({ engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, @@ -221,7 +224,7 @@ test("tied first place delivers a range even when narrowing flags are still open datedEventCount: 3, datedDomainCount: 3, refreshExhausted: false, - targetedCollectExhausted: false, + targetedCollectExhausted: true, }); assert.equal(decision.stopReason, "tied_first"); assert.equal(decision.canOfferRange, true); diff --git a/frontend/tests/rectification-tied-first-fix-20260914.test.ts b/frontend/tests/rectification-tied-first-fix-20260914.test.ts new file mode 100644 index 00000000..ea00b30c --- /dev/null +++ b/frontend/tests/rectification-tied-first-fix-20260914.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + decideRectification, + publicCanAdopt, + rangeMatchesOpeningWindow, + sessionOutcomeAllowsDelivery, +} from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { + RECTIFICATION_DELIVERED_COPY, + RECTIFICATION_QUESTION_RETRY_LIMIT, + RECTIFICATION_QUESTION_UNAVAILABLE_COPY, + interviewDeliveredGap, + rectificationQuestionGapState, +} from "../src/lib/rectification-surface-state.ts"; +import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; +import type { CandidateDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; + +const DATED_PROBE: CandidateDiscriminatorProbe = { + probeId: "probe-2023-career", + candidateSetVersion: "set-1", + question: "2023 年 5 月前后,你有没有入职、换工作,或者当时手上的职责明显变重?", + expectedOutcomes: [ + { + outcomeId: "yes", + supportsCandidateIds: ["05:06"], + conflictsCandidateIds: ["05:08", "05:14"], + }, + { + outcomeId: "no", + supportsCandidateIds: ["05:08", "05:14"], + conflictsCandidateIds: ["05:06"], + }, + ], + candidateSplitHash: "05:06|05:08|05:14", + informationGain: 0.4, + sourceFeatures: [{ technique: "test", calculationResultId: null }], + domain: "career", + year: 2023, + semanticKey: "career.2023.entry", +}; + +const TIED_THREE = [ + { time: "05:06", score: 13 }, + { time: "05:08", score: 13 }, + { time: "05:14", score: 13 }, + { time: "05:03", score: 12 }, + { time: "04:59", score: 11 }, + { time: "04:46", score: 10 }, + { time: "04:53", score: 10 }, + { time: "05:15", score: 10 }, + { time: "04:50", score: 8 }, +] as const; + +const OPENING = ["04:45", "05:15"] as const; + +function tiedInput(overrides: Partial[0]> = {}) { + return { + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: false, + trainingGateOpen: true, + candidateScores: TIED_THREE.map((item) => ({ time: item.time, score: item.score })), + holdoutValidation: "unavailable" as const, + datedEventCount: 3, + datedDomainCount: 2, + inferenceCredibleRange: OPENING, + openingCandidateRange: OPENING, + ...overrides, + }; +} + +test("tied first place with a dated probe keeps collecting instead of delivering", () => { + const decision = decideRectification(tiedInput({ + discriminatorProbe: DATED_PROBE, + targetedCollectExhausted: true, + refreshExhausted: false, + })); + assert.notEqual(decision.nextAction, "complete_with_range"); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); + assert.equal(publicCanAdopt(decision), false); + assert.equal(decision.canAdopt, false); + assert.equal(decision.nextAction, "ask_candidate_discriminator"); +}); + +test("tied first place with no probe still collects while targeted questions remain", () => { + const decision = decideRectification(tiedInput({ + discriminatorProbe: null, + targetedCollectExhausted: false, + refreshExhausted: true, + })); + assert.notEqual(decision.nextAction, "complete_with_range"); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); + assert.equal(publicCanAdopt(decision), false); + assert.equal(decision.nextAction, "ask_fact_collection"); +}); + +test("tied first place with no probe and targeted collect exhausted delivers a range", () => { + const decision = decideRectification(tiedInput({ + discriminatorProbe: null, + targetedCollectExhausted: true, + refreshExhausted: false, + })); + assert.equal(decision.stopReason, "tied_first"); + assert.equal(decision.nextAction, "complete_with_range"); + assert.equal(decision.canOfferRange, true); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("a live first-place tie with remaining probes does not announce adopt_representative", () => { + const decision = decideRectification(tiedInput({ + discriminatorProbe: DATED_PROBE, + datedEventCount: 3, + targetedCollectExhausted: false, + refreshExhausted: false, + })); + assert.notEqual(decision.sessionOutcome, "adopt_representative"); + assert.equal(publicCanAdopt(decision), false); + assert.equal(rangeMatchesOpeningWindow(decision.credibleRange, OPENING), true); +}); + +test("an unnarrowed delivered range withholds adopt", () => { + const unnarrowed = decideRectification(tiedInput({ + discriminatorProbe: null, + targetedCollectExhausted: true, + inferenceCredibleRange: OPENING, + openingCandidateRange: OPENING, + })); + assert.equal(sessionOutcomeAllowsDelivery(unnarrowed.sessionOutcome), true); + assert.equal(unnarrowed.sessionOutcome === "provisional_range" + || unnarrowed.sessionOutcome === "completed_with_range", true); + assert.equal(unnarrowed.canAdopt, false); + assert.equal(publicCanAdopt(unnarrowed), false); + assert.equal(unnarrowed.canOfferRange, true); + + const narrowed = decideRectification(tiedInput({ + discriminatorProbe: null, + targetedCollectExhausted: true, + inferenceCredibleRange: ["04:48", "05:07"], + openingCandidateRange: OPENING, + })); + assert.equal(sessionOutcomeAllowsDelivery(narrowed.sessionOutcome), true); + assert.equal(narrowed.canAdopt, true); + assert.equal(narrowed.sessionOutcome, "adopt_representative"); +}); + +test("a delivered dead choice is the delivered gap, not unavailable repair", () => { + const warnings: string[] = []; + const original = console.warn; + console.warn = (value: unknown) => { + warnings.push(String(value)); + }; + try { + assert.equal( + interviewDeliveredGap({ + questionMissing: false, + deadChoice: true, + sessionOutcome: "completed_with_range", + stopReason: "tied_first", + questionId: "distinguish:career.2023.entry", + hasChoiceCard: false, + }), + true, + ); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: false, + questionLoadFailed: true, + deadChoice: true, + questionId: "distinguish:career.2023.entry", + hasChoiceCard: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + sessionOutcome: "completed_with_range", + stopReason: "tied_first", + }), + "delivered", + ); + assert.ok(warnings.some((line) => line.includes("rectification_delivered_state_dead_choice"))); + } finally { + console.warn = original; + } + assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false); +}); + +test("a non-delivery dead choice stays on the unavailable repair path", () => { + assert.equal( + interviewDeliveredGap({ + questionMissing: false, + deadChoice: true, + sessionOutcome: "collect_evidence", + hasChoiceCard: false, + }), + false, + ); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: false, + questionLoadFailed: true, + deadChoice: true, + questionPersisted: false, + collectWaiting: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + sessionOutcome: "collect_evidence", + }), + "unavailable", + ); + assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。"); +});