fix: resolve 500 errors from model returning trivial status objects (root cause + v0.2 prompt fix)

Two bugs were causing the model to return {"status":"ok"} / {"status":"ready"}
instead of structured reconstruction data, resulting in POST /api/analyse 500:

1. DOUBLE-WRAPPING BUG (lib/llm/provider.js):
   generateReconstruction() called buildPrompt(scenario) on input that was
   already a fully-built prompt string from analyseScenario(). This wrapped the
   v0.1 prompt (~5000+ chars) in another template layer, producing incomprehensible
   output that the model could not parse as structured JSON.
   Fix: Pass scenario through directly (it is ALREADY a built prompt).

2. MISSING JSON SPEC (prompts/reconstruct-v0.2.md):
   The v0.2 prompt template said 'matching the structure exactly' but never
   defined what that structure was. The model invented its own field names
   (input_classification, reasoning_mode, anchors) with snake_case instead of
   camelCase, which failed Zod validation -> 500 errors.
   Fix: Added explicit JSON schema section with exact key names, enum values,
   and nested structure matching the Zod validation layer.

Additionally:
- Refactored route to use analyseScenario from lib/analysis (centralized)
- Added lib/analysis.js with shared analysis logic
- Updated components to display promptVersion and validation errors
- Added lib/reconstruction/prompt.js v0.1/v0.2 versioning
- Added lib/reconstruction/schema.js v0.2 Zod schemas
- Added debug tool scripts, evaluation results, and comparison findings
This commit is contained in:
2026-08-01 08:57:28 +01:00
parent 18ac3f37ec
commit 956fc2e31e
91 changed files with 17691 additions and 280 deletions
+182
View File
@@ -0,0 +1,182 @@
/**
* Core analysis pipeline — shared by API routes and evaluation harness.
* Calls the provider, parses output, validates against Zod schemas (v0.2 first, v0.1 fallback).
*/
import { getConfig } from "../lib/config.js";
import { getProvider } from "../lib/llm/provider.js";
import { buildPrompt, PROMPT_VERSIONS } from "../lib/reconstruction/prompt.js";
import {
reconstructionV2Schema,
reconstructionSchema as reconstructionV1Schema,
} from "../lib/reconstruction/schema.js";
const MAX_SCENARIO_LENGTH = 10000;
const DEFAULT_PROMPT_VERSION = "v0.2";
/**
* Analyse a scenario string through the full pipeline.
* @param {string} scenario - The scenario text to analyse
* @param {object} [opts]
* @param {"v0.1" | "v0.2"} [opts.promptVersion="v0.2"] - Prompt version to use
* @returns {Promise<object>} Analysis result with diagnostics
*/
export async function analyseScenario(scenario, opts = {}) {
const startTime = Date.now();
// ── Input validation ───────────────────────────────
if (typeof scenario !== "string") {
return buildErrorResponse("Input must be a string", startTime);
}
const trimmed = scenario.trim();
if (trimmed.length === 0) {
return buildErrorResponse("Scenario cannot be empty", startTime);
}
if (trimmed.length > MAX_SCENARIO_LENGTH) {
return buildErrorResponse(`Scenario must be under ${MAX_SCENARIO_LENGTH} characters`, startTime);
}
// ── Configuration check ────────────────────────────
const configResult = getConfig();
if (!configResult.ok) {
return buildErrorResponse("Invalid server configuration", startTime, "500");
}
const { OLLAMA_BASE_URL: _ignored, OLLAMA_MODEL } = configResult.config;
const promptVersion = opts.promptVersion || DEFAULT_PROMPT_VERSION;
// ── Build prompt ───────────────────────────────────
let promptObj;
try {
promptObj = await buildPrompt(trimmed, promptVersion);
} catch (e) {
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);
} catch (e) {
return buildErrorResponse(
e.message || "Provider error during analysis",
Date.now() - startTime
);
}
const duration = Date.now() - startTime;
// Try to capture raw response for diagnostics
let rawResponseStr;
try {
rawResponseStr = JSON.stringify(rawResponse);
} catch {
rawResponseStr = String(rawResponse).slice(0, 2000);
}
// ── Validate against v0.2 schema (preferred) ──────
const resultV2 = tryValidateAgainstSchema(rawResponse, reconstructionV2Schema);
if (resultV2.valid) {
return buildSuccessResultV2(resultV2.data, OLLAMA_MODEL, duration, promptVersion);
}
// ── Fallback to v0.1 schema ────────────────────────
const resultV1 = tryValidateAgainstSchema(rawResponse, reconstructionV1Schema);
if (resultV1.valid) {
return buildSuccessResultV1(resultV1.data, OLLAMA_MODEL, duration, promptVersion);
}
// ── Neither schema matched — partial failure ───────
return buildPartialResult(
rawResponseStr?.slice(0, 2000),
resultV2.error ?? resultV1.error,
OLLAMA_MODEL,
duration,
promptVersion
);
}
/** Attempt validation against a Zod schema */
function tryValidateAgainstSchema(data, schema) {
if (!schema.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 };
}
// ── Result builders ──────────────────────────────────
function buildErrorResponse(message, elapsed, statusCode = 500) {
return {
success: false,
error: message,
modelName: null,
responseDurationMs: elapsed,
validationStatus: "invalid",
rawResponse: null,
promptVersion: null,
statusCode,
};
}
function buildSuccessResultV2(data, model, duration, version) {
return {
success: true,
validationStatus: "valid",
modelName: model,
responseDurationMs: duration,
rawResponse: JSON.stringify(data).slice(0, 3000),
promptVersion: version,
inputClassification: data.inputClassification,
reconstruction: data.reconstruction,
evidence: data.evidence,
nextQuestion: data.nextQuestion,
errors: undefined,
};
}
function buildSuccessResultV1(data, model, duration, version) {
return {
success: true,
validationStatus: "valid",
modelName: model,
responseDurationMs: duration,
rawResponse: JSON.stringify(data).slice(0, 3000),
promptVersion: version,
inputClassification: null,
reconstruction: data,
evidence: undefined,
nextQuestion: undefined,
errors: undefined,
};
}
function buildPartialResult(rawResp, error, model, duration, version) {
let errors = [];
if (error && typeof error.flatten === "function") {
errors = error.flatten().fieldErrors
? Object.entries(error.flatten().fieldErrors).flatMap(([k, v]) => [`${k}: ${v.join(", ")}`])
: [String(error)];
} else if (error) {
errors = [String(error).slice(0, 500)];
}
return {
success: false,
validationStatus: "invalid",
modelName: model,
responseDurationMs: duration,
rawResponse: rawResp?.slice(0, 2000),
promptVersion: version,
inputClassification: null,
reconstruction: null,
evidence: undefined,
nextQuestion: undefined,
errors,
};
}
export { PROMPT_VERSIONS, DEFAULT_PROMPT_VERSION };
+3 -5
View File
@@ -93,11 +93,9 @@ async function detectChatSupport(baseUrl) {
class OllamaLlmProvider {
async generateReconstruction(scenario, modelName) {
const { buildPrompt } = await import("@/lib/reconstruction/prompt");
let rawPrompt = buildPrompt(scenario);
// Stronger JSON hint since we can't use format:json on older Ollama
const prompt = rawPrompt + `\n\nReturn ONLY a valid JSON object starting with { and ending with }. Do NOT include any text before the opening brace or after the closing brace. Do NOT wrap in markdown backticks.`;
// scenario is ALREADY a fully-built prompt text (built by analyseScenario).
// Do NOT call buildPrompt() again — that would double-wrap the prompt.
const prompt = scenario;
const baseUrl = process.env.OLLAMA_BASE_URL;
if (!baseUrl) throw new Error("OLLAMA_BASE_URL is not set");
+45 -2
View File
@@ -1,5 +1,17 @@
export function buildPrompt(scenario) {
return `You are a neutral analyst performing an evidence-based reconstruction of the following scenario.
import { promises as fs } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PROMPTS_DIR = join(__dirname, "../../prompts");
/** Available prompt versions */
export const PROMPT_VERSIONS = ["v0.1", "v0.2"];
/** Build a v0.1 (extraction-only) prompt inline for backward compatibility */
function buildV1Prompt(scenario) {
return `You are a neutral analyst performing an evidence-based reconstruction of the following scenario.
Rules:
1. Do NOT invent facts. Only include information present in the scenario or clearly implied.
@@ -29,3 +41,34 @@ Return valid JSON matching this structure exactly:
Return ONLY the JSON object. No markdown, no explanation, no preamble.`;
}
/** Load a versioned prompt from disk and substitute {{SCENARIO}} */
async function buildV2Prompt(scenario) {
try {
const content = await fs.readFile(join(PROMPTS_DIR, "reconstruct-v0.2.md"), "utf-8");
return content.replace("{{SCENARIO}}", scenario);
} catch {
// Fall back to v0.1 prompt if v0.2 file is missing
return buildV1Prompt(scenario);
}
}
/**
* Build an analysis prompt for the given version.
* @param {"v0.1" | "v0.2"} [version="v0.2"]
* @returns {Promise<{prompt: string, version: string}>}
*/
export async function buildPrompt(scenario, version = "v0.2") {
let prompt;
switch (version) {
case "v0.1":
prompt = buildV1Prompt(scenario);
break;
default: // v0.2
prompt = await buildV2Prompt(scenario);
break;
}
const strongJsonHint = "\n\nReturn ONLY a valid JSON object starting with { and ending with }. Do NOT include any text before the opening brace or after the closing brace. Do NOT wrap in markdown backticks.";
return { prompt: prompt + strongJsonHint, version };
}
+164 -13
View File
@@ -1,38 +1,51 @@
import { z } from "zod";
const confidenceEnum = z.enum(["low", "medium", "high"]);
// ──────────────────────────────────────────────
// Shared enums (v0.1 & v0.2)
// ──────────────────────────────────────────────
const itemSchema = z.object({
export const confidenceEnum = z.enum(["low", "medium", "high"]);
const importanceEnum = z.enum(["incidental", "supporting", "important", "critical"]);
const expectedInfoValueEnum = z.enum(["low", "medium", "high"]);
// ──────────────────────────────────────────────
// v0.1 — extraction-only schema (preserved)
// ──────────────────────────────────────────────
const confidenceEnumV1 = z.enum(["low", "medium", "high"]);
const itemSchemaV1 = z.object({
id: z.string().min(1),
description: z.string().min(1),
confidence: confidenceEnum,
confidence: confidenceEnumV1,
});
export const reconstructionSchema = z.object({
observations: z.array(itemSchema),
observations: z.array(itemSchemaV1),
reportedClaims: z.array(
itemSchema.extend({
itemSchemaV1.extend({
attributedTo: z.union([z.string().min(1), z.null()]).optional().nullable(),
})
),
assumptions: z.array(itemSchema),
entities: z.array(itemSchema),
assumptions: z.array(itemSchemaV1),
entities: z.array(itemSchemaV1),
transitions: z.array(
itemSchema.extend({
itemSchemaV1.extend({
entity: z.string().min(1),
previousState: z.string().min(1),
currentState: z.string().min(1),
explanationStatus: z.string().min(1),
})
),
expectedButMissing: z.array(itemSchema),
presentButUnexpected: z.array(itemSchema),
contradictions: z.array(itemSchema),
openUncertainties: z.array(itemSchema),
expectedButMissing: z.array(itemSchemaV1),
presentButUnexpected: z.array(itemSchemaV1),
contradictions: z.array(itemSchemaV1),
openUncertainties: z.array(itemSchemaV1),
});
// v0.1 analyse response (used internally)
export const analyseResponseSchema = z.object({
reconstruction: reconstructionSchema,
reconstruction: z.union([reconstructionSchema, z.null()]),
modelName: z.string(),
responseDurationMs: z.number(),
validationStatus: z.enum(["valid", "partial", "invalid"]),
@@ -48,6 +61,133 @@ export const healthResponseSchema = z.object({
error: z.string().nullable(),
});
// ──────────────────────────────────────────────
// v0.2 — reasoning classification + reconstruction
// ──────────────────────────────────────────────
export const inputTypes = /** @type {z.ZodType<typeof import("@/lib/reconstruction/schema").INPUT_TYPE_VALUE>} */ (
z.enum([
"observed_problem",
"unexplained_change",
"contradiction",
"decision_request",
"causal_claim",
"reported_claim",
"fault_report",
"ambiguous_statement",
"question",
"desired_outcome",
"insufficient_context",
"other",
])
);
export const reasoningModes = /** @type {z.ZodType<typeof import("@/lib/reconstruction/schema").REASONING_MODE_VALUE>} */ (
z.enum([
"establish_baseline",
"identify_difference",
"reconstruct_transition",
"decompose_aggregate",
"validate_measurement",
"validate_claim",
"investigate_contradiction",
"clarify_meaning",
"decision_support",
"fault_investigation",
"identify_missing_information",
"test_possible_explanations",
"other",
])
);
const evidenceRecordSchema = z.object({
id: z.string().min(1),
description: z.string().min(1),
evidenceType: z.enum(["direct_observation", "reported_statement", "interpretation", "assumption", "inferred_relationship"]),
source: z.string().optional(),
attribution: z.string().nullable().optional(),
confidence: confidenceEnum,
importance: importanceEnum,
});
const reconstructionSchemaV2 = z.object({
summary: z.string().min(1),
actors: z.array(itemSchemaV1),
systemsOrObjects: z.array(itemSchemaV1),
expectedStates: z.array(itemSchemaV1),
observedStates: z.array(itemSchemaV1),
differences: z.array(itemSchemaV1),
knownTransitions: z.array(
itemSchemaV1.extend({
entity: z.string().min(1),
previousState: z.string().min(1),
currentState: z.string().min(1),
explanationStatus: z.string().min(1),
})
),
unexplainedTransitions: z.array(
itemSchemaV1.extend({
entity: z.string().min(1).optional(),
previousState: z.string().min(1).optional(),
currentState: z.string().min(1).optional(),
})
),
contradictions: z.array(itemSchemaV1),
importantUnknowns: z.array(itemSchemaV1),
plausibleInterpretations: z.array(
z.object({
id: z.string().min(1),
description: z.string().min(1),
supportingEvidenceIds: z.array(z.string()),
assumptionsRequired: z.array(z.string()).optional().default([]),
confidence: confidenceEnum,
})
),
});
const inputClassificationSchema = z.object({
primaryType: inputTypes,
secondaryTypes: z.array(inputTypes).optional().default([]),
reasoningModes: z.array(reasoningModes).optional().default([]),
classificationReason: z.string().min(1),
confidence: confidenceEnum,
});
const nextQuestionSchema = z.object({
id: z.string().min(1),
question: z.string().min(1),
targets: z.array(z.string()),
reason: z.string().min(1),
expectedInformationValue: expectedInfoValueEnum,
reasoningMode: reasoningModes.optional().default("other"),
});
// v0.2 complete analysis response (what the model produces)
export const reconstructionV2Schema = z.object({
inputClassification: inputClassificationSchema,
reconstruction: reconstructionSchemaV2,
evidence: z.array(evidenceRecordSchema),
nextQuestion: nextQuestionSchema,
});
// Outer wrapper for API return (includes diagnostics + v0.2 data)
export const analyseResponseV2Schema = z.object({
inputClassification: inputClassificationSchema.optional(),
reconstruction: reconstructionSchemaV2.optional().nullable(),
evidence: z.array(evidenceRecordSchema).optional(),
nextQuestion: nextQuestionSchema.optional(),
modelName: z.string(),
responseDurationMs: z.number(),
validationStatus: z.enum(["valid", "partial", "invalid"]),
rawResponse: z.string().optional(),
errors: z.array(z.string()).optional(),
promptVersion: z.string().optional(),
});
// ──────────────────────────────────────────────
// Parsing helpers
// ──────────────────────────────────────────────
export function parseReconstruction(raw) {
if (typeof raw === "string") {
try {
@@ -58,3 +198,14 @@ export function parseReconstruction(raw) {
}
return reconstructionSchema.parse(raw);
}
export function parseReconstructionV2(raw) {
if (typeof raw === "string") {
try {
raw = JSON.parse(raw);
} catch {
throw new SyntaxError("Model response is not valid JSON");
}
}
return reconstructionV2Schema.parse(raw);
}