feat: add initial situation graph orchestration

This commit is contained in:
2026-08-02 06:52:26 +01:00
parent 79ea2f6824
commit 3c1362d8a1
4 changed files with 458 additions and 0 deletions
+38
View File
@@ -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 },
);
}
}
+125
View File
@@ -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");
}
+114
View File
@@ -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",
});
});
});
+181
View File
@@ -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",
);
});
});