feat: formulate follow-up questions from graph context

This commit is contained in:
2026-08-02 12:47:34 +01:00
parent 4affadab4b
commit 48ce66dddb
5 changed files with 581 additions and 10 deletions
+23 -8
View File
@@ -1,8 +1,8 @@
import { describeGraph } from "./builder.js";
import { formulateQuestion } from "./question-formulator.js";
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
import {
applyGraphUpdate,
buildDeterministicQuestionForUnknown,
detectDuplicateNodeIds,
findAffectedNodes,
scoreUnknownCandidate,
@@ -630,17 +630,32 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
const selectedNode = deterministicSelection?.nodeId
? updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
)
: null;
const formulatedQuestion = selectedNode
? formulateQuestion({
node: selectedNode,
graph: updatedSituationGraph,
context: {
resolvedValues: validatedProposal.updatedNodes
.map((update) => update.newValue)
.filter(
(value) => typeof value === "string" && value.trim().length > 0,
),
},
})
: null;
const finalSelectedQuestion = deterministicSelection
? {
nodeId: deterministicSelection.nodeId,
question:
deterministicSelection.question ||
buildDeterministicQuestionForUnknown(
updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
),
),
reason: deterministicSelection.reason,
formulatedQuestion?.question || deterministicSelection.question,
reason: formulatedQuestion?.reason || deterministicSelection.reason,
strategy: formulatedQuestion?.strategy,
}
: null;
+336
View File
@@ -0,0 +1,336 @@
function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function sentenceCase(value) {
const trimmed = String(value || "").trim();
if (!trimmed) return "this uncertainty";
return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
}
function buildNodeMap(graph) {
return new Map((graph?.nodes || []).map((node) => [node.id, node]));
}
function collectRelatedNodes(node, graph) {
if (!node || !graph) return [];
const nodesById = buildNodeMap(graph);
const relatedIds = new Set([
...(node.dependsOn || []),
...(node.affects || []),
...(node.childIds || []),
]);
if (node.parentId) {
relatedIds.add(node.parentId);
}
for (const edge of graph.edges || []) {
if (edge.fromNodeId === node.id) {
relatedIds.add(edge.toNodeId);
}
if (edge.toNodeId === node.id) {
relatedIds.add(edge.fromNodeId);
}
}
return [...relatedIds].map((nodeId) => nodesById.get(nodeId)).filter(Boolean);
}
function collectResolvedContextValues(graph) {
const resolvedSet = new Set(graph?.resolvedNodeIds || []);
return (graph?.nodes || [])
.filter((node) => resolvedSet.has(node.id))
.map((node) => node.value)
.filter((value) => typeof value === "string" && value.trim().length > 0);
}
function extractMeaning(node) {
const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
let meaning = String(
node?.label || node?.description || "this uncertainty",
).trim();
const lowered = normaliseText(raw);
if (
/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(lowered)
) {
return "the relevant customer, user, or value recipient";
}
meaning = meaning
.replace(/^uncertainty regarding\s+/i, "")
.replace(/^uncertainty about\s+/i, "")
.replace(/^lack of\s+/i, "")
.replace(/^unknown\s+/i, "")
.replace(/^whether\s+/i, "")
.replace(/^the\s+/, "")
.trim();
if (!meaning) {
return "this uncertainty";
}
return sentenceCase(meaning);
}
function extractActionPhrase(texts) {
for (const text of texts) {
const value = String(text || "").trim();
if (!value) continue;
const matches = [
value.match(/\b(?:whether|deciding|decision) to\s+([^.,;:]+)/i),
value.match(/\b(?:justify|continuing|proceeding with)\s+([^.,;:]+)/i),
value.match(
/\b(build|launch|adopt|buy|continue|proceed|invest in|fund)\s+([^.,;:]+)/i,
),
].filter(Boolean);
const match = matches[0];
if (!match) continue;
const phrase = (match[1] || `${match[1] || ""} ${match[2] || ""}`)
.replace(/^to\s+/i, "")
.trim();
if (phrase) {
return phrase;
}
}
return null;
}
function toGerundPhrase(phrase) {
const trimmed = String(phrase || "").trim();
if (!trimmed) return "proceeding with this decision";
const [firstWord, ...rest] = trimmed.split(/\s+/);
const lower = firstWord.toLowerCase();
const irregular = {
be: "being",
build: "building",
continue: "continuing",
decide: "deciding",
proceed: "proceeding",
launch: "launching",
invest: "investing",
fund: "funding",
buy: "buying",
pay: "paying",
adopt: "adopting",
};
let gerund = irregular[lower];
if (!gerund) {
if (lower.endsWith("e") && !lower.endsWith("ee")) {
gerund = `${lower.slice(0, -1)}ing`;
} else {
gerund = `${lower}ing`;
}
}
return [gerund, ...rest].join(" ");
}
function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
const text = normaliseText(combinedText);
const relatedText = normaliseText(
relatedNodes
.map((relatedNode) => `${relatedNode.label} ${relatedNode.description}`)
.join(" "),
);
const resolvedValues = collectResolvedContextValues(graph);
const actionPhrase = extractActionPhrase([
...resolvedValues,
...relatedNodes.map((relatedNode) => relatedNode.value),
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
]);
const decisionContext =
/\b(decision|whether to|build|launch|continue|proceed|invest|allocate)\b/.test(
`${text} ${relatedText} ${resolvedValues.join(" ")}`,
) || Boolean(actionPhrase);
if (
/\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
text,
)
) {
return { strategy: "constraint", meaning, actionPhrase };
}
if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) {
return { strategy: "actor/customer", meaning, actionPhrase };
}
if (/\b(before|previous|baseline|prior|comparable state)\b/.test(text)) {
return { strategy: "baseline", meaning, actionPhrase };
}
if (/\b(when|timing|timeline|duration|sequence|milestone)\b/.test(text)) {
return { strategy: "transition/timing", meaning, actionPhrase };
}
const hasDefinitionLanguage =
/\b(define|definition|meaning|term|terminology)\b/.test(text);
const hasDecisionValueLanguage =
decisionContext &&
/\b(value|commercial value|commercial viability|viability|justify|sufficient|success|threshold|criterion)\b/.test(
text,
);
const hasMeasurementLanguage =
/\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
text,
);
if (hasDecisionValueLanguage && hasMeasurementLanguage) {
return { strategy: "measurement", meaning, actionPhrase };
}
if (/\b(define|definition|meaning|term|terminology)\b/.test(text)) {
return { strategy: "definition", meaning, actionPhrase };
}
if (hasDecisionValueLanguage) {
return { strategy: "decision criterion", meaning, actionPhrase };
}
if (/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text)) {
return { strategy: "evidence", meaning, actionPhrase };
}
if (hasMeasurementLanguage) {
return { strategy: "measurement", meaning, actionPhrase };
}
if (
/\b(objective|goal|outcome|problem|job to be done|benefit)\b/.test(text)
) {
return { strategy: "objective", meaning, actionPhrase };
}
if (
node?.kind === "reported_claim" ||
node?.kind === "conclusion" ||
/\b(claim|assertion|true|false)\b/.test(text)
) {
return { strategy: "evidence", meaning, actionPhrase };
}
return { strategy: "generic clarification", meaning, actionPhrase };
}
function buildQuestion({ strategy, meaning, actionPhrase }) {
switch (strategy) {
case "decision criterion":
return actionPhrase
? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?`
: "What outcome would be sufficient to justify this decision?";
case "definition":
return `What does ${meaning} mean in this situation?`;
case "evidence":
return `What evidence would show whether ${meaning} is true?`;
case "baseline":
return `What was the comparable state before ${meaning}?`;
case "actor/customer":
return "Who experiences the problem or receives the value in this situation?";
case "objective":
return "What outcome is this decision or effort meant to achieve?";
case "constraint":
return "What constraint most limits the available options in this situation?";
case "measurement":
return `What measure would determine whether ${meaning} is sufficient?`;
case "transition/timing":
return `When does ${meaning} become relevant in the decision or change?`;
default:
return `What specific fact would resolve whether ${meaning} is true?`;
}
}
function isCompoundQuestion(question) {
const trimmed = String(question || "").trim();
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,80}\?/i.test(trimmed) && /,/.test(trimmed)) {
return true;
}
return false;
}
function validateFormulatedQuestion(question, meaning) {
const trimmed = String(question || "").trim();
const lower = trimmed.toLowerCase();
const meaningWords = normaliseText(meaning)
.split(" ")
.filter((word) => word.length > 3);
const overlappingWord = meaningWords.find((word) => lower.includes(word));
if (!trimmed) return false;
if ((trimmed.match(/\?/g) || []).length !== 1) return false;
if (isCompoundQuestion(trimmed)) return false;
if (/^what is\s+/i.test(trimmed)) return false;
if (/^how should uncertainty regarding\b/i.test(trimmed)) return false;
if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
return false;
if (
/\bprice|pricing|price point\b/i.test(trimmed) &&
!/\bprice\b/i.test(meaning)
) {
return false;
}
if (
!overlappingWord &&
!/\b(decision|evidence|constraint|customer|value|outcome)\b/i.test(trimmed)
) {
return false;
}
return true;
}
export function formulateQuestion({ node, graph, context = {} }) {
const relatedNodes = collectRelatedNodes(node, graph);
const meaning = extractMeaning(node);
const combinedText = [
node?.label,
node?.description,
...relatedNodes.map((relatedNode) => relatedNode.label),
...relatedNodes.map((relatedNode) => relatedNode.description),
graph?.centralStatement,
...(context.resolvedValues || []),
]
.filter(Boolean)
.join(" ");
const detected = detectStrategy({
node,
graph,
relatedNodes,
combinedText,
meaning,
});
let question = buildQuestion(detected);
if (!validateFormulatedQuestion(question, meaning)) {
question = `What evidence would resolve whether ${meaning} is true?`;
}
return {
question,
reason: `Formulated from graph context using the ${detected.strategy} strategy.`,
strategy: detected.strategy,
};
}