fix(graph): preserve grammar for question-like unknown labels
This commit is contained in:
@@ -115,12 +115,72 @@ function sanitizeQuestionText(question) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a meaning string is already an interrogative (wh-question,
|
||||
* yes/no question, or modal auxiliary inversion). This covers the class of
|
||||
* labels that were previously interpolated raw into template frames, producing
|
||||
* output like "What would clarify are the projected office savings from
|
||||
* relocation realistic in this situation?".
|
||||
*/
|
||||
function isInterrogativeMeaning(meaning) {
|
||||
const trimmed = String(meaning || "").trim().toLowerCase();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// Wh-questions (direct or indirect): who/what/where/when/how ...
|
||||
if (/^(who|what|where|when|how)\b/.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Yes/no questions via subject-auxiliary inversion: the first word is a
|
||||
// modal/auxiliary verb followed by a subject determiner (the / a / an / this / etc.)
|
||||
// This covers "Is the …", "Are the …", "Will we …", "Does it …", etc.
|
||||
const auxVerbs =
|
||||
"is|are|was|were|do|does|did|have|has|had|will|would|can|could|should|may|might|must";
|
||||
if (
|
||||
new RegExp(
|
||||
`^(?:${auxVerbs})\\s+(?:the|a|an|this|that|these|those|my|your|our|their|some|any|each|every|both|i|you|we|he|she|it|they)`,
|
||||
"i",
|
||||
).test(trimmed)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// "whether" clauses — also already question-shaped
|
||||
if (/^whether\b/i.test(trimmed)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrapInterrogativeForTemplate(meaning) {
|
||||
const stripped = stripTrailingPunctuation(meaning).trim();
|
||||
|
||||
// Direct interrogatives → return unchanged (already coherent standalone questions)
|
||||
if (/^(who|what|where|when|how)\b/.test(stripped.toLowerCase())) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function buildNeutralClarificationQuestion(meaning) {
|
||||
return `What would clarify ${stripTrailingPunctuation(meaning)} in this situation?`;
|
||||
const content = wrapInterrogativeForTemplate(meaning);
|
||||
// If content is already interrogative (wh-), use it as-is with trailing context
|
||||
if (isInterrogativeMeaning(content)) {
|
||||
return `${content}?`;
|
||||
}
|
||||
return `What would clarify ${content} in this situation?`;
|
||||
}
|
||||
|
||||
function buildEvidenceFallbackQuestion(meaning) {
|
||||
return `What evidence would confirm or rule out ${stripTrailingPunctuation(meaning)}?`;
|
||||
const content = wrapInterrogativeForTemplate(meaning);
|
||||
if (isInterrogativeMeaning(content)) {
|
||||
return `${content}?`;
|
||||
}
|
||||
return `What evidence would confirm or rule out ${content}?`;
|
||||
}
|
||||
|
||||
function extractConstraintClarificationSubject(node) {
|
||||
@@ -1172,27 +1232,52 @@ function buildQuestionFromFamily({
|
||||
},
|
||||
);
|
||||
}
|
||||
// If meaning is already interrogative, use it directly instead of wrapping
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return `${wrapInterrogativeForTemplate(meaning)}?`;
|
||||
}
|
||||
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
|
||||
}
|
||||
|
||||
if (questionFamily === "definition") {
|
||||
// If meaning is already interrogative, use it directly
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return `${wrapInterrogativeForTemplate(meaning)}?`;
|
||||
}
|
||||
return `What does ${meaning} mean in this situation?`;
|
||||
}
|
||||
|
||||
if (reasoningPattern === "comparison") {
|
||||
if (selectedQuestionTemplate === "comparison_timing_basis") {
|
||||
return `What evidence would clarify whether ${stripTrailingPunctuation(meaning)}?`;
|
||||
const cmpContent = wrapInterrogativeForTemplate(meaning);
|
||||
if (isInterrogativeMeaning(cmpContent)) {
|
||||
return `${cmpContent}?`;
|
||||
}
|
||||
return `What evidence would clarify whether ${stripTrailingPunctuation(cmpContent)}?`;
|
||||
}
|
||||
if (selectedQuestionTemplate === "comparison_measurement_basis") {
|
||||
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
|
||||
const cmpContent = wrapInterrogativeForTemplate(meaning);
|
||||
if (isInterrogativeMeaning(cmpContent)) {
|
||||
return `${cmpContent}?`;
|
||||
}
|
||||
return `What evidence would clarify ${stripTrailingPunctuation(cmpContent)}?`;
|
||||
}
|
||||
// Default comparison — handle interrogative meaning
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return `${wrapInterrogativeForTemplate(meaning)}?`;
|
||||
}
|
||||
return `What evidence would clarify ${stripTrailingPunctuation(meaning)}?`;
|
||||
}
|
||||
|
||||
if (reasoningPattern === "contradiction") {
|
||||
return investigationStrategy?.key === "contradiction_resolution"
|
||||
? buildQuestionFromStrategy(investigationStrategy)
|
||||
: `What fact would resolve the contradiction about ${stripTrailingPunctuation(meaning)}?`;
|
||||
if (investigationStrategy?.key === "contradiction_resolution") {
|
||||
return buildQuestionFromStrategy(investigationStrategy);
|
||||
}
|
||||
// If meaning is interrogative, use it directly
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return `${wrapInterrogativeForTemplate(meaning)}?`;
|
||||
}
|
||||
return `What fact would resolve the contradiction about ${stripTrailingPunctuation(meaning)}?`;
|
||||
}
|
||||
|
||||
if (reasoningPattern === "explanation") {
|
||||
@@ -1203,9 +1288,14 @@ function buildQuestionFromFamily({
|
||||
return `Which option should be investigated first, and why?`;
|
||||
}
|
||||
|
||||
return investigationStrategy
|
||||
? buildQuestionFromStrategy(investigationStrategy)
|
||||
: buildNeutralClarificationQuestion(meaning);
|
||||
// Default (diagnosis) — handle interrogative meaning
|
||||
if (investigationStrategy) {
|
||||
return buildQuestionFromStrategy(investigationStrategy);
|
||||
}
|
||||
if (isInterrogativeMeaning(meaning)) {
|
||||
return `${wrapInterrogativeForTemplate(meaning)}?`;
|
||||
}
|
||||
return buildNeutralClarificationQuestion(meaning);
|
||||
}
|
||||
|
||||
export function formulateTieResolutionQuestion({ graph }) {
|
||||
@@ -1590,6 +1680,12 @@ export function selectInvestigationStrategy({ node, graph, context = {} }) {
|
||||
}
|
||||
|
||||
function buildQuestionFromStrategy(strategy) {
|
||||
// If meaning is interrogative, use it directly instead of embedding in a template
|
||||
const m = strategy.meaning;
|
||||
if (isInterrogativeMeaning(m)) {
|
||||
return `${wrapInterrogativeForTemplate(m)}?`;
|
||||
}
|
||||
|
||||
switch (strategy.key) {
|
||||
case "decision_threshold":
|
||||
return strategy.actionPhrase
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formulateQuestion } from "@/lib/graph/question-formulator.js";
|
||||
import { 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",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Experiment 58A.4 — question formulation for pre-question-shaped labels
|
||||
*
|
||||
* Defect: when an unknown's label is already question or clause shaped,
|
||||
* the engine interpolates it raw into a generic template frame, producing
|
||||
* ungrammatical output such as "What would clarify are the projected office
|
||||
* savings from relocation realistic in this situation?".
|
||||
*
|
||||
* The fix must generalise structurally to ANY interrogative/clause label,
|
||||
* not just the specific patterns seen so far.
|
||||
*/
|
||||
describe("question formulation — pre-question labels (58A.4)", () => {
|
||||
|
||||
// ─── Helper: run formulateQuestion on an unknown and extract the question text ───
|
||||
function questionFor(label, extra = {}) {
|
||||
const node = makeNode({
|
||||
id: "n-test",
|
||||
label,
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
...extra,
|
||||
});
|
||||
const result = formulateQuestion({
|
||||
node,
|
||||
graph: makeGraphFor(node),
|
||||
});
|
||||
return result.question;
|
||||
}
|
||||
|
||||
// ─── Test 1: the exact defect from 58A.3 — wh-question label ───
|
||||
it("produces a grammatical question when label is a wh-question", () => {
|
||||
const q = questionFor(
|
||||
"Are the projected office savings from relocation realistic?",
|
||||
);
|
||||
|
||||
// Must NOT contain template injection artefact
|
||||
expect(q).not.toMatch(/what would clarify are .* in this situation/i);
|
||||
// Should be grammatical — either a standalone wh-question or wrapped properly
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
// The core meaning must survive
|
||||
expect(q.toLowerCase()).toMatch(/office savings|relocation/);
|
||||
});
|
||||
|
||||
// ─── Test 2: clause-shaped statement label (no question mark) ───
|
||||
it("produces a grammatical question when label is a declarative clause", () => {
|
||||
const q = questionFor(
|
||||
"The projected office savings from relocation are unrealistic",
|
||||
);
|
||||
|
||||
// Should be grammatical question form
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/office savings|relocation/);
|
||||
});
|
||||
|
||||
// ─── Test 3: yes/no question label ───
|
||||
it("produces a grammatical question when label is a yes/no question", () => {
|
||||
const q = questionFor("Is the budget sufficient for this project?");
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/budget|sufficient/);
|
||||
});
|
||||
|
||||
// ─── Test 4: whether-clause label ───
|
||||
it("produces a grammatical question when label starts with 'whether'", () => {
|
||||
const q = questionFor(
|
||||
"Whether the new pricing strategy will increase revenue",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/pricing|revenue/);
|
||||
});
|
||||
|
||||
// ─── Test 5: what-question label ───
|
||||
it("produces a grammatical question when label is a what-question", () => {
|
||||
const q = questionFor(
|
||||
"What are the key risks of this project?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
// Should not double-wrap with "what would clarify"
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify what/i);
|
||||
expect(q.toLowerCase()).toMatch(/key risks/);
|
||||
});
|
||||
|
||||
// ─── Test 6: how-question label ───
|
||||
it("produces a grammatical question when label is a how-question", () => {
|
||||
const q = questionFor(
|
||||
"How do we measure success for this initiative?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify how/i);
|
||||
expect(q.toLowerCase()).toMatch(/measure success/);
|
||||
});
|
||||
|
||||
// ─── Test 7: why-question label ───
|
||||
it("produces a grammatical question when label is a why-question", () => {
|
||||
const q = questionFor(
|
||||
"Why did the previous quarter underperform?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify why/i);
|
||||
expect(q.toLowerCase()).toMatch(/quarter|underperform/);
|
||||
});
|
||||
|
||||
// ─── Test 8: who-question label ───
|
||||
it("produces a grammatical question when label is a who-question", () => {
|
||||
const q = questionFor(
|
||||
"Who is responsible for the client onboarding process?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify who/i);
|
||||
expect(q.toLowerCase()).toMatch(/responsible|client/);
|
||||
});
|
||||
|
||||
// ─── Test 9: when-question label ───
|
||||
it("produces a grammatical question when label is a when-question", () => {
|
||||
const q = questionFor(
|
||||
"When does the current contract expire?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify when/i);
|
||||
expect(q.toLowerCase()).toMatch(/contract|expire/);
|
||||
});
|
||||
|
||||
// ─── Test 10: where-question label ───
|
||||
it("produces a grammatical question when label is a where-question", () => {
|
||||
const q = questionFor(
|
||||
"Where should the new warehouse be located?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify where/i);
|
||||
expect(q.toLowerCase()).toMatch(/warehouse|located/);
|
||||
});
|
||||
|
||||
// ─── Test 11: non-question labels should NOT be affected ───
|
||||
it("still applies template frames to declarative labels", () => {
|
||||
const q = questionFor(
|
||||
"Office lease exit penalty amount",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
// For a normal declarative label, the template frame should apply
|
||||
// The key is that it's grammatical, not that it uses a specific frame
|
||||
expect(q.toLowerCase()).toMatch(/lease|penalty/);
|
||||
});
|
||||
|
||||
// ─── Test 12: evidence-fallback path with pre-question label ───
|
||||
it("produces grammatical output via evidence fallback for question labels", () => {
|
||||
const q = questionFor(
|
||||
"Are the projected office savings from relocation realistic?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
// The output should be a single coherent question
|
||||
const questionMarks = (q.match(/\?/g) || []).length;
|
||||
expect(questionMarks).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Test 13: clause without question mark — statement form ───
|
||||
it("handles label shaped like a that-clause", () => {
|
||||
const q = questionFor(
|
||||
"That the migration will reduce operational costs by at least 20%",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/migration|cost/);
|
||||
});
|
||||
|
||||
// ─── Test 14: complex interrogative with embedded clause ───
|
||||
it("handles label shaped like an indirect question", () => {
|
||||
const q = questionFor(
|
||||
"How much funding we need to complete the product launch",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/funding|launch/);
|
||||
});
|
||||
|
||||
// ─── Test 15: the exact output from 58A.2 template — should not recur ───
|
||||
it("does not produce the 58A.2 malformed question", () => {
|
||||
const q = questionFor(
|
||||
"Are the projected office savings from relocation realistic?",
|
||||
);
|
||||
|
||||
expect(q).not.toBe(
|
||||
"What was the comparable state before are the projected office savings from relocation realistic?",
|
||||
);
|
||||
expect(q).not.toMatch(/before .* are .* realistic/);
|
||||
});
|
||||
|
||||
// ─── Test 16: short yes/no question label ───
|
||||
it("handles very short interrogative labels", () => {
|
||||
const q = questionFor("Is this the right approach?");
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).toMatch(/right|approach/);
|
||||
});
|
||||
|
||||
// ─── Test 17: label with trailing punctuation already stripped in extractMeaning ───
|
||||
it("handles label that is interrogative but loses its question mark via stripTrailingPunctuation", () => {
|
||||
// This tests the internal pipeline: extractMeaning strips trailing ?,
|
||||
// then the meaning feeds into buildNeutralClarificationQuestion or evidence fallback
|
||||
const q = questionFor(
|
||||
"What is the total addressable market for this segment?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
const questionMarks = (q.match(/\?/g) || []).length;
|
||||
expect(questionMarks).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Test 18: long complex interrogative label ───
|
||||
it("handles very long interrogative labels without template doubling", () => {
|
||||
const q = questionFor(
|
||||
"Are the current employee satisfaction scores sufficient to justify continuing the remote work policy?",
|
||||
);
|
||||
|
||||
expect(q.endsWith("?")).toBe(true);
|
||||
expect(q.toLowerCase()).not.toMatch(/what would clarify are .* sufficient/i);
|
||||
expect(q.toLowerCase()).toMatch(/satisfaction|remote/);
|
||||
});
|
||||
|
||||
// ─── Test 19: interrogative label in definition reasoning pattern ───
|
||||
it("handles interrogative labels through definition path", () => {
|
||||
const node = makeNode({
|
||||
id: "n-def",
|
||||
label: "What does 'customer' mean in this context?",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const result = formulateQuestion({
|
||||
node,
|
||||
graph: makeGraphFor(node),
|
||||
});
|
||||
|
||||
expect(result.question.endsWith("?")).toBe(true);
|
||||
const questionMarks = (result.question.match(/\?/g) || []).length;
|
||||
expect(questionMarks).toBe(1);
|
||||
});
|
||||
|
||||
// ─── Test 20: interrogative label in evidence gathering pattern ───
|
||||
it("handles interrogative labels through evidence path", () => {
|
||||
const node = makeNode({
|
||||
id: "n-evidence",
|
||||
label: "What additional data do we need to validate the hypothesis?",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const result = formulateQuestion({
|
||||
node,
|
||||
graph: makeGraphFor(node),
|
||||
});
|
||||
|
||||
expect(result.question.endsWith("?")).toBe(true);
|
||||
const questionMarks = (result.question.match(/\?/g) || []).length;
|
||||
expect(questionMarks).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user