From 963c147c58f02ae7d4de82cbafe98436a1edcb9b Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Mon, 14 Sep 2026 21:43:21 +0800 Subject: [PATCH] fix(rectification): do not hold delivery on exhausted or closed-ceiling paths (BUG-688) Style questions still precede a converging range card when a renderable followup exists. Exhausted, closed-ceiling, and holdout-unavailable exits deliver immediately. Hold at most once per Case. Restore the single-gate exit assertion and stop treating the tie-break ack as an exit carrier. --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 16 ++++ ...ctification-tiebreak-hold-exit-20260914.md | 21 ++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 2 +- .../core/rectification-decision.ts | 24 +++--- .../v9/decision-from-dossier.ts | 73 ++++++++++++------- ...ification-exhaustion-exit-20260906.test.ts | 33 +++------ ...tion-probe-pool-exhausted-20260911.test.ts | 49 ++++++++----- ...tion-tiebreak-before-card-20260914.test.ts | 66 ++++++++++++++--- 10 files changed, 198 insertions(+), 92 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-tiebreak-hold-exit-20260914.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7fbc50..fe503ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 风格参考题问不出时仍会给出范围卡 + +生时校正里,带年月的题问完后仍会先问还没用过的风格参考题。如果这道题这一轮问不出来(比如候选在该分盘上只有一种上升星座),或已经问过一轮,会直接给出范围卡,不会停在没有题目的等待里。Skill 版本不变。 + ## 2026-09-14 — 给范围卡之前先问两道风格参考题 生时校正里,带年月的题问完后、给出范围卡之前,会先连着问还没用过的风格参考题(相处方式、做事风格)。某条线答过「没有」不会把对应的风格题一起关掉。点「再答两道参考题」后,题会出现在对话里。范围卡不再列出你已经说过没有或跳过的线。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1a09ac67..87989a5e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10709,3 +10709,19 @@ - 相关记录:BUG-661、BUG-662 - 复发自:无 - 修复版本:待发布 + +## BUG-688 | 风格题前置把耗尽与收口路径的交付也压住了 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`decideRectification` `shouldHoldForTieBreak`、耗尽 / closed-ceiling 出口 +- 用户现象:带年月题和定向补事都问完、本应出范围卡时,若风格参考题拿不出来,会话停在「还在区分」且没有题。门禁上 6 条「该交付却不交付」变红。 +- 触发条件:`pendingTieBreak` 为真,但风格题因分盘只有一种上升星座、焦点落库失败或已 hold 过一次而无法渲染;路径是 exhausted / closed-ceiling / 无载体收口。 +- 根因:BUG-686 把「还有没问的风格题」做成无条件延迟交付,插在所有交付分支之前,没有出口、没有次数上限。`6fd7925a` 只把断言改绿,行为未修。 +- 修复:只有真正拿得到可渲染风格题时才 hold;exhausted、closed-ceiling、holdout 不可用的收口不 hold;同一 Case 最多 hold 一轮。恢复「恰好一条交付闸」和「参考题确认语不算出口」两条断言。 +- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`、`rectification-exhaustion-exit-20260906.test.ts`、`rectification-probe-pool-exhausted-20260911.test.ts`。 +- 防复发:任何「延迟交付」的守卫都必须带出口,且不得作用于耗尽与收口路径。验收必须比对 `npm test` 失败数与基线。不得把参考题确认语算作出载体。 +- 相关记录:BUG-686、BUG-656、BUG-674、BUG-680、BUG-597 +- 复发自:BUG-686(风格题前置扩大到所有交付分支) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-tiebreak-hold-exit-20260914.md b/docs/tasks/PROGRESS-rectification-tiebreak-hold-exit-20260914.md new file mode 100644 index 00000000..703f11c2 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-tiebreak-hold-exit-20260914.md @@ -0,0 +1,21 @@ +# 进度 · 参考题前置不得压住出口(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-tiebreak-hold-exit-20260914`(基于 `origin/staging` @ `ada2cfb1`) +- 任务单:`docs/tasks/TASK-rectification-tiebreak-hold-exit-fix-20260914.md` +- BUG-688 resolved(复发自 BUG-686) +- 未改 `active_rectification_event_engine.py`、确认门、并列判据、Skill 版本 + +## 完成 + +- T1:`shouldHoldForTieBreak` 要求 `tieBreakFollowupReady`(可渲染风格题);`exhausted` 与 `user_stopped` 不 hold;closed-ceiling(`coverageBlocks && engineOffers && !narrowingOpen`)和 holdout 不可用的收口不 hold。同一 Case 已 hold 过一轮(已问过 `varga.d9` / `varga.d10` 或焦点带 `tie_break_round`)不再 hold。 +- T2:恢复 `last closed-ceiling card…` 的 `assert.equal(gates.length, 1)`;`exitAppendCalls()` 去掉 `只微调排序`。文案放宽(「范围已经收到 / 能问的都问完了」)保留。 +- D3 已做,未走让步。 + +## 验收数字 + +- 定向:`rectification-tiebreak-before-card-20260914.test.ts` 12 条 + `rectification-probe-pool-exhausted-20260911.test.ts` 10 条全绿。`rectification-exhaustion-exit-20260906.test.ts` 本机因 Skill SHA 软链无法导入(Windows 基线缺口),Linux 门禁会跑。 +- `tsc --noEmit`:0 错 +- `npx eslint` 改动文件:0 error(2 条既有 unused-vars warning,未顺手改) +- Skill 版本不变 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index d630b0a7..dac18dfd 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-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待执行 | `codex/rectification-tiebreak-hold-exit-20260914` | +| `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待验收 | `codex/rectification-tiebreak-hold-exit-20260914` | | `TASK-rectification-cluster-width-research-20260914.md` | `PROGRESS-rectification-cluster-width-research-20260914.md` | **研究单**:上一轮证明调权重改不动交付区间宽度——所有方案宽度中位数都等于整个搜索窗。先确认 sweep 的宽度口径是否含淘汰(M0),再画簇结构像(M1),最后量三个改法:放宽簇上限、按分差决定是否合并、交付区间改分位覆盖(M2)。真值覆盖率不得下降 | 待执行 | `codex/rectification-cluster-width-research-20260914` | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 84e3c97a..07b0816f 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 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. Unused style questions are asked before this card appears, whether or not the top two are tied. | 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. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered. | 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/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 4af09bee..45d07175 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -232,6 +232,10 @@ export type DecideRectificationInput = Readonly<{ openingCandidateRange?: readonly [string, string] | null; /** Unasked D9/D10 style questions remain. */ pendingTieBreak?: boolean; + /** A renderable style followup exists this turn (choice_frame present). */ + tieBreakFollowupReady?: boolean; + /** This Case already entered a style-question hold (at most one round). */ + tieBreakAlreadyHeld?: boolean; }>; function classifyStop( @@ -372,9 +376,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat && engineOffers && !narrowingOpen ) { - if (shouldHoldForTieBreak(input, separation)) { - return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason); - } return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability); } return collect( @@ -411,9 +412,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason); } if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) { - if (shouldHoldForTieBreak(input, separation)) { - return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason); - } return finish("adopt_representative", { input, separation, @@ -468,9 +466,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability); } if (holdout === "unavailable") { - if (shouldHoldForTieBreak(input, separation)) { - return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason); - } return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability); } if (shouldHoldForTieBreak(input, separation)) { @@ -589,12 +584,17 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean { } export function shouldHoldForTieBreak( - input: Pick, + input: Pick< + DecideRectificationInput, + "pendingTieBreak" | "tieBreakFollowupReady" | "tieBreakAlreadyHeld" | "userStopped" + >, separation: CandidateSeparation, kind?: "user_stopped" | "offer" | "exhausted", ): boolean { - if (kind === "user_stopped" || input.userStopped === true) return false; - if (input.pendingTieBreak !== true) return false; + if (kind === "user_stopped" || kind === "exhausted" || input.userStopped === true) return false; + if (input.tieBreakAlreadyHeld === true) return false; + const ready = input.tieBreakFollowupReady ?? input.pendingTieBreak; + if (ready !== true) return false; return separation.ranked.length >= 2; } 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 8853b61a..0805a737 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -42,6 +42,7 @@ import { isRemainingEvidenceCollect, tieBreakGateInput, tieBreakPersonalityAvailable, + tieBreakPersonalityFollowup, } from "./method-followup"; import { buildConfirmationGate } from "./confirmation-gate"; import { @@ -763,24 +764,53 @@ function windowWidenSuggestedFromDossier(dossier: DecisionDossier, evidenceFinge }); } +function isStyleProbeKey(value: unknown): boolean { + const key = String(value ?? ""); + return key.startsWith("varga.d9") || key.startsWith("varga.d10"); +} + +function tieBreakHoldFlags( + dossier: DecisionDossier, + catalog: ReturnType, + birthDate?: string | null, +): { + pendingTieBreak: boolean; + tieBreakFollowupReady: boolean; + tieBreakAlreadyHeld: boolean; +} { + const gate = tieBreakGateInput({ + evidence: dossier.evidence, + declinedTopics: dossier.conversationSummary.declinedSkippedTopics, + closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, + catalog, + birthDate, + accepted: Boolean(dossier.case.acceptedTime), + stage: dossier.case.stage, + blockScan: dossier.case.blockScan, + reportedBirthTime: dossier.case.reportedBirthTime, + candidateRange: dossier.case.candidateRange, + }); + const followup = tieBreakPersonalityFollowup(gate); + const asked = catalog.askedProbeKeys ?? []; + const answered = catalog.answeredProbes ?? []; + const schema = dossier.conversationSummary.activeFocus?.expectedAnswerSchema; + const alreadyHeld = asked.some(isStyleProbeKey) + || answered.some((item) => isStyleProbeKey(item.semantic_key)) + || Boolean(schema && typeof schema === "object" && schema.tie_break_round === true); + return { + pendingTieBreak: tieBreakPersonalityAvailable(gate), + tieBreakFollowupReady: Boolean(followup?.choice_frame), + tieBreakAlreadyHeld: alreadyHeld, + }; +} + export function decideFromDossier( dossier: DecisionDossier, options?: DecideFromDossierOptions, ): RectificationDecision { const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); - const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({ - evidence: dossier.evidence, - declinedTopics: dossier.conversationSummary.declinedSkippedTopics, - closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, - catalog, - birthDate: options?.birthDate, - accepted: Boolean(dossier.case.acceptedTime), - stage: dossier.case.stage, - blockScan: dossier.case.blockScan, - reportedBirthTime: dossier.case.reportedBirthTime, - candidateRange: dossier.case.candidateRange, - })); + const tieBreakHold = tieBreakHoldFlags(dossier, catalog, options?.birthDate); const oosBlindPrompts = refinementFromDecisionReceipt( dossier.latestResult?.decisionReceipt ?? null, ).oos_blind_prompts; @@ -892,7 +922,7 @@ export function decideFromDossier( ), windowWidenSuggested, openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange), - pendingTieBreak, + ...tieBreakHold, ...narrowingExhaustion(dossier, inference, options, catalog), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), @@ -907,18 +937,7 @@ export function decideAfterInferenceChange(input: { snapshotCurrent?: boolean; }): RectificationDecision { const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence); - const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({ - evidence: input.dossier.evidence, - declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, - closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics, - catalog, - birthDate: input.birthDate, - accepted: Boolean(input.dossier.case.acceptedTime), - stage: input.dossier.case.stage, - blockScan: input.dossier.case.blockScan, - reportedBirthTime: input.dossier.case.reportedBirthTime, - candidateRange: input.dossier.case.candidateRange, - })); + const tieBreakHold = tieBreakHoldFlags(input.dossier, catalog, input.birthDate); const collecting = buildMethodFollowupPlan({ evidence: input.dossier.evidence, declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics, @@ -974,7 +993,7 @@ export function decideAfterInferenceChange(input: { evidenceLedgerFingerprint(input.dossier.evidence as never), ), openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange), - pendingTieBreak, + ...tieBreakHold, }); } const training = input.state.events.filter((item) => item.usage === "training"); @@ -1052,7 +1071,7 @@ export function decideAfterInferenceChange(input: { evidenceLedgerFingerprint(input.dossier.evidence as never), ), openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange), - pendingTieBreak, + ...tieBreakHold, ...narrowingExhaustion(input.dossier, input.state, undefined, catalog), }), droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped), diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 44ed9e17..c3e18959 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -554,7 +554,7 @@ function assistantAppendCalls(calls: Array<{ fn: string; args: Record }>) { return assistantAppendCalls(calls).filter((item) => ( GATE_SENTENCE.test(String(item.args.p_assistant_message)) - || /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了|只微调排序/.test( + || /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/.test( String(item.args.p_assistant_message), ) )); @@ -858,30 +858,21 @@ test("last closed-ceiling card concatenates the gate into one append", async () expectedRevision: 6, }); const gates = exitAppendCalls(accounting.calls); - // 原值: 答完最后一道区分卡立刻 append 1 条含「目前范围」的交付闸 - // 新值: 交付旁白改收口「能问的都问完了」;若风格参考题未用完则先问参考题(0 条交付闸) - // 原因: BUG-686 / BUG-687 - assert.ok( - gates.length === 1 - || /只微调排序|能问的都问完了|范围已经收到|目前范围/.test(applied.narration ?? ""), - JSON.stringify({ - gates: gates.map((item) => item.args.p_assistant_message), - narration: applied.narration, - }), - ); + // 原值: 恰好 1 条交付闸,旁白含「目前范围」 + // 新值: 恰好 1 条交付闸,旁白可含「范围已经收到 / 能问的都问完了」 + // 原因: BUG-688 恢复「恰好一条」;文案放宽来自 BUG-687 + assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message))); assert.doesNotMatch(applied.narration, /领域不限|做不了|材料不够|还差 \d+ 件/); assert.match( applied.narration, - /范围还能再收一截|目前范围|就能开始筛|当前范围|范围已经收到|能问的都问完了|只微调排序/, + /范围还能再收一截|目前范围|就能开始筛|当前范围|范围已经收到|能问的都问完了/, + ); + assert.equal( + (String(gates[0]?.args.p_assistant_message).match( + /范围还能再收一截|目前范围|就能开始筛|范围已经收到|能问的都问完了/g, + ) ?? []).length > 0, + true, ); - if (gates.length === 1) { - assert.equal( - (String(gates[0]?.args.p_assistant_message).match( - /范围还能再收一截|目前范围|就能开始筛|范围已经收到|能问的都问完了|只微调排序/g, - ) ?? []).length > 0, - true, - ); - } }); test("closed ceiling with a leftover discriminator persists the card not the gate", async () => { diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts index 9e166933..b4a59968 100644 --- a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -784,20 +784,16 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t declinedTopics: TARGETED_ALL_DECLINED, }); const delivered = decideFromDossier(allDeclined, { birthDate: "1997-08-08" }); - // 原值: 全部定向补事拒答后立刻 offer_provisional_range / ready_to_adopt / complete_with_range - // 新值: 风格参考题未用完时先 ask_candidate_discriminator - // 原因: BUG-686 出卡前先问参考题 + // 原值: 风格题前置把 closed-ceiling 也 hold 成 ask_candidate_discriminator + // 新值: 耗尽/收口路径直接交付 + // 原因: BUG-688 D2 assert.ok( - delivered.heldForTieBreak === true - || delivered.nextAction === "offer_provisional_range" + delivered.nextAction === "offer_provisional_range" || delivered.nextAction === "ready_to_adopt" || delivered.nextAction === "complete_with_range", delivered.nextAction, ); - // 原值: canOfferRange true - // 新值: 卡住参考题时 false - // 原因: BUG-686 - assert.equal(delivered.canOfferRange, delivered.heldForTieBreak !== true); + assert.equal(delivered.canOfferRange, true); const catalog = rectificationFollowupCatalog(allDeclined.latestResult, allDeclined.evidence); assert.equal( targetedCollectPool( @@ -821,11 +817,15 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前 declinedTopics: TARGETED_ALL_DECLINED, }); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); - // 原值: offer_provisional_range / ready_to_adopt / complete_with_range - // 新值: 未用的 D10 风格参考题先占位,nextAction=ask_candidate_discriminator - // 原因: BUG-686 出卡前先问参考题 - assert.equal(decision.heldForTieBreak, true, decision.nextAction); - assert.equal(decision.nextAction, "ask_candidate_discriminator"); + // 原值: heldForTieBreak 把 closed-ceiling 压成 ask_candidate_discriminator + // 新值: 定向补事关完后交付范围卡 + // 原因: BUG-688 D2 耗尽/收口不得 hold + assert.ok( + decision.nextAction === "offer_provisional_range" + || decision.nextAction === "ready_to_adopt" + || decision.nextAction === "complete_with_range", + decision.nextAction, + ); const delivery = rangeDeliveryForSnapshot({ decisionReceipt: dossier.latestResult?.decisionReceipt, candidates: dossier.latestResult?.candidates, @@ -851,17 +851,28 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前 assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/); assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/); const publicAction = publicNextAction(decision); - // 原值: can_offer_range true,nextUser 为出卡 - // 新值: 卡住参考题时不出卡 - // 原因: BUG-686 - assert.equal(publicAction.can_offer_range, false); + assert.equal(publicAction.can_offer_range, true); + assert.equal(rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: false, + collectWaiting: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: 0, + offerAwaitingReader: publicAction.can_offer_range, + }), "idle"); const idle = await persistNextInterviewIfIdle({ accounting: idleHandlers(dossier).client, userId: USER_ID, caseId: CASE_ID, }); - assert.match(idle.hostNarration ?? "", /只微调排序|目前范围|能问的都问完了/); + assert.match(idle.hostNarration ?? "", /目前范围|范围已经收到|能问的都问完了/); assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/); + assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/); }); test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => { diff --git a/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts b/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts index a94e09a9..1f3fd017 100644 --- a/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts +++ b/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts @@ -121,6 +121,29 @@ function tiedDecision(overrides: Partial[ }); } +/** Normal convergence: unique first place, methods covered, holdout passed. */ +function convergingDecision(overrides: Partial[0]> = {}) { + return decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: [ + { time: "04:53", score: 24 }, + { time: "05:00", score: 14 }, + { time: "05:06", score: 13 }, + ], + discriminatorProbe: null, + holdoutValidation: "passed", + datedEventCount: 3, + datedDomainCount: 3, + targetedCollectExhausted: true, + refreshExhausted: true, + tieBreakFollowupReady: true, + pendingTieBreak: true, + ...overrides, + }); +} + test("a tie-break snapshot turn is merged into messages with its question", () => { type Message = { role: "assistant"; @@ -198,8 +221,8 @@ test("a delivery card plus an unanswered focus is not an idle gap", () => { ); }); -test("a first-place tie with unused personality questions holds delivery", () => { - const decision = tiedDecision({ pendingTieBreak: true }); +test("a converging case with unused personality questions holds delivery", () => { + const decision = convergingDecision(); assert.equal(decision.heldForTieBreak, true); assert.notEqual(decision.nextAction, "complete_with_range"); assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); @@ -231,19 +254,40 @@ test("the same tie delivers after both personality questions are used", () => { }); test("a two-point lead still asks personality questions before delivery", () => { - const decision = tiedDecision({ - pendingTieBreak: true, - candidateScores: [ - { time: "04:53", score: 16 }, - { time: "05:00", score: 14 }, - { time: "05:06", score: 13 }, - ], - }); + const decision = convergingDecision(); assert.equal(decision.heldForTieBreak, true); assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); assert.equal(tieBreakPersonalityFollowup(planInput())?.choice_kind, "varga_style"); }); +test("pending style flag without a renderable followup delivers", () => { + const decision = convergingDecision({ + pendingTieBreak: true, + tieBreakFollowupReady: false, + }); + assert.equal(decision.heldForTieBreak ?? false, false); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("an exhausted first-place tie delivers even if style questions remain", () => { + const decision = tiedDecision({ + pendingTieBreak: true, + tieBreakFollowupReady: true, + }); + assert.equal(decision.heldForTieBreak ?? false, false); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("already held once without a followup delivers", () => { + const decision = convergingDecision({ + pendingTieBreak: true, + tieBreakFollowupReady: true, + tieBreakAlreadyHeld: true, + }); + assert.equal(decision.heldForTieBreak ?? false, false); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + test("a layer with only one rising sign does not occupy the style slot", () => { const onlyD10 = { ...planInput(), @@ -262,7 +306,7 @@ test("a layer with only one rising sign does not occupy the style slot", () => { test("holding for a tie-break does not mark a delivery turn", () => { resetDeliveryTurnGuardForTests(); - const decision = tiedDecision({ pendingTieBreak: true }); + const decision = convergingDecision(); assert.equal(decision.heldForTieBreak, true); markDeliveryTurn({ caseId: "case-1", resultId: "result-1" }); assert.equal(alreadyDelivered({