feat: add graph update proposal orchestration

This commit is contained in:
2026-08-02 08:15:54 +01:00
parent b38a6a9f2e
commit f3cdfce0b0
2 changed files with 452 additions and 4 deletions
+325 -3
View File
@@ -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();
});
});