/** * 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 } from "../llm/provider.js"; import { makeGraph, startCaseRequestSchema, situationGraphSchema, updateCaseRequestSchema, } from "./schema.js"; import { buildInitialGraph, describeGraph } from "./builder.js"; import { applyValidatedProposal } from "./apply-proposal.js"; import { buildGraphUpdatePrompt } from "./prompt-builder.js"; import { buildReasoningState, formulateTieResolutionQuestion, } from "./question-formulator.js"; import { parseGraphUpdateProposal } from "./update-proposal.js"; import { explainUnknownSelection, selectActiveUnknownCandidate, validateGraphReferences, } from "./utils.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, }) { 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, }; } 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, }) { 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 ?? [], unknownSelectionExplanation: unknownSelectionExplanation ?? null, }; } export async function startCase(body) { 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 analysis = await analyseScenario(scenario, { promptVersion }); if (!analysis.success) { return { success: false, error: analysis.error ?? "Scenario analysis failed", diagnostics: buildDiagnostics({ analysis, graph: null, graphReferenceValidation: null, }), analysisErrors: analysis.errors ?? undefined, rawResponse: analysis.rawResponse ?? undefined, statusCode: Number(analysis.statusCode) || 502, }; } const initialGraph = buildInitialGraph({ reconstruction: analysis.reconstruction, evidence: analysis.evidence, }); const currentSummary = describeGraph(initialGraph); const deterministicSelection = selectActiveUnknownCandidate( { ...initialGraph, resolvedNodeIds: [], }, [], ); const activeUnknownNodeId = deterministicSelection?.status === "selected" ? deterministicSelection.nodeId : null; const situationGraph = makeGraph({ centralStatement: scenario, nodes: initialGraph.nodes, edges: initialGraph.edges, activeUnknownNodeId, resolvedNodeIds: [], currentSummary, reasoningState: buildReasoningState({ centralStatement: scenario, nodes: initialGraph.nodes, edges: initialGraph.edges, resolvedNodeIds: [], }), }); situationGraphSchema.parse(situationGraph); const graphReferenceValidation = validateGraphReferences(situationGraph); const selectedQuestion = deterministicSelection?.status === "ambiguous" ? { id: "q_tie_resolution", ...formulateTieResolutionQuestion({ graph: situationGraph }), tiedCandidateIds: deterministicSelection.tiedCandidateIds, } : (analysis.nextQuestion ?? 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, }), validationErrors: graphReferenceValidation.errors, statusCode: 500, }; } return { success: true, situationGraph, selectedQuestion, diagnostics: buildDiagnostics({ analysis, graph: situationGraph, graphReferenceValidation, unknownSelectionExplanation, }), }; } 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 } = 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 { const config = dependencies.config ?? assertConfig(); modelName = config.OLLAMA_MODEL; 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) { 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: [], unknownSelectionExplanation: explainUnknownSelection( situationGraph, situationGraph.resolvedNodeIds || [], ), }), }, statusCode: applicationResult.stage === "application" || applicationResult.stage === "result_validation" ? 500 : 400, }; } 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, 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, unknownSelectionExplanation: buildUnknownSelectionDiagnostics( applicationResult.updatedSituationGraph, applicationResult.updatedSituationGraph.resolvedNodeIds || [], applicationResult.selectedQuestion, ), }), }; } 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: [], unknownSelectionExplanation: buildUnknownSelectionDiagnostics( situationGraph, situationGraph.resolvedNodeIds || [], null, ), }), }; }