feat: surface new unknowns after graph updates

This commit is contained in:
2026-08-02 10:30:59 +01:00
parent 904aec7616
commit 72ef175971
16 changed files with 910 additions and 41 deletions
+207
View File
@@ -41,6 +41,180 @@ function normaliseText(value) {
.trim();
}
function buildNodeById(graph, addedNodes = []) {
return new Map(
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
);
}
function isCompoundQuestion(question) {
if (typeof question !== "string") return false;
const trimmed = question.trim();
if (!trimmed) return false;
const questionMarks = (trimmed.match(/\?/g) || []).length;
if (questionMarks > 1) return true;
if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
return true;
return false;
}
function validateAddedUnknowns(graph, proposal) {
const errors = [];
const addedUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown",
);
if (addedUnknowns.length > 3) {
errors.push(
`Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
);
}
const unresolvedExistingUnknowns = graph.nodes.filter(
(node) =>
node.kind === "unknown" &&
!proposal.resolvedUnknownNodeIds.includes(node.id),
);
const seenAddedUnknownMeanings = new Map();
const answerDerivedNodeIds = new Set([
...proposal.updatedNodes.map((update) => update.nodeId),
...proposal.resolvedUnknownNodeIds,
...proposal.addedNodes
.filter((node) => node.kind !== "unknown")
.map((node) => node.id),
]);
for (const unknownNode of addedUnknowns) {
const meaningKeys = [
normaliseText(unknownNode.label),
normaliseText(unknownNode.description),
].filter(Boolean);
for (const meaningKey of meaningKeys) {
if (seenAddedUnknownMeanings.has(meaningKey)) {
errors.push(
`Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
);
break;
}
seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
}
for (const existingUnknown of unresolvedExistingUnknowns) {
const existingMeaningKeys = [
normaliseText(existingUnknown.label),
normaliseText(existingUnknown.description),
].filter(Boolean);
if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
errors.push(
`Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
);
break;
}
}
if (
unknownNode.description.trim() === unknownNode.label.trim() ||
!/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
unknownNode.description,
)
) {
errors.push(
`New unknown must include why it matters in its description: "${unknownNode.id}"`,
);
}
const connectedEdge = proposal.addedEdges.find(
(edge) =>
(edge.fromNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.toNodeId)) ||
(edge.toNodeId === unknownNode.id &&
answerDerivedNodeIds.has(edge.fromNodeId)),
);
if (!connectedEdge) {
errors.push(
`New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
);
}
}
return errors;
}
function validateSelectedQuestion(graph, proposal) {
const errors = [];
const selectedQuestion = proposal.selectedQuestion;
const nodeById = buildNodeById(graph, proposal.addedNodes);
if (selectedQuestion == null) {
return { errors, selectedQuestionNodeId: null };
}
const node = nodeById.get(selectedQuestion.nodeId);
if (!node) {
errors.push(
`selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
);
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
if (node.kind !== "unknown") {
errors.push(
`selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
);
}
const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
selectedQuestion.nodeId,
);
const updatedStatus = proposal.updatedNodes.find(
(update) => update.nodeId === selectedQuestion.nodeId,
)?.newStatus;
const effectiveStatus = updatedStatus ?? node.status;
if (resolvesNode || effectiveStatus === "resolved") {
errors.push(
`selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
);
}
if (
graph.activeUnknownNodeId &&
proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
selectedQuestion.nodeId === graph.activeUnknownNodeId
) {
errors.push(
`selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
);
}
if (isCompoundQuestion(selectedQuestion.question)) {
errors.push("selectedQuestion must be a single non-compound question");
}
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
}
function validateQuestionSelectionRequirement(graph, proposal) {
const addedConsequentialUnknowns = proposal.addedNodes.filter(
(node) => node.kind === "unknown" && node.status !== "resolved",
);
if (
proposal.selectedQuestion == null &&
addedConsequentialUnknowns.length > 0
) {
return [
"selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
];
}
return [];
}
function buildResolvedUnknownUpdate(node) {
return {
nodeId: node.id,
@@ -191,6 +365,9 @@ function buildAffectedNodeIds(graph, proposal) {
function buildChangesApplied(proposal, affectedNodeIds) {
return {
addedNodeCount: proposal.addedNodes.length,
addedUnknownCount: proposal.addedNodes.filter(
(node) => node.kind === "unknown",
).length,
updatedNodeCount: proposal.updatedNodes.length,
addedEdgeCount: proposal.addedEdges.length,
removedEdgeCount: proposal.removedEdgeIds.length,
@@ -322,6 +499,18 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
proposalCompatibilityErrors.push(
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
);
proposalCompatibilityErrors.push(
...validateAddedUnknowns(situationGraph, validatedProposal),
);
const selectedQuestionValidation = validateSelectedQuestion(
situationGraph,
validatedProposal,
);
proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
proposalCompatibilityErrors.push(
...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
);
if (proposalCompatibilityErrors.length > 0) {
return {
@@ -361,6 +550,10 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
newActiveUnknownNodeId = null;
}
if (validatedProposal.selectedQuestion?.nodeId) {
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
}
const remainingUnknownExists =
newActiveUnknownNodeId != null &&
updatedSituationGraph.nodes.some(
@@ -378,6 +571,19 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
)?.nodeId ?? null;
}
if (
validatedProposal.selectedQuestion?.nodeId &&
newActiveUnknownNodeId !== validatedProposal.selectedQuestion.nodeId
) {
return {
success: false,
stage: "proposal_compatibility",
errors: [
`activeUnknownNodeId and selectedQuestion.nodeId disagree: "${newActiveUnknownNodeId}" vs "${validatedProposal.selectedQuestion.nodeId}"`,
],
};
}
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
@@ -430,6 +636,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: validatedProposal.selectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation,
};
+1
View File
@@ -299,6 +299,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
stage: "update_applied",
updatedSituationGraph: applicationResult.updatedSituationGraph,
proposal: applicationResult.graphUpdate,
selectedQuestion: applicationResult.selectedQuestion,
affectedNodeIds: applicationResult.affectedNodeIds,
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
previousActiveUnknownNodeId:
+29 -12
View File
@@ -68,6 +68,7 @@ The JSON object must contain exactly these top-level fields:
- removedEdgeIds
- resolvedUnknownNodeIds
- affectedNodeIds
- selectedQuestion
## Required Shapes
- addedNodes: array of nodes using these exact keys:
@@ -79,27 +80,43 @@ The JSON object must contain exactly these top-level fields:
- 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
## 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.
5. Resolve the answered unknown first when the answer supports it.
6. Then inspect the answer for newly introduced consequential uncertainty.
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. Select exactly one new active unknown in selectedQuestion when any consequential unresolved unknown exists.
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. Return selectedQuestion as null only when no consequential unresolved unknown remains.
20. Use empty arrays when there are no changes in a category.
21. Never return null array entries.
22. Never use unknown enum values.
23. Do not change existing IDs.
24. 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.
- 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 a new metric or observation is necessary, add the smallest set of nodes and edges needed.
- 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.
- If the answer does not justify a change, return empty arrays for every category.
## Example Constraint Reminder
@@ -108,7 +125,7 @@ ${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.
Never include any field other than the contract fields above.
`;
}
+16 -2
View File
@@ -101,11 +101,22 @@ const graphUpdateNodeChangeSchema = z.object({
nodeId: z.string().min(1),
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
previousValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
previousValue: z
.union([z.string(), z.number(), z.null()])
.nullable()
.optional(),
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
reason: z.string().min(1),
});
export const selectedQuestionSchema = z
.object({
nodeId: z.string().min(1),
question: z.string().min(1),
reason: z.string().min(1),
})
.strict();
export const graphUpdateSchema = z.object({
addedNodes: z.array(situationNodeSchema).default([]),
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
@@ -113,6 +124,7 @@ export const graphUpdateSchema = z.object({
removedEdgeIds: z.array(z.string()).default([]),
resolvedUnknownNodeIds: z.array(z.string()).default([]),
affectedNodeIds: z.array(z.string()).default([]),
selectedQuestion: selectedQuestionSchema.nullable().default(null),
});
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
@@ -169,7 +181,9 @@ export function makeNode(opts) {
/** Create a minimal valid edge — used in tests and fixtures */
export function makeEdge(opts) {
return situationEdgeSchema.parse({
id: opts.id || "e" + opts.fromNodeId.slice(0,3) + "-" + opts.toNodeId.slice(0,3),
id:
opts.id ||
"e" + opts.fromNodeId.slice(0, 3) + "-" + opts.toNodeId.slice(0, 3),
fromNodeId: opts.fromNodeId,
toNodeId: opts.toNodeId,
relationship: opts.relationship ?? "supports",
+19
View File
@@ -9,6 +9,8 @@ const TOP_LEVEL_ARRAY_FIELDS = [
"affectedNodeIds",
];
const TOP_LEVEL_NULLABLE_FIELDS = ["selectedQuestion"];
function cloneJsonSafe(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
@@ -79,6 +81,22 @@ function fillMissingOptionalArrays(proposal, normalisationsApplied) {
return proposal;
}
function fillMissingNullableFields(proposal, normalisationsApplied) {
if (!proposal || typeof proposal !== "object") return proposal;
for (const field of TOP_LEVEL_NULLABLE_FIELDS) {
if (!(field in proposal)) {
proposal[field] = null;
normalisationsApplied.push({
path: [field],
change: "Filled missing optional nullable field with null",
});
}
}
return proposal;
}
export function parseGraphUpdateProposal(rawResponse) {
const raw = rawResponse;
let parsed;
@@ -111,6 +129,7 @@ export function parseGraphUpdateProposal(rawResponse) {
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
normalised = fillMissingNullableFields(normalised, normalisationsApplied);
const parsedProposal = graphUpdateSchema.safeParse(normalised);