feat: add graph update proposal orchestration
This commit is contained in:
+127
-1
@@ -4,12 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { analyseScenario } from "../analysis.js";
|
import { analyseScenario } from "../analysis.js";
|
||||||
|
import { assertConfig } from "../config.js";
|
||||||
|
import { getProvider } from "../llm/provider.js";
|
||||||
import {
|
import {
|
||||||
makeGraph,
|
makeGraph,
|
||||||
startCaseRequestSchema,
|
startCaseRequestSchema,
|
||||||
situationGraphSchema,
|
situationGraphSchema,
|
||||||
|
updateCaseRequestSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
import { buildInitialGraph, describeGraph } from "./builder.js";
|
import { buildInitialGraph, describeGraph } from "./builder.js";
|
||||||
|
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
|
||||||
|
import { parseGraphUpdateProposal } from "./update-proposal.js";
|
||||||
import {
|
import {
|
||||||
selectActiveUnknownCandidate,
|
selectActiveUnknownCandidate,
|
||||||
validateGraphReferences,
|
validateGraphReferences,
|
||||||
@@ -124,5 +129,126 @@ export async function startCase(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCase() {
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
const mockAnalyseScenario = vi.fn();
|
const mockAnalyseScenario = vi.fn();
|
||||||
|
const MOCK_CONFIG = { OLLAMA_MODEL: "configured" };
|
||||||
|
|
||||||
vi.mock("@/lib/analysis.js", () => ({
|
vi.mock("@/lib/analysis.js", () => ({
|
||||||
analyseScenario: (...args) => mockAnalyseScenario(...args),
|
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", () => {
|
describe("lib/graph/orchestrator startCase", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -197,11 +260,270 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
expect(result.diagnostics.compatibilityChanges).toHaveLength(1);
|
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 { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
||||||
|
};
|
||||||
|
|
||||||
await expect(updateCase()).rejects.toThrow(
|
const result = await updateCase(makeUpdateRequest(), {
|
||||||
"updateCase is not implemented yet",
|
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