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[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[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"]); // 原值: 空池邀请自由打字 // 新值: 已拒答的线不再列出;D5 未覆盖且未拒绝的健康线仍在池里 // 原因: D3 删除自由文本邀请;BUG-687 不得把已拒答的线再问一遍; // remainingTargetedDomains 不再按分盘层剔除 assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/); assert.doesNotMatch(copy, /问完了|不限领域|确切哪一天/); assert.match(copy, /哪年住院或手术/); const closed = [ ...declined, { questionId: "collect:targeted:health_pressure", status: "declined", target_kind: "targeted:health_pressure", }, ]; const closedCopy = rangeNarrowHint(layers, evidence, closed, ["04:48", "05:07"], 5, ["04:48", "05:07"]); assert.match(closedCopy, /再对照几件经历会更准/); }); 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); });