Files
confidence-engine/lib/graph/orchestrator.js
T

1263 lines
48 KiB
JavaScript

/**
* 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,
code: analysis.code ?? 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,
},
};
}