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
+127 -1
View File
@@ -4,12 +4,17 @@
*/
import { analyseScenario } from "../analysis.js";
import { assertConfig } from "../config.js";
import { getProvider } from "../llm/provider.js";
import {
makeGraph,
startCaseRequestSchema,
situationGraphSchema,
updateCaseRequestSchema,
} from "./schema.js";
import { buildInitialGraph, describeGraph } from "./builder.js";
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
import { parseGraphUpdateProposal } from "./update-proposal.js";
import {
selectActiveUnknownCandidate,
validateGraphReferences,
@@ -124,5 +129,126 @@ export async function startCase(body) {
}
export async function updateCase() {
throw new Error("updateCase is not implemented yet");
return updateCaseWithDependencies(...arguments);
}
function sanitiseErrorMessage(error, fallbackMessage) {
if (typeof error?.message === "string" && error.message.trim().length > 0) {
return error.message;
}
return fallbackMessage;
}
async function updateCaseWithDependencies(body, dependencies = {}) {
const parsedRequest = updateCaseRequestSchema.safeParse(body);
if (!parsedRequest.success) {
return {
success: false,
stage: "request_validation",
error: "Invalid update-case request",
validationErrors: toValidationErrors(parsedRequest.error),
statusCode: 400,
};
}
const { situationGraph, previousQuestion, answer, promptVersion } =
parsedRequest.data;
const graphSchemaValidation = situationGraphSchema.safeParse(situationGraph);
const graphReferenceValidation = validateGraphReferences(situationGraph);
if (!graphSchemaValidation.success || !graphReferenceValidation.valid) {
return {
success: false,
stage: "graph_validation",
error: "Invalid situation graph",
graphValidationErrors: [
...(!graphSchemaValidation.success
? toValidationErrors(graphSchemaValidation.error)
: []),
...(!graphReferenceValidation.valid
? graphReferenceValidation.errors
: []),
],
statusCode: 400,
};
}
const buildPrompt =
dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt;
const parseProposal =
dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal;
let modelName = null;
let rawResponse;
const startedAt = Date.now();
try {
const config = dependencies.config ?? assertConfig();
modelName = config.OLLAMA_MODEL;
const prompt = buildPrompt({
situationGraph,
previousQuestion,
answer,
promptVersion,
});
const provider = dependencies.provider ?? getProvider();
rawResponse = await provider.generateReconstruction(prompt, modelName);
} catch (error) {
return {
success: false,
stage: "provider",
error: "Graph update proposal generation failed",
providerErrors: [
sanitiseErrorMessage(
error,
"Provider failed to generate graph update proposal",
),
],
diagnostics: {
promptVersion: promptVersion ?? null,
modelName,
responseDurationMs: Date.now() - startedAt,
normalisationsApplied: [],
},
statusCode: 502,
};
}
const parsedProposal = parseProposal(rawResponse);
const responseDurationMs = Date.now() - startedAt;
if (!parsedProposal.success) {
return {
success: false,
stage: "proposal_validation",
error: "Invalid graph update proposal",
proposalErrors: parsedProposal.errors,
diagnostics: {
promptVersion: promptVersion ?? null,
modelName,
responseDurationMs,
normalisationsApplied: parsedProposal.normalisationsApplied,
},
statusCode: 502,
};
}
return {
success: true,
stage: "proposal_ready",
proposal: parsedProposal.proposal,
diagnostics: {
promptVersion: promptVersion ?? null,
modelName,
responseDurationMs,
normalisationsApplied: parsedProposal.normalisationsApplied,
graphNodeCount: situationGraph.nodes.length,
graphEdgeCount: situationGraph.edges.length,
},
};
}
+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();
});
});