import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { inspectDiscriminatorProbes, withCompletedContrastOptions, type CandidateDiscriminatorProbe, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; import { contrastPacketFromDossier, decideFromDossier, followupAsksRenderableDiscriminator, rectificationFollowupCatalog, type DecisionDossier, } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; const CAREER_2020_04: ConflictProbe = { id: "probe:career.2020.04.dasha_boundary", semantic_key: "career.2020.04.dasha_boundary", candidate_split_hash: "career.2020.04", domain: "career", year: 2020, question: "2020 年 4 月前后有没有入职或换工作?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.9, source: "dasha_boundary", choice_kind: "existence", }; const REL_2024_05: ConflictProbe = { id: "probe:relationship.2024.05.dasha_boundary", semantic_key: "relationship.2024.05.dasha_boundary", candidate_split_hash: "relationship.2024.05", domain: "relationship", year: 2024, question: "2024 年 5 月前后有没有开始一段认真关系?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.85, source: "dasha_boundary", choice_kind: "existence", }; const D24: ConflictProbe = { id: "contrast:varga.d24.05:00/05:10", semantic_key: "varga.d24.05:00/05:10", candidate_split_hash: "varga.d24.05:00/05:10", domain: "education", year: 0, question: "有没有学业或考试发挥明显失常、压力特别大的时候?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 2.5, source: "varga_contrast", choice_kind: "event_quality", }; const D12: ConflictProbe = { id: "contrast:varga.d12.05:00/05:10", semantic_key: "varga.d12.05:00/05:10", candidate_split_hash: "varga.d12.05:00/05:10", domain: "family", year: 0, question: "家里有没有结婚、添丁或住院这类事?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.8, source: "varga_contrast", choice_kind: "existence", }; const D9_STYLE_NO_OPTIONS: ConflictProbe = { id: "contrast:varga.d9.巨蟹座/狮子座", semantic_key: "varga.d9.巨蟹座/狮子座", candidate_split_hash: "varga.d9.巨蟹座/狮子座", domain: "relationship", year: 0, question: "亲密关系里更接近下面哪一种相处方式?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.4, source: "varga_contrast", choice_kind: "varga_style", }; const D10_STYLE_NO_OPTIONS: ConflictProbe = { id: "contrast:varga.d10.天秤座/天蝎座", semantic_key: "varga.d10.天秤座/天蝎座", candidate_split_hash: "varga.d10.天秤座/天蝎座", domain: "career", year: 0, question: "平时做事更接近下面哪一种职责风格?", candidate_ids: ["05:00", "05:10"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 1.2, source: "varga_contrast", choice_kind: "varga_style", }; const FAMILY_2021_COLLECT = { year: 2021, year_label: "2021 年前后", domain: "family" as const, event_family: "家人结婚、添丁或住院", source: "age_band" as const, tracks: ["vimshottari", "narayana"] as const, tracks_agree: false, unique_minute_claim: false as const, user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", role: "collect" as const, phase: "evidence_collection" as const, information_gain: 0, semantic_key: "family.2021", candidate_split_hash: "family:2021", candidate_ids: [] as const, expected_outcomes: [] as const, choice_kind: "existence" as const, }; const EVIDENCE = [ { id: "e-career-entry", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: null, eventKind: "career_entry", }, { id: "e-career-exit", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-10-01", occurredTo: null, eventKind: "career_exit", }, { id: "e-rel-start", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2024-05-01", occurredTo: null, eventKind: "relationship_start", }, { id: "e-rel-end", status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: null, eventKind: "relationship_end", }, ] as const; function contrastFromConflict(probe: ConflictProbe): CandidateDiscriminatorProbe { return { probeId: probe.id, candidateSetVersion: "05:00-05:10", question: probe.question, expectedOutcomes: probe.expected_outcomes.map((row) => ({ outcomeId: row.answer_class, supportsCandidateIds: row.supports, conflictsCandidateIds: row.conflicts, })), candidateSplitHash: probe.candidate_split_hash, informationGain: probe.information_gain, sourceFeatures: [{ technique: probe.domain, calculationResultId: null }], domain: probe.domain, year: probe.year > 0 ? probe.year : null, semanticKey: probe.semantic_key, choiceKind: probe.choice_kind, }; } function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType } { const state = buildInferenceState({ range_start: "05:00", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 18 }, { id: "05:10", time: "05:10", relative_support: 16 }, ], events: [ { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, ], probes: [CAREER_2020_04, REL_2024_05, D24, D12, D9_STYLE_NO_OPTIONS, D10_STYLE_NO_OPTIONS], answered_probes: [ { probe_id: CAREER_2020_04.id, semantic_key: CAREER_2020_04.semantic_key, candidate_split_hash: CAREER_2020_04.candidate_split_hash, answer_class: "yes", classified_from: "choice", }, { probe_id: REL_2024_05.id, semantic_key: REL_2024_05.semantic_key, candidate_split_hash: REL_2024_05.candidate_split_hash, answer_class: "yes", classified_from: "choice", }, ], }); const dossier: DecisionDossier = { evidence: EVIDENCE, conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, latestResult: { resultId: "55555555-5555-4555-8555-555555555555", candidates: state.candidates.map((item) => ({ candidateId: item.id, time: item.time, rank: item.rank, relativeSupport: Math.round(item.posterior_score), })), representativeTime: state.representative_time, evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never), decisionReceipt: { inference_state: state, evidence_collection_probes: [FAMILY_2021_COLLECT], }, }, case: { acceptedTime: null }, }; return { dossier, state }; } test("deadlock case: dated pool empty delivers instead of a D9/D10 style card", () => { const { dossier } = deadlockDossier(); const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier)); assert.equal(inspected.selected?.semanticKey, D9_STYLE_NO_OPTIONS.semantic_key); const d9 = withCompletedContrastOptions(contrastFromConflict(D9_STYLE_NO_OPTIONS)); const d10 = withCompletedContrastOptions(contrastFromConflict(D10_STYLE_NO_OPTIONS)); assert.equal(d9.ok, true); assert.equal(d10.ok, true); if (d9.ok) assert.equal(d9.probe.choiceKind, "varga_style"); if (d10.ok) assert.equal(d10.probe.choiceKind, "varga_style"); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, }); assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.equal( plan.dropped_probes.some((item) => item.semantic_key.startsWith("varga.d9.") || item.semantic_key.startsWith("varga.d10.")), true, JSON.stringify(plan.dropped_probes), ); const decision = decideFromDossier(dossier); // 原值: ask_candidate_discriminator,出 D9/D10 性格卡 // 新值: 带年月池空即交付,性格题 yearless_deferred // 原因: BUG-651 性格题不得挡在结果前面 assert.ok( decision.nextAction === "offer_provisional_range" || decision.nextAction === "ready_to_adopt" || decision.nextAction === "complete_with_range" || decision.nextAction === "ask_fact_collection", decision.nextAction, ); assert.notEqual(decision.sessionOutcome, "discriminate_candidates"); assert.equal(decision.canConfirmExactMinute, false); }); test("varga_style probes missing style_options complete the same way in inspect and followup ranking", () => { const probe = contrastFromConflict(D9_STYLE_NO_OPTIONS); const inspected = inspectDiscriminatorProbes({ candidateSetVersion: "05:00-05:10", vargaDifferences: [], probes: [probe], }); assert.ok(inspected.selected); assert.equal(inspected.selected?.choiceKind, "varga_style"); assert.equal(inspected.dropped.length, 0); const plan = buildMethodFollowupPlan({ evidence: EVIDENCE, sessionOutcome: "discriminate_candidates", candidatesSeparated: false, contrastPacket: { candidateSetVersion: "05:00-05:10", vargaDifferences: [], probes: [probe], }, }); assert.notEqual(plan.next_followup?.semantic_key, probe.semanticKey); assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); // 原值: 补全 style_options 后仍把 D9 当下一道区分题 // 新值: D9 记 yearless_deferred,不再占用 discriminator // 原因: BUG-651 assert.equal( plan.dropped_probes.some((item) => ( item.semantic_key === probe.semanticKey && item.reason === "yearless_deferred" )), true, JSON.stringify(plan.dropped_probes), ); }); test("method coverage incomplete still asks a renderable D9 style card", () => { const { dossier } = deadlockDossier(); const decision = decideFromDossier(dossier); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: [], sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, }); assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered"); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); // 原值: 方法覆盖未齐仍问 D9 性格卡 // 新值: 训练门开、带年月池空即交付 // 原因: BUG-651 性格题不得挡在结果前面 assert.ok( decision.nextAction === "offer_provisional_range" || decision.nextAction === "ready_to_adopt" || decision.nextAction === "complete_with_range" || decision.nextAction === "ask_fact_collection", decision.nextAction, ); assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.equal(decision.probe, null); }); test("clock-key varga_style without signs fail-closes to dated family collect instead of a dead-end discriminator", () => { const d9Clock: ConflictProbe = { ...D9_STYLE_NO_OPTIONS, id: "contrast:varga.d9.05:00/05:10", semantic_key: "varga.d9.05:00/05:10", candidate_split_hash: "varga.d9.05:00/05:10", }; const d10Clock: ConflictProbe = { ...D10_STYLE_NO_OPTIONS, id: "contrast:varga.d10.05:00/05:10", semantic_key: "varga.d10.05:00/05:10", candidate_split_hash: "varga.d10.05:00/05:10", }; const { dossier } = deadlockDossier(); const state = buildInferenceState({ range_start: "05:00", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 18 }, { id: "05:10", time: "05:10", relative_support: 16 }, ], events: [ { id: "e-career-entry", domain: "career", year: 2020, precision: "month" }, { id: "e-career-exit", domain: "career", year: 2020, precision: "month" }, { id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" }, { id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" }, ], probes: [CAREER_2020_04, REL_2024_05, D24, D12, d9Clock, d10Clock], answered_probes: [ { probe_id: CAREER_2020_04.id, semantic_key: CAREER_2020_04.semantic_key, candidate_split_hash: CAREER_2020_04.candidate_split_hash, answer_class: "yes", classified_from: "choice", }, { probe_id: REL_2024_05.id, semantic_key: REL_2024_05.semantic_key, candidate_split_hash: REL_2024_05.candidate_split_hash, answer_class: "yes", classified_from: "choice", }, ], }); const clockDossier: DecisionDossier = { ...dossier, latestResult: { ...dossier.latestResult!, decisionReceipt: { inference_state: state, evidence_collection_probes: [FAMILY_2021_COLLECT], }, }, }; const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier)); assert.equal(inspected.selected, null); assert.equal(inspected.dropped.some((item) => ( item.semantic_key === d9Clock.semantic_key && item.reason === "yearless_ungrounded_contrast" )), true); const d9 = withCompletedContrastOptions(contrastFromConflict(d9Clock)); assert.equal(d9.ok, true); if (d9.ok) assert.equal(d9.probe.choiceKind, "existence"); const catalog = rectificationFollowupCatalog(clockDossier.latestResult, clockDossier.evidence); const plan = buildMethodFollowupPlan({ evidence: clockDossier.evidence, declinedTopics: [], sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, }); assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); // 原值: 落到家人采集 + probe_year 2021 // 新值: 训练门开后不落领域轮转;年龄段年份不进题干 // 原因: 撤回 BUG-642(BUG-648) assert.notEqual(plan.next_followup?.domain, "family"); assert.notEqual(plan.next_followup?.probe_year, 2021); assert.notEqual(plan.next_followup?.semantic_key, d9Clock.semantic_key); assert.notEqual(plan.next_followup?.semantic_key, d10Clock.semantic_key); const decision = decideFromDossier(clockDossier); assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); assert.equal(decision.probe, null); assert.equal(decision.canConfirmExactMinute, false); }); test("unrenderable contrast probes are recorded in dropped_probes instead of being dropped silently", () => { const unrenderable = contrastFromConflict({ ...D9_STYLE_NO_OPTIONS, information_gain: 0, semantic_key: "varga.d9.dropped", candidate_split_hash: "varga.d9.dropped", id: "contrast:varga.d9.dropped", }); const inspected = inspectDiscriminatorProbes({ candidateSetVersion: "05:00-05:10", vargaDifferences: [], probes: [unrenderable], }); assert.equal(inspected.selected, null); assert.equal(inspected.dropped.some((item) => item.semantic_key === unrenderable.semanticKey), true); const plan = buildMethodFollowupPlan({ evidence: EVIDENCE, sessionOutcome: "discriminate_candidates", candidatesSeparated: false, contrastPacket: { candidateSetVersion: "05:00-05:10", vargaDifferences: [], probes: [unrenderable], }, }); assert.equal( plan.dropped_probes.some((item) => item.semantic_key === unrenderable.semanticKey), true, JSON.stringify(plan.dropped_probes), ); }); test("agent message path no longer returns the discriminator dead-end copy", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const fastPath = route.slice( route.indexOf('if (action === "message")'), route.indexOf("const requestTime"), ); assert.doesNotMatch(fastPath, /目前没有可继续区分/); assert.match(route, /nonConvergingRangeNarration/); assert.match(fastPath, /persistNextInterviewIfIdle/); assert.match(fastPath, /!plan\.next_followup/); assert.ok(fastPath.includes("ask_candidate_discriminator")); assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/); assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({")); }); test("decision.probe matches the followup the plan will actually ask", () => { const { dossier } = deadlockDossier(); const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, }); const decision = decideFromDossier(dossier); // 原值: 下一问是 D9/D10,decision.probe 对得上 // 新值: 性格题 deferred,decision.probe 与 next 都不是性格卡 // 原因: BUG-651 assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); assert.equal(decision.probe, null); assert.notEqual(plan.next_followup?.choice_kind, "varga_style"); assert.notEqual(plan.next_followup?.semantic_key, D24.semantic_key); }); test("birthDate gating matches the real followup plan on an under-age event probe", () => { const { dossier: base } = deadlockDossier(); const childhood = { year: 2015, year_label: "2015 年前后", domain: "career" as const, event_family: "入职或职责变化", source: "dasha_activation" as const, tracks: ["vimshottari", "narayana"] as const, tracks_agree: true, unique_minute_claim: false as const, user_meaning: "2015 年前后有没有入职或职责变化?", role: "distinguish" as const, phase: "candidate_discriminator" as const, information_gain: 3.2, semantic_key: "career.2015.dasha_activation", candidate_split_hash: "career.2015", candidate_ids: ["05:00", "05:10"] as const, expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] }, { answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] }, ], style_options: [ { label: "明确发生且时间吻合", answer_class: "yes" }, { label: "发生过但程度较弱", answer_class: "weak_yes" }, { label: "明确没有发生", answer_class: "no" }, { label: "这段记不清楚", answer_class: "unsure" }, ], choice_kind: "existence" as const, }; const dossier: DecisionDossier = { ...base, latestResult: base.latestResult ? { ...base.latestResult, decisionReceipt: { ...(base.latestResult.decisionReceipt ?? {}), discriminating_event_probes: [childhood], }, } : null, }; const birthDate = "2010-01-15"; const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence); const planOpen = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: [], sessionOutcome: "discriminate_candidates", ...catalog, candidatesSeparated: false, }); const planGated = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: [], sessionOutcome: "discriminate_candidates", ...catalog, birthDate, candidatesSeparated: false, }); assert.equal(planOpen.next_followup?.semantic_key, childhood.semantic_key); assert.notEqual(planGated.next_followup?.semantic_key, childhood.semantic_key); const decision = decideFromDossier(dossier, { birthDate }); assert.equal( decision.nextAction === "ask_candidate_discriminator", followupAsksRenderableDiscriminator(planGated.next_followup), ); if (followupAsksRenderableDiscriminator(planGated.next_followup)) { assert.equal(decision.probe?.semanticKey, planGated.next_followup?.semantic_key); } else { assert.equal(decision.nextAction, "ask_fact_collection"); } });