# Project export: Veritas

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Veritas fights misinformation by combining AI speed with human judgment. It searches trustworthy sources, analyzes evidence with Agents, and returns a transparent credibility score.
- Devpost: https://devpost.com/software/veritas-qj3c8x
- GitHub: https://github.com/amai0413/veritas_dvc
- Demo: https://docs.google.com/presentation/d/1mpVicLmYDCsHnVv8YbVC5pP1fXAKjEjc/edit?usp=drive_link&ouid=103469130338430063548&rtpof=true&sd=true
- Video: https://www.youtube.com/embed/bpMl1Lbk3PQ?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 5 GitHub contributor(s) — iamaustin777 (6 commits), Claude Opus 4.8 (3 commits), tadaddy (2 commits), Ai Y (1 commits), taohaoze7-prog (1 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

\# 🦊 Veritas - AI Truth Engine



\*\*Veritas\*\* is an AI-powered fact-checking system that evaluates claims using a 3-agent architecture. It finds real sources from .edu and .gov sites, extracts evidence, and uses Claude AI to return a truth score from 0-100.



\## 🎯 Features



\- 🔍 \*\*Real web search\*\* - Prioritizes .edu and .gov sources

\- 📄 \*\*Content extraction\*\* - Reads full article content

\- 🧠 \*\*AI analysis\*\* - Uses Claude AI for intelligent reasoning

\- 📊 \*\*Score 0-100\*\* - How likely the claim is true

\- 🏷️ \*\*Status badges\*\* - Verified, False, Disputed, Uncertain

\- 📚 \*\*Source transparency\*\* - Shows where evidence came from

\- 📊 \*\*Tracing\*\* - Arize OTEL integration for monitoring



\## 🏗️ Architecture





## Detected evidence (automated analysis)

Indexed codebase: 33 recognized source files, 179 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Flask (technology) — claimed on Devpost, not found in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code
- HTML (language) — claimed on Devpost, not found in the code
- JavaScript (language) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (47 of 47)

```
.env.example
.gitignore
.gitkeep
README.md
src/veritas_browser_agent_fixed.py
src/veritas_extractor_agent.py
src/veritas_searcher_agent.py
web/.gitignore
web/AGENTS.md
web/app/api/analyze/route.ts
web/app/api/check/route.ts
web/app/api/deepcheck/route.ts
web/app/globals.css
web/app/layout.tsx
web/app/page.tsx
web/CLAUDE.md
web/components/DocumentAnalysis.tsx
web/components/EvidenceMap.tsx
web/components/ScorePanel.tsx
web/components/SourceCard.tsx
web/components/VerdictBadge.tsx
web/components/VeritasMark.tsx
web/instrumentation-client.ts
web/instrumentation.ts
web/lib/arize.ts
web/lib/decompose.ts
web/lib/demoData.ts
web/lib/display.ts
web/lib/fetchArticle.ts
web/lib/research.ts
web/lib/scoring.ts
web/lib/types.ts
web/lib/verdict.ts
web/next.config.ts
web/package.json
web/postcss.config.mjs
web/README.md
web/scripts/exp/dataset_export.json
web/scripts/exp/examples.jsonl
web/scripts/exp/exp_new.jsonl
web/scripts/exp/exp_old.jsonl
web/scripts/exp/id_map.json
web/scripts/exp/run.mjs
web/sentry.edge.config.ts
web/sentry.server.config.ts
web/skills-lock.json
web/tsconfig.json
```

### Dependencies

- web/package.json: @anthropic-ai/sdk@^0.105.0, @browserbasehq/sdk@^2.14.1, @grpc/grpc-js@^1.14.4, @mozilla/readability@^0.6.0, @opentelemetry/api@^1.9.1, @opentelemetry/exporter-trace-otlp-grpc@^0.219.0, @opentelemetry/resources@^2.8.0, @opentelemetry/sdk-trace-base@^2.8.0, @opentelemetry/sdk-trace-node@^2.8.0, @sentry/nextjs@^10.59.0, @tailwindcss/postcss@^4, @types/jsdom@^28.0.3, @types/node@^20, @types/react@^19, @types/react-dom@^19, jsdom@^29.1.1, next@16.2.9, playwright-core@^1.61.0, react@19.2.4, react-dom@19.2.4, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Merge pull request #6 from amai0413/austin/deep-check
- web/: Deep check (agentic search + Browserbase scrape + evidence-grounded verdict)
- Update .gitignore
- Merge pull request #4 from amai0413/austin/uniform-opinion-scoring
- web/: score opinions like any claim (evidence-supported), uniform UI
- Merge pull request #3 from amai0413/austin/fix-opinion-and-speed
- web/: fix opinion handling + Arize-safe latency tuning
- Merge pull request #2 from amai0413/austin/nextjs-web-app
- Add Next.js web app (Veritas) under web/
- Veritas Logo
- Add Veritas AI Truth Engine - 3 agent fact-checking system
- Initialize repository

## Key source files (fetched from GitHub, selected and truncated for size)

### web/CLAUDE.md

```markdown
@AGENTS.md

```

### web/AGENTS.md

```markdown
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

```

### web/package.json

```
{
  "name": "veritas",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@browserbasehq/sdk": "^2.14.1",
    "@grpc/grpc-js": "^1.14.4",
    "@mozilla/readability": "^0.6.0",
    "@opentelemetry/api": "^1.9.1",
    "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0",
    "@opentelemetry/resources": "^2.8.0",
    "@opentelemetry/sdk-trace-base": "^2.8.0",
    "@opentelemetry/sdk-trace-node": "^2.8.0",
    "@sentry/nextjs": "^10.59.0",
    "jsdom": "^29.1.1",
    "next": "16.2.9",
    "playwright-core": "^1.61.0",
    "react": "19.2.4",
    "react-dom": "19.2.4"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/jsdom": "^28.0.3",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### web/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Veritas — The Human + AI Truth Engine",
  description:
    "Blend an AI evidence-check with human consensus into one credibility score, and see the whole reasoning on a 2D evidence map.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col">{children}</body>
    </html>
  );
}

```

### web/app/page.tsx

```typescript
"use client";

import { useMemo, useState } from "react";
import type { ClaimInput, DocumentAnalysis as DocumentAnalysisData, VoteVerdict } from "@/lib/types";
import { scoreClaim } from "@/lib/scoring";
import { DEMO_SCENARIOS } from "@/lib/demoData";
import ScorePanel from "@/components/ScorePanel";
import EvidenceMap from "@/components/EvidenceMap";
import SourceCard from "@/components/SourceCard";
import DocumentAnalysis from "@/components/DocumentAnalysis";
import VeritasMark from "@/components/VeritasMark";

type Mode = "claim" | "article";

const SAMPLE_ARTICLE = `The Great Wall of China is the only man-made structure visible from space with the naked eye. It stretches for over 13,000 miles and was built in a single dynasty to keep out invaders. Construction first began more than 2,000 years ago. Today the wall draws tens of millions of tourists every year, making it one of the most visited landmarks on Earth.`;

// shared liquid-glass surfaces — translucent, strong blur, bright specular top edge
const CARD =
  "relative overflow-hidden rounded-3xl border border-white/60 bg-white/85 backdrop-blur-2xl shadow-[0_24px_70px_-28px_rgba(55,75,135,0.5),0_2px_6px_-3px_rgba(55,75,135,0.12)] before:pointer-events-none before:absolute before:inset-x-6 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-white before:to-transparent";
const INPUT =
  "w-full rounded-xl border border-slate-300/70 bg-white/70 p-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-[#d8a93f]/60 focus:ring-2 focus:ring-[#d8a93f]/20";
const CTA =
  "inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-br from-[#f0cf7a] to-[#d8a93f] px-4 py-2.5 text-sm font-semibold text-[#2a1e05] shadow-[0_8px_24px_-8px_rgba(216,169,63,0.65)] transition hover:brightness-[1.06] hover:shadow-[0_12px_32px_-8px_rgba(216,169,63,0.75)] disabled:cursor-not-allowed disabled:from-slate-300 disabled:to-slate-300 disabled:text-white/80 disabled:shadow-none";

export default function Home() {
  const [mode, setMode] = useState<Mode>("claim");

  // ---- single-claim mode ----
  const [input, setInput] = useState("");
  const [claim, setClaim] = useState<ClaimInput | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [isMock, setIsMock] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
  // ---- deep check (agentic: search + scrape real sources) ----
  const [deepLoading, setDeepLoading] = useState(false);
  const [deepInfo, setDeepInfo] = useState<{
    queries: string[];
    evidence: { title: string; url: string; siteName?: string; query: string }[];
    sourcesLive: boolean;
  } | null>(null);

  // ---- article mode ----
  const [docInput, setDocInput] = useState("");
  const [urlInput, setUrlInput] = useState("");
  const [analysis, setAnalysis] = useState<DocumentAnalysisData | null>(null);
  const [docLoading, setDocLoading] = useState(false);
  const [docError, setDocError] = useState<string | null>(null);
  const [docMock, setDocMock] = useState(false);
  const [analyzeNonce, setAnalyzeNonce] = useState(0); // remounts results to reset per-claim votes

  const scored = useMemo(() => (claim ? scoreClaim(claim) : null), [claim]);

  async function checkClaim() {
    const text = input.trim();
    if (!text || loading) return;
    setLoading(true);
    setError(null);
    try {
      const res = await fetch("/api/check", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ claim: text }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? "Something went wrong.");
        return;
      }
      setClaim({
        text,
        aiVerdict: data.aiVerdict,
        sources: data.sources ?? [],
        votes: { trusted: [], public: [] },
      });
      setIsMock(Boolean(data.mock));
      setDeepInfo(null);
      setSelectedIndex(null);
    } catch {
      setError("Network error — could not reach the analysis endpoint.");
    } finally {
      setLoading(false);
    }
  }

  // Agentic deep check: the server finds real links, scrapes them, and grounds
  // the verdict in those pages. Slower than a normal check, but real evidence.
  async function deepCheck() {
    const text = input.trim();
    if (!text || deepLoading || loading) return;
    setDeepLoading(true);
    setError(null);
    setDeepInfo(null);
    try {
      const res = await fetch("/api/deepcheck", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ claim: text }),
      });
      const data = await res.json();
      if (!res.ok) {
        setError(data.error ?? "Something went wrong.");
        return;
      }
      setClaim({
        text,
        aiVerdict: data.aiVerdict,
        sources: data.sources ?? [],
        votes: { trusted: [], public: [] },
      });
      setIsMock(Boolean(data.mock));
      setDeepInfo({
        queries: data.queries ?? [],
        evidence: data.evidence ?? [],
        sourcesLive: Boolean(data.sourcesLive),
      });
      setSelectedIndex(null);
    } catch {
      setError("Network error — could not reach the analysis endpoint.");
    } finally {
      setDeepLoading(false);
    }
  }

  async function runAnalyze(body: { document?: string; url?: string }) {
    setDocLoading(true);
    setDocError(null);
    try {
      const res = await fetch("/api/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      const data = await res.json();
      if (!res.ok) {
        setDocError(data.error ?? "Something went wrong.");
        return;
      }
      setAnalysis(data);
      setDocMock(Boolean(data.mock));
      setAnalyzeNonce((n) => n + 1);
    } catch {
      setDocError("Network error
[truncated — 15957 more characters]
```

### web/app/api/deepcheck/route.ts

```typescript
// Deep check — the agentic "find the links yourself" path.
//   claim -> Claude writes search queries -> web search for real result links
//         -> Browserbase scrapes the top pages -> verdict grounded in REAL excerpts.
// Unlike /api/check (sources are AI-estimated), here the sources are pages we
// actually retrieved. Fully traced in Arize: deep_check -> research -> verdict.
// Env-gated like the rest: no ANTHROPIC_API_KEY -> deterministic mock.

import Anthropic from "@anthropic-ai/sdk";
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
import * as Sentry from "@sentry/nextjs";
import { getArize, OI } from "@/lib/arize";
import { MODEL, liveVerdict, liveVerdictWithEvidence, mockVerdict } from "@/lib/verdict";
import { gatherEvidence, type Evidence } from "@/lib/research";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 120; // search + multi-page scrape can take a while

type EvidenceMeta = { title: string; url: string; siteName?: string; query: string };

function meta(evidence: Evidence[]): EvidenceMeta[] {
  return evidence.map((e) => ({ title: e.title, url: e.url, siteName: e.siteName, query: e.query }));
}

export async function POST(request: Request) {
  let claimText = "";
  try {
    const body = await request.json();
    claimText = typeof body?.claim === "string" ? body.claim.trim() : "";
  } catch {
    return Response.json({ error: "Invalid request body." }, { status: 400 });
  }
  if (!claimText) {
    return Response.json({ error: "Please provide a claim to check." }, { status: 400 });
  }
  if (!process.env.ANTHROPIC_API_KEY) {
    return Response.json({ ...mockVerdict(claimText), queries: [], evidence: [], sourcesLive: false, mock: true });
  }

  const client = new Anthropic();
  const arize = await getArize();

  // The actual work, optionally wrapped in Arize spans.
  async function execute(
    tracer?: NonNullable<Awaited<ReturnType<typeof getArize>>>["tracer"],
    parentCtx?: ReturnType<typeof trace.setSpan>,
  ) {
    // 1) Agentic research: queries -> search -> scrape.
    let research: Awaited<ReturnType<typeof gatherEvidence>>;
    if (tracer && parentCtx) {
      const s = tracer.startSpan("veritas.research", undefined, parentCtx);
      try {
        research = await gatherEvidence(client, claimText);
        s.setAttribute(OI.SPAN_KIND, "RETRIEVER");
        s.setAttribute("tool.name", "browserbase+duckduckgo");
        s.setAttribute(OI.INPUT_VALUE, claimText);
        s.setAttribute("veritas.queries", JSON.stringify(research.queries));
        s.setAttribute("veritas.result_count", research.results.length);
        s.setAttribute("veritas.scraped_count", research.evidence.length);
        s.setAttribute(OI.OUTPUT_VALUE, JSON.stringify(meta(research.evidence)));
        s.setAttribute(OI.OUTPUT_MIME, "application/json");
        s.setStatus({ code: SpanStatusCode.OK });
      } catch (e) {
        s.setAttribute(OI.SPAN_KIND, "RETRIEVER");
        s.setAttribute(OI.INPUT_VALUE, claimText);
        s.recordException(e as Error);
        s.setStatus({ code: SpanStatusCode.ERROR });
        throw e;
      } finally {
        s.end();
      }
    } else {
      research = await gatherEvidence(client, claimText);
    }

    const haveEvidence = research.evidence.length > 0;

    // 2) Verdict — grounded in the scraped pages when we got any, else fall back
    //    to the model's own knowledge so the user still gets an answer.
    async function runVerdict() {
      return haveEvidence
        ? liveVerdictWithEvidence(client, claimText, research.evidence, { effort: "low" })
        : liveVerdict(client, claimText, { effort: "low" });
    }

    let verdict;
    if (tracer && parentCtx) {
      const s = tracer.startSpan("anthropic.verdict", undefined, parentCtx);
      try {
        verdict = await runVerdict();
        s.setAttribute(OI.SPAN_KIND, "LLM");
        s.setAttribute(OI.LLM_PROVIDER, "anthropic");
        s.setAttribute(OI.LLM_MODEL, MODEL);
        s.setAttribute(OI.INPUT_VALUE, claimText);
        s.setAttribute(OI.OUTPUT_VALUE, JSON.stringify(verdict.aiVerdict));
        s.setAttribute(OI.OUTPUT_MIME, "application/json");
        s.setAttribute(OI.TOK_PROMPT, verdict.promptTokens);
        s.setAttribute(OI.TOK_COMPLETION, verdict.completionTokens);
        s.setAttribute(OI.TOK_TOTAL, verdict.promptTokens + verdict.completionTokens);
        s.setAttribute("veritas.grounded", haveEvidence);
        s.setStatus({ code: SpanStatusCode.OK });
      } catch (e) {
        s.setAttribute(OI.SPAN_KIND, "LLM");
        s.setAttribute(OI.LLM_MODEL, MODEL);
        s.setAttribute(OI.INPUT_VALUE, claimText);
        s.recordException(e as Error);
        s.setStatus({ code: SpanStatusCode.ERROR });
        throw e;
      } finally {
        s.end();
      }
    } else {
      verdict = await runVerdict();
    }

    return {
      aiVerdict: verdict.aiVerdict,
      sources: verdict.sources,
      queries: research.queries,
      evidence: meta(research.evidence),
      sourcesLive: haveEvidence,
      mock: false,
    };
  }

  // Traced path.
  if (arize) {
    return arize.tracer.startActiveSpan("veritas.deep_check", async (root) => {
      root.setAttribute(OI.SPAN_KIND, "CHAIN");
      root.setAttribute(OI.INPUT_VALUE, claimText);
      root.setAttribute(OI.INPUT_MIME, "text/plain");
      const parentCtx = trace.setSpan(context.active(), root);
      try {
        const out = await execute(arize.tracer, parentCtx);
        root.setAttribute(OI.OUTPUT_VALUE, JSON.stringify(out.aiVerdict));
        root.setAttribute("veritas.sources_live", out.sourcesLive);
        root.setStatus({ code: SpanStatusCode.OK });
        return Response.json(out);
      } catch (e) {
        root.recordException(e as Error);
        root.setStatus({ code: SpanStatusCode.ERROR });
        Sentry.captureException(e, { tags: { phase: "traced", route: "deepcheck" } });
        return Response.json(
[truncated — 473 more characters]
```

### web/app/api/check/route.ts

```typescript
// AI evidence-check endpoint (Phase 1 + Phase 2).
//   - Phase 1: single route, Claude when ANTHROPIC_API_KEY is set, else
//     deterministic mock JSON (no Browserbase/Fetch.ai).
//   - Phase 2 (Arize AX): when ARIZE_API_KEY + ARIZE_SPACE_ID are set, the live
//     verdict call is traced to Arize and a second LLM "evaluator" pass scores
//     the verdict's quality (the evaluator loop). All env-gated; tracing failures
//     never break the response.
//
// The verdict engine itself lives in lib/verdict.ts so the long-form /api/analyze
// pipeline shares the exact same prompt and schema.

import Anthropic from "@anthropic-ai/sdk";
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
import * as Sentry from "@sentry/nextjs";
import { getArize, OI } from "@/lib/arize";
import { MODEL, clamp, liveVerdict, mockVerdict, type VerdictResult } from "@/lib/verdict";

export const runtime = "nodejs";
export const dynamic = "force-dynamic"; // external call per request — never cache

const EVAL_SCHEMA = {
  type: "object",
  properties: {
    score: { type: "integer", description: "0-100 quality of the verdict's reasoning" },
    label: { type: "string", enum: ["pass", "weak", "fail"] },
    explanation: { type: "string", description: "One sentence justification" },
  },
  required: ["score", "label", "explanation"],
  additionalProperties: false,
} as const;

const EVAL_SYSTEM = `You are a strict evaluator of fact-check verdicts. Judge whether the verdict's reasoning is internally consistent with its numeric score and would hold up to scrutiny. Return a quality score (0-100), a label, and a one-sentence explanation.`;

type EvalResult = {
  score: number;
  label: string;
  explanation: string;
  promptTokens: number;
  completionTokens: number;
};

async function runEvaluator(
  client: Anthropic,
  claimText: string,
  verdict: VerdictResult,
): Promise<EvalResult> {
  const response = await client.messages.create({
    model: MODEL,
    max_tokens: 1000,
    output_config: {
      effort: "low",
      format: { type: "json_schema", schema: EVAL_SCHEMA },
    },
    system: EVAL_SYSTEM,
    messages: [
      {
        role: "user",
        content: `Claim: "${claimText}"\nVerdict score: ${verdict.aiVerdict.score}\nReasoning: ${verdict.aiVerdict.reasoning}`,
      },
    ],
  });

  const tb = response.content.find((b) => b.type === "text");
  const raw = tb && tb.type === "text" ? JSON.parse(tb.text) : {};
  return {
    score: Math.round(clamp(raw.score, 0, 100)),
    label: String(raw.label ?? "weak"),
    explanation: String(raw.explanation ?? ""),
    promptTokens: response.usage?.input_tokens ?? 0,
    completionTokens: response.usage?.output_tokens ?? 0,
  };
}

export async function POST(request: Request) {
  let claimText = "";
  try {
    const body = await request.json();
    claimText = typeof body?.claim === "string" ? body.claim.trim() : "";
  } catch {
    return Response.json({ error: "Invalid request body." }, { status: 400 });
  }

  if (!claimText) {
    return Response.json({ error: "Please provide a claim to check." }, { status: 400 });
  }

  // No key -> guaranteed mock.
  if (!process.env.ANTHROPIC_API_KEY) {
    return Response.json({ ...mockVerdict(claimText), mock: true });
  }

  const client = new Anthropic();
  const arize = await getArize();

  // Phase 2: traced path with evaluator loop (only when Arize is configured).
  if (arize) {
    return arize.tracer.startActiveSpan("veritas.check_claim", async (root) => {
      root.setAttribute(OI.SPAN_KIND, "CHAIN");
      root.setAttribute(OI.INPUT_VALUE, claimText);
      root.setAttribute(OI.INPUT_MIME, "text/plain");
      // Parent child spans EXPLICITLY to the root context so the trace tree nests
      // correctly on its own — no reliance on a globally-registered context manager.
      const parentCtx = trace.setSpan(context.active(), root);
      try {
        const verdict = await arize.tracer.startActiveSpan("anthropic.verdict", {}, parentCtx, async (s) => {
          try {
            const r = await liveVerdict(client, claimText, { effort: "low" });
            s.setAttribute(OI.SPAN_KIND, "LLM");
            s.setAttribute(OI.LLM_PROVIDER, "anthropic");
            s.setAttribute(OI.LLM_SYSTEM, "anthropic");
            s.setAttribute(OI.LLM_MODEL, MODEL);
            s.setAttribute(OI.INPUT_VALUE, claimText);
            s.setAttribute(OI.OUTPUT_VALUE, JSON.stringify(r.aiVerdict));
            s.setAttribute(OI.OUTPUT_MIME, "application/json");
            s.setAttribute(OI.TOK_PROMPT, r.promptTokens);
            s.setAttribute(OI.TOK_COMPLETION, r.completionTokens);
            s.setAttribute(OI.TOK_TOTAL, r.promptTokens + r.completionTokens);
            s.setStatus({ code: SpanStatusCode.OK });
            return r;
          } catch (e) {
            // Label the span even on failure so it never lands as an empty UNKNOWN.
            s.setAttribute(OI.SPAN_KIND, "LLM");
            s.setAttribute(OI.LLM_PROVIDER, "anthropic");
            s.setAttribute(OI.LLM_MODEL, MODEL);
            s.setAttribute(OI.INPUT_VALUE, claimText);
            s.recordException(e as Error);
            s.setStatus({ code: SpanStatusCode.ERROR });
            throw e;
          } finally {
            s.end();
          }
        });

        // Internal self-check — a second Claude pass that grades the verdict's
        // self-consistency. This is OURS (traced as veritas.self_check); it is
        // distinct from the Arize online LLM-as-judge "Veritas Verdict Calibration"
        // evaluator, which scores the trace from the Arize platform. Run inline so
        // the full 3-span trace (check_claim -> verdict -> self_check) is always
        // exported deterministically before the response — Arize is the priority.
        await arize.tracer.startActiveSpan("veritas.self_check", {}, parentCtx, async (s) => {
          try {
            const ev = await runEvaluator(client, claimText, verdict);
       
[truncated — 2005 more characters]
```

### web/app/api/analyze/route.ts

```typescript
// Long-form analysis endpoint (Phase A). Decomposes a document into its central
// factual claims, runs each through the SAME verdict engine as /api/check, and
// aggregates them into a document-level credibility score.
//
// Observability: when Arize is configured the whole run is one trace —
//   veritas.analyze_document (CHAIN)
//     └─ veritas.decompose      (LLM)
//     └─ anthropic.verdict × N  (LLM, fanned out in parallel)
// The per-claim verdict spans are parented explicitly to the document root so
// the fan-out nests correctly even though they run concurrently.
//
// Env-gated exactly like /api/check: no ANTHROPIC_API_KEY -> deterministic mock.

import Anthropic from "@anthropic-ai/sdk";
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
import * as Sentry from "@sentry/nextjs";
import { getArize, OI } from "@/lib/arize";
import { MODEL, liveVerdict, mockVerdict } from "@/lib/verdict";
import { decomposeDocument, mockDecompose } from "@/lib/decompose";
import { scoreClaim, scoreDocument } from "@/lib/scoring";
import { fetchArticle, ArticleFetchError } from "@/lib/fetchArticle";
import type {
  DocumentAnalysis,
  DocumentClaim,
  DocumentSource,
  ExtractedClaim,
} from "@/lib/types";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const MAX_DOC_CHARS = 16000; // bound payload/context size

type VerdictLike = { aiVerdict: { score: number; reasoning: string; verifiable: boolean }; sources: DocumentClaim["sources"] };

// Turn extracted claims + their verdicts into a fully-scored document analysis.
// A failed verdict falls back to the mock so the document still renders whole.
function buildAnalysis(
  summary: string,
  note: string,
  items: { claim: ExtractedClaim; verdict: VerdictLike | null }[],
): DocumentAnalysis {
  const claims: DocumentClaim[] = items.map(({ claim, verdict }) => {
    const v = verdict ?? mockVerdict(claim.text);
    const scored = scoreClaim({
      text: claim.text,
      aiVerdict: v.aiVerdict,
      sources: v.sources,
      votes: { trusted: [], public: [] },
    });
    return { ...scored, quote: claim.quote, importance: claim.importance };
  });
  return { ...scoreDocument(claims, summary), note };
}

function mockAnalysis(document: string): DocumentAnalysis {
  const { summary, note, claims } = mockDecompose(document);
  return buildAnalysis(
    summary,
    note,
    claims.map((claim) => ({ claim, verdict: mockVerdict(claim.text) })),
  );
}

export async function POST(request: Request) {
  let document = "";
  let url = "";
  try {
    const body = await request.json();
    document = typeof body?.document === "string" ? body.document.trim() : "";
    url = typeof body?.url === "string" ? body.url.trim() : "";
  } catch {
    return Response.json({ error: "Invalid request body." }, { status: 400 });
  }

  // If a URL was given, fetch + extract the article first (no LLM key needed).
  // Fetch problems are user-facing 400s, not silent mocks.
  let source: DocumentSource | undefined;
  if (url) {
    try {
      const article = await fetchArticle(url);
      document = article.text;
      source = { title: article.title, url: article.url, siteName: article.siteName };
    } catch (e) {
      if (e instanceof ArticleFetchError) {
        return Response.json({ error: e.message }, { status: 400 });
      }
      Sentry.captureException(e, { tags: { route: "analyze", step: "fetch" } });
      return Response.json({ error: "Could not fetch that URL." }, { status: 502 });
    }
  }

  if (document.length < 40) {
    return Response.json(
      { error: "Please paste a longer passage (or a link) to analyze." },
      { status: 400 },
    );
  }
  document = document.slice(0, MAX_DOC_CHARS);

  // No key -> guaranteed mock.
  if (!process.env.ANTHROPIC_API_KEY) {
    return Response.json({ ...mockAnalysis(document), source, mock: true });
  }

  const client = new Anthropic();
  const arize = await getArize();

  // Runs the decompose + verdict fan-out. `trace?` wires each step into Arize
  // spans when provided; otherwise it's a plain pipeline.
  async function run(tracer?: NonNullable<Awaited<ReturnType<typeof getArize>>>["tracer"], parentCtx?: ReturnType<typeof trace.setSpan>) {
    // 1) Decompose the document into claims.
    let decomposed: { summary: string; note: string; claims: ExtractedClaim[] };
    if (tracer && parentCtx) {
      const s = tracer.startSpan("veritas.decompose", undefined, parentCtx);
      try {
        const r = await decomposeDocument(client, document);
        s.setAttribute(OI.SPAN_KIND, "LLM");
        s.setAttribute(OI.LLM_PROVIDER, "anthropic");
        s.setAttribute(OI.LLM_MODEL, MODEL);
        s.setAttribute(OI.INPUT_VALUE, document.slice(0, 4000));
        s.setAttribute(OI.OUTPUT_VALUE, JSON.stringify({ summary: r.summary, note: r.note, claims: r.claims.map((c) => c.text) }));
        s.setAttribute(OI.OUTPUT_MIME, "application/json");
        s.setAttribute(OI.TOK_PROMPT, r.promptTokens);
        s.setAttribute(OI.TOK_COMPLETION, r.completionTokens);
        s.setAttribute("veritas.claim_count", r.claims.length);
        s.setStatus({ code: SpanStatusCode.OK });
        decomposed = { summary: r.summary, note: r.note, claims: r.claims };
      } catch (e) {
        // Label the span even on failure so it never lands as an empty UNKNOWN.
        s.setAttribute(OI.SPAN_KIND, "LLM");
        s.setAttribute(OI.LLM_PROVIDER, "anthropic");
        s.setAttribute(OI.LLM_MODEL, MODEL);
        s.setAttribute(OI.INPUT_VALUE, document.slice(0, 4000));
        s.recordException(e as Error);
        s.setStatus({ code: SpanStatusCode.ERROR });
        throw e;
      } finally {
        s.end();
      }
    } else {
      decomposed = await decomposeDocument(client, document);
    }

    // 2) Verdict per claim, fanned out concurrently.
    const items = await Promise.all(
      decomposed.claims.map(async (claim) => {
        if (tracer && parentCtx) {
          const s
[truncated — 2869 more characters]
```

### web/sentry.edge.config.ts

```typescript
// Sentry edge-runtime init (Phase 2). Env-gated on SENTRY_DSN, same as the
// server config. Imported from instrumentation.ts when NEXT_RUNTIME === "edge".
import * as Sentry from "@sentry/nextjs";

const dsn = process.env.SENTRY_DSN;

if (dsn) {
  Sentry.init({
    dsn,
    tracesSampleRate: process.env.NODE_ENV === "development" ? 1.0 : 0.1,
    enableLogs: true,
  });
}

```

### web/instrumentation.ts

```typescript
// Next.js server instrumentation (Phase 2). `register` runs once at server
// start and lazily imports the runtime-appropriate Sentry config — those configs
// are themselves env-gated, so with no SENTRY_DSN this loads the SDK but inits
// nothing. `onRequestError` forwards captured server errors to Sentry (a no-op
// when Sentry is uninitialized).
import * as Sentry from "@sentry/nextjs";

export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./sentry.server.config");
  }
  if (process.env.NEXT_RUNTIME === "edge") {
    await import("./sentry.edge.config");
  }
}

export const onRequestError = Sentry.captureRequestError;

```

[22 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]