diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b289079
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.ruff_cache/
+.venv/
+dist/
+build/
+*.egg-info/
+# Frontend
+frontend/node_modules/
+frontend/dist/
+frontend/*.tsbuildinfo
+frontend/vite.config.js
+frontend/vite.config.d.ts
diff --git a/README.md b/README.md
index ac9d1e4..298121a 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,9 @@
**Real-time claim verification and evidence analysis for streaming information.**
-TruthStream continuously monitors incoming information streams, extracts factual claims, gathers supporting and contradicting evidence from trusted sources, and generates confidence scores with transparent explanations.
+TruthStream continuously monitors incoming information streams, extracts factual claims,
+gathers supporting and contradicting evidence from trusted sources, and generates confidence
+scores with transparent explanations.
Instead of asking:
@@ -12,180 +14,205 @@ TruthStream asks:
> "What evidence supports this claim, and how strong is that evidence?"
----
+## Current State
-## Why TruthStream?
-
-Information spreads faster than verification.
-
-News articles, social media posts, blogs, and public announcements can reach millions of people before fact-checkers have time to investigate.
-
-TruthStream helps bridge this gap by providing automated, evidence-based verification for information as it appears.
-
----
-
-## Features
-
-### Real-Time Stream Processing
-
-Monitor live information sources including:
-
-* News feeds
-* Social media streams
-* Public announcements
-* Government releases
-* Research publications
-
-### Claim Extraction
-
-Automatically identifies factual claims from incoming text using Natural Language Processing (NLP).
-
-### Evidence Retrieval
-
-Searches trusted sources for supporting and contradicting information.
+TruthStream is now an MVP product foundation. It includes:
-### Confidence Scoring
+- A deterministic claim extraction pipeline.
+- A pluggable evidence retrieval interface with demo and real provider modes.
+- Real evidence providers for Wikipedia, Crossref, and OpenAlex with in-memory request caching.
+- Evidence-weighted confidence scoring with structured evidence summaries.
+- A FastAPI service with `/health`, `/verify`, and `/verify/batch` endpoints.
+- A CLI for local claim verification with JSON, stdin, and pretty-print output modes.
+- A React + TypeScript + Tailwind frontend with Home, Verify, History, About, and Settings pages.
+- Automated tests for the core pipeline and API.
-Assigns a confidence score based on:
+The MVP intentionally avoids external model or search dependencies so it can run locally and in CI.
+Future implementations can replace the extractor and retriever with NLP models, search APIs,
+stream processors, and persistent storage without changing the public API contract.
-* Source credibility
-* Evidence consistency
-* Number of supporting sources
-* Number of contradicting sources
-* Information recency
-
-### Explainable Results
-
-Provides transparent reasoning instead of a simple true/false label.
-
-Example:
-
-Claim:
-"Country X has banned AI systems."
-
-Result:
-
-* Confidence Score: 12%
-* Supporting Sources: 0
-* Contradicting Sources: 5
-* Status: Likely False
-
-Reason:
-No official government announcement found. Multiple trusted news organizations report no such policy.
-
-### Analytics Dashboard
-
-Visualize:
+## Why TruthStream?
-* Trending claims
-* Verification status
-* Evidence graphs
-* Misinformation spikes
-* Source reliability metrics
+Information spreads faster than verification.
----
+News articles, social media posts, blogs, and public announcements can reach millions of people
+before fact-checkers have time to investigate. TruthStream helps bridge this gap by providing
+automated, evidence-based verification for information as it appears.
-## System Architecture
+## MVP Architecture
-Incoming Stream
-↓
+```text
+Incoming Text
+ ↓
Claim Extraction
-↓
+ ↓
Evidence Retrieval
-↓
-Source Evaluation
-↓
+ ↓
Confidence Scoring
-↓
+ ↓
Explanation Generation
-↓
-Dashboard & Alerts
-
----
-
-## Use Cases
-
-### Misinformation Detection
-
-Identify potentially misleading claims before they spread widely.
-
-### Journalism
-
-Assist reporters with rapid claim verification.
-
-### Research
-
-Study information propagation and verification dynamics.
-
-### Public Transparency
-
-Provide evidence-based verification of public statements and announcements.
-
-### Open Source Intelligence (OSINT)
-
-Track and evaluate claims across multiple public information channels.
-
----
-
-## Technology Stack
-
-Frontend:
-
-* React
-* TypeScript
-* Tailwind CSS
-
-Backend:
-
-* Python
-* FastAPI
-
-Data Streaming:
-
-* Apache Kafka
-* Apache Flink
-
-AI & NLP:
-
-* DeepSeek/OpenAI
-* Sentence Transformers
-* Named Entity Recognition
-* Claim Extraction Models
-
-Storage:
-
-* PostgreSQL
-* Redis
-
-Visualization:
-
-* Chart.js
-* D3.js
-
----
-
-## Future Roadmap
-
-* Multi-language verification
-* Real-time misinformation alerts
-* Knowledge graph integration
-* Cross-source contradiction detection
-* Source credibility learning system
-* Public API
-* Browser extension
-* Research paper support
-
----
+ ↓
+API / CLI Output
+```
-## Research Vision
+Core modules:
-TruthStream is not designed to determine absolute truth.
+- `truthstream.extraction`: deterministic claim and entity extraction heuristics.
+- `truthstream.evidence`: retriever protocols, composite retrieval, and demo fallback retriever.
+- `truthstream.providers`: cached Wikipedia, Crossref, and OpenAlex provider integrations.
+- `truthstream.settings`: runtime selection for demo or real evidence modes.
+- `truthstream.scoring`: source-credibility and recency weighted confidence scoring.
+- `truthstream.pipeline`: orchestration layer that composes extraction, retrieval, scoring, and explanation.
+- `truthstream.api`: FastAPI application.
+- `truthstream.cli`: command-line entrypoint.
-Its goal is to provide transparent, evidence-based confidence assessments by continuously analyzing information from multiple independent sources.
+## Quickstart
-The focus is on verifiability, evidence strength, and explainability rather than binary judgments.
+### 1. Create a virtual environment
----
+```bash
+python -m venv .venv
+source .venv/bin/activate
+```
+
+### 2. Install dependencies
+
+```bash
+pip install -e '.[dev]'
+```
+
+### 3. Run tests
+
+```bash
+pytest
+ruff check .
+```
+
+### 4. Run the API
+
+```bash
+uvicorn truthstream.api:app --reload
+```
+
+By default the API uses deterministic demo evidence for offline development. To enable real
+Wikipedia, Crossref, and OpenAlex providers, set:
+
+```bash
+TRUTHSTREAM_EVIDENCE_MODE=real uvicorn truthstream.api:app --reload
+```
+
+Then verify text:
+
+```bash
+curl -X POST http://127.0.0.1:8000/verify \
+ -H 'Content-Type: application/json' \
+ -d '{"text":"Country X has banned AI systems."}'
+```
+
+Batch verification is also available:
+
+```bash
+curl -X POST http://127.0.0.1:8000/verify/batch \
+ -H 'Content-Type: application/json' \
+ -d '[{"text":"Country X has banned AI systems."},{"text":"The moon has a new public library."}]'
+```
+
+### 5. Run the frontend
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Open http://127.0.0.1:5173. The Vite dev server proxies `/api` requests to the FastAPI backend by default.
+
+### 6. Run the CLI
+
+```bash
+truthstream "Country X has banned AI systems."
+truthstream --pretty "Country X has banned AI systems."
+truthstream --evidence real --pretty "Recent research on multilingual misinformation is rising."
+echo "Country X has banned AI systems." | truthstream --pretty
+```
+
+## Frontend
+
+The frontend lives in `frontend/` and is built with React, TypeScript, Vite, and Tailwind CSS.
+It connects to the existing backend API instead of replacing it. The Verify page posts claims to
+`/verify`, displays loading and error states, renders a confidence meter, separates supporting,
+contradicting, and neutral evidence, links to sources, and stores optional local history.
+
+## Evidence Provider System
+
+TruthStream now separates retrieval orchestration from source-specific integrations:
+
+- `EvidenceProvider` defines the provider contract.
+- `CachedEvidenceProvider` adds TTL caching around any provider.
+- `CompositeEvidenceRetriever` queries multiple providers and keeps working if one provider fails.
+- `WikipediaEvidenceProvider` retrieves encyclopedia search results.
+- `CrossrefEvidenceProvider` retrieves scholarly work metadata.
+- `OpenAlexEvidenceProvider` retrieves open scholarly graph results.
+
+Remote provider evidence is returned as neutral evidence candidates. This keeps retrieval
+deterministic and leaves support/contradiction classification to the verification/ranking layer.
+The original in-memory retriever remains available for tests, demos, and offline development.
+
+## Example API Response
+
+```json
+[
+ {
+ "claim": {
+ "text": "Country X has banned AI systems.",
+ "entities": ["Country X", "AI"]
+ },
+ "confidence_score": 0.0,
+ "status": "likely_false",
+ "supporting_evidence": [],
+ "contradicting_evidence": [
+ {
+ "source": {
+ "name": "Official Government Registry",
+ "url": null,
+ "credibility": 0.95
+ },
+ "title": "No nationwide AI ban has been issued",
+ "snippet": "The registry lists AI safety guidance but no blanket ban on AI systems.",
+ "stance": "contradicts",
+ "recency_score": 1.0
+ }
+ ],
+ "neutral_evidence": [],
+ "explanation": "Confidence is 0% with stronger contradicting evidence, based on 0 supporting item(s) from 0 source(s) and 2 contradicting item(s) from 2 source(s).",
+ "evidence_summary": {
+ "supporting_count": 0,
+ "contradicting_count": 2,
+ "neutral_count": 0,
+ "supporting_weight": 0,
+ "contradicting_weight": 1.724
+ }
+ }
+]
+```
+
+## Long-Term Vision
+
+TruthStream is not designed to determine absolute truth. Its goal is to provide transparent,
+evidence-based confidence assessments by continuously analyzing information from multiple
+independent sources.
+
+Planned capabilities include:
+
+- Multi-language verification.
+- Real-time misinformation alerts.
+- Stream ingestion using Kafka or equivalent event queues.
+- Knowledge graph integration.
+- Cross-source contradiction detection.
+- Source credibility learning.
+- Public API and dashboard.
+- Browser extension.
+- Research paper support.
## License
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..fad2d5e
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,32 @@
+# TruthStream Frontend
+
+React + TypeScript + Tailwind website for the TruthStream verification API.
+
+## Local development
+
+From the repository root, start the backend:
+
+```bash
+uvicorn truthstream.api:app --reload
+```
+
+In a second terminal:
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Open http://127.0.0.1:5173.
+
+The frontend defaults to the Vite `/api` proxy, which forwards requests to `http://127.0.0.1:8000`.
+You can change the API base URL in the Settings page.
+
+## Pages
+
+- Home: product landing page.
+- Verify: claim input, loading state, confidence meter, evidence cards, and errors.
+- History: locally saved verification results.
+- About: explanation of the verification flow.
+- Settings: API base URL, dark mode, and local history preferences.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..4816410
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,26 @@
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ },
+ }
+);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..3a6c67a
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ TruthStream
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..bffa3cd
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "truthstream-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --host 0.0.0.0",
+ "lint": "eslint ."
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^6.0.5",
+ "typescript": "^5.7.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "lucide-react": "^0.468.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.17.0",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@types/node": "^22.10.2",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react-hooks": "^5.1.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "globals": "^15.14.0",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript-eslint": "^8.18.2"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..cd54b4b
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Layout } from './components/Layout';
+import { About } from './pages/About';
+import { History } from './pages/History';
+import { Home } from './pages/Home';
+import { Settings } from './pages/Settings';
+import { Verify } from './pages/Verify';
+import { clearHistory, loadHistory, loadSettings, saveHistory, saveSettings } from './lib/storage';
+import type { HistoryItem, Settings as SettingsType } from './types/api';
+
+type Page = 'home' | 'verify' | 'history' | 'about' | 'settings';
+
+export function App() {
+ const [page, setPage] = useState('home');
+ const [settings, setSettings] = useState(() => loadSettings());
+ const [history, setHistory] = useState(() => loadHistory());
+
+ useEffect(() => {
+ const handler = (event: Event) => {
+ const nextPage = (event as CustomEvent).detail;
+ setPage(nextPage);
+ };
+ window.addEventListener('truthstream:navigate', handler);
+ return () => window.removeEventListener('truthstream:navigate', handler);
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', settings.darkMode);
+ saveSettings(settings);
+ }, [settings]);
+
+ useEffect(() => {
+ saveHistory(history);
+ }, [history]);
+
+ const content = useMemo(() => {
+ switch (page) {
+ case 'verify':
+ return (
+ setHistory((items) => [item, ...items].slice(0, 25))}
+ settings={settings}
+ />
+ );
+ case 'history':
+ return { clearHistory(); setHistory([]); }} />;
+ case 'about':
+ return ;
+ case 'settings':
+ return ;
+ default:
+ return setPage('verify')} />;
+ }
+ }, [history, page, settings]);
+
+ return {content} ;
+}
diff --git a/frontend/src/components/ConfidenceMeter.tsx b/frontend/src/components/ConfidenceMeter.tsx
new file mode 100644
index 0000000..7639122
--- /dev/null
+++ b/frontend/src/components/ConfidenceMeter.tsx
@@ -0,0 +1,31 @@
+import type { VerificationStatus } from '../types/api';
+import { statusLabel, statusTone } from '../lib/format';
+
+interface ConfidenceMeterProps {
+ confidence: number;
+ status: VerificationStatus;
+}
+
+export function ConfidenceMeter({ confidence, status }: ConfidenceMeterProps) {
+ const percent = Math.round(confidence * 100);
+
+ return (
+
+
+
+
Confidence
+
{percent}%
+
+
+ {statusLabel(status)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/EvidenceCard.tsx b/frontend/src/components/EvidenceCard.tsx
new file mode 100644
index 0000000..f5d0d01
--- /dev/null
+++ b/frontend/src/components/EvidenceCard.tsx
@@ -0,0 +1,34 @@
+import type { Evidence } from '../types/api';
+
+interface EvidenceCardProps {
+ evidence: Evidence;
+}
+
+export function EvidenceCard({ evidence }: EvidenceCardProps) {
+ const tone =
+ evidence.stance === 'supports'
+ ? 'border-emerald-500/40 bg-emerald-500/10'
+ : evidence.stance === 'contradicts'
+ ? 'border-rose-500/40 bg-rose-500/10'
+ : 'border-sky-500/30 bg-sky-500/10';
+
+ return (
+
+
+
{evidence.title}
+
+ {evidence.stance}
+
+
+ {evidence.snippet}
+
+
{evidence.source.name} · credibility {Math.round(evidence.source.credibility * 100)}%
+ {evidence.source.url ? (
+
+ Open source ↗
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..11aca10
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from 'react';
+import { Link } from './Link';
+
+interface LayoutProps {
+ activePage: string;
+ children: ReactNode;
+}
+
+const navItems = [
+ { id: 'home', label: 'Home' },
+ { id: 'verify', label: 'Verify' },
+ { id: 'history', label: 'History' },
+ { id: 'about', label: 'About' },
+ { id: 'settings', label: 'Settings' },
+];
+
+export function Layout({ activePage, children }: LayoutProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Link.tsx b/frontend/src/components/Link.tsx
new file mode 100644
index 0000000..5e28691
--- /dev/null
+++ b/frontend/src/components/Link.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from 'react';
+
+interface LinkProps {
+ active: boolean;
+ children: ReactNode;
+ page: string;
+}
+
+export function Link({ active, children, page }: LinkProps) {
+ return (
+ window.dispatchEvent(new CustomEvent('truthstream:navigate', { detail: page }))}
+ type="button"
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ResultPanel.tsx b/frontend/src/components/ResultPanel.tsx
new file mode 100644
index 0000000..d93726a
--- /dev/null
+++ b/frontend/src/components/ResultPanel.tsx
@@ -0,0 +1,56 @@
+import { ConfidenceMeter } from './ConfidenceMeter';
+import { EvidenceCard } from './EvidenceCard';
+import type { VerificationResult } from '../types/api';
+
+interface ResultPanelProps {
+ result: VerificationResult;
+}
+
+export function ResultPanel({ result }: ResultPanelProps) {
+ const groups = [
+ { title: 'Supporting evidence', items: result.supporting_evidence },
+ { title: 'Contradicting evidence', items: result.contradicting_evidence },
+ { title: 'Neutral evidence', items: result.neutral_evidence },
+ ];
+
+ return (
+
+
+
+
Claim
+
{result.claim.text}
+
{result.explanation}
+
+
+
+
+
+
+ {groups.map((group) => (
+
+
{group.title}
+ {group.items.length ? (
+
+ {group.items.map((item, index) => (
+
+ ))}
+
+ ) : (
+
+ No {group.title.toLowerCase()} found for this claim.
+
+ )}
+
+ ))}
+
+ );
+}
+
+function Metric({ label, value }: { label: string; value: number }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..35363df
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,25 @@
+import type { VerificationResult } from '../types/api';
+
+export async function verifyClaim(apiBaseUrl: string, text: string): Promise {
+ const response = await fetch(`${apiBaseUrl}/verify`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text }),
+ });
+
+ if (!response.ok) {
+ const details = await safeError(response);
+ throw new Error(details || `Verification failed with status ${response.status}`);
+ }
+
+ return (await response.json()) as VerificationResult[];
+}
+
+async function safeError(response: Response): Promise {
+ try {
+ const body = await response.json();
+ return typeof body.detail === 'string' ? body.detail : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..9c6e054
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -0,0 +1,18 @@
+import type { VerificationStatus } from '../types/api';
+
+export function statusLabel(status: VerificationStatus): string {
+ return status.replace(/_/g, ' ').replace(/^\w/, (match: string) => match.toUpperCase());
+}
+
+export function statusTone(status: VerificationStatus): string {
+ switch (status) {
+ case 'likely_true':
+ return 'text-emerald-400 bg-emerald-500/10 ring-emerald-500/30';
+ case 'likely_false':
+ return 'text-rose-400 bg-rose-500/10 ring-rose-500/30';
+ case 'mixed':
+ return 'text-amber-300 bg-amber-500/10 ring-amber-500/30';
+ default:
+ return 'text-sky-300 bg-sky-500/10 ring-sky-500/30';
+ }
+}
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
new file mode 100644
index 0000000..8bda5ba
--- /dev/null
+++ b/frontend/src/lib/storage.ts
@@ -0,0 +1,44 @@
+import type { HistoryItem, Settings } from '../types/api';
+
+const SETTINGS_KEY = 'truthstream.settings';
+const HISTORY_KEY = 'truthstream.history';
+
+export const defaultSettings: Settings = {
+ apiBaseUrl: '/api',
+ darkMode: true,
+ saveHistory: true,
+};
+
+export function loadSettings(): Settings {
+ const raw = localStorage.getItem(SETTINGS_KEY);
+ if (!raw) return defaultSettings;
+
+ try {
+ return { ...defaultSettings, ...JSON.parse(raw) };
+ } catch {
+ return defaultSettings;
+ }
+}
+
+export function saveSettings(settings: Settings): void {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+}
+
+export function loadHistory(): HistoryItem[] {
+ const raw = localStorage.getItem(HISTORY_KEY);
+ if (!raw) return [];
+
+ try {
+ return JSON.parse(raw) as HistoryItem[];
+ } catch {
+ return [];
+ }
+}
+
+export function saveHistory(history: HistoryItem[]): void {
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, 25)));
+}
+
+export function clearHistory(): void {
+ localStorage.removeItem(HISTORY_KEY);
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..ed0dc63
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx
new file mode 100644
index 0000000..37a2b65
--- /dev/null
+++ b/frontend/src/pages/About.tsx
@@ -0,0 +1,31 @@
+export function About() {
+ return (
+
+
+
About
+
TruthStream explains evidence instead of pretending to know absolute truth.
+
+ The goal is to help journalists, researchers, and curious readers rapidly inspect what sources say about a claim.
+
+
+
+ {[
+ ['1', 'Extract claims', 'The backend identifies factual claims from submitted text.'],
+ ['2', 'Retrieve evidence', 'Evidence providers collect supporting, contradicting, or neutral source material.'],
+ ['3', 'Score confidence', 'The verification engine weighs source credibility, recency, and stance.'],
+ ['4', 'Show the work', 'The UI displays confidence, evidence cards, source links, and explanations.'],
+ ].map(([step, title, body]) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx
new file mode 100644
index 0000000..7558f82
--- /dev/null
+++ b/frontend/src/pages/History.tsx
@@ -0,0 +1,44 @@
+import { ResultPanel } from '../components/ResultPanel';
+import type { HistoryItem } from '../types/api';
+
+interface HistoryProps {
+ history: HistoryItem[];
+ onClear: () => void;
+}
+
+export function History({ history, onClear }: HistoryProps) {
+ return (
+
+
+
+
History
+
Recent verifications
+
+
+ Clear history
+
+
+ {history.length ? (
+
+ {history.map((item) => (
+
+
+
{new Date(item.createdAt).toLocaleString()}
+
{item.claimText}
+
+ {item.results.map((result, index) => (
+
+ ))}
+
+ ))}
+
+ ) : (
+
+
🕓
+
No saved verifications yet.
+
Run a claim from the Verify page to build your local history.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx
new file mode 100644
index 0000000..72e8195
--- /dev/null
+++ b/frontend/src/pages/Home.tsx
@@ -0,0 +1,47 @@
+interface HomeProps {
+ onVerify: () => void;
+}
+
+export function Home({ onVerify }: HomeProps) {
+ return (
+
+
+
+ Evidence-centered verification for fast-moving information
+
+
+ Verify claims with sources, confidence, and context.
+
+
+ TruthStream turns a claim into a transparent evidence report. It shows what supports the claim, what contradicts it, and how confident the system is.
+
+
+
+ Verify a claim
+
+ window.dispatchEvent(new CustomEvent('truthstream:navigate', { detail: 'about' }))} type="button">
+ Learn how it works
+
+
+
+
+
+
Try typing
+
“India banned AI”
+
+
+ {[
+ ['Evidence', '3 sources'],
+ ['Confidence', '72%'],
+ ['Status', 'Mixed'],
+ ].map(([label, value]) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
new file mode 100644
index 0000000..d1ebc69
--- /dev/null
+++ b/frontend/src/pages/Settings.tsx
@@ -0,0 +1,53 @@
+import type { Settings as SettingsType } from '../types/api';
+
+interface SettingsProps {
+ settings: SettingsType;
+ onSettings: (settings: SettingsType) => void;
+}
+
+export function Settings({ settings, onSettings }: SettingsProps) {
+ return (
+
+
+
Settings
+
Configure your TruthStream workspace.
+
+
+
API base URL
+
onSettings({ ...settings, apiBaseUrl: event.target.value })}
+ value={settings.apiBaseUrl}
+ />
+
Use /api with the Vite proxy, or point directly to your FastAPI server.
+
+ onSettings({ ...settings, darkMode: checked })}
+ />
+ onSettings({ ...settings, saveHistory: checked })}
+ />
+
+ );
+}
+
+function Toggle({ checked, description, label, onChange }: { checked: boolean; description: string; label: string; onChange: (checked: boolean) => void }) {
+ return (
+
+
+
{label}
+
{description}
+
+
onChange(!checked)} type="button">
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Verify.tsx b/frontend/src/pages/Verify.tsx
new file mode 100644
index 0000000..10dc623
--- /dev/null
+++ b/frontend/src/pages/Verify.tsx
@@ -0,0 +1,87 @@
+import { useState } from 'react';
+import { verifyClaim } from '../lib/api';
+import type { HistoryItem, Settings, VerificationResult } from '../types/api';
+import { ResultPanel } from '../components/ResultPanel';
+
+interface VerifyProps {
+ settings: Settings;
+ onHistoryItem: (item: HistoryItem) => void;
+}
+
+export function Verify({ settings, onHistoryItem }: VerifyProps) {
+ const [text, setText] = useState('');
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function submit() {
+ if (!text.trim()) {
+ setError('Enter a claim before verifying.');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+ try {
+ const nextResults = await verifyClaim(settings.apiBaseUrl, text.trim());
+ setResults(nextResults);
+ if (settings.saveHistory) {
+ onHistoryItem({
+ id: crypto.randomUUID(),
+ claimText: text.trim(),
+ createdAt: new Date().toISOString(),
+ results: nextResults,
+ });
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Verification failed.');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+ Verify
+ What should we check?
+
+
+ {results.length ? (
+
+ {results.map((result, index) => (
+
+ ))}
+
+ ) : (
+
+
+
🔎
+
Evidence results will appear here.
+
Submit a claim to see confidence, evidence cards, source links, and the explanation generated by the backend.
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..052785e
--- /dev/null
+++ b/frontend/src/styles.css
@@ -0,0 +1,20 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color-scheme: light;
+}
+
+:root.dark {
+ color-scheme: dark;
+}
+
+body {
+ margin: 0;
+}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
new file mode 100644
index 0000000..82f51db
--- /dev/null
+++ b/frontend/src/types/api.ts
@@ -0,0 +1,57 @@
+export type EvidenceStance = 'supports' | 'contradicts' | 'neutral';
+export type VerificationStatus =
+ | 'likely_true'
+ | 'mixed'
+ | 'insufficient_evidence'
+ | 'likely_false';
+
+export interface Source {
+ name: string;
+ url: string | null;
+ credibility: number;
+}
+
+export interface Evidence {
+ source: Source;
+ title: string;
+ snippet: string;
+ stance: EvidenceStance;
+ recency_score: number;
+}
+
+export interface Claim {
+ text: string;
+ entities: string[];
+}
+
+export interface EvidenceSummary {
+ supporting_count: number;
+ contradicting_count: number;
+ neutral_count: number;
+ supporting_weight: number;
+ contradicting_weight: number;
+}
+
+export interface VerificationResult {
+ claim: Claim;
+ confidence_score: number;
+ status: VerificationStatus;
+ supporting_evidence: Evidence[];
+ contradicting_evidence: Evidence[];
+ neutral_evidence: Evidence[];
+ explanation: string;
+ evidence_summary: EvidenceSummary;
+}
+
+export interface HistoryItem {
+ id: string;
+ claimText: string;
+ createdAt: string;
+ results: VerificationResult[];
+}
+
+export interface Settings {
+ apiBaseUrl: string;
+ darkMode: boolean;
+ saveHistory: boolean;
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..3715c55
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,19 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: 'class',
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ extend: {
+ colors: {
+ ink: '#07111f',
+ signal: '#38bdf8',
+ truth: '#22c55e',
+ warning: '#f97316',
+ },
+ boxShadow: {
+ glow: '0 0 40px rgba(56, 189, 248, 0.25)',
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..2f27c13
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2020"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..16dfedc
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..6c8de7f
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,16 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: process.env.VITE_TRUTHSTREAM_API_URL ?? 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ rewrite: (path: string) => path.replace(/^\/api/, ''),
+ },
+ },
+ },
+});
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8d9bc73
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,41 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "truthstream"
+version = "0.1.0"
+description = "Evidence-centered claim verification MVP for streaming information."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "MIT" }
+authors = [{ name = "TruthStream Contributors" }]
+dependencies = [
+ "fastapi>=0.111,<1.0",
+ "pydantic>=2.7,<3.0",
+ "uvicorn[standard]>=0.30,<1.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.2,<9.0",
+ "httpx>=0.27,<1.0",
+ "ruff>=0.5,<1.0",
+]
+
+[project.scripts]
+truthstream = "truthstream.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
+
+[tool.ruff]
+line-length = 100
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
diff --git a/src/truthstream/__init__.py b/src/truthstream/__init__.py
new file mode 100644
index 0000000..0bbeb83
--- /dev/null
+++ b/src/truthstream/__init__.py
@@ -0,0 +1,5 @@
+"""TruthStream MVP package."""
+
+from truthstream.pipeline import VerificationPipeline
+
+__all__ = ["VerificationPipeline"]
diff --git a/src/truthstream/api.py b/src/truthstream/api.py
new file mode 100644
index 0000000..558f6bd
--- /dev/null
+++ b/src/truthstream/api.py
@@ -0,0 +1,51 @@
+"""FastAPI application for TruthStream."""
+
+try:
+ from fastapi import FastAPI, HTTPException
+except ImportError as exc: # pragma: no cover - exercised only when optional deps are missing.
+ raise RuntimeError(
+ "The API requires FastAPI. Install API dependencies with `pip install -e '.[dev]'`."
+ ) from exc
+
+from truthstream.models import StreamItem
+from truthstream.pipeline import VerificationPipeline
+from truthstream.settings import build_evidence_retriever
+
+app = FastAPI(
+ title="TruthStream API",
+ version="0.1.0",
+ description="MVP API for transparent, evidence-centered claim verification.",
+)
+pipeline = VerificationPipeline(retriever=build_evidence_retriever())
+
+
+@app.get("/health")
+def health() -> dict[str, str]:
+ """Return service health."""
+
+ return {"status": "ok"}
+
+
+@app.post("/verify")
+def verify(item: dict[str, str]) -> list[dict[str, object]]:
+ """Verify claims from an incoming stream item."""
+
+ return _verify_item(item)
+
+
+@app.post("/verify/batch")
+def verify_batch(items: list[dict[str, str]]) -> list[list[dict[str, object]]]:
+ """Verify multiple incoming stream items in request order."""
+
+ if not items:
+ raise HTTPException(status_code=400, detail="At least one item is required.")
+ return [_verify_item(item) for item in items]
+
+
+def _verify_item(item: dict[str, str]) -> list[dict[str, object]]:
+ text = item.get("text", "").strip()
+ if not text:
+ raise HTTPException(status_code=400, detail="Item text is required.")
+
+ results = pipeline.verify(StreamItem(text=text, source=item.get("source")))
+ return [result.to_dict() for result in results]
diff --git a/src/truthstream/cli.py b/src/truthstream/cli.py
new file mode 100644
index 0000000..f73672b
--- /dev/null
+++ b/src/truthstream/cli.py
@@ -0,0 +1,44 @@
+"""Command-line entrypoint for local TruthStream verification."""
+
+import argparse
+import json
+import sys
+
+from truthstream.models import StreamItem
+from truthstream.pipeline import VerificationPipeline
+from truthstream.settings import build_evidence_retriever
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Verify claims in a text snippet.")
+ parser.add_argument("text", nargs="?", help="Text to analyze for factual claims.")
+ parser.add_argument(
+ "--evidence",
+ choices=("demo", "real"),
+ default="demo",
+ help="Evidence backend to use. Choose real for Wikipedia, Crossref, and OpenAlex.",
+ )
+ parser.add_argument(
+ "--pretty",
+ action="store_true",
+ help="Print a compact human-readable summary instead of JSON.",
+ )
+ args = parser.parse_args()
+
+ text = args.text or sys.stdin.read().strip()
+ retriever = build_evidence_retriever(args.evidence)
+ results = VerificationPipeline(retriever=retriever).verify(StreamItem(text=text))
+
+ if args.pretty:
+ for result in results:
+ print(f"Claim: {result.claim.text}")
+ print(f"Status: {result.status}")
+ print(f"Confidence: {result.confidence_score:.0%}")
+ print(f"Explanation: {result.explanation}")
+ return
+
+ print(json.dumps([result.to_dict() for result in results], indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/truthstream/evidence.py b/src/truthstream/evidence.py
new file mode 100644
index 0000000..bc5807c
--- /dev/null
+++ b/src/truthstream/evidence.py
@@ -0,0 +1,131 @@
+"""Evidence retrieval abstractions and an in-memory MVP implementation."""
+
+from collections.abc import Iterable
+from typing import Protocol
+
+from truthstream.models import Claim, Evidence, EvidenceStance, Source
+from truthstream.providers import EvidenceProvider, default_providers
+
+
+class EvidenceRetriever(Protocol):
+ """Retrieve evidence for a claim."""
+
+ def retrieve(self, claim: Claim) -> list[Evidence]:
+ """Return evidence related to the supplied claim."""
+
+
+class CompositeEvidenceRetriever:
+ """Retrieve evidence from multiple providers while tolerating provider failures."""
+
+ def __init__(self, providers: Iterable[EvidenceProvider], max_results: int = 12) -> None:
+ self._providers = list(providers)
+ self._max_results = max_results
+
+ def retrieve(self, claim: Claim) -> list[Evidence]:
+ evidence: list[Evidence] = []
+ for provider in self._providers:
+ try:
+ evidence.extend(provider.fetch(claim))
+ except Exception:
+ continue
+ return evidence[: self._max_results]
+
+
+class InMemoryEvidenceRetriever:
+ """Simple keyword-based evidence retriever for local demos and tests."""
+
+ def __init__(
+ self, evidence: Iterable[Evidence] | None = None, min_overlap: int = 2
+ ) -> None:
+ self._evidence = list(evidence or default_evidence())
+ self._min_overlap = min_overlap
+
+ def retrieve(self, claim: Claim) -> list[Evidence]:
+ claim_tokens = _tokens(claim.text)
+ ranked = []
+ for evidence in self._evidence:
+ searchable = f"{evidence.title} {evidence.snippet} {evidence.source.name}"
+ overlap = len(claim_tokens & _tokens(searchable))
+ if overlap >= self._min_overlap:
+ ranked.append((overlap, evidence))
+ ranked.sort(key=lambda item: item[0], reverse=True)
+ return [evidence for _, evidence in ranked[:8]]
+
+
+def default_evidence() -> list[Evidence]:
+ """Seed evidence for the MVP demo dataset."""
+
+ official = Source(name="Official Government Registry", credibility=0.95)
+ newsroom = Source(name="Trusted News Wire", credibility=0.86)
+ research = Source(name="Research Monitor", credibility=0.82)
+ return [
+ Evidence(
+ source=official,
+ title="No nationwide AI ban has been issued",
+ snippet="The registry lists AI safety guidance but no blanket ban on AI systems.",
+ stance=EvidenceStance.CONTRADICTS,
+ ),
+ Evidence(
+ source=newsroom,
+ title="Officials deny reports of an AI systems ban",
+ snippet="Multiple agencies said current policy focuses on audits, not a ban.",
+ stance=EvidenceStance.CONTRADICTS,
+ recency_score=0.9,
+ ),
+ Evidence(
+ source=research,
+ title="Research publications on multilingual misinformation are rising",
+ snippet="Recent papers report increased multilingual misinformation monitoring needs.",
+ stance=EvidenceStance.SUPPORTS,
+ recency_score=0.8,
+ ),
+ ]
+
+
+def _tokens(text: str) -> set[str]:
+ stopwords = {
+ "the",
+ "and",
+ "has",
+ "have",
+ "was",
+ "were",
+ "with",
+ "from",
+ "that",
+ "this",
+ "but",
+ "not",
+ "new",
+ "public",
+ }
+ return {
+ _stem(token)
+ for token in "".join(ch.lower() if ch.isalnum() else " " for ch in text).split()
+ if len(token) > 2 and token not in stopwords
+ }
+
+
+def _stem(token: str) -> str:
+ """Apply tiny deterministic normalization for the MVP retriever."""
+
+ if token.endswith("ies") and len(token) > 4:
+ return f"{token[:-3]}y"
+ if token.endswith("ed") and len(token) > 4:
+ stem = token[:-2]
+ if len(stem) > 2 and stem[-1] == stem[-2]:
+ return stem[:-1]
+ return stem
+ if token.endswith("s") and len(token) > 4:
+ return token[:-1]
+ return token
+
+
+def real_evidence_retriever(max_results: int = 12) -> CompositeEvidenceRetriever:
+ """Build a retriever using the default real evidence providers.
+
+ This factory keeps the default pipeline backward compatible: existing callers still get the
+ deterministic in-memory retriever unless they explicitly opt into remote providers.
+ """
+
+ return CompositeEvidenceRetriever(default_providers(), max_results=max_results)
diff --git a/src/truthstream/extraction.py b/src/truthstream/extraction.py
new file mode 100644
index 0000000..41df912
--- /dev/null
+++ b/src/truthstream/extraction.py
@@ -0,0 +1,53 @@
+"""Lightweight claim extraction for the MVP.
+
+This module intentionally uses deterministic heuristics so the first version works
+without external model keys. It can later be replaced by an NLP model behind the
+same interface.
+"""
+
+import re
+
+from truthstream.models import Claim, StreamItem
+
+_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
+_ENTITY_RE = re.compile(r"\b[A-Z][A-Za-z0-9&.-]*(?:\s+[A-Z][A-Za-z0-9&.-]*)*\b")
+_FACTUAL_CUES = (
+ " is ",
+ " are ",
+ " was ",
+ " were ",
+ " has ",
+ " have ",
+ " will ",
+ " announced ",
+ " banned ",
+ " reported ",
+ " increased ",
+ " decreased ",
+)
+
+
+class ClaimExtractor:
+ """Extract concise factual claims from stream items."""
+
+ def extract(self, item: StreamItem) -> list[Claim]:
+ sentences = [part.strip() for part in _SENTENCE_RE.split(item.text) if part.strip()]
+ claims: list[Claim] = []
+
+ for sentence in sentences:
+ normalized = f" {sentence.lower()} "
+ if any(cue in normalized for cue in _FACTUAL_CUES):
+ claims.append(Claim(text=sentence, entities=self._entities(sentence)))
+
+ if not claims and item.text.strip():
+ claims.append(Claim(text=item.text.strip(), entities=self._entities(item.text)))
+
+ return claims
+
+ @staticmethod
+ def _entities(text: str) -> list[str]:
+ entities = []
+ for match in _ENTITY_RE.findall(text):
+ if match not in entities:
+ entities.append(match)
+ return entities
diff --git a/src/truthstream/models.py b/src/truthstream/models.py
new file mode 100644
index 0000000..ed208c7
--- /dev/null
+++ b/src/truthstream/models.py
@@ -0,0 +1,109 @@
+"""Domain models for TruthStream's evidence-centered verification pipeline."""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from enum import StrEnum
+
+
+class EvidenceStance(StrEnum):
+ """How a piece of evidence relates to a claim."""
+
+ SUPPORTS = "supports"
+ CONTRADICTS = "contradicts"
+ NEUTRAL = "neutral"
+
+
+class VerificationStatus(StrEnum):
+ """Human-readable confidence band for a verification result."""
+
+ LIKELY_TRUE = "likely_true"
+ MIXED = "mixed"
+ INSUFFICIENT_EVIDENCE = "insufficient_evidence"
+ LIKELY_FALSE = "likely_false"
+
+
+@dataclass(slots=True)
+class Source:
+ """Metadata for a source used during evidence retrieval."""
+
+ name: str
+ url: str | None = None
+ credibility: float = 1.0
+
+ def __post_init__(self) -> None:
+ _validate_range("credibility", self.credibility)
+
+
+@dataclass(slots=True)
+class Evidence:
+ """Evidence found for or against a claim."""
+
+ source: Source
+ title: str
+ snippet: str
+ stance: EvidenceStance
+ recency_score: float = 1.0
+
+ def __post_init__(self) -> None:
+ self.stance = EvidenceStance(self.stance)
+ _validate_range("recency_score", self.recency_score)
+
+
+@dataclass(slots=True)
+class Claim:
+ """A factual claim extracted from an incoming text item."""
+
+ text: str
+ entities: list[str] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class StreamItem:
+ """Incoming content to verify."""
+
+ text: str
+ source: str | None = None
+
+ def __post_init__(self) -> None:
+ if not self.text.strip():
+ raise ValueError("StreamItem.text must not be empty")
+
+
+@dataclass(slots=True)
+class EvidenceSummary:
+ """Aggregated evidence counts and weights for a verification result."""
+
+ supporting_count: int
+ contradicting_count: int
+ neutral_count: int
+ supporting_weight: float
+ contradicting_weight: float
+
+
+@dataclass(slots=True)
+class VerificationResult:
+ """Transparent verification output for a single claim."""
+
+ claim: Claim
+ confidence_score: float
+ status: VerificationStatus
+ supporting_evidence: list[Evidence]
+ contradicting_evidence: list[Evidence]
+ neutral_evidence: list[Evidence]
+ explanation: str
+ evidence_summary: EvidenceSummary
+
+ def __post_init__(self) -> None:
+ self.status = VerificationStatus(self.status)
+ _validate_range("confidence_score", self.confidence_score)
+
+ def to_dict(self) -> dict[str, object]:
+ """Return a JSON-serializable representation."""
+
+ return asdict(self)
+
+
+def _validate_range(field_name: str, value: float) -> None:
+ if not 0 <= value <= 1:
+ raise ValueError(f"{field_name} must be between 0 and 1")
diff --git a/src/truthstream/pipeline.py b/src/truthstream/pipeline.py
new file mode 100644
index 0000000..8f200ee
--- /dev/null
+++ b/src/truthstream/pipeline.py
@@ -0,0 +1,43 @@
+"""Composable verification pipeline for TruthStream."""
+
+from truthstream.evidence import EvidenceRetriever, InMemoryEvidenceRetriever
+from truthstream.extraction import ClaimExtractor
+from truthstream.models import EvidenceStance, StreamItem, VerificationResult
+from truthstream.scoring import score_evidence
+
+
+class VerificationPipeline:
+ """Extract claims, retrieve evidence, score confidence, and explain results."""
+
+ def __init__(
+ self,
+ extractor: ClaimExtractor | None = None,
+ retriever: EvidenceRetriever | None = None,
+ ) -> None:
+ self.extractor = extractor or ClaimExtractor()
+ self.retriever = retriever or InMemoryEvidenceRetriever()
+
+ def verify(self, item: StreamItem) -> list[VerificationResult]:
+ results: list[VerificationResult] = []
+ for claim in self.extractor.extract(item):
+ evidence = self.retriever.retrieve(claim)
+ confidence, status, explanation, summary = score_evidence(evidence)
+ results.append(
+ VerificationResult(
+ claim=claim,
+ confidence_score=round(confidence, 4),
+ status=status,
+ supporting_evidence=[
+ item for item in evidence if item.stance == EvidenceStance.SUPPORTS
+ ],
+ contradicting_evidence=[
+ item for item in evidence if item.stance == EvidenceStance.CONTRADICTS
+ ],
+ neutral_evidence=[
+ item for item in evidence if item.stance == EvidenceStance.NEUTRAL
+ ],
+ explanation=explanation,
+ evidence_summary=summary,
+ )
+ )
+ return results
diff --git a/src/truthstream/providers.py b/src/truthstream/providers.py
new file mode 100644
index 0000000..675820f
--- /dev/null
+++ b/src/truthstream/providers.py
@@ -0,0 +1,226 @@
+"""Modular evidence providers for real-world source integrations.
+
+Providers intentionally return neutral evidence. They retrieve potentially relevant,
+deterministic source material; support/contradiction classification can later be handled by a
+separate ranking or stance-detection component without changing the provider interface.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import Callable, Iterable
+from dataclasses import dataclass
+from typing import Any, Protocol
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+from truthstream.models import Claim, Evidence, EvidenceStance, Source
+
+JsonOpener = Callable[[str, dict[str, str], float], dict[str, Any]]
+
+
+class EvidenceProvider(Protocol):
+ """Fetch evidence candidates for a claim from one source family."""
+
+ name: str
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ """Return evidence candidates for the supplied claim."""
+
+
+@dataclass(slots=True)
+class ProviderConfig:
+ """Network and result-limit settings shared by HTTP evidence providers."""
+
+ timeout_seconds: float = 5.0
+ max_results: int = 5
+ user_agent: str = "TruthStream/0.1 (+https://github.com/truthstream)"
+
+
+@dataclass(slots=True)
+class CacheEntry:
+ """In-memory cache entry for provider responses."""
+
+ expires_at: float
+ evidence: list[Evidence]
+
+
+class CachedEvidenceProvider:
+ """TTL cache wrapper for provider responses."""
+
+ def __init__(self, provider: EvidenceProvider, ttl_seconds: int = 900) -> None:
+ self.provider = provider
+ self.name = provider.name
+ self._ttl_seconds = ttl_seconds
+ self._cache: dict[str, CacheEntry] = {}
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ cache_key = claim.text.strip().lower()
+ now = time.time()
+ cached = self._cache.get(cache_key)
+ if cached and cached.expires_at > now:
+ return list(cached.evidence)
+
+ evidence = self.provider.fetch(claim)
+ self._cache[cache_key] = CacheEntry(
+ expires_at=now + self._ttl_seconds,
+ evidence=list(evidence),
+ )
+ return evidence
+
+
+class WikipediaEvidenceProvider:
+ """Evidence provider backed by Wikipedia's public REST search endpoint."""
+
+ name = "wikipedia"
+ base_url = "https://en.wikipedia.org/w/rest.php/v1/search/page"
+
+ def __init__(
+ self,
+ config: ProviderConfig | None = None,
+ opener: JsonOpener | None = None,
+ ) -> None:
+ self.config = config or ProviderConfig()
+ self._opener = opener or _get_json
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ payload = self._opener(
+ f"{self.base_url}?{urlencode({'q': claim.text, 'limit': self.config.max_results})}",
+ {"User-Agent": self.config.user_agent},
+ self.config.timeout_seconds,
+ )
+ pages = payload.get("pages", [])
+ return [
+ Evidence(
+ source=Source(
+ name="Wikipedia",
+ url=f"https://en.wikipedia.org/wiki/{page.get('key', '')}",
+ credibility=0.72,
+ ),
+ title=str(page.get("title", "Wikipedia result")),
+ snippet=_clean_snippet(str(page.get("excerpt", ""))),
+ stance=EvidenceStance.NEUTRAL,
+ recency_score=0.7,
+ )
+ for page in pages[: self.config.max_results]
+ if page.get("title")
+ ]
+
+
+class CrossrefEvidenceProvider:
+ """Evidence provider backed by Crossref works search."""
+
+ name = "crossref"
+ base_url = "https://api.crossref.org/works"
+
+ def __init__(
+ self,
+ config: ProviderConfig | None = None,
+ opener: JsonOpener | None = None,
+ ) -> None:
+ self.config = config or ProviderConfig()
+ self._opener = opener or _get_json
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ payload = self._opener(
+ f"{self.base_url}?{urlencode({'query': claim.text, 'rows': self.config.max_results})}",
+ {"User-Agent": self.config.user_agent},
+ self.config.timeout_seconds,
+ )
+ items = payload.get("message", {}).get("items", [])
+ evidence = []
+ for item in items[: self.config.max_results]:
+ title = _first(item.get("title"), "Crossref work")
+ evidence.append(
+ Evidence(
+ source=Source(name="Crossref", url=item.get("URL"), credibility=0.84),
+ title=title,
+ snippet=_first(item.get("container-title"), "Scholarly metadata result."),
+ stance=EvidenceStance.NEUTRAL,
+ recency_score=0.75,
+ )
+ )
+ return evidence
+
+
+class OpenAlexEvidenceProvider:
+ """Evidence provider backed by OpenAlex works search."""
+
+ name = "openalex"
+ base_url = "https://api.openalex.org/works"
+
+ def __init__(
+ self,
+ config: ProviderConfig | None = None,
+ opener: JsonOpener | None = None,
+ ) -> None:
+ self.config = config or ProviderConfig()
+ self._opener = opener or _get_json
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ query = urlencode({"search": claim.text, "per-page": self.config.max_results})
+ payload = self._opener(
+ f"{self.base_url}?{query}",
+ {"User-Agent": self.config.user_agent},
+ self.config.timeout_seconds,
+ )
+ results = payload.get("results", [])
+ evidence = []
+ for item in results[: self.config.max_results]:
+ evidence.append(
+ Evidence(
+ source=Source(
+ name="OpenAlex",
+ url=item.get("id") or item.get("doi"),
+ credibility=0.83,
+ ),
+ title=str(item.get("display_name", "OpenAlex work")),
+ snippet=_openalex_snippet(item),
+ stance=EvidenceStance.NEUTRAL,
+ recency_score=0.75,
+ )
+ )
+ return evidence
+
+
+def default_providers(config: ProviderConfig | None = None) -> list[EvidenceProvider]:
+ """Return the default real evidence providers with caching enabled."""
+
+ provider_config = config or ProviderConfig()
+ providers: Iterable[EvidenceProvider] = (
+ WikipediaEvidenceProvider(provider_config),
+ CrossrefEvidenceProvider(provider_config),
+ OpenAlexEvidenceProvider(provider_config),
+ )
+ return [CachedEvidenceProvider(provider) for provider in providers]
+
+
+def _get_json(url: str, headers: dict[str, str], timeout_seconds: float) -> dict[str, Any]:
+ request = Request(url, headers=headers)
+ try:
+ with urlopen(request, timeout=timeout_seconds) as response: # noqa: S310 - trusted URLs.
+ return json.loads(response.read().decode("utf-8"))
+ except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
+ return {}
+
+
+def _first(value: Any, default: str) -> str:
+ if isinstance(value, list) and value:
+ return str(value[0])
+ if isinstance(value, str) and value:
+ return value
+ return default
+
+
+def _clean_snippet(snippet: str) -> str:
+ return snippet.replace("", "").replace(" ", "")
+
+
+def _openalex_snippet(item: dict[str, Any]) -> str:
+ publication_year = item.get("publication_year")
+ source = item.get("primary_location", {}).get("source", {}) or {}
+ source_name = source.get("display_name")
+ pieces = [str(piece) for piece in (publication_year, source_name) if piece]
+ return " | ".join(pieces) if pieces else "OpenAlex scholarly metadata result."
diff --git a/src/truthstream/scoring.py b/src/truthstream/scoring.py
new file mode 100644
index 0000000..0d3324a
--- /dev/null
+++ b/src/truthstream/scoring.py
@@ -0,0 +1,67 @@
+"""Confidence scoring for evidence-centered verification."""
+
+from truthstream.models import Evidence, EvidenceStance, EvidenceSummary, VerificationStatus
+
+
+def score_evidence(
+ evidence: list[Evidence],
+) -> tuple[float, VerificationStatus, str, EvidenceSummary]:
+ """Calculate a normalized confidence score, status, explanation, and evidence summary."""
+
+ supporting = [item for item in evidence if item.stance == EvidenceStance.SUPPORTS]
+ contradicting = [item for item in evidence if item.stance == EvidenceStance.CONTRADICTS]
+ neutral = [item for item in evidence if item.stance == EvidenceStance.NEUTRAL]
+
+ support_weight = _weight(supporting)
+ contradiction_weight = _weight(contradicting)
+ summary = EvidenceSummary(
+ supporting_count=len(supporting),
+ contradicting_count=len(contradicting),
+ neutral_count=len(neutral),
+ supporting_weight=round(support_weight, 4),
+ contradicting_weight=round(contradiction_weight, 4),
+ )
+ total = support_weight + contradiction_weight
+
+ if total == 0:
+ return (
+ 0.5,
+ VerificationStatus.INSUFFICIENT_EVIDENCE,
+ "No supporting or contradicting evidence was found in the configured sources.",
+ summary,
+ )
+
+ confidence = support_weight / total
+ status = _status(confidence, len(supporting), len(contradicting))
+ explanation = _explain(confidence, supporting, contradicting)
+ return confidence, status, explanation, summary
+
+
+def _weight(evidence: list[Evidence]) -> float:
+ return sum(item.source.credibility * item.recency_score for item in evidence)
+
+
+def _status(
+ confidence: float, supporting_count: int, contradicting_count: int
+) -> VerificationStatus:
+ if supporting_count == 0 and contradicting_count > 0:
+ return VerificationStatus.LIKELY_FALSE
+ if contradicting_count == 0 and supporting_count > 0:
+ return VerificationStatus.LIKELY_TRUE
+ if confidence >= 0.7:
+ return VerificationStatus.LIKELY_TRUE
+ if confidence <= 0.3:
+ return VerificationStatus.LIKELY_FALSE
+ return VerificationStatus.MIXED
+
+
+def _explain(confidence: float, supporting: list[Evidence], contradicting: list[Evidence]) -> str:
+ support_sources = {item.source.name for item in supporting}
+ contradiction_sources = {item.source.name for item in contradicting}
+ strongest_stance = "supporting" if confidence >= 0.5 else "contradicting"
+ return (
+ f"Confidence is {confidence:.0%} with stronger {strongest_stance} evidence, based on "
+ f"{len(supporting)} supporting item(s) from {len(support_sources)} source(s) and "
+ f"{len(contradicting)} contradicting item(s) from "
+ f"{len(contradiction_sources)} source(s)."
+ )
diff --git a/src/truthstream/settings.py b/src/truthstream/settings.py
new file mode 100644
index 0000000..ca3e83a
--- /dev/null
+++ b/src/truthstream/settings.py
@@ -0,0 +1,27 @@
+"""Runtime settings helpers for TruthStream."""
+
+from __future__ import annotations
+
+import os
+from typing import Literal
+
+from truthstream.evidence import (
+ EvidenceRetriever,
+ InMemoryEvidenceRetriever,
+ real_evidence_retriever,
+)
+
+EvidenceMode = Literal["demo", "real"]
+
+
+def build_evidence_retriever(mode: EvidenceMode | None = None) -> EvidenceRetriever:
+ """Build the configured evidence retriever.
+
+ `demo` preserves the deterministic v0.1 behavior. `real` enables the Wikipedia, Crossref,
+ and OpenAlex provider stack.
+ """
+
+ selected_mode = mode or os.getenv("TRUTHSTREAM_EVIDENCE_MODE", "demo").lower()
+ if selected_mode == "real":
+ return real_evidence_retriever()
+ return InMemoryEvidenceRetriever()
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..c1e82ef
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,61 @@
+import pytest
+
+pytest.importorskip("fastapi")
+
+
+def test_health_endpoint() -> None:
+ from fastapi.testclient import TestClient
+
+ from truthstream.api import app
+
+ client = TestClient(app)
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+
+def test_verify_endpoint() -> None:
+ from fastapi.testclient import TestClient
+
+ from truthstream.api import app
+
+ client = TestClient(app)
+ response = client.post("/verify", json={"text": "Country X has banned AI systems."})
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body[0]["claim"]["text"] == "Country X has banned AI systems."
+ assert body[0]["status"] == "likely_false"
+ assert body[0]["evidence_summary"]["contradicting_count"] == 2
+
+
+def test_verify_endpoint_rejects_empty_text() -> None:
+ from fastapi.testclient import TestClient
+
+ from truthstream.api import app
+
+ client = TestClient(app)
+ response = client.post("/verify", json={"text": " "})
+
+ assert response.status_code == 400
+
+
+def test_verify_batch_endpoint_preserves_order() -> None:
+ from fastapi.testclient import TestClient
+
+ from truthstream.api import app
+
+ client = TestClient(app)
+ response = client.post(
+ "/verify/batch",
+ json=[
+ {"text": "Country X has banned AI systems."},
+ {"text": "The moon has a new public library."},
+ ],
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body[0][0]["status"] == "likely_false"
+ assert body[1][0]["status"] == "insufficient_evidence"
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..a1a6475
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,22 @@
+from truthstream.models import StreamItem, VerificationStatus
+from truthstream.pipeline import VerificationPipeline
+
+
+def test_pipeline_contradicts_unsupported_ai_ban_claim() -> None:
+ results = VerificationPipeline().verify(StreamItem(text="Country X has banned AI systems."))
+
+ assert len(results) == 1
+ assert results[0].status == VerificationStatus.LIKELY_FALSE
+ assert results[0].confidence_score == 0
+ assert results[0].evidence_summary.contradicting_count == 2
+ assert results[0].evidence_summary.supporting_count == 0
+
+
+def test_pipeline_reports_insufficient_evidence_for_unknown_claim() -> None:
+ results = VerificationPipeline().verify(StreamItem(text="The moon has a new public library."))
+
+ assert len(results) == 1
+ assert results[0].status == VerificationStatus.INSUFFICIENT_EVIDENCE
+ assert results[0].confidence_score == 0.5
+ assert results[0].evidence_summary.supporting_weight == 0
+ assert results[0].evidence_summary.contradicting_weight == 0
diff --git a/tests/test_providers.py b/tests/test_providers.py
new file mode 100644
index 0000000..b02253e
--- /dev/null
+++ b/tests/test_providers.py
@@ -0,0 +1,119 @@
+from truthstream.evidence import CompositeEvidenceRetriever
+from truthstream.models import Claim, Evidence, EvidenceStance, Source
+from truthstream.providers import (
+ CachedEvidenceProvider,
+ CrossrefEvidenceProvider,
+ OpenAlexEvidenceProvider,
+ ProviderConfig,
+ WikipediaEvidenceProvider,
+)
+
+
+def test_wikipedia_provider_maps_search_results_to_neutral_evidence() -> None:
+ def opener(url: str, headers: dict[str, str], timeout: float) -> dict[str, object]:
+ assert "wikipedia" in url
+ assert headers["User-Agent"].startswith("TruthStream")
+ assert timeout == 1
+ return {"pages": [{"title": "Example", "key": "Example", "excerpt": "Result"}]}
+
+ provider = WikipediaEvidenceProvider(ProviderConfig(timeout_seconds=1), opener=opener)
+
+ evidence = provider.fetch(Claim(text="example claim"))
+
+ assert evidence[0].source.name == "Wikipedia"
+ assert evidence[0].stance == EvidenceStance.NEUTRAL
+ assert evidence[0].title == "Example"
+
+
+def test_crossref_provider_maps_work_metadata() -> None:
+ def opener(url: str, headers: dict[str, str], timeout: float) -> dict[str, object]:
+ return {
+ "message": {
+ "items": [
+ {
+ "title": ["Research result"],
+ "container-title": ["Journal"],
+ "URL": "https://doi.org/10.0000/example",
+ }
+ ]
+ }
+ }
+
+ evidence = CrossrefEvidenceProvider(opener=opener).fetch(Claim(text="research claim"))
+
+ assert evidence[0].source.name == "Crossref"
+ assert evidence[0].title == "Research result"
+ assert evidence[0].snippet == "Journal"
+
+
+def test_openalex_provider_maps_work_metadata() -> None:
+ def opener(url: str, headers: dict[str, str], timeout: float) -> dict[str, object]:
+ return {
+ "results": [
+ {
+ "display_name": "Open research result",
+ "id": "https://openalex.org/W1",
+ "publication_year": 2026,
+ "primary_location": {"source": {"display_name": "Archive"}},
+ }
+ ]
+ }
+
+ evidence = OpenAlexEvidenceProvider(opener=opener).fetch(Claim(text="research claim"))
+
+ assert evidence[0].source.name == "OpenAlex"
+ assert evidence[0].title == "Open research result"
+ assert evidence[0].snippet == "2026 | Archive"
+
+
+def test_cached_provider_reuses_claim_response() -> None:
+ calls = 0
+
+ class Provider:
+ name = "fake"
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ nonlocal calls
+ calls += 1
+ return [
+ Evidence(
+ source=Source(name="Fake", credibility=0.5),
+ title=claim.text,
+ snippet="Cached",
+ stance=EvidenceStance.NEUTRAL,
+ )
+ ]
+
+ provider = CachedEvidenceProvider(Provider())
+
+ assert provider.fetch(Claim(text="Same claim"))[0].title == "Same claim"
+ assert provider.fetch(Claim(text="Same claim"))[0].title == "Same claim"
+ assert calls == 1
+
+
+def test_composite_retriever_keeps_working_when_provider_fails() -> None:
+ class BrokenProvider:
+ name = "broken"
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ raise RuntimeError("provider down")
+
+ class WorkingProvider:
+ name = "working"
+
+ def fetch(self, claim: Claim) -> list[Evidence]:
+ return [
+ Evidence(
+ source=Source(name="Working", credibility=0.8),
+ title="Result",
+ snippet="Result",
+ stance=EvidenceStance.NEUTRAL,
+ )
+ ]
+
+ evidence = CompositeEvidenceRetriever([BrokenProvider(), WorkingProvider()]).retrieve(
+ Claim(text="claim")
+ )
+
+ assert len(evidence) == 1
+ assert evidence[0].source.name == "Working"