From 3c1362d8a12f4b642f7f2951c26f356d97186653 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 06:52:26 +0100 Subject: [PATCH] feat: add initial situation graph orchestration --- app/api/cases/start/route.js | 38 +++++ lib/graph/orchestrator.js | 125 ++++++++++++++++ tests/app/api/cases-start-route.test.js | 114 +++++++++++++++ tests/graph/orchestrator.test.js | 181 ++++++++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 app/api/cases/start/route.js create mode 100644 lib/graph/orchestrator.js create mode 100644 tests/app/api/cases-start-route.test.js create mode 100644 tests/graph/orchestrator.test.js diff --git a/app/api/cases/start/route.js b/app/api/cases/start/route.js new file mode 100644 index 0000000..ac8ad6b --- /dev/null +++ b/app/api/cases/start/route.js @@ -0,0 +1,38 @@ +import { startCase } from "@/lib/graph/orchestrator.js"; + +export async function POST(request) { + try { + const body = await request.json(); + const result = await startCase(body); + + if (result.success) { + return Response.json(result, { status: 200 }); + } + + const status = + result.statusCode === 400 + ? 400 + : result.statusCode >= 500 + ? result.statusCode + : 500; + + return Response.json( + { + success: false, + error: result.error ?? "Start case failed", + validationErrors: result.validationErrors, + diagnostics: result.diagnostics, + analysisErrors: result.analysisErrors, + }, + { status }, + ); + } catch { + return Response.json( + { + success: false, + error: "Internal server error", + }, + { status: 500 }, + ); + } +} diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js new file mode 100644 index 0000000..abdb0aa --- /dev/null +++ b/lib/graph/orchestrator.js @@ -0,0 +1,125 @@ +/** + * Situation Graph Case Orchestrator — manages the lifecycle of a case. + * startCase builds initial graph from analysis; updateCase applies answers. + */ + +import { analyseScenario } from "../analysis.js"; +import { + makeGraph, + startCaseRequestSchema, + situationGraphSchema, +} from "./schema.js"; +import { buildInitialGraph, describeGraph } from "./builder.js"; +import { + selectActiveUnknownCandidate, + validateGraphReferences, +} from "./utils.js"; + +function toValidationErrors(error) { + return ( + error?.errors?.map((issue) => ({ + path: issue.path, + message: issue.message, + code: issue.code, + })) ?? [{ message: "Validation failed" }] + ); +} + +function buildDiagnostics({ analysis, graph, graphReferenceValidation }) { + return { + promptVersion: analysis?.promptVersion ?? null, + modelName: analysis?.modelName ?? null, + responseDurationMs: analysis?.responseDurationMs ?? null, + validationStatus: analysis?.validationStatus ?? "invalid", + nodeCount: graph?.nodes?.length ?? 0, + edgeCount: graph?.edges?.length ?? 0, + graphReferenceValidation, + }; +} + +export async function startCase(body) { + const parsedRequest = startCaseRequestSchema.safeParse(body); + + if (!parsedRequest.success) { + return { + success: false, + error: "Invalid start-case request", + validationErrors: toValidationErrors(parsedRequest.error), + statusCode: 400, + }; + } + + const { scenario, promptVersion } = parsedRequest.data; + const analysis = await analyseScenario(scenario, { promptVersion }); + + if (!analysis.success) { + return { + success: false, + error: analysis.error ?? "Scenario analysis failed", + diagnostics: buildDiagnostics({ + analysis, + graph: null, + graphReferenceValidation: null, + }), + analysisErrors: analysis.errors ?? undefined, + rawResponse: analysis.rawResponse ?? undefined, + statusCode: Number(analysis.statusCode) || 502, + }; + } + + const initialGraph = buildInitialGraph({ + reconstruction: analysis.reconstruction, + evidence: analysis.evidence, + }); + + const currentSummary = describeGraph(initialGraph); + const activeUnknownNodeId = + selectActiveUnknownCandidate( + { + ...initialGraph, + resolvedNodeIds: [], + }, + [], + )?.nodeId ?? null; + + const situationGraph = makeGraph({ + centralStatement: scenario, + nodes: initialGraph.nodes, + edges: initialGraph.edges, + activeUnknownNodeId, + resolvedNodeIds: [], + currentSummary, + }); + + situationGraphSchema.parse(situationGraph); + + const graphReferenceValidation = validateGraphReferences(situationGraph); + if (!graphReferenceValidation.valid) { + return { + success: false, + error: "Situation graph reference validation failed", + diagnostics: buildDiagnostics({ + analysis, + graph: situationGraph, + graphReferenceValidation, + }), + validationErrors: graphReferenceValidation.errors, + statusCode: 500, + }; + } + + return { + success: true, + situationGraph, + selectedQuestion: analysis.nextQuestion ?? null, + diagnostics: buildDiagnostics({ + analysis, + graph: situationGraph, + graphReferenceValidation, + }), + }; +} + +export async function updateCase() { + throw new Error("updateCase is not implemented yet"); +} diff --git a/tests/app/api/cases-start-route.test.js b/tests/app/api/cases-start-route.test.js new file mode 100644 index 0000000..76937d7 --- /dev/null +++ b/tests/app/api/cases-start-route.test.js @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockStartCase = vi.fn(); + +vi.mock("@/lib/graph/orchestrator.js", () => ({ + startCase: (...args) => mockStartCase(...args), +})); + +describe("app/api/cases/start route", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("delegates request body to the orchestrator", async () => { + mockStartCase.mockResolvedValue({ + success: true, + situationGraph: { nodes: [{ id: "n1" }], edges: [] }, + selectedQuestion: null, + diagnostics: {}, + }); + + const { POST } = await import("@/app/api/cases/start/route.js"); + const request = new Request("http://localhost/api/cases/start", { + method: "POST", + body: JSON.stringify({ scenario: "Scenario text" }), + headers: { "content-type": "application/json" }, + }); + + await POST(request); + + expect(mockStartCase).toHaveBeenCalledWith({ scenario: "Scenario text" }); + }); + + it("returns 200 on success", async () => { + mockStartCase.mockResolvedValue({ + success: true, + situationGraph: { nodes: [{ id: "n1" }], edges: [] }, + selectedQuestion: null, + diagnostics: {}, + }); + + const { POST } = await import("@/app/api/cases/start/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/start", { + method: "POST", + body: JSON.stringify({ scenario: "Scenario text" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(200); + }); + + it("returns 400 for invalid request input", async () => { + mockStartCase.mockResolvedValue({ + success: false, + error: "Invalid start-case request", + validationErrors: [{ message: "Required" }], + statusCode: 400, + }); + + const { POST } = await import("@/app/api/cases/start/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/start", { + method: "POST", + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: "Invalid start-case request", + }); + }); + + it("returns provider/internal failures as 5xx without stack traces", async () => { + mockStartCase.mockResolvedValue({ + success: false, + error: "Provider unavailable", + diagnostics: { modelName: "llama3" }, + statusCode: 502, + }); + + const { POST } = await import("@/app/api/cases/start/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/start", { + method: "POST", + body: JSON.stringify({ scenario: "Scenario text" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.not.toHaveProperty("stack"); + }); + + it("returns structured 500 on malformed JSON", async () => { + const { POST } = await import("@/app/api/cases/start/route.js"); + const request = { + json: vi.fn().mockRejectedValue(new Error("Unexpected token")), + }; + + const response = await POST(request); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: "Internal server error", + }); + }); +}); diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js new file mode 100644 index 0000000..a574821 --- /dev/null +++ b/tests/graph/orchestrator.test.js @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAnalyseScenario = vi.fn(); + +vi.mock("@/lib/analysis.js", () => ({ + analyseScenario: (...args) => mockAnalyseScenario(...args), +})); + +function makeAnalysisResult(overrides = {}) { + return { + success: true, + validationStatus: "valid", + modelName: "llama3", + responseDurationMs: 321, + rawResponse: "{}", + promptVersion: "v0.3", + reconstruction: { + summary: "Revenue and complaints diverge", + actors: [], + systemsOrObjects: [], + expectedStates: [], + observedStates: [ + { + id: "obs-1", + label: "Revenue up", + description: "Revenue up 15%", + confidence: "high", + }, + ], + differences: [], + knownTransitions: [], + unexplainedTransitions: [], + contradictions: [], + importantUnknowns: [ + { + id: "unk-1", + label: "Complaint rate denominator", + description: "Need the denominator for complaint rate", + confidence: "high", + }, + ], + plausibleInterpretations: [], + }, + evidence: [], + nextQuestion: { + id: "q-1", + question: "What denominator is being used for the complaint rate?", + }, + ...overrides, + }; +} + +describe("lib/graph/orchestrator startCase", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("passes a valid request through to analyseScenario", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ + scenario: "Revenue increased while complaint counts rose faster.", + promptVersion: "v0.3", + }); + + expect(result.success).toBe(true); + expect(mockAnalyseScenario).toHaveBeenCalledWith( + "Revenue increased while complaint counts rose faster.", + { promptVersion: "v0.3" }, + ); + }); + + it("rejects invalid request input without throwing", async () => { + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "" }); + + expect(result).toMatchObject({ + success: false, + error: "Invalid start-case request", + statusCode: 400, + }); + expect(result.validationErrors).toBeInstanceOf(Array); + expect(mockAnalyseScenario).not.toHaveBeenCalled(); + }); + + it("builds a valid graph on successful analysis", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "Scenario text" }); + + expect(result.success).toBe(true); + expect(result.situationGraph.centralStatement).toBe("Scenario text"); + expect(result.situationGraph.currentSummary).toContain("Nodes:"); + expect(result.diagnostics).toMatchObject({ + validationStatus: "valid", + modelName: "llama3", + graphReferenceValidation: { valid: true, errors: [] }, + }); + }); + + it("applies active unknown selection to the graph", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "Scenario text" }); + + expect(result.success).toBe(true); + expect(result.situationGraph.activeUnknownNodeId).toBeTruthy(); + }); + + it("returns structured failure when graph reference validation fails", async () => { + mockAnalyseScenario.mockResolvedValue(makeAnalysisResult()); + const utils = await import("@/lib/graph/utils.js"); + const validateSpy = vi + .spyOn(utils, "validateGraphReferences") + .mockReturnValue({ + valid: false, + errors: ['Edge references non-existent toNodeId "missing"'], + }); + + const { startCase } = await import("@/lib/graph/orchestrator.js"); + const result = await startCase({ scenario: "Scenario text" }); + + expect(result).toMatchObject({ + success: false, + error: "Situation graph reference validation failed", + validationErrors: ['Edge references non-existent toNodeId "missing"'], + statusCode: 500, + }); + expect(result.diagnostics.graphReferenceValidation.valid).toBe(false); + validateSpy.mockRestore(); + }); + + it("preserves analysis/provider failure details", async () => { + mockAnalyseScenario.mockResolvedValue({ + success: false, + error: "Provider unavailable", + errors: ["socket hang up"], + rawResponse: null, + modelName: "llama3", + responseDurationMs: 99, + promptVersion: "v0.3", + validationStatus: "invalid", + statusCode: 502, + }); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "Scenario text" }); + + expect(result).toMatchObject({ + success: false, + error: "Provider unavailable", + analysisErrors: ["socket hang up"], + statusCode: 502, + }); + }); + + it("returns null selectedQuestion when analysis has no nextQuestion", async () => { + mockAnalyseScenario.mockResolvedValue( + makeAnalysisResult({ nextQuestion: undefined }), + ); + const { startCase } = await import("@/lib/graph/orchestrator.js"); + + const result = await startCase({ scenario: "Scenario text" }); + + expect(result.success).toBe(true); + expect(result.selectedQuestion).toBeNull(); + }); + + it("exports placeholder updateCase", async () => { + const { updateCase } = await import("@/lib/graph/orchestrator.js"); + + await expect(updateCase()).rejects.toThrow( + "updateCase is not implemented yet", + ); + }); +});