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
+18 -3
View File
@@ -23,12 +23,17 @@ export default function GraphUpdateView({ updateResult }) {
affectedNodeIds, affectedNodeIds,
previousActiveUnknownNodeId, previousActiveUnknownNodeId,
newActiveUnknownNodeId, newActiveUnknownNodeId,
selectedQuestion,
changesApplied, changesApplied,
proposal, proposal,
previousSituationGraph, previousSituationGraph,
updatedSituationGraph, updatedSituationGraph,
} = updateResult; } = updateResult;
const newlySurfacedUnknownNodeIds = (proposal.addedNodes || [])
.filter((node) => node.kind === "unknown")
.map((node) => node.id);
const previousNodesById = new Map( const previousNodesById = new Map(
(previousSituationGraph?.nodes || []).map((node) => [node.id, node]), (previousSituationGraph?.nodes || []).map((node) => [node.id, node]),
); );
@@ -123,10 +128,15 @@ export default function GraphUpdateView({ updateResult }) {
{resolveActiveUnknown(newActiveUnknownNodeId)} {resolveActiveUnknown(newActiveUnknownNodeId)}
</div> </div>
)} )}
{!newActiveUnknownNodeId && previousActiveUnknownNodeId && ( {selectedQuestion?.question && (
<div> <div>
<span className="font-medium">Next question status:</span> No next <span className="font-medium">Next question:</span>{" "}
question selected yet. {selectedQuestion.question}
</div>
)}
{!selectedQuestion?.question && !newActiveUnknownNodeId && previousActiveUnknownNodeId && (
<div>
<span className="font-medium">Next question status:</span> No next question selected yet.
</div> </div>
)} )}
</div> </div>
@@ -137,6 +147,11 @@ export default function GraphUpdateView({ updateResult }) {
items={resolvedUnknownNodeIds} items={resolvedUnknownNodeIds}
renderItem={resolveNodePresentation} renderItem={resolveNodePresentation}
/> />
<ListSection
title="Newly surfaced unknowns"
items={newlySurfacedUnknownNodeIds}
renderItem={resolveNodePresentation}
/>
<ListSection <ListSection
title="Affected nodes" title="Affected nodes"
items={affectedNodeIds} items={affectedNodeIds}
+56 -2
View File
@@ -52,9 +52,16 @@ function normaliseStartResult(data) {
typeof data?.selectedQuestion === "string" typeof data?.selectedQuestion === "string"
? data.selectedQuestion ? data.selectedQuestion
: data?.selectedQuestion?.question ?? null, : data?.selectedQuestion?.question ?? null,
newlySurfacedNodeIds: data?.newlySurfacedNodeIds ?? [],
}; };
} }
function normaliseUpdateSelectedQuestion(selectedQuestion) {
if (!selectedQuestion) return null;
if (typeof selectedQuestion === "string") return selectedQuestion;
return selectedQuestion.question ?? null;
}
export function ScenarioResultPanels({ status, result }) { export function ScenarioResultPanels({ status, result }) {
if (!result) return null; if (!result) return null;
@@ -83,6 +90,7 @@ export function ScenarioResultPanels({ status, result }) {
<SituationGraphView <SituationGraphView
situationGraph={result.situationGraph} situationGraph={result.situationGraph}
selectedQuestion={result.selectedQuestion} selectedQuestion={result.selectedQuestion}
newlySurfacedNodeIds={result.newlySurfacedNodeIds}
/> />
)} )}
@@ -192,7 +200,12 @@ export default function ScenarioForm() {
setResult((current) => ({ setResult((current) => ({
...current, ...current,
situationGraph: outcome.updatedSituationGraph, situationGraph: outcome.updatedSituationGraph,
selectedQuestion: null, selectedQuestion: normaliseUpdateSelectedQuestion(
outcome.selectedQuestion,
),
newlySurfacedNodeIds: (outcome.proposal?.addedNodes || [])
.filter((node) => node.kind === "unknown")
.map((node) => node.id),
diagnostics: outcome.diagnostics, diagnostics: outcome.diagnostics,
})); }));
setAnswer(""); setAnswer("");
@@ -208,9 +221,14 @@ export default function ScenarioForm() {
const canRenderAnswerForm = const canRenderAnswerForm =
status === "success" && status === "success" &&
updateStatus === "idle" &&
Boolean(result?.situationGraph) && Boolean(result?.situationGraph) &&
Boolean(result?.selectedQuestion); Boolean(result?.selectedQuestion);
const canRenderDisabledFollowUpForm =
updateStatus === "success" &&
Boolean(updateResult?.selectedQuestion?.question || result?.selectedQuestion);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -277,8 +295,44 @@ export default function ScenarioForm() {
{updateStatus === "success" && updateResult && ( {updateStatus === "success" && updateResult && (
<> <>
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800"> <div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
No next question selected yet. {updateResult.selectedQuestion?.question
? updateResult.selectedQuestion.question
: "No next question selected yet."}
</div> </div>
{canRenderDisabledFollowUpForm && (
<form className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 opacity-70">
<div>
<h2 className="text-base font-semibold text-gray-900">Selected Question</h2>
<p className="mt-1 text-sm text-gray-700">
{updateResult.selectedQuestion?.question || result?.selectedQuestion}
</p>
</div>
<div>
<label htmlFor="follow-up-disabled-textarea" className="mb-2 block text-sm font-medium text-gray-700">
Your answer
</label>
<textarea
id="follow-up-disabled-textarea"
rows={4}
disabled
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm opacity-70"
placeholder="Additional submission is disabled in this one-update prototype."
/>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-gray-500">
Additional submission is disabled in this one-update prototype.
</p>
<button
type="button"
disabled
className="rounded-lg bg-blue-700 px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Update situation
</button>
</div>
</form>
)}
<GraphUpdateView updateResult={updateResult} /> <GraphUpdateView updateResult={updateResult} />
</> </>
)} )}
+25 -1
View File
@@ -8,6 +8,8 @@ function NodeBadge({ children, tone = "gray" }) {
blue: "border-blue-200 bg-blue-50 text-blue-700", blue: "border-blue-200 bg-blue-50 text-blue-700",
green: "border-green-200 bg-green-50 text-green-700", green: "border-green-200 bg-green-50 text-green-700",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700", yellow: "border-yellow-200 bg-yellow-50 text-yellow-700",
red: "border-red-200 bg-red-50 text-red-700",
purple: "border-purple-200 bg-purple-50 text-purple-700",
}; };
return ( return (
@@ -17,7 +19,13 @@ function NodeBadge({ children, tone = "gray" }) {
); );
} }
function NodeGroup({ title, nodes }) { function NodeGroup({
title,
nodes,
resolvedNodeIds = new Set(),
newlySurfacedNodeIds = new Set(),
activeUnknownNodeId = null,
}) {
if (!nodes?.length) return null; if (!nodes?.length) return null;
return ( return (
@@ -32,6 +40,15 @@ function NodeGroup({ title, nodes }) {
<span className="font-medium text-gray-900">{node.label}</span> <span className="font-medium text-gray-900">{node.label}</span>
<NodeBadge tone="blue">{node.status}</NodeBadge> <NodeBadge tone="blue">{node.status}</NodeBadge>
<NodeBadge tone="green">{node.confidence}</NodeBadge> <NodeBadge tone="green">{node.confidence}</NodeBadge>
{resolvedNodeIds.has(node.id) && (
<NodeBadge tone="red">resolved unknown</NodeBadge>
)}
{newlySurfacedNodeIds.has(node.id) && (
<NodeBadge tone="purple">newly surfaced unknown</NodeBadge>
)}
{activeUnknownNodeId === node.id && (
<NodeBadge tone="yellow">active unknown</NodeBadge>
)}
{node.value != null && ( {node.value != null && (
<NodeBadge tone="yellow"> <NodeBadge tone="yellow">
{node.value} {node.value}
@@ -52,6 +69,7 @@ function NodeGroup({ title, nodes }) {
export default function SituationGraphView({ export default function SituationGraphView({
situationGraph, situationGraph,
selectedQuestion, selectedQuestion,
newlySurfacedNodeIds = [],
}) { }) {
if (!situationGraph) return null; if (!situationGraph) return null;
@@ -70,6 +88,9 @@ export default function SituationGraphView({
return acc; return acc;
}, {}); }, {});
const resolvedNodeIdSet = new Set(situationGraph.resolvedNodeIds || []);
const newlySurfacedNodeIdSet = new Set(newlySurfacedNodeIds || []);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{selectedQuestionText && ( {selectedQuestionText && (
@@ -110,6 +131,9 @@ export default function SituationGraphView({
key={kind} key={kind}
title={kind.replace(/_/g, " ")} title={kind.replace(/_/g, " ")}
nodes={nodes} nodes={nodes}
resolvedNodeIds={resolvedNodeIdSet}
newlySurfacedNodeIds={newlySurfacedNodeIdSet}
activeUnknownNodeId={situationGraph.activeUnknownNodeId}
/> />
))} ))}
+207
View File
@@ -41,6 +41,180 @@ function normaliseText(value) {
.trim(); .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) { function buildResolvedUnknownUpdate(node) {
return { return {
nodeId: node.id, nodeId: node.id,
@@ -191,6 +365,9 @@ function buildAffectedNodeIds(graph, proposal) {
function buildChangesApplied(proposal, affectedNodeIds) { function buildChangesApplied(proposal, affectedNodeIds) {
return { return {
addedNodeCount: proposal.addedNodes.length, addedNodeCount: proposal.addedNodes.length,
addedUnknownCount: proposal.addedNodes.filter(
(node) => node.kind === "unknown",
).length,
updatedNodeCount: proposal.updatedNodes.length, updatedNodeCount: proposal.updatedNodes.length,
addedEdgeCount: proposal.addedEdges.length, addedEdgeCount: proposal.addedEdges.length,
removedEdgeCount: proposal.removedEdgeIds.length, removedEdgeCount: proposal.removedEdgeIds.length,
@@ -322,6 +499,18 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
proposalCompatibilityErrors.push( proposalCompatibilityErrors.push(
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal), ...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) { if (proposalCompatibilityErrors.length > 0) {
return { return {
@@ -361,6 +550,10 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
newActiveUnknownNodeId = null; newActiveUnknownNodeId = null;
} }
if (validatedProposal.selectedQuestion?.nodeId) {
newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
}
const remainingUnknownExists = const remainingUnknownExists =
newActiveUnknownNodeId != null && newActiveUnknownNodeId != null &&
updatedSituationGraph.nodes.some( updatedSituationGraph.nodes.some(
@@ -378,6 +571,19 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
)?.nodeId ?? null; )?.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.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
@@ -430,6 +636,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId, previousActiveUnknownNodeId,
newActiveUnknownNodeId, newActiveUnknownNodeId,
selectedQuestion: validatedProposal.selectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds), changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation, graphReferenceValidation: resultReferenceValidation,
}; };
+1
View File
@@ -299,6 +299,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
stage: "update_applied", stage: "update_applied",
updatedSituationGraph: applicationResult.updatedSituationGraph, updatedSituationGraph: applicationResult.updatedSituationGraph,
proposal: applicationResult.graphUpdate, proposal: applicationResult.graphUpdate,
selectedQuestion: applicationResult.selectedQuestion,
affectedNodeIds: applicationResult.affectedNodeIds, affectedNodeIds: applicationResult.affectedNodeIds,
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds, resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
previousActiveUnknownNodeId: previousActiveUnknownNodeId:
+29 -12
View File
@@ -68,6 +68,7 @@ The JSON object must contain exactly these top-level fields:
- removedEdgeIds - removedEdgeIds
- resolvedUnknownNodeIds - resolvedUnknownNodeIds
- affectedNodeIds - affectedNodeIds
- selectedQuestion
## Required Shapes ## Required Shapes
- addedNodes: array of nodes using these exact keys: - 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 - removedEdgeIds: array of strings
- resolvedUnknownNodeIds: array of strings - resolvedUnknownNodeIds: array of strings
- affectedNodeIds: array of strings - affectedNodeIds: array of strings
- selectedQuestion: either null or an object using these exact keys:
nodeId, question, reason
## Proposal Rules ## Proposal Rules
1. Propose changes only. Never return a replacement graph. 1. Propose changes only. Never return a replacement graph.
2. Preserve unrelated nodes and edges by omitting them from the proposal. 2. Preserve unrelated nodes and edges by omitting them from the proposal.
3. Reference existing node IDs when updating an existing concept. 3. Reference existing node IDs when updating an existing concept.
4. Use addedNodes only for genuinely new concepts. 4. Use addedNodes only for genuinely new concepts.
5. Resolve the active unknown when the answer supports it. 5. Resolve the answered unknown first when the answer supports it.
6. Propagate only through explicit dependencies or relationships already present in the graph. 6. Then inspect the answer for newly introduced consequential uncertainty.
7. Do not invent evidence. 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. Do not create unsupported causal edges. 8. Add at most 3 new unknown nodes.
9. Do not ask more than one next question. In this contract you are not returning any next-question field at all. 9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters.
10. Use empty arrays when there are no changes in a category. 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.
11. Never return null array entries. 10. Do not add broad generic discovery questions.
12. Never use unknown enum values. 11. Do not add duplicate unknowns.
13. Do not change existing IDs. 12. Do not expand unrelated branches.
14. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal. 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 ## Additional Guidance
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes. - 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. - 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. - If the answer does not justify a change, return empty arrays for every category.
## Example Constraint Reminder ## Example Constraint Reminder
@@ -108,7 +125,7 @@ ${formatExampleAnswerBlock()}
## Output Contract Reminder ## Output Contract Reminder
Return one JSON object only, with exact field names and exact enum values. Return one JSON object only, with exact field names and exact enum values.
Never include a full graph. 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), nodeId: z.string().min(1),
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(), previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
newStatus: 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(), newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
reason: z.string().min(1), 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({ export const graphUpdateSchema = z.object({
addedNodes: z.array(situationNodeSchema).default([]), addedNodes: z.array(situationNodeSchema).default([]),
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]), updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
@@ -113,6 +124,7 @@ export const graphUpdateSchema = z.object({
removedEdgeIds: z.array(z.string()).default([]), removedEdgeIds: z.array(z.string()).default([]),
resolvedUnknownNodeIds: z.array(z.string()).default([]), resolvedUnknownNodeIds: z.array(z.string()).default([]),
affectedNodeIds: z.array(z.string()).default([]), affectedNodeIds: z.array(z.string()).default([]),
selectedQuestion: selectedQuestionSchema.nullable().default(null),
}); });
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */ /** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
@@ -169,7 +181,9 @@ export function makeNode(opts) {
/** Create a minimal valid edge — used in tests and fixtures */ /** Create a minimal valid edge — used in tests and fixtures */
export function makeEdge(opts) { export function makeEdge(opts) {
return situationEdgeSchema.parse({ 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, fromNodeId: opts.fromNodeId,
toNodeId: opts.toNodeId, toNodeId: opts.toNodeId,
relationship: opts.relationship ?? "supports", relationship: opts.relationship ?? "supports",
+19
View File
@@ -9,6 +9,8 @@ const TOP_LEVEL_ARRAY_FIELDS = [
"affectedNodeIds", "affectedNodeIds",
]; ];
const TOP_LEVEL_NULLABLE_FIELDS = ["selectedQuestion"];
function cloneJsonSafe(value) { function cloneJsonSafe(value) {
if (value == null) return value; if (value == null) return value;
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
@@ -79,6 +81,22 @@ function fillMissingOptionalArrays(proposal, normalisationsApplied) {
return proposal; 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) { export function parseGraphUpdateProposal(rawResponse) {
const raw = rawResponse; const raw = rawResponse;
let parsed; let parsed;
@@ -111,6 +129,7 @@ export function parseGraphUpdateProposal(rawResponse) {
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied); let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
normalised = applyKnownEnumAliases(normalised, normalisationsApplied); normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied); normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
normalised = fillMissingNullableFields(normalised, normalisationsApplied);
const parsedProposal = graphUpdateSchema.safeParse(normalised); const parsedProposal = graphUpdateSchema.safeParse(normalised);
+3
View File
@@ -25,7 +25,9 @@ function makeSuccessResult() {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n1"], resolvedUnknownNodeIds: ["n1"],
affectedNodeIds: ["n1"], affectedNodeIds: ["n1"],
selectedQuestion: null,
}, },
selectedQuestion: null,
affectedNodeIds: ["n1"], affectedNodeIds: ["n1"],
resolvedUnknownNodeIds: ["n1"], resolvedUnknownNodeIds: ["n1"],
previousActiveUnknownNodeId: "n0", previousActiveUnknownNodeId: "n0",
@@ -268,6 +270,7 @@ describe("app/api/cases/update route", () => {
resolvedUnknownNodeIds: success.resolvedUnknownNodeIds, resolvedUnknownNodeIds: success.resolvedUnknownNodeIds,
previousActiveUnknownNodeId: success.previousActiveUnknownNodeId, previousActiveUnknownNodeId: success.previousActiveUnknownNodeId,
newActiveUnknownNodeId: success.newActiveUnknownNodeId, newActiveUnknownNodeId: success.newActiveUnknownNodeId,
selectedQuestion: success.selectedQuestion,
changesApplied: success.changesApplied, changesApplied: success.changesApplied,
diagnostics: success.diagnostics, diagnostics: success.diagnostics,
}); });
+244
View File
@@ -99,6 +99,7 @@ function makeApplicationFixture() {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [complaintRateUnknown.id], resolvedUnknownNodeIds: [complaintRateUnknown.id],
affectedNodeIds: [qualityDeterioration.id], affectedNodeIds: [qualityDeterioration.id],
selectedQuestion: null,
}; };
return { return {
@@ -444,6 +445,7 @@ describe("applyValidatedProposal", () => {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
selectedQuestion: null,
}, },
}); });
@@ -455,4 +457,246 @@ describe("applyValidatedProposal", () => {
expect.arrayContaining([expect.stringContaining("no meaningful change")]), expect.arrayContaining([expect.stringContaining("no meaningful change")]),
); );
}); });
it("resolves one unknown and adds consequential unknowns with one selected question", () => {
const { graph, ids } = makeApplicationFixture();
const proposal = {
addedNodes: [
makeNode({
id: "n-commercial-value",
label: "Commercial value definition",
description:
"Need a concrete definition of commercial value because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
}),
makeNode({
id: "n-demand-evidence",
label: "Evidence of demand",
description:
"Need evidence of demand because it matters to the build decision.",
kind: "unknown",
status: "unknown",
confidence: "medium",
}),
makeNode({
id: "n-build-decision",
label: "Build Confidence Engine decision",
description: "Decision situation introduced by the answer.",
kind: "state",
status: "supported",
confidence: "medium",
}),
],
updatedNodes: [
{
nodeId: ids.complaintRateUnknown,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Decision whether to build Confidence Engine",
reason: "The answer resolves the original context unknown.",
},
],
addedEdges: [
makeEdge({
id: "e-build-commercial-value",
fromNodeId: "n-build-decision",
toNodeId: "n-commercial-value",
relationship: "depends_on",
confidence: "medium",
description: "The decision depends on defining commercial value.",
}),
makeEdge({
id: "e-build-demand-evidence",
fromNodeId: "n-build-decision",
toNodeId: "n-demand-evidence",
relationship: "depends_on",
confidence: "medium",
description: "The decision depends on evidence of demand.",
}),
],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
affectedNodeIds: [],
selectedQuestion: {
nodeId: "n-commercial-value",
question: "How should commercial value be defined for this decision?",
reason:
"This is the most consequential unresolved unknown introduced by the answer.",
},
};
const result = applyValidatedProposal({ situationGraph: graph, proposal });
expect(result.success).toBe(true);
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
ids.complaintRateUnknown,
);
expect(
result.updatedSituationGraph.nodes.some(
(node) => node.id === "n-commercial-value",
),
).toBe(true);
expect(
result.updatedSituationGraph.nodes.some(
(node) => node.id === "n-demand-evidence",
),
).toBe(true);
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
expect(result.selectedQuestion).toEqual(proposal.selectedQuestion);
});
it("rejects more than 3 added unknowns", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
addedNodes: [1, 2, 3, 4].map((index) =>
makeNode({
id: `n-unknown-${index}`,
label: `Unknown ${index}`,
description: `Need unknown ${index} because it matters to the decision.`,
kind: "unknown",
status: "unknown",
confidence: "medium",
}),
),
addedEdges: [1, 2, 3, 4].map((index) =>
makeEdge({
id: `e-unknown-${index}`,
fromNodeId: ids.complaintRateUnknown,
toNodeId: `n-unknown-${index}`,
relationship: "depends_on",
confidence: "medium",
description: `Links unknown ${index}`,
}),
),
selectedQuestion: {
nodeId: "n-unknown-1",
question: "What is unknown 1?",
reason: "Follow-up required.",
},
},
});
expect(result.success).toBe(false);
expect(result.errors.join(" ")).toContain("too many unknown nodes");
});
it("rejects unrelated added unknowns", () => {
const { graph, proposal } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
addedNodes: [
makeNode({
id: "n-unrelated",
label: "Office rent",
description:
"Need office rent because it matters to a different branch.",
kind: "unknown",
status: "unknown",
confidence: "low",
}),
],
selectedQuestion: {
nodeId: "n-unrelated",
question: "What is the office rent?",
reason: "Unrelated test.",
},
},
});
expect(result.success).toBe(false);
expect(result.errors.join(" ")).toContain(
"explicitly related to an answer-derived node",
);
});
it("rejects selected question referencing resolved node", () => {
const { graph, proposal, ids } = makeApplicationFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
...proposal,
selectedQuestion: {
nodeId: ids.complaintRateUnknown,
question: "What is the complaint rate?",
reason: "Invalid reselection.",
},
},
});
expect(result.success).toBe(false);
expect(result.errors.join(" ")).toContain(
"selectedQuestion must reference an unresolved node",
);
});
it("active unknown matches selected question node", () => {
const { graph, ids } = makeApplicationFixture();
const proposal = {
addedNodes: [
makeNode({
id: "n-success-threshold",
label: "Success threshold",
description:
"Need a success threshold because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
}),
makeNode({
id: "n-build-decision",
label: "Build Confidence Engine decision",
description: "Decision introduced by the answer.",
kind: "state",
status: "supported",
confidence: "medium",
}),
],
updatedNodes: [
{
nodeId: ids.complaintRateUnknown,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Decision whether to build Confidence Engine",
reason: "The answer resolves the original unknown.",
},
],
addedEdges: [
makeEdge({
id: "e-build-success-threshold",
fromNodeId: "n-build-decision",
toNodeId: "n-success-threshold",
relationship: "depends_on",
confidence: "medium",
description: "The decision depends on a success threshold.",
}),
],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
affectedNodeIds: [],
selectedQuestion: {
nodeId: "n-success-threshold",
question: "What success threshold would justify building it?",
reason: "One consequential unknown remains.",
},
};
const result = applyValidatedProposal({ situationGraph: graph, proposal });
expect(result.success).toBe(true);
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
});
}); });
+61
View File
@@ -113,6 +113,7 @@ function makeProposal(overrides = {}) {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"], resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [], affectedNodeIds: [],
selectedQuestion: null,
...overrides, ...overrides,
}; };
} }
@@ -529,6 +530,66 @@ describe("lib/graph/orchestrator startCase", () => {
expect(result.proposal.nextQuestion).toBeUndefined(); expect(result.proposal.nextQuestion).toBeUndefined();
}); });
it("returns selectedQuestion from applied update proposal", async () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js");
const provider = {
generateReconstruction: vi.fn().mockResolvedValue(
makeProposal({
addedNodes: [
makeNode({
id: "n-build-decision",
label: "Build Confidence Engine decision",
description: "Decision introduced by the answer.",
kind: "state",
status: "supported",
confidence: "medium",
}),
makeNode({
id: "n-commercial-value",
label: "Commercial value definition",
description:
"Need a concrete definition because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
}),
],
addedEdges: [
{
id: "e-build-commercial-value",
fromNodeId: "n-build-decision",
toNodeId: "n-commercial-value",
relationship: "depends_on",
confidence: "medium",
description:
"The decision depends on commercial value definition.",
},
],
selectedQuestion: {
nodeId: "n-commercial-value",
question:
"How should commercial value be defined for this decision?",
reason: "Consequential unresolved uncertainty remains.",
},
}),
),
};
const result = await updateCase(makeUpdateRequest(), {
provider,
config: MOCK_CONFIG,
applyProposal: true,
});
expect(result.success).toBe(true);
expect(result.selectedQuestion).toEqual({
nodeId: "n-commercial-value",
question: "How should commercial value be defined for this decision?",
reason: "Consequential unresolved uncertainty remains.",
});
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
});
it("defaults to proposal-only mode", async () => { it("defaults to proposal-only mode", async () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js"); const { updateCase } = await import("@/lib/graph/orchestrator.js");
const applyValidatedProposal = vi.fn(); const applyValidatedProposal = vi.fn();
+10
View File
@@ -72,6 +72,7 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("removedEdgeIds"); expect(prompt).toContain("removedEdgeIds");
expect(prompt).toContain("resolvedUnknownNodeIds"); expect(prompt).toContain("resolvedUnknownNodeIds");
expect(prompt).toContain("affectedNodeIds"); expect(prompt).toContain("affectedNodeIds");
expect(prompt).toContain("selectedQuestion");
}); });
it("lists enum values", () => { it("lists enum values", () => {
@@ -98,4 +99,13 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("Return JSON only"); expect(prompt).toContain("Return JSON only");
expect(prompt).toContain("Return one JSON object only"); expect(prompt).toContain("Return one JSON object only");
}); });
it("describes controlled emergent unknown rules", () => {
const prompt = buildGraphUpdatePrompt(makeContext());
expect(prompt).toContain("Add at most 3 new unknown nodes");
expect(prompt).toContain("Resolve the answered unknown first");
expect(prompt).toContain(
"selectedQuestion.question must be one narrow non-compound question",
);
});
}); });
+82 -12
View File
@@ -164,18 +164,53 @@ describe("graphUpdateSchema", () => {
const result = graphUpdateSchema.safeParse({ const result = graphUpdateSchema.safeParse({
addedNodes: [node], addedNodes: [node],
updatedNodes: [{ nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered" }], updatedNodes: [
{
nodeId: "n1",
newStatus: "resolved",
previousStatus: "unknown",
reason: "Question answered",
},
],
addedEdges: [edge], addedEdges: [edge],
removedEdgeIds: ["e-old"], removedEdgeIds: ["e-old"],
resolvedUnknownNodeIds: ["n2"], resolvedUnknownNodeIds: ["n2"],
affectedNodeIds: ["n3"], affectedNodeIds: ["n3"],
selectedQuestion: {
nodeId: "n2",
question: "What does this new node mean?",
reason: "A follow-up unknown remains.",
},
});
expect(result.success).toBe(true);
});
it("allows null selectedQuestion", () => {
const result = graphUpdateSchema.safeParse({
selectedQuestion: null,
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("rejects update with invalid node kind in addedNodes", () => { it("rejects update with invalid node kind in addedNodes", () => {
const invalid = graphUpdateSchema.safeParse({ const invalid = graphUpdateSchema.safeParse({
addedNodes: [{ id: "x", label: "Test", kind: "invalid_kind", description: "test", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }], addedNodes: [
{
id: "x",
label: "Test",
kind: "invalid_kind",
description: "test",
status: "unknown",
confidence: "medium",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
}); });
expect(invalid.success).toBe(false); expect(invalid.success).toBe(false);
}); });
@@ -184,7 +219,9 @@ describe("graphUpdateSchema", () => {
describe("API request schemas", () => { describe("API request schemas", () => {
describe("startCaseRequestSchema", () => { describe("startCaseRequestSchema", () => {
it("validates scenario field", () => { it("validates scenario field", () => {
const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario" }); const result = startCaseRequestSchema.safeParse({
scenario: "Test scenario",
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@@ -195,14 +232,16 @@ describe("API request schemas", () => {
it("rejects scenario over 10000 chars", () => { it("rejects scenario over 10000 chars", () => {
const longScenario = "a".repeat(10001); const longScenario = "a".repeat(10001);
const result = startCaseRequestSchema.safeParse({ scenario: longScenario }); const result = startCaseRequestSchema.safeParse({
scenario: longScenario,
});
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it("accepts optional promptVersion", () => { it("accepts optional promptVersion", () => {
const result = startCaseRequestSchema.safeParse({ const result = startCaseRequestSchema.safeParse({
scenario: "Test", scenario: "Test",
promptVersion: "v0.3" promptVersion: "v0.3",
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@@ -213,7 +252,7 @@ describe("API request schemas", () => {
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Test scenario", centralStatement: "Test scenario",
nodes: [makeNode({ id: "n1", label: "N" })], nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Current state of situation" currentSummary: "Current state of situation",
}); });
const result = updateCaseRequestSchema.safeParse({ const result = updateCaseRequestSchema.safeParse({
situationGraph: graph, situationGraph: graph,
@@ -235,7 +274,7 @@ describe("API request schemas", () => {
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Test", centralStatement: "Test",
nodes: [makeNode({ id: "n1", label: "N" })], nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Test summary" currentSummary: "Test summary",
}); });
const result = updateCaseRequestSchema.safeParse({ const result = updateCaseRequestSchema.safeParse({
situationGraph: graph, situationGraph: graph,
@@ -261,7 +300,9 @@ describe("deterministic ID generation", () => {
}); });
it("IDs are prefixed with 'n' and short", () => { it("IDs are prefixed with 'n' and short", () => {
const id = makeNodeId("A very long label that would produce a longer hash if not truncated"); const id = makeNodeId(
"A very long label that would produce a longer hash if not truncated",
);
expect(id.startsWith("n")).toBe(true); expect(id.startsWith("n")).toBe(true);
expect(id.length).toBeLessThan(15); expect(id.length).toBeLessThan(15);
}); });
@@ -325,7 +366,7 @@ describe("helper functions", () => {
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Test", centralStatement: "Test",
currentSummary: "Default summary", currentSummary: "Default summary",
nodes: [makeNode({ id: "n1", label: "Placeholder" })] nodes: [makeNode({ id: "n1", label: "Placeholder" })],
}); });
const result = situationGraphSchema.safeParse(graph); const result = situationGraphSchema.safeParse(graph);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -346,19 +387,48 @@ describe("helper functions", () => {
describe("enum values completeness", () => { describe("enum values completeness", () => {
it("SituationKind has all expected values", () => { it("SituationKind has all expected values", () => {
const expected = ["observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion"]; const expected = [
"observation",
"reported_claim",
"metric",
"state",
"transition",
"relationship",
"assumption",
"unknown",
"conclusion",
];
const actual = Object.values(SituationKind); const actual = Object.values(SituationKind);
expect(actual).toEqual(expect.arrayContaining(expected)); expect(actual).toEqual(expect.arrayContaining(expected));
}); });
it("SituationStatus has all expected values", () => { it("SituationStatus has all expected values", () => {
const expected = ["known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved"]; const expected = [
"known",
"unknown",
"provisional",
"supported",
"weakened",
"contradicted",
"resolved",
];
const actual = Object.values(SituationStatus); const actual = Object.values(SituationStatus);
expect(actual).toEqual(expect.arrayContaining(expected)); expect(actual).toEqual(expect.arrayContaining(expected));
}); });
it("SituationRelationship has all expected values", () => { it("SituationRelationship has all expected values", () => {
const expected = ["supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other"]; const expected = [
"supports",
"weakens",
"contradicts",
"depends_on",
"causes",
"may_cause",
"measures",
"compares_with",
"updates",
"other",
];
const actual = Object.values(SituationRelationship); const actual = Object.values(SituationRelationship);
expect(actual).toEqual(expect.arrayContaining(expected)); expect(actual).toEqual(expect.arrayContaining(expected));
}); });
+29 -1
View File
@@ -18,6 +18,7 @@ function makeValidProposal(overrides = {}) {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"], resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [], affectedNodeIds: [],
selectedQuestion: null,
...overrides, ...overrides,
}; };
} }
@@ -117,7 +118,34 @@ describe("parseGraphUpdateProposal", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
it("does not invent a next question", () => { it("defaults missing selectedQuestion to null", () => {
const result = parseGraphUpdateProposal({
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.success).toBe(true);
expect(result.proposal.selectedQuestion).toBeNull();
});
it("parses a valid selectedQuestion", () => {
const result = parseGraphUpdateProposal(
makeValidProposal({
selectedQuestion: {
nodeId: "n-follow-up",
question: "How should commercial value be defined for this decision?",
reason: "A consequential unknown remains unresolved.",
},
}),
);
expect(result.success).toBe(true);
expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up");
});
it("does not invent a next question field outside the contract", () => {
const result = parseGraphUpdateProposal(makeValidProposal()); const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.proposal.nextQuestion).toBeUndefined(); expect(result.proposal.nextQuestion).toBeUndefined();
}); });
+7 -1
View File
@@ -64,9 +64,15 @@ test("graph-backed one-turn update smoke test", async ({ page }) => {
timeout: 240000, timeout: 240000,
}); });
await expect(page.getByText(/Resolved unknowns/i)).toBeVisible(); await expect(page.getByText(/Resolved unknowns/i)).toBeVisible();
await expect(page.getByText(/Newly surfaced unknowns/i)).toBeVisible();
await expect(page.getByText(/Affected nodes/i)).toBeVisible(); await expect(page.getByText(/Affected nodes/i)).toBeVisible();
await expect( await expect(
page.getByText(/No next question selected yet\./i), page.getByText(/Selected Question|Next question:/i),
).toBeVisible();
await expect(
page.getByText(
/Additional submission is disabled in this one-update prototype\./i,
),
).toBeVisible(); ).toBeVisible();
await expect(page.getByText(/Error:/i)).toHaveCount(0); await expect(page.getByText(/Error:/i)).toHaveCount(0);
await expect(page.getByText(/Update error:/i)).toHaveCount(0); await expect(page.getByText(/Update error:/i)).toHaveCount(0);
+100 -4
View File
@@ -113,11 +113,37 @@ function makeUpdateSuccess(overrides = {}) {
value: "1.9 complaints per 100 units", value: "1.9 complaints per 100 units",
unit: null, unit: null,
}, },
{
id: "n-next-unknown",
label: "Commercial value definition",
description: "Need a definition because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
value: null,
unit: null,
},
], ],
edges: [], edges: [],
}, },
proposal: { proposal: {
addedNodes: [], addedNodes: [
{
id: "n-next-unknown",
label: "Commercial value definition",
description: "Need a definition because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
},
],
updatedNodes: [ updatedNodes: [
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" }, { nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
], ],
@@ -125,6 +151,16 @@ function makeUpdateSuccess(overrides = {}) {
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"], resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: ["n-conclusion"], affectedNodeIds: ["n-conclusion"],
selectedQuestion: {
nodeId: "n-next-unknown",
question: "How should commercial value be defined for this decision?",
reason: "A narrower consequential uncertainty remains.",
},
},
selectedQuestion: {
nodeId: "n-next-unknown",
question: "How should commercial value be defined for this decision?",
reason: "A narrower consequential uncertainty remains.",
}, },
affectedNodeIds: ["n-conclusion"], affectedNodeIds: ["n-conclusion"],
resolvedUnknownNodeIds: ["n-unknown"], resolvedUnknownNodeIds: ["n-unknown"],
@@ -323,6 +359,20 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Complaint rate denominator"); expect(html).toContain("Complaint rate denominator");
}); });
it("newly surfaced unknowns render", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain("Newly surfaced unknowns");
expect(html).toContain("Commercial value definition");
});
it("affected nodes render", () => { it("affected nodes render", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<GraphUpdateView <GraphUpdateView
@@ -337,11 +387,33 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Quality deterioration"); expect(html).toContain("Quality deterioration");
}); });
it("no fake next question appears", () => { it("renders validated next question when present", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<GraphUpdateView <GraphUpdateView
updateResult={{ updateResult={{
...makeUpdateSuccess({ newActiveUnknownNodeId: null }), ...makeUpdateSuccess(),
previousSituationGraph: makeGraphResult().situationGraph,
}}
/>,
);
expect(html).toContain(
"How should commercial value be defined for this decision?",
);
});
it("no fake next question appears when there is none", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={{
...makeUpdateSuccess({
newActiveUnknownNodeId: null,
selectedQuestion: null,
proposal: {
...makeUpdateSuccess().proposal,
selectedQuestion: null,
},
}),
previousSituationGraph: makeGraphResult().situationGraph, previousSituationGraph: makeGraphResult().situationGraph,
}} }}
/>, />,
@@ -363,7 +435,31 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Previous active unknown"); expect(html).toContain("Previous active unknown");
expect(html).toContain("Complaint rate denominator"); expect(html).toContain("Complaint rate denominator");
expect(html).toContain("New active unknown"); expect(html).toContain("New active unknown");
expect(html).toContain("Unknown node (ID: n-next-unknown)"); expect(html).toContain("Commercial value definition");
});
it("situation graph marks newly surfaced and active unknowns", () => {
const html = renderToStaticMarkup(
<SituationGraphView
situationGraph={makeUpdateSuccess().updatedSituationGraph}
selectedQuestion={makeUpdateSuccess().selectedQuestion}
newlySurfacedNodeIds={["n-next-unknown"]}
/>,
);
expect(html).toContain("newly surfaced unknown");
expect(html).toContain("active unknown");
expect(html).toContain("resolved unknown");
});
it("disabled follow-up form is shown only as prototype limitation", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
updateResult={makeUpdateSuccess()}
/>,
);
expect(html).toContain("How should commercial value be defined for this decision?");
}); });
it("raw ids remain only in collapsed proposal details", () => { it("raw ids remain only in collapsed proposal details", () => {