# Project export: Collina

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: Live AI referee for spoken debates, identifies speakers, flags logical fallacies, updates the score in real time, and crowns a winner. The instant-replay referee for arguments.
- Devpost: https://devpost.com/software/collina
- GitHub: https://github.com/EvxLee/Collina
- Team: 2 GitHub contributor(s) — Claude Opus 4.8 (6 commits), Evan Lee (1 commits)

## Devpost submission (written by the team)

### Inspiration

Arguments happen everywhere, but strong reasoning often gets lost beneath confidence, interruptions, and rhetorical tricks. We built Collina to make debates clearer and more entertaining by turning argument analysis into a live, game-show-style experience.

### What it does

Collina is an AI referee for spoken debates. It separates speakers, generates a live transcript, flags logical fallacies, rewards strong arguments and rebuttals, updates an explainable scoreboard, calls fouls out loud, and crowns a winner with a punchy final verdict.

### How we built it

Next.js, React, TypeScript, and Tailwind CSS: Power the debate interface, transcript, foul flags, animations, and live scoreboard. Deepgram: Provides speech recognition, speaker diarization, and spoken referee callouts. Claude: Analyzes each turn for argument strength and fallacies, then generates the final verdict. Redis Vector Search: Retrieves the most relevant definitions from our 17-fallacy taxonomy for grounded analysis. Hugging Face Transformers: Generates local sentence embeddings for semantic fallacy retrieval. A shared debate client supports both a stage-safe prerecorded demo and a live microphone mode using the same analysis and scoring pipeline. Challenges we faced Our biggest challenge was balancing speed with accuracy. The referee must react quickly enough to feel live, but false fallacy calls can make the entire experience feel unfair. We also had to design scoring rules that were consistent, explainable, and immediately understandable to an audience. Reliability was equally important, so we built an offline mock demo and a local semantic-search fallback that keep the core experience working when an external service is unavailable.

## README (from the GitHub repository)

# Collina — AI Debate Referee

Two people argue out loud. Collina separates their voices, catches logical fallacies in real time, moves a live scoreboard, calls out fouls in a dramatic AI voice, and crowns a winner.

> Built with **Deepgram** (speech-to-text + diarization + text-to-speech), **Claude** (fallacy analysis + verdict), and **Redis** (fallacy vector search).

---

## ⚡ TL;DR

```bash
npm install
npm run dev
```

Open **http://localhost:3000** → click **"Call to order"**. That's the full demo, no keys needed.

---

## 🎮 Two ways to run it

| Button | What it does | Needs keys? |
|---|---|---|
| **Call to order** | Plays a scripted courtroom rehearsal. Polished, offline, can't fail. **Keep this as the stage backup.** | ❌ No |
| **Go live (mic)** | Pick any topic, debate through the mic, and watch the real AI pipeline judge each turn. | ✅ Yes (see below) |

---

## 1️⃣ Mock demo (works right now)

```bash
npm install      # one time
npm run dev      # start the app
```

1. Open **http://localhost:3000**
2. Press **`F`** for fullscreen presentation mode
3. Click **"Call to order"**

No API keys, no internet, no setup. This is the stage-safe demo.

---

## 2️⃣ Live mode (real mic + real AI)

Live mode needs three services. Do these once:

### Step 1 — Add your keys
Copy the example file and fill it in:
```bash
cp .env.example .env.local
```
Edit `.env.local`:
```env
ANTHROPIC_API_KEY=sk-ant-...        # Claude (analysis + verdict)
DEEPGRAM_API_KEY=...                # Deepgram (mic + voice) — needs "Member" role
REDIS_URL=redis://localhost:6379
NEXT_PUBLIC_USE_REAL_PIPELINE=true  # turn the real pipeline ON
```

### Step 2 — Start Redis
```bash
docker run -d --name debate-redis -p 6379:6379 redis/redis-stack:latest
npm run seed:redis     # load the fallacy taxonomy (run once)
```

### Step 3 — Run it
```bash
npm run dev
```
Open **http://localhost:3000** → enter a topic → click **"Go live (mic)"** → allow the microphone → debate → click **"End debate"** for the verdict.

> 🎙️ **Tip for clean voice separation:** take clear turns, don't talk over each other, and ideally use two distinct-sounding voices.

---

## ⌨️ Controls

| Key / Button | Action |
|---|---|
| `Call to order` | Run the scripted courtroom rehearsal |
| `Go live (mic)` | Start a real-time mic debate |
| `End debate` | Stop the mic and get the verdict |
| `F` | Fullscreen presentation mode |
| `M` | Mute / unmute the AI ref voice |
| `R` | Restart the debate |

---

## 🧰 Commands

```bash
npm run dev             # start the app (http://localhost:3000)
npm run build           # production build
npm run lint            # strict TypeScript check
npm run seed:redis      # load fallacies into Redis (live mode)
npm run test:scoring    # verify the scoring math
npm run test:analyze    # test fallacy detection (needs Anthropic key + Redis)
npm run test:transcribe -- <audio-file>   # test a recorded clip
```

---

## 🩹 Troubleshooting

| Problem | Fix |
|---|---|
| **"credit balance too low"** (analysis/verdict fail) | Add credits to your Anthropic account → console.anthropic.com → Plans & Billing |
| **403 on mic / "Insufficient permissions"** | Your Deepgram key needs the **Member** role — create a new key with it |
| **Live mic does nothing** | Check `NEXT_PUBLIC_USE_REAL_PIPELINE=true`, all keys set, Redis running, mic permission allowed |
| **Redis errors** | Start it: `docker start debate-redis` (then `npm run seed:redis`) |
| **Falls back to mock** | That's intentional when keys/flag are missing — the demo always works |

---

## 🧠 How it works

```
Audio ─▶ Deepgram (transcribe + who-said-what)
      ─▶ split into turns
      ─▶ Redis finds the most relevant fallacies
      ─▶ Claude judges the turn (fallacies + strength)
      ─▶ score updates live  +  Deepgram voices the foul
End  ─▶ Claude delivers the winner's verdict
```

The UI imports only `startDebate` and `getVerdict` from `lib/debate-client.ts`. Mock and real pipelines share that exact contract, so the UI is identical either way.


## Detected evidence (automated analysis)

Indexed codebase: 47 recognized source files, 158 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (52 of 52)

```
.env.example
.gitignore
app/api/analyze/route.ts
app/api/deepgram-token/route.ts
app/api/transcribe/route.ts
app/api/tts/route.ts
app/api/verdict/route.ts
app/globals.css
app/layout.tsx
app/page.tsx
components/DebateArena.tsx
components/FallacyFlag.tsx
components/Icons.tsx
components/Scoreboard.tsx
components/Transcript.tsx
components/useRefAudio.ts
components/WinnerCard.tsx
lib/analyze.ts
lib/anthropic.ts
lib/callout.ts
lib/debate-client.ts
lib/deepgram.ts
lib/demoClip.ts
lib/embeddings.ts
lib/fallacies.ts
lib/fallacyStore.ts
lib/fetchJson.ts
lib/liveSegmentation.ts
lib/mockDebateClient.ts
lib/mockTurns.ts
lib/realDebateClient.ts
lib/realtimeDebateClient.ts
lib/redis.ts
lib/scoring.ts
lib/segmentation.ts
lib/tts.ts
lib/types.ts
lib/verdict.ts
next.config.ts
package.json
postcss.config.mjs
README.md
scripts/_env.ts
scripts/preflight.ts
scripts/seed-redis.ts
scripts/test-analyze.ts
scripts/test-retrieval.ts
scripts/test-scoring.ts
scripts/test-segmentation.ts
scripts/test-transcribe.ts
tailwind.config.ts
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.105.0, @deepgram/sdk@^5.4.0, @huggingface/transformers@^4.2.0, @next/env@^16.2.9, @types/node@^26.0.0, @types/react@^19.2.17, @types/react-dom@^19.2.3, autoprefixer@^10.5.0, dotenv@^17.4.2, next@^16.2.9, postcss@^8.5.15, react@^19.2.7, react-dom@^19.2.7, redis@^6.0.0, tailwindcss@^3.4.19, tsx@^4.22.4, typescript@^6.0.3, zod@^3.25.76

### Recent commits (newest first)

- latest version
- Fix demo regression + mic auth
- Clean up: remove internal planning docs, keep README
- Rewrite README: simple organized setup for mock + live mic modes
- Add live mic mode to UI: Go-live button, End-debate, live caption
- Merge remote-tracking branch 'origin/chester' into evan
- Batches 3-7: Claude analysis, Deepgram STT/TTS, real pipeline, real-time mic
- chester frontend
- Batch 2: Redis vector search for the fallacy taxonomy
- Kickoff: scaffold + shared contract + scoring (Batch 0-1)
- help with context
- project context
- instructions
- Initial commit

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

### package.json

```
{
  "name": "debate-referee",
  "version": "0.1.0",
  "description": "A playful live AI referee for spoken debates.",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "tsc --noEmit",
    "test:scoring": "tsx scripts/test-scoring.ts",
    "seed:redis": "tsx scripts/seed-redis.ts",
    "test:retrieval": "tsx scripts/test-retrieval.ts",
    "test:analyze": "tsx scripts/test-analyze.ts",
    "test:transcribe": "tsx scripts/test-transcribe.ts",
    "test:segmentation": "tsx scripts/test-segmentation.ts",
    "preflight": "tsx scripts/preflight.ts",
    "check": "npm run lint && npm run test:scoring && npm run build"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/EvxLee/ContextSwitcher.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/EvxLee/ContextSwitcher/issues"
  },
  "homepage": "https://github.com/EvxLee/ContextSwitcher#readme",
  "private": true,
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@deepgram/sdk": "^5.4.0",
    "@huggingface/transformers": "^4.2.0",
    "@next/env": "^16.2.9",
    "next": "^16.2.9",
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "redis": "^6.0.0",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "@types/node": "^26.0.0",
    "@types/react": "^19.2.17",
    "@types/react-dom": "^19.2.3",
    "autoprefixer": "^10.5.0",
    "dotenv": "^17.4.2",
    "postcss": "^8.5.15",
    "tailwindcss": "^3.4.19",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3"
  }
}

```

### app/page.tsx

```typescript
import { DebateArena } from "@/components/DebateArena";

const DEMO_TOPIC = "Pineapple belongs on pizza.";

export default function Home() {
  return <DebateArena initialTopic={DEMO_TOPIC} />;
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Debate Referee | Court of Public Opinion",
  description: "A live AI court for arguments, fallacies, scoring, and final judgments.",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

```

### app/api/deepgram-token/route.ts

```typescript
// app/api/deepgram-token/route.ts — hand the browser what it needs to open the
// Deepgram live socket. Browsers can't set an Authorization header on a
// WebSocket and can't use the JWT temp-token flow there, so the only thing that
// works client-side is subprotocol auth with the API key: new WebSocket(url,
// ["token", key]). We return the key from this same-origin route (it stays out
// of the JS bundle; rotate it after the event).
import { NextResponse } from "next/server";

export const runtime = "nodejs";

export async function POST() {
  const key = process.env.DEEPGRAM_API_KEY;
  if (!key) {
    return NextResponse.json({ error: "DEEPGRAM_API_KEY is not set." }, { status: 500 });
  }
  return NextResponse.json({ key });
}

```

### app/api/tts/route.ts

```typescript
// app/api/tts/route.ts — POST { text }, get { audioBase64 } (MP3) for a ref callout.
import { NextResponse } from "next/server";
import { synthesizeSpeech } from "@/lib/tts";

export const runtime = "nodejs";

export async function POST(req: Request) {
  let text: unknown;
  try {
    ({ text } = await req.json());
  } catch {
    return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
  }
  if (typeof text !== "string" || text.trim().length === 0) {
    return NextResponse.json({ error: "Body must include a non-empty 'text' string." }, { status: 400 });
  }

  try {
    const audioBase64 = await synthesizeSpeech(text);
    return NextResponse.json({ audioBase64 });
  } catch (err) {
    console.error("[/api/tts]", err);
    return NextResponse.json({ error: (err as Error).message || "TTS failed." }, { status: 500 });
  }
}

```

### app/api/verdict/route.ts

```typescript
// app/api/verdict/route.ts — POST a finished DebateSession, get { winner, verdict }.
import { NextResponse } from "next/server";
import { generateVerdict } from "@/lib/verdict";
import type { DebateSession } from "@/lib/types";

export const runtime = "nodejs";

export async function POST(req: Request) {
  let session: DebateSession;
  try {
    session = (await req.json()) as DebateSession;
  } catch {
    return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
  }
  if (!session || !Array.isArray(session.turns)) {
    return NextResponse.json({ error: "Body must be a DebateSession with a 'turns' array." }, { status: 400 });
  }

  try {
    return NextResponse.json(await generateVerdict(session));
  } catch (err) {
    console.error("[/api/verdict]", err);
    return NextResponse.json({ error: (err as Error).message || "Verdict failed." }, { status: 500 });
  }
}

```

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

```typescript
// app/api/analyze/route.ts — POST a turn's text, get back scored analysis.
// This is the seam Chester swaps onto at T+6h: the UI can call this per turn.
import { NextResponse } from "next/server";
import { analyzeTurn } from "@/lib/analyze";

// node-redis + Transformers.js need the Node runtime (not edge).
export const runtime = "nodejs";

export async function POST(req: Request) {
  let body: unknown;
  try {
    body = await req.json();
  } catch {
    return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
  }

  const { text, previousTurnText, topic } = (body ?? {}) as {
    text?: unknown;
    previousTurnText?: unknown;
    topic?: unknown;
  };

  if (typeof text !== "string" || text.trim().length === 0) {
    return NextResponse.json(
      { error: "Body must include a non-empty 'text' string." },
      { status: 400 }
    );
  }

  try {
    const analysis = await analyzeTurn({
      text,
      previousTurnText: typeof previousTurnText === "string" ? previousTurnText : undefined,
      topic: typeof topic === "string" ? topic : undefined,
    });
    return NextResponse.json(analysis);
  } catch (err) {
    console.error("[/api/analyze]", err);
    return NextResponse.json(
      { error: (err as Error).message || "Analysis failed." },
      { status: 500 }
    );
  }
}

```

### app/api/transcribe/route.ts

```typescript
// app/api/transcribe/route.ts — audio in, diarized turns out.
//   POST { source: "demo" }            -> uses the configured demo clip
//   POST multipart form, field "audio" -> uses the uploaded file
// Returns { topic, turns: RawTurn[] }.
import { NextResponse } from "next/server";
import { writeFile, unlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { transcribeClip } from "@/lib/deepgram";
import { segmentTurns } from "@/lib/segmentation";
import { resolveDemoClipPath, DEMO_TOPIC } from "@/lib/demoClip";

export const runtime = "nodejs"; // Deepgram SDK + fs are Node-only

export async function POST(req: Request) {
  const contentType = req.headers.get("content-type") || "";
  let tempPath: string | null = null;

  try {
    let clipPath: string;

    if (contentType.includes("multipart/form-data")) {
      const form = await req.formData();
      const file = form.get("audio");
      if (!(file instanceof File)) {
        return NextResponse.json({ error: "Expected an 'audio' file field." }, { status: 400 });
      }
      const buffer = Buffer.from(await file.arrayBuffer());
      const safeName = (file.name || "upload").replace(/[^a-z0-9.]/gi, "_");
      clipPath = path.join(os.tmpdir(), `debate-${Date.now()}-${safeName}`);
      await writeFile(clipPath, buffer);
      tempPath = clipPath;
    } else {
      const demo = resolveDemoClipPath();
      if (!demo) {
        return NextResponse.json(
          { error: "No demo clip found. Set DEMO_CLIP_PATH or drop an audio file in samples/." },
          { status: 400 }
        );
      }
      clipPath = demo;
    }

    const utterances = await transcribeClip(clipPath);
    const turns = segmentTurns(utterances);
    return NextResponse.json({ topic: DEMO_TOPIC, turns });
  } catch (err) {
    console.error("[/api/transcribe]", err);
    return NextResponse.json({ error: (err as Error).message || "Transcription failed." }, { status: 500 });
  } finally {
    if (tempPath) await unlink(tempPath).catch(() => {});
  }
}

```

### next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Transformers.js (local embeddings, used in Batch 2) ships native/.wasm
  // assets that Next should not try to bundle into server code.
  serverExternalPackages: ["@huggingface/transformers"],
};

export default nextConfig;

```

### tailwind.config.ts

```typescript
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./lib/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      colors: {
        // Shared speaker colors so transcript + scoreboard always agree.
        speakerA: { DEFAULT: "#3b82f6", soft: "#1e3a5f" }, // blue
        speakerB: { DEFAULT: "#f97316", soft: "#5c2e0e" }, // orange
      },
    },
  },
  plugins: [],
};

export default config;

```

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