chore: preserve initial reconstruction prototype
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
+36
@@ -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*
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+5
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7594
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./app/**/*.{js,jsx}", "./components/**/*.{js,jsx}"],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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, ".") } },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user