chore: preserve initial reconstruction prototype

This commit is contained in:
2026-07-31 18:51:38 +01:00
commit a2f9e472ea
26 changed files with 8874 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Local Ollama server address
OLLAMA_BASE_URL=http://192.168.x.x:11434
# Model name (e.g., llama3, mistral, codellama, etc.)
OLLAMA_MODEL=replace-with-model-name
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+36
View File
@@ -0,0 +1,36 @@
# Dependencies
node_modules/
# Next.js build output
.next/
out/
dist/
# Coverage
coverage/
# Test result output
*.lcov
test-results/
# Environment files with secrets
.env
.env.local
.env.*.local
# Ollama model files (if any local cache)
ollama-cache/
# OS generated files
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Debug / logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+88
View File
@@ -0,0 +1,88 @@
# Confidence Engine
An experimental prototype that tests whether an LLM can build and maintain an evidence-based reconstruction of a situation over multiple turns.
## Purpose
This is Milestone 1 — a technical vertical slice only. It demonstrates:
- Sending a scenario to a local Ollama model via a Next.js server route
- Receiving structured JSON output
- Validating the result with Zod schemas
- Displaying the reconstruction and diagnostic information in a plain UI
## Prerequisites
- **Node.js 18+** (LTS recommended)
- **npm** (or equivalent package manager)
- **Ollama** installed and running on your local network, with a model pulled (e.g., `ollama pull llama3`)
## Installation
```bash
cd confidence-engine
npm install
cp .env.example .env.local
```
Edit `.env.local` and set:
- `OLLAMA_BASE_URL` — your Ollama server address (e.g., `http://192.168.1.100:11434`)
- `OLLAMA_MODEL` — the model name (e.g., `llama3`)
## Development Commands
```bash
npm run dev # Start development server on localhost:3000
npm run build # Production build
npm run start # Run production server
npm run lint # ESLint check
```
## Testing Commands
```bash
npm test # Run all tests (one-shot)
npm run test:watch # Run tests in watch mode
```
Tests mock the Ollama network request. No real Ollama server is needed to run them.
## Verifying Ollama Connectivity
1. Start the dev server: `npm run dev`
2. Open http://localhost:3000/api/health
3. You should see JSON with `"reachable": true` and your model name
## Current Limitations
- **Single-turn only** — no conversation memory or multi-turn dialogue
- **No persistence** — results are not saved between requests
- **Ollama only** — the provider abstraction exists but only Ollama is implemented
- **JSON mode reliance** — output quality depends on the model's ability to produce valid structured JSON
- **No question generation** — no follow-up questions or uncertainty resolution yet
- **No real-time streaming** — waits for full response before displaying results
- **Plain UI** — no animations, theming, or responsive polish beyond basic layout
## Deliberately Not Implemented
- Authentication / user accounts
- Billing / rate limiting
- Database or file storage
- Vector databases or embeddings
- Deployment configuration (Docker, Vercel, etc.)
- External cloud LLM providers (OpenAI, Anthropic, etc.)
- Agent frameworks or tool use
- Complex state management (Zustand, Redux, etc.)
- Multi-turn conversation history
- Question generation or ranking
## Architecture Notes
The application uses a **provider abstraction** in `lib/llm/provider.js`. The server routes call the provider, which hides the Ollama-specific implementation. To add a new provider, implement the same interface:
```js
{
generateReconstruction(scenario, modelName): Promise<Reconstruction>
}
```READMEEOF
+101
View File
@@ -0,0 +1,101 @@
import { getConfig } from "@/lib/config";
import { getProvider } from "@/lib/llm/provider";
import { reconstructionSchema } from "@/lib/reconstruction/schema";
const MAX_SCENARIO_LENGTH = 10000;
export async function POST(request) {
const startTime = Date.now();
let rawResponse = null;
try {
const body = await request.json();
if (!body.scenario || typeof body.scenario !== "string") {
return Response.json(
{ error: "Request must include a 'scenario' string field" },
{ status: 400 }
);
}
const trimmed = body.scenario.trim();
if (trimmed.length === 0) {
return Response.json(
{ error: "Scenario cannot be empty" },
{ status: 400 }
);
}
if (trimmed.length > MAX_SCENARIO_LENGTH) {
return Response.json(
{ error: `Scenario must be under ${MAX_SCENARIO_LENGTH} characters` },
{ status: 400 }
);
}
const configResult = getConfig();
if (!configResult.ok) {
return Response.json(
{ error: "Invalid server configuration" },
{ status: 500 }
);
}
const { OLLAMA_BASE_URL, OLLAMA_MODEL } = configResult.config;
const provider = getProvider();
// Attempt parse to capture raw for debugging
let reconstruction;
try {
reconstruction = await provider.generateReconstruction(trimmed, OLLAMA_MODEL);
} catch (e) {
return Response.json(
{
error: e.message || "Unknown server error",
responseDurationMs: Date.now() - startTime,
modelName: OLLAMA_MODEL,
validationStatus: "invalid",
},
{ status: 500 }
);
}
// Try to stringify for rawResponse display (safe even if it's already an object)
try {
rawResponse = JSON.stringify(reconstruction);
} catch {
rawResponse = String(reconstruction).slice(0, 2000);
}
const duration = Date.now() - startTime;
// Validate with Zod schema
const validationResult = reconstructionSchema.safeParse(reconstruction);
if (!validationResult.success) {
return Response.json({
reconstruction: null,
modelName: OLLAMA_MODEL,
responseDurationMs: duration,
validationStatus: "invalid",
rawResponse: rawResponse?.slice(0, 2000),
errors: validationResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
});
}
return Response.json({
reconstruction: validationResult.data,
modelName: OLLAMA_MODEL,
responseDurationMs: duration,
validationStatus: "valid",
rawResponse: rawResponse?.slice(0, 2000),
});
} catch (e) {
const duration = Date.now() - startTime;
return Response.json(
{ error: e.message || "Unknown server error", responseDurationMs: duration },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { getConfig } from "@/lib/config";
export async function GET() {
try {
const result = getConfig();
if (!result.ok) {
return Response.json({
configPresent: false,
baseUrl: null,
model: null,
reachable: false,
error: "Missing or invalid environment configuration",
}, { status: 500 });
}
const { OLLAMA_BASE_URL, OLLAMA_MODEL } = result.config;
// Test reachability with a short timeout
let reachable = false;
let reachError = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
signal: controller.signal
});
clearTimeout(timeout);
reachable = res.ok;
} catch (e) {
reachError = e.message || "Connection failed";
}
return Response.json({
configPresent: true,
baseUrl: OLLAMA_BASE_URL,
model: OLLAMA_MODEL,
reachable,
error: reachable ? null : (`Could not reach Ollama at ${OLLAMA_BASE_URL}: ${reachError || "timeout"}`),
});
} catch (e) {
return Response.json(
{ configPresent: false, error: e.message },
{ status: 500 }
);
}
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+16
View File
@@ -0,0 +1,16 @@
import "./globals.css";
export const metadata = {
title: "Confidence Engine",
description: "Experimental evidence-based situation reconstruction prototype",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className="min-h-screen bg-gray-50 text-gray-900">
{children}
</body>
</html>
);
}
+15
View File
@@ -0,0 +1,15 @@
import ScenarioForm from "@/components/scenario-form";
export default function Home() {
return (
<main className="mx-auto max-w-2xl px-6 py-12">
<h1 className="mb-2 text-3xl font-bold tracking-tight">Confidence Engine</h1>
<p className="mb-8 text-sm text-gray-500">
Experimental prototype: enter a scenario and send it to a local LLM for
evidence-based structured reconstruction. This is a technical vertical
slice not a production system.
</p>
<ScenarioForm />
</main>
);
}
+50
View File
@@ -0,0 +1,50 @@
const ValidationIndicator = ({ status }) => {
const styles = {
valid: "text-green-600",
partial: "text-yellow-600",
invalid: "text-red-600",
};
const labels = {
valid: "✅ Validation passed",
partial: "⚠️ Partial validation",
invalid: "❌ Validation failed",
};
return (
<div className={`flex items-center gap-2 ${styles[status] || "text-gray-500"}`}>
<span className="font-medium">{labels[status] || status}</span>
</div>
);
};
export default function DiagnosticsView({ result }) {
const metrics = [
{ label: "Model", value: result.modelName || "?" },
{ label: "Duration", value: result.responseDurationMs != null ? `${result.responseDurationMs}ms` : "?" },
{ label: "Validation", value: <ValidationIndicator status={result.validationStatus || "invalid"} /> },
];
return (
<div className="rounded border border-gray-200 bg-gray-50 p-4">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Diagnostics</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
{metrics.map(({ label, value }) => (
<div key={label}>
<dt className="text-gray-500">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{result.rawResponse && (
<details className="mt-4">
<summary className="cursor-pointer text-xs text-gray-500 underline hover:text-gray-700">
View raw model response
</summary>
<pre className="mt-2 max-h-60 overflow-auto rounded bg-gray-900 px-3 py-2 text-xs leading-relaxed text-green-400">
{result.rawResponse}
</pre>
</details>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
const categoryLabels = {
observations: "Direct Observations",
reportedClaims: "Reported Claims",
assumptions: "Unsupported Assumptions",
entities: "Entities",
transitions: "Transitions",
expectedButMissing: "Expected But Missing",
presentButUnexpected: "Present But Unexpected",
contradictions: "Contradictions",
openUncertainties: "Open Uncertainties",
};
const confidenceColor = {
low: "text-red-600 bg-red-50 border-red-200",
medium: "text-yellow-700 bg-yellow-50 border-yellow-200",
high: "text-green-700 bg-green-50 border-green-200",
};
const ConfidenceBadge = ({ level }) => (
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${confidenceColor[level] || "text-gray-600 bg-gray-100"}`}>
{level}
</span>
);
function ItemList({ items, renderExtra }) {
if (!items?.length) return <p className="text-sm italic text-gray-400">None identified</p>;
return (
<ul className="space-y-2">
{items.map((item) => (
<li key={item.id} className="rounded border border-gray-200 bg-white px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">#{item.id}</span>
<ConfidenceBadge level={item.confidence} />
</div>
<p className="mt-1">{item.description}</p>
{renderExtra && renderExtra(item)}
</li>
))}
</ul>
);
}
export default function ReconstructionView({ reconstruction, partial }) {
if (partial) {
return (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
Partial result some fields failed validation. Showing what was accepted.
</div>
);
}
const categories = Object.entries(categoryLabels).map(([key, label]) => ({
key,
label,
items: reconstruction[key],
}));
return (
<div className="space-y-1">
<h2 className="mb-3 text-lg font-semibold">Reconstruction</h2>
{categories.map(({ key, label, items }) => (
<div key={key} className="mb-4 rounded border border-gray-200 bg-white p-4">
<h3 className="mb-2 text-sm font-medium text-gray-600">{label}</h3>
<ItemList items={items} />
</div>
))}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState, useRef } from "react";
import ReconstructionView from "@/components/reconstruction-view";
import DiagnosticsView from "@/components/diagnostics-view";
const MAX_LENGTH = 10000;
export default function ScenarioForm() {
const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success
const [result, setResult] = useState(null);
const textareaRef = useRef(null);
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("loading");
setResult(null);
try {
const res = await fetch("/api/analyse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenario }),
});
const data = await res.json();
if (res.ok && data.validationStatus === "valid") {
setStatus("success");
setResult(data);
} else {
setStatus("error");
setResult(data);
}
} catch (err) {
setStatus("error");
setResult({ error: err.message || "Network request failed" });
}
};
// Always show diagnostics when there's a result (even if validation failed)
const hasDiagnostics = result && (result.reconstruction || result.modelName || result.responseDurationMs !== undefined);
return (
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
ref={textareaRef}
value={scenario}
onChange={(e) => setScenario(e.target.value)}
placeholder="Describe the scenario you want analysed..."
rows={10}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{scenario.length}/{MAX_LENGTH}</span>
<button
type="submit"
disabled={status === "loading" || !scenario.trim()}
className="rounded-lg bg-gray-900 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{status === "loading" ? "Analysing..." : "Analyse"}
</button>
</div>
</form>
{status === "error" && (
<div className="space-y-3">
{result?.error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Error: {result.error}
</div>
)}
{hasDiagnostics && result?.modelName && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-gray-500">Model</dt>
<dd>{result.modelName}</dd>
<dt className="text-gray-500">Duration</dt>
<dd>{result.responseDurationMs != null ? `${result.responseDurationMs}ms` : "?"}</dd>
</dl>
)}
</div>
)}
{status === "success" && result?.reconstruction && (
<div className="space-y-4">
<ReconstructionView reconstruction={result.reconstruction} />
<DiagnosticsView result={result} />
</div>
)}
{status === "error" && result?.reconstruction && (
<div className="space-y-3">
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-2 text-sm text-yellow-800">
Partial result some fields failed validation. Showing what was accepted.
</div>
<ReconstructionView reconstruction={result.reconstruction} partial />
</div>
)}
{status === "loading" && (
<div className="py-12 text-center text-sm text-gray-400">Waiting for model response...</div>
)}
</div>
);
}
+30
View File
@@ -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;
}
+265
View File
@@ -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;
}
}
}
+14
View File
@@ -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"];
+31
View File
@@ -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.`;
}
+60
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+3
View File
@@ -0,0 +1,3 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+7594
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"type": "module",
"name": "confidence-engine",
"version": "0.1.0",
"private": true,
"description": "Experimental prototype for evidence-based situation reconstruction using local LLMs",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"vitest": "^2.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx}", "./components/**/*.{js,jsx}"],
theme: { extend: {} },
plugins: [],
};
+233
View File
@@ -0,0 +1,233 @@
import { describe, it, expect, vi } from "vitest";
import { reconstructionSchema } from "@/lib/reconstruction/schema";
import { parseReconstruction } from "@/lib/reconstruction/schema";
describe("reconstruction schema", () => {
it("validates a complete valid reconstruction", () => {
const input = {
observations: [{ id: "o1", description: "Saw smoke", confidence: "high" }],
reportedClaims: [{ id: "rc1", description: "He said the alarm went off", confidence: "medium", attributedTo: "John" }],
assumptions: [{ id: "a1", description: "It was a fire", confidence: "low" }],
entities: [{ id: "e1", description: "John", confidence: "high" }],
transitions: [{ id: "t1", description: "John left the room", confidence: "medium", entity: "John", previousState: "present", currentState: "gone", explanationStatus: "confirmed" }],
expectedButMissing: [{ id: "eb1", description: "No one called 911", confidence: "high" }],
presentButUnexpected: [{ id: "pb1", description: "The lights were on", confidence: "low" }],
contradictions: [{ id: "c1", description: "Said he was home but car is gone", confidence: "medium" }],
openUncertainties: [{ id: "ou1", description: "Who was in the room?", confidence: "high" }],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(true);
});
it("rejects invalid confidence values", () => {
const input = {
observations: [{ id: "o1", description: "test", confidence: "extreme" }],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("Expected");
}
});
it("rejects missing required fields", () => {
const input = {
observations: [{ id: "o1" }],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(false);
});
it("rejects invalid confidence values in reportedClaims", () => {
const input = {
observations: [],
reportedClaims: [{ id: "rc1", description: "test", confidence: "very_high", attributedTo: null }],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(false);
});
it("rejects empty transitions", () => {
const input = {
observations: [],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [{ id: "t1", description: "", confidence: "high", entity: "", previousState: "", currentState: "", explanationStatus: "" }],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(false);
});
it("allows null attributedTo on reported claims", () => {
const input = {
observations: [],
reportedClaims: [{ id: "rc1", description: "Someone called it in", confidence: "medium", attributedTo: null }],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
};
const result = reconstructionSchema.safeParse(input);
expect(result.success).toBe(true);
});
});
describe("parseReconstruction", () => {
it("parses a raw JSON string", () => {
const raw = JSON.stringify({
observations: [{ id: "o1", description: "test", confidence: "high" }],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
});
const result = parseReconstruction(raw);
expect(result.observations[0].id).toBe("o1");
});
it("rejects malformed JSON string", () => {
expect(() => parseReconstruction("{invalid json")).toThrow(SyntaxError);
});
it("rejects valid JSON that fails schema validation", () => {
const raw = JSON.stringify({
observations: [{ id: "o1", description: "test", confidence: "extreme" }],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
});
expect(() => parseReconstruction(raw)).toThrow();
});
});
describe("empty scenario rejection", () => {
it("rejects empty string", () => {
const trimmed = "".trim();
expect(trimmed.length).toBe(0);
});
it("rejects whitespace-only string", () => {
const trimmed = " \n\t ".trim();
expect(trimmed.length).toBe(0);
});
});
describe("provider response parsing", () => {
it("handles Ollama generate response shape", async () => {
vi.stubGlobal("process", { env: { OLLAMA_BASE_URL: "http://localhost:11434" } });
const mockResponse = JSON.stringify({
observations: [{ id: "o1", description: "test", confidence: "high" }],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ response: mockResponse }),
});
const { getProvider } = await import("@/lib/llm/provider");
const provider = new getProvider().constructor ? null : getProvider();
// The provider is instantiated in getProvider
expect(true).toBe(true);
});
it("handles raw JSON object response", () => {
const parsed = parseReconstruction({
observations: [],
reportedClaims: [{ id: "rc1", description: "he said", confidence: "medium", attributedTo: "Alice" }],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
});
expect(parsed.reportedClaims[0].attributedTo).toBe("Alice");
});
});
describe("malformed model output", () => {
it("throws on non-JSON string", () => {
expect(() => parseReconstruction("hello world")).toThrow(SyntaxError);
});
it("throws on JSON without required fields", () => {
const raw = JSON.stringify({ notTheRightStructure: true });
expect(() => parseReconstruction(raw)).toThrow();
});
it("handles empty arrays for all categories", () => {
const result = parseReconstruction({
observations: [],
reportedClaims: [],
assumptions: [],
entities: [],
transitions: [],
expectedButMissing: [],
presentButUnexpected: [],
contradictions: [],
openUncertainties: [],
});
expect(result.observations.length).toBe(0);
});
});
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({
test: { globals: true },
resolve: { alias: { "@": path.resolve(__dirname, ".") } },
});