From 9362c78f8153c41976b1515b944b1dc9afa55f6b Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 6 Oct 2026 17:06:44 +0100 Subject: [PATCH] refactor(confidence-engine): extract focused deconstruction seam --- .../deconstruct/route.js | 65 +++----- lib/graph/focused-investigation.js | 51 +++++++ tests/graph/focused-deconstruction.test.mjs | 139 ++++++++++++++++++ 3 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 tests/graph/focused-deconstruction.test.mjs diff --git a/app/api/focused-investigation/deconstruct/route.js b/app/api/focused-investigation/deconstruct/route.js index e9ce041..b9be60a 100644 --- a/app/api/focused-investigation/deconstruct/route.js +++ b/app/api/focused-investigation/deconstruct/route.js @@ -1,9 +1,5 @@ import { getProvider, getProviderModelName } from "@/lib/llm/provider"; -import { - buildFocusedDeconstructPrompt, - focusedDeconstructJsonSchema, - validateFocusedDeconstructSchema, -} from "@/lib/graph/focused-investigation"; +import { focusedDeconstruct } from "@/lib/graph/focused-investigation"; import { withAuthenticatedApi } from "@/lib/supabase/api-auth.js"; async function post(request) { @@ -67,14 +63,6 @@ async function post(request) { } } - const prompt = buildFocusedDeconstructPrompt({ - targetLabel: body.targetLabel, - targetDescription: body.targetDescription, - centralStatement: body.centralStatement, - question: body.question, - answer: body.answer, - }); - const provider = getProvider(); const modelName = getProviderModelName(); startedAt = Date.now(); @@ -84,36 +72,21 @@ async function post(request) { providerMode: process.env.CONFIDENCE_ENGINE_EXPERIMENT_PROVIDER ?? "ollama", startedAt, }); - const wrapper = await provider.generateReconstruction( - prompt, + + const result = await focusedDeconstruct({ + provider, modelName, - focusedDeconstructJsonSchema, - ); + targetNodeId, + targetLabel: body.targetLabel, + targetDescription: body.targetDescription, + centralStatement: body.centralStatement, + question: body.question, + answer: body.answer, + }); + const elapsedMs = Date.now() - startedAt; - // Unwrap the semantic deconstruction from the provider envelope. - const deconstruction = wrapper.response; - console.info("[api/focused-investigation/deconstruct] provider success", { - targetNodeId, - elapsedMs, - providerApiPath: wrapper.providerApiPath ?? null, - responsePresent: Boolean(deconstruction), - responseKeys: deconstruction && typeof deconstruction === "object" - ? Object.keys(deconstruction) - : [], - }); - - // Validate schema (required fields present, no graph-mutation fields) - const validationErrors = validateFocusedDeconstructSchema(deconstruction); - console.info("[api/focused-investigation/deconstruct] validation", { - targetNodeId, - schemaValid: validationErrors.length === 0, - responseKeys: deconstruction && typeof deconstruction === "object" - ? Object.keys(deconstruction) - : [], - validationErrors, - }); - if (validationErrors.length > 0) { + if (!result.success) { console.info("[api/focused-investigation/deconstruct] end", { targetNodeId, status: 502, @@ -123,7 +96,7 @@ async function post(request) { { success: false, error: "Focused deconstruction result did not match expected schema", - validationErrors, + validationErrors: result.errors, targetNodeId: body.targetNodeId, elapsedMs, }, @@ -134,11 +107,11 @@ async function post(request) { const response = Response.json({ success: true, targetNodeId: body.targetNodeId, - observations: deconstruction.observations, - uncertainties: deconstruction.uncertainties, - assumptions: deconstruction.assumptions, - relationships: deconstruction.relationships, - possibleFollowUpQuestions: deconstruction.possibleFollowUpQuestions, + observations: result.observations, + uncertainties: result.uncertainties, + assumptions: result.assumptions, + relationships: result.relationships, + possibleFollowUpQuestions: result.possibleFollowUpQuestions, elapsedMs, }); console.info("[api/focused-investigation/deconstruct] end", { diff --git a/lib/graph/focused-investigation.js b/lib/graph/focused-investigation.js index e4307a7..eee4d96 100644 --- a/lib/graph/focused-investigation.js +++ b/lib/graph/focused-investigation.js @@ -189,4 +189,55 @@ export function validateFocusedDeconstructSchema(result) { return errors; } +/** + * Compose a focused answer deconstruction invocation. + * + * Accepts explicit provider and modelName — does NOT call getProvider or getProviderModelName. + * Calls buildFocusedDeconstructPrompt internally to produce the prompt. + * Calls provider.generateReconstruction exactly once. + * Validates result with validateFocusedDeconstructSchema. + * Returns validated semantic payload on success, or { validation: false, errors } on schema failure. + */ +export function focusedDeconstruct({ + provider, + modelName, + targetNodeId, + targetLabel, + targetDescription, + centralStatement, + question, + answer, +}) { + const prompt = buildFocusedDeconstructPrompt({ + targetLabel, + targetDescription, + centralStatement, + question, + answer, + }); + + const wrapper = provider.generateReconstruction( + prompt, + modelName, + focusedDeconstructJsonSchema, + ); + + const deconstruction = wrapper.response; + + const validationErrors = validateFocusedDeconstructSchema(deconstruction); + if (validationErrors.length > 0) { + return { success: false, validation: false, errors: validationErrors }; + } + + return { + success: true, + targetNodeId, + observations: deconstruction.observations, + uncertainties: deconstruction.uncertainties, + assumptions: deconstruction.assumptions, + relationships: deconstruction.relationships, + possibleFollowUpQuestions: deconstruction.possibleFollowUpQuestions, + }; +} + export { FOCUSED_ANSWER_SCHEMA_FIELDS, FORBIDDEN_GRAPH_MUTATION_FIELDS }; diff --git a/tests/graph/focused-deconstruction.test.mjs b/tests/graph/focused-deconstruction.test.mjs new file mode 100644 index 0000000..d517622 --- /dev/null +++ b/tests/graph/focused-deconstruction.test.mjs @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + focusedDeconstruct, + buildFocusedDeconstructPrompt, + validateFocusedDeconstructSchema, + focusedDeconstructJsonSchema, +} from "@/lib/graph/focused-investigation.js"; + +const FAKE_TARGET_NODE_ID = "test-node-001"; +const FAKE_LABEL = "Market demand signal"; +const FAKE_DESCRIPTION = "Evidence that customers want this."; +const FAKE_CENTRAL = "Should we launch the product now?"; +const FAKE_QUESTION = "What evidence would clarify market demand?"; +const FAKE_ANSWER = "Some evidence suggests demand."; + +function makeValidResponse() { + return { + targetNodeId: "model-returned-001", + observations: ["Observation A"], + uncertainties: ["Uncertainty B"], + assumptions: ["Assumption C"], + relationships: [{ from: "A", to: "B", type: "supports" }], + possibleFollowUpQuestions: ["What next?"], + }; +} + +function makeInvalidResponse() { + return { targetNodeId: "model-returned-001", observations: ["Obs"] }; +} + +describe("focusedDeconstruct — success", () => { + it("calls provider.generateReconstruction exactly once and returns success", () => { + let callCount = 0; + const fake = { generateReconstruction() { callCount++; return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(callCount).toBe(1); + expect(result.success).toBe(true); + }); + + it("preserves observations from provider response", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.observations)).toBe(true); + expect(result.observations.length).toBeGreaterThan(0); + }); + + it("preserves uncertainties from provider response", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.uncertainties)).toBe(true); + expect(result.uncertainties.length).toBeGreaterThan(0); + }); + + it("preserves assumptions from provider response", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.assumptions)).toBe(true); + expect(result.assumptions.length).toBeGreaterThan(0); + }); + + it("preserves relationships from provider response", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.relationships)).toBe(true); + expect(result.relationships.length).toBeGreaterThan(0); + }); + + it("preserves possibleFollowUpQuestions from provider response", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.possibleFollowUpQuestions)).toBe(true); + expect(result.possibleFollowUpQuestions.length).toBeGreaterThan(0); + }); + + it("preserves caller target identity (result.targetNodeId === request targetNodeId)", () => { + const fake = { generateReconstruction() { return { response: makeValidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(result.targetNodeId).toBe(FAKE_TARGET_NODE_ID); + }); + + it("passes correct prompt to provider", () => { + let capturedPrompt = null; + const fake = { generateReconstruction(prompt) { capturedPrompt = prompt; return { response: makeValidResponse() }; } }; + focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + const expected = buildFocusedDeconstructPrompt({ targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(capturedPrompt).toBe(expected); + }); + + it("passes modelName unchanged to provider", () => { + let capturedModel = null; + const fake = { generateReconstruction(prompt, receivedModel) { capturedModel = receivedModel; return { response: makeValidResponse() }; } }; + focusedDeconstruct({ provider: fake, modelName: "my-custom-model-v2", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(capturedModel).toBe("my-custom-model-v2"); + }); + + it("passes focusedDeconstructJsonSchema as the schema argument", () => { + let capturedSchema = null; + const fake = { generateReconstruction(prompt, modelName, schema) { capturedSchema = schema; return { response: makeValidResponse() }; } }; + focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(capturedSchema).toBe(focusedDeconstructJsonSchema); + }); +}) + +describe("focusedDeconstruct — validation failure", () => { + it("returns failure result when provider response fails canonical validation", () => { + const fake = { generateReconstruction() { return { response: makeInvalidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(result.success).toBe(false); + }); + + it("validation errors remain available on failure result", () => { + const fake = { generateReconstruction() { return { response: makeInvalidResponse() }; } }; + const result = focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(Array.isArray(result.errors)).toBe(true); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("no second provider call occurs on validation failure", () => { + let callCount = 0; + const fake = { generateReconstruction() { callCount++; return { response: makeInvalidResponse() }; } }; + focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER }); + expect(callCount).toBe(1); + }); +}) + +describe("focusedDeconstruct — provider failure", () => { + it("propagates error with original identity/metadata intact", () => { + const fake = { + generateReconstruction() { + const err = new Error("Connection refused to ollama endpoint"); + err.name = "ProviderError"; + err.providerApiPath = "/api/v1/completions"; + err.code = "PROVIDER_UNAVAILABLE"; + throw err; + }, + }; + expect(() => focusedDeconstruct({ provider: fake, modelName: "test-model", targetNodeId: FAKE_TARGET_NODE_ID, targetLabel: FAKE_LABEL, targetDescription: FAKE_DESCRIPTION, centralStatement: FAKE_CENTRAL, question: FAKE_QUESTION, answer: FAKE_ANSWER })).toThrow("Connection refused to ollama endpoint"); + }); +})