Files
confidence-engine/lib/graph/prompt-builder.js
T
robbond 6aef806845 feat(graph): add structuralActionRequired contract (57J.67)
- Add structuralActionRequired field to graphUpdateSchema (optional boolean nullable)
- Validate declaration consistency in validateGraphUpdate():
  - true requires meaningful mutation (addedNodes/updatedNodes/addedEdges)
  - false permits intentional no-op when userSupportedMeaning populated
  - null/absent with meaning → reject
  - true/false mismatch on output shape → reject
  - preserve legacy no-op guard for non-contract paths
- Update prompt-builder: add field to required list, insert contract section between rules and Additional Guidance with two mandatory sentences
- 50 new tests: schema validation (4), prompt builder content checks (10), utils contract matrix (10), plus 26 existing suite migrations

All 197 graph tests pass.
2026-08-12 07:26:26 +01:00

169 lines
11 KiB
JavaScript

import {
ConfidenceLevel,
answerResolutionGuidance,
answerSupportCategory,
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);
const supportCategories = formatEnumValues(answerSupportCategory);
const resolutionGuidanceValues = formatEnumValues(answerResolutionGuidance);
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}
## Allowed answerMeaning.supportCategory Values
${supportCategories}
## Allowed answerMeaning.resolutionGuidance Values
${resolutionGuidanceValues}
## Required JSON Field Names
The JSON object must contain exactly these top-level fields:
- addedNodes
- updatedNodes
- addedEdges
- removedEdgeIds
- resolvedUnknownNodeIds
- affectedNodeIds
- selectedQuestion
- answerMeaning
- structuralActionRequired
## 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
- selectedQuestion: either null or an object using these exact keys:
nodeId, question, reason
- answerMeaning: either null or an object using these exact keys:
userSupportedMeaning, possibleInference, supportCategory, resolutionGuidance
- structuralActionRequired: boolean (required when userSupportedMeaning is populated)
## 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 answered unknown first when the answer supports it.
6. If answerMeaning.userSupportedMeaning contains consequential information or unresolved uncertainty that is not already represented in the graph, you MUST express its effect through structural mutation. This may be an update/refinement of existing structure, resolution of an existing unknown, a genuinely new unknown, or a justified relationship. answerMeaning alone is not sufficient for a successful proposal.
7. Add new unknown nodes only when the answer introduces a new decision, claim, object, measure, dependency, or unresolved term directly relevant to the case.
8. Add at most 3 new unknown nodes.
9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters.
9a. In the description of every new unknown, explicitly include a short why-it-matters clause using wording such as because, so that, needed to decide, or matters because.
10. Do not add broad generic discovery questions.
11. Do not add duplicate unknowns.
12. Do not expand unrelated branches.
13. Propagate only through explicit dependencies or relationships already present in the graph, except for the minimal new edges needed to connect validated new unknowns to the relevant answer-derived decision or context node.
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
14. Do not invent evidence.
15. Do not create unsupported causal edges.
16. When your proposal adds one or more new unresolved unknowns (status !== 'resolved'), you MUST include a selectedQuestion identifying one of those as a candidate unknown node. The engine validates your candidate and retains deterministic final-priority selection; your candidate does not need to be the highest-scoring unknown — it only needs to be a valid unresolved unknown that exists in the graph or in addedNodes.
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
20. Return selectedQuestion as null only when no consequential unresolved unknown remains.
21. Use empty arrays when there are no changes in a category.
22. Never return null array entries.
23. Never use unknown enum values.
24. Do not change existing IDs.
25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
26. answerMeaning.userSupportedMeaning must state only what the user's answer directly supports.
27. Put any stronger interpretation in answerMeaning.possibleInference, not in userSupportedMeaning.
28. When answerMeaning is present, populate supportCategory with one of the allowed values whenever the user's meaning fits an existing category. Use other when none of the protected categories applies. Do not leave supportCategory null merely because the wording is uncertain.
29. If the answer is conditional or qualified, preserve that qualification explicitly in userSupportedMeaning.
30. If the answer says the user is unsure or does not resolve the distinction, state that uncertainty directly in userSupportedMeaning.
31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning.
32. Populate resolutionGuidance when the user's meaning genuinely implies must_remain_unresolved, may_resolve, or must_resolve. Keep it null only when no existing resolution state actually applies.
## Contract: structuralActionRequired Declaration Rule
When answerMeaning.userSupportedMeaning is populated you MUST set structuralActionRequired to match what your proposal outputs:
- Set structuralActionRequired = true if and only if your proposal adds nodes, updates node status/value, or modifies edges (addedNodes.length > 0, updatedNodes with a meaningful change, or addedEdges.length > 0).
- Set structuralActionRequired = false if and only if your proposal has zero structural mutations — the two sentences are an intentional no-op declaration.
## Additional Guidance
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
- When rule #6 applies to explicitly unresolved uncertainty: first check whether an existing unresolved node already represents the same uncertainty; if so, update/refine that existing structure rather than adding a duplicate; if no such node exists, add a new unknown that directly represents the unresolved uncertainty; do not use an edge alone to represent a previously unrepresented uncertainty.
- "Same uncertainty" means the same resolution question: resolving the existing unknown would also resolve the uncertainty introduced by the user's answer. Mere topical overlap (concerning the same topic, object, decision, or domain) is not automatically the same uncertainty. If the new concern can remain unresolved after the existing node is resolved, represent it separately as a distinct uncertainty.
- When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation.
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
- When selectedQuestion is provided, your role ends at supplying one valid unresolved unknown node from the graph or addedNodes — the engine retains deterministic final-priority selection and may choose a different question if multiple candidates exist.
- If rule #6 does not apply (the answer contains no user-supported meaning that requires graph progress) and there is no other justification for change, return empty arrays for every category.
- If rule #6 applies but you choose an update/refinement of existing structure, resolve an existing unknown, or add justified new structure, your structural proposal plus answerMeaning together represent the complete response — answerMeaning preserves semantic fidelity while structural mutation handles graph progress; neither replaces the other.
- If you add a new unknown with addedNodes, connect it with at least one addedEdge to an existing updated/resolved node or to a newly added non-unknown node from the answer.
- For answerMeaning.supportCategory, use only these exact values: ${supportCategories}. Use other when none of the protected categories applies.
- For answerMeaning.resolutionGuidance, use only these exact values: ${resolutionGuidanceValues}. Keep it null only when none of those existing resolution states genuinely applies.
## 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 any field other than the contract fields above.
`;
}
export const buildUpdatePrompt = buildGraphUpdatePrompt;