feat: surface new unknowns after graph updates
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user