TruthCheck Docs
Everything you need to understand, run, and extend TruthCheck v7.0.
What is TruthCheck?
TruthCheck is an AI-powered bilingual fact-checking application. It verifies claims against global evidence using DeepSeek Reasoner and real-time web search (Brave Search primary, DuckDuckGo fallback) — available in English and Spanish.
- ▸Bilingual UI (EN/ES) — full interface and AI output in both languages
- ▸AI Claim Extraction — identifies atomic, verifiable statements from any text
- ▸Parallel Verification — all claims verified simultaneously for minimum wait time
- ▸Progressive Results — cards appear incrementally as each verification completes
- ▸Structured Verdicts — one of seven verdicts (Supported through Contradicted), each with a confidence level and an assessment of how good the evidence behind it was
- ▸Stated Caveats — scope, timing, definition, and correlation-vs-causation limits are called out alongside the verdict rather than left implicit
- ▸Legacy 0–5 Score — still returned, derived from the verdict for older clients
- ▸Verbatim Quotations — the quoted passage is always an exact substring of the text you submitted, never a translation or paraphrase
- ▸Errors Are Not Verdicts — a search outage returns an explicit error, never "Unverified"
- ▸Grounded Citations — every factual statement in the explanation links to the exact passage it came from, which you can expand and open
- ▸Source Provenance — sources whose page was actually read are marked as such; a search-result summary is never presented as verified content
- ▸Source Evidence — up to 4 web search results per claim as clickable source cards
- ▸Real-time Status — progress bar, pending claim feed, elapsed timer, live domain feed
- ▸Copy Result — one-click clipboard export of claim, verdict, reasoning, and sources
- ▸Character Counter — amber/red threshold warnings at 12k/15k chars
- ▸Empty-State Handling — an explicit "no verifiable claims found" message when extraction succeeds with zero claims
- ▸Keyboard Shortcut — Ctrl+Enter / ⌘+Enter to submit
- ▸Dark Mode — follows system preference automatically
- ▸Error Monitoring — Sentry integration for server-side exceptions
Quick Start
Prerequisites
- Node.js 20+
- DeepSeek API key — platform.deepseek.com
- Brave Search API key (optional — required on Vercel/cloud where DuckDuckGo is blocked)
Setup
git clone <repo-url> cd truth-checker npm install # Copy and fill in environment variables cp .env.example .env.local # Required: DEEPSEEK_API_KEY # Optional: BRAVE_SEARCH_API_KEY # Optional: FACTCHECK_* pipeline budgets (see docs/DEVELOPMENT.md) npm run dev
App available at http://localhost:3000
Common Commands
npm run dev # Start dev server npm run build # Production build npm run start # Start production server npm run lint # ESLint npm run typecheck # tsc --noEmit npm test # Run all tests (Vitest) npm run test:watch # Watch mode
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16.2.3 (App Router, standalone) |
| AI — Extraction | DeepSeek Chat (deepseek-chat) |
| AI — Reasoning | DeepSeek Reasoner (deepseek-reasoner) |
| Search | Brave Search API (primary) / DuckDuckGo HTML scraper (fallback) |
| Validation | Zod — LLM response schema enforcement |
| Error Monitoring | Sentry (@sentry/nextjs) |
| i18n | Custom lib/i18n.ts + React Context |
| Analytics | Vercel Analytics |
| Testing | Vitest + React Testing Library |
| Styling | Tailwind CSS v4 |
| Icons | Lucide React |
| Reverse Proxy | nginx 1.27 (TLS, rate limiting) |
| Containerization | Docker / Docker Compose |
Security
| Control | Details |
|---|---|
| Rate Limiting | Per-IP sliding window: 5 req/min (/analyze), 10 req/min (/extract), 20 req/min (/verify) |
| Security Headers | CSP, HSTS (1 year + preload), X-Frame-Options: DENY, X-Content-Type-Options, Permissions-Policy |
| Input Validation | 15,000 char limit; max 10 claims per extraction; claim.id format enforced; empty input rejected |
| Secrets | Hard startup failure if DEEPSEEK_API_KEY is missing — no mock/fallback |
| Network Isolation | Next.js on internal Docker network only; nginx is the sole public entry point |
| TLS | Mozilla Intermediate config — TLS 1.2/1.3, strong ciphers, OCSP stapling |
| Audit Logging | Structured JSON logs on every API request (timestamp, IP, route, payload size, duration, status) |
| Prompt Injection Hardening | Fixed fact-checking policy sent as a system message; claim + evidence confined to a delimited, length-bounded user message |
| Proxy-Safe Client IP | Rate limiting and audit logs key on a deployment-specific trusted header (X-Real-IP behind nginx, X-Forwarded-For on Vercel) — never a spoofable client-supplied value |
Architecture
TruthCheck is built on Next.js 16 App Router. All AI logic runs server-side in API routes. The frontend is a single client component (app/page.tsx) that orchestrates the full pipeline.
Analysis Pipeline
- User submits text →
POST /api/extract→ DeepSeek Chat extracts atomic claims (max 10) - Frontend fans out parallel
POST /api/verifycalls — one per claim - Each verify call plans a bounded set of search queries, runs them against Brave Search (DuckDuckGo fallback), and deduplicates the candidates
- The DeepSeek Reasoner is called only when evidence was actually obtained — every other outcome short-circuits with an explicit status
- Results stream in one-by-one; each replaces its skeleton card as it arrives
Errors Are Not Verdicts
Every result carries a pipelineStatus that separates infrastructure failure from a genuine absence of evidence. If the search providers are unreachable, the claim comes back as an error, not as “Unverified” — a service being down is not a finding about the claim. The same holds when a check runs out of time: each claim has a hard deadline, and exceeding it is reported as its own timeout status and labeled Time limit exceeded, not “System error” or doubt about the claim.
Key Files
| app/page.tsx | Root client component — UI layout and prop wiring |
| hooks/useAnalyze.ts | handleAnalyze() — pipeline state, fetch orchestration, error handling |
| app/api/extract/route.ts | POST /api/extract — claim extraction via deepseek-chat |
| app/api/verify/route.ts | POST /api/verify — evidence search + reasoning via deepseek-reasoner |
| lib/analyzer.ts | extractClaims() + verifyClaim() — extraction and pipeline orchestration |
| lib/factcheck/types.ts | Pipeline contracts — dependency-free, safe to import from client code |
| lib/factcheck/quote.ts | resolveQuote() — guarantees a quotation is verbatim from the input |
| lib/factcheck/searchPlan.ts | Bounded query planning with a deterministic single-query fallback |
| lib/factcheck/search.ts | Provider orchestration → typed SearchOutcome (Brave primary, DuckDuckGo fallback) |
| lib/factcheck/urlSafety.ts | Address and URL eligibility — the retrieval security boundary |
| lib/factcheck/retrieve.ts | Bounded page fetch with DNS pinning and redirect re-validation |
| lib/factcheck/passages.ts | Sentence chunking and relevance ranking of page text |
| lib/factcheck/verify.ts | Citation prompt, server-side validation, single repair retry |
| lib/factcheck/outcome.ts | The status → score table: operational failure vs. no evidence |
| lib/factcheck/config.ts | FACTCHECK_* budgets, validated and clamped at startup |
| lib/i18n.ts | Translation dictionary (en/es) + dynamic string helpers |
| lib/i18nContext.tsx | LanguageProvider + useLanguage() hook |
Development Guide
Adding a Translation Key
- Add the English string to
dict.eninlib/i18n.ts - Add the Spanish string to
dict.eswith the same key - Use it in any client component:
const { t } = useLanguage(); t('my_key')
The test suite automatically enforces that dict.en and dict.es have identical keys.
Styling
Tailwind CSS v4. Custom utility classes in app/globals.css:
.glass— glassmorphism with backdrop blur.gradient-text— indigo-to-cyan gradient text.hero-pattern— radial dot grid background
Docker
docker compose up -d --build # Build and start docker compose down # Stop
API Reference
POST/api/extract
Extracts atomic factual claims from a body of text using deepseek-chat. Returns at most 10 claims.
// Request
{ "text": "...", "language": "en" }
// Response — originalTextQuote is always verbatim from the input, never translated
{ "claims": [{ "id": "claim-0", "text": "...", "originalTextQuote": "...",
"quoteStart": 0, "quoteEnd": 45 }] }POST/api/verify
Verifies a single claim via web search + DeepSeek Reasoner. Pass "language": "es" to receive reasoning in Spanish.
// Request
{ "claim": { "id": "claim-0", "text": "...", "originalTextQuote": "..." }, "language": "en" }
// Response — read "verdict", not "legacyScore"
{ "result": { "claimId": "claim-0", "score": 0, "reasoning": "...",
"sources": [...], "citations": [], "pipelineStatus": "ok",
"phase2": {
"verdict": "contradicted",
"confidence": { "level": "high", "score": 0.91, "rationale": "..." },
"evidenceQuality": { "level": "high", "rationale": "...", "limitations": [] },
"summary": "...", "caveats": [],
"supportingEvidence": [], "contradictingEvidence": [...],
"legacyScore": 0 } } }
// Response when the search providers are down — an error, not a verdict
{ "result": { "claimId": "claim-0", "score": -1, "reasoning": "...",
"sources": [], "citations": [],
"pipelineStatus": "search_unavailable",
"warnings": [{ "code": "search_provider_failed" }] } }Pipeline Status
| Status | Score | Meaning |
|---|---|---|
| ok | 0–5 | Evidence was obtained and judged |
| no_results | 1 | A provider answered successfully but returned nothing |
| insufficient_evidence | 1 | Sources were found but none was relevant enough |
| search_unavailable | -1 | Every search provider failed |
| retrieval_unavailable | -1 | Candidates existed but every page fetch failed |
Verdicts
The primary result. A verdict describes what the evidence showed — it is never a statement that the claim is true or false in the abstract.
| Verdict | Meaning |
|---|---|
| supported | The retrieved evidence directly supports the claim |
| mostly_supported | Supported in substance, with a caveat that does not overturn it |
| mixed | Credible evidence points both ways, or the claim is true only under some readings |
| insufficient_evidence | Not enough relevant evidence was retrieved to judge it either way |
| mostly_contradicted | Contradicted in substance, with some support that survives |
| contradicted | The retrieved evidence directly contradicts the claim |
| not_checkable | Opinion, prediction, or otherwise not verifiable against evidence |
Confidence and evidence quality
Confidence (high / medium / low) is how firmly the verdict follows from the evidence. Evidence quality (high / medium / low / insufficient) is how good that evidence was — its independence, primacy, and directness. They are separate on purpose: strong agreement among weak sources is not the same as a firm conclusion. A directional verdict can never be reported as high confidence on low or insufficient evidence; that cap is enforced by the server, not by the model.
Caveats
A verdict can carry caveats naming what qualifies it — missing context, time sensitivity, geographic or population scope, ambiguous definitions, correlation reported as causation, units and quantities, conflicting sources, or thin primary evidence. Caveats marked materialare ones that shaped the verdict itself. Caveat detection is the weakest part of this pipeline today and misses a meaningful share of what it should catch — read the absence of a caveat as "not found", not as "not applicable".
Legacy Score Interpretation
The 0–5 score predates verdicts and is now derived from them for backward compatibility. It is lossy — insufficient_evidence and not_checkable both collapse to 1. New clients should read verdict.
| Score | Label | Meaning |
|---|---|---|
| -1 | Error | Verification could not run (search or API failure) |
| 0 | Contradicted | Evidence explicitly contradicts the claim |
| 1 | Unverified | No relevant evidence found |
| 2–3 | Uncertain | Evidence is partial, ambiguous, or inconclusive |
| 4–5 | Likely True | Multiple sources explicitly confirm the claim |