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,
|
||||
};
|
||||
}
|
||||
|
||||
+41
-16
@@ -46,6 +46,29 @@ function buildDiagnostics({ analysis, graph, graphReferenceValidation }) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpdateDiagnostics({
|
||||
promptVersion,
|
||||
modelName,
|
||||
responseDurationMs,
|
||||
normalisationsApplied,
|
||||
graph,
|
||||
graphReferenceValidation,
|
||||
}) {
|
||||
return {
|
||||
promptVersion: promptVersion ?? "v0.4",
|
||||
modelName: modelName ?? null,
|
||||
responseDurationMs: responseDurationMs ?? null,
|
||||
validationStatus: "valid",
|
||||
nodeCount: graph?.nodes?.length ?? 0,
|
||||
edgeCount: graph?.edges?.length ?? 0,
|
||||
graphReferenceValidation: graphReferenceValidation ?? {
|
||||
valid: true,
|
||||
errors: [],
|
||||
},
|
||||
normalisationsApplied: normalisationsApplied ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function startCase(body) {
|
||||
const parsedRequest = startCaseRequestSchema.safeParse(body);
|
||||
|
||||
@@ -254,12 +277,14 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
stage: applicationResult.stage,
|
||||
errors: applicationResult.errors,
|
||||
diagnostics: {
|
||||
promptVersion: promptVersion ?? null,
|
||||
modelName,
|
||||
responseDurationMs,
|
||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||
graphNodeCount: situationGraph.nodes.length,
|
||||
graphEdgeCount: situationGraph.edges.length,
|
||||
...buildUpdateDiagnostics({
|
||||
promptVersion,
|
||||
modelName,
|
||||
responseDurationMs,
|
||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||
graph: situationGraph,
|
||||
graphReferenceValidation: graphReferenceValidation,
|
||||
}),
|
||||
},
|
||||
statusCode:
|
||||
applicationResult.stage === "application" ||
|
||||
@@ -280,14 +305,14 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
applicationResult.previousActiveUnknownNodeId,
|
||||
newActiveUnknownNodeId: applicationResult.newActiveUnknownNodeId,
|
||||
changesApplied: applicationResult.changesApplied,
|
||||
diagnostics: {
|
||||
promptVersion: promptVersion ?? null,
|
||||
diagnostics: buildUpdateDiagnostics({
|
||||
promptVersion,
|
||||
modelName,
|
||||
responseDurationMs,
|
||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||
graphNodeCount: applicationResult.updatedSituationGraph.nodes.length,
|
||||
graphEdgeCount: applicationResult.updatedSituationGraph.edges.length,
|
||||
},
|
||||
graph: applicationResult.updatedSituationGraph,
|
||||
graphReferenceValidation: applicationResult.graphReferenceValidation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,13 +320,13 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
|
||||
success: true,
|
||||
stage: "proposal_ready",
|
||||
proposal: parsedProposal.proposal,
|
||||
diagnostics: {
|
||||
promptVersion: promptVersion ?? null,
|
||||
diagnostics: buildUpdateDiagnostics({
|
||||
promptVersion,
|
||||
modelName,
|
||||
responseDurationMs,
|
||||
normalisationsApplied: parsedProposal.normalisationsApplied,
|
||||
graphNodeCount: situationGraph.nodes.length,
|
||||
graphEdgeCount: situationGraph.edges.length,
|
||||
},
|
||||
graph: situationGraph,
|
||||
graphReferenceValidation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ The JSON object must contain exactly these top-level fields:
|
||||
|
||||
## 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 does not justify a change, return empty arrays for every category.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user