feat: enforce one-concept questions

This commit is contained in:
2026-08-03 08:40:39 +01:00
parent 449cf996dc
commit 3c0f7f5a45
5 changed files with 634 additions and 11 deletions
+174
View File
@@ -498,6 +498,33 @@ function buildBroadInvestigationQuestion(graph) {
return `What changed during that period that could help explain why ${central}?`;
}
function buildFoundationalDirectQuestion(node) {
const label = stripTrailingPunctuation(node?.label || "");
if (/^who experiences this problem$/i.test(label)) {
return "Who experiences this problem?";
}
if (/^what happens when this problem is not resolved$/i.test(label)) {
return "What happens when this problem is not resolved?";
}
if (/^how often this problem happens$/i.test(label)) {
return "How often does this problem happen?";
}
if (/^how people deal with this problem today$/i.test(label)) {
return "How do people deal with this problem today?";
}
if (
/^whether people actively look for help with this problem$/i.test(label)
) {
return "Do people actively look for help with this problem?";
}
if (/^whether people would pay to solve this problem$/i.test(label)) {
return "Would people pay to solve this problem?";
}
return null;
}
function isRelationshipExplanationUnknown(node, graph) {
const text = normaliseText(`${node?.label || ""} ${node?.description || ""}`);
return (
@@ -514,6 +541,12 @@ function isBroadCompositeUnknownText(text) {
);
}
function isCommercialValidationUnknownText(text) {
return /\b(genuine problem|people would value|pay for it|commercially justified|commercial justification|commercial value|justified confidence|decision support methods|decision support|budget do they currently allocate|willingness to pay|problem existence|seek help)\b/.test(
text,
);
}
function hasCompoundAbstractSignals(text) {
return (
/\b(timing|measurement|basis|cost|costs|debt|stock|tax|capital spending|mix|segment)\s+(and|or)\s+\b/.test(
@@ -592,6 +625,15 @@ export function assessUnknownAtomicity({ node, graph }) {
};
}
if (isCommercialValidationUnknownText(nodeText)) {
return {
atomicity: "composite",
reason:
"This unknown combines multiple problem-validation or commercial-validation dimensions, so it should be decomposed before asking a direct question.",
decompositionKind: "commercial_validation",
};
}
return {
atomicity: "atomic",
reason:
@@ -998,11 +1040,133 @@ function validateFormulatedQuestion(question, meaning) {
return true;
}
function countPrimaryConcepts(question) {
let concepts = 1;
if (/\band what\b/i.test(question)) concepts += 1;
if (/\bhow often\b.*\bwhat\b/i.test(question)) concepts += 1;
if (/\bcost\b.*\bbudget\b|\bbudget\b.*\bcost\b/i.test(question)) {
concepts += 1;
}
if (/\bwho\b.*\bwhat\b|\bwhat\b.*\bwho\b/i.test(question)) concepts += 1;
return concepts;
}
export function assessQuestionComplexity({ question, selectedUnknown, graph }) {
const text = String(question || "").trim();
const lower = text.toLowerCase();
const reasons = [];
const compoundQuestionSignals = [];
const abstractMatches =
lower.match(
/\b(justified confidence|financial or operational cost|comparable decision support methods|commercial justification|decision support methods|value recipient)\b/g,
) || [];
const primaryConceptCount = countPrimaryConcepts(lower);
if ((text.match(/\?/g) || []).length !== 1) {
reasons.push("multiple_question_marks");
compoundQuestionSignals.push("multiple_question_marks");
}
if (/\band what\b|\bwhat .* and .* what\b/i.test(text)) {
reasons.push("multiple_requested_answers");
compoundQuestionSignals.push("joined_requests");
}
if (/,[^,]{0,60},/.test(text) || /,\s*(and|or)\b/i.test(text)) {
reasons.push("list_like_question");
compoundQuestionSignals.push("comma_list");
}
if (/\bcost\b.*\bbudget\b|\bbudget\b.*\bcost\b/i.test(text)) {
reasons.push("cost_and_budget_combined");
compoundQuestionSignals.push("distinct_measures_combined");
}
if (primaryConceptCount > 1) {
reasons.push("multiple_primary_concepts");
}
if (text.split(/\s+/).length > 20) {
reasons.push("very_long_question");
}
if (abstractMatches.length > 1) {
reasons.push("abstract_term_chain");
}
const cognitiveLoad =
reasons.length >= 3 ? "high" : reasons.length === 2 ? "medium" : "low";
return {
acceptable: reasons.length === 0,
primaryConceptCount,
compoundQuestionSignals: [...new Set(compoundQuestionSignals)],
abstractTermCount: abstractMatches.length,
cognitiveLoad,
reasons,
selectedUnknownId: selectedUnknown?.id ?? null,
graphCentralStatement: graph?.centralStatement ?? null,
};
}
function applyPlainLanguageNormalisations(question) {
const normalisations = [];
let next = String(question || "");
const replacements = [
[
/individuals experiencing insufficient justified confidence/gi,
"people who struggle to feel confident about a decision",
"simplified_justified_confidence_phrase",
],
[
/current financial or operational cost/gi,
"current cost",
"simplified_cost_phrase",
],
[
/comparable decision support methods/gi,
"other ways they deal with the problem",
"simplified_decision_support_phrase",
],
[
/the relevant customer, user, or value recipient/gi,
"the people affected",
"simplified_actor_phrase",
],
];
for (const [pattern, replacement, code] of replacements) {
if (pattern.test(next)) {
next = next.replace(pattern, replacement);
normalisations.push(code);
}
}
next = sanitizeQuestionText(next);
return { question: next, normalisations };
}
export function formulateQuestion({ node, graph, context = {} }) {
if (context.selectionState?.status === "ambiguous") {
return formulateTieResolutionQuestion({ graph });
}
const foundationalDirectQuestion = buildFoundationalDirectQuestion(node);
if (foundationalDirectQuestion) {
const plainLanguage = applyPlainLanguageNormalisations(
sanitizeQuestionText(foundationalDirectQuestion),
);
const questionComplexity = assessQuestionComplexity({
question: plainLanguage.question,
selectedUnknown: node,
graph,
});
return {
question: plainLanguage.question,
reason:
"Formulated as a direct foundational question because this child unknown should be answered one step at a time.",
strategy: null,
investigationStrategy: null,
questionComplexity,
plainLanguageNormalisations: plainLanguage.normalisations,
};
}
const investigationStrategy = selectInvestigationStrategy({
node,
graph,
@@ -1016,6 +1180,8 @@ export function formulateQuestion({ node, graph, context = {} }) {
: buildNeutralClarificationQuestion(extractMeaning(node));
question = sanitizeQuestionText(question);
const plainLanguage = applyPlainLanguageNormalisations(question);
question = plainLanguage.question;
const fallbackMeaning = extractMeaning(node);
if (
@@ -1044,6 +1210,12 @@ export function formulateQuestion({ node, graph, context = {} }) {
);
}
const questionComplexity = assessQuestionComplexity({
question,
selectedUnknown: node,
graph,
});
return {
question,
reason: investigationStrategy
@@ -1051,5 +1223,7 @@ export function formulateQuestion({ node, graph, context = {} }) {
: "Formulated as a neutral clarification question because no narrower investigation strategy clearly applied.",
strategy: investigationStrategy?.key ?? null,
investigationStrategy,
questionComplexity,
plainLanguageNormalisations: plainLanguage.normalisations,
};
}