feat: add initial situation graph orchestration
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user