feat: add one-turn situation graph update UI
This commit is contained in:
+145
-1
@@ -34,6 +34,140 @@ function collectDuplicateEdgeIds(edges) {
|
||||
.map(([edgeId, count]) => ({ edgeId, count }));
|
||||
}
|
||||
|
||||
function normaliseText(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildResolvedUnknownUpdate(node) {
|
||||
return {
|
||||
nodeId: node.id,
|
||||
previousStatus: node.status ?? null,
|
||||
newStatus: "resolved",
|
||||
previousValue: node.value ?? null,
|
||||
newValue: node.value ?? null,
|
||||
reason:
|
||||
"Resolved because the proposal explicitly marked this unknown as resolved.",
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileResolutionSemantics(graph, proposal) {
|
||||
const nextProposal = cloneJsonSafe(proposal);
|
||||
const errors = [];
|
||||
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const updatedNodeById = new Map(
|
||||
nextProposal.updatedNodes.map((nodeUpdate) => [
|
||||
nodeUpdate.nodeId,
|
||||
nodeUpdate,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
|
||||
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
|
||||
|
||||
if (!existingNode) {
|
||||
errors.push(
|
||||
`Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingNode.kind !== "unknown") {
|
||||
errors.push(
|
||||
`Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
|
||||
if (!existingUpdate) {
|
||||
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
|
||||
nextProposal.updatedNodes.push(syntheticUpdate);
|
||||
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingUpdate.newStatus !== "resolved") {
|
||||
existingUpdate.newStatus = "resolved";
|
||||
if (existingUpdate.previousStatus == null) {
|
||||
existingUpdate.previousStatus = existingNode.status ?? null;
|
||||
}
|
||||
if (existingUpdate.previousValue === undefined) {
|
||||
existingUpdate.previousValue = existingNode.value ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const update of nextProposal.updatedNodes) {
|
||||
const existingNode = graphNodeById.get(update.nodeId);
|
||||
if (
|
||||
existingNode?.kind === "unknown" &&
|
||||
update.newStatus === "resolved" &&
|
||||
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
|
||||
) {
|
||||
errors.push(
|
||||
`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
proposal: nextProposal,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSemanticDuplicateUnknowns(graph, proposal) {
|
||||
const errors = [];
|
||||
const unresolvedUnknowns = graph.nodes.filter(
|
||||
(node) =>
|
||||
node.kind === "unknown" &&
|
||||
!proposal.resolvedUnknownNodeIds.includes(node.id),
|
||||
);
|
||||
|
||||
for (const addedNode of proposal.addedNodes) {
|
||||
const addedTexts = [
|
||||
normaliseText(addedNode.label),
|
||||
normaliseText(addedNode.description),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const unresolvedUnknown of unresolvedUnknowns) {
|
||||
const unresolvedTexts = [
|
||||
normaliseText(unresolvedUnknown.label),
|
||||
normaliseText(unresolvedUnknown.description),
|
||||
].filter(Boolean);
|
||||
|
||||
const duplicatesMeaning = addedTexts.some((text) =>
|
||||
unresolvedTexts.includes(text),
|
||||
);
|
||||
|
||||
if (!duplicatesMeaning) continue;
|
||||
|
||||
const linkedToUnknown = proposal.addedEdges.some(
|
||||
(edge) =>
|
||||
(edge.fromNodeId === addedNode.id &&
|
||||
edge.toNodeId === unresolvedUnknown.id) ||
|
||||
(edge.toNodeId === addedNode.id &&
|
||||
edge.fromNodeId === unresolvedUnknown.id),
|
||||
);
|
||||
|
||||
const updatedUnknown = proposal.updatedNodes.some(
|
||||
(update) => update.nodeId === unresolvedUnknown.id,
|
||||
);
|
||||
|
||||
if (!linkedToUnknown && !updatedUnknown) {
|
||||
errors.push(
|
||||
`Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function buildAffectedNodeIds(graph, proposal) {
|
||||
const affected = new Set(proposal.affectedNodeIds ?? []);
|
||||
|
||||
@@ -116,8 +250,13 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
||||
};
|
||||
}
|
||||
|
||||
const validatedProposal = proposalValidation.data;
|
||||
const reconciledProposal = reconcileResolutionSemantics(
|
||||
situationGraph,
|
||||
proposalValidation.data,
|
||||
);
|
||||
const validatedProposal = reconciledProposal.proposal;
|
||||
const proposalCompatibilityErrors = [];
|
||||
proposalCompatibilityErrors.push(...reconciledProposal.errors);
|
||||
const proposalGraphValidation = validateGraphUpdate(
|
||||
situationGraph,
|
||||
validatedProposal,
|
||||
@@ -180,6 +319,10 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
||||
),
|
||||
);
|
||||
|
||||
proposalCompatibilityErrors.push(
|
||||
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
|
||||
);
|
||||
|
||||
if (proposalCompatibilityErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -288,5 +431,6 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
|
||||
previousActiveUnknownNodeId,
|
||||
newActiveUnknownNodeId,
|
||||
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
||||
graphReferenceValidation: resultReferenceValidation,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user