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
@@ -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);
});