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,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion,
changesApplied,
proposal,
previousSituationGraph,
updatedSituationGraph,
} = updateResult;
const newlySurfacedUnknownNodeIds = (proposal.addedNodes || [])
.filter((node) => node.kind === "unknown")
.map((node) => node.id);
const previousNodesById = new Map(
(previousSituationGraph?.nodes || []).map((node) => [node.id, node]),
);
@@ -123,10 +128,15 @@ export default function GraphUpdateView({ updateResult }) {
{resolveActiveUnknown(newActiveUnknownNodeId)}
</div>
)}
{!newActiveUnknownNodeId && previousActiveUnknownNodeId && (
{selectedQuestion?.question && (
<div>
<span className="font-medium">Next question status:</span> No next
question selected yet.
<span className="font-medium">Next question:</span>{" "}
{selectedQuestion.question}
</div>
)}
{!selectedQuestion?.question && !newActiveUnknownNodeId && previousActiveUnknownNodeId && (
<div>
<span className="font-medium">Next question status:</span> No next question selected yet.
</div>
)}
</div>
@@ -137,6 +147,11 @@ export default function GraphUpdateView({ updateResult }) {
items={resolvedUnknownNodeIds}
renderItem={resolveNodePresentation}
/>
<ListSection
title="Newly surfaced unknowns"
items={newlySurfacedUnknownNodeIds}
renderItem={resolveNodePresentation}
/>
<ListSection
title="Affected nodes"
items={affectedNodeIds}
+56 -2
View File
@@ -52,9 +52,16 @@ function normaliseStartResult(data) {
typeof data?.selectedQuestion === "string"
? data.selectedQuestion
: 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 }) {
if (!result) return null;
@@ -83,6 +90,7 @@ export function ScenarioResultPanels({ status, result }) {
<SituationGraphView
situationGraph={result.situationGraph}
selectedQuestion={result.selectedQuestion}
newlySurfacedNodeIds={result.newlySurfacedNodeIds}
/>
)}
@@ -192,7 +200,12 @@ export default function ScenarioForm() {
setResult((current) => ({
...current,
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,
}));
setAnswer("");
@@ -208,9 +221,14 @@ export default function ScenarioForm() {
const canRenderAnswerForm =
status === "success" &&
updateStatus === "idle" &&
Boolean(result?.situationGraph) &&
Boolean(result?.selectedQuestion);
const canRenderDisabledFollowUpForm =
updateStatus === "success" &&
Boolean(updateResult?.selectedQuestion?.question || result?.selectedQuestion);
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
@@ -277,8 +295,44 @@ export default function ScenarioForm() {
{updateStatus === "success" && updateResult && (
<>
<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>
{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} />
</>
)}
+25 -1
View File
@@ -8,6 +8,8 @@ function NodeBadge({ children, tone = "gray" }) {
blue: "border-blue-200 bg-blue-50 text-blue-700",
green: "border-green-200 bg-green-50 text-green-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 (
@@ -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;
return (
@@ -32,6 +40,15 @@ function NodeGroup({ title, nodes }) {
<span className="font-medium text-gray-900">{node.label}</span>
<NodeBadge tone="blue">{node.status}</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 && (
<NodeBadge tone="yellow">
{node.value}
@@ -52,6 +69,7 @@ function NodeGroup({ title, nodes }) {
export default function SituationGraphView({
situationGraph,
selectedQuestion,
newlySurfacedNodeIds = [],
}) {
if (!situationGraph) return null;
@@ -70,6 +88,9 @@ export default function SituationGraphView({
return acc;
}, {});
const resolvedNodeIdSet = new Set(situationGraph.resolvedNodeIds || []);
const newlySurfacedNodeIdSet = new Set(newlySurfacedNodeIds || []);
return (
<div className="space-y-4">
{selectedQuestionText && (
@@ -110,6 +131,9 @@ export default function SituationGraphView({
key={kind}
title={kind.replace(/_/g, " ")}
nodes={nodes}
resolvedNodeIds={resolvedNodeIdSet}
newlySurfacedNodeIds={newlySurfacedNodeIdSet}
activeUnknownNodeId={situationGraph.activeUnknownNodeId}
/>
))}
+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);
+3
View File
@@ -25,7 +25,9 @@ function makeSuccessResult() {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n1"],
affectedNodeIds: ["n1"],
selectedQuestion: null,
},
selectedQuestion: null,
affectedNodeIds: ["n1"],
resolvedUnknownNodeIds: ["n1"],
previousActiveUnknownNodeId: "n0",
@@ -268,6 +270,7 @@ describe("app/api/cases/update route", () => {
resolvedUnknownNodeIds: success.resolvedUnknownNodeIds,
previousActiveUnknownNodeId: success.previousActiveUnknownNodeId,
newActiveUnknownNodeId: success.newActiveUnknownNodeId,
selectedQuestion: success.selectedQuestion,
changesApplied: success.changesApplied,
diagnostics: success.diagnostics,
});
+244
View File
@@ -99,6 +99,7 @@ function makeApplicationFixture() {
removedEdgeIds: [],
resolvedUnknownNodeIds: [complaintRateUnknown.id],
affectedNodeIds: [qualityDeterioration.id],
selectedQuestion: null,
};
return {
@@ -444,6 +445,7 @@ describe("applyValidatedProposal", () => {
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
selectedQuestion: null,
},
});
@@ -455,4 +457,246 @@ describe("applyValidatedProposal", () => {
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: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
selectedQuestion: null,
...overrides,
};
}
@@ -529,6 +530,66 @@ describe("lib/graph/orchestrator startCase", () => {
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 () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js");
const applyValidatedProposal = vi.fn();
+10
View File
@@ -72,6 +72,7 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("removedEdgeIds");
expect(prompt).toContain("resolvedUnknownNodeIds");
expect(prompt).toContain("affectedNodeIds");
expect(prompt).toContain("selectedQuestion");
});
it("lists enum values", () => {
@@ -98,4 +99,13 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("Return JSON 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({
addedNodes: [node],
updatedNodes: [{ nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered" }],
updatedNodes: [
{
nodeId: "n1",
newStatus: "resolved",
previousStatus: "unknown",
reason: "Question answered",
},
],
addedEdges: [edge],
removedEdgeIds: ["e-old"],
resolvedUnknownNodeIds: ["n2"],
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);
});
it("rejects update with invalid node kind in addedNodes", () => {
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);
});
@@ -184,7 +219,9 @@ describe("graphUpdateSchema", () => {
describe("API request schemas", () => {
describe("startCaseRequestSchema", () => {
it("validates scenario field", () => {
const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario" });
const result = startCaseRequestSchema.safeParse({
scenario: "Test scenario",
});
expect(result.success).toBe(true);
});
@@ -195,14 +232,16 @@ describe("API request schemas", () => {
it("rejects scenario over 10000 chars", () => {
const longScenario = "a".repeat(10001);
const result = startCaseRequestSchema.safeParse({ scenario: longScenario });
const result = startCaseRequestSchema.safeParse({
scenario: longScenario,
});
expect(result.success).toBe(false);
});
it("accepts optional promptVersion", () => {
const result = startCaseRequestSchema.safeParse({
scenario: "Test",
promptVersion: "v0.3"
promptVersion: "v0.3",
});
expect(result.success).toBe(true);
});
@@ -213,7 +252,7 @@ describe("API request schemas", () => {
const graph = makeGraph({
centralStatement: "Test scenario",
nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Current state of situation"
currentSummary: "Current state of situation",
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
@@ -235,7 +274,7 @@ describe("API request schemas", () => {
const graph = makeGraph({
centralStatement: "Test",
nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Test summary"
currentSummary: "Test summary",
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
@@ -261,7 +300,9 @@ describe("deterministic ID generation", () => {
});
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.length).toBeLessThan(15);
});
@@ -325,7 +366,7 @@ describe("helper functions", () => {
const graph = makeGraph({
centralStatement: "Test",
currentSummary: "Default summary",
nodes: [makeNode({ id: "n1", label: "Placeholder" })]
nodes: [makeNode({ id: "n1", label: "Placeholder" })],
});
const result = situationGraphSchema.safeParse(graph);
expect(result.success).toBe(true);
@@ -346,19 +387,48 @@ describe("helper functions", () => {
describe("enum values completeness", () => {
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);
expect(actual).toEqual(expect.arrayContaining(expected));
});
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);
expect(actual).toEqual(expect.arrayContaining(expected));
});
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);
expect(actual).toEqual(expect.arrayContaining(expected));
});
+29 -1
View File
@@ -18,6 +18,7 @@ function makeValidProposal(overrides = {}) {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
selectedQuestion: null,
...overrides,
};
}
@@ -117,7 +118,34 @@ describe("parseGraphUpdateProposal", () => {
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());
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,
});
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(/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();
await expect(page.getByText(/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",
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: [],
},
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: [
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
],
@@ -125,6 +151,16 @@ function makeUpdateSuccess(overrides = {}) {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
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"],
resolvedUnknownNodeIds: ["n-unknown"],
@@ -323,6 +359,20 @@ describe("graph-backed UI rendering", () => {
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", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
@@ -337,11 +387,33 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Quality deterioration");
});
it("no fake next question appears", () => {
it("renders validated next question when present", () => {
const html = renderToStaticMarkup(
<GraphUpdateView
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,
}}
/>,
@@ -363,7 +435,31 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Previous active unknown");
expect(html).toContain("Complaint rate denominator");
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", () => {