fix: normalise compatible live reconstruction responses

This commit is contained in:
2026-08-02 07:50:02 +01:00
parent 575b8fd971
commit 02a6ecd0da
5 changed files with 325 additions and 16 deletions
+77 -16
View File
@@ -5,7 +5,12 @@
import { getConfig } from "../lib/config.js";
import { getProvider } from "../lib/llm/provider.js";
import { buildPrompt, PROMPT_VERSIONS, DEFAULT_PROMPT_VERSION } from "../lib/reconstruction/prompt.js";
import {
buildPrompt,
PROMPT_VERSIONS,
DEFAULT_PROMPT_VERSION,
} from "../lib/reconstruction/prompt.js";
import { normaliseAnalysisResponse } from "../lib/reconstruction/compatibility.js";
import {
reconstructionV2Schema,
reconstructionSchema as reconstructionV1Schema,
@@ -33,7 +38,10 @@ export async function analyseScenario(scenario, opts = {}) {
return buildErrorResponse("Scenario cannot be empty", startTime);
}
if (trimmed.length > MAX_SCENARIO_LENGTH) {
return buildErrorResponse(`Scenario must be under ${MAX_SCENARIO_LENGTH} characters`, startTime);
return buildErrorResponse(
`Scenario must be under ${MAX_SCENARIO_LENGTH} characters`,
startTime,
);
}
// ── Configuration check ────────────────────────────
@@ -50,18 +58,24 @@ export async function analyseScenario(scenario, opts = {}) {
try {
promptObj = await buildPrompt(trimmed, promptVersion);
} catch (e) {
return buildErrorResponse(`Failed to build prompt: ${e.message}`, startTime);
return buildErrorResponse(
`Failed to build prompt: ${e.message}`,
startTime,
);
}
// ── Call provider ──────────────────────────────────
const provider = getProvider();
let rawResponse;
try {
rawResponse = await provider.generateReconstruction(promptObj.prompt, OLLAMA_MODEL);
rawResponse = await provider.generateReconstruction(
promptObj.prompt,
OLLAMA_MODEL,
);
} catch (e) {
return buildErrorResponse(
e.message || "Provider error during analysis",
Date.now() - startTime
Date.now() - startTime,
);
}
@@ -75,16 +89,37 @@ export async function analyseScenario(scenario, opts = {}) {
rawResponseStr = String(rawResponse).slice(0, 2000);
}
const compatibility = normaliseAnalysisResponse(rawResponse);
const candidateResponse = compatibility.normalised;
// ── Validate against v0.2 schema (preferred) ──────
const resultV2 = tryValidateAgainstSchema(rawResponse, reconstructionV2Schema);
const resultV2 = tryValidateAgainstSchema(
candidateResponse,
reconstructionV2Schema,
);
if (resultV2.valid) {
return buildSuccessResultV2(resultV2.data, OLLAMA_MODEL, duration, promptVersion);
return buildSuccessResultV2(
resultV2.data,
OLLAMA_MODEL,
duration,
promptVersion,
compatibility,
);
}
// ── Fallback to v0.1 schema ────────────────────────
const resultV1 = tryValidateAgainstSchema(rawResponse, reconstructionV1Schema);
const resultV1 = tryValidateAgainstSchema(
candidateResponse,
reconstructionV1Schema,
);
if (resultV1.valid) {
return buildSuccessResultV1(resultV1.data, OLLAMA_MODEL, duration, promptVersion);
return buildSuccessResultV1(
resultV1.data,
OLLAMA_MODEL,
duration,
promptVersion,
compatibility,
);
}
// ── Neither schema matched — partial failure ───────
@@ -93,17 +128,23 @@ export async function analyseScenario(scenario, opts = {}) {
resultV2.error ?? resultV1.error,
OLLAMA_MODEL,
duration,
promptVersion
promptVersion,
compatibility,
);
}
/** Attempt validation against a Zod schema */
function tryValidateAgainstSchema(data, schema) {
if (!schema.safeParse) {
return { valid: false, error: new Error("Schema does not support safeParse") };
return {
valid: false,
error: new Error("Schema does not support safeParse"),
};
}
const result = schema.safeParse(data);
return result.success ? { valid: true, data: result.data } : { valid: false, error: result.error };
return result.success
? { valid: true, data: result.data }
: { valid: false, error: result.error };
}
// ── Result builders ──────────────────────────────────
@@ -121,7 +162,15 @@ function buildErrorResponse(message, elapsed, statusCode = 500) {
};
}
function buildSuccessResultV2(data, model, duration, version) {
function buildCompatibilityDiagnostics(compatibility) {
return {
compatibilityApplied: compatibility.changesApplied.length > 0,
compatibilityChanges: compatibility.changesApplied,
compatibilityWarnings: compatibility.warnings,
};
}
function buildSuccessResultV2(data, model, duration, version, compatibility) {
return {
success: true,
validationStatus: "valid",
@@ -134,10 +183,11 @@ function buildSuccessResultV2(data, model, duration, version) {
evidence: data.evidence,
nextQuestion: data.nextQuestion,
errors: undefined,
...buildCompatibilityDiagnostics(compatibility),
};
}
function buildSuccessResultV1(data, model, duration, version) {
function buildSuccessResultV1(data, model, duration, version, compatibility) {
return {
success: true,
validationStatus: "valid",
@@ -150,14 +200,24 @@ function buildSuccessResultV1(data, model, duration, version) {
evidence: undefined,
nextQuestion: undefined,
errors: undefined,
...buildCompatibilityDiagnostics(compatibility),
};
}
function buildPartialResult(rawResp, error, model, duration, version) {
function buildPartialResult(
rawResp,
error,
model,
duration,
version,
compatibility,
) {
let errors = [];
if (error && typeof error.flatten === "function") {
errors = error.flatten().fieldErrors
? Object.entries(error.flatten().fieldErrors).flatMap(([k, v]) => [`${k}: ${v.join(", ")}`])
? Object.entries(error.flatten().fieldErrors).flatMap(([k, v]) => [
`${k}: ${v.join(", ")}`,
])
: [String(error)];
} else if (error) {
errors = [String(error).slice(0, 500)];
@@ -175,6 +235,7 @@ function buildPartialResult(rawResp, error, model, duration, version) {
evidence: undefined,
nextQuestion: undefined,
errors,
...buildCompatibilityDiagnostics(compatibility),
};
}
+3
View File
@@ -34,6 +34,9 @@ function buildDiagnostics({ analysis, graph, graphReferenceValidation }) {
nodeCount: graph?.nodes?.length ?? 0,
edgeCount: graph?.edges?.length ?? 0,
graphReferenceValidation,
compatibilityApplied: analysis?.compatibilityApplied ?? false,
compatibilityChanges: analysis?.compatibilityChanges ?? [],
compatibilityWarnings: analysis?.compatibilityWarnings ?? [],
};
}
+40
View File
@@ -0,0 +1,40 @@
function cloneJsonSafe(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
}
export function normaliseAnalysisResponse(input) {
const normalised = cloneJsonSafe(input);
const changesApplied = [];
const warnings = [];
if (!normalised || typeof normalised !== "object") {
return { normalised: input, changesApplied, warnings };
}
if (Array.isArray(normalised.evidence)) {
normalised.evidence = normalised.evidence.map((record, index) => {
if (!record || typeof record !== "object") return record;
if (record.source === null) {
changesApplied.push({
path: ["evidence", index, "source"],
change: "Converted null source to undefined",
});
const { source: _removed, ...rest } = record;
return rest;
}
return record;
});
}
if (changesApplied.length > 0) {
warnings.push(
"Applied deterministic reconstruction compatibility normalisation",
);
}
return { normalised, changesApplied, warnings };
}