From a2f9e472ea50ac33a116c734a62c86f6b0054dbc Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 31 Jul 2026 18:51:38 +0100 Subject: [PATCH] chore: preserve initial reconstruction prototype --- .env.example | 5 + .eslintrc.json | 3 + .gitignore | 36 + README.md | 88 + app/api/analyse/route.js | 101 + app/api/health/route.js | 49 + app/globals.css | 3 + app/layout.jsx | 16 + app/page.jsx | 15 + components/diagnostics-view.jsx | 50 + components/reconstruction-view.jsx | 70 + components/scenario-form.jsx | 107 + lib/config.js | 30 + lib/llm/provider.js | 265 + lib/llm/types.js | 14 + lib/reconstruction/prompt.js | 31 + lib/reconstruction/schema.js | 60 + next-env.d.ts | 5 + next.config.mjs | 3 + package-lock.json | 7594 ++++++++++++++++++++++++++++ package.json | 32 + postcss.config.cjs | 6 + tailwind.config.cjs | 6 + tests/reconstruction.test.js | 233 + tsconfig.json | 41 + vitest.config.js | 11 + 26 files changed, 8874 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/api/analyse/route.js create mode 100644 app/api/health/route.js create mode 100644 app/globals.css create mode 100644 app/layout.jsx create mode 100644 app/page.jsx create mode 100644 components/diagnostics-view.jsx create mode 100644 components/reconstruction-view.jsx create mode 100644 components/scenario-form.jsx create mode 100644 lib/config.js create mode 100644 lib/llm/provider.js create mode 100644 lib/llm/types.js create mode 100644 lib/reconstruction/prompt.js create mode 100644 lib/reconstruction/schema.js create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.cjs create mode 100644 tailwind.config.cjs create mode 100644 tests/reconstruction.test.js create mode 100644 tsconfig.json create mode 100644 vitest.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa72cca --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..957cd15 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a898f55 --- /dev/null +++ b/.gitignore @@ -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* diff --git a/README.md b/README.md new file mode 100644 index 0000000..9eb7ffe --- /dev/null +++ b/README.md @@ -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 +} +```READMEEOF \ No newline at end of file diff --git a/app/api/analyse/route.js b/app/api/analyse/route.js new file mode 100644 index 0000000..31ac1e1 --- /dev/null +++ b/app/api/analyse/route.js @@ -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 } + ); + } +} diff --git a/app/api/health/route.js b/app/api/health/route.js new file mode 100644 index 0000000..04a63d6 --- /dev/null +++ b/app/api/health/route.js @@ -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 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/app/layout.jsx b/app/layout.jsx new file mode 100644 index 0000000..b601b1b --- /dev/null +++ b/app/layout.jsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.jsx b/app/page.jsx new file mode 100644 index 0000000..8c44694 --- /dev/null +++ b/app/page.jsx @@ -0,0 +1,15 @@ +import ScenarioForm from "@/components/scenario-form"; + +export default function Home() { + return ( +
+

Confidence Engine

+

+ 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. +

+ +
+ ); +} diff --git a/components/diagnostics-view.jsx b/components/diagnostics-view.jsx new file mode 100644 index 0000000..535c4e4 --- /dev/null +++ b/components/diagnostics-view.jsx @@ -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 ( +
+ {labels[status] || status} +
+ ); +}; + +export default function DiagnosticsView({ result }) { + const metrics = [ + { label: "Model", value: result.modelName || "?" }, + { label: "Duration", value: result.responseDurationMs != null ? `${result.responseDurationMs}ms` : "?" }, + { label: "Validation", value: }, + ]; + + return ( +
+

Diagnostics

+
+ {metrics.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {result.rawResponse && ( +
+ + View raw model response + +
+            {result.rawResponse}
+          
+
+ )} +
+ ); +} diff --git a/components/reconstruction-view.jsx b/components/reconstruction-view.jsx new file mode 100644 index 0000000..67e8034 --- /dev/null +++ b/components/reconstruction-view.jsx @@ -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 }) => ( + + {level} + +); + +function ItemList({ items, renderExtra }) { + if (!items?.length) return

None identified

; + + return ( +
    + {items.map((item) => ( +
  • +
    + #{item.id} + +
    +

    {item.description}

    + {renderExtra && renderExtra(item)} +
  • + ))} +
+ ); +} + +export default function ReconstructionView({ reconstruction, partial }) { + if (partial) { + return ( +
+ ⚠ Partial result — some fields failed validation. Showing what was accepted. +
+ ); + } + + const categories = Object.entries(categoryLabels).map(([key, label]) => ({ + key, + label, + items: reconstruction[key], + })); + + return ( +
+

Reconstruction

+ {categories.map(({ key, label, items }) => ( +
+

{label}

+ +
+ ))} +
+ ); +} diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx new file mode 100644 index 0000000..85bdd4c --- /dev/null +++ b/components/scenario-form.jsx @@ -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 ( +
+
+