342 lines
9.7 KiB
JavaScript
342 lines
9.7 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 { parseGraphUpdateProposal } from "./update-proposal.js";
|
|
import {
|
|
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 }) {
|
|
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 ?? [],
|
|
};
|
|
}
|
|
|
|
function buildUpdateDiagnostics({
|
|
promptVersion,
|
|
modelName,
|
|
responseDurationMs,
|
|
normalisationsApplied,
|
|
graph,
|
|
graphReferenceValidation,
|
|
selectedQuestion,
|
|
}) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 activeUnknownNodeId =
|
|
selectActiveUnknownCandidate(
|
|
{
|
|
...initialGraph,
|
|
resolvedNodeIds: [],
|
|
},
|
|
[],
|
|
)?.nodeId ?? null;
|
|
|
|
const situationGraph = makeGraph({
|
|
centralStatement: scenario,
|
|
nodes: initialGraph.nodes,
|
|
edges: initialGraph.edges,
|
|
activeUnknownNodeId,
|
|
resolvedNodeIds: [],
|
|
currentSummary,
|
|
});
|
|
|
|
situationGraphSchema.parse(situationGraph);
|
|
|
|
const graphReferenceValidation = validateGraphReferences(situationGraph);
|
|
if (!graphReferenceValidation.valid) {
|
|
return {
|
|
success: false,
|
|
error: "Situation graph reference validation failed",
|
|
diagnostics: buildDiagnostics({
|
|
analysis,
|
|
graph: situationGraph,
|
|
graphReferenceValidation,
|
|
}),
|
|
validationErrors: graphReferenceValidation.errors,
|
|
statusCode: 500,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
situationGraph,
|
|
selectedQuestion: analysis.nextQuestion ?? null,
|
|
diagnostics: buildDiagnostics({
|
|
analysis,
|
|
graph: situationGraph,
|
|
graphReferenceValidation,
|
|
}),
|
|
};
|
|
}
|
|
|
|
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,
|
|
});
|
|
|
|
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,
|
|
}),
|
|
},
|
|
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,
|
|
}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
stage: "proposal_ready",
|
|
proposal: parsedProposal.proposal,
|
|
diagnostics: buildUpdateDiagnostics({
|
|
promptVersion,
|
|
modelName,
|
|
responseDurationMs,
|
|
normalisationsApplied: parsedProposal.normalisationsApplied,
|
|
graph: situationGraph,
|
|
graphReferenceValidation,
|
|
selectedQuestion: null,
|
|
}),
|
|
};
|
|
}
|