feat: define graph update proposal contract

This commit is contained in:
2026-08-02 08:00:37 +01:00
parent 02a6ecd0da
commit b38a6a9f2e
4 changed files with 477 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import {
ConfidenceLevel,
SituationKind,
SituationRelationship,
SituationStatus,
} from "./schema.js";
const DEFAULT_PROMPT_VERSION = "v0.4";
function formatEnumValues(values) {
return Object.values(values).join(" | ");
}
function formatGraph(graph) {
return JSON.stringify(graph, null, 2);
}
function formatExampleAnswerBlock() {
return [
"Example answer the model must be able to handle without hard-coding output:",
'"The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units."',
"This may justify resolving a rate-related unknown or updating a metric node, but only if the current graph and answer support that proposal.",
].join("\n");
}
export function buildGraphUpdatePrompt({
situationGraph,
previousQuestion,
answer,
promptVersion = DEFAULT_PROMPT_VERSION,
}) {
const nodeKinds = formatEnumValues(SituationKind);
const nodeStatuses = formatEnumValues(SituationStatus);
const edgeRelationships = formatEnumValues(SituationRelationship);
const confidenceLevels = formatEnumValues(ConfidenceLevel);
return `You are proposing a graph update for Confidence Engine ${promptVersion}.
Return exactly one JSON object matching the GraphUpdate contract.
Return JSON only. Do not include markdown, explanation, or any text before or after the JSON object.
## Current Situation Graph
${formatGraph(situationGraph)}
## Previous Selected Question
${previousQuestion}
## User Answer
${answer}
## Allowed Node Kinds
${nodeKinds}
## Allowed Node Statuses
${nodeStatuses}
## Allowed Edge Relationships
${edgeRelationships}
## Allowed Confidence Values
${confidenceLevels}
## Required JSON Field Names
The JSON object must contain exactly these top-level fields:
- addedNodes
- updatedNodes
- addedEdges
- removedEdgeIds
- resolvedUnknownNodeIds
- affectedNodeIds
## Required Shapes
- addedNodes: array of nodes using these exact keys:
id, label, description, kind, status, confidence, value, unit, evidenceIds, dependsOn, affects, parentId, childIds
- updatedNodes: array of node updates using these exact keys:
nodeId, previousStatus, newStatus, previousValue, newValue, reason
- addedEdges: array of edges using these exact keys:
id, fromNodeId, toNodeId, relationship, confidence, description
- removedEdgeIds: array of strings
- resolvedUnknownNodeIds: array of strings
- affectedNodeIds: array of strings
## Proposal Rules
1. Propose changes only. Never return a replacement graph.
2. Preserve unrelated nodes and edges by omitting them from the proposal.
3. Reference existing node IDs when updating an existing concept.
4. Use addedNodes only for genuinely new concepts.
5. Resolve the active unknown when the answer supports it.
6. Propagate only through explicit dependencies or relationships already present in the graph.
7. Do not invent evidence.
8. Do not create unsupported causal edges.
9. Do not ask more than one next question. In this contract you are not returning any next-question field at all.
10. Use empty arrays when there are no changes in a category.
11. Never return null array entries.
12. Never use unknown enum values.
13. Do not change existing IDs.
14. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
## Additional Guidance
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
- If a new metric or observation is necessary, add the smallest set of nodes and edges needed.
- If the answer does not justify a change, return empty arrays for every category.
## Example Constraint Reminder
${formatExampleAnswerBlock()}
## Output Contract Reminder
Return one JSON object only, with exact field names and exact enum values.
Never include a full graph.
Never include a nextQuestion field.
`;
}
export const buildUpdatePrompt = buildGraphUpdatePrompt;
+138
View File
@@ -0,0 +1,138 @@
import { graphUpdateSchema } from "./schema.js";
const TOP_LEVEL_ARRAY_FIELDS = [
"addedNodes",
"updatedNodes",
"addedEdges",
"removedEdgeIds",
"resolvedUnknownNodeIds",
"affectedNodeIds",
];
function cloneJsonSafe(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
}
function removeNullArrayEntries(value, path = [], normalisationsApplied = []) {
if (Array.isArray(value)) {
const filtered = [];
value.forEach((item, index) => {
if (item === null) {
normalisationsApplied.push({
path: [...path, index],
change: "Removed null array entry",
});
return;
}
filtered.push(
removeNullArrayEntries(item, [...path, index], normalisationsApplied),
);
});
return filtered;
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
removeNullArrayEntries(child, [...path, key], normalisationsApplied),
]),
);
}
return value;
}
function applyKnownEnumAliases(proposal, normalisationsApplied) {
if (!proposal || typeof proposal !== "object") return proposal;
if (Array.isArray(proposal.addedNodes)) {
proposal.addedNodes = proposal.addedNodes.map((node, index) => {
if (node?.kind === "reported_statement") {
normalisationsApplied.push({
path: ["addedNodes", index, "kind"],
change: "Converted reported_statement to reported_claim",
});
return { ...node, kind: "reported_claim" };
}
return node;
});
}
return proposal;
}
function fillMissingOptionalArrays(proposal, normalisationsApplied) {
if (!proposal || typeof proposal !== "object") return proposal;
for (const field of TOP_LEVEL_ARRAY_FIELDS) {
if (!(field in proposal)) {
proposal[field] = [];
normalisationsApplied.push({
path: [field],
change: "Filled missing optional array with []",
});
}
}
return proposal;
}
export function parseGraphUpdateProposal(rawResponse) {
const raw = rawResponse;
let parsed;
if (typeof rawResponse === "string") {
try {
parsed = JSON.parse(rawResponse);
} catch (error) {
return {
success: false,
proposal: null,
raw,
normalisationsApplied: [],
errors: [error.message || "Model response is not valid JSON"],
};
}
} else if (rawResponse && typeof rawResponse === "object") {
parsed = cloneJsonSafe(rawResponse);
} else {
return {
success: false,
proposal: null,
raw,
normalisationsApplied: [],
errors: ["Graph update proposal must be a JSON object or JSON string"],
};
}
const normalisationsApplied = [];
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
const parsedProposal = graphUpdateSchema.safeParse(normalised);
if (!parsedProposal.success) {
return {
success: false,
proposal: null,
raw,
normalisationsApplied,
errors: parsedProposal.error.issues.map((issue) => ({
path: issue.path,
message: issue.message,
code: issue.code,
})),
};
}
return {
success: true,
proposal: parsedProposal.data,
raw,
normalisationsApplied,
errors: [],
};
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { buildGraphUpdatePrompt } from "@/lib/graph/prompt-builder.js";
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
function makeContext() {
const unknown = makeNode({
id: "n-unknown",
label: "Complaint rate denominator",
description: "Need the denominator to compare complaint rates",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const observation = makeNode({
id: "n-obs",
label: "Complaints up 35%",
description: "Complaints increased by 35%",
kind: "observation",
status: "supported",
confidence: "high",
});
return {
situationGraph: makeGraph({
centralStatement: "Complaints increased while production increased.",
nodes: [unknown, observation],
edges: [
makeEdge({
id: "e1",
fromNodeId: observation.id,
toNodeId: unknown.id,
relationship: "supports",
confidence: "high",
description: "Observation informs the unknown",
}),
],
activeUnknownNodeId: unknown.id,
resolvedNodeIds: [],
currentSummary:
"Nodes: 1 observation, 1 unknown | Edges: 1 total | Unknowns: 1 unresolved",
}),
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.",
};
}
describe("buildGraphUpdatePrompt", () => {
it("includes the current graph", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain(
"Complaints increased while production increased.",
);
expect(prompt).toContain("Complaint rate denominator");
});
it("includes previous question and answer", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain(
"What denominator is being used for the complaint rate?",
);
expect(prompt).toContain(
"The complaint rate fell from 2.0 complaints per 100 units to 1.9 complaints per 100 units.",
);
});
it("contains exact schema keys", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain("addedNodes");
expect(prompt).toContain("updatedNodes");
expect(prompt).toContain("addedEdges");
expect(prompt).toContain("removedEdgeIds");
expect(prompt).toContain("resolvedUnknownNodeIds");
expect(prompt).toContain("affectedNodeIds");
});
it("lists enum values", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain(
"observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion",
);
expect(prompt).toContain(
"known | unknown | provisional | supported | weakened | contradicted | resolved",
);
expect(prompt).toContain(
"supports | weakens | contradicts | depends_on | causes | may_cause | measures | compares_with | updates | other",
);
});
it("forbids full-graph replacement", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain("Never return a replacement graph");
expect(prompt).toContain("Propose changes only");
});
it("requires JSON only", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain("Return JSON only");
expect(prompt).toContain("Return one JSON object only");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { parseGraphUpdateProposal } from "@/lib/graph/update-proposal.js";
function makeValidProposal(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 complaint rate.",
},
],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
...overrides,
};
}
describe("parseGraphUpdateProposal", () => {
it("parses a valid proposal", () => {
const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.success).toBe(true);
expect(result.proposal.updatedNodes).toHaveLength(1);
});
it("fails on malformed JSON", () => {
const result = parseGraphUpdateProposal("{not json");
expect(result.success).toBe(false);
});
it("fails when required update content is invalid", () => {
const result = parseGraphUpdateProposal({
updatedNodes: [{ nodeId: "n-unknown" }],
});
expect(result.success).toBe(false);
});
it("removes null array entries and logs them", () => {
const result = parseGraphUpdateProposal(
JSON.stringify({
...makeValidProposal(),
addedNodes: [null],
}),
);
expect(result.success).toBe(true);
expect(result.proposal.addedNodes).toEqual([]);
expect(result.normalisationsApplied).toEqual(
expect.arrayContaining([
expect.objectContaining({ change: "Removed null array entry" }),
]),
);
});
it("fills missing optional arrays with empty arrays", () => {
const result = parseGraphUpdateProposal({
updatedNodes: [],
});
expect(result.success).toBe(true);
expect(result.proposal.addedNodes).toEqual([]);
expect(result.proposal.addedEdges).toEqual([]);
expect(result.normalisationsApplied.length).toBeGreaterThan(0);
});
it("normalises confirmed enum alias and preserves IDs", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
addedNodes: [
{
id: "n-new",
label: "Reported update",
description: "A new reported claim",
kind: "reported_statement",
status: "supported",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
});
expect(result.success).toBe(true);
expect(result.proposal.addedNodes[0].kind).toBe("reported_claim");
expect(result.proposal.addedNodes[0].id).toBe("n-new");
});
it("unknown enum values still fail", () => {
const result = parseGraphUpdateProposal({
...makeValidProposal(),
addedNodes: [
{
id: "n-new",
label: "Bad node",
description: "Bad node",
kind: "unsupported_kind",
status: "supported",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
});
expect(result.success).toBe(false);
});
it("does not invent a next question", () => {
const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.proposal.nextQuestion).toBeUndefined();
});
});