diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 13b0e21..f80ac42 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -34,11 +34,123 @@ import { validateGraphReferences, validateGraphUpdate, } from "./utils.js"; +import { getProvider } from "@/lib/llm/provider"; function cloneJsonSafe(value) { return JSON.parse(JSON.stringify(value)); } +function buildSemanticDecompositionPrompt({ parentNode, graph }) { + return `You are proposing narrower child unknowns for one unresolved compound parent unknown. + +Return exactly one JSON object. Return JSON only. + +This is NOT a graph update task. +Do NOT mutate graph state. +Do NOT select a child. +Do NOT rank children. +Do NOT resolve the parent. +Do NOT create conclusions, actions, recommendations, or unrelated graph content. + +Required top-level fields: +- parentNodeId +- proposedChildren + +Field rules: +- parentNodeId must exactly equal the supplied parent node id. +- proposedChildren must be an array of 0-5 objects. +- each child object must contain only: label, description. +- each child must preserve the parent's meaning while making it narrower and directly answerable. +- each child must express only one uncertainty. + +Parent unknown: +- id: ${parentNode.id} +- label: ${parentNode.label} +- description: ${parentNode.description || ""} + +Consider breaking into dimensions like: customer type, team capability, responsibility distribution, pricing structure, time factors — whatever creates the dependency. + +Each candidate child should: +- be a genuine uncertainty (not an action or conclusion) +- be narrow enough to address in one analytical step +- focus on one specific dimension +- stay grounded in the parent's core meaning`; +} + +function validateSemanticDecompositionResult(result, parentNodeId) { + const errors = []; + if (!result || typeof result !== "object") { + return ["Semantic decomposition result must be an object."]; + } + if (result.parentNodeId !== parentNodeId) { + errors.push( + "Semantic decomposition result parentNodeId did not match the requested parent node id.", + ); + } + if (!Array.isArray(result.proposedChildren)) { + errors.push( + "Semantic decomposition result must include a proposedChildren array.", + ); + return errors; + } + if (result.proposedChildren.length > 5) { + errors.push("Semantic decomposition result proposed too many children."); + } + for (const child of result.proposedChildren) { + if (!child || typeof child !== "object") { + errors.push("Each proposed child must be an object."); + continue; + } + const keys = Object.keys(child); + if (!keys.includes("label") || !keys.includes("description")) { + errors.push("Each proposed child must include label and description."); + } + const unexpectedKeys = keys.filter( + (key) => !["label", "description"].includes(key), + ); + if (unexpectedKeys.length > 0) { + errors.push( + `Proposed child contained unexpected fields: ${unexpectedKeys.join(", ")}`, + ); + } + if (typeof child.label !== "string" || child.label.trim().length === 0) { + errors.push("Each proposed child label must be a non-empty string."); + } + if ( + typeof child.description !== "string" || + child.description.trim().length === 0 + ) { + errors.push("Each proposed child description must be a non-empty string."); + } + } + return errors; +} + +async function proposeSemanticDecompositionChildren({ + parentNode, + graph, + provider, + modelName, +}) { + const llmProvider = provider ?? getProvider(); + const raw = await llmProvider.generateReconstruction( + buildSemanticDecompositionPrompt({ parentNode, graph }), + modelName ?? process.env.OLLAMA_MODEL, + ); + const validationErrors = validateSemanticDecompositionResult(raw, parentNode.id); + if (validationErrors.length > 0) { + return { success: false, proposedChildren: [], errors: validationErrors }; + } + return { + success: true, + proposedChildren: raw.proposedChildren.map((child) => ({ + label: child.label.trim(), + description: child.description.trim(), + })), + errors: [], + }; +} + function zodIssuesToErrors(error) { return ( error?.issues?.map((issue) => { @@ -1820,8 +1932,32 @@ function buildDecompositionTemplates(parentNode, graph, depth = 0) { return []; } -function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { - const templates = buildDecompositionTemplates(parentNode, graph, depth); +async function buildCompositeUnknownChildren( + parentNode, + graph, + depth = 0, + activeReasoningPattern, + options = {}, +) { + let templates = buildDecompositionTemplates(parentNode, graph, depth); + + let usedSemanticFallback = false; + let semanticFallbackErrors = []; + + if (templates.length === 0 && options.allowSemanticFallback) { + const semanticFallback = await proposeSemanticDecompositionChildren({ + parentNode, + graph, + provider: options.provider, + modelName: options.modelName, + }); + if (semanticFallback.success) { + templates = semanticFallback.proposedChildren; + usedSemanticFallback = true; + } else { + semanticFallbackErrors = semanticFallback.errors; + } + } if (templates.length === 0) { return { @@ -1833,6 +1969,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { acceptedChildCount: 0, rejectedChildren: [], childQualitySummary: [], + usedSemanticFallback, + semanticFallbackErrors, reason: "Decomposition stopped because no meaning-preserving child family was justified for this parent.", }; @@ -1936,6 +2074,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { acceptedChildCount, rejectedChildren, childQualitySummary, + usedSemanticFallback, + semanticFallbackErrors, reason: acceptedChildCount === 0 ? "Decomposition stopped because all proposed children failed quality checks." @@ -1952,6 +2092,8 @@ function buildCompositeUnknownChildren(parentNode, graph, depth = 0) { acceptedChildCount, rejectedChildren, childQualitySummary, + usedSemanticFallback, + semanticFallbackErrors, reason: childNodes.length > 0 ? "Decomposed a composite unknown into smaller broad candidate dimensions before asking the next question." @@ -2768,10 +2910,12 @@ function buildSelectedQuestionResult({ }; } -function resolveAmbiguousGraphBackedSelection({ +async function resolveAmbiguousGraphBackedSelection({ graphSnapshot, updatedSituationGraph, deterministicSelection, + provider = null, + modelName = null, }) { const orderedCandidateIds = deterministicSelection?.displayOrder || @@ -2785,7 +2929,7 @@ function resolveAmbiguousGraphBackedSelection({ continue; } - const candidateResult = runDeterministicDecomposition({ + const candidateResult = await runDeterministicDecomposition({ graphSnapshot, proposalSnapshot: { addedNodes: [], @@ -2804,6 +2948,8 @@ function resolveAmbiguousGraphBackedSelection({ reason: "Selected this tied candidate for deterministic decomposition-based reselection.", }, + provider, + modelName, }); if (!candidateResult.success) { @@ -2874,7 +3020,11 @@ function reseatSelectionAfterQuestionRejection({ const QUESTION_FORMULATION_REJECTION_NO_QUESTION_REASON = "The selected investigation target remains active, but its current graph-backed question formulation was rejected as too complex."; -export function determineGraphBackedQuestion({ situationGraph }) { +export async function determineGraphBackedQuestion({ + situationGraph, + provider = null, + modelName = null, +}) { const graphSnapshot = cloneJsonSafe(situationGraph); let updatedSituationGraph = cloneJsonSafe(situationGraph); let deterministicSelection = selectActiveUnknownCandidate( @@ -2882,7 +3032,7 @@ export function determineGraphBackedQuestion({ situationGraph }) { updatedSituationGraph.resolvedNodeIds || [], ); - const decompositionResult = runDeterministicDecomposition({ + const decompositionResult = await runDeterministicDecomposition({ graphSnapshot, proposalSnapshot: { addedNodes: [], @@ -2896,6 +3046,8 @@ export function determineGraphBackedQuestion({ situationGraph }) { updatedSituationGraph, reasoningResolution: { reasoningStateOverride: {} }, deterministicSelection, + provider, + modelName, }); if (!decompositionResult.success) { @@ -2952,10 +3104,12 @@ export function determineGraphBackedQuestion({ situationGraph }) { deterministicSelection?.status === "ambiguous" && !questionResult.selectedQuestion?.question ) { - const reselectionResult = resolveAmbiguousGraphBackedSelection({ + const reselectionResult = await resolveAmbiguousGraphBackedSelection({ graphSnapshot, updatedSituationGraph, deterministicSelection, + provider, + modelName, }); if (reselectionResult) { @@ -3015,13 +3169,15 @@ export function determineGraphBackedQuestion({ situationGraph }) { }; } -function runDeterministicDecomposition({ +async function runDeterministicDecomposition({ graphSnapshot, proposalSnapshot, updatedSituationGraph, reasoningResolution, deterministicSelection, structurallyAdmittedNodeIds = new Set(), + provider = null, + modelName = null, }) { let workingGraph = updatedSituationGraph; let workingSelection = deterministicSelection; @@ -3189,11 +3345,16 @@ function runDeterministicDecomposition({ } decompositionAttempted = true; - const decomposition = buildCompositeUnknownChildren( + const decomposition = await buildCompositeUnknownChildren( selectedNode, workingGraph, decompositionDepth, activeReasoningPattern, + { + allowSemanticFallback: Boolean(provider), + provider, + modelName, + }, ); proposedChildCount = decomposition.proposedChildCount; @@ -3805,11 +3966,13 @@ function deriveReasoningStateOverride({ }; } -export function applyValidatedProposal({ +export async function applyValidatedProposal({ situationGraph, proposal, previousQuestion = null, answer = null, + provider = null, + modelName = null, }) { const graphValidation = situationGraphSchema.safeParse(situationGraph); const proposalValidation = graphUpdateSchema.safeParse(proposal); @@ -4093,13 +4256,15 @@ export function applyValidatedProposal({ updatedSituationGraph.resolvedNodeIds, ); - const decompositionResult = runDeterministicDecomposition({ + const decompositionResult = await runDeterministicDecomposition({ graphSnapshot, proposalSnapshot, updatedSituationGraph, reasoningResolution, deterministicSelection, structurallyAdmittedNodeIds, + provider, + modelName, }); if (!decompositionResult.success) { diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js index 26036a0..05c253a 100644 --- a/lib/graph/orchestrator.js +++ b/lib/graph/orchestrator.js @@ -343,7 +343,7 @@ function buildUpdateDiagnostics({ }; } -export async function startCase(body) { +export async function startCase(body, dependencies = {}) { const parsedRequest = startCaseRequestSchema.safeParse(body); if (!parsedRequest.success) { @@ -399,8 +399,12 @@ export async function startCase(body) { const graphReferenceValidation = validateGraphReferences( initialSituationGraph, ); - const initialQuestionResult = determineGraphBackedQuestion({ + const provider = dependencies.provider ?? getProvider(); + const modelName = dependencies.modelName ?? analysis?.modelName ?? null; + const initialQuestionResult = await determineGraphBackedQuestion({ situationGraph: initialSituationGraph, + provider, + modelName, }); const situationGraph = initialQuestionResult.success ? initialQuestionResult.updatedSituationGraph diff --git a/tests/graph/decomposition-quality.test.js b/tests/graph/decomposition-quality.test.js index 93604d7..140821d 100644 --- a/tests/graph/decomposition-quality.test.js +++ b/tests/graph/decomposition-quality.test.js @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { assessChildUnknownQuality, applyValidatedProposal, + determineGraphBackedQuestion, MAX_DECOMPOSITION_DEPTH, } from "@/lib/graph/apply-proposal.js"; import { makeGraph, makeNode } from "@/lib/graph/schema.js"; @@ -193,7 +194,7 @@ describe("decomposition stopping conditions", () => { }; } - it("does not decompose an atomic selected unknown", () => { + it("does not decompose an atomic selected unknown", async () => { const atomic = makeNode({ id: "n-atomic", label: "Were both figures measured over the same accounting period?", @@ -213,7 +214,7 @@ describe("decomposition stopping conditions", () => { currentSummary: "Atomic selected node graph", }); - const result = applyValidatedProposal({ + const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeMeaningfulNoOpProposal(), }); @@ -225,7 +226,7 @@ describe("decomposition stopping conditions", () => { ); }); - it("does not manufacture comparison children for a generic explanatory parent", () => { + it("does not manufacture comparison children for a generic explanatory parent", async () => { const { parent, graph } = makeParentGraph({ centralStatement: "Traffic increased, but sales stayed flat.", parentLabel: @@ -252,7 +253,7 @@ describe("decomposition stopping conditions", () => { ], }); - const result = applyValidatedProposal({ + const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeMeaningfulNoOpProposal(), }); @@ -286,7 +287,7 @@ describe("decomposition stopping conditions", () => { ); }); - it("still decomposes a genuine comparison/measurement parent into valid comparison children", () => { + it("still decomposes a genuine comparison/measurement parent into valid comparison children", async () => { const { graph } = makeParentGraph({ centralStatement: "Revenue increased by 18%, but cash in the bank fell over the same period.", @@ -313,7 +314,7 @@ describe("decomposition stopping conditions", () => { ], }); - const result = applyValidatedProposal({ + const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeMeaningfulNoOpProposal(), }); @@ -342,4 +343,45 @@ describe("decomposition stopping conditions", () => { expect(MAX_DECOMPOSITION_DEPTH).toBeGreaterThanOrEqual(2); expect(MAX_DECOMPOSITION_DEPTH).toBeLessThanOrEqual(3); }); + + it("rejects invalid semantic fallback children through existing quality validation", async () => { + const { parent, graph } = makeParentGraph({ + centralStatement: + "A dependency persists, but the cause is unclear.", + parentLabel: "Which factor is causing the dependency", + parentDescription: + "Need to know whether team capability, customer dependency, responsibility distribution, or insufficient deliberate delegation is causing the dependency.", + }); + const provider = { + generateReconstruction: async () => ({ + parentNodeId: parent.id, + proposedChildren: [ + { + label: "Team capability or customer dependency", + description: + "Need to know whether team capability or customer dependency is causing the dependency.", + }, + { + label: "Responsibility distribution or insufficient deliberate delegation", + description: + "Need to know whether responsibility distribution or insufficient deliberate delegation is causing the dependency.", + }, + ], + }), + }; + + const result = await determineGraphBackedQuestion({ + situationGraph: graph, + provider, + modelName: "mock-ollama", + }); + + expect(result.success).toBe(true); + expect(result.decompositionAttempted).toBe(true); + expect(result.decompositionAccepted).toBe(false); + expect(result.proposedChildCount).toBe(2); + expect(result.acceptedChildCount).toBe(0); + expect(result.childUnknownCount).toBe(0); + expect(result.rejectedChildren).toHaveLength(2); + }); }); diff --git a/tests/graph/unknown-answerability.test.js b/tests/graph/unknown-answerability.test.js index 4bf21bc..465d294 100644 --- a/tests/graph/unknown-answerability.test.js +++ b/tests/graph/unknown-answerability.test.js @@ -3,7 +3,10 @@ import { assessUnknownAnswerability, assessUnknownAtomicity, } from "@/lib/graph/question-formulator.js"; -import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; +import { + applyValidatedProposal, + determineGraphBackedQuestion, +} from "@/lib/graph/apply-proposal.js"; import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const COMMERCIAL_SCENARIO = @@ -95,10 +98,10 @@ describe("assessUnknownAnswerability", () => { }); describe("answerability-triggered decomposition", () => { - it("decomposes a non-answerable parent into independently answerable child investigations", () => { + it("decomposes a non-answerable parent into independently answerable child investigations", async () => { const graph = makeCommercialContainerGraph(); - const result = applyValidatedProposal({ + const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeMeaningfulNoOpProposal(), }); @@ -116,10 +119,10 @@ describe("answerability-triggered decomposition", () => { ); }); - it("keeps the parent unresolved while selecting a child unknown", () => { + it("keeps the parent unresolved while selecting a child unknown", async () => { const graph = makeCommercialContainerGraph(); - const result = applyValidatedProposal({ + const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeMeaningfulNoOpProposal(), }); @@ -135,4 +138,85 @@ describe("answerability-triggered decomposition", () => { expect(selectedChild.parentId).toBe(parentNode.id); expect(selectedChild.status).toBe("unknown"); }); + + it("invokes bounded semantic fallback when no deterministic family exists and accepts valid children without direct LLM graph mutation", async () => { + const parent = makeNode({ + id: "n-dependency-parent", + label: "Which factor is causing the dependency", + description: + "Need to know whether team capability, customer or client dependency, responsibility distribution, or insufficient deliberate delegation or stepping away is causing the dependency.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const graph = makeGraph({ + centralStatement: + "A dependency persists, but it is unclear whether the cause is team capability, customer dependency, responsibility distribution, or insufficient deliberate delegation.", + nodes: [parent], + edges: [], + activeUnknownNodeId: parent.id, + resolvedNodeIds: [], + currentSummary: "Dependency-factor decomposition fixture", + }); + + const atomicity = assessUnknownAtomicity({ node: parent, graph }); + const answerability = assessUnknownAnswerability({ node: parent, graph }); + const provider = { + generateReconstruction: async () => ({ + parentNodeId: parent.id, + proposedChildren: [ + { + label: "Whether team capability is causing the dependency", + description: + "Need to know whether team capability is causing the dependency, because that would narrow the source of the dependency.", + }, + { + label: "Whether customer dependency is causing the dependency", + description: + "Need to know whether customer dependency is causing the dependency, because that would narrow the source of the dependency.", + }, + ], + }), + }; + const result = await determineGraphBackedQuestion({ + situationGraph: graph, + provider, + modelName: "mock-ollama", + }); + + expect(atomicity.atomicity).toBe("atomic"); + expect(answerability.independentlyAnswerable).toBe(false); + expect(answerability.decompositionRequired).toBe(true); + expect(result.success).toBe(true); + expect(result.decompositionAttempted).toBe(true); + expect(result.decompositionTriggeredByAnswerability).toBe(true); + expect(result.decompositionAccepted).toBe(true); + expect(result.proposedChildCount).toBe(2); + expect(result.acceptedChildCount).toBe(2); + expect(result.childUnknownCount).toBe(2); + expect(result.selectedContainerUnknown).toBe(parent.id); + expect(result.selectedChildUnknown).not.toBe(parent.id); + expect(result.selectedQuestion).toBeTruthy(); + }); + + it("preserves deterministic family path without invoking semantic fallback", async () => { + const graph = makeCommercialContainerGraph(); + let providerCalled = false; + const provider = { + generateReconstruction: async () => { + providerCalled = true; + return { parentNodeId: "n-commercial-parent", proposedChildren: [] }; + }, + }; + + const result = await determineGraphBackedQuestion({ + situationGraph: graph, + provider, + modelName: "mock-ollama", + }); + + expect(result.success).toBe(true); + expect(result.decompositionPerformed).toBe(true); + expect(providerCalled).toBe(false); + }); });