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
+111 -11
View File
@@ -1385,10 +1385,74 @@ function describeObservationFocus(context, which) {
}
function buildDecompositionTemplates(parentNode, graph, depth = 0) {
const parentText = normaliseText(
`${parentNode?.label || ""} ${parentNode?.description || ""}`,
);
const context = buildDecompositionContext(graph);
const firstFocus = describeObservationFocus(context, "first");
const secondFocus = describeObservationFocus(context, "second");
if (
/\b(genuine problem|commercially justified|commercial justification|people would value|pay for it|justified confidence|decision support methods|willingness to pay|seek help)\b/.test(
parentText,
)
) {
return [
{
label: "Who experiences this problem",
description:
"Need to know who experiences this problem, because that must be clear before deciding whether it is commercially justified.",
dependsOnLabels: [],
},
{
label: "What happens when this problem is not resolved",
description:
"Need to know what happens when this problem is not resolved, because that is needed before judging whether the problem matters.",
dependsOnLabels: ["Who experiences this problem"],
},
{
label: "How often this problem happens",
description:
"Need to know how often this problem happens, because that helps judge whether it is a real recurring problem.",
dependsOnLabels: ["Who experiences this problem"],
},
{
label: "How people deal with this problem today",
description:
"Need to know how people deal with this problem today, because that is needed before comparing alternatives or value.",
dependsOnLabels: [
"Who experiences this problem",
"What happens when this problem is not resolved",
"How often this problem happens",
],
},
depth === 0
? {
label: "Whether people actively look for help with this problem",
description:
"Need to know whether people actively look for help with this problem, because that is needed before judging demand or willingness to pay.",
dependsOnLabels: [
"Who experiences this problem",
"What happens when this problem is not resolved",
"How often this problem happens",
"How people deal with this problem today",
],
}
: {
label: "Whether people would pay to solve this problem",
description:
"Need to know whether people would pay to solve this problem, because that can only be judged after the problem itself is established.",
dependsOnLabels: [
"Who experiences this problem",
"What happens when this problem is not resolved",
"How often this problem happens",
"How people deal with this problem today",
"Whether people actively look for help with this problem",
],
},
];
}
if (/\btiming or measurement basis\b/i.test(parentNode.label)) {
return [
{
@@ -1434,14 +1498,25 @@ function buildDecompositionTemplates(parentNode, graph, depth = 0) {
function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
const templates = buildDecompositionTemplates(parentNode, graph, depth);
const candidateNodes = templates.map(
(template) =>
findEquivalentDecompositionChild(
graph,
parentNode.id,
template.label,
template.description,
) || {
const labelToId = new Map(
templates.map((template) => [
template.label,
buildDecompositionChildId(parentNode.id, template.label),
]),
);
const candidateNodes = templates.map((template) => {
const existing = findEquivalentDecompositionChild(
graph,
parentNode.id,
template.label,
template.description,
);
const dependsOn = (template.dependsOnLabels || [])
.map((label) => labelToId.get(label))
.filter(Boolean);
return (
existing || {
id: buildDecompositionChildId(parentNode.id, template.label),
label: template.label,
description: template.description,
@@ -1451,12 +1526,13 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) {
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
dependsOn,
affects: [],
parentId: parentNode.id,
childIds: [],
},
);
}
);
});
const childNodes = [];
const childEdges = [];
const childNodeIds = [];
@@ -1566,9 +1642,14 @@ function runDeterministicDecomposition({
let proposedChildCount = 0;
let acceptedChildCount = 0;
let selectedChildNodeId = null;
let selectedUnknownBefore =
deterministicSelection?.status === "selected"
? deterministicSelection.nodeId
: null;
let decompositionStoppedReason = null;
let rejectedChildren = [];
let childQualitySummary = [];
let decompositionTriggeredByQuestionComplexity = false;
while (workingSelection?.status === "selected" && workingSelection?.nodeId) {
const selectedNode = findNodeById(workingGraph, workingSelection.nodeId);
@@ -1699,6 +1780,8 @@ function runDeterministicDecomposition({
rejectedChildren,
childQualitySummary,
selectedChildNodeId,
selectedUnknownBefore,
decompositionTriggeredByQuestionComplexity,
};
}
@@ -2121,6 +2204,10 @@ export function applyValidatedProposal({
})
: null;
const questionComplexity = formulatedQuestion?.questionComplexity ?? null;
const plainLanguageNormalisations =
formulatedQuestion?.plainLanguageNormalisations ?? [];
const finalSelectedQuestion =
deterministicSelection?.status === "ambiguous"
? {
@@ -2137,6 +2224,8 @@ export function applyValidatedProposal({
reason: formulatedQuestion?.reason || deterministicSelection.reason,
strategy: formulatedQuestion?.strategy,
investigationStrategy: formulatedQuestion?.investigationStrategy,
questionComplexity,
plainLanguageNormalisations,
}
: null;
@@ -2202,6 +2291,17 @@ export function applyValidatedProposal({
rejectedChildren,
selectedChildNodeId,
childQualitySummary,
selectedUnknownBefore: decompositionResult.selectedUnknownBefore,
selectedUnknownAfter: deterministicSelection?.nodeId ?? null,
questionComplexityAccepted: questionComplexity?.acceptable ?? null,
primaryConceptCount: questionComplexity?.primaryConceptCount ?? null,
cognitiveLoad: questionComplexity?.cognitiveLoad ?? null,
complexityReasons: questionComplexity?.reasons ?? [],
decompositionTriggeredByQuestionComplexity:
decompositionResult.decompositionTriggeredByQuestionComplexity ?? false,
previousQuestion,
finalQuestion: finalSelectedQuestion?.question ?? null,
plainLanguageNormalisations,
propagationPerformed,
resolvedChildNodeId,
parentNodeId,
+54
View File
@@ -133,6 +133,16 @@ function buildUpdateDiagnostics({
childUnknownCount,
childNodeIds,
atomicityReason,
questionComplexityAccepted,
primaryConceptCount,
cognitiveLoad,
complexityReasons,
decompositionTriggeredByQuestionComplexity,
previousQuestion,
finalQuestion,
selectedUnknownBefore,
selectedUnknownAfter,
plainLanguageNormalisations,
}) {
return {
promptVersion: promptVersion ?? "v0.4",
@@ -202,6 +212,17 @@ function buildUpdateDiagnostics({
childUnknownCount: childUnknownCount ?? 0,
childNodeIds: childNodeIds ?? [],
atomicityReason: atomicityReason ?? null,
questionComplexityAccepted: questionComplexityAccepted ?? null,
primaryConceptCount: primaryConceptCount ?? null,
cognitiveLoad: cognitiveLoad ?? null,
complexityReasons: complexityReasons ?? [],
decompositionTriggeredByQuestionComplexity:
decompositionTriggeredByQuestionComplexity ?? false,
previousQuestion: previousQuestion ?? null,
finalQuestion: finalQuestion ?? null,
selectedUnknownBefore: selectedUnknownBefore ?? null,
selectedUnknownAfter: selectedUnknownAfter ?? null,
plainLanguageNormalisations: plainLanguageNormalisations ?? [],
unknownSelectionExplanation: unknownSelectionExplanation ?? null,
};
}
@@ -495,6 +516,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
childUnknownCount: 0,
childNodeIds: [],
atomicityReason: null,
questionComplexityAccepted: null,
primaryConceptCount: null,
cognitiveLoad: null,
complexityReasons: [],
decompositionTriggeredByQuestionComplexity: false,
previousQuestion,
finalQuestion: null,
selectedUnknownBefore: null,
selectedUnknownAfter: null,
plainLanguageNormalisations: [],
unknownSelectionExplanation: explainUnknownSelection(
situationGraph,
situationGraph.resolvedNodeIds || [],
@@ -582,6 +613,19 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
childUnknownCount: applicationResult.childUnknownCount,
childNodeIds: applicationResult.childNodeIds,
atomicityReason: applicationResult.atomicityReason,
questionComplexityAccepted:
applicationResult.questionComplexityAccepted,
primaryConceptCount: applicationResult.primaryConceptCount,
cognitiveLoad: applicationResult.cognitiveLoad,
complexityReasons: applicationResult.complexityReasons,
decompositionTriggeredByQuestionComplexity:
applicationResult.decompositionTriggeredByQuestionComplexity,
previousQuestion: applicationResult.previousQuestion,
finalQuestion: applicationResult.finalQuestion,
selectedUnknownBefore: applicationResult.selectedUnknownBefore,
selectedUnknownAfter: applicationResult.selectedUnknownAfter,
plainLanguageNormalisations:
applicationResult.plainLanguageNormalisations,
unknownSelectionExplanation: buildUnknownSelectionDiagnostics(
applicationResult.updatedSituationGraph,
applicationResult.updatedSituationGraph.resolvedNodeIds || [],
@@ -650,6 +694,16 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
childUnknownCount: 0,
childNodeIds: [],
atomicityReason: null,
questionComplexityAccepted: null,
primaryConceptCount: null,
cognitiveLoad: null,
complexityReasons: [],
decompositionTriggeredByQuestionComplexity: false,
previousQuestion,
finalQuestion: null,
selectedUnknownBefore: null,
selectedUnknownAfter: null,
plainLanguageNormalisations: [],
unknownSelectionExplanation: buildUnknownSelectionDiagnostics(
situationGraph,
situationGraph.resolvedNodeIds || [],
+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,
};
}