# Project export: Trustfall

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: Trustfall helps collect privacy-protected, real human judgments on suspicious and legitimate messages without storing raw private message text.
- Devpost: https://devpost.com/software/trustfall-oxibqa
- GitHub: https://github.com/lukejun001/Trustfall
- Video: https://www.youtube.com/embed/Nb5Zr57a4go?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 2 GitHub contributor(s) — Claude Opus 4.8 (1M context) (2 commits), Luke Jun (1 commits)

## Devpost submission (written by the team)

### Inspiration

Suspicious messages are one of the most common ways people encounter scams, phishing, impersonation, and social engineering. Not to mention, there is a huge vunerability group: old people using outdated email handlers like Yahoo, in which there is a lot of evidence showing that it cannot reliably filter scams itself. But building better classifiers for these messages is hard because the best examples often come from real emails, texts, DMs, and marketplace posts that may contain sensitive personal information.

### What it does

Trustfall helps collect privacy-protected, real human judgments on suspicious and legitimate messages without storing raw private message text, and trains models based on this collected data to improve its general judgement.

### How we built it

Next.js App Router, Prisma, SQLite locally, browser-side .eml parsing, server-side redaction, Terac callback integration, train/eval exports, and a Qwen baseline workflow.

### Challenges we ran into

The hardest part was balancing usefulness and privacy. Scam and phishing messages are valuable because they are realistic, but realism can also mean they contain names, phone numbers, links, codes, addresses, or other sensitive details. We had to design the workflow so the browser, the API, and the participant review step all reduce privacy risk before data is stored. Another challenge was separating collection from labeling. We did not want workers to label their own submissions, and we wanted Wave 2 to depend on messages collected from other participants. That required thinking carefully about task ordering, label counts, and how to prioritize messages with fewer labels. We also had to keep the project lightweight enough for a hackathon while still making the data pipeline auditable. Instead of adding a large external dataset or synthetic labels, we focused on making a clean Terac-only workflow with deterministic exports and a baseline evaluation path.

### Accomplishments we're proud of

We are proud that Trustfall treats privacy as a core product feature, not an afterthought. Raw message text is only held while the participant is filling out the form, and stored records are redacted before they can be used downstream. We are also proud of the two-wave workflow. It creates a cleaner separation between message collection and human labeling, which makes the resulting dataset more trustworthy. The app includes practical details like .eml parsing, attachment stripping, redacted previews, participant attestations, label-count prioritization, admin visibility, and train/eval exports. Most importantly, the system is designed around real Terac-collected human labels. That makes the project more realistic than a demo built on public or synthetic data, and it gives us a foundation for measuring whether fine-tuning actually improves over a base model on held-out examples.

### What we learned

We learned that data collection UX matters just as much as model training. If the contribution flow feels unsafe or confusing, people will not provide high-quality examples. Showing a redacted preview before submission gives participants more control and makes the privacy model easier to understand. We also learned that “human-labeled data” is not automatically trustworthy. You need clear task separation, duplicate labels, export rules, and held-out evaluation to make the data useful for training. Building the workflow forced us to think about provenance, consent, privacy, and evaluation as one connected system. Finally, we learned that a small, focused dataset collected under the right constraints can be more valuable than a large dataset with unclear provenance.

### What's next

Next, we want to complete the full fine-tuning loop: collect more Terac-labeled examples, train on the sanitized human-labeled training split, and compare the fine-tuned model against the Qwen baseline on held-out evaluation messages. We also want to improve the labeling interface with clearer rubrics, disagreement tracking, confidence scores, and adjudication for messages where human labels conflict. On the privacy side, we want to add stronger redaction checks, better preview highlighting, and support for more message formats. Longer term, Trustfall could become a general-purpose human-data collection layer for safety-focused classifiers: phishing, scams, marketplace fraud, impersonation, spam, and other high-risk communication patterns where realistic examples matter but privacy cannot be compromised.

## README (from the GitHub repository)

# Trustfall Terac workflow

Trustfall collects privacy-protected, real human judgments about suspicious and legitimate messages during a Terac event. It is a small Next.js App Router project using Prisma and SQLite locally.

> **Only Terac-collected labels should be used for training.** Do not add public datasets, Kaggle, Hugging Face, or AI-generated labels to the training set.

## Run locally

```bash
npm install
cp .env.example .env
npx prisma migrate dev --name init
npm run dev
```

Visit `http://localhost:3000/terac?mode=collect&teracSubmissionId=test123` for **Trustfall Wave 1: Message Collection**. Workers submit one suspicious and one normal message, review browser-redacted previews, and return directly to Terac—no labeling is part of this wave. Wave 2 labeling remains separate and requires messages from other participants.

Wave 1 defaults to a local `.eml` upload: the browser parses it, strips attachments and raw headers, and sends only the worker-approved sanitized preview to the API. Use the visible paste-text fallback for SMS, DMs, marketplace posts, and other non-email messages. Run `npm run test:sanitization` to check `.eml` parsing and redaction behavior.

Visit `http://localhost:3000/terac?mode=label&teracSubmissionId=test-label-1` for **Trustfall Wave 2: Message Labeling**. Each worker receives `LABELS_PER_PARTICIPANT` redacted messages (8 by default), prioritized by fewest existing labels. Labeling cannot begin until enough Wave 1 messages exist.

The admin overview is at `/admin/data`. Downloadable exports are at `/api/export/train` and `/api/export/eval`; only messages with two or more human labels are included. The deterministic ID hash assigns 80% to train and 20% to holdout evaluation.

## Privacy model

Raw message text is only held in the browser while the user is filling the form. Before any write, the server replaces links, email addresses, phone numbers, and 4+ digit codes. It never stores unredacted input. Participants must also attest that they removed private information and have a right to share the message.

## Terac callback

Set `TERAC_CALLBACK_BASE_URL` to Terac’s completion callback origin or URL. `/api/terac/callback?teracSubmissionId=…` then redirects there with `teracSubmissionId` and `result=completed`. Without it, the app redirects to the local completion screen.

## Terac Wave 1 pilot API

Set `TERAC_API_KEY` only in `.env` or your deployment secret store; never expose it to the browser. `GET /api/terac/pilot` lists projects (read-only). `POST /api/terac/pilot` supports `create_project` and `create_wave1_draft`, but only after setting `TERAC_PILOT_WRITE_ENABLED=true`. The integration deliberately does not expose Terac's launch endpoint: creating a draft is safe for review, while launch begins recruitment and may incur cost.

## Pre-training Qwen baseline

Run this locally after real Wave 1 data is collected (never in Vercel): `python3 -m pip install -r requirements-baseline.txt && npm run baseline:qwen`. It reads only sanitized, non-synthetic messages and writes immutable JSONL predictions to `baseline_before_training/`; it does not train, modify, or export source records.

## Vercel

Set `DATABASE_URL` to a production-compatible Prisma database (SQLite is for local development; use a hosted relational database adapter before production deployment) and `TERAC_CALLBACK_BASE_URL` in Vercel project settings. Run `prisma generate` during build if your deployment environment does not do it automatically.


## Detected evidence (automated analysis)

Indexed codebase: 64 recognized source files, 185 KB.
- CSS (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Vercel (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (79 of 79)

```
.env.example
.gitignore
app/admin/data/page.tsx
app/api/export/eval/route.ts
app/api/export/train/route.ts
app/api/label-queue/route.ts
app/api/labels/route.ts
app/api/messages/route.ts
app/api/participants/route.ts
app/api/terac/callback/route.ts
app/api/terac/pilot/route.ts
app/api/wave2/readiness/route.ts
app/demo/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
app/terac/complete/page.tsx
app/terac/label/page.tsx
app/terac/page.tsx
app/terac/submit/page.tsx
components/PrivacyNotice.tsx
docs/captioned-demo.md
docs/claude-code-expert-adjudicator.md
docs/final-pilot.md
docs/trustfall-question-tree.md
docs/wave2-qwen.md
lib/consensus.ts
lib/constants.ts
lib/eml.ts
lib/exportData.ts
lib/prisma.ts
lib/redact.ts
lib/review.ts
lib/terac-api.ts
lib/terac.ts
lib/text-cleanup.ts
next-env.d.ts
next.config.ts
package.json
postcss.config.js
presentation/README.md
prisma/dev.db
prisma/migrations/20260621001631_init/migration.sql
prisma/migrations/20260621003800_add_wave1_collection/migration.sql
prisma/migrations/20260621004431_add_wave2_assignments/migration.sql
prisma/migrations/20260621005243_add_sanitized_email_metadata/migration.sql
prisma/migrations/20260621010922_add_synthetic_fixture_guard/migration.sql
prisma/migrations/20260621030000_add_expert_adjudication/migration.sql
prisma/migrations/migration_lock.toml
prisma/postgres/migrations/20260621020000_init/migration.sql
prisma/postgres/migrations/20260621030000_add_expert_adjudication/migration.sql
prisma/postgres/migrations/migration_lock.toml
prisma/postgres/schema.prisma
prisma/schema.prisma
README.md
requirements-baseline.txt
requirements-qwen-training.txt
scripts/adjudicate_messages.py
scripts/build_pilot_workbook.py
scripts/build_wave2_dataset.py
scripts/evaluate_qwen_lora.py
scripts/expert_adjudications.json
scripts/export-eval.ts
scripts/export-train.ts
scripts/full-e2e.ts
scripts/generate_presentation.cjs
scripts/generate-fake-eml-fixtures.ts
scripts/qa-import-fake-eml.ts
scripts/qa-report-fake-eml.ts
scripts/qwen_training_common.py
scripts/record_demo.cjs
scripts/refresh_presentation_metrics.py
scripts/run_qwen_baseline.py
scripts/run_wave2_qwen.py
scripts/test-sanitization.ts
scripts/train_qwen_lora.py
tailwind.config.ts
tmp/trustfall-e2e-test.db
tsconfig.json
```

### Dependencies

- package.json: @prisma/client@^6.7.0, @types/node@^22.15.0, @types/react@^19.1.0, @types/react-dom@^19.1.0, autoprefixer@^10.4.21, next@^15.3.0, playwright@^1.61.0, postcss@^8.5.3, pptxgenjs@^4.0.1, prisma@^6.7.0, react@^19.1.0, react-dom@^19.1.0, tailwindcss@^3.4.17, tsx@^4.19.0, typescript@^5.8.0, zod@^3.24.0

### Recent commits (newest first)

- Add Trustfall progressive question tree
- Add safe captioned Playwright product demo
- Add visual Excel pilot-stats workbook generator
- Add expert adjudication layer and freeze final-pilot dataset
- Add Claude Code expert adjudication handoff
- Add refreshable Trustfall project presentation
- Make Qwen LoRA training fit Apple MPS memory
- Add Wave 2 Qwen LoRA training pipeline
- Fix Qwen baseline Postgres execution
- Count only reviewable messages for Wave 2 readiness
- Replace private greeting names with placeholders
- Exclude gibberish and incomplete records from labeling
- Clean MIME and mojibake artifacts in reviews
- Prepare guarded Wave 2 labeling rollout
- Add Qwen pre-training baseline runner
- Show readable sanitized submissions in admin
- Send JSON body for Terac opportunity launch
- Add guarded Terac pilot launch action
- Merge remote-tracking branch 'origin/main'
- Initial commit

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

### docs/captioned-demo.md

```markdown
# Captioned product demo

The `/demo` route is a fully synthetic, non-persistent walkthrough. It is safe to record: it does not show worker messages or call production write APIs.

After `/demo` is deployed, install the recorder once and create the video:

```bash
npm install
npx playwright install chromium
DEMO_BASE_URL="https://trustfall-8eks.vercel.app" npm run demo:record
```

The output is `demo-recordings/trustfall-captioned-demo.webm`. It already contains synchronized on-screen captions. Upload it directly to Loom, YouTube, or Drive. If a platform needs MP4, convert it with any local video converter after recording.

The five caption beats are built into the script: product overview, collection, redaction, human labeling, and measured model result.

```

### docs/wave2-qwen.md

```markdown
# Wave 2 Qwen LoRA pipeline

Run this only after Wave 2 has produced at least two completed labels for several real messages. It uses only sanitized, non-synthetic messages, excludes unreadable submissions, and leaves the database unchanged.

```bash
cd "/Users/LukeJun/Desktop/Coding Projects/Hackathon Berkley 2026/trustfall-terac"
python3 -m venv .venv-qwen
.venv-qwen/bin/pip install -r requirements-qwen-training.txt
set -a; source .env.local; set +a
.venv-qwen/bin/python scripts/run_wave2_qwen.py --device mps
```

Before running the pipeline, apply the expert adjudication layer so messages that lack worker consensus still receive a defensible final target (see [final-pilot.md](final-pilot.md)):

```bash
set -a; source .env.local; set +a
.venv-qwen/bin/python scripts/adjudicate_messages.py   # idempotent upsert; prints counts only
```

The pipeline (`run_wave2_qwen.py`) then runs four ordered steps:

1. **build** — freeze a versioned dataset. Final target per message is the expert adjudication when present, otherwise worker consensus when it meets the gate, otherwise the message is excluded.
2. **baseline** — score the base Qwen model on the frozen **test** inputs *before* training.
3. **train** — fine-tune the LoRA adapter on the **train** split only.
4. **evaluate** — run the untouched **test** split once and compare against the baseline.

It creates local-only, Git-ignored artifacts:

- `wave2_training_data/<timestamp>/`: immutable `train.jsonl`, `validation.jsonl`, `test.jsonl`, and `manifest.json` (provenance counts, split counts, small-data limitation).
- `baseline_before_training/<timestamp>/test-baseline.jsonl`: base-model predictions on the test inputs.
- `qwen_lora_artifacts/<timestamp>/`: the Qwen LoRA adapter, not a replacement base model.
- `wave2_evaluation/<timestamp>/comparison.json`: baseline vs. fine-tuned test comparison (valid-JSON rate, risk exact and within-one-level accuracy, scam-type accuracy, red-flag F1).

The split is deterministic at the message level (70/15/15 by a hash of the message id). A message and its labels can never appear in two splits. The default quality gate requires at least two independent labels and at least 50% agreement on both risk level and scam type. Validation is reserved for any training-setting choice; the test split is used exactly once.

For this small pilot, report the resulting comparison as a proof-of-concept. Do not claim production readiness from this data volume alone.

```

### package.json

```
{
  "name": "trustfall-terac",
  "private": true,
  "version": "0.1.0",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev",
    "prisma:postgres:generate": "prisma generate --schema=prisma/postgres/schema.prisma",
    "prisma:postgres:deploy": "prisma migrate deploy --schema=prisma/postgres/schema.prisma",
    "vercel-build": "prisma generate --schema=prisma/postgres/schema.prisma && prisma migrate deploy --schema=prisma/postgres/schema.prisma && next build",
    "export:train": "tsx scripts/export-train.ts",
    "export:eval": "tsx scripts/export-eval.ts",
    "test:sanitization": "tsx scripts/test-sanitization.ts",
    "qa:eml:generate": "tsx scripts/generate-fake-eml-fixtures.ts",
    "qa:eml:import": "tsx scripts/qa-import-fake-eml.ts",
    "qa:eml:report": "tsx scripts/qa-report-fake-eml.ts",
    "qa:eml": "npm run qa:eml:generate && npm run qa:eml:import && npm run qa:eml:report",
    "test:e2e:full": "mkdir -p tmp && rm -f tmp/trustfall-e2e-test.db && touch tmp/trustfall-e2e-test.db && DATABASE_URL='file:../tmp/trustfall-e2e-test.db' npx prisma migrate deploy && DATABASE_URL='file:../tmp/trustfall-e2e-test.db' tsx scripts/full-e2e.ts",
    "baseline:qwen": "python3 scripts/run_qwen_baseline.py",
    "dataset:wave2": "python3 scripts/build_wave2_dataset.py",
    "train:qwen-lora": "python3 scripts/train_qwen_lora.py",
    "eval:qwen-lora": "python3 scripts/evaluate_qwen_lora.py",
    "run:wave2-qwen": "python3 scripts/run_wave2_qwen.py",
    "presentation:refresh": "python3 scripts/refresh_presentation_metrics.py",
    "presentation:build": "node scripts/generate_presentation.cjs",
    "demo:record": "node scripts/record_demo.cjs"
  },
  "dependencies": {
    "@prisma/client": "^6.7.0",
    "next": "^15.3.0",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@types/node": "^22.15.0",
    "@types/react": "^19.1.0",
    "@types/react-dom": "^19.1.0",
    "autoprefixer": "^10.4.21",
    "playwright": "^1.61.0",
    "postcss": "^8.5.3",
    "pptxgenjs": "^4.0.1",
    "prisma": "^6.7.0",
    "tailwindcss": "^3.4.17",
    "tsx": "^4.19.0",
    "typescript": "^5.8.0"
  }
}

```

### app/layout.tsx

```typescript
import "./globals.css";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Trustfall · Terac", description: "Human-labeled scam safety data collection" };
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="en"><body>{children}</body></html>; }

```

### app/page.tsx

```typescript
import Link from "next/link";
export default function Home() { return <main className="shell"><div className="card"><p className="text-cyan-300">Trustfall</p><h1 className="mt-2 text-4xl font-bold">Human judgment makes scam safety better.</h1><p className="mt-4 text-slate-300">Collect privacy-protected, human-labeled examples through Terac.</p><Link className="btn mt-6" href="/terac">Open Terac workflow</Link></div></main>; }

```

### app/terac/page.tsx

```typescript
"use client";
import Link from "next/link";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { PrivacyNotice } from "@/components/PrivacyNotice";
function TeracLanding() { const params = useSearchParams(); const mode = params.get("mode") === "label" ? "label" : "collect"; const providedId = params.get("teracSubmissionId"); const id = providedId ?? crypto.randomUUID(); const local = providedId ? "" : "&local=1"; if (mode === "label") return <main className="shell"><div className="card"><p className="font-semibold text-cyan-300">TERAC WORKER TASK · WAVE 2</p><h1 className="mt-2 text-3xl font-bold">Trustfall Message Labeling Task</h1><p className="mt-3 text-lg text-slate-300">You are helping label redacted messages so Trustfall can learn what scams look like and how to explain risks clearly.</p><div className="mt-4 inline-block rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1 text-sm text-cyan-100">Estimated time: 6–8 minutes</div><div className="my-6 rounded-xl border border-slate-700 bg-slate-800/70 p-4 text-slate-200">This is a paid Terac labeling task. You will review redacted messages from other participants. For each message, decide whether it looks suspicious, identify red flags, and write a short plain-English warning.</div><ol className="grid gap-3 sm:grid-cols-2"><li className="rounded-lg bg-slate-800 p-4">1. Read each redacted message carefully.</li><li className="rounded-lg bg-slate-800 p-4">2. Choose the risk level and scam type.</li><li className="rounded-lg bg-slate-800 p-4">3. Select the red flags you notice.</li><li className="rounded-lg bg-slate-800 p-4">4. Write a clear warning someone non-technical could understand.</li><li className="rounded-lg bg-slate-800 p-4 sm:col-span-2">5. Finish the task and return to Terac.</li></ol><Link className="btn mt-6" href={`/terac/label?mode=label&teracSubmissionId=${encodeURIComponent(id)}${local}`}>Start labeling task</Link></div></main>; const href = `/terac/submit?mode=collect&teracSubmissionId=${encodeURIComponent(id)}${local}`; return <main className="shell"><div className="card"><p className="font-semibold text-cyan-300">TERAC WORKER TASK · WAVE 1</p><h1 className="mt-2 text-3xl font-bold">Trustfall Message Collection Task</h1><p className="mt-3 text-lg text-slate-300">You are helping build a human-sourced scam-safety dataset. In this task, you will upload or paste two messages you personally received: one suspicious message and one normal message.</p><div className="mt-4 inline-block rounded-full border border-cyan-400/30 bg-cyan-400/10 px-3 py-1 text-sm text-cyan-100">Estimated time: 4–6 minutes</div><div className="my-6 rounded-xl border border-slate-700 bg-slate-800/70 p-4 text-slate-200">This is a paid Terac data collection task. Uploading a Gmail .eml email file is the default; paste text is available for texts, DMs, and other message types.</div><ol className="my-6 grid gap-3 sm:grid-cols-2"><li className="rounded-lg bg-slate-800 p-4">1. Upload or paste one suspicious message you personally received.</li><li className="rounded-lg bg-slate-800 p-4">2. Upload or paste one normal or legitimate message you personally received.</li><li className="rounded-lg bg-slate-800 p-4">3. Review the editable sanitized preview.</li><li className="rounded-lg bg-slate-800 p-4">4. Finish the task and return to Terac.</li></ol><PrivacyNotice /><Link className="btn mt-6" href={href}>Start collection task</Link></div></main>; }
export default function Page() { return <Suspense fallback={<main className="shell">Loading…</main>}><TeracLanding /></Suspense>; }

```

### app/demo/page.tsx

```typescript
"use client";

import { useState } from "react";

const raw = "Hi Jordan,\n\nYour package is waiting. Confirm delivery at https://track-fast.example/claim?code=884219 or call 555-0100 today.\n\nThanks,\nMaya";
const redacted = "Hi [PERSON],\n\nYour package is waiting. Confirm delivery at [LINK] or call [PHONE] today.\n\nThanks,\n[PERSON]";

export default function DemoPage() {
  const [step, setStep] = useState(0);
  const [showRaw, setShowRaw] = useState(true);
  const [labelSaved, setLabelSaved] = useState(false);

  return <main className="mx-auto max-w-6xl px-6 py-8">
    <section className="rounded-3xl border border-cyan-400/30 bg-gradient-to-br from-slate-900 to-slate-950 p-8 shadow-2xl" data-demo="hero">
      <p className="text-sm font-bold uppercase tracking-[0.24em] text-cyan-300">Trustfall · interactive product demo</p>
      <h1 className="mt-3 max-w-3xl text-5xl font-bold leading-tight">Human judgment makes scam safety better.</h1>
      <p className="mt-4 max-w-2xl text-lg text-slate-300">A privacy-first workflow from real-world signals to human labels to a measured model improvement.</p>
      <button className="btn mt-6" data-demo="begin" onClick={() => setStep(1)}>Start the safe walkthrough</button>
    </section>

    {step >= 1 && <section className="mt-8 grid gap-6 lg:grid-cols-2" data-demo="collection">
      <article className="card"><p className="font-semibold text-cyan-300">WAVE 1 · MESSAGE COLLECTION</p><h2 className="mt-2 text-2xl font-bold">Capture the signal, not the identity.</h2><p className="mt-3 text-sm text-slate-400">This is a synthetic demo message. It is never submitted or stored.</p><div className="mt-5 rounded-xl border border-slate-700 bg-slate-950 p-4"><p className="whitespace-pre-wrap text-sm leading-6 text-slate-200">{showRaw ? raw : redacted}</p></div><button className="btn mt-5" data-demo="redact" onClick={() => { setShowRaw(false); setStep(2); }}>Redact this message</button></article>
      <article className="card"><p className="font-semibold text-cyan-300">PRIVACY LAYER</p><h2 className="mt-2 text-2xl font-bold">Useful evidence survives.</h2><div className="mt-6 grid grid-cols-2 gap-3 text-sm"><div className="rounded-xl bg-slate-800 p-3"><b className="text-cyan-300">[PERSON]</b><br />Names become placeholders</div><div className="rounded-xl bg-slate-800 p-3"><b className="text-cyan-300">[LINK]</b><br />URLs are defanged</div><div className="rounded-xl bg-slate-800 p-3"><b className="text-cyan-300">[PHONE]</b><br />Contact data removed</div><div className="rounded-xl bg-slate-800 p-3"><b className="text-cyan-300">Metadata</b><br />Safety features retained</div></div><p className="mt-6 text-sm text-slate-400">The model receives sanitized text and safety-relevant structure—not raw emails, headers, or attachments.</p></article>
    </section>}

    {step >= 2 && <section className="mt-8 grid gap-6 lg:grid-cols-2" data-demo="labeling">
      <article className="card"><p className="font-semibold text-cyan-300">WAVE 2 · HUMAN LABELING</p><h2 className="mt-2 text-2xl font-bold">Independent people turn signals into judgment.</h2><div className="mt-5 rounded-xl border border-cyan-400/30 bg-slate-950 p-4"><p className="whitespace-pre-wrap text-sm leading-6">{redacted}</p></div><div className="mt-5 grid grid-cols-2 gap-3 text-sm"><div className="rounded-lg border border-slate-600 p-3"><span className="text-slate-400">Risk level</span><br /><b>High</b></div><div className="rounded-lg border border-slate-600 p-3"><span className="text-slate-400">Scam type</span><br /><b>Delivery phishing</b></div><div className="rounded-lg border border-slate-600 p-3"><span className="text-slate-400">Red flags</span><br /><b>Urgency · Link</b></div><div className="rounded-lg border border-slate-600 p-3"><span className="text-slate-400">Safe action</span><br /><b>Use official app</b></div></div><button className="btn mt-5" data-demo="save-label" onClick={() => { setLabelSaved(true); setStep(3); }}>{labelSaved ? "Label saved" : "Save human judgment"}</button></article>
      <article className="card"><p className="font-semibold text-cyan-300">QUALITY GATE</p><h2 className="mt-2 text-2xl font-bold">Consensus before training.</h2><ol className="mt-5 space-y-4 text-sm text-slate-300"><li><span className="mr-3 text-cyan-300">01</span>Two independent labels are required.</li><li><span className="mr-3 text-cyan-300">02</span>Low-agreement and unreadable records are surfaced, not hidden.</li><li><span className="mr-3 text-cyan-300">03</span>Only final consensus targets enter a versioned training snapshot.</li></ol></article>
    </section>}

    {step >= 3 && <section className="mt-8 grid gap-5 sm:grid-cols-3" data-demo="results">
      {[ ["50", "real sanitized messages"], ["78", "human labels saved"], ["100%", "valid JSON after pilot LoRA"] ].map(([value, label]) => <article className="card" key={label}><p className="text-4xl font-bold text-cyan-300">{value}</p><p className="mt-2 text-sm text-slate-300">{label}</p></article>)}
      <article className="card sm:col-span-3"><p className="font-semibold text-cyan-300">MODEL RESULT · SMALL-DATA PILOT</p><h2 className="mt-2 text-2xl font-bold">Measured improvement, presented honestly.</h2><div className="mt-5 grid gap-4 sm:grid-cols-3"><div className="rounded-xl bg-slate-800 p-4"><span className="text-sm text-slate-400">Valid JSON</span><p className="mt-2 text-xl font-bold">75% → <span className="text-cyan-300">100%</span></p></div><div className="rounded-xl bg-slate-800 p-4"><span className="text-sm text-slate-400">Risk exact match</span><p className="mt-2 text-xl font-bold">0% → <span className="text-cyan-300">25%</span></p></div><div className="rounded-xl bg-slate-800 p-4"><span className="text-sm text-slate-400">Scam type exact</span><p className="mt-2 text-xl font-bold">0% → <span className="text-cyan-300">25%</span></p></div></div><p className="mt-4 text-xs text-slate-400">Pilot result on a four-message frozen holdout. Trustfall
[truncated — 100 more characters]
```

### app/api/participants/route.ts

```typescript
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) { const { teracSubmissionId, mode = "collect" } = await request.json(); if (!teracSubmissionId || typeof teracSubmissionId !== "string") return NextResponse.json({ error: "Missing participant ID." }, { status: 400 }); const participant = await prisma.teracParticipant.findUnique({ where: { teracSubmissionId } }); if (!participant) return NextResponse.json({ error: "Participant not found." }, { status: 404 }); const messages = await prisma.collectedMessage.count({ where: { submittedByParticipantId: participant.id } }); if (mode === "collect") { if (messages < 2) return NextResponse.json({ error: `Collection incomplete: ${messages}/2 messages.` }, { status: 400 }); await prisma.teracParticipant.update({ where: { id: participant.id }, data: { wave: "collect", status: "collection_completed", collectionCompletedAt: new Date(), completedAt: new Date() } }); return NextResponse.json({ ok: true }); } const assignments = await prisma.labelAssignment.findMany({ where: { participantId: participant.id } }); if (!assignments.length || assignments.some(assignment => !assignment.completedAt)) return NextResponse.json({ error: `Labeling incomplete: ${assignments.filter(assignment => assignment.completedAt).length}/${assignments.length || "?"} messages.` }, { status: 400 }); await prisma.teracParticipant.update({ where: { id: participant.id }, data: { wave: "label", status: "label_completed", labelCompletedAt: new Date(), completedAt: new Date() } }); return NextResponse.json({ ok: true }); }

```

### app/terac/complete/page.tsx

```typescript
"use client";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
function CompletePage() { const q = useSearchParams(); const id = q.get("teracSubmissionId") ?? ""; const mode = q.get("mode") === "label" ? "label" : "collect"; const local = q.get("local") === "1"; const [state, setState] = useState("Verifying completion…"); useEffect(() => { if (!id) return setState("Local testing completion."); fetch("/api/participants", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ teracSubmissionId: id, mode }) }).then(async r => { const d = await r.json(); if (!r.ok) throw new Error(d.error); setState("complete"); }).catch(e => setState(e.message)); }, [id, mode]); const complete = state === "complete" || (!id && local); const thanks = mode === "label" ? "Thanks — your labels were submitted." : "Thanks — your redacted messages were submitted."; return <main className="shell"><div className="card text-center"><p className="text-cyan-300">TERAC WORKER TASK · {mode === "label" ? "WAVE 2" : "WAVE 1"}</p><h1 className="mt-2 text-3xl font-bold">{complete ? thanks : "Almost there"}</h1><p className="mt-4 text-slate-300">{complete ? (local ? "Local testing completion. No Terac callback was requested." : "Your task is complete. Return to Terac to record completion.") : state}</p>{complete && !local && id && <a className="btn mt-6" href={`/api/terac/callback?teracSubmissionId=${encodeURIComponent(id)}`}>Return to Terac</a>}</div></main>; }
export default function Page() { return <Suspense fallback={<main className="shell">Loading…</main>}><CompletePage /></Suspense>; }

```

### app/api/label-queue/route.ts

```typescript
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { isReviewableForLabeling } from "@/lib/review";
const labelsPerParticipant = () => { const n = Number(process.env.LABELS_PER_PARTICIPANT ?? 8); return Number.isInteger(n) && n > 0 ? n : 8; };
export async function GET(request: Request) {
  const id = new URL(request.url).searchParams.get("teracSubmissionId"); if (!id) return NextResponse.json({ error: "Missing participant ID" }, { status: 400 });
  const required = labelsPerParticipant();
  const participant = await prisma.teracParticipant.upsert({ where: { teracSubmissionId: id }, update: { wave: "label" }, create: { teracSubmissionId: id, wave: "label", status: "label_started" } });
  let assignments = await prisma.labelAssignment.findMany({ where: { participantId: participant.id }, include: { message: { select: { id: true, sanitizedText: true, source: true, shortContext: true } } }, orderBy: { position: "asc" } });
  if (!assignments.length) {
    const candidates = await prisma.collectedMessage.findMany({ where: { isSyntheticTestFixture: false, submittedByParticipantId: { not: participant.id }, labels: { none: { labeledByParticipantId: participant.id } }, assignments: { none: { participantId: participant.id } } }, include: { _count: { select: { labels: true } } } });
    const reviewable = candidates.filter(message => isReviewableForLabeling(message.sanitizedBody ?? message.sanitizedText));
    if (reviewable.length < required) return NextResponse.json({ ready: false, required, available: reviewable.length, messages: [] });
    const shuffled = reviewable.sort((a, b) => a._count.labels - b._count.labels || Math.random() - 0.5).slice(0, required);
    await prisma.$transaction(shuffled.map((message, position) => prisma.labelAssignment.create({ data: { participantId: participant.id, messageId: message.id, position } })));
    assignments = await prisma.labelAssignment.findMany({ where: { participantId: participant.id }, include: { message: { select: { id: true, sanitizedText: true, source: true, shortContext: true } } }, orderBy: { position: "asc" } });
  }
  const completed = assignments.filter(assignment => assignment.completedAt).length;
  return NextResponse.json({ ready: true, required, completed, messages: assignments.map(assignment => ({ ...assignment.message, assignmentId: assignment.id, completed: Boolean(assignment.completedAt) })) });
}

```

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