When the seven collect lines are closed, the range card still appears, but the copy asks for one more exact-day event of any kind instead of saying the questions are finished. A new dated event after delivery stales the snapshot so the session does not stay delivered.
409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
|
||
/** Normal convergence: unique first place, methods covered, holdout passed. */
|
||
function convergingDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
|
||
return decideRectification({
|
||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||
methodCoverageAll: true,
|
||
trainingGateOpen: true,
|
||
candidateScores: [
|
||
{ time: "04:53", score: 24 },
|
||
{ time: "05:00", score: 14 },
|
||
{ time: "05:06", score: 13 },
|
||
],
|
||
discriminatorProbe: null,
|
||
holdoutValidation: "passed",
|
||
datedEventCount: 3,
|
||
datedDomainCount: 3,
|
||
targetedCollectExhausted: true,
|
||
refreshExhausted: true,
|
||
tieBreakFollowupReady: true,
|
||
pendingTieBreak: 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:48–05:07",
|
||
renderKey: "existing",
|
||
turnId: "turn-1",
|
||
}];
|
||
const turns = [
|
||
{ id: "turn-1", role: "assistant", text: "目前范围 04:48–05: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 converging case with unused personality questions holds delivery", () => {
|
||
const decision = convergingDecision();
|
||
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 still asks personality questions before delivery", () => {
|
||
const decision = convergingDecision();
|
||
assert.equal(decision.heldForTieBreak, true);
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
|
||
assert.equal(tieBreakPersonalityFollowup(planInput())?.choice_kind, "varga_style");
|
||
});
|
||
|
||
test("pending style flag without a renderable followup delivers", () => {
|
||
const decision = convergingDecision({
|
||
pendingTieBreak: true,
|
||
tieBreakFollowupReady: false,
|
||
});
|
||
assert.equal(decision.heldForTieBreak ?? false, false);
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||
});
|
||
|
||
test("an exhausted first-place tie delivers even if style questions remain", () => {
|
||
const decision = tiedDecision({
|
||
pendingTieBreak: true,
|
||
tieBreakFollowupReady: true,
|
||
});
|
||
assert.equal(decision.heldForTieBreak ?? false, false);
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||
});
|
||
|
||
test("already held once without a followup delivers", () => {
|
||
const decision = convergingDecision({
|
||
pendingTieBreak: true,
|
||
tieBreakFollowupReady: true,
|
||
tieBreakAlreadyHeld: true,
|
||
});
|
||
assert.equal(decision.heldForTieBreak ?? false, false);
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||
});
|
||
|
||
test("a layer with only one rising sign does not occupy the style slot", () => {
|
||
const onlyD10 = {
|
||
...planInput(),
|
||
remainingLayers: ["d10"],
|
||
contrastPacket: {
|
||
candidateSetVersion: "04:48-05:07",
|
||
vargaDifferences: [],
|
||
probes: [D10],
|
||
},
|
||
};
|
||
const followup = tieBreakPersonalityFollowup(onlyD10);
|
||
assert.equal(followup?.choice_kind, "varga_style");
|
||
assert.match(followup?.semantic_key ?? "", /varga\.d10/);
|
||
assert.doesNotMatch(followup?.semantic_key ?? "", /varga\.d9/);
|
||
});
|
||
|
||
test("holding for a tie-break does not mark a delivery turn", () => {
|
||
resetDeliveryTurnGuardForTests();
|
||
const decision = convergingDecision();
|
||
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"]);
|
||
// 原值: /结婚|收入|添丁/ 且 /能问的都问完了/
|
||
// 新值: 不得再写「哪年结婚或订婚」这类还开着的线;收口改为不限领域、先要确切日期
|
||
// 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b
|
||
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
|
||
assert.doesNotMatch(copy, /问完了/);
|
||
assert.match(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);
|
||
});
|