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