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