fix(rectification): ask tie-break questions before the range card (BUG-685/686/687)

Close leads hold delivery until unused D9/D10 style questions are asked. Tie-break POST merges the new turn into the transcript. Range copy no longer lists declined lines.
This commit is contained in:
jesse-ux
2026-09-14 18:23:54 +08:00
parent 1c424bf6d5
commit 7f28bfecbb
22 changed files with 833 additions and 86 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-14 — 分数接近时先问两道参考题,再给出范围卡
生时校正里,前两名只差一分或打平时,会先连着问两道参考题(相处方式、做事风格),再给范围卡。点「再答两道参考题」后,题会出现在对话里。范围卡不再列出你已经说过没有或跳过的线。Skill 版本不变。
## 2026-09-14 — 并列不等于可以结束:还有题就继续问,区间没收窄不说可采用
生时校正里,几个候选分数打平不再一上来就宣布「可采用」。还有带年月的区分题或定向补事时继续问。都问完了才给区间;如果区间还是一开始那一段,只给范围、不推荐代表分钟。交付之后如果还留着一张画不出的题,不再印「没有拿到下一个问题」。Skill 版本不变。
+48
View File
@@ -10661,3 +10661,51 @@
- 复发自:BUG-682`delivered` 终态要求无题,死卡把会话钉在问题态)
- 修复版本:待发布
- 证据缺口:同一会话 GET 的 `current_question``choice_card``question_source`,以及最后一条助手 turn 的 `offer_result_id`。缺这四项之前不得臆断写入路径。
## BUG-685 | 点「再答两道参考题」服务端成功,对话区没有任何变化
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`requestTieBreak``loadCaseSnapshot``applySnapshotTurnsToMessages``rectificationQuestionGapState`
- 用户现象:点「再答两道参考题微调排序」后 POST 返回 `ok: true``choiceReady: true`,界面仍是原来的范围卡,新参考题看不见。
- 触发条件:范围卡在场时点参考题入口;服务端已写入助手 turn 和活动焦点。
- 根因:`requestTieBreak` 调用不带合并回调的 `loadCaseSnapshot()`,新 turn 不进 `messages`。交付卡在场使缺口为 `idle``persisted_question` 也不会兜底画这道题。
- 修复:`requestTieBreak` 用与 `submitChoice` 相同的 `mergeTurnQuestions` / `appendUnseenAssistantTurns`。未答焦点优先于「等读者点卡」的 idle。
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`
- 防复发:服务端返回 ok 而界面无变化,一律视为缺陷。任何新写 turn 的前端动作必须把 turn 并进对话区。
- 相关记录:BUG-663、BUG-666、BUG-667
- 复发自:BUG-663(入口只写焦点,前端刷新不合并 turn)
- 修复版本:待发布
## BUG-686 | 平局直接出卡,参考题留成卡下可选按钮
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`decideRectification` `heldForTieBreak``persistNextInterviewAfterChoice` / `persistNextInterviewIfIdle`、范围卡按钮
- 用户现象:带年月题和定向补事问完后,前两名 16/16 并列,直接出范围卡让人在 26%/26%/20% 里选;参考题要自己点按钮。
- 触发条件:前两名 posterior 分差 ≤ 1,D9/D10 参考题仍可用且未用过。
- 根因:性格题被设计成出卡后的可选入口,而不是出卡前的必经步骤。
- 修复:产品拍板方案 A(2026-09-14)。分差 ≤ 1 且参考题可用时先连问最多两道 `varga_style`(仍只 ±1、不淘汰),两道用完再出卡。仍平时卡上写「这两分钟按现有信息分不开,参考题已经用过。」用过之后不再显示入口按钮。
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`16/16 不交付且下一问是 `varga_style`;两道用完才交付且按钮关闭;lead=2 不插参考题。
- 防复发:不得把性格题改成淘汰候选。自动进入参考题时不得再写第二条交付消息。
- 相关记录:BUG-663、BUG-667、BUG-629、BUG-597
- 复发自:BUG-663(入口做成出卡后可选)
- 修复版本:待发布
## BUG-687 | 交付文案把已经拒答的线又列一遍
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`rangeNarrowHint`
- 用户现象:结婚、收入、家人都答过「没有/跳过」,范围卡仍写「能把它们分开的是这几条线:哪年结婚或订婚、哪年收入明显变过、家里哪年添丁」。
- 触发条件:定向补事已拒答或已关闭,随后交付区间。
- 根因:`rangeNarrowHint``targetedCollectPool` 时传入空的 `declinedTopics`,有意忽略拒答。
- 修复:传入真实 `declinedTopics`。一条都不剩时写「能问的都问完了」,不再暗示还有没问的线。
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`
- 防复发:交付文案不得列出已拒答或已回答的线。
- 相关记录:BUG-661、BUG-662
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,36 @@
# 进度 · 平局先问参考题再出卡(2026-09-14)
## 范围
- 分支:`codex/rectification-tiebreak-before-card-20260914`(基于 `origin/staging` @ `a6381e02`
- 任务单:`docs/tasks/TASK-rectification-tiebreak-before-card-20260914.md`
- BUG-685 / 686 / 687 resolved
- 任务 14 均做完,没有走让步
## 完成
- T1`requestTieBreak` 成功路径用 `applySnapshotTurnsToMessages` 把新 turn 并进对话。缺口里未答焦点优先于「等读者点卡」。`refetchQuestion` 与 repair 失败回拉同样合并(服务端可能已写 turn)。
- T2`pendingTieBreak && lead ≤ 1``heldForTieBreak`,先持久化 D9/D10 `varga_style`(沿用 `tie_break_round`),用完再交付。仍平时卡上写「参考题已经用过」,按钮随 `tie_break_available` 收起。
- T3`rangeNarrowHint` 传入真实拒答;空池写「能问的都问完了」。
- T4`tieBreakGateInput` 供 GET 与 POST 共用。GET 本轮仍不另载 `birthDate`(与原先一致);POST 继续从 compute 读取。
## `loadCaseSnapshot` 逐处
| 位置 | 回调 | 依据 |
| --- | --- | --- |
| `send` 成功/失败 | 有(只 merge 已有消息上的题) | 模型 turn 已在 `messages` |
| `submitChoice` | 有 merge + append | 结构化选择后可能多出助手 turn |
| 采用成功后 | 无 | 随后 `send` 写采用旁白;快照只刷新已采用态 |
| 参考题 409 | 无 | 没有新题,只重拉以藏按钮 |
| 参考题成功 | 有 | BUG-685 |
| `refetchQuestion` | 有 | 准备缺口期间服务端可能已写下下一问 |
| repair-exit 失败回拉 | 有 | 修复失败后快照里可能已有新 turn |
## 验收数字
- `tsc --noEmit`0 错
- `npm run lint`0 error / 120 warning(既有,未顺手改)
- 定向:`rectification-tiebreak-before-card-20260914.test.ts` 8/8tie-break-entry、collection-question-pool、delivery-vs-collect、tied-first、surface-state、probe-pool-exhausted T4、targeted-collect 相关全过
- 全量 `npm test`3011 / pass 2843 / fail 154 / skip 14(含 T4 旧断言,已改为「能问的都问完了」后定向 20/20)。失败条数与 Windows 基线 153 同型(CRLF、Skill SHA、Docker/DB、无 symlink)。本轮新测 8 条全过。
- `next build`:本机 Turbopack 拒跨 worktree 的 `node_modules` junction。Linux 门禁会在树内安装后构建。
- 浏览器级验收:无 staging 登录态,清单见 `docs/testing/rectification-tiebreak-before-card-20260914.md`
+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-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待执行 | `codex/rectification-tiebreak-before-card-20260914` |
| `TASK-rectification-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待验收 | `codex/rectification-tiebreak-before-card-20260914` |
| `TASK-rectification-tied-first-premature-delivery-fix-20260914.md` | `PROGRESS-rectification-tied-first-fix-20260914.md` | **P0 回归修复单**BUG-680 的 `tied_first` 早退被实现到 `coverageBlocks` 之前且无 `probe` 守卫 → 新案子答两题、区间还是整个开局窗口就宣布 `can_adopt`/代表分钟(BUG-683);交付态下仍出现死卡致 `delivered` 终态不触发、印「没有拿到下一个问题」(BUG-684 investigating | 待验收 | `codex/rectification-tied-first-fix-20260914` |
@@ -0,0 +1,19 @@
# 平局先问参考题:真机清单(2026-09-14)
分支 `codex/rectification-tiebreak-before-card-20260914`。本环境无 staging 登录态。
## BUG-685
- [ ] 范围卡上点「再答两道参考题微调排序」后,对话区立刻出现助手消息和可点的参考题,不是什么都没变。
## BUG-686
- [ ] 前两名分数打平或只差一分、参考题还没用过时:先连着出两道参考题(相处方式、做事风格),再出范围卡。
- [ ] 两道答完后才出范围卡;卡上不再有「再答两道参考题微调排序」。
- [ ] 若仍分不开,卡上有一句「这两分钟按现有信息分不开,参考题已经用过。」
- [ ] 同一轮不会出现两条交付结论。
## BUG-687
- [ ] 范围卡文案不再出现已经答过「没有/跳过」的线(结婚、收入、添丁等)。
- [ ] 能问的线都关闭时,写「能问的都问完了」,不暗示还有没问的线。
+1 -1
View File
@@ -234,7 +234,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a “还能再收窄” caption under the title, then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold | enabled |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines (or “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. A close lead asks the two personality cards before this card appears. | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
@@ -120,13 +120,17 @@ import {
interviewQuestionBlocksAdoptOffer,
nextSelectionCardLock,
parseTurnQuestion,
persistedOfferFromTurn,
questionIsAnswered,
resolveSelectionCardMessageKey,
type SelectionCardLock,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import {
appendUnseenAssistantTurns,
applySnapshotTurnsToMessages,
mergeTurnQuestions,
} from "@/lib/rectification-snapshot-messages";
type PersistedTurn = Readonly<{
id: string;
@@ -245,33 +249,6 @@ type RenderMessage = ChatMessageView & {
candidateOffer?: Readonly<{ resultId: string }>;
};
function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
@@ -295,16 +272,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function appendUnseenAssistantTurns(current: RenderMessage[], turns: readonly unknown[]): RenderMessage[] {
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
const extras = messagesFromTurns(turns as readonly PersistedTurn[]).filter((message) => (
message.role === "assistant"
&& message.turnId
&& !known.has(message.turnId)
));
return extras.length ? [...current, ...extras] : current;
}
function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
@@ -1194,7 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const withoutPlaceholder = current.filter((message) => message.renderKey !== assistantRenderKey);
const withHistory = appendUnseenAssistantTurns(
mergeTurnQuestions(withoutPlaceholder, turns),
turns,
messagesFromTurns(turns as readonly PersistedTurn[]),
);
if (withHistory.length > withoutPlaceholder.length) return withHistory;
return [
@@ -1396,7 +1363,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法开始参考题");
}
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法开始参考题");
} finally {
@@ -1579,7 +1552,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
questionMissing: currentQuestion === null,
questionLoadFailed: questionSource === "unavailable" || deadChoice,
questionPersisted: Boolean(currentQuestion?.prompt && questionSource === "focus") && !deadChoice,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime,
offerAwaitingReader: showSelectionCards && !candidateResult?.selectedTime && currentQuestion === null,
nextUserActionId,
collectWaiting: collectWaiting && !deadChoice,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
@@ -1622,7 +1595,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
},
});
async function refetchQuestion() {
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
}
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
@@ -1650,7 +1629,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot();
await loadCaseSnapshot((turns) => {
setMessages((current) => applySnapshotTurnsToMessages(
current,
turns,
(incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]),
));
});
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
@@ -59,6 +59,9 @@ export function RectificationRangeDelivery({
{RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry}
</button>
) : null}
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
) : null}
{sharedTraits.length > 0 ? (
<ul className="rectification-range-delivery__shared">
{sharedTraits.map((line) => (
@@ -197,6 +197,8 @@ export type RectificationDecision = Readonly<{
droppedProbes: readonly DroppedProbe[];
stopReason?: EvidenceStopReason | null;
terminationCopy?: string | null;
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
heldForTieBreak?: boolean;
}>;
export type DecideRectificationInput = Readonly<{
@@ -228,6 +230,8 @@ export type DecideRectificationInput = Readonly<{
targetedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
/** Unasked D9/D10 style questions remain. */
pendingTieBreak?: boolean;
}>;
function classifyStop(
@@ -329,7 +333,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "user_stopped", rangeDeliveryCapability);
}
if (input.snapshotCurrent === false) {
@@ -346,7 +350,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& !probe
&& input.targetedCollectExhausted !== false
) {
return completeWithRange(
return deliverRange(
input,
separation,
holdout,
range,
@@ -367,6 +372,9 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& engineOffers
&& !narrowingOpen
) {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return collect(
@@ -382,7 +390,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, capability, stopClass.reason);
}
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (!separation.sufficient) {
if (probe) {
@@ -400,7 +408,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
return finish("adopt_representative", {
@@ -413,10 +421,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
stopReason: "probe_pool_exhausted",
});
}
return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (input.accepted) {
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
@@ -448,9 +456,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
}
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability);
}
if (holdout === "unavailable") {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return finish("adopt_representative", {
@@ -479,7 +490,7 @@ function discriminateOrExhaust(
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (budgetExhausted(input)) {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason);
return deliverRange(input, separation, holdout, range, "exhausted", capability, stopReason);
}
return discriminate(separation, holdout, range, probe, capability, stopReason);
}
@@ -565,6 +576,61 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
export function shouldHoldForTieBreak(
input: Pick<DecideRectificationInput, "pendingTieBreak" | "userStopped">,
separation: CandidateSeparation,
kind?: "user_stopped" | "offer" | "exhausted",
): boolean {
if (kind === "user_stopped" || input.userStopped === true) return false;
if (input.pendingTieBreak !== true) return false;
if (separation.ranked.length < 2) return false;
return separation.lead <= 1;
}
function holdForTieBreak(
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
return {
phase: "discrimination",
nextAction: "ask_candidate_discriminator",
sessionOutcome: "discriminate_candidates",
resultStatus: "discriminating",
canOfferRange: false,
...waitToNarrowCapability(capability),
precisionStage: "theme_refine",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
probe: null,
holdoutValidation: holdout,
droppedProbes: [],
heldForTieBreak: true,
...(stopReason ? { stopReason } : {}),
};
}
function deliverRange(
input: DecideRectificationInput,
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
kind: "user_stopped" | "offer" | "exhausted",
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (shouldHoldForTieBreak(input, separation, kind)) {
return holdForTieBreak(separation, holdout, range, capability, stopReason);
}
return completeWithRange(separation, holdout, range, kind, capability, stopReason);
}
function clockMinute(value: string): string {
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
if (!match) return value.trim().slice(0, 5);
@@ -148,6 +148,8 @@ export const RECTIFICATION_USER_COPY = {
rangeDeliveryAdopted: "已采用",
rangeDeliveryTieBreakEntry: "再答两道参考题微调排序",
rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。",
rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。",
rangeDeliveryCollectClosed: "能问的都问完了",
verificationReportSummary: "查看验证报告",
} as const;
@@ -501,6 +503,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.rangeDeliveryAdopted,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed,
RECTIFICATION_USER_COPY.verificationReportSummary,
postAdoptVerifyDoneCopy("04:53", false),
rangeChangedAfterEvidence(["04:50", "04:57"], ["04:47", "05:15"]) ?? "",
@@ -101,6 +101,7 @@ import {
spokenCollectFallbackFollowup,
spokenFollowupForUser,
targetedCollectFollowup,
tieBreakGateInput,
tieBreakPersonalityFollowup,
collectQuestionDomain,
ledgerHasConfirmedDatedEvent,
@@ -1019,6 +1020,30 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
}
const heldFollowup = decision.heldForTieBreak === true
? tieBreakPersonalityFollowup({ ...planInput, tieBreakRequested: true })
: null;
if (heldFollowup) {
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
followup: heldFollowup,
askedTurnId: input.askedTurnId ?? null,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return {
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
choiceReady: true,
persisted: true,
focusId: persistedFocus.focus?.id ?? null,
focus: persistedFocus.focus,
followup: heldFollowup,
};
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
if (shouldSkipFollowupPersist({
@@ -1229,22 +1254,18 @@ export async function requestTieBreakPersonality(input: {
birthDate = null;
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const followup = tieBreakPersonalityFollowup({
const followup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...catalog,
catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}),
});
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
if (!followup) {
return {
persisted: false,
@@ -1631,6 +1652,38 @@ export async function persistNextInterviewIfIdle(input: {
});
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
if (decision.heldForTieBreak === true) {
const heldFollowup = tieBreakPersonalityFollowup(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
if (heldFollowup) {
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: dossier.latestResult?.decisionReceipt,
followup: heldFollowup,
askedTurnId: input.askedTurnId ?? null,
});
const open = openQuestionFromPersistedFocus(persistedFocus);
if (isRenderableChoiceOpenQuestion(open) && open.prompt) {
return finishIdle({
persisted: true,
choiceReady: true,
hostNarration: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
});
}
}
}
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
activeFocus: null,
@@ -16,7 +16,7 @@ import { collectionProgressFromReceipt } from "@/lib/rectification-agentic/v9/ev
import { slimDecisionReceipt } from "@/lib/rectification-agentic/v9/case-receipt-projection";
import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/divergence-panel";
import { rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
export function dossierResponse(
@@ -106,15 +106,21 @@ function publicLatestResult(
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
accepted: Boolean(dossier.case.acceptedTime),
tieBreakAvailable: tieBreakPersonalityAvailable({
tieBreakAvailable: tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...catalog,
catalog,
accepted: Boolean(dossier.case.acceptedTime),
}),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
})),
tieBreakUsed: (catalog.askedProbeKeys ?? []).some((key) => (
key.includes("varga.d9") || key.includes("varga.d10")
)),
stillTied: decision.separation.lead <= 1 && decision.separation.ranked.length >= 2,
});
const withDelivery = {
...projected,
@@ -4,7 +4,7 @@
* value, not a fixed domain rotation. Age-band years never enter prompts.
*/
import { USER_COLLECT_QUESTION } from "../user-copy.ts";
import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../user-copy.ts";
import { canonicalCollectDomain } from "./domain-alias.ts";
export const COLLECT_KIND_ORDER = [
@@ -825,7 +825,6 @@ export function targetedCollectHintFromPool(
return targetedCollectHint(items[0]);
}
/** Card copy ignores a declined targeted collect so the delivered range still says what would narrow it. */
export function rangeNarrowHint(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
@@ -834,7 +833,21 @@ export function rangeNarrowHint(
candidateCount?: number,
credibleRange?: readonly [string, string] | null,
): string {
const open = targetedCollectPool(remainingLayers, evidence, [], splitTimes, candidateCount, credibleRange);
const open = targetedCollectPool(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
);
if (open.length === 0) {
const range = clockRangePair(credibleRange) ?? clockRangePair(splitTimes);
if (range && (candidateCount ?? 0) > 0) {
return `现在还剩 ${range[0]}${range[1]}${candidateCount} 个候选。${RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed}`;
}
return RECTIFICATION_USER_COPY.rangeDeliveryCollectClosed;
}
const remaining = remainingCandidatesLine(
splitTimes,
candidateCount ?? 0,
@@ -842,14 +855,6 @@ export function rangeNarrowHint(
credibleRange,
);
const hint = targetedCollectHintFromPool(open)
?? targetedCollectHint(targetedCollectPool(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
credibleRange,
)[0])
?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`;
if (remaining && !hint.startsWith(remaining)) {
return `${remaining}${hint}`;
@@ -40,6 +40,8 @@ import {
eventFamilyForDiscriminator,
exhaustionSpokenCollectFollowup,
isRemainingEvidenceCollect,
tieBreakGateInput,
tieBreakPersonalityAvailable,
} from "./method-followup";
import { buildConfirmationGate } from "./confirmation-gate";
import {
@@ -767,6 +769,18 @@ export function decideFromDossier(
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: options?.birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
const oosBlindPrompts = refinementFromDecisionReceipt(
dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
@@ -878,6 +892,7 @@ export function decideFromDossier(
),
windowWidenSuggested,
openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange),
pendingTieBreak,
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -892,6 +907,18 @@ export function decideAfterInferenceChange(input: {
snapshotCurrent?: boolean;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: input.birthDate,
accepted: Boolean(input.dossier.case.acceptedTime),
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
reportedBirthTime: input.dossier.case.reportedBirthTime,
candidateRange: input.dossier.case.candidateRange,
}));
const collecting = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
@@ -947,6 +974,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
});
}
const training = input.state.events.filter((item) => item.usage === "training");
@@ -1024,6 +1052,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -12,6 +12,7 @@ import type {
} from "../core/types.ts";
import {
RANGE_DELIVERY_MORE_MINUTES,
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryFitLine,
rangeDeliveryWindowLine,
@@ -82,6 +83,7 @@ export type RangeDeliveryProjection = Readonly<{
verification_markdown: string | null;
narrow_hint: string | null;
tie_break_available: boolean;
tie_break_note: string | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -334,6 +336,8 @@ export function buildRangeDelivery(input: {
}>[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
}): RangeDeliveryProjection {
const inference = input.inference;
const range = input.credibleRange
@@ -419,6 +423,11 @@ export function buildRangeDelivery(input: {
inference?.credible_range,
),
tie_break_available: input.tieBreakAvailable === true,
tie_break_note: input.tieBreakAvailable !== true
&& input.tieBreakUsed === true
&& input.stillTied === true
? RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed
: null,
};
}
@@ -477,6 +486,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
window_scan?: unknown;
accepted?: boolean;
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -507,6 +518,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
// judgment the tie-break route uses. Callers pass it; window_scan
// alone must not keep the button after both cards are answered.
tieBreakAvailable: snapshot?.accepted !== true && snapshot?.tieBreakAvailable === true,
tieBreakUsed: snapshot?.tieBreakUsed === true,
stillTied: snapshot?.stillTied === true,
});
}
@@ -649,5 +662,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
? row.narrowHint.trim()
: null,
tie_break_available: row.tie_break_available === true || row.tieBreakAvailable === true,
tie_break_note: typeof row.tie_break_note === "string" && row.tie_break_note.trim()
? row.tie_break_note.trim()
: typeof row.tieBreakNote === "string" && row.tieBreakNote.trim()
? row.tieBreakNote.trim()
: null,
};
}
@@ -165,6 +165,7 @@ import {
WIDEN_WINDOW_INTENT,
blockPeriodsForChoice,
buildBlockChoiceFrame,
followupCaseArgs,
type BlockScanBlock,
type BlockScanPayload,
type RectificationCaseStage,
@@ -3015,6 +3016,53 @@ export function buildMethodFollowupPlan(input: {
};
}
export function tieBreakGateInput(input: {
evidence: Parameters<typeof buildMethodFollowupPlan>[0]["evidence"];
declinedTopics?: Parameters<typeof buildMethodFollowupPlan>[0]["declinedTopics"];
closedCollectFocuses?: Parameters<typeof buildMethodFollowupPlan>[0]["closedCollectFocuses"];
catalog: Pick<
Parameters<typeof buildMethodFollowupPlan>[0],
| "remainingLayers"
| "remainingSplitTimes"
| "remainingCandidateCount"
| "remainingCredibleRange"
| "answeredProbes"
| "eventProbes"
| "contrastPacket"
| "topCandidateTimes"
| "askedProbeKeys"
| "eventClarificationProbes"
| "evidenceCollectionProbes"
| "precisionStage"
| "nakshatraProbe"
| "oosBlindPrompts"
| "holdoutEvents"
>;
birthDate?: string | null;
accepted?: boolean;
stage?: string | null;
blockScan?: BlockScanPayload | null;
reportedBirthTime?: string | null;
candidateRange?: { start_time?: string; end_time?: string } | null;
}): Parameters<typeof tieBreakPersonalityFollowup>[0] & { accepted?: boolean } {
return {
evidence: input.evidence,
declinedTopics: input.declinedTopics ?? [],
closedCollectFocuses: input.closedCollectFocuses ?? input.declinedTopics ?? [],
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
...input.catalog,
birthDate: input.birthDate ?? null,
accepted: input.accepted === true,
...followupCaseArgs({
stage: input.stage,
blockScan: input.blockScan,
reportedBirthTime: input.reportedBirthTime,
candidateRange: input.candidateRange,
}),
};
}
export function tieBreakPersonalityFollowup(
input: Parameters<typeof buildMethodFollowupPlan>[0],
): MethodFollowup | null {
@@ -0,0 +1,64 @@
import { parseTurnQuestion, persistedOfferFromTurn, type TurnQuestion } from "./rectification-agentic/v9/turn-question.ts";
export type SnapshotTurnMessage = {
role: "assistant" | "user";
turnId?: string;
text: string;
renderKey: string;
question?: TurnQuestion;
candidateOffer?: { resultId: string };
};
export function mergeTurnQuestions<T extends SnapshotTurnMessage>(
current: T[],
turns: readonly unknown[],
): T[] {
const byId = new Map<string, { question: TurnQuestion | null; offerResultId: string | null }>();
for (const item of turns) {
if (!item || typeof item !== "object") continue;
const turn = item as { id?: unknown; question?: unknown; offer_result_id?: unknown };
if (typeof turn.id !== "string") continue;
byId.set(turn.id, {
question: parseTurnQuestion(turn.question),
offerResultId: typeof turn.offer_result_id === "string" ? turn.offer_result_id : null,
});
}
return current.map((message) => {
if (!message.turnId || !byId.has(message.turnId)) return message;
const next = byId.get(message.turnId);
const question = next?.question ?? undefined;
return {
...message,
question: question ?? undefined,
candidateOffer: persistedOfferFromTurn(
next?.offerResultId,
message.candidateOffer,
true,
),
};
});
}
export function appendUnseenAssistantTurns<T extends SnapshotTurnMessage>(
current: T[],
extras: readonly T[],
): T[] {
const known = new Set(current.flatMap((message) => message.turnId ? [message.turnId] : []));
const incoming = extras.filter((message) => (
message.role === "assistant"
&& message.turnId
&& !known.has(message.turnId)
));
return incoming.length ? [...current, ...incoming] : current;
}
export function applySnapshotTurnsToMessages<T extends SnapshotTurnMessage>(
current: T[],
turns: readonly unknown[],
extrasFromTurns: (turns: readonly unknown[]) => T[],
): T[] {
return mergeTurnQuestions(
appendUnseenAssistantTurns(current, extrasFromTurns(turns)),
turns,
);
}
@@ -321,10 +321,11 @@ export function rectificationQuestionGapState(input: RectificationQuestionGapInp
const retryGate = input.retryAttempts < limit ? "preparing" : "unavailable";
if (!input.snapshotLoaded) return retryGate;
if (!input.resumableCase) return "idle";
if (input.liveQuestionVisible || input.offerAwaitingReader) return "idle";
if (input.liveQuestionVisible) return "idle";
if (interviewDeliveredGap(input)) return "delivered";
if (input.collectWaiting) return "collect_waiting";
if (input.questionPersisted) return "persisted_question";
if (input.offerAwaitingReader) return "idle";
if (input.nextUserActionId === "start_consultation") return "verified_idle";
if (input.questionLoadFailed) return "unavailable";
// Either the snapshot names no question, or it names one that no settled
@@ -621,7 +621,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
// 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /applyLiveCandidateOffer/);
assert.match(chat, /persistedOfferFromTurn/);
// 原值: chat 源码含 persistedOfferFromTurnmergeTurnQuestions 写在组件内)
// 新值: 合并抽到 rectification-snapshot-messages.ts,仍调用 persistedOfferFromTurn
// 原因: BUG-685 要把新 turn 并进对话区,合并逻辑必须可单测
const snapshotMessages = readFileSync(new URL("../src/lib/rectification-snapshot-messages.ts", import.meta.url), "utf8");
assert.match(snapshotMessages, /persistedOfferFromTurn/);
assert.match(chat, /applySnapshotTurnsToMessages|mergeTurnQuestions/);
assert.match(chat, /resolveSelectionCardMessageKey/);
assert.match(chat, /keepSelectionCardsWhileBusy/);
assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/);
@@ -835,7 +835,11 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
assert.match(title, /^目前范围 04:4805:07(对照了 4 件经历)$/);
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
assert.match(delivery.narrow_hint ?? "", /现在还剩 04:4805:07 里 6 个候选/);
assert.match(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
// 原值: /还能再收窄:如果记得/(忽略全部拒答,仍列出可收窄的线)
// 新值: 全部定向补事已拒答,收口「能问的都问完了」
// 原因: BUG-687
assert.match(delivery.narrow_hint ?? "", /能问的都问完了/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/);
const publicAction = publicNextAction(decision);
@@ -871,7 +875,7 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
userId: USER_ID,
caseId: CASE_ID,
});
assert.match(idle.hostNarration ?? "", /目前范围|还能再收窄/);
assert.match(idle.hostNarration ?? "", /目前范围|能问的都问完了/);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/);
assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/);
});
@@ -205,7 +205,11 @@ test("personality D9/D10 stay out of the scoring pool until the range-card opt-i
},
tieBreakAvailable: tieBreakPersonalityAvailable(planInput),
});
assert.match(delivery.narrow_hint ?? "", /还能再收窄/);
// 原值: /还能再收窄/rangeNarrowHint 忽略拒答,仍列出搬家/财务)
// 新值: 拒答后不再列出那些线;此 fixture 剩余线都已覆盖或拒答,收口「能问的都问完了」
// 原因: BUG-687 交付文案不得列出已关闭的线
assert.match(delivery.narrow_hint ?? "", /能问的都问完了/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /搬家|收入/);
assert.doesNotMatch(delivery.narrow_hint ?? "", SPLIT_ENDPOINT_PHRASE);
assert.equal(delivery.tie_break_available, true);
const incompleteScan = rangeDeliveryForSnapshot({
@@ -0,0 +1,344 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
decideRectification,
publicCanAdopt,
sessionOutcomeAllowsDelivery,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { rangeNarrowHint } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
tieBreakGateInput,
tieBreakPersonalityAvailable,
tieBreakPersonalityFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import { applySnapshotTurnsToMessages } from "../src/lib/rectification-snapshot-messages.ts";
import type { TurnQuestion } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import { alreadyDelivered, markDeliveryTurn, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
function contrastProbe(layer: "d9" | "d10") {
const signs = layer === "d9" ? ["巨蟹座", "狮子座"] as const : ["狮子座", "处女座"];
return {
probeId: `contrast:varga.${layer}.${signs[0]}/${signs[1]}`,
candidateSetVersion: "04:48-05:07",
question: layer === "d9"
? "亲密关系里更接近下面哪一种相处方式?"
: "平时做事,你更接近下面哪一种?",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["04:53"], conflictsCandidateIds: ["05:06"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:06"], conflictsCandidateIds: ["04:53"] },
],
candidateSplitHash: `varga.${layer}.${signs[0]}/${signs[1]}`,
informationGain: layer === "d9" ? 1.4 : 0.9,
sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }],
domain: layer === "d9" ? "relationship" : "career",
year: null,
semanticKey: `varga.${layer}.${signs[0]}/${signs[1]}`,
choiceKind: "varga_style" as const,
styleOptions: [
{ label: "相处里更在意照顾对方的感受", answerClass: "yes" as const, sign: signs[0] },
{ label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: signs[1] },
],
};
}
const D9 = contrastProbe("d9");
const D10 = contrastProbe("d10");
function planInput(askedProbeKeys: readonly string[] = []) {
return {
evidence: [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "上大学",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "入职实习",
},
{
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "确定关系",
},
],
declinedTopics: [],
sessionOutcome: "discriminate_candidates" as const,
candidatesSeparated: false,
remainingLayers: ["d9", "d10"],
remainingSplitTimes: ["04:48", "05:07"] as const,
remainingCandidateCount: 4,
topCandidateTimes: ["04:48", "04:53", "05:06", "05:07"],
askedProbeKeys,
contrastPacket: {
candidateSetVersion: "04:48-05:07",
vargaDifferences: [],
probes: [D9, D10],
},
};
}
function tiedDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 16 },
{ time: "05:00", score: 16 },
{ time: "05:06", score: 15 },
{ time: "04:51", score: 14 },
{ time: "04:59", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "unavailable",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
...overrides,
});
}
test("a tie-break snapshot turn is merged into messages with its question", () => {
type Message = {
role: "assistant";
text: string;
renderKey: string;
turnId: string;
question?: TurnQuestion;
};
const current: Message[] = [{
role: "assistant",
text: "目前范围 04:4805:07",
renderKey: "existing",
turnId: "turn-1",
}];
const turns = [
{ id: "turn-1", role: "assistant", text: "目前范围 04:4805:07" },
{
id: "turn-2",
role: "assistant",
text: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
question: {
focus_id: "focus-d9",
question_id: "varga.d9.巨蟹座/狮子座",
kind: "choice",
prompt: D9.question,
options: [
{ key: "A", label: "相处里更在意照顾对方的感受" },
{ key: "B", label: "习惯带头,也不排斥站到台前" },
{ key: "C", label: "一时说不好" },
{ key: "D", label: "先这样" },
],
status: "active",
answer_option: null,
probe_id: D9.probeId,
},
},
];
const next = applySnapshotTurnsToMessages(current, turns, (incoming): Message[] => (
incoming.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const turn = item as { id?: string; role?: string; text?: string; question?: unknown };
if (turn.role !== "assistant" || typeof turn.id !== "string") return [];
return [{
role: "assistant" as const,
text: turn.text ?? "",
renderKey: turn.id,
turnId: turn.id,
}];
})
));
assert.equal(next.length, 2);
assert.equal(next[1]?.turnId, "turn-2");
assert.equal(next[1]?.text, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck);
assert.equal(next[1]?.question?.prompt, D9.question);
assert.equal(next[1]?.question?.kind, "choice");
});
test("a delivery card plus an unanswered focus is not an idle gap", () => {
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: false,
questionPersisted: true,
offerAwaitingReader: true,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"persisted_question",
);
});
test("a first-place tie with unused personality questions holds delivery", () => {
const decision = tiedDecision({ pendingTieBreak: true });
assert.equal(decision.heldForTieBreak, true);
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(publicCanAdopt(decision), false);
const followup = tieBreakPersonalityFollowup(planInput());
assert.equal(followup?.choice_kind, "varga_style");
assert.match(followup?.semantic_key ?? "", /varga\.d9/);
});
test("the same tie delivers after both personality questions are used", () => {
const bothAsked = planInput([D9.semanticKey, D10.semanticKey]);
assert.equal(tieBreakPersonalityAvailable(bothAsked), false);
const decision = tiedDecision({ pendingTieBreak: false });
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
assert.equal(rangeDeliveryForSnapshot({
tieBreakAvailable: tieBreakPersonalityAvailable(bothAsked),
tieBreakUsed: true,
stillTied: decision.separation.lead <= 1,
}).tie_break_available, false);
assert.equal(
rangeDeliveryForSnapshot({
tieBreakAvailable: false,
tieBreakUsed: true,
stillTied: true,
}).tie_break_note,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
);
});
test("a two-point lead does not insert personality questions before delivery", () => {
const decision = tiedDecision({
pendingTieBreak: true,
candidateScores: [
{ time: "04:53", score: 16 },
{ time: "05:00", score: 14 },
{ time: "05:06", score: 13 },
],
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("holding for a tie-break does not mark a delivery turn", () => {
resetDeliveryTurnGuardForTests();
const decision = tiedDecision({ pendingTieBreak: true });
assert.equal(decision.heldForTieBreak, true);
markDeliveryTurn({ caseId: "case-1", resultId: "result-1" });
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-1",
hasUserMessage: false,
}), true);
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-2",
hasUserMessage: false,
}), false);
resetDeliveryTurnGuardForTests();
});
test("range copy omits declined lines and closes when none remain", () => {
const layers = ["d9", "d2", "d7", "d4"];
const evidence = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
},
];
const declined = [
{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship" },
{ questionId: "collect:targeted:finance", status: "declined", target_kind: "targeted:finance" },
{ questionId: "collect:targeted:family", status: "declined", target_kind: "targeted:family" },
];
const openCopy = rangeNarrowHint(layers, evidence, [], ["04:48", "05:07"], 5, ["04:48", "05:07"]);
assert.match(openCopy, /结婚/);
assert.match(openCopy, /收入/);
assert.match(openCopy, /添丁/);
const copy = rangeNarrowHint(layers, evidence, declined, ["04:48", "05:07"], 5, ["04:48", "05:07"]);
assert.doesNotMatch(copy, /结婚/);
assert.doesNotMatch(copy, /收入/);
assert.doesNotMatch(copy, /添丁/);
assert.match(copy, /能问的都问完了/);
});
test("GET availability and the POST gate share one input builder", () => {
const openPlan = planInput();
const open = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: openPlan.askedProbeKeys,
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
const closed = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: [D9.semanticKey, D10.semanticKey],
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
assert.equal(tieBreakPersonalityAvailable(open), true);
assert.equal(Boolean(tieBreakPersonalityFollowup(open)?.choice_kind === "varga_style"), true);
assert.equal(tieBreakPersonalityAvailable(closed), false);
assert.equal(tieBreakPersonalityFollowup(closed), null);
});