From f3cdfce0b0cebe814152f2243f6fefc22d0aeb95 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 08:15:54 +0100 Subject: [PATCH] feat: add graph update proposal orchestration --- lib/graph/orchestrator.js | 128 +++++++++++- tests/graph/orchestrator.test.js | 328 ++++++++++++++++++++++++++++++- 2 files changed, 452 insertions(+), 4 deletions(-) diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index f041af1..62038a8 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -4,12 +4,17 @@ */ import { analyseScenario } from "../analysis.js"; +import { assertConfig } from "../config.js"; +import { getProvider } from "../llm/provider.js"; import { makeGraph, startCaseRequestSchema, situationGraphSchema, + updateCaseRequestSchema, } from "./schema.js"; import { buildInitialGraph, describeGraph } from "./builder.js"; +import { buildGraphUpdatePrompt } from "./prompt-builder.js"; +import { parseGraphUpdateProposal } from "./update-proposal.js"; import { selectActiveUnknownCandidate, validateGraphReferences, @@ -124,5 +129,126 @@ export async function startCase(body) { } export async function updateCase() { - throw new Error("updateCase is not implemented yet"); + return updateCaseWithDependencies(...arguments); +} + +function sanitiseErrorMessage(error, fallbackMessage) { + if (typeof error?.message === "string" && error.message.trim().length > 0) { + return error.message; + } + + return fallbackMessage; +} + +async function updateCaseWithDependencies(body, dependencies = {}) { + const parsedRequest = updateCaseRequestSchema.safeParse(body); + + if (!parsedRequest.success) { + return { + success: false, + stage: "request_validation", + error: "Invalid update-case request", + validationErrors: toValidationErrors(parsedRequest.error), + statusCode: 400, + }; + } + + const { situationGraph, previousQuestion, answer, promptVersion } = + parsedRequest.data; + + const graphSchemaValidation = situationGraphSchema.safeParse(situationGraph); + const graphReferenceValidation = validateGraphReferences(situationGraph); + + if (!graphSchemaValidation.success || !graphReferenceValidation.valid) { + return { + success: false, + stage: "graph_validation", + error: "Invalid situation graph", + graphValidationErrors: [ + ...(!graphSchemaValidation.success + ? toValidationErrors(graphSchemaValidation.error) + : []), + ...(!graphReferenceValidation.valid + ? graphReferenceValidation.errors + : []), + ], + statusCode: 400, + }; + } + + const buildPrompt = + dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt; + const parseProposal = + dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal; + + let modelName = null; + let rawResponse; + const startedAt = Date.now(); + + try { + const config = dependencies.config ?? assertConfig(); + modelName = config.OLLAMA_MODEL; + + const prompt = buildPrompt({ + situationGraph, + previousQuestion, + answer, + promptVersion, + }); + + const provider = dependencies.provider ?? getProvider(); + rawResponse = await provider.generateReconstruction(prompt, modelName); + } catch (error) { + return { + success: false, + stage: "provider", + error: "Graph update proposal generation failed", + providerErrors: [ + sanitiseErrorMessage( + error, + "Provider failed to generate graph update proposal", + ), + ], + diagnostics: { + promptVersion: promptVersion ?? null, + modelName, + responseDurationMs: Date.now() - startedAt, + normalisationsApplied: [], + }, + statusCode: 502, + }; + } + + const parsedProposal = parseProposal(rawResponse); + const responseDurationMs = Date.now() - startedAt; + + if (!parsedProposal.success) { + return { + success: false, + stage: "proposal_validation", + error: "Invalid graph update proposal", + proposalErrors: parsedProposal.errors, + diagnostics: { + promptVersion: promptVersion ?? null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + }, + statusCode: 502, + }; + } + + return { + success: true, + stage: "proposal_ready", + proposal: parsedProposal.proposal, + diagnostics: { + promptVersion: promptVersion ?? null, + modelName, + responseDurationMs, + normalisationsApplied: parsedProposal.normalisationsApplied, + graphNodeCount: situationGraph.nodes.length, + graphEdgeCount: situationGraph.edges.length, + }, + }; } diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index 682a270..e863fe8 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const mockAnalyseScenario = vi.fn(); +const MOCK_CONFIG = { OLLAMA_MODEL: "configured" }; vi.mock("@/lib/analysis.js", () => ({ analyseScenario: (...args) => mockAnalyseScenario(...args), @@ -53,6 +55,67 @@ function makeAnalysisResult(overrides = {}) { }; } +function makeUpdateGraph() { + const unknown = makeNode({ + id: "n-unknown", + label: "Complaint rate denominator", + description: "Need the denominator for the complaint rate", + kind: "unknown", + status: "unknown", + confidence: "high", + }); + const observation = makeNode({ + id: "n-observation", + label: "Complaint count rose", + description: "Complaint count rose faster than output", + kind: "observation", + status: "supported", + confidence: "high", + }); + + return makeGraph({ + centralStatement: + "Complaint counts increased while production also increased.", + nodes: [unknown, observation], + edges: [], + activeUnknownNodeId: unknown.id, + resolvedNodeIds: [], + currentSummary: "Nodes: 1 unknown, 1 observation | Edges: 0 total", + }); +} + +function makeUpdateRequest(overrides = {}) { + return { + situationGraph: makeUpdateGraph(), + previousQuestion: "What denominator is being used for the complaint rate?", + answer: + "The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.", + promptVersion: "v0.4", + ...overrides, + }; +} + +function makeProposal(overrides = {}) { + return { + addedNodes: [], + updatedNodes: [ + { + nodeId: "n-unknown", + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "1.9 complaints per 100 units", + reason: "The answer directly provides the normalized rate.", + }, + ], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-unknown"], + affectedNodeIds: [], + ...overrides, + }; +} + describe("lib/graph/orchestrator startCase", () => { beforeEach(() => { vi.resetModules(); @@ -197,11 +260,270 @@ describe("lib/graph/orchestrator startCase", () => { expect(result.diagnostics.compatibilityChanges).toHaveLength(1); }); - it("exports placeholder updateCase", async () => { + it("produces a validated update proposal for a valid request", async () => { const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; - await expect(updateCase()).rejects.toThrow( - "updateCase is not implemented yet", + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result).toMatchObject({ + success: true, + stage: "proposal_ready", + proposal: makeProposal(), + diagnostics: { + promptVersion: "v0.4", + modelName: "configured", + graphNodeCount: 2, + graphEdgeCount: 0, + }, + }); + expect(provider.generateReconstruction).toHaveBeenCalledTimes(1); + }); + + it("valid request reaches prompt builder", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const buildGraphUpdatePrompt = vi.fn().mockReturnValue("PROMPT"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + + const request = makeUpdateRequest(); + const result = await updateCase(request, { + buildGraphUpdatePrompt, + provider, + config: MOCK_CONFIG, + }); + + expect(result.success).toBe(true); + expect(buildGraphUpdatePrompt).toHaveBeenCalledWith({ + situationGraph: request.situationGraph, + previousQuestion: request.previousQuestion, + answer: request.answer, + promptVersion: request.promptVersion, + }); + expect(provider.generateReconstruction).toHaveBeenCalledWith( + "PROMPT", + "configured", ); }); + + it("invalid request prevents provider call", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn(), + }; + + const result = await updateCase( + { previousQuestion: "Q?", answer: "A" }, + { + provider, + config: MOCK_CONFIG, + }, + ); + + expect(result).toMatchObject({ + success: false, + stage: "request_validation", + error: "Invalid update-case request", + statusCode: 400, + }); + expect(result.validationErrors).toBeInstanceOf(Array); + expect(provider.generateReconstruction).not.toHaveBeenCalled(); + }); + + it("invalid graph prevents provider call", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn(), + }; + + const graph = makeUpdateGraph(); + graph.nodes[0].dependsOn.push("missing-node"); + + const result = await updateCase( + makeUpdateRequest({ situationGraph: graph }), + { + provider, + config: MOCK_CONFIG, + }, + ); + + expect(result).toMatchObject({ + success: false, + stage: "graph_validation", + error: "Invalid situation graph", + statusCode: 400, + }); + expect(result.graphValidationErrors).toEqual( + expect.arrayContaining([ + expect.stringContaining('depends on "missing-node"'), + ]), + ); + expect(provider.generateReconstruction).not.toHaveBeenCalled(); + }); + + it("prompt includes previous question and answer", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + const request = makeUpdateRequest(); + + await updateCase(request, { + provider, + config: MOCK_CONFIG, + }); + + const prompt = provider.generateReconstruction.mock.calls[0][0]; + expect(prompt).toContain(request.previousQuestion); + expect(prompt).toContain(request.answer); + }); + + it("returns proposal validation failure for malformed JSON", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue("{not json"), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result).toMatchObject({ + success: false, + stage: "proposal_validation", + error: "Invalid graph update proposal", + diagnostics: { + promptVersion: "v0.4", + modelName: "configured", + }, + statusCode: 502, + }); + expect(result.proposalErrors).toBeInstanceOf(Array); + }); + + it("returns structured errors for schema-invalid proposal", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue({ + updatedNodes: [{ nodeId: "n-unknown" }], + }), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result.success).toBe(false); + expect(result.stage).toBe("proposal_validation"); + expect(result.proposalErrors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(Array), + message: expect.any(String), + }), + ]), + ); + }); + + it("includes parser normalisations in diagnostics", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue({ + updatedNodes: [], + }), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result.success).toBe(true); + expect(result.diagnostics.normalisationsApplied).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + change: "Filled missing optional array with []", + }), + ]), + ); + }); + + it("returns structured provider-stage failure", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi + .fn() + .mockRejectedValue(new Error("provider offline")), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result).toMatchObject({ + success: false, + stage: "provider", + error: "Graph update proposal generation failed", + providerErrors: ["provider offline"], + statusCode: 502, + }); + }); + + it("does not mutate the input graph", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + const request = makeUpdateRequest(); + const originalGraph = JSON.parse(JSON.stringify(request.situationGraph)); + + await updateCase(request, { + provider, + config: MOCK_CONFIG, + }); + + expect(request.situationGraph).toEqual(originalGraph); + }); + + it("does not call applyGraphUpdate", async () => { + const utils = await import("@/lib/graph/utils.js"); + const applySpy = vi.spyOn(utils, "applyGraphUpdate"); + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + + await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(applySpy).not.toHaveBeenCalled(); + applySpy.mockRestore(); + }); + + it("does not invent a next question outside the proposal", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + const provider = { + generateReconstruction: vi.fn().mockResolvedValue(makeProposal()), + }; + + const result = await updateCase(makeUpdateRequest(), { + provider, + config: MOCK_CONFIG, + }); + + expect(result.selectedQuestion).toBeUndefined(); + expect(result.nextQuestion).toBeUndefined(); + expect(result.proposal.nextQuestion).toBeUndefined(); + }); });