diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index c92ca83..da727f8 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -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; diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js new file mode 100644 index 0000000..b19371f --- /dev/null +++ b/lib/graph/question-formulator.js @@ -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, + }; +} diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index f43fe1a..12bd5bc 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -547,8 +547,13 @@ describe("applyValidatedProposal", () => { ).toBe(true); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); - expect(result.selectedQuestion?.question).toContain( - "Commercial value definition", + expect(result.selectedQuestion?.question).toMatch(/\?$/); + expect(result.selectedQuestion?.question.length).toBeGreaterThan(20); + expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( + "price", + ); + expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( + "how should uncertainty regarding", ); }); diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js index 6041289..3a61642 100644 --- a/tests/graph/orchestrator.test.js +++ b/tests/graph/orchestrator.test.js @@ -584,6 +584,12 @@ describe("lib/graph/orchestrator startCase", () => { expect(result.success).toBe(true); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); + expect(result.selectedQuestion?.question).not.toBe( + "How should commercial value be defined for this decision?", + ); + expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( + "how should uncertainty regarding", + ); }); it("deterministically prioritises customer value over pricing follow-up", async () => { diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js new file mode 100644 index 0000000..eba2f8e --- /dev/null +++ b/tests/graph/question-formulator.test.js @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { formulateQuestion } from "@/lib/graph/question-formulator.js"; +import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; + +function makeGraphFor(node, extra = {}) { + return makeGraph({ + centralStatement: extra.centralStatement || "Decision context", + nodes: [node, ...(extra.nodes || [])], + edges: extra.edges || [], + activeUnknownNodeId: node.id, + resolvedNodeIds: extra.resolvedNodeIds || [], + currentSummary: "Test summary", + }); +} + +describe("formulateQuestion", () => { + it("commercial viability plus build decision produces a decision-criterion question", () => { + const unknown = makeNode({ + id: "n-commercial", + label: "Uncertainty regarding the commercial value of the product", + description: + "Commercial justification remains unclear because the decision depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + parentId: "n-decision", + }); + const decision = makeNode({ + id: "n-decision", + label: "Build decision", + description: "Decision introduced by the answer.", + kind: "state", + status: "known", + confidence: "medium", + childIds: [unknown.id], + value: "Deciding whether to build the product", + }); + const graph = makeGraphFor(unknown, { + nodes: [decision], + resolvedNodeIds: [decision.id], + }); + + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.strategy).toBe("decision criterion"); + expect(result.question).toContain("What outcome"); + expect(result.question.toLowerCase()).toContain("justify"); + }); + + it("commercial viability does not produce a pricing-first question", () => { + const unknown = makeNode({ + id: "n-commercial", + label: "Commercial viability", + description: + "Commercial viability remains unresolved because the decision depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + }); + const graph = makeGraphFor(unknown); + + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.question.toLowerCase()).not.toContain("price"); + expect(result.question.toLowerCase()).not.toContain("pricing"); + }); + + it("undefined term produces a definition question", () => { + const unknown = makeNode({ + id: "n-term", + label: "Success criteria definition", + description: + "Need a definition of the term because the team uses it inconsistently.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.strategy).toBe("definition"); + expect(result.question).toMatch(/^What does /); + }); + + it("unsupported claim produces an evidence question", () => { + const unknown = makeNode({ + id: "n-claim", + label: "Demand claim", + description: "Need evidence because the claim has not been validated.", + kind: "reported_claim", + status: "provisional", + confidence: "low", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.strategy).toBe("evidence"); + expect(result.question).toContain("What evidence"); + }); + + it("missing previous state produces a baseline question", () => { + const unknown = makeNode({ + id: "n-baseline", + label: "Baseline conversion rate", + description: + "Need the previous baseline because the change cannot be assessed without it.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.strategy).toBe("baseline"); + expect(result.question).toContain("What was the comparable state before"); + }); + + it("unknown customer produces an actor/customer question", () => { + const unknown = makeNode({ + id: "n-customer", + label: "Target customer", + description: + "Need to know the customer because value depends on who receives it.", + kind: "unknown", + status: "unknown", + confidence: "high", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.strategy).toBe("actor/customer"); + expect(result.question).toContain( + "Who experiences the problem or receives the value", + ); + }); + + it("constraint unknown produces a constraint question", () => { + const unknown = makeNode({ + id: "n-constraint", + label: "Budget constraint", + description: + "Need the main budget constraint because it limits the available options.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.strategy).toBe("constraint"); + expect(result.question).toContain("What constraint most limits"); + }); + + it("question is singular and answerable", () => { + const unknown = makeNode({ + id: "n-evidence", + label: "Evidence of demand", + description: + "Need evidence of demand because the decision depends on it.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.question.match(/\?/g) || []).toHaveLength(1); + expect(result.question.toLowerCase()).not.toContain(" and "); + }); + + it("awkward uncertainty phrasing is rejected via fallback", () => { + const unknown = makeNode({ + id: "n-weird", + label: "Uncertainty regarding service reliability", + description: "Unknown service reliability.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.question).not.toContain("How should uncertainty regarding"); + expect(result.question).not.toContain( + "What would resolve uncertainty regarding", + ); + }); +});