feat: apply validated graph update proposals
This commit is contained in:
@@ -0,0 +1,292 @@
|
|||||||
|
import { describeGraph } from "./builder.js";
|
||||||
|
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
||||||
|
import {
|
||||||
|
applyGraphUpdate,
|
||||||
|
detectDuplicateNodeIds,
|
||||||
|
findAffectedNodes,
|
||||||
|
selectActiveUnknownCandidate,
|
||||||
|
validateGraphReferences,
|
||||||
|
validateGraphUpdate,
|
||||||
|
} from "./utils.js";
|
||||||
|
|
||||||
|
function cloneJsonSafe(value) {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function zodIssuesToErrors(error) {
|
||||||
|
return (
|
||||||
|
error?.issues?.map((issue) => {
|
||||||
|
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
|
||||||
|
return `${path}${issue.message}`;
|
||||||
|
}) ?? ["Validation failed"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDuplicateEdgeIds(edges) {
|
||||||
|
const counts = new Map();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...counts.entries()]
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([edgeId, count]) => ({ edgeId, count }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAffectedNodeIds(graph, proposal) {
|
||||||
|
const affected = new Set(proposal.affectedNodeIds ?? []);
|
||||||
|
|
||||||
|
for (const update of proposal.updatedNodes ?? []) {
|
||||||
|
affected.add(update.nodeId);
|
||||||
|
for (const nodeId of findAffectedNodes(graph, update.nodeId)) {
|
||||||
|
affected.add(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) {
|
||||||
|
affected.add(nodeId);
|
||||||
|
for (const affectedNodeId of findAffectedNodes(graph, nodeId)) {
|
||||||
|
affected.add(affectedNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...affected];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChangesApplied(proposal, affectedNodeIds) {
|
||||||
|
return {
|
||||||
|
addedNodeCount: proposal.addedNodes.length,
|
||||||
|
updatedNodeCount: proposal.updatedNodes.length,
|
||||||
|
addedEdgeCount: proposal.addedEdges.length,
|
||||||
|
removedEdgeCount: proposal.removedEdgeIds.length,
|
||||||
|
resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length,
|
||||||
|
affectedNodeCount: affectedNodeIds.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyValidatedProposal({ situationGraph, proposal }) {
|
||||||
|
const graphValidation = situationGraphSchema.safeParse(situationGraph);
|
||||||
|
const proposalValidation = graphUpdateSchema.safeParse(proposal);
|
||||||
|
|
||||||
|
const existingGraphReferenceValidation = graphValidation.success
|
||||||
|
? validateGraphReferences(situationGraph)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const existingDuplicateNodeIds = graphValidation.success
|
||||||
|
? detectDuplicateNodeIds(situationGraph.nodes)
|
||||||
|
: [];
|
||||||
|
const existingDuplicateEdgeIds = graphValidation.success
|
||||||
|
? collectDuplicateEdgeIds(situationGraph.edges)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!graphValidation.success ||
|
||||||
|
!existingGraphReferenceValidation?.valid ||
|
||||||
|
existingDuplicateNodeIds.length > 0 ||
|
||||||
|
existingDuplicateEdgeIds.length > 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: "graph_validation",
|
||||||
|
errors: [
|
||||||
|
...(!graphValidation.success
|
||||||
|
? zodIssuesToErrors(graphValidation.error)
|
||||||
|
: []),
|
||||||
|
...(!existingGraphReferenceValidation?.valid
|
||||||
|
? existingGraphReferenceValidation.errors
|
||||||
|
: []),
|
||||||
|
...existingDuplicateNodeIds.map(
|
||||||
|
({ nodeId, count }) =>
|
||||||
|
`Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
...existingDuplicateEdgeIds.map(
|
||||||
|
({ edgeId, count }) =>
|
||||||
|
`Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!proposalValidation.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: "proposal_compatibility",
|
||||||
|
errors: zodIssuesToErrors(proposalValidation.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedProposal = proposalValidation.data;
|
||||||
|
const proposalCompatibilityErrors = [];
|
||||||
|
const proposalGraphValidation = validateGraphUpdate(
|
||||||
|
situationGraph,
|
||||||
|
validatedProposal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!proposalGraphValidation.valid) {
|
||||||
|
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
|
||||||
|
const reachableNodeIds = new Set([
|
||||||
|
...situationGraph.nodes.map((node) => node.id),
|
||||||
|
...validatedProposal.addedNodes.map((node) => node.id),
|
||||||
|
]);
|
||||||
|
const addedEdgeDuplicateIds = collectDuplicateEdgeIds(
|
||||||
|
validatedProposal.addedEdges,
|
||||||
|
);
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
...addedEdgeDuplicateIds.map(
|
||||||
|
({ edgeId, count }) =>
|
||||||
|
`Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const edge of validatedProposal.addedEdges) {
|
||||||
|
if (existingEdgeIds.has(edge.id)) {
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
`Cannot add edge with duplicate ID: "${edge.id}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!reachableNodeIds.has(edge.fromNodeId)) {
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!reachableNodeIds.has(edge.toNodeId)) {
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedEdgeIds = new Set(validatedProposal.removedEdgeIds);
|
||||||
|
for (const edgeId of removedEdgeIds) {
|
||||||
|
if (!existingEdgeIds.has(edgeId)) {
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
`Cannot remove non-existent edge: "${edgeId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedNodeDuplicates = detectDuplicateNodeIds([
|
||||||
|
...situationGraph.nodes,
|
||||||
|
...validatedProposal.addedNodes,
|
||||||
|
]);
|
||||||
|
proposalCompatibilityErrors.push(
|
||||||
|
...combinedNodeDuplicates.map(
|
||||||
|
({ nodeId, count }) =>
|
||||||
|
`Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (proposalCompatibilityErrors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: "proposal_compatibility",
|
||||||
|
errors: proposalCompatibilityErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const graphSnapshot = cloneJsonSafe(situationGraph);
|
||||||
|
const proposalSnapshot = cloneJsonSafe(validatedProposal);
|
||||||
|
const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null;
|
||||||
|
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
||||||
|
|
||||||
|
const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
|
||||||
|
if (!applied.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: "application",
|
||||||
|
errors: applied.errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSituationGraph = {
|
||||||
|
...graphSnapshot,
|
||||||
|
nodes: applied.nodes,
|
||||||
|
edges: applied.edges,
|
||||||
|
resolvedNodeIds: applied.resolvedNodeIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeUnknownWasResolved =
|
||||||
|
previousActiveUnknownNodeId != null &&
|
||||||
|
updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId);
|
||||||
|
|
||||||
|
let newActiveUnknownNodeId = previousActiveUnknownNodeId;
|
||||||
|
if (activeUnknownWasResolved) {
|
||||||
|
newActiveUnknownNodeId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingUnknownExists =
|
||||||
|
newActiveUnknownNodeId != null &&
|
||||||
|
updatedSituationGraph.nodes.some(
|
||||||
|
(node) =>
|
||||||
|
node.id === newActiveUnknownNodeId &&
|
||||||
|
node.kind === "unknown" &&
|
||||||
|
!updatedSituationGraph.resolvedNodeIds.includes(node.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!remainingUnknownExists) {
|
||||||
|
newActiveUnknownNodeId =
|
||||||
|
selectActiveUnknownCandidate(
|
||||||
|
updatedSituationGraph,
|
||||||
|
updatedSituationGraph.resolvedNodeIds,
|
||||||
|
)?.nodeId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
||||||
|
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
||||||
|
|
||||||
|
const resultGraphValidation = situationGraphSchema.safeParse(
|
||||||
|
updatedSituationGraph,
|
||||||
|
);
|
||||||
|
const resultReferenceValidation = resultGraphValidation.success
|
||||||
|
? validateGraphReferences(updatedSituationGraph)
|
||||||
|
: null;
|
||||||
|
const resultDuplicateNodeIds = resultGraphValidation.success
|
||||||
|
? detectDuplicateNodeIds(updatedSituationGraph.nodes)
|
||||||
|
: [];
|
||||||
|
const resultDuplicateEdgeIds = resultGraphValidation.success
|
||||||
|
? collectDuplicateEdgeIds(updatedSituationGraph.edges)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resultGraphValidation.success ||
|
||||||
|
!resultReferenceValidation?.valid ||
|
||||||
|
resultDuplicateNodeIds.length > 0 ||
|
||||||
|
resultDuplicateEdgeIds.length > 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: "result_validation",
|
||||||
|
errors: [
|
||||||
|
...(!resultGraphValidation.success
|
||||||
|
? zodIssuesToErrors(resultGraphValidation.error)
|
||||||
|
: []),
|
||||||
|
...(!resultReferenceValidation?.valid
|
||||||
|
? resultReferenceValidation.errors
|
||||||
|
: []),
|
||||||
|
...resultDuplicateNodeIds.map(
|
||||||
|
({ nodeId, count }) =>
|
||||||
|
`Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
...resultDuplicateEdgeIds.map(
|
||||||
|
({ edgeId, count }) =>
|
||||||
|
`Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
updatedSituationGraph,
|
||||||
|
graphUpdate: validatedProposal,
|
||||||
|
affectedNodeIds,
|
||||||
|
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
||||||
|
previousActiveUnknownNodeId,
|
||||||
|
newActiveUnknownNodeId,
|
||||||
|
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
updateCaseRequestSchema,
|
updateCaseRequestSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
import { buildInitialGraph, describeGraph } from "./builder.js";
|
import { buildInitialGraph, describeGraph } from "./builder.js";
|
||||||
|
import { applyValidatedProposal } from "./apply-proposal.js";
|
||||||
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
|
import { buildGraphUpdatePrompt } from "./prompt-builder.js";
|
||||||
import { parseGraphUpdateProposal } from "./update-proposal.js";
|
import { parseGraphUpdateProposal } from "./update-proposal.js";
|
||||||
import {
|
import {
|
||||||
@@ -180,6 +181,9 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt;
|
dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt;
|
||||||
const parseProposal =
|
const parseProposal =
|
||||||
dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal;
|
dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal;
|
||||||
|
const applyProposalUpdate =
|
||||||
|
dependencies.applyValidatedProposal ?? applyValidatedProposal;
|
||||||
|
const shouldApplyProposal = dependencies.applyProposal === true;
|
||||||
|
|
||||||
let modelName = null;
|
let modelName = null;
|
||||||
let rawResponse;
|
let rawResponse;
|
||||||
@@ -238,6 +242,55 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldApplyProposal) {
|
||||||
|
const applicationResult = applyProposalUpdate({
|
||||||
|
situationGraph,
|
||||||
|
proposal: parsedProposal.proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applicationResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
stage: applicationResult.stage,
|
||||||
|
errors: applicationResult.errors,
|
||||||
|
diagnostics: {
|
||||||
|
promptVersion: promptVersion ?? null,
|
||||||
|
modelName,
|
||||||
|
responseDurationMs,
|
||||||
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||||
|
graphNodeCount: situationGraph.nodes.length,
|
||||||
|
graphEdgeCount: situationGraph.edges.length,
|
||||||
|
},
|
||||||
|
statusCode:
|
||||||
|
applicationResult.stage === "application" ||
|
||||||
|
applicationResult.stage === "result_validation"
|
||||||
|
? 500
|
||||||
|
: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stage: "update_applied",
|
||||||
|
updatedSituationGraph: applicationResult.updatedSituationGraph,
|
||||||
|
proposal: applicationResult.graphUpdate,
|
||||||
|
affectedNodeIds: applicationResult.affectedNodeIds,
|
||||||
|
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
|
||||||
|
previousActiveUnknownNodeId:
|
||||||
|
applicationResult.previousActiveUnknownNodeId,
|
||||||
|
newActiveUnknownNodeId: applicationResult.newActiveUnknownNodeId,
|
||||||
|
changesApplied: applicationResult.changesApplied,
|
||||||
|
diagnostics: {
|
||||||
|
promptVersion: promptVersion ?? null,
|
||||||
|
modelName,
|
||||||
|
responseDurationMs,
|
||||||
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||||
|
graphNodeCount: applicationResult.updatedSituationGraph.nodes.length,
|
||||||
|
graphEdgeCount: applicationResult.updatedSituationGraph.edges.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
stage: "proposal_ready",
|
stage: "proposal_ready",
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
|
||||||
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
||||||
|
|
||||||
|
function makeApplicationFixture() {
|
||||||
|
const complaintRateUnknown = makeNode({
|
||||||
|
id: "n-complaint-rate-unknown",
|
||||||
|
label: "Complaint rate",
|
||||||
|
description: "Need the complaint rate per 100 units",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
affects: ["n-quality-deterioration"],
|
||||||
|
});
|
||||||
|
const staffingUnknown = makeNode({
|
||||||
|
id: "n-staffing-unknown",
|
||||||
|
label: "Staffing change",
|
||||||
|
description: "Need to know if staffing changed",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
});
|
||||||
|
const qualityDeterioration = makeNode({
|
||||||
|
id: "n-quality-deterioration",
|
||||||
|
label: "Quality deterioration conclusion",
|
||||||
|
description: "Conclusion that quality deteriorated",
|
||||||
|
kind: "conclusion",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-complaint-rate-unknown"],
|
||||||
|
});
|
||||||
|
const complaintCount = makeNode({
|
||||||
|
id: "n-complaint-count",
|
||||||
|
label: "Complaint count observation",
|
||||||
|
description: "Complaint count increased",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
value: 135,
|
||||||
|
unit: "count",
|
||||||
|
});
|
||||||
|
const productionCount = makeNode({
|
||||||
|
id: "n-production-count",
|
||||||
|
label: "Production count observation",
|
||||||
|
description: "Production increased",
|
||||||
|
kind: "observation",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "high",
|
||||||
|
value: 7100,
|
||||||
|
unit: "units",
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = makeGraph({
|
||||||
|
centralStatement: "Complaints rose while production also rose.",
|
||||||
|
nodes: [
|
||||||
|
complaintRateUnknown,
|
||||||
|
staffingUnknown,
|
||||||
|
qualityDeterioration,
|
||||||
|
complaintCount,
|
||||||
|
productionCount,
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-quality-depends-rate",
|
||||||
|
fromNodeId: complaintRateUnknown.id,
|
||||||
|
toNodeId: qualityDeterioration.id,
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "The rate informs the quality conclusion",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
activeUnknownNodeId: complaintRateUnknown.id,
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Initial summary",
|
||||||
|
});
|
||||||
|
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: complaintRateUnknown.id,
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: "2.0 complaints per 100 units",
|
||||||
|
newValue: "1.9 complaints per 100 units",
|
||||||
|
reason: "The answer provides the updated normalized complaint rate.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: qualityDeterioration.id,
|
||||||
|
previousStatus: "supported",
|
||||||
|
newStatus: "weakened",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: null,
|
||||||
|
reason: "The improved rate weakens the deterioration conclusion.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [complaintRateUnknown.id],
|
||||||
|
affectedNodeIds: [qualityDeterioration.id],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
proposal,
|
||||||
|
ids: {
|
||||||
|
complaintRateUnknown: complaintRateUnknown.id,
|
||||||
|
staffingUnknown: staffingUnknown.id,
|
||||||
|
qualityDeterioration: qualityDeterioration.id,
|
||||||
|
complaintCount: complaintCount.id,
|
||||||
|
productionCount: productionCount.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyValidatedProposal", () => {
|
||||||
|
it("applies a valid proposal successfully", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
graphUpdate: proposal,
|
||||||
|
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
|
||||||
|
previousActiveUnknownNodeId: ids.complaintRateUnknown,
|
||||||
|
newActiveUnknownNodeId: ids.staffingUnknown,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.complaintRateUnknown,
|
||||||
|
)?.status,
|
||||||
|
).toBe("resolved");
|
||||||
|
expect(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.qualityDeterioration,
|
||||||
|
)?.status,
|
||||||
|
).toBe("weakened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid graph before application", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
graph.nodes[0].dependsOn.push("missing-node");
|
||||||
|
|
||||||
|
const original = JSON.parse(JSON.stringify(graph));
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.stage).toBe("graph_validation");
|
||||||
|
expect(graph).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects updates referencing nonexistent nodes", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
updatedNodes: [
|
||||||
|
...proposal.updatedNodes,
|
||||||
|
{
|
||||||
|
nodeId: "ghost-node",
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: null,
|
||||||
|
reason: "Invalid reference",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
stage: "proposal_compatibility",
|
||||||
|
});
|
||||||
|
expect(result.errors).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining(
|
||||||
|
'Cannot update non-existent node: "ghost-node"',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects added edges with invalid references", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-invalid",
|
||||||
|
fromNodeId: "missing-node",
|
||||||
|
toNodeId: "n-quality-deterioration",
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Invalid edge",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.stage).toBe("proposal_compatibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate IDs", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
addedNodes: [
|
||||||
|
makeNode({
|
||||||
|
id: ids.qualityDeterioration,
|
||||||
|
label: "Duplicate",
|
||||||
|
description: "Duplicate node id",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.stage).toBe("proposal_compatibility");
|
||||||
|
expect(result.errors.join(" ")).toContain("duplicate node ID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves unrelated nodes byte-for-byte", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
const originalComplaintCount = JSON.stringify(
|
||||||
|
graph.nodes.find((node) => node.id === ids.complaintCount),
|
||||||
|
);
|
||||||
|
const originalProductionCount = JSON.stringify(
|
||||||
|
graph.nodes.find((node) => node.id === ids.productionCount),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.complaintCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(originalComplaintCount);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
result.updatedSituationGraph.nodes.find(
|
||||||
|
(node) => node.id === ids.productionCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(originalProductionCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds resolved unknowns to resolvedNodeIds", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
|
||||||
|
ids.complaintRateUnknown,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active unknown when it remains unresolved", () => {
|
||||||
|
const { graph, ids } = makeApplicationFixture();
|
||||||
|
const proposal = {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: ids.qualityDeterioration,
|
||||||
|
previousStatus: "supported",
|
||||||
|
newStatus: "weakened",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: null,
|
||||||
|
reason: "Only the conclusion changes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [ids.qualityDeterioration],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.previousActiveUnknownNodeId).toBe(ids.complaintRateUnknown);
|
||||||
|
expect(result.newActiveUnknownNodeId).toBe(ids.complaintRateUnknown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports affected node ids", () => {
|
||||||
|
const { graph, proposal, ids } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.affectedNodeIds).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
ids.complaintRateUnknown,
|
||||||
|
ids.qualityDeterioration,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revalidates the completed graph references", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is atomic on failure", () => {
|
||||||
|
const { graph, proposal } = makeApplicationFixture();
|
||||||
|
const originalGraph = JSON.parse(JSON.stringify(graph));
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
...proposal,
|
||||||
|
addedEdges: [
|
||||||
|
makeEdge({
|
||||||
|
id: "e-bad",
|
||||||
|
fromNodeId: "missing-node",
|
||||||
|
toNodeId: "n-quality-deterioration",
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: "medium",
|
||||||
|
description: "Invalid edge",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(graph).toEqual(originalGraph);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a proposal with no meaningful change", () => {
|
||||||
|
const { graph } = makeApplicationFixture();
|
||||||
|
|
||||||
|
const result = applyValidatedProposal({
|
||||||
|
situationGraph: graph,
|
||||||
|
proposal: {
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: "n-quality-deterioration",
|
||||||
|
previousStatus: null,
|
||||||
|
newStatus: null,
|
||||||
|
previousValue: null,
|
||||||
|
newValue: null,
|
||||||
|
reason: "No change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: [],
|
||||||
|
affectedNodeIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
stage: "proposal_compatibility",
|
||||||
|
});
|
||||||
|
expect(result.errors).toEqual(
|
||||||
|
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { validateGraphReferences } from "@/lib/graph/utils.js";
|
||||||
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
import { makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
|
|
||||||
const mockAnalyseScenario = vi.fn();
|
const mockAnalyseScenario = vi.fn();
|
||||||
@@ -526,4 +527,123 @@ describe("lib/graph/orchestrator startCase", () => {
|
|||||||
expect(result.nextQuestion).toBeUndefined();
|
expect(result.nextQuestion).toBeUndefined();
|
||||||
expect(result.proposal.nextQuestion).toBeUndefined();
|
expect(result.proposal.nextQuestion).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults to proposal-only mode", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const applyValidatedProposal = vi.fn();
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue(makeProposal()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCase(makeUpdateRequest(), {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyValidatedProposal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.stage).toBe("proposal_ready");
|
||||||
|
expect(applyValidatedProposal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the proposal only when explicitly enabled", async () => {
|
||||||
|
const { updateCase } = await import("@/lib/graph/orchestrator.js");
|
||||||
|
const request = makeUpdateRequest({
|
||||||
|
situationGraph: makeGraph({
|
||||||
|
centralStatement:
|
||||||
|
"Complaint counts increased while production also increased.",
|
||||||
|
nodes: [
|
||||||
|
makeNode({
|
||||||
|
id: "n-rate",
|
||||||
|
label: "Complaint rate",
|
||||||
|
description: "Need complaint rate",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "high",
|
||||||
|
affects: ["n-conclusion"],
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-other-unknown",
|
||||||
|
label: "Other unknown",
|
||||||
|
description: "Another unresolved unknown",
|
||||||
|
kind: "unknown",
|
||||||
|
status: "unknown",
|
||||||
|
confidence: "medium",
|
||||||
|
}),
|
||||||
|
makeNode({
|
||||||
|
id: "n-conclusion",
|
||||||
|
label: "Quality deterioration",
|
||||||
|
description: "Quality conclusion",
|
||||||
|
kind: "conclusion",
|
||||||
|
status: "supported",
|
||||||
|
confidence: "medium",
|
||||||
|
dependsOn: ["n-rate"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
activeUnknownNodeId: "n-rate",
|
||||||
|
resolvedNodeIds: [],
|
||||||
|
currentSummary: "Initial summary",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const provider = {
|
||||||
|
generateReconstruction: vi.fn().mockResolvedValue({
|
||||||
|
addedNodes: [],
|
||||||
|
updatedNodes: [
|
||||||
|
{
|
||||||
|
nodeId: "n-rate",
|
||||||
|
previousStatus: "unknown",
|
||||||
|
newStatus: "resolved",
|
||||||
|
previousValue: "2.0 complaints per 100 units",
|
||||||
|
newValue: "1.9 complaints per 100 units",
|
||||||
|
reason: "The answer provides the updated rate.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeId: "n-conclusion",
|
||||||
|
previousStatus: "supported",
|
||||||
|
newStatus: "weakened",
|
||||||
|
previousValue: null,
|
||||||
|
newValue: null,
|
||||||
|
reason: "The updated rate weakens the conclusion.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
addedEdges: [],
|
||||||
|
removedEdgeIds: [],
|
||||||
|
resolvedUnknownNodeIds: ["n-rate"],
|
||||||
|
affectedNodeIds: ["n-conclusion"],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateCase(request, {
|
||||||
|
provider,
|
||||||
|
config: MOCK_CONFIG,
|
||||||
|
applyProposal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
stage: "update_applied",
|
||||||
|
affectedNodeIds: expect.arrayContaining(["n-rate", "n-conclusion"]),
|
||||||
|
resolvedUnknownNodeIds: ["n-rate"],
|
||||||
|
previousActiveUnknownNodeId: "n-rate",
|
||||||
|
newActiveUnknownNodeId: "n-other-unknown",
|
||||||
|
});
|
||||||
|
expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startCase behaviour remains unchanged", 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.selectedQuestion).toEqual({
|
||||||
|
id: "q-1",
|
||||||
|
question: "What denominator is being used for the complaint rate?",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user