From 8cca70774c9e2f13fe5dc3fb4321ad01d1b76c70 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 13 Aug 2026 16:58:32 +0100 Subject: [PATCH] fix(reasoning): narrow decision audience question routing --- lib/graph/question-formulator.js | 58 ++++++++-- tests/graph/question-formulator.test.js | 140 ++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 10 deletions(-) diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js index 3816100..7189590 100644 --- a/lib/graph/question-formulator.js +++ b/lib/graph/question-formulator.js @@ -57,6 +57,31 @@ function collectResolvedContextValues(graph) { .filter((value) => typeof value === "string" && value.trim().length > 0); } +function hasAudienceIdentityQuestion(text) { + const normalised = normaliseText(text); + if (!normalised) return false; + + return ( + /\bwho\s+(is|are)\s+(the\s+)?(customer|user|buyer|stakeholder|recipient|audience)\b/.test( + normalised, + ) || + /\bwhich\s+(customer|user|buyer|stakeholder|recipient|audience|segment|segments)\b/.test( + normalised, + ) || + /\b(target|intended)\s+(customer|user|buyer|stakeholder|recipient|audience|segment)\b/.test( + normalised, + ) || + /\bidentify(ing)?\s+(the\s+)?(customer|user|buyer|stakeholder|recipient|audience|segment)\b/.test( + normalised, + ) || + /\bwho\s+experiences\b/.test(normalised) || + /\bwho\s+(this|the decision|the product|the service)\s+is\s+for\b/.test( + normalised, + ) || + /\bwho\s+would\s+(receive|use|benefit)\b/.test(normalised) + ); +} + function extractMeaning(node) { const raw = `${node?.label || ""} ${node?.description || ""}`.trim(); let meaning = stripTrailingPunctuation( @@ -64,12 +89,25 @@ function extractMeaning(node) { ).trim(); const lowered = normaliseText(raw); - if ( - /\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(lowered) - ) { + if (hasAudienceIdentityQuestion(lowered)) { return "the relevant customer, user, or value recipient"; } + const description = String(node?.description || "").trim(); + const strippedDescription = stripTrailingPunctuation(description) + .replace(/^uncertainty regarding\s+/i, "") + .replace(/^uncertainty about\s+/i, "") + .trim(); + + if ( + /\b(status|likelihood|probability|chance|risk|uncertainty)\b/i.test( + String(node?.label || ""), + ) && + /^whether\s+/i.test(strippedDescription) + ) { + return sentenceCase(strippedDescription); + } + meaning = meaning .replace(/^uncertainty regarding\s+/i, "") .replace(/^uncertainty about\s+/i, "") @@ -123,7 +161,9 @@ function sanitizeQuestionText(question) { * relocation realistic in this situation?". */ function isInterrogativeMeaning(meaning) { - const trimmed = String(meaning || "").trim().toLowerCase(); + const trimmed = String(meaning || "") + .trim() + .toLowerCase(); if (!trimmed) return false; // Wh-questions (direct or indirect): who/what/where/when/how ... @@ -1159,11 +1199,7 @@ function selectQuestionFamily({ } if (reasoningPattern === "decision") { - if ( - /\b(audience|customer|user|buyer|stakeholder|recipient|who experiences)\b/.test( - text, - ) - ) { + if (hasAudienceIdentityQuestion(text)) { return { family: "decision_foundation", template: "decision_audience" }; } if ( @@ -1743,7 +1779,9 @@ function validateFormulatedQuestion(question, meaning) { } if ( !overlappingWord && - !/\b(decision|evidence|constraint|customer|value|outcome)\b/i.test(trimmed) + !/\b(decision|evidence|constraint|customer|user|buyer|stakeholder|recipient|audience|value|outcome|who experiences)\b/i.test( + trimmed, + ) ) { return false; } diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js index e2f982b..ce4a7c5 100644 --- a/tests/graph/question-formulator.test.js +++ b/tests/graph/question-formulator.test.js @@ -302,6 +302,146 @@ describe("formulateQuestion", () => { ); }); + it("60B.21 signing-status node does not route to decision_audience and preserves the proposition", () => { + const unknown = makeNode({ + id: "n-customer-signing-status", + label: "Prospective enterprise customer signing status", + description: + "Uncertainty about whether the prospective enterprise customer will sign if we launch this year, so that its resolution is needed to decide which timing option provides superior net value.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(unknown, { + centralStatement: + "We need to decide which launch timing option provides superior net value.", + }); + + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.questionFamily).not.toBe("decision_foundation"); + expect(result.selectedQuestionTemplate).not.toBe("decision_audience"); + expect(result.question.toLowerCase()).toContain( + "whether the prospective enterprise customer will sign if we launch this year", + ); + expect(result.question.toLowerCase()).not.toContain( + "relevant customer, user, or value recipient", + ); + }); + + it("legitimate audience identity unknown still routes to decision_audience", () => { + const unknown = makeNode({ + id: "n-target-customer", + label: "Who is the target customer?", + description: + "Need to know who the target customer is before continuing development.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(unknown, { + centralStatement: + "Before investing more, we need to know whether continuing development is commercially justified.", + }); + + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.questionFamily).toBe("decision_foundation"); + expect(result.selectedQuestionTemplate).toBe("decision_audience"); + expect(result.question).toBe("Who experiences this problem?"); + }); + + it("customer as proposition subject does not imply audience family", () => { + const unknown = makeNode({ + id: "n-customer-renewal", + label: "Customer renewal likelihood", + description: + "Uncertainty about whether the customer will renew next year, because that affects the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.selectedQuestionTemplate).not.toBe("decision_audience"); + expect(result.question.toLowerCase()).toContain( + "whether the customer will renew next year", + ); + }); + + it("user as proposition subject does not imply audience family", () => { + const unknown = makeNode({ + id: "n-user-adoption", + label: "User adoption uncertainty", + description: + "Uncertainty about whether users will adopt the change, because that affects the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.selectedQuestionTemplate).not.toBe("decision_audience"); + expect(result.question.toLowerCase()).toContain( + "whether users will adopt the change", + ); + }); + + it("buyer or stakeholder lexical mentions do not automatically trigger audience discovery", () => { + const unknown = makeNode({ + id: "n-stakeholder-approval", + label: "Stakeholder approval uncertainty", + description: + "Uncertainty about whether the stakeholder will approve the plan, because that affects the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const result = formulateQuestion({ + node: unknown, + graph: makeGraphFor(unknown), + }); + + expect(result.selectedQuestionTemplate).not.toBe("decision_audience"); + expect(result.question.toLowerCase()).toContain( + "whether the stakeholder will approve the plan", + ); + }); + + it("existing direct interrogative decision behaviour is preserved", () => { + const unknown = makeNode({ + id: "n-client-leave", + label: "Will our largest client leave if we relocate?", + description: + "Uncertainty regarding whether our largest client would depart following a relocation to Manchester; matters because their departure would cost approximately £5M per year.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(unknown, { + centralStatement: + "We need to decide whether relocating is commercially justified.", + }); + + const result = formulateQuestion({ node: unknown, graph }); + + expect(result.question.toLowerCase()).toBe( + "will our largest client leave if we relocate?", + ); + }); + it("evidence-resolvable competing-cause unknown stays on an evidence route rather than neutral clarification", () => { const unknown = makeNode({ id: "n-delivery-cause",