import { describe, expect, it } from "vitest"; import { buildEpisodeAwareGraphPrompt } from "@/lib/graph/prompt-builder-episode.js"; import { buildGraphUpdatePrompt } from "@/lib/graph/prompt-builder.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; // ── Test fixture: multi-turn prepared episode ─────────────── function makeEpisodeFixture() { const situationGraph = makeGraph({ centralStatement: "Market share declining in core segment", nodes: [ makeNode({ id: "core-shrink-unknown", label: "Core segment shrink rate", description: "Need to determine if core market is genuinely shrinking or if this is measurement artifact", kind: "unknown", status: "unknown", confidence: "medium", }), makeNode({ id: "competitor-move-obs", label: "Competitor pricing move", description: "Competitor X reduced prices by 15%", kind: "observation", status: "supported", confidence: "high", }), ], edges: [ makeEdge({ id: "e1", fromNodeId: "competitor-move-obs", toNodeId: "core-shrink-unknown", relationship: "supports", confidence: "medium", description: "Observation informs unknown", }), ], activeUnknownNodeId: "core-shrink-unknown", resolvedNodeIds: [], currentSummary: "Nodes: 1 observation, 1 unknown | Edges: 1 total | Unknowns: 1 unresolved", }); const turns = [ { contributionId: "contrib-a01", sequence: 1, question: "Has the core market volume actually decreased, or is it a measurement artifact?", answer: "Our internal data shows core volume dropped from 12,400 to 11,800 units over Q3.", }, { contributionId: "contrib-a02", sequence: 2, question: "Does Competitor X's pricing explain the full decline?", answer: "Competitor X's price cut correlates with a 40% shift in our B2B accounts. We estimate ~75% of the decline maps to this competitive move.", }, { contributionId: "contrib-b01", sequence: 3, question: "Is core quality perception affected?", answer: "We have no evidence of quality degradation — returns rate is stable at 0.3%.", }, ]; const eligibleCanonicalFindings = [ { findingId: "find-core-drop", contributionId: "contrib-a01", proposition: "Core market volume declined from 12,400 to 11,800 units in Q3", sourceObservation: "Raw internal data shows drop from 12,400 to 11,800 units", endorsement: null, // working premise — not explicit agreement }, { findingId: "find-competitive-shift", contributionId: "contrib-a02", proposition: "Competitor X pricing accounts for ~75% of core decline via B2B account shift", sourceObservation: "Model inference linking price to volume", endorsement: "agree", // explicitly endorsed by user }, { findingId: "find-quality-stable", contributionId: "contrib-b01", proposition: "Core quality perception remains stable — returns rate unchanged at 0.3%", sourceObservation: "Original observation from Q2 analysis", endorsement: null, // working premise — not explicit agreement }, ]; const excludedFindingProvenance = [ { findingId: "find-excluded-supply", contributionId: "contrib-a01", proposition: "SUPPLY-chain disruption partially contributed to volume loss (excluded from current reasoning)", sourceObservation: "Initial hypothesis later determined irrelevant", disposition: "not_relevant", }, ]; return { situationGraph, targetNodeId: "core-shrink-unknown", turns, eligibleCanonicalFindings, excludedFindingProvenance }; } // ── Helper: extract provider-active content (everything before excluded section) ── function getProviderContent(prompt) { const excludedSection = prompt.indexOf("## Excluded Findings"); return excludedSection > 0 ? prompt.slice(0, excludedSection) : prompt; } // ==================================================================== // Case 1: Ordered multi-turn evidence // ==================================================================== describe("Episode reasoning input — ordered multi-turn evidence", () => { const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() }); it("includes all three turns in sequence order", () => { expect(prompt).toContain("Turn 1 [contributionId: contrib-a01]"); expect(prompt).toContain("Turn 2 [contributionId: contrib-a02]"); expect(prompt).toContain("Turn 3 [contributionId: contrib-b01]"); // Verify ordering: Turn 1 appears before Turn 2 before Turn 3 const t1 = prompt.indexOf("Turn 1 [contributionId: contrib-a01]"); const t2 = prompt.indexOf("Turn 2 [contributionId: contrib-a02]"); const t3 = prompt.indexOf("Turn 3 [contributionId: contrib-b01]"); expect(t1).toBeLessThan(t2); expect(t2).toBeLessThan(t3); }); it("includes verbatim answers from each turn in provider-active content", () => { const providerContent = getProviderContent(prompt); expect(providerContent).toContain("core volume dropped from 12,400 to 11,800 units over Q3"); expect(providerContent).toContain("40% shift in our B2B accounts"); expect(providerContent).toContain("returns rate is stable at 0.3%"); }); it("includes ordered investigation turns header", () => { expect(prompt).toContain("### Ordered Investigation Turns (ordered context)"); }); }); // ==================================================================== // Case 2: Raw evidence distinction // ==================================================================== describe("Episode reasoning input — raw evidence distinction", () => { const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() }); const providerContent = getProviderContent(prompt); it("clearly labels answers as verbatim (raw evidence)", () => { expect(providerContent).toContain("Verbatim Answer"); expect(providerContent).toContain("not raw user statements"); }); it("labels eligible findings as canonical propositions derived from evidence", () => { expect(providerContent).toContain("Eligible Canonical Findings Derived During Investigation"); expect(providerContent).toContain("canonical propositions derived from the investigation turns above"); }); it("does NOT conflate Finding proposition with raw user statement", () => { // The prompt must distinguish, not blur: expect(providerContent).not.toContain("user said"); expect(providerContent).not.toContain("raw evidence" + "prop"); // would blur the distinction // And should clearly separate the two concepts: expect(providerContent).toContain("Proposition:"); expect(providerContent).toContain("Verbatim Answer:"); }); it("marks source of each proposition as derived, not raw", () => { expect(providerContent).toContain("derived from the investigation"); }); }); // ==================================================================== // Case 3: Null versus agree distinction // ==================================================================== describe("Episode reasoning input — null versus agree endorsement", () => { const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() }); const providerContent = getProviderContent(prompt); it("null is labeled as working premise, not explicit agreement", () => { // Both findings with null endorsement must show the working premise label expect(providerContent).toContain("null (working premise, not explicit agreement)"); }); it("agree is labeled as explicitly endorsed proposition", () => { expect(providerContent).toContain("agree (explicitly endorsed proposition)"); }); it("both semantics are distinguishable in provider-active content", () => { // The two distinct labels must appear const nullLabel = "null (working premise, not explicit agreement)"; const agreeLabel = "agree (explicitly endorsed proposition)"; expect(providerContent).toContain(nullLabel); expect(providerContent).toContain(agreeLabel); // And the endorsement semantics section exists: expect(providerContent).toContain("### Endorsement Semantics"); }); }); // ==================================================================== // Case 4: Excluded evidence absent from provider-active content // ==================================================================== describe("Episode reasoning input — excluded evidence absent", () => { const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() }); const providerContent = getProviderContent(prompt); it("excluded proposition does NOT appear in provider-active content", () => { // The excluded finding's proposition must be absent from the provider-visible section expect(providerContent).not.toContain("SUPPLY-chain disruption"); }); it("prepared episode retains excluded provenance (data layer)", () => { // Excluded findings are preserved in the prepared episode contract, // they just don't appear in provider-active reasoning content. const episode = makeEpisodeFixture(); expect(episode.excludedFindingProvenance).toHaveLength(1); expect(episode.excludedFindingProvenance[0].proposition).toContain("SUPPLY-chain disruption"); }); it("excluded proposition absent from both prompt and provider content", () => { // Since excluded findings are intentionally NOT serialized into the prompt, // the excluded proposition is absent everywhere in the generated prompt. expect(prompt).not.toContain("SUPPLY-chain disruption"); const providerContent = getProviderContent(prompt); expect(providerContent).not.toContain("SUPPLY-chain disruption"); }); }); // ==================================================================== // Case 5: Corrected proposition — provider receives corrected form // ==================================================================== describe("Episode reasoning input — corrected proposition", () => { function makeCorrectedEpisode() { const situationGraph = makeGraph({ centralStatement: "Baseline scenario", nodes: [makeNode({ id: "a-unknown", label: "A Unknown", kind: "unknown", status: "unknown" })], edges: [], activeUnknownNodeId: "a-unknown", resolvedNodeIds: [], currentSummary: "Test", }); return { situationGraph, targetNodeId: "a-unknown", turns: [ { contributionId: "contrib-c01", sequence: 1, question: "Is A true?", answer: "Yes." }, ], eligibleCanonicalFindings: [ { findingId: "find-corrected", contributionId: "contrib-c01", proposition: "Corrected canonical text — user fixed the observation", sourceObservation: "Original model observation that was incorrect", endorsement: null, }, ], excludedFindingProvenance: [], }; } const prompt = buildEpisodeAwareGraphPrompt({ episode: makeCorrectedEpisode() }); const providerContent = getProviderContent(prompt); it("provider-active evidence uses the corrected canonical proposition", () => { expect(providerContent).toContain("Corrected canonical text — user fixed the observation"); }); it("sourceObservation does NOT need to appear in provider-active content", () => { // The prompt builder intentionally omits sourceObservation from active reasoning // because corrected proposition is the current canonical authority. expect(providerContent).not.toContain("Original model observation that was incorrect"); }); it("corrected Finding appears under eligible findings, not excluded", () => { expect(prompt).toContain("## Eligible Canonical Findings Derived During Investigation"); }); }); // ==================================================================== // Case 6: Existing GraphUpdateProposal contract still targeted // ==================================================================== describe("Episode reasoning input — proposal contract preserved", () => { const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() }); it("required JSON fields are present", () => { expect(prompt).toContain("addedNodes"); expect(prompt).toContain("updatedNodes"); expect(prompt).toContain("addedEdges"); expect(prompt).toContain("removedEdgeIds"); expect(prompt).toContain("resolvedUnknownNodeIds"); expect(prompt).toContain("affectedNodeIds"); expect(prompt).toContain("selectedQuestion"); expect(prompt).toContain("answerMeaning"); expect(prompt).toContain("structuralActionRequired"); }); it("enum values present", () => { expect(prompt).toContain("observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion | option"); expect(prompt).toContain("known | unknown | provisional | supported | weakened | contradicted | resolved"); }); it("proposal rules are intact", () => { expect(prompt).toContain("Propose changes only. Never return a replacement graph."); expect(prompt).toContain("answerMeaning.userSupportedMeaning must state only what the user's answer directly supports."); expect(prompt).toContain("MUST express its effect through structural mutation"); }); it("Decision Sufficiency Rule is present", () => { expect(prompt).toContain("## Decision Sufficiency Rule"); }); it("output contract reminder is present", () => { expect(prompt).toContain("Return one JSON object only"); expect(prompt).toContain("Never include a full graph"); }); }); // ==================================================================== // Case 7: Single-turn regression — existing path intact // ==================================================================== describe("Episode reasoning input — single-turn regression", () => { it("buildGraphUpdatePrompt still accepts previousQuestion/answer and produces same structure", () => { const prompt = buildGraphUpdatePrompt({ situationGraph: makeGraph({ centralStatement: "test", nodes: [makeNode({ id: "n1", label: "A", kind: "unknown", status: "unknown" })], edges: [], activeUnknownNodeId: "n1", resolvedNodeIds: [], currentSummary: "test graph", }), previousQuestion: "What is A?", answer: "It's B.", }); // Essential single-turn sections must still be present: expect(prompt).toContain("## Previous Selected Question"); expect(prompt).toContain("What is A?"); expect(prompt).toContain("## User Answer"); expect(prompt).toContain("It's B."); // GraphUpdateProposal contract fields must still be present: expect(prompt).toContain("addedNodes"); expect(prompt).toContain("updatedNodes"); expect(prompt).toContain("answerMeaning"); // Episode-aware sections must NOT appear in single-turn path: expect(prompt).not.toContain("## Ordered Investigation Turns"); expect(prompt).not.toContain("## Eligible Canonical Findings Derived During Investigation"); expect(prompt).not.toContain("Completed Focused Investigation Evidence"); }); it("buildGraphUpdatePrompt still contains existing rules", () => { const prompt = buildGraphUpdatePrompt({ situationGraph: makeGraph({ centralStatement: "test", nodes: [makeNode({ id: "n1", label: "A", kind: "unknown", status: "unknown" })], edges: [], activeUnknownNodeId: "n1", resolvedNodeIds: [], currentSummary: "test graph", }), previousQuestion: "Q?", answer: "A.", }); // These are established rules that must survive unchanged: expect(prompt).toContain("MUST express its effect through structural mutation"); expect(prompt).toContain("rule #6"); expect(prompt).toContain("Do not add duplicate unknowns"); }); });