fix(rectification): keep tied-first from delivering while probes remain (BUG-683/684)
tied_first is only terminal when there is no dated probe and targeted collect is exhausted. An unnarrowed opening window cannot announce adopt. Delivered dead choice cards take the delivered gap instead of the repair copy.
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-14 — 并列不等于可以结束:还有题就继续问,区间没收窄不说可采用
|
||||
|
||||
生时校正里,几个候选分数打平不再一上来就宣布「可采用」。还有带年月的区分题或定向补事时继续问。都问完了才给区间;如果区间还是一开始那一段,只给范围、不推荐代表分钟。交付之后如果还留着一张画不出的题,不再印「没有拿到下一个问题」。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-14 — 并列到顶时给出区间卡,不再说「没有拿到下一个问题」
|
||||
|
||||
生时校正里,几个候选打成平手、再问也分不开时,会给出区间和代表分钟的交付卡,不会只念结论却不出卡,也不会印「没有拿到下一个问题」。刷新尝试如果没写进库,不会假装已经试过。Skill 版本不变。
|
||||
|
||||
@@ -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`。缺这四项之前不得臆断写入路径。
|
||||
|
||||
@@ -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`。
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -14,3 +14,10 @@
|
||||
|
||||
- [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。
|
||||
- [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。
|
||||
|
||||
## BUG-683 / 684(并列早退回归)
|
||||
|
||||
新建一次校正,只答两轮采集(例如上学 / 毕业,实习 / 离职)。
|
||||
|
||||
- [ ] 答完两题后不得出现「可采用」或推荐的代表分钟;范围应仍在收窄中,并继续出带年月区分题或定向补事。
|
||||
- [ ] 若助手已经给出区间、界面却还挂着一张画不出选项的题,不得印「没有拿到下一个问题。」+「接着问」。
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Parameters<typeof decideRectification>[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, "没有拿到下一个问题。");
|
||||
});
|
||||
Reference in New Issue
Block a user