chore: preserve initial reconstruction prototype
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const envSchema = z.object({
|
||||
OLLAMA_BASE_URL: z.string().url(),
|
||||
OLLAMA_MODEL: z.string().min(1),
|
||||
});
|
||||
|
||||
export function getConfig() {
|
||||
const parsed = envSchema.safeParse({
|
||||
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL,
|
||||
OLLAMA_MODEL: process.env.OLLAMA_MODEL,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
return { ok: true, config: parsed.data };
|
||||
}
|
||||
|
||||
export function assertConfig() {
|
||||
const result = getConfig();
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
"Invalid configuration:\n" +
|
||||
Object.entries(result.error).map(([k, v]) => ` ${k}: ${v}`).join("\n")
|
||||
);
|
||||
}
|
||||
return result.config;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Provider abstraction — the app calls getProvider() which returns an object
|
||||
* with a generateReconstruction(scenario, modelName) method.
|
||||
* Only Ollama is implemented right now; swapping providers requires only
|
||||
* changing getProvider().
|
||||
*/
|
||||
|
||||
export function getProvider() {
|
||||
return new OllamaLlmProvider();
|
||||
}
|
||||
|
||||
function recoverJson(raw) {
|
||||
if (typeof raw !== "string") return raw;
|
||||
const trimmed = raw.trim();
|
||||
|
||||
if (trimmed.length === 0) throw new SyntaxError("Model produced empty output");
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Not directly parseable — try closing braces/brackets from the right side
|
||||
}
|
||||
|
||||
let result = trimmed;
|
||||
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const ch = result[i];
|
||||
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\') { escaped = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') braceDepth++;
|
||||
else if (ch === '}') braceDepth--;
|
||||
else if (ch === '[') bracketDepth++;
|
||||
else if (ch === ']') bracketDepth--;
|
||||
}
|
||||
|
||||
const closingBrackets = [];
|
||||
for (let i = 0; i < bracketDepth; i++) closingBrackets.push(']');
|
||||
for (let i = 0; i < braceDepth; i++) closingBrackets.push('}');
|
||||
|
||||
if (closingBrackets.length > 0) {
|
||||
const closed = result + closingBrackets.reverse().join('');
|
||||
try { return JSON.parse(closed); } catch { /* still broken */ }
|
||||
}
|
||||
|
||||
const lastOpen = Math.max(result.lastIndexOf('{'), result.lastIndexOf('['));
|
||||
if (lastOpen >= 0) {
|
||||
try { return JSON.parse(result.slice(lastOpen)); } catch { /* nothing works */ }
|
||||
}
|
||||
|
||||
throw new SyntaxError("Model output could not be parsed as JSON: " + result.slice(0, 300) + "...");
|
||||
}
|
||||
|
||||
let _chatSupported = null;
|
||||
|
||||
async function detectChatSupport(baseUrl) {
|
||||
if (_chatSupported !== null) return _chatSupported;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "dummy-check",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await res.body?.consume();
|
||||
_chatSupported = true;
|
||||
} else if (res.status === 405 || res.status === 501) {
|
||||
await res.body?.consume();
|
||||
_chatSupported = false;
|
||||
} else {
|
||||
await res.body?.consume();
|
||||
_chatSupported = false;
|
||||
}
|
||||
} catch {
|
||||
_chatSupported = false;
|
||||
}
|
||||
|
||||
return _chatSupported;
|
||||
}
|
||||
|
||||
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.`;
|
||||
|
||||
const baseUrl = process.env.OLLAMA_BASE_URL;
|
||||
if (!baseUrl) throw new Error("OLLAMA_BASE_URL is not set");
|
||||
|
||||
let apiUsed = null;
|
||||
let chatSupported = false;
|
||||
let rawResponse = null;
|
||||
let fullResponseData = null;
|
||||
|
||||
// ================================================================
|
||||
// Step 1: Detect whether /api/chat exists (cache result)
|
||||
// ================================================================
|
||||
try {
|
||||
chatSupported = await detectChatSupport(baseUrl);
|
||||
} catch { /* failed silently — defaults to false */ }
|
||||
|
||||
// ================================================================
|
||||
// Step 2: Try /api/chat if supported and format:json works
|
||||
// ================================================================
|
||||
if (chatSupported) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 60000);
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
format: "json",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.ok) {
|
||||
fullResponseData = await res.json();
|
||||
rawResponse = typeof fullResponseData.message?.content === "string"
|
||||
? fullResponseData.message.content
|
||||
: JSON.stringify(fullResponseData.message?.content ?? null);
|
||||
apiUsed = "/api/chat";
|
||||
} else {
|
||||
await res.body?.consume();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!e.message.includes("abort")) { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Step 3: /api/generate (works on all Ollama versions)
|
||||
// Use a long timeout — cold starts can take 2-4 minutes for large models.
|
||||
// ================================================================
|
||||
if (rawResponse == null || rawResponse === "") {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 300000); // 5 min for cold start
|
||||
|
||||
apiUsed = "/api/generate"; // set BEFORE the request so we know which API failed
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
prompt,
|
||||
stream: false,
|
||||
// No format:json — older Ollama doesn't support it on /api/generate either.
|
||||
// We rely on the strong prompt instruction above instead.
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`/api/generate returned ${res.status}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
fullResponseData = await res.json();
|
||||
|
||||
rawResponse = typeof fullResponseData.response === "string"
|
||||
? fullResponseData.response
|
||||
: JSON.stringify(fullResponseData);
|
||||
|
||||
} catch (e) {
|
||||
if (apiUsed === "/api/generate") {
|
||||
throw new Error(
|
||||
`Ollama /api/generate request timed out after 5 minutes.\n\n` +
|
||||
`This usually means:\n` +
|
||||
`1. The model is loading into memory for the first time (cold start) — this can take several minutes\n` +
|
||||
`2. Your hardware is slow for this model size\n` +
|
||||
`3. Ollama server is overloaded\n\n` +
|
||||
`Try:\n` +
|
||||
`- Run the request again after ~1 minute (model may be cached now)\n` +
|
||||
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
|
||||
`- Check Ollama logs: \`ollama serve\` or look at your system logs`
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Step 4: Diagnose empty output
|
||||
// ================================================================
|
||||
if (rawResponse === "") {
|
||||
let diagInfo = "";
|
||||
|
||||
if (fullResponseData) {
|
||||
const keys = Object.keys(fullResponseData);
|
||||
diagInfo = "Keys in response: " + keys.join(", ") + "\n";
|
||||
|
||||
for (const key of keys) {
|
||||
const val = fullResponseData[key];
|
||||
if (typeof val === "string") {
|
||||
diagInfo += ` ${key}: "${val.slice(0, 200)}"\n`;
|
||||
} else if (typeof val === "object" && val != null) {
|
||||
try { diagInfo += ` ${key}: ${JSON.stringify(val).slice(0, 300)}\n`; } catch { diagInfo += ` ${key}: [object]\n`; }
|
||||
} else {
|
||||
diagInfo += ` ${key}: ${String(val)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Model produced empty output.\n\n" +
|
||||
"API used: " + (apiUsed || "none") + "\n" +
|
||||
"/api/chat supported: " + chatSupported + "\n" +
|
||||
"Full server response:\n" + (diagInfo || "(none)\n") +
|
||||
"\nPossible causes:\n" +
|
||||
"- Check 'ollama list' — make sure the model name matches exactly what's installed\n" +
|
||||
"- The model may be corrupted. Try: ollama pull " + modelName + "\n" +
|
||||
"- This Ollama version does not support format:json — using prompt instructions only (reliability varies)\n" +
|
||||
"- If your model is very small (e.g., tinyllama, phi), try a larger one like llama3.1 or mistral"
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Step 5: Parse and return
|
||||
// ================================================================
|
||||
try {
|
||||
return recoverJson(rawResponse);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
"Model returned output that could not be parsed as valid JSON.\n\n" +
|
||||
"API used: " + (apiUsed || "none") + "\n" +
|
||||
"/api/chat supported: " + chatSupported + "\n" +
|
||||
"Raw model output:\n" + rawResponse.slice(0, 1000) + (rawResponse.length > 1000 ? "\n...(truncated)" : "") +
|
||||
"\n\nPossible causes:\n" +
|
||||
"- This Ollama version does not support format:json. The model is producing free-form text.\n" +
|
||||
"- Try a larger model (llama3.1, mistral-large) which follows JSON instructions better\n" +
|
||||
"- Shorten your scenario to under 500 words\n" +
|
||||
"- Consider upgrading Ollama: https://ollama.com/download"
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Types defined via JSDoc for validation patterns
|
||||
// Reconstruction: {
|
||||
// observations: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// reportedClaims: Array<{id, description, confidence:"low"|"medium"|"high", attributedTo:null|string}>,
|
||||
// assumptions: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// entities: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// transitions: Array<{id, description, confidence:"low"|"medium"|"high", entity:string, previousState:string, currentState:string, explanationStatus:string}>,
|
||||
// expectedButMissing: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// presentButUnexpected: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// contradictions: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// openUncertainties: Array<{id, description, confidence:"low"|"medium"|"high"}>,
|
||||
// }
|
||||
|
||||
export const CONFIDENCE_VALUES = ["low", "medium", "high"];
|
||||
@@ -0,0 +1,31 @@
|
||||
export function buildPrompt(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.
|
||||
2. Distinguish carefully between:
|
||||
- Direct observations (you witnessed directly)
|
||||
- Reported claims (statements made by another person/entity)
|
||||
- Interpretations (your analysis of what something means)
|
||||
- Unsupported assumptions (things you are guessing without evidence)
|
||||
3. If information is unknown, place it under "openUncertainties" — never guess.
|
||||
4. Be precise, concise, and grounded in the text.
|
||||
|
||||
Scenario:
|
||||
${scenario}
|
||||
|
||||
Return valid JSON matching this structure exactly:
|
||||
{
|
||||
"observations": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"reportedClaims": [{"id": "...", "description": "...", "confidence": "low|medium|high", "attributedTo": "person/entity or null"}],
|
||||
"assumptions": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"entities": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"transitions": [{"id": "...", "description": "...", "confidence": "low|medium|high", "entity": "...", "previousState": "...", "currentState": "...", "explanationStatus": "..."}],
|
||||
"expectedButMissing": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"presentButUnexpected": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"contradictions": [{"id": "...", "description": "...", "confidence": "low|medium|high"}],
|
||||
"openUncertainties": [{"id": "...", "description": "...", "confidence": "low|medium|high"}]
|
||||
}
|
||||
|
||||
Return ONLY the JSON object. No markdown, no explanation, no preamble.`;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const confidenceEnum = z.enum(["low", "medium", "high"]);
|
||||
|
||||
const itemSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
confidence: confidenceEnum,
|
||||
});
|
||||
|
||||
export const reconstructionSchema = z.object({
|
||||
observations: z.array(itemSchema),
|
||||
reportedClaims: z.array(
|
||||
itemSchema.extend({
|
||||
attributedTo: z.union([z.string().min(1), z.null()]).optional().nullable(),
|
||||
})
|
||||
),
|
||||
assumptions: z.array(itemSchema),
|
||||
entities: z.array(itemSchema),
|
||||
transitions: z.array(
|
||||
itemSchema.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),
|
||||
});
|
||||
|
||||
export const analyseResponseSchema = z.object({
|
||||
reconstruction: reconstructionSchema,
|
||||
modelName: z.string(),
|
||||
responseDurationMs: z.number(),
|
||||
validationStatus: z.enum(["valid", "partial", "invalid"]),
|
||||
rawResponse: z.string().optional(),
|
||||
errors: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const healthResponseSchema = z.object({
|
||||
configPresent: z.boolean(),
|
||||
baseUrl: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
reachable: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export function parseReconstruction(raw) {
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new SyntaxError("Model response is not valid JSON");
|
||||
}
|
||||
}
|
||||
return reconstructionSchema.parse(raw);
|
||||
}
|
||||
Reference in New Issue
Block a user