fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Successful in 13m52s

Refresh persist failures no longer count as attempts. tied_first completes with a range before stillNeedNarrowing. Delivery narration and cards share DELIVERY_OUTCOMES. The question gap gets a delivered terminal so the unavailable copy does not appear after a range is given.
This commit is contained in:
jesse-ux
2026-09-14 15:41:04 +08:00
parent e59ef9fbfd
commit d5a8db0a4e
16 changed files with 644 additions and 9 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-14 — 并列到顶时给出区间卡,不再说「没有拿到下一个问题」
生时校正里,几个候选打成平手、再问也分不开时,会给出区间和代表分钟的交付卡,不会只念结论却不出卡,也不会印「没有拿到下一个问题」。刷新尝试如果没写进库,不会假装已经试过。Skill 版本不变。
## 2026-09-14 — 口述追问挂回助手消息;同屏只写一个范围;引擎代表时间不进模型上下文
生时校正里,定向补事答「有过这件事」之后的「大概哪年几月?」会跟选择题一样出现在助手消息里,带头像,不再是时间轴下面一行裸题。采集旁白「现在还剩 X–Y」与时间轴「目前范围」用同一段可信区间。已被淘汰的引擎代表分钟不会再把宫位表送给模型。Skill 版本不变。
+48
View File
@@ -10580,3 +10580,51 @@
- 相关记录:BUG-678、BUG-635、BUG-677
- 复发自:BUG-678DESIGN 与合同测试分叉)
- 修复版本:待发布
## 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` 仍为 truePOST 第二次 `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-674POST 与 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
- 复发自:无
- 修复版本:待发布
@@ -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`
- T4BUG_HISTORY、CHANGELOG、真机清单、DESIGN。
## 验收数字
- `./node_modules/.bin/tsc --noEmit`0 错
- `npm run lint`0 error / 120 warning(既有,未顺手改)
- 相关单测:`rectification-delivery-vs-collect-20260914.test.ts` 6/6candidate-result / surface-state / superseded-focus / unwritten-evidence 之外的定向套件 53/53probe-pool-exhausted 10/10convergence-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。
+1 -1
View File
@@ -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` 压过 exhaustedBUG-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` 压过 exhaustedBUG-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` |
@@ -0,0 +1,16 @@
# 并列到顶交付 vs 采集回退:真机清单(2026-09-14)
分支 `codex/rectification-delivery-vs-collect-20260914`。本环境无 staging 登录态。
## BUG-680 / 681
进入当时那个校正会话,或再走一遍「定向补事问到家人并跳过」。
- [ ] 助手念完交付结论后,同一屏出现区间交付卡(范围、代表分钟、可点「更像这个」若允许选择)。
- [ ] 不会只剩「目前范围 …,再说一件带年月的事就能继续」而没有卡。
- [ ] 同屏不会既出交付卡又出「再说一件带年月的事就能继续」。
## BUG-682
- [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。
- [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。
+1
View File
@@ -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 |
@@ -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) {
<ConsultationTimelineLiveRow id="question-preparing" label={RECTIFICATION_QUESTION_PREPARING_LABEL} />
</div>
)}
{questionGap === "delivered" && !showSelectionCards && (
<p className="rectification-pending-note" role="status">
{RECTIFICATION_DELIVERED_COPY}
</p>
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
@@ -130,12 +130,25 @@ export const ADOPT_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
"validated_range",
]);
/** Range-delivery card and delivery copy share this set. ADOPT_OUTCOMES stays the adopt gate. */
export const DELIVERY_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = 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<RectificationDecision, "canAdopt" | "sessionOutcome">): 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;
@@ -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<Record<string, unknown>>[];
}): 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"
@@ -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) => {
@@ -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";
}
@@ -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;
@@ -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", () => {
@@ -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<string, unknown> = {}) {
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, "没有拿到下一个问题。");
});
@@ -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 () => {
@@ -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);