diff --git a/CHANGELOG.md b/CHANGELOG.md index 6943f05f..2892a92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-14 — 并列到顶时给出区间卡,不再说「没有拿到下一个问题」 + +生时校正里,几个候选打成平手、再问也分不开时,会给出区间和代表分钟的交付卡,不会只念结论却不出卡,也不会印「没有拿到下一个问题」。刷新尝试如果没写进库,不会假装已经试过。Skill 版本不变。 + + ## 2026-09-14 — 口述追问挂回助手消息;同屏只写一个范围;引擎代表时间不进模型上下文 生时校正里,定向补事答「有过这件事」之后的「大概哪年几月?」会跟选择题一样出现在助手消息里,带头像,不再是时间轴下面一行裸题。采集旁白「现在还剩 X–Y」与时间轴「目前范围」用同一段可信区间。已被淘汰的引擎代表分钟不会再把宫位表送给模型。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a1a0cac2..e15ba23f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10580,3 +10580,51 @@ - 相关记录:BUG-678、BUG-635、BUG-677 - 复发自:BUG-678(DESIGN 与合同测试分叉) - 修复版本:待发布 + +## BUG-680 | 刷新尝试落库失败仍按已尝试推进,POST 交付 GET 回采集 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`refreshDatedDiscriminatorPoolIfNeeded`、`persistRefreshAttempt`、`decideRectification`、`shouldSkipFollowupPersist` +- 用户现象:跳过最后一道定向补事题后,助手播出完整交付结论,刷新后没有交付卡、没有下一题,时间轴写「再说一件带年月的事就能继续」,并印出「没有拿到下一个问题。」 +- 触发条件:前两名 posterior 并列(如 16/16),`stop_reason=tied_first`;本轮选择题刚推进 revision,刷新尝试用旧 revision 写库撞版本冲突。 +- 根因:`persistRefreshAttempt` 失败时返回值被丢弃,`attemptRecorded` 仍为 true,POST 第二次 `decideFromDossier` 把 `refreshExhausted` 当成真并交付;GET 读库没有这条 attempt,`stillNeedNarrowing` 为真,把 `tied_first` 压回 `collect_evidence`。并列到顶本是终局,被「还有收窄手段」无条件压过。 +- 修复:落库失败时 `attemptRecorded=false`,不把内存态写进 dossier,并打 `rectification_refresh_attempt_persist_failed`。`tied_first` 在 `stillNeedNarrowing` / coverageBlocks 之前直接 `completeWithRange`。idle persist 在 `tied_first` 且 outcome 属于交付集合时跳过下一问。不放宽 `can_adopt`。 +- 验证:`frontend/tests/rectification-delivery-vs-collect-20260914.test.ts`:persist 抛错后 attemptRecorded=false;同一份并列 dossier 上 `persistNextInterviewIfIdle` 与 `decideFromDossier` 都交付。 +- 防复发:任何「是否已尝试/已耗尽」的标志,落库失败时一律按未完成处理。POST 与 GET 对同一状态的决策必须一致,并由对拍测试守着。不得靠内存 overlay 播交付。 +- 相关记录:BUG-674、BUG-681、BUG-682 +- 复发自:BUG-674(POST 与 GET 对同一状态判定不一致) +- 修复版本:待发布 + +## BUG-681 | 交付话术能说、交付卡不能画 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`DELIVERY_OUTCOMES`、`deliveryNarrationAllowed`、`canShowRectificationSelectionCards`、`canShowRectificationReadonlyRange` +- 用户现象:正文已经念完「现在给的范围是…代表分钟为…」,界面没有区间交付卡。 +- 触发条件:`session_outcome` 为 `completed_with_range` 或 `provisional_range`;`can_adopt` 为假。 +- 根因:卡片闸门用 `ADOPT_OUTCOMES` 且要求 `canAdopt`。这两种正常收尾不在采用集合里。话术闸门认 `offer_provisional_range` / 公开采用,于是能说不能画。 +- 修复:新增 `DELIVERY_OUTCOMES` = 采用集合 ∪ `{completed_with_range, provisional_range}`。卡片闸门用它,不把 `can_adopt` 提权。交付 outcome 且 `selection_allowed` 时出卡、不出「再说一件」范围行。 +- 验证:`rectification-delivery-vs-collect-20260914.test.ts`、`rectification-candidate-result.test.ts` 表驱动:话术为真则卡为真。 +- 防复发:不得新增第二套交付卡。话术与卡必须由同一判据驱动。 +- 相关记录:BUG-680、BUG-590 +- 复发自:无 +- 修复版本:待发布 + +## BUG-682 | 已交付无题时缺口状态机兜底成「没有拿到下一个问题」 + +- 状态:resolved +- 首次发现:2026-09-14 +- 最近更新:2026-09-14 +- 影响面:`rectificationQuestionGapState`、`interviewDeliveredGap`、`rectification-agentic-chat.tsx` +- 用户现象:没有题、没有卡时印出「没有拿到下一个问题。」并出现「接着问」。 +- 触发条件:`current_question === null` 且 `session_outcome=collect_evidence`、`stop_reason=tied_first`(或已交付 outcome);不是 collect_waiting 的那几个不足理由。 +- 根因:缺口状态机没有「已交付 / 没有更多可问」终态,重试耗尽后落到 `unavailable`。 +- 修复:新增 `delivered`:无题且 outcome 属于交付集合,或 `stop_reason` 为 `tied_first` / `user_uncertainty_too_high`。该状态显示出口说明,禁止「没有拿到下一个问题」。普通题没取到仍是 `unavailable`。 +- 验证:`rectification-delivery-vs-collect-20260914.test.ts`、`rectification-surface-state.test.ts`。 +- 防复发:已交付无题不得再走修复入口文案。不得把 `delivered` 误伤真正缺题的 `unavailable`。 +- 相关记录:BUG-680、BUG-681 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md b/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md new file mode 100644 index 00000000..02e8bffe --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-delivery-vs-collect-20260914.md @@ -0,0 +1,23 @@ +# 进度 · 播了交付却回到采集态(2026-09-14) + +## 范围 + +- 分支:`codex/rectification-delivery-vs-collect-20260914`(基于 `origin/staging` @ `e59ef9fb`) +- 任务单:`docs/tasks/TASK-rectification-delivery-vs-collect-split-20260914.md` +- BUG-680 / 681 / 682 resolved +- 任务 1/2/3 均做完,没有走让步 + +## 完成 + +- T1:`persistRefreshAttempt` 失败时 `attemptRecorded=false`,不 overlay 内存态。`tied_first` 提到 `stillNeedNarrowing` 与 coverageBlocks 之前。idle persist 在并列到顶时跳过下一问。 +- T2:`DELIVERY_OUTCOMES`;卡片闸门用它;交付 outcome 且可选择时出卡、不出采集范围行。未提权 `can_adopt`。 +- T3:缺口状态 `delivered`,文案「再问下去也分不开了。范围在上面,对不上可以改选。」普通缺题仍 `unavailable`。 +- T4:BUG_HISTORY、CHANGELOG、真机清单、DESIGN。 + +## 验收数字 + +- `./node_modules/.bin/tsc --noEmit`:0 错 +- `npm run lint`:0 error / 120 warning(既有,未顺手改) +- 相关单测:`rectification-delivery-vs-collect-20260914.test.ts` 6/6;candidate-result / surface-state / superseded-focus / unwritten-evidence 之外的定向套件 53/53;probe-pool-exhausted 10/10;convergence-budget 14/14 +- 全量 `npm test`:2996 / pass 2829 / fail 153 / skip 14。失败条数与本机 Windows 基线一致(spoken-orphan 轮次 2989/2822/153/14;本轮多 7 条新测全部通过)。153 条是 CRLF、Skill SHA、Docker/DB、`G:\\G:\\` 双盘符、无 symlink 权限,不是本单回归。Linux 门禁基线仍是任务书写的 27 条缺口。 +- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction(`points out of the filesystem root`)。未在本树 `npm ci`(避免再占一份依赖)。Linux 门禁会在树内安装后构建。未测 `/` Static 与首屏 gzip。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4a9d225d..51a9a147 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-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` | +| `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` | | `TASK-rectification-spoken-orphan-and-engine-representative-20260914.md` | `PROGRESS-rectification-spoken-orphan-20260914.md` | **P0**:口述题(定向补事年份追问)没有 `askedTurnId` 时仍是无头像裸行——BUG-675 的挂回规则只覆盖选择题(BUG-678);同屏两个范围口径(旁白用活跃候选首尾、时间轴用 credible_range);引擎 `representative_time` 把已淘汰分钟的宫位表带进模型上下文(BUG-676 收敛为 resolved) | 待验收 | `codex/rectification-spoken-orphan-20260914` | diff --git a/docs/testing/rectification-delivery-vs-collect-20260914.md b/docs/testing/rectification-delivery-vs-collect-20260914.md new file mode 100644 index 00000000..397d2651 --- /dev/null +++ b/docs/testing/rectification-delivery-vs-collect-20260914.md @@ -0,0 +1,16 @@ +# 并列到顶交付 vs 采集回退:真机清单(2026-09-14) + +分支 `codex/rectification-delivery-vs-collect-20260914`。本环境无 staging 登录态。 + +## BUG-680 / 681 + +进入当时那个校正会话,或再走一遍「定向补事问到家人并跳过」。 + +- [ ] 助手念完交付结论后,同一屏出现区间交付卡(范围、代表分钟、可点「更像这个」若允许选择)。 +- [ ] 不会只剩「目前范围 …,再说一件带年月的事就能继续」而没有卡。 +- [ ] 同屏不会既出交付卡又出「再说一件带年月的事就能继续」。 + +## BUG-682 + +- [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。 +- [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7a1a8b8e..ef362936 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -231,6 +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 | | `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 4e747421..ba980cfc 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -58,6 +58,7 @@ import { RECTIFICATION_QUESTION_RETRY_LIMIT, RECTIFICATION_QUESTION_UNAVAILABLE_COPY, RECTIFICATION_COLLECT_WAITING_PLACEHOLDER, + RECTIFICATION_DELIVERED_COPY, RECTIFICATION_STOPPED_NOTICE, isAbortError, rectificationConversationState, @@ -1581,6 +1582,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime, nextUserActionId, collectWaiting: collectWaiting && !deadChoice, + sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null, + stopReason: interviewStopReason, busy, readonly, regenerating: regeneratingMessageKey !== null, @@ -1898,6 +1901,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { )} + {questionGap === "delivered" && !showSelectionCards && ( +

+ {RECTIFICATION_DELIVERED_COPY} +

+ )} {questionGap === "unavailable" && (
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? ( diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 03c2837c..b69a8d27 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -130,12 +130,25 @@ export const ADOPT_OUTCOMES: ReadonlySet = new Set([ "validated_range", ]); +/** Range-delivery card and delivery copy share this set. ADOPT_OUTCOMES stays the adopt gate. */ +export const DELIVERY_OUTCOMES: ReadonlySet = new Set([ + ...ADOPT_OUTCOMES, + "completed_with_range", + "provisional_range", +]); + export function sessionOutcomeAllowsAdopt( outcome: DecisionSessionOutcome | string | null | undefined, ): outcome is DecisionSessionOutcome { return typeof outcome === "string" && ADOPT_OUTCOMES.has(outcome as DecisionSessionOutcome); } +export function sessionOutcomeAllowsDelivery( + outcome: DecisionSessionOutcome | string | null | undefined, +): outcome is DecisionSessionOutcome { + return typeof outcome === "string" && DELIVERY_OUTCOMES.has(outcome as DecisionSessionOutcome); +} + export function publicCanAdopt(decision: Pick): boolean { return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome); } @@ -151,7 +164,8 @@ export function deliveryNarrationAllowed( ): boolean { if (publicCanAdopt(decision)) return true; const action = nextAction ?? decision.nextAction; - return decision.selectionAllowed === true && action === "offer_provisional_range"; + if (decision.selectionAllowed === true && action === "offer_provisional_range") return true; + return decision.selectionAllowed === true && sessionOutcomeAllowsDelivery(decision.sessionOutcome); } export type CompletionStatus = @@ -317,6 +331,14 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } return collect(separation, holdout, range, probe, capability, stopReason); } + if ( + stopClass?.kind === "exhausted" + && stopClass.reason === "tied_first" + && input.trainingGateOpen !== false + && separation.ranked.length > 0 + ) { + return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + } if (coverageBlocks) { const engineOffers = input.engineCeiling.acceptanceAllowed || input.engineCeiling.proposeAllowed; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index af543307..75a353fb 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -15,6 +15,7 @@ import { nonConvergingRangeNarration, publicCanAdopt, publicNextAction, + sessionOutcomeAllowsDelivery, type RectificationDecision, } from "../core/rectification-decision.ts"; import { @@ -272,6 +273,12 @@ function shouldSkipFollowupPersist(input: { declinedTopics?: readonly Readonly>[]; }): boolean { if (input.accepted) return false; + if ( + input.stopReason === "tied_first" + && sessionOutcomeAllowsDelivery(input.sessionOutcome) + ) { + return true; + } if (!input.canAdopt) return false; if ( input.nextAction === "ask_fact_collection" diff --git a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts index 1b8928a6..a58946d8 100644 --- a/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts +++ b/frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts @@ -565,7 +565,7 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { { ...state, revision: state.revision + 1 }, "no_new_probes", ); - await persistRefreshAttempt({ + const recorded = await persistRefreshAttempt({ accounting: input.accounting, userId: input.userId, caseId: input.caseId, @@ -573,6 +573,20 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { previous: state, next: attempted, }); + if (!recorded) { + console.warn(JSON.stringify({ + event: "rectification_refresh_attempt_persist_failed", + case_id: input.caseId, + candidate_set_id: state.candidate_set_id, + answer_count: state.answered_probes.length, + })); + return { + dossier: input.dossier, + state, + refreshed: false, + attemptRecorded: false, + }; + } return { dossier: applyRefreshedProbesToDossier(input.dossier, attempted), state: attempted, @@ -597,11 +611,17 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: { askableCount: askable.length, }); if (!written) { + console.warn(JSON.stringify({ + event: "rectification_refresh_attempt_persist_failed", + case_id: input.caseId, + candidate_set_id: state.candidate_set_id, + answer_count: state.answered_probes.length, + })); return { - dossier: applyRefreshedProbesToDossier(input.dossier, nextState), - state: nextState, + dossier: input.dossier, + state, refreshed: false, - attemptRecorded: true, + attemptRecorded: false, }; } const persistableEventProbes = result.eventProbes.filter((probe) => { diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index b09556ac..4ecdc103 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -22,6 +22,7 @@ import { MIN_SEPARATION_LEAD } from "./rectification-agentic/core/candidate-sepa import { engineCapabilityCeilingFromReceipt, sessionOutcomeAllowsAdopt, + sessionOutcomeAllowsDelivery, type DecisionSessionOutcome, } from "./rectification-agentic/core/rectification-decision"; import { @@ -426,8 +427,11 @@ export function canRenderRectificationSelectionCards( export function canShowRectificationSelectionCards( result: RectificationCandidateResult | null, ): boolean { - return canRenderRectificationSelectionCards(result) - && sessionOutcomeAllowsAdopt(result?.sessionOutcome); + if (!result || !sessionOutcomeAllowsDelivery(result.sessionOutcome)) return false; + if (sessionOutcomeAllowsAdopt(result.sessionOutcome)) { + return canRenderRectificationSelectionCards(result); + } + return result.selectionAllowed === true; } export function canShowRectificationReadonlyRange( @@ -435,6 +439,9 @@ export function canShowRectificationReadonlyRange( ): boolean { if (!result?.credibleRange) return false; const outcome = result.sessionOutcome; + if (sessionOutcomeAllowsDelivery(outcome)) { + return result.selectionAllowed !== true; + } return outcome === "collect_evidence" || outcome === "discriminate_candidates"; } diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 1bdb746f..385f3610 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -10,6 +10,7 @@ import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx"; import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts"; +import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts"; /** * Upper bound on hydrating a Case (turns + snapshot) after `/cases/open` @@ -30,6 +31,7 @@ export const RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2_000; export const RECTIFICATION_QUESTION_PREPARING_LABEL = "正在准备下一个问题…"; export const RECTIFICATION_QUESTION_UNAVAILABLE_COPY = "没有拿到下一个问题。"; export const RECTIFICATION_COLLECT_WAITING_PLACEHOLDER = "再说一件带年月的事"; +export const RECTIFICATION_DELIVERED_COPY = "再问下去也分不开了。范围在上面,对不上可以改选。"; export const RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"; export const RECTIFICATION_QUESTION_REPAIR_FAILED_COPY = "暂时接不上,请新建一次校正。"; export const RECTIFICATION_QUESTION_REPAIR_LIMIT = 2; @@ -269,7 +271,8 @@ export type RectificationQuestionGapState = | "unavailable" | "verified_idle" | "persisted_question" - | "collect_waiting"; + | "collect_waiting" + | "delivered"; export type RectificationQuestionGapInput = Readonly<{ /** The current question is rendered live inside an assistant message. */ @@ -289,6 +292,8 @@ export type RectificationQuestionGapInput = Readonly<{ questionPersisted?: boolean; /** Training gate still closed; pool empty; keep the case open for another dated event. */ collectWaiting?: boolean; + sessionOutcome?: string | null; + stopReason?: string | null; busy: boolean; readonly: boolean; regenerating: boolean; @@ -313,6 +318,7 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp if (!input.snapshotLoaded) return retryGate; if (!input.resumableCase) return "idle"; if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle"; + if (interviewDeliveredGap(input)) return "delivered"; if (input.collectWaiting) return "collect_waiting"; if (input.questionPersisted) return "persisted_question"; if (input.nextUserActionId === "start_consultation") return "verified_idle"; @@ -378,6 +384,16 @@ export function interviewChoiceCardUnavailable(input: Readonly<{ return false; } +export function interviewDeliveredGap(input: Readonly<{ + questionMissing: boolean; + sessionOutcome?: string | null; + stopReason?: string | null; +}>): boolean { + if (!input.questionMissing) return false; + if (sessionOutcomeAllowsDelivery(input.sessionOutcome)) return true; + return input.stopReason === "tied_first" || input.stopReason === "user_uncertainty_too_high"; +} + export function interviewCollectWaiting(input: Readonly<{ stopReason?: string | null; sessionOutcome?: string | null; diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index 41b13270..a60efb97 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -305,6 +305,19 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () = }); assert.equal(canShowRectificationSelectionCards(adopted), true); assert.equal(canShowRectificationReadonlyRange(adopted), false); + const delivered = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + session_outcome: "completed_with_range", + canAdopt: false, + can_adopt: false, + selectionAllowed: true, + credible_range: ["04:48", "05:07"], + }); + // 原值: completed_with_range 且 canAdopt=false 不出卡、只出「再说一件」 + // 新值: 出交付卡,不出采集范围行 + // 原因: BUG-681 能说就能画 + assert.equal(canShowRectificationSelectionCards(delivered), true); + assert.equal(canShowRectificationReadonlyRange(delivered), false); }); test("parses inference marks from decisionReceipt.inference_state.candidates", () => { diff --git a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts new file mode 100644 index 00000000..e7a0aa7e --- /dev/null +++ b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts @@ -0,0 +1,420 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DELIVERY_OUTCOMES, + decideRectification, + deliveryNarrationAllowed, + sessionOutcomeAllowsDelivery, + type DecisionSessionOutcome, +} from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { + canShowRectificationReadonlyRange, + canShowRectificationSelectionCards, + parseRectificationCandidateResult, +} from "../src/lib/rectification-candidate-result.ts"; +import { + persistNextInterviewIfIdle, +} from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + refreshDatedDiscriminatorPoolIfNeeded, + resetRefreshDiscriminatorProbesForTests, + setRefreshDiscriminatorProbesForTests, +} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts"; +import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts"; +import { + RECTIFICATION_DELIVERED_COPY, + RECTIFICATION_QUESTION_RETRY_LIMIT, + RECTIFICATION_QUESTION_UNAVAILABLE_COPY, + rectificationQuestionGapState, +} from "../src/lib/rectification-surface-state.ts"; +import { + CASE_ID, + OPEN_ENGINE_CAPABILITY_CEILING, + TURN_ID, + USER_ID, + candidateSnapshotFixture, + computeFixture, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +const TIED_CANDIDATES = [ + { candidateId: "88888888-8888-4888-8888-888888888881", time: "04:53", rank: 1, relativeSupport: 16 }, + { candidateId: "88888888-8888-4888-8888-888888888882", time: "05:00", rank: 2, relativeSupport: 16 }, + { candidateId: "88888888-8888-4888-8888-888888888883", time: "05:06", rank: 3, relativeSupport: 15 }, + { candidateId: "88888888-8888-4888-8888-888888888884", time: "04:51", rank: 4, relativeSupport: 14 }, + { candidateId: "88888888-8888-4888-8888-888888888885", time: "04:59", rank: 5, relativeSupport: 13 }, +] as const; + +const SNAPSHOT_BASE = { + resultId: "11111111-1111-4111-8111-111111111111", + candidates: [ + { candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 }, + { candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 }, + ], + overallConfidence: "medium" as const, + selectionAllowed: true, + canAdopt: false, + confirmationAllowed: false, + representativeTime: "04:53", + selectedTime: null, + selectionKind: null, + credibleRange: ["04:48", "05:07"] as const, + decisionReceipt: { + display_allowed: true, + selection_allowed: true, + acceptance_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + accept_allowed: true, + confirm_allowed: false, + }, +}; + +function datedEvidenceRpc() { + return [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "career_entry", + domain: "career", + occurred_from: "2016-09-01", + occurred_to: null, + date_precision: "month", + summary: "入职", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:06.000Z", + }, + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "education_completion", + domain: "education", + occurred_from: "2012-06-01", + occurred_to: null, + date_precision: "month", + summary: "毕业", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:07.000Z", + }, + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "family_event", + domain: "family", + occurred_from: "2018-03-01", + occurred_to: null, + date_precision: "month", + summary: "家里添丁", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:08.000Z", + }, + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_start", + domain: "relationship", + occurred_from: "2020-05-01", + occurred_to: null, + date_precision: "month", + summary: "恋爱", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:09.000Z", + }, + ]; +} + +function tiedInference(extra: Record = {}) { + const times = TIED_CANDIDATES.map((item) => item.time); + return { + algorithm_version: INFERENCE_ALGORITHM_VERSION, + candidate_set_id: candidateSetId("04:48", "05:07", times), + revision: 6, + phase: "discrimination", + result_status: "discriminating", + range_start: "04:48", + range_end: "05:07", + candidates: TIED_CANDIDATES.map((item) => ({ + id: item.candidateId, + time: item.time, + cluster_range: ["04:48", "05:07"] as const, + prior_score: item.relativeSupport, + posterior_score: item.relativeSupport, + probability: item.relativeSupport === 16 ? 0.2592219508007118 : item.relativeSupport / 74, + status: "active" as const, + rank: item.rank, + strong_conflict_count: 0, + })), + events: datedEvidenceRpc().map((item, index) => ({ + id: item.id, + domain: item.domain, + year: 2012 + index, + precision: "month" as const, + usage: "training" as const, + })), + probes: [], + answered_probes: [ + { + probe_id: "probe-1", + semantic_key: "career.2016.test", + candidate_split_hash: "split-1", + answer_class: "yes" as const, + classified_from: "choice" as const, + }, + ], + rounds: [], + entropy: 1, + representative_time: "04:53", + credible_range: ["04:48", "05:07"] as const, + refresh_attempts: [], + ...extra, + }; +} + +function tiedRpcDossier() { + const evidence = datedEvidenceRpc(); + return dossierFixture({ + evidenceCount: 4, + evidence, + latestResult: candidateSnapshotFixture({ + representativeTime: "04:53", + selectionAllowed: true, + candidates: TIED_CANDIDATES.map((item) => ({ + candidate_id: item.candidateId, + rank: item.rank, + time: item.time, + relative_support: item.relativeSupport, + tied_minute_count: 2, + })), + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: tiedInference(), + }, + }), + }); +} + +test("tied first place delivers a range even when narrowing flags are still open", () => { + const decision = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: false, + trainingGateOpen: true, + candidateScores: TIED_CANDIDATES.map((item) => ({ time: item.time, score: item.relativeSupport })), + discriminatorProbe: null, + holdoutValidation: "unavailable", + datedEventCount: 3, + datedDomainCount: 3, + refreshExhausted: false, + targetedCollectExhausted: false, + }); + assert.equal(decision.stopReason, "tied_first"); + assert.equal(decision.canOfferRange, true); + assert.equal(decision.nextAction, "complete_with_range"); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); +}); + +test("refresh persist failure is not recorded as an attempt", async () => { + resetRefreshDiscriminatorProbesForTests(); + setRefreshDiscriminatorProbesForTests(async ({ state }) => ({ + state: { ...state, candidates: [] }, + eventProbes: [], + candidateSetId: state.candidate_set_id, + refreshCount: 1, + })); + const rpc = tiedRpcDossier(); + const dossier = parseV9CaseDossier(rpc); + assert.ok(dossier); + const warnings: string[] = []; + const original = console.warn; + console.warn = (value: unknown) => { + warnings.push(String(value)); + }; + try { + const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({ + accounting: fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_compute: () => computeFixture(), + append_agentic_rectification_inference_transition: () => { + throw new Error("candidate_state_inconsistent"); + }, + }).client, + userId: USER_ID, + caseId: CASE_ID, + dossier: dossier!, + hasDatedProbe: false, + }); + assert.equal(refreshed.attemptRecorded, false); + assert.equal(refreshed.refreshed, false); + assert.ok(warnings.some((line) => line.includes("rectification_refresh_attempt_persist_failed"))); + const before = decideFromDossier(dossier!, { birthDate: "1997-08-08" }); + const after = decideFromDossier(refreshed.dossier, { birthDate: "1997-08-08" }); + assert.equal(after.sessionOutcome, before.sessionOutcome); + assert.equal(after.canOfferRange, before.canOfferRange); + assert.equal(after.stopReason, before.stopReason); + } finally { + console.warn = original; + resetRefreshDiscriminatorProbesForTests(); + } +}); + +test("POST idle persist and GET decideFromDossier agree on a first-place tie", async () => { + const rpc = tiedRpcDossier(); + const dossier = parseV9CaseDossier(rpc); + assert.ok(dossier); + const getDecision = decideFromDossier(dossier!, { birthDate: "1997-08-08", snapshotCurrent: true }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => rpc, + get_agentic_rectification_case_compute: () => computeFixture(), + append_agentic_rectification_inference_transition: () => { + throw new Error("candidate_state_inconsistent"); + }, + set_agentic_rectification_conversation_focus: (_fn, args) => { + throw new Error(`must not persist ${String(args.p_question_id)}`); + }, + append_agentic_rectification_turn: () => ({ + turn_id: "33333333-3333-4333-8333-333333333333", + idempotent: false, + }), + finalize_agentic_rectification_turn: () => ({ + turn_id: "33333333-3333-4333-8333-333333333333", + status: "completed", + idempotent: false, + }), + get_agentic_rectification_turn_receipt: () => null, + }); + const idle = await persistNextInterviewIfIdle({ + accounting: accounting.client, + userId: USER_ID, + caseId: CASE_ID, + }); + const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome); + const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration); + assert.equal(getDecision.stopReason, "tied_first"); + assert.equal(getDelivers, true); + assert.equal(postDelivers, getDelivers); +}); + +test("completed_with_range with selection allowed shows the delivery card, not the collect range line", () => { + const result = parseRectificationCandidateResult({ + ...SNAPSHOT_BASE, + session_outcome: "completed_with_range", + selectionAllowed: true, + canAdopt: false, + }); + assert.equal(canShowRectificationSelectionCards(result), true); + assert.equal(canShowRectificationReadonlyRange(result), false); +}); + +test("delivery narration and delivery cards stay true together for every outcome", () => { + const outcomes: DecisionSessionOutcome[] = [ + "collect_evidence", + "compare_blocks", + "widen_window", + "discriminate_candidates", + "validate_holdout", + "provisional_range", + "provisional_range_user_stopped", + "completed_with_range", + "validated_range", + "exact_minute_confirmed", + "adopt_representative", + "awaiting_confirmation", + ]; + for (const outcome of outcomes) { + const adopt = outcome === "adopt_representative" + || outcome === "provisional_range_user_stopped" + || outcome === "awaiting_confirmation" + || outcome === "validated_range"; + const nextAction = outcome === "provisional_range" + ? "offer_provisional_range" + : outcome === "completed_with_range" + ? "complete_with_range" + : "ask_fact_collection"; + const narrate = deliveryNarrationAllowed({ + canAdopt: adopt, + sessionOutcome: outcome, + nextAction, + selectionAllowed: true, + }); + const result = parseRectificationCandidateResult({ + ...SNAPSHOT_BASE, + session_outcome: outcome, + canAdopt: adopt, + selectionAllowed: true, + }); + const cards = canShowRectificationSelectionCards(result); + if (narrate) { + assert.equal(cards, true, outcome); + } + if (DELIVERY_OUTCOMES.has(outcome) && outcome !== "completed_with_range" && outcome !== "provisional_range") { + assert.equal(narrate, true, outcome); + } + } +}); + +test("a delivered tie is not the unavailable repair gap", () => { + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + sessionOutcome: "collect_evidence", + stopReason: "tied_first", + }), + "delivered", + ); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: false, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + sessionOutcome: "completed_with_range", + }), + "delivered", + ); + assert.equal( + rectificationQuestionGapState({ + liveQuestionVisible: false, + questionMissing: true, + questionLoadFailed: true, + busy: false, + readonly: false, + regenerating: false, + snapshotLoaded: true, + resumableCase: true, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + }), + "unavailable", + ); + assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false); + assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。"); +}); diff --git a/frontend/tests/rectification-superseded-focus.test.ts b/frontend/tests/rectification-superseded-focus.test.ts index a79a72a4..b9b346d9 100644 --- a/frontend/tests/rectification-superseded-focus.test.ts +++ b/frontend/tests/rectification-superseded-focus.test.ts @@ -240,6 +240,15 @@ test("deliveryNarrationAllowed requires public can_adopt or a selectable range o nextAction: "offer_provisional_range", selectionAllowed: false, }), false); + // 原值: completed_with_range 且 canAdopt=false 不能播交付 + // 新值: selectionAllowed 且 outcome 在 DELIVERY_OUTCOMES 即可播 + // 原因: BUG-681 能说就能画,completed_with_range / provisional_range 与卡片同真同假 + assert.equal(deliveryNarrationAllowed({ + canAdopt: false, + sessionOutcome: "completed_with_range", + nextAction: "complete_with_range", + selectionAllowed: true, + }), true); }); test("duplicate_focus on the last collect ask does not emit delivery copy", async () => { diff --git a/frontend/tests/rectification-surface-state.test.ts b/frontend/tests/rectification-surface-state.test.ts index 45568c61..a941304b 100644 --- a/frontend/tests/rectification-surface-state.test.ts +++ b/frontend/tests/rectification-surface-state.test.ts @@ -140,6 +140,26 @@ test("question gap: a persisted focus question is shown instead of preparing", ( assert.equal(rectificationQuestionRetryActive("persisted_question"), false); }); +test("question gap: a delivered range is not the unavailable repair path", () => { + assert.equal( + rectificationQuestionGapState({ + ...gapBase, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + sessionOutcome: "completed_with_range", + }), + "delivered", + ); + assert.equal( + rectificationQuestionGapState({ + ...gapBase, + retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT, + stopReason: "tied_first", + }), + "delivered", + ); + assert.equal(rectificationQuestionRetryActive("delivered"), false); +}); + test("question gap: a choice question without a card is unavailable, not collect waiting", () => { assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: false }), true); assert.equal(interviewChoiceCardUnavailable({ questionKind: "choice", hasChoiceCard: true }), false);