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