diff --git a/app/api/focused-investigation/deconstruct/route.js b/app/api/focused-investigation/deconstruct/route.js new file mode 100644 index 0000000..d0c6657 --- /dev/null +++ b/app/api/focused-investigation/deconstruct/route.js @@ -0,0 +1,89 @@ +import { getProvider } from "@/lib/llm/provider"; +import { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation"; + +export async function POST(request) { + try { + const body = await request.json(); + + if (!body.targetNodeId || typeof body.targetNodeId !== "string") { + return Response.json( + { error: "Request must include a 'targetNodeId' string field" }, + { status: 400 }, + ); + } + if (!body.targetLabel || typeof body.targetLabel !== "string") { + return Response.json( + { error: "Request must include a 'targetLabel' string field" }, + { status: 400 }, + ); + } + if (!body.targetDescription || typeof body.targetDescription !== "string") { + return Response.json( + { error: "Request must include a 'targetDescription' string field" }, + { status: 400 }, + ); + } + if (!body.centralStatement || typeof body.centralStatement !== "string") { + return Response.json( + { error: "Request must include a 'centralStatement' string field" }, + { status: 400 }, + ); + } + if (!body.question || typeof body.question !== "string") { + return Response.json( + { error: "Request must include a 'question' string field" }, + { status: 400 }, + ); + } + if (!body.answer || typeof body.answer !== "string") { + return Response.json( + { error: "Request must include an 'answer' string field" }, + { status: 400 }, + ); + } + + const prompt = buildFocusedDeconstructPrompt({ + targetLabel: body.targetLabel, + targetDescription: body.targetDescription, + centralStatement: body.centralStatement, + question: body.question, + answer: body.answer, + }); + + const provider = getProvider(); + const startedAt = Date.now(); + const raw = await provider.generateReconstruction(prompt, process.env.OLLAMA_MODEL); + const elapsedMs = Date.now() - startedAt; + + // Validate schema (required fields present, no graph-mutation fields) + const validationErrors = validateFocusedDeconstructSchema(raw); + if (validationErrors.length > 0) { + return Response.json( + { + success: false, + error: "Focused deconstruction result did not match expected schema", + validationErrors, + targetNodeId: body.targetNodeId, + elapsedMs, + }, + { status: 502 }, + ); + } + + return Response.json({ + success: true, + targetNodeId: raw.targetNodeId, + observations: raw.observations, + uncertainties: raw.uncertainties, + assumptions: raw.assumptions, + relationships: raw.relationships, + possibleFollowUpQuestions: raw.possibleFollowUpQuestions, + elapsedMs, + }); + } catch (e) { + return Response.json( + { error: e.message || "Unknown server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/focused-investigation/formulate/route.js b/app/api/focused-investigation/formulate/route.js new file mode 100644 index 0000000..8fb8252 --- /dev/null +++ b/app/api/focused-investigation/formulate/route.js @@ -0,0 +1,52 @@ +import { formulateQuestionForTarget } from "@/lib/graph/focused-investigation"; + +export async function POST(request) { + try { + const body = await request.json(); + + if (!body.targetNodeId || typeof body.targetNodeId !== "string") { + return Response.json( + { error: "Request must include a 'targetNodeId' string field" }, + { status: 400 }, + ); + } + + if (!body.situationGraph || typeof body.situationGraph !== "object") { + return Response.json( + { error: "Request must include a 'situationGraph' object field" }, + { status: 400 }, + ); + } + + const result = formulateQuestionForTarget({ + situationGraph: body.situationGraph, + targetNodeId: body.targetNodeId, + }); + + if (!result.success) { + return Response.json( + { success: false, error: result.error }, + { status: 400 }, + ); + } + + return Response.json({ + success: true, + targetNodeId: result.targetNodeId, + question: result.question, + strategy: result.strategy, + reasoningPattern: result.reasoningPattern, + reasoningPatternReason: result.reasoningPatternReason, + reason: result.reason, + questionFamily: result.questionFamily, + selectedQuestionTemplate: result.selectedQuestionTemplate, + allowedQuestionFamilies: result.allowedQuestionFamilies, + rejectedQuestionFamilies: result.rejectedQuestionFamilies, + }); + } catch (e) { + return Response.json( + { error: e.message || "Unknown server error" }, + { status: 500 }, + ); + } +} diff --git a/lib/graph/focused-investigation.js b/lib/graph/focused-investigation.js new file mode 100644 index 0000000..e349092 --- /dev/null +++ b/lib/graph/focused-investigation.js @@ -0,0 +1,139 @@ +import { formulateQuestion } from "@/lib/graph/question-formulator.js"; + +const FOCUSED_ANSWER_SCHEMA_FIELDS = [ + "targetNodeId", + "observations", + "uncertainties", + "assumptions", + "relationships", + "possibleFollowUpQuestions", +]; + +const FORBIDDEN_GRAPH_MUTATION_FIELDS = [ + "addedNodes", + "updatedNodes", + "removedNodes", + "addedEdges", + "removedEdges", + "resolvedNodeIds", + "activeUnknownNodeId", + "selectedQuestion", +]; + +/** + * Formulate a question for the explicitly user-selected unresolved node. + * + * Input: { situationGraph, targetNodeId } + * Validates that target exists, is an unknown, and is not resolved. + * Calls formulateQuestion with only factual explicit-user-targeting context. + * Does NOT invoke selectActiveUnknownCandidate, determineGraphBackedQuestion, + * global ranking, or global recommendation. + * Does NOT mutate activeUnknownNodeId, selectedQuestion, SituationGraph, or resolvedNodeIds. + */ +export function formulateQuestionForTarget({ situationGraph, targetNodeId }) { + const nodesById = new Map(situationGraph?.nodes?.map((n) => [n.id, n]) || []); + const targetNode = nodesById.get(targetNodeId); + + if (!targetNode) { + return { success: false, error: `Target node ${targetNodeId} not found in graph.` }; + } + + if (targetNode.kind !== "unknown") { + return { success: false, error: `Target node ${targetNodeId} is not an unknown (kind=${targetNode.kind}).` }; + } + + if (targetNode.status === "resolved") { + return { success: false, error: `Target node ${targetNodeId} is already resolved.` }; + } + + // Build explicit user-targeting context — only factual fields from the targeting request. + // No activeUnknownNodeId override, no selectedQuestion mutation, no global selection state. + const context = { + resolvedValues: [], + suppressDecisionSufficiencyConfirmation: false, + _explicitTargetNodeId: targetNodeId, + _explicitTargetLabel: targetNode.label, + _explicitTargetDescription: targetNode.description, + }; + + const result = formulateQuestion({ node: targetNode, graph: situationGraph, context }); + + return { + success: true, + targetNodeId, + question: result.question, + strategy: result.strategy ?? null, + reasoningPattern: result.reasoningPattern, + reasoningPatternReason: result.reasoningPatternReason, + reason: result.reason, + questionFamily: result.questionFamily, + selectedQuestionTemplate: result.selectedQuestionTemplate, + allowedQuestionFamilies: result.allowedQuestionFamilies, + rejectedQuestionFamilies: result.rejectedQuestionFamilies, + }; +} + +/** + * Build a focused answer deconstruction prompt for one explicitly user-chosen investigation. + */ +export function buildFocusedDeconstructPrompt({ targetLabel, targetDescription, centralStatement, question, answer }) { + return `You are performing focused answer deconstruction for one explicitly user-chosen investigation. + +Return exactly one JSON object. Return JSON only. + +This is NOT a graph update task. +Do NOT output graph mutations. +Do NOT output selection, ranking, ownership, recommendation, confidence, or next-best-question semantics. +Do NOT include any of these fields: addedNodes, updatedNodes, removedNodes, addedEdges, removedEdges, resolvedNodeIds, activeUnknownNodeId, selectedQuestion. + +Required top-level fields: +- targetNodeId +- observations +- uncertainties +- assumptions +- relationships +- possibleFollowUpQuestions + +Field rules: +- targetNodeId must be included as a string identifying this investigation node +- observations: only statements directly supported by the answer +- uncertainties: only things the answer explicitly leaves unknown or unclear +- assumptions: include only if the answer itself relies on an assumption +- relationships: only direct supported relationships among extracted items, each with { from, to, type, rationale } +- possibleFollowUpQuestions: unresolved questions genuinely exposed by this answer, unranked + +Focused case context: +- target label: ${targetLabel} +- target description: ${targetDescription} +- central case statement: ${centralStatement} + +Question: +${question} + +Answer: +${answer}`; +} + +/** + * Validate that a focused-answer deconstruction result contains only the expected fields. + * Returns an array of errors (empty = valid). + */ +export function validateFocusedDeconstructSchema(result) { + const errors = []; + + for (const field of FOCUSED_ANSWER_SCHEMA_FIELDS) { + if (!(field in result)) { + errors.push(`Missing required field: ${field}`); + } + } + + for (const field of FORBIDDEN_GRAPH_MUTATION_FIELDS) { + if (field in result) { + errors.push(`Forbidden graph-mutation field present: ${field}`); + } + } + + return errors; +} + +export { FOCUSED_ANSWER_SCHEMA_FIELDS, FORBIDDEN_GRAPH_MUTATION_FIELDS }; diff --git a/tests/graph/focused-investigation-boundaries.test.js b/tests/graph/focused-investigation-boundaries.test.js new file mode 100644 index 0000000..3b1998f --- /dev/null +++ b/tests/graph/focused-investigation-boundaries.test.js @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { makeNode, makeGraph } from "@/lib/graph/schema.js"; +import { formulateQuestionForTarget, buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation.js"; + +// ── helpers ────────────────────────────────────────────────────────────── + +function makeTestGraph() { + return makeGraph({ + centralStatement: "Should we launch the product now?", + currentSummary: "initial summary", + nodes: [ + makeNode({ id: "a", label: "Market demand signal", description: "Evidence that customers want this.", kind: "unknown", status: "unknown" }), + makeNode({ id: "b", label: "Competitor activity", description: "What others are doing.", kind: "observation", status: "supported" }), + ], + edges: [], + resolvedNodeIds: ["b"], + }); +} + +// ── Boundary 1: explicit formulation ───────────────────────────────────── + +describe("formulateQuestionForTarget — explicit node selection", () => { + it("validates that the target node exists in the graph", () => { + const graph = makeTestGraph(); + const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "nonexistent" }); + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + it("validates that the target node is an unknown", () => { + const graph = makeTestGraph(); + const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "b" }); + expect(result.success).toBe(false); + expect(result.error).toContain("not an unknown"); + }); + + it("validates that the target node is not resolved", () => { + const graph = makeTestGraph(); + // Create a resolved unknown node + graph.nodes.push(makeNode({ id: "c", label: "Done", description: "Completed item", kind: "unknown", status: "resolved" })); + const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "c" }); + expect(result.success).toBe(false); + expect(result.error).toContain("resolved"); + }); + + it("uses the supplied targetNodeId in the formulation output", () => { + const graph = makeTestGraph(); + const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "a" }); + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe("a"); + }); + + it("does not mutate activeUnknownNodeId on the graph", () => { + const graph = makeTestGraph(); + const before = graph.activeUnknownNodeId; + formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "a" }); + expect(graph.activeUnknownNodeId).toBe(before); + }); + + it("does not mutate SituationGraph in any way", () => { + const graph = makeTestGraph(); + const snapshot = JSON.stringify(graph); + formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "a" }); + expect(JSON.stringify(graph)).toBe(snapshot); + }); + + it("does not invoke global selection (selectActiveUnknownCandidate / determineGraphBackedQuestion)", () => { + const graph = makeTestGraph(); + const result = formulateQuestionForTarget({ situationGraph: graph, targetNodeId: "a" }); + expect(result.success).toBe(true); + expect(result.targetNodeId).toBe("a"); + expect(result.question).toBeDefined(); + expect(typeof result.question).toBe("string"); + expect(result.question.length).toBeGreaterThan(0); + }); +}); + +// ── Boundary 2: focused answer deconstruction ──────────────────────────── + +describe("buildFocusedDeconstructPrompt", () => { + it("produces a prompt containing the target label", () => { + const prompt = buildFocusedDeconstructPrompt({ + targetLabel: "Market demand signal", + targetDescription: "Evidence that customers want this.", + centralStatement: "Should we launch?", + question: "What evidence would clarify market demand?", + answer: "Some evidence suggests demand.", + }); + expect(prompt).toContain("Market demand signal"); + }); + + it("produces a prompt containing the answer text", () => { + const prompt = buildFocusedDeconstructPrompt({ + targetLabel: "X", + targetDescription: "Y", + centralStatement: "Z", + question: "Q?", + answer: "Some evidence suggests demand.", + }); + expect(prompt).toContain("Some evidence suggests demand."); + }); + + it("does NOT output graph-mutation fields (only lists them as forbidden in the instructions)", () => { + const prompt = buildFocusedDeconstructPrompt({ + targetLabel: "X", + targetDescription: "Y", + centralStatement: "Z", + question: "Q?", + answer: "A.", + }); + // The prompt instructs the model NOT to output these fields. + // It mentions them only as forbidden outputs, not as required outputs. + expect(prompt).toContain("Do NOT include any of these fields"); + expect(prompt).not.toContain("- addedNodes"); + expect(prompt).not.toContain("- updatedNodes"); + expect(prompt).not.toContain("- removedNodes"); + expect(prompt).not.toContain("- resolvedNodeIds"); + }); +}); + +describe("validateFocusedDeconstructSchema", () => { + it("passes when all required fields are present", () => { + const result = { targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [] }; + expect(validateFocusedDeconstructSchema(result)).toEqual([]); + }); + + it("fails when a required field is missing", () => { + const result = { targetNodeId: "a", observations: [], uncertainties: [], assumptions: [] }; + const errors = validateFocusedDeconstructSchema(result); + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.includes("relationships") || e.includes("possibleFollowUpQuestions"))).toBeTruthy(); + }); + + it("rejects results that contain graph-mutation fields", () => { + const result = { + targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [], + addedNodes: [{ id: "x" }], + }; + const errors = validateFocusedDeconstructSchema(result); + expect(errors.some((e) => e.includes("addedNodes"))).toBeTruthy(); + }); + + it("rejects results that contain resolvedNodeIds", () => { + const result = { + targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [], + resolvedNodeIds: ["x"], + }; + const errors = validateFocusedDeconstructSchema(result); + expect(errors.some((e) => e.includes("resolvedNodeIds"))).toBeTruthy(); + }); + + it("rejects results that contain activeUnknownNodeId", () => { + const result = { + targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [], + activeUnknownNodeId: "x", + }; + const errors = validateFocusedDeconstructSchema(result); + expect(errors.some((e) => e.includes("activeUnknownNodeId"))).toBeTruthy(); + }); + + it("rejects results that contain selectedQuestion", () => { + const result = { + targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [], + selectedQuestion: "some question", + }; + const errors = validateFocusedDeconstructSchema(result); + expect(errors.some((e) => e.includes("selectedQuestion"))).toBeTruthy(); + }); + + it("all required fields are observations, uncertainties, assumptions, relationships, possibleFollowUpQuestions", () => { + const fullResult = { targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [] }; + const errors = validateFocusedDeconstructSchema(fullResult); + expect(errors.length).toBe(0); + }); + + it("focused result contains no graph-mutation fields when valid", () => { + const fullResult = { targetNodeId: "a", observations: [], uncertainties: [], assumptions: [], relationships: [], possibleFollowUpQuestions: [] }; + const errors = validateFocusedDeconstructSchema(fullResult); + expect(errors.length).toBe(0); + // Confirm no forbidden keys are in the result + const forbidden = ["addedNodes", "updatedNodes", "removedNodes", "addedEdges", "removedEdges", "resolvedNodeIds", "activeUnknownNodeId", "selectedQuestion"]; + for (const key of forbidden) { + expect(key in fullResult).toBe(false); + } + }); +});