feat: enforce one-concept questions
This commit is contained in:
+111
-11
@@ -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,
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user