/** * Situation Graph Case Orchestrator — manages the lifecycle of a case. * startCase builds initial graph from analysis; updateCase applies answers. */ import { analyseScenario } from "../analysis.js"; import { assertConfig } from "../config.js"; import { getProvider, getProviderModelName } from "@/lib/llm/provider.js"; import { makeGraph, startCaseRequestSchema, situationGraphSchema, updateCaseRequestSchema, } from "./schema.js"; import { buildInitialGraph, describeGraph } from "./builder.js"; import { applyValidatedProposal, determineGraphBackedQuestion, } from "./apply-proposal.js"; import { buildGraphUpdatePrompt } from "./prompt-builder.js"; import assessInvestigationState from "../assessment/investigation-state-assessor.js"; import { buildReasoningState, formulateQuestion, formulateTieResolutionQuestion, } from "./question-formulator.js"; import { parseGraphUpdateProposal } from "./update-proposal.js"; import { explainUnknownSelection, selectActiveUnknownCandidate, validateGraphReferences, } from "./utils.js"; import { validateFindings, produceFindingInformedSummary } from "./finding-helpers.js"; import { prepareCompletedEpisode } from "./episode-preparation.js"; import { buildEpisodeAwareGraphPrompt } from "./prompt-builder-episode.js"; function toValidationErrors(error) { return ( error?.errors?.map((issue) => ({ path: issue.path, message: issue.message, code: issue.code, })) ?? [{ message: "Validation failed" }] ); } function buildDiagnostics({ analysis, graph, graphReferenceValidation, unknownSelectionExplanation, reconstructionQuestion, reconstructionQuestionAccepted, reconstructionQuestionRejectionReasons, finalGraphBackedQuestion, selectedUnknownNodeId, decompositionApplied, questionComplexityAssessment, answerabilityAssessment, independentlyAnswerable, prerequisiteConceptCount, decompositionTriggeredByAnswerability, decompositionReason, selectedContainerUnknown, selectedChildUnknown, reasoningPattern, questionFamily, allowedQuestionFamilies, rejectedQuestionFamilies, selectedQuestionTemplate, reasoningPatternReason, reasoningPatternValidation, patternCompatibleNodeCount, incompatibleNodeIds, compatibilityFailures, replacementActions, graphReasoningIntegrity, noQuestionReason, }) { return { promptVersion: analysis?.promptVersion ?? null, modelName: analysis?.modelName ?? null, responseDurationMs: analysis?.responseDurationMs ?? null, validationStatus: analysis?.validationStatus ?? "invalid", nodeCount: graph?.nodes?.length ?? 0, edgeCount: graph?.edges?.length ?? 0, graphReferenceValidation, compatibilityApplied: analysis?.compatibilityApplied ?? false, compatibilityChanges: analysis?.compatibilityChanges ?? [], compatibilityWarnings: analysis?.compatibilityWarnings ?? [], unknownSelectionExplanation: unknownSelectionExplanation ?? null, reconstructionQuestion: reconstructionQuestion ?? null, reconstructionQuestionAccepted: reconstructionQuestionAccepted ?? null, reconstructionQuestionRejectionReasons: reconstructionQuestionRejectionReasons ?? [], finalGraphBackedQuestion: finalGraphBackedQuestion ?? null, selectedUnknownNodeId: selectedUnknownNodeId ?? null, decompositionApplied: decompositionApplied ?? false, questionComplexityAssessment: questionComplexityAssessment ?? null, answerabilityAssessment: answerabilityAssessment ?? null, independentlyAnswerable: independentlyAnswerable ?? null, prerequisiteConceptCount: prerequisiteConceptCount ?? null, decompositionTriggeredByAnswerability: decompositionTriggeredByAnswerability ?? false, decompositionReason: decompositionReason ?? null, selectedContainerUnknown: selectedContainerUnknown ?? null, selectedChildUnknown: selectedChildUnknown ?? null, reasoningPattern: reasoningPattern ?? null, questionFamily: questionFamily ?? null, allowedQuestionFamilies: allowedQuestionFamilies ?? [], rejectedQuestionFamilies: rejectedQuestionFamilies ?? [], selectedQuestionTemplate: selectedQuestionTemplate ?? null, reasoningPatternReason: reasoningPatternReason ?? null, reasoningPatternValidation: reasoningPatternValidation ?? null, patternCompatibleNodeCount: patternCompatibleNodeCount ?? 0, incompatibleNodeIds: incompatibleNodeIds ?? [], compatibilityFailures: compatibilityFailures ?? [], replacementActions: replacementActions ?? [], graphReasoningIntegrity: graphReasoningIntegrity ?? null, noQuestionReason: noQuestionReason ?? null, }; } function fallbackStartCaseReasoningPatternValidation( selectedQuestion, existingValidation, ) { if (existingValidation) { return existingValidation; } if (!selectedQuestion?.reasoningPattern) { return null; } return { activePattern: selectedQuestion.reasoningPattern, valid: Boolean(selectedQuestion.question), reason: selectedQuestion.question ? "Initial graph-backed selection produced a reasoning-pattern-compatible question." : "Initial graph-backed selection did not produce a valid question for the inferred reasoning pattern.", }; } function buildUnknownSelectionDiagnostics( graph, resolvedNodeIds = [], selectedQuestion = null, ) { const explanation = explainUnknownSelection(graph, resolvedNodeIds); if (explanation.status === "ambiguous") { return { ...explanation, tieResolutionQuestion: selectedQuestion?.selectionStatus === "ambiguous" ? selectedQuestion.question : formulateTieResolutionQuestion({ graph }).question, alphabeticalUsedAsReasoning: false, }; } return explanation; } function buildUpdateDiagnostics({ promptVersion, modelName, responseDurationMs, normalisationsApplied, graph, graphReferenceValidation, selectedQuestion, unknownSelectionExplanation, previousReasoningState, reasoningState, resolvedReasoningNodeIds, emergentReasoningNodeCreated, emergentReasoningNodeId, emergentReasoningNodeReason, atomicityAssessment, atomicityDecisionReason, decompositionDepth, decompositionAttempted, decompositionAccepted, decompositionStoppedReason, proposedChildCount, acceptedChildCount, rejectedChildren, selectedChildNodeId, childQualitySummary, propagationPerformed, resolvedChildNodeId, parentNodeId, parentStatusBefore, parentStatusAfter, parentConfidenceBefore, parentConfidenceAfter, evidenceConfidenceBefore, evidenceConfidenceAfter, completenessBefore, completenessAfter, conclusionConfidenceBefore, conclusionConfidenceAfter, resolvedDirectChildren, unresolvedDirectChildren, contradictoryDirectChildren, corroboratingBranchCount, conflictingBranchCount, duplicateEvidenceCount, independentBranchCount, interactionSummary, confidenceCapReason, ancestorPropagationStoppedReason, affectedAncestorIds, nextSelectedSibling, parentResolved, decompositionPerformed, childUnknownCount, childNodeIds, atomicityReason, questionComplexityAccepted, primaryConceptCount, cognitiveLoad, complexityReasons, decompositionTriggeredByQuestionComplexity, previousQuestion, finalQuestion, selectedUnknownBefore, selectedUnknownAfter, plainLanguageNormalisations, reasoningPattern, questionFamily, allowedQuestionFamilies, rejectedQuestionFamilies, selectedQuestionTemplate, reasoningPatternReason, unresolvedCandidateCount, eligibleCandidateCount, candidateNodeIds, resolvedCurrentTurnNodeIds, noQuestionReason, reasoningPatternValidation, patternCompatibleNodeCount, incompatibleNodeIds, compatibilityFailures, replacementActions, graphReasoningIntegrity, }) { return { promptVersion: promptVersion ?? "v0.4", modelName: modelName ?? null, responseDurationMs: responseDurationMs ?? null, validationStatus: "valid", nodeCount: graph?.nodes?.length ?? 0, edgeCount: graph?.edges?.length ?? 0, graphReferenceValidation: graphReferenceValidation ?? { valid: true, errors: [], }, normalisationsApplied: normalisationsApplied ?? [], investigationStrategy: selectedQuestion?.investigationStrategy ?? selectedQuestion?.strategy ?? null, previousComparabilityStatus: previousReasoningState?.comparabilityStatus ?? null, comparabilityStatus: reasoningState?.comparabilityStatus ?? null, relationshipStatus: reasoningState?.relationshipStatus ?? null, relationshipAssessed: reasoningState?.relationshipAssessed ?? null, reasoningStagesBefore: previousReasoningState?.reasoningStages ?? [], reasoningStagesAfter: reasoningState?.reasoningStages ?? [], resolvedReasoningNodeIds: resolvedReasoningNodeIds ?? [], emergentReasoningNodeCreated: emergentReasoningNodeCreated ?? false, emergentReasoningNodeId: emergentReasoningNodeId ?? null, emergentReasoningNodeReason: emergentReasoningNodeReason ?? null, atomicityAssessment: atomicityAssessment ?? null, atomicityDecisionReason: atomicityDecisionReason ?? null, decompositionDepth: decompositionDepth ?? 0, decompositionAttempted: decompositionAttempted ?? false, decompositionAccepted: decompositionAccepted ?? false, decompositionStoppedReason: decompositionStoppedReason ?? null, proposedChildCount: proposedChildCount ?? 0, acceptedChildCount: acceptedChildCount ?? 0, rejectedChildren: rejectedChildren ?? [], selectedChildNodeId: selectedChildNodeId ?? null, childQualitySummary: childQualitySummary ?? [], propagationPerformed: propagationPerformed ?? false, resolvedChildNodeId: resolvedChildNodeId ?? null, parentNodeId: parentNodeId ?? null, parentStatusBefore: parentStatusBefore ?? null, parentStatusAfter: parentStatusAfter ?? null, parentConfidenceBefore: parentConfidenceBefore ?? null, parentConfidenceAfter: parentConfidenceAfter ?? null, evidenceConfidenceBefore: evidenceConfidenceBefore ?? null, evidenceConfidenceAfter: evidenceConfidenceAfter ?? null, completenessBefore: completenessBefore ?? null, completenessAfter: completenessAfter ?? null, conclusionConfidenceBefore: conclusionConfidenceBefore ?? null, conclusionConfidenceAfter: conclusionConfidenceAfter ?? null, resolvedDirectChildren: resolvedDirectChildren ?? 0, unresolvedDirectChildren: unresolvedDirectChildren ?? 0, contradictoryDirectChildren: contradictoryDirectChildren ?? 0, corroboratingBranchCount: corroboratingBranchCount ?? 0, conflictingBranchCount: conflictingBranchCount ?? 0, duplicateEvidenceCount: duplicateEvidenceCount ?? 0, independentBranchCount: independentBranchCount ?? 0, interactionSummary: interactionSummary ?? null, confidenceCapReason: confidenceCapReason ?? null, ancestorPropagationStoppedReason: ancestorPropagationStoppedReason ?? null, affectedAncestorIds: affectedAncestorIds ?? [], nextSelectedSibling: nextSelectedSibling ?? null, parentResolved: parentResolved ?? false, decompositionPerformed: decompositionPerformed ?? false, 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 ?? [], reasoningPattern: reasoningPattern ?? null, questionFamily: questionFamily ?? null, allowedQuestionFamilies: allowedQuestionFamilies ?? [], rejectedQuestionFamilies: rejectedQuestionFamilies ?? [], selectedQuestionTemplate: selectedQuestionTemplate ?? null, reasoningPatternReason: reasoningPatternReason ?? null, unresolvedCandidateCount: unresolvedCandidateCount ?? 0, eligibleCandidateCount: eligibleCandidateCount ?? 0, candidateNodeIds: candidateNodeIds ?? [], resolvedCurrentTurnNodeIds: resolvedCurrentTurnNodeIds ?? [], noQuestionReason: noQuestionReason ?? null, reasoningPatternValidation: reasoningPatternValidation ?? null, patternCompatibleNodeCount: patternCompatibleNodeCount ?? 0, incompatibleNodeIds: incompatibleNodeIds ?? [], compatibilityFailures: compatibilityFailures ?? [], replacementActions: replacementActions ?? [], graphReasoningIntegrity: graphReasoningIntegrity ?? null, unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } export async function startCase(body, dependencies = {}) { const parsedRequest = startCaseRequestSchema.safeParse(body); if (!parsedRequest.success) { return { success: false, error: "Invalid start-case request", validationErrors: toValidationErrors(parsedRequest.error), statusCode: 400, }; } const { scenario, promptVersion } = parsedRequest.data; const analysisOptions = { promptVersion }; if (dependencies.reconstructionProvider) { analysisOptions.reconstructionProvider = dependencies.reconstructionProvider; } if (dependencies.reconstructionModelName) { analysisOptions.reconstructionModelName = dependencies.reconstructionModelName; } const analysis = await analyseScenario(scenario, analysisOptions); if (!analysis.success) { return { success: false, error: analysis.error ?? "Scenario analysis failed", diagnostics: buildDiagnostics({ analysis, graph: null, graphReferenceValidation: null, }), analysisErrors: analysis.errors ?? undefined, validationIssues: analysis.validationIssues ?? undefined, providerApiPath: analysis.providerApiPath ?? undefined, providerExecution: analysis.providerExecution ?? undefined, rawResponse: analysis.rawResponse ?? undefined, statusCode: Number(analysis.statusCode) || 502, }; } const initialGraph = buildInitialGraph({ reconstruction: analysis.reconstruction, evidence: analysis.evidence, }); const currentSummary = describeGraph(initialGraph); const initialSituationGraph = makeGraph({ centralStatement: scenario, nodes: initialGraph.nodes, edges: initialGraph.edges, activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary, reasoningState: buildReasoningState({ centralStatement: scenario, nodes: initialGraph.nodes, edges: initialGraph.edges, resolvedNodeIds: [], }), }); situationGraphSchema.parse(initialSituationGraph); const graphReferenceValidation = validateGraphReferences( initialSituationGraph, ); if (dependencies.reconstructionOnly) { return { success: graphReferenceValidation.valid, summary: analysis.reconstruction?.summary ?? null, reconstruction: analysis.reconstruction, situationGraph: initialSituationGraph, selectedQuestion: null, diagnostics: buildDiagnostics({ analysis, graph: initialSituationGraph, graphReferenceValidation, }), validationErrors: graphReferenceValidation.valid ? undefined : graphReferenceValidation.errors, statusCode: graphReferenceValidation.valid ? 200 : 500, }; } 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 : initialSituationGraph; const selectedQuestion = initialQuestionResult.success ? initialQuestionResult.selectedQuestion : null; const unknownSelectionExplanation = buildUnknownSelectionDiagnostics( situationGraph, [], selectedQuestion, ); if (!graphReferenceValidation.valid) { return { success: false, error: "Situation graph reference validation failed", diagnostics: buildDiagnostics({ analysis, graph: situationGraph, graphReferenceValidation, unknownSelectionExplanation, reconstructionQuestion: analysis.nextQuestion?.question ?? null, reconstructionQuestionAccepted: false, reconstructionQuestionRejectionReasons: analysis.nextQuestion?.question != null ? [ "reconstruction_question_not_authoritative", "graph_backed_pipeline_required", ] : [], finalGraphBackedQuestion: selectedQuestion?.question ?? null, selectedUnknownNodeId: initialQuestionResult.selectedUnknownAfter ?? null, decompositionApplied: initialQuestionResult.decompositionPerformed ?? false, questionComplexityAssessment: initialQuestionResult.questionComplexityAssessment ?? null, answerabilityAssessment: initialQuestionResult.answerabilityAssessment ?? null, independentlyAnswerable: initialQuestionResult.independentlyAnswerable ?? null, prerequisiteConceptCount: initialQuestionResult.prerequisiteConceptCount ?? null, decompositionTriggeredByAnswerability: initialQuestionResult.decompositionTriggeredByAnswerability ?? false, decompositionReason: initialQuestionResult.selectedQuestion?.reason ?? null, selectedContainerUnknown: initialQuestionResult.selectedContainerUnknown ?? null, selectedChildUnknown: initialQuestionResult.selectedChildUnknown ?? null, reasoningPattern: initialQuestionResult.selectedQuestion?.reasoningPattern ?? null, questionFamily: initialQuestionResult.selectedQuestion?.questionFamily ?? null, allowedQuestionFamilies: initialQuestionResult.selectedQuestion?.allowedQuestionFamilies ?? [], rejectedQuestionFamilies: initialQuestionResult.selectedQuestion?.rejectedQuestionFamilies ?? [], selectedQuestionTemplate: initialQuestionResult.selectedQuestion?.selectedQuestionTemplate ?? null, reasoningPatternReason: initialQuestionResult.selectedQuestion?.reasoningPatternReason ?? null, reasoningPatternValidation: fallbackStartCaseReasoningPatternValidation( initialQuestionResult.selectedQuestion, initialQuestionResult.reasoningPatternValidation, ), patternCompatibleNodeCount: initialQuestionResult.patternCompatibleNodeCount ?? 0, incompatibleNodeIds: initialQuestionResult.incompatibleNodeIds ?? [], compatibilityFailures: initialQuestionResult.compatibilityFailures ?? [], replacementActions: initialQuestionResult.replacementActions ?? [], graphReasoningIntegrity: initialQuestionResult.graphReasoningIntegrity ?? null, noQuestionReason: initialQuestionResult.noQuestionReason ?? null, }), validationErrors: graphReferenceValidation.errors, statusCode: 500, }; } return { success: true, summary: analysis.reconstruction?.summary ?? null, reconstruction: analysis.reconstruction, situationGraph, selectedQuestion, diagnostics: buildDiagnostics({ analysis, graph: situationGraph, graphReferenceValidation, unknownSelectionExplanation, reconstructionQuestion: analysis.nextQuestion?.question ?? null, reconstructionQuestionAccepted: false, reconstructionQuestionRejectionReasons: analysis.nextQuestion?.question != null ? [ "reconstruction_question_not_authoritative", "graph_backed_pipeline_required", ] : [], finalGraphBackedQuestion: selectedQuestion?.question ?? null, selectedUnknownNodeId: initialQuestionResult.selectedUnknownAfter ?? null, decompositionApplied: initialQuestionResult.decompositionPerformed ?? false, questionComplexityAssessment: initialQuestionResult.questionComplexityAssessment ?? null, answerabilityAssessment: initialQuestionResult.answerabilityAssessment ?? null, independentlyAnswerable: initialQuestionResult.independentlyAnswerable ?? null, prerequisiteConceptCount: initialQuestionResult.prerequisiteConceptCount ?? null, decompositionTriggeredByAnswerability: initialQuestionResult.decompositionTriggeredByAnswerability ?? false, decompositionReason: initialQuestionResult.selectedQuestion?.reason ?? null, selectedContainerUnknown: initialQuestionResult.selectedContainerUnknown ?? null, selectedChildUnknown: initialQuestionResult.selectedChildUnknown ?? null, reasoningPattern: initialQuestionResult.selectedQuestion?.reasoningPattern ?? null, questionFamily: initialQuestionResult.selectedQuestion?.questionFamily ?? null, allowedQuestionFamilies: initialQuestionResult.selectedQuestion?.allowedQuestionFamilies ?? [], rejectedQuestionFamilies: initialQuestionResult.selectedQuestion?.rejectedQuestionFamilies ?? [], selectedQuestionTemplate: initialQuestionResult.selectedQuestion?.selectedQuestionTemplate ?? null, reasoningPatternReason: initialQuestionResult.selectedQuestion?.reasoningPatternReason ?? null, reasoningPatternValidation: fallbackStartCaseReasoningPatternValidation( initialQuestionResult.selectedQuestion, initialQuestionResult.reasoningPatternValidation, ), patternCompatibleNodeCount: initialQuestionResult.patternCompatibleNodeCount ?? 0, incompatibleNodeIds: initialQuestionResult.incompatibleNodeIds ?? [], compatibilityFailures: initialQuestionResult.compatibilityFailures ?? [], replacementActions: initialQuestionResult.replacementActions ?? [], graphReasoningIntegrity: initialQuestionResult.graphReasoningIntegrity ?? null, noQuestionReason: initialQuestionResult.noQuestionReason ?? null, }), assessment: assessInvestigationState({ situationGraph, selectedQuestion, noQuestionReason: initialQuestionResult.noQuestionReason ?? null, diagnostics: { promptVersion, modelName: analysis?.modelName ?? null, responseDurationMs: analysis?.responseDurationMs ?? null, validationStatus: analysis?.validationStatus ?? "invalid", nodeCount: situationGraph?.nodes?.length ?? 0, edgeCount: situationGraph?.edges?.length ?? 0, reasoningPattern: initialQuestionResult.selectedQuestion?.reasoningPattern ?? null, }, }), }; } export async function updateCase() { return updateCaseWithDependencies(...arguments); } function sanitiseErrorMessage(error, fallbackMessage) { if (typeof error?.message === "string" && error.message.trim().length > 0) { return error.message; } return fallbackMessage; } async function updateCaseWithDependencies(body, dependencies = {}) { const parsedRequest = updateCaseRequestSchema.safeParse(body); if (!parsedRequest.success) { return { success: false, stage: "request_validation", error: "Invalid update-case request", validationErrors: toValidationErrors(parsedRequest.error), statusCode: 400, }; } const { situationGraph, previousQuestion, answer, promptVersion, findings: incomingFindings } = parsedRequest.data; const graphSchemaValidation = situationGraphSchema.safeParse(situationGraph); const graphReferenceValidation = validateGraphReferences(situationGraph); if (!graphSchemaValidation.success || !graphReferenceValidation.valid) { return { success: false, stage: "graph_validation", error: "Invalid situation graph", graphValidationErrors: [ ...(!graphSchemaValidation.success ? toValidationErrors(graphSchemaValidation.error) : []), ...(!graphReferenceValidation.valid ? graphReferenceValidation.errors : []), ], statusCode: 400, }; } const buildPrompt = dependencies.buildGraphUpdatePrompt ?? buildGraphUpdatePrompt; const parseProposal = dependencies.parseGraphUpdateProposal ?? parseGraphUpdateProposal; const applyProposalUpdate = dependencies.applyValidatedProposal ?? applyValidatedProposal; const shouldApplyProposal = dependencies.applyProposal === true; let modelName = null; let rawResponse; const startedAt = Date.now(); try { if (!dependencies.config) assertConfig(); modelName = dependencies.modelName ?? dependencies.config?.modelName ?? dependencies.config?.OLLAMA_MODEL ?? getProviderModelName(); const prompt = buildPrompt({ situationGraph, previousQuestion, answer, promptVersion, }); const provider = dependencies.provider ?? getProvider(); rawResponse = await provider.generateReconstruction(prompt, modelName); } catch (error) { return { success: false, stage: "provider", error: "Graph update proposal generation failed", providerErrors: [ sanitiseErrorMessage( error, "Provider failed to generate graph update proposal", ), ], diagnostics: { promptVersion: promptVersion ?? null, modelName, responseDurationMs: Date.now() - startedAt, normalisationsApplied: [], }, statusCode: 502, }; } const parsedProposal = parseProposal(rawResponse); const responseDurationMs = Date.now() - startedAt; if (!parsedProposal.success) { return { success: false, stage: "proposal_validation", error: "Invalid graph update proposal", proposalErrors: parsedProposal.errors, diagnostics: { promptVersion: promptVersion ?? null, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, }, statusCode: 502, }; } if (shouldApplyProposal) { const applicationResult = applyProposalUpdate({ situationGraph, proposal: parsedProposal.proposal, previousQuestion, answer, }); if (!applicationResult.success) { // Compact rejected-proposal snapshot for proposal_compatibility diagnostics. // Diagnostic evidence only — does not alter validation or mutation. const rejectedProposalSnapshot = applicationResult.stage === "proposal_compatibility" && parsedProposal.proposal ? { answerMeaning: parsedProposal.proposal.answerMeaning ? { userSupportedMeaning: parsedProposal.proposal.answerMeaning.userSupportedMeaning ?? null, possibleInference: parsedProposal.proposal.answerMeaning.possibleInference ?? null, } : null, updatedNodes: (parsedProposal.proposal.updatedNodes ?? []).map((n) => ({ nodeId: n.nodeId, newValue: n.newValue, })), resolvedUnknownNodeIds: parsedProposal.proposal.resolvedUnknownNodeIds ?? [], addedNodes: (parsedProposal.proposal.addedNodes ?? []).map((n) => ({ id: n.id, kind: n.kind, label: n.label, description: n.description, parentId: n.parentId ?? null, dependsOn: n.dependsOn ?? [], affects: n.affects ?? [], childIds: n.childIds ?? [], })), addedEdges: (parsedProposal.proposal.addedEdges ?? []).map((e) => ({ fromNodeId: e.fromNodeId, toNodeId: e.toNodeId, relationship: e.relationship, })), } : null; return { success: false, stage: applicationResult.stage, errors: applicationResult.errors, diagnostics: { ...buildUpdateDiagnostics({ promptVersion, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, graph: situationGraph, graphReferenceValidation: graphReferenceValidation, selectedQuestion: null, previousReasoningState: buildReasoningState(situationGraph), reasoningState: buildReasoningState(situationGraph), resolvedReasoningNodeIds: [], emergentReasoningNodeCreated: false, emergentReasoningNodeId: null, emergentReasoningNodeReason: null, atomicityAssessment: null, atomicityDecisionReason: null, decompositionDepth: 0, decompositionAttempted: false, decompositionAccepted: false, decompositionStoppedReason: null, proposedChildCount: 0, acceptedChildCount: 0, rejectedChildren: [], selectedChildNodeId: null, childQualitySummary: [], propagationPerformed: false, resolvedChildNodeId: null, parentNodeId: null, parentStatusBefore: null, parentStatusAfter: null, parentConfidenceBefore: null, parentConfidenceAfter: null, evidenceConfidenceBefore: null, evidenceConfidenceAfter: null, completenessBefore: null, completenessAfter: null, conclusionConfidenceBefore: null, conclusionConfidenceAfter: null, resolvedDirectChildren: 0, unresolvedDirectChildren: 0, contradictoryDirectChildren: 0, corroboratingBranchCount: 0, conflictingBranchCount: 0, duplicateEvidenceCount: 0, independentBranchCount: 0, interactionSummary: null, confidenceCapReason: null, ancestorPropagationStoppedReason: null, affectedAncestorIds: [], nextSelectedSibling: null, parentResolved: false, decompositionPerformed: false, childUnknownCount: 0, childNodeIds: [], atomicityReason: null, questionComplexityAccepted: null, primaryConceptCount: null, cognitiveLoad: null, complexityReasons: [], decompositionTriggeredByQuestionComplexity: false, previousQuestion, finalQuestion: null, selectedUnknownBefore: null, selectedUnknownAfter: null, unresolvedCandidateCount: 0, eligibleCandidateCount: 0, candidateNodeIds: [], resolvedCurrentTurnNodeIds: [], noQuestionReason: null, reasoningPatternValidation: null, patternCompatibleNodeCount: 0, incompatibleNodeIds: [], compatibilityFailures: [], replacementActions: [], graphReasoningIntegrity: null, plainLanguageNormalisations: [], unknownSelectionExplanation: explainUnknownSelection( situationGraph, situationGraph.resolvedNodeIds || [], ), }), ...(rejectedProposalSnapshot && { rejectedProposalSnapshot }), }, statusCode: applicationResult.stage === "application" || applicationResult.stage === "result_validation" ? 500 : 400, }; } // ── v2: process incoming findings (no graph mutation) ──── let appendedFindings = []; if (incomingFindings && Array.isArray(incomingFindings) && incomingFindings.length > 0) { const validated = validateFindings(incomingFindings); // Filter out rejected findings for display only — do NOT modify currentSummary. // Direct concatenation of Finding text into Current Understanding would bypass // the authoritative case/update reasoning that evaluates Finding context. // Per v1 handoff contract: "Noted as not directly relevant" and similar markers // are display-only; they must never be appended to summary or graph state. const validForDisplay = validated.findings.filter((f) => f.evaluation !== "rejected"); // Normalize and include approved findings in the response (display passthrough) const normalized = validForDisplay.map((f) => ({ id: f.id, proposition: f.proposition, status: f.status, userDisposition: f.userDisposition, originatingTargetNodeId: f.originatingTargetNodeId, contributionId: f.contributionId, sourceObservation: f.sourceObservation, createdAt: f.createdAt, })); appendedFindings = normalized; } // ── Post-authoritative finding-informed understanding seam ──── const authoritativeSummary = applicationResult.updatedSituationGraph?.currentSummary ?? ""; const experimentalSummary = produceFindingInformedSummary( authoritativeSummary, appendedFindings, ); return { success: true, stage: "update_applied", updatedSituationGraph: applicationResult.updatedSituationGraph, proposal: applicationResult.graphUpdate, selectedQuestion: applicationResult.selectedQuestion, affectedNodeIds: applicationResult.affectedNodeIds, resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds, previousActiveUnknownNodeId: applicationResult.previousActiveUnknownNodeId, newActiveUnknownNodeId: applicationResult.newActiveUnknownNodeId, changesApplied: applicationResult.changesApplied, appendedFindings, summary: experimentalSummary, diagnostics: buildUpdateDiagnostics({ promptVersion, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, graph: applicationResult.updatedSituationGraph, graphReferenceValidation: applicationResult.graphReferenceValidation, selectedQuestion: applicationResult.selectedQuestion, previousReasoningState: applicationResult.previousReasoningState, reasoningState: applicationResult.reasoningState, resolvedReasoningNodeIds: applicationResult.resolvedReasoningNodeIds, emergentReasoningNodeCreated: applicationResult.emergentReasoningNodeCreated, emergentReasoningNodeId: applicationResult.emergentReasoningNodeId, emergentReasoningNodeReason: applicationResult.emergentReasoningNodeReason, atomicityAssessment: applicationResult.atomicityAssessment, atomicityDecisionReason: applicationResult.atomicityDecisionReason, decompositionDepth: applicationResult.decompositionDepth, decompositionAttempted: applicationResult.decompositionAttempted, decompositionAccepted: applicationResult.decompositionAccepted, decompositionStoppedReason: applicationResult.decompositionStoppedReason, proposedChildCount: applicationResult.proposedChildCount, acceptedChildCount: applicationResult.acceptedChildCount, rejectedChildren: applicationResult.rejectedChildren, selectedChildNodeId: applicationResult.selectedChildNodeId, childQualitySummary: applicationResult.childQualitySummary, propagationPerformed: applicationResult.propagationPerformed, resolvedChildNodeId: applicationResult.resolvedChildNodeId, parentNodeId: applicationResult.parentNodeId, parentStatusBefore: applicationResult.parentStatusBefore, parentStatusAfter: applicationResult.parentStatusAfter, parentConfidenceBefore: applicationResult.parentConfidenceBefore, parentConfidenceAfter: applicationResult.parentConfidenceAfter, evidenceConfidenceBefore: applicationResult.evidenceConfidenceBefore, evidenceConfidenceAfter: applicationResult.evidenceConfidenceAfter, completenessBefore: applicationResult.completenessBefore, completenessAfter: applicationResult.completenessAfter, conclusionConfidenceBefore: applicationResult.conclusionConfidenceBefore, conclusionConfidenceAfter: applicationResult.conclusionConfidenceAfter, resolvedDirectChildren: applicationResult.resolvedDirectChildren, unresolvedDirectChildren: applicationResult.unresolvedDirectChildren, contradictoryDirectChildren: applicationResult.contradictoryDirectChildren, corroboratingBranchCount: applicationResult.corroboratingBranchCount, conflictingBranchCount: applicationResult.conflictingBranchCount, duplicateEvidenceCount: applicationResult.duplicateEvidenceCount, independentBranchCount: applicationResult.independentBranchCount, interactionSummary: applicationResult.interactionSummary, confidenceCapReason: applicationResult.confidenceCapReason, ancestorPropagationStoppedReason: applicationResult.ancestorPropagationStoppedReason, affectedAncestorIds: applicationResult.affectedAncestorIds, nextSelectedSibling: applicationResult.nextSelectedSibling, parentResolved: applicationResult.parentResolved, decompositionPerformed: applicationResult.decompositionPerformed, 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, unresolvedCandidateCount: applicationResult.unresolvedCandidateCount, eligibleCandidateCount: applicationResult.eligibleCandidateCount, candidateNodeIds: applicationResult.candidateNodeIds, resolvedCurrentTurnNodeIds: applicationResult.resolvedCurrentTurnNodeIds, noQuestionReason: applicationResult.noQuestionReason, reasoningPatternValidation: applicationResult.reasoningPatternValidation, patternCompatibleNodeCount: applicationResult.patternCompatibleNodeCount, incompatibleNodeIds: applicationResult.incompatibleNodeIds, compatibilityFailures: applicationResult.compatibilityFailures, replacementActions: applicationResult.replacementActions, graphReasoningIntegrity: applicationResult.graphReasoningIntegrity, plainLanguageNormalisations: applicationResult.plainLanguageNormalisations, reasoningPattern: applicationResult.selectedQuestion?.reasoningPattern ?? null, questionFamily: applicationResult.selectedQuestion?.questionFamily ?? null, allowedQuestionFamilies: applicationResult.selectedQuestion?.allowedQuestionFamilies ?? [], rejectedQuestionFamilies: applicationResult.selectedQuestion?.rejectedQuestionFamilies ?? [], selectedQuestionTemplate: applicationResult.selectedQuestion?.selectedQuestionTemplate ?? null, reasoningPatternReason: applicationResult.selectedQuestion?.reasoningPatternReason ?? null, unknownSelectionExplanation: buildUnknownSelectionDiagnostics( applicationResult.updatedSituationGraph, applicationResult.updatedSituationGraph.resolvedNodeIds || [], applicationResult.selectedQuestion, ), }), assessment: assessInvestigationState({ situationGraph: applicationResult.updatedSituationGraph, selectedQuestion: applicationResult.selectedQuestion, noQuestionReason: applicationResult.noQuestionReason ?? null, diagnostics: { promptVersion, modelName, responseDurationMs, validationStatus: "valid", nodeCount: applicationResult.updatedSituationGraph?.nodes?.length ?? 0, edgeCount: applicationResult.updatedSituationGraph?.edges?.length ?? 0, reasoningPattern: applicationResult.selectedQuestion?.reasoningPattern ?? null, }, }), }; } return { success: true, stage: "proposal_ready", proposal: parsedProposal.proposal, diagnostics: buildUpdateDiagnostics({ promptVersion, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, graph: situationGraph, graphReferenceValidation, selectedQuestion: null, previousReasoningState: buildReasoningState(situationGraph), reasoningState: buildReasoningState(situationGraph), resolvedReasoningNodeIds: [], emergentReasoningNodeCreated: false, emergentReasoningNodeId: null, emergentReasoningNodeReason: null, atomicityAssessment: null, atomicityDecisionReason: null, decompositionDepth: 0, decompositionAttempted: false, decompositionAccepted: false, decompositionStoppedReason: null, proposedChildCount: 0, acceptedChildCount: 0, rejectedChildren: [], selectedChildNodeId: null, childQualitySummary: [], propagationPerformed: false, resolvedChildNodeId: null, parentNodeId: null, parentStatusBefore: null, parentStatusAfter: null, parentConfidenceBefore: null, parentConfidenceAfter: null, evidenceConfidenceBefore: null, evidenceConfidenceAfter: null, completenessBefore: null, completenessAfter: null, conclusionConfidenceBefore: null, conclusionConfidenceAfter: null, resolvedDirectChildren: 0, unresolvedDirectChildren: 0, contradictoryDirectChildren: 0, corroboratingBranchCount: 0, conflictingBranchCount: 0, duplicateEvidenceCount: 0, independentBranchCount: 0, interactionSummary: null, confidenceCapReason: null, ancestorPropagationStoppedReason: null, affectedAncestorIds: [], nextSelectedSibling: null, parentResolved: false, decompositionPerformed: false, childUnknownCount: 0, childNodeIds: [], atomicityReason: null, questionComplexityAccepted: null, primaryConceptCount: null, cognitiveLoad: null, complexityReasons: [], decompositionTriggeredByQuestionComplexity: false, previousQuestion, finalQuestion: null, selectedUnknownBefore: null, selectedUnknownAfter: null, unresolvedCandidateCount: 0, eligibleCandidateCount: 0, candidateNodeIds: [], resolvedCurrentTurnNodeIds: [], noQuestionReason: null, reasoningPatternValidation: null, patternCompatibleNodeCount: 0, incompatibleNodeIds: [], compatibilityFailures: [], replacementActions: [], graphReasoningIntegrity: null, plainLanguageNormalisations: [], reasoningPattern: null, questionFamily: null, allowedQuestionFamilies: [], rejectedQuestionFamilies: [], selectedQuestionTemplate: null, reasoningPatternReason: null, unknownSelectionExplanation: buildUnknownSelectionDiagnostics( situationGraph, situationGraph.resolvedNodeIds || [], null, ), }), assessment: assessInvestigationState({ situationGraph, selectedQuestion: null, noQuestionReason: null, diagnostics: { promptVersion, modelName, responseDurationMs, validationStatus: "valid", nodeCount: situationGraph?.nodes?.length ?? 0, edgeCount: situationGraph?.edges?.length ?? 0, reasoningPattern: null, }, }), }; } // ── Episode-aware reconsideration seam ───────────────────── /** * Reason over a completed focused episode and return a parsed GraphUpdateProposal. * * This is the smallest sibling to updateCase that reuses existing provider, * model, and parsing mechanics — only the prompt path differs. */ export async function reconsiderCompletedEpisode(epiParams, deps = {}) { const { situationGraph: epiGraph, targetNodeId, contributions, findings, episode: rawEpisode } = epiParams; // Accept prepared episode or produce it deterministically let episode; if (rawEpisode) { episode = rawEpisode; } else if (epiParams?.turns || epiParams?.eligibleCanonicalFindings) { // Already a prepared episode passed as first positional arg episode = epiParams; } else { episode = prepareCompletedEpisode(epiParams); } if (!episode?.turns && !episode?.eligibleCanonicalFindings) { return { success: false, stage: "input_validation", error: "Invalid prepared episode — requires turns or findings", statusCode: 400, }; } const getModelName = deps.getModelName; const buildEpisodePrompt = deps.buildEpisodePrompt; const parseProposal = deps.parseProposal; const provider = epiParams.provider ?? deps.provider; let modelName = null; let rawResponse; const startedAt = Date.now(); try { // Preserve original priority (deps.modelName > deps.config.OLLAMA_MODEL > getModelName) with assertConfig fallback only when no deps provide a model let resolvedConfig = undefined; if (deps.modelName == null && deps.config?.OLLAMA_MODEL == null && deps.config?.modelName == null && deps.config !== null) { const hasOtherDeps = Object.keys(deps).some((k) => k !== "config" && k !== "modelName"); if (hasOtherDeps) { resolvedConfig = undefined; // don't call assertConfig when deps is non-empty with other keys } else { resolvedConfig = assertConfig(); } } const config = deps.config ?? resolvedConfig; modelName = deps.modelName ?? (config != null ? (config.modelName ?? config.OLLAMA_MODEL) : undefined) ?? getModelName?.() ?? getProviderModelName() ?? null; const promptBuilder = buildEpisodePrompt ?? buildEpisodeAwareGraphPrompt; const prompt = promptBuilder({ episode }); const prov = provider ?? getProvider(); rawResponse = await prov.generateReconstruction(prompt, modelName); } catch (error) { return { success: false, stage: "provider", error: "Graph update proposal generation failed", providerErrors: [ sanitiseErrorMessage( error, "Provider failed to generate graph update proposal from episode", ), ], diagnostics: { promptVersion: null, modelName, responseDurationMs: Date.now() - startedAt, normalisationsApplied: [], }, statusCode: 502, }; } const parser = parseProposal ?? parseGraphUpdateProposal; const parsedProposal = parser(rawResponse); const responseDurationMs = Date.now() - startedAt; if (!parsedProposal.success) { return { success: false, stage: "proposal_validation", error: "Invalid graph update proposal", proposalErrors: parsedProposal.errors, diagnostics: { promptVersion: null, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, }, statusCode: 502, }; } return { success: true, stage: "proposal_ready", proposal: parsedProposal.proposal, diagnostics: { promptVersion: null, modelName, responseDurationMs, normalisationsApplied: parsedProposal.normalisationsApplied, }, }; }