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, }; }