fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
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:
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-14 — 并列到顶时给出区间卡,不再说「没有拿到下一个问题」
|
||||
|
||||
生时校正里,几个候选打成平手、再问也分不开时,会给出区间和代表分钟的交付卡,不会只念结论却不出卡,也不会印「没有拿到下一个问题」。刷新尝试如果没写进库,不会假装已经试过。Skill 版本不变。
|
||||
|
||||
|
||||
## 2026-09-14 — 口述追问挂回助手消息;同屏只写一个范围;引擎代表时间不进模型上下文
|
||||
|
||||
生时校正里,定向补事答「有过这件事」之后的「大概哪年几月?」会跟选择题一样出现在助手消息里,带头像,不再是时间轴下面一行裸题。采集旁白「现在还剩 X–Y」与时间轴「目前范围」用同一段可信区间。已被淘汰的引擎代表分钟不会再把宫位表送给模型。Skill 版本不变。
|
||||
|
||||
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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。
|
||||
@@ -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` |
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 并列到顶交付 vs 采集回退:真机清单(2026-09-14)
|
||||
|
||||
分支 `codex/rectification-delivery-vs-collect-20260914`。本环境无 staging 登录态。
|
||||
|
||||
## BUG-680 / 681
|
||||
|
||||
进入当时那个校正会话,或再走一遍「定向补事问到家人并跳过」。
|
||||
|
||||
- [ ] 助手念完交付结论后,同一屏出现区间交付卡(范围、代表分钟、可点「更像这个」若允许选择)。
|
||||
- [ ] 不会只剩「目前范围 …,再说一件带年月的事就能继续」而没有卡。
|
||||
- [ ] 同屏不会既出交付卡又出「再说一件带年月的事就能继续」。
|
||||
|
||||
## BUG-682
|
||||
|
||||
- [ ] 没有下一题时,不再出现「没有拿到下一个问题。」和「接着问」。
|
||||
- [ ] 可以改选或继续在输入框补经历,文案不是报错口吻。
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user