feat: formulate follow-up questions from graph context
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user