From b38a6a9f2e23d83e3e666878f279f6680b718f29 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 2 Aug 2026 08:00:37 +0100 Subject: [PATCH] feat: define graph update proposal contract --- lib/graph/prompt-builder.js | 114 +++++++++++++++++++++++ lib/graph/update-proposal.js | 138 ++++++++++++++++++++++++++++ tests/graph/prompt-builder.test.js | 101 ++++++++++++++++++++ tests/graph/update-proposal.test.js | 124 +++++++++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 lib/graph/prompt-builder.js create mode 100644 lib/graph/update-proposal.js create mode 100644 tests/graph/prompt-builder.test.js create mode 100644 tests/graph/update-proposal.test.js diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js new file mode 100644 index 0000000..87b101d --- /dev/null +++ b/lib/graph/prompt-builder.js @@ -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; diff --git a/lib/graph/update-proposal.js b/lib/graph/update-proposal.js new file mode 100644 index 0000000..f44a581 --- /dev/null +++ b/lib/graph/update-proposal.js @@ -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: [], + }; +} diff --git a/tests/graph/prompt-builder.test.js b/tests/graph/prompt-builder.test.js new file mode 100644 index 0000000..7ea795a --- /dev/null +++ b/tests/graph/prompt-builder.test.js @@ -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"); + }); +}); diff --git a/tests/graph/update-proposal.test.js b/tests/graph/update-proposal.test.js new file mode 100644 index 0000000..6bba205 --- /dev/null +++ b/tests/graph/update-proposal.test.js @@ -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(); + }); +});