477 lines
15 KiB
JavaScript
477 lines
15 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 } 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,
|
|
emergentReasoningNodeCreated,
|
|
emergentReasoningNodeId,
|
|
emergentReasoningNodeReason,
|
|
atomicityAssessment,
|
|
decompositionPerformed,
|
|
childUnknownCount,
|
|
childNodeIds,
|
|
atomicityReason,
|
|
}) {
|
|
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,
|
|
decompositionPerformed: decompositionPerformed ?? false,
|
|
childUnknownCount: childUnknownCount ?? 0,
|
|
childNodeIds: childNodeIds ?? [],
|
|
atomicityReason: atomicityReason ?? null,
|
|
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: [],
|
|
emergentReasoningNodeCreated: false,
|
|
emergentReasoningNodeId: null,
|
|
emergentReasoningNodeReason: null,
|
|
atomicityAssessment: null,
|
|
decompositionPerformed: false,
|
|
childUnknownCount: 0,
|
|
childNodeIds: [],
|
|
atomicityReason: null,
|
|
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,
|
|
emergentReasoningNodeCreated:
|
|
applicationResult.emergentReasoningNodeCreated,
|
|
emergentReasoningNodeId: applicationResult.emergentReasoningNodeId,
|
|
emergentReasoningNodeReason:
|
|
applicationResult.emergentReasoningNodeReason,
|
|
atomicityAssessment: applicationResult.atomicityAssessment,
|
|
decompositionPerformed: applicationResult.decompositionPerformed,
|
|
childUnknownCount: applicationResult.childUnknownCount,
|
|
childNodeIds: applicationResult.childNodeIds,
|
|
atomicityReason: applicationResult.atomicityReason,
|
|
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: [],
|
|
emergentReasoningNodeCreated: false,
|
|
emergentReasoningNodeId: null,
|
|
emergentReasoningNodeReason: null,
|
|
atomicityAssessment: null,
|
|
decompositionPerformed: false,
|
|
childUnknownCount: 0,
|
|
childNodeIds: [],
|
|
atomicityReason: null,
|
|
unknownSelectionExplanation: buildUnknownSelectionDiagnostics(
|
|
situationGraph,
|
|
situationGraph.resolvedNodeIds || [],
|
|
null,
|
|
),
|
|
}),
|
|
};
|
|
}
|