# Project export: LightHome

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: LightHome detects hidden risk patterns in online conversations before explicit content appears.
- Devpost: https://devpost.com/software/lighthome
- GitHub: https://github.com/minjunggho/LightHome.git
- Video: https://www.youtube.com/embed/_z39BkMkDRo?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — kevincui1034 (15 commits), minjunggho (10 commits), Claude Opus 4.8 (9 commits)

## Devpost submission (written by the team)

### Inspiration

Online grooming rarely begins with explicit language. It begins with something ordinary: a compliment, a shared interest, a private joke. The danger is the shift from friendly rapport into isolation, secrecy, and boundary testing, and that shift usually contains no flagged words. The scale makes this urgent. In 2023, NCMEC received more than 36 million CyberTipline reports, including 186,000 online enticement reports, up 300 percent from the prior year. Research shows a predator can move from first contact to high-risk grooming in about 45 minutes. Most systems wait for explicit content, the stage where the bad words finally appear. By then it is too late. LightHome was built around one idea: do not wait for the explicit message, catch the dangerous shape earlier. Our demo line is that we catch it at minute 12.

### What it does

LightHome analyzes the structure of a conversation over time, not just its vocabulary. Every message runs through structural signals: directionality, reciprocity, boundary recycling, and escalation velocity. Instead of only asking whether someone said a banned word, it asks who is asking the personal questions, whether vulnerability flows both ways, whether one person is separating the child from parents and peers, and whether the same boundary push returns after hesitation. Those signals feed a Bayesian stage tracker that maintains live probabilities across four stages: trust-building, isolation, desensitization, and escalation. An alert fires only when four structural conditions hold at the same time, which is what keeps the negative cases quiet. A teen saying "don't tell my mom" does not trip the alert, and the trust-and-safety view shows exactly which condition held it green. LightHome has two views. The parent dashboard shows risk progression and guidance without exposing raw private messages. The platform trust-and-safety view shows the transcript, the per-message feature breakdown, and the full decision trace for a human moderator.

### How we built it

LightHome is an explainable pipeline with separate layers for feature extraction, stage tracking, AI synthesis, state, and observability. The four structural extractors run in local Python before Claude ever sees the message. Claude then synthesizes the message and those features into a stage-likelihood distribution, which feeds the Bayesian tracker that updates the conversation's stage probabilities over time. Claude is one input, not the brain. Because the structural features are computed independently, they can overrule the model when it is wrong, and we log that feature-versus-Claude disagreement as a first-class, queryable signal in our trace. There is one internal decision record, and the API projects it into two views, so "the parent never sees raw messages" is enforced at the API boundary, not by frontend convention. Stack: Python and FastAPI, custom NLP feature extractors (spaCy, VADER, sentence-transformers), a Bayesian stage tracker, and a Next.js and Tailwind dashboard with a live message stream, probability bar, stage timeline, and conditions table. Sponsors we built with Claude (Anthropic) is our stage-likelihood synthesis layer. It takes the message plus the four locally-computed features and returns a probability distribution over the four grooming stages via structured output. It also condenses a dashboard answer into one or two spoken sentences for the voice summary. Crucially it is one input, not the brain, and the structural features can overrule it. Redis holds per-session conversation state so the Bayesian tracker keeps a running prior across the message stream. It sits behind a state-store protocol and swaps to an in-memory fallback with one line if Redis is down. Arize (Phoenix) is our decision observability and audit trail. Every Claude call is captured as a span with the features in and stage likelihoods out, and we log the feature-versus-Claude disagreement as a first-class, queryable attribute. A local JSONL fallback keeps traces available offline. Sentry monitors the detection system itself, tracking real alert-fire events and pipeline failures rather than a bare init. Deepgram powers voice. Its listen API transcribes the trust-and-safety composer mic (speech to text), and its Aura TTS voices the spoken dashboard summary (text to speech), so a moderator can ask and hear instead of only typing and reading.

### Challenges we ran into

The hardest part was avoiding a keyword detector. Surface language is misleading: "don't tell my mom yet" can be risky in one context and ordinary in another. The system has to read structure, not panic at isolated phrases. That is why the negative demos mattered as much as the positive one. We built three fictional scenarios: a grooming arc that should trigger, a coach-student conversation that should not, and a teen relationship with casual secrecy language that should not. We also kept the demo restrained, since the point is that the risky pattern appears before any explicit content exists.

### Accomplishments we're proud of

LightHome is explainable by design. Every alert breaks down into raw features, stage likelihoods, Bayesian priors and posteriors, alert status, and alert reasons. We are proud of the false-positive case: a system that flagged every private teen conversation would be harmful and unusable, and LightHome shows why similar words do not mean similar risk. The parent view communicates risk progression without turning the product into a surveillance feed.

### What we learned

Detecting obvious bad content is not the hard part. The hard part is detecting early structural movement while preserving context, uncertainty, and human judgment. Safety systems need to be humble: expose their reasoning, show uncertainty, and keep humans in the loop.

### What's next

Next, we want to evaluate LightHome against larger datasets and measure false positives and false negatives more rigorously. We would also improve the feature extractors, expand the trust-and-safety workflow, and test the system across more conversation styles. Our long-term plan is to integrate LightHome into more platforms, especially platforms where many children and teenagers communicate online. This could include gaming communities, education platforms, social apps, messaging tools, and creator communities. The goal is not to replace existing moderation systems, but to add an early structural risk layer that can detect concerning progression before explicit content appears. We also want to build better platform integrations so LightHome can work as an API layer for trust-and-safety teams. Platforms could send conversation events to LightHome, receive structured risk records, and review explainable alerts through a moderation dashboard. LightHome was built and demoed entirely with fictional scenarios. A real deployment would require expert review, privacy review, child safety partnerships, and careful evaluation with organizations such as Thorn, NCMEC, and platform trust-and-safety teams. LightHome is a prototype, not a replacement for professional child safety work. Its purpose is to show how AI can help detect harmful conversation patterns earlier, more explainably, and with humans still in control. Safety and deployment note Built and demoed entirely on fictional scenarios. It is a prototype, not a replacement for professional child safety work. Any real deployment would require expert review, privacy review, and rigorous evaluation with organizations. However, since this is a project for the betterment of society, we believe that using AI for this purpose should be the goal of AI in general.

## README (from the GitHub repository)

# Lighthome

> Catch the *shape* of an online grooming conversation — not the bad words — and alert a parent before anything explicit is said.

Most safety systems wait for **Stage 4** (explicit content), because that's where the flagged words are. By then it's too late. Grooming follows a documented staged arc — **Trust → Isolation → Desensitization → Escalation** — and the real signal is the **Stage 1 → Stage 2 transition**: the structural shift from friendly to isolating. That transition contains *no flagged words*. Lighthome catches it there.

Research shows a predator can move from first contact to high-risk grooming in **~45 minutes**. Our demo line: **we catch it at minute 12.**

---

## How it works

```
[Message]
   → 4 feature extractors        (local Python, run BEFORE Claude sees anything)
   → Claude synthesis            (features + message → stage likelihoods)
   → Bayesian update             (prior P → posterior P over the 4 stages)
   → Alert engine                (fires only when 4 structural conditions hold at once)
   → ONE DecisionRecord          → { Redis state, Arize/Phoenix trace, Sentry }
   → API projects to a view      → parent | tns
   → frontend renders
```

The thing that makes this more than a prompt wrapper: the four structural features are computed **independently** of Claude, so when Claude is wrong the features can **overrule** it — and that disagreement is logged as a first-class, queryable signal in the Arize/Phoenix trace.

**Privacy by projection.** There is one internal `DecisionRecord`. The API serves two views — `parent` (probabilities, dominant stage, alert level, guidance — *nothing else*) and `tns` (the full record). "The parent never sees raw messages" is enforced at the **API boundary**, not by frontend convention.

The alert fires only when **all four** structural conditions hold simultaneously (`harmful_mass`, `directionality`, `reciprocity`, `velocity`). This is what keeps the negative cases green — a teen saying *"don't tell my mom"* does **not** trip the alert, and the T&S panel shows exactly *which* condition held it green.

---

## Tech stack

### Backend — Python / FastAPI
| Tool | Role |
|------|------|
| **FastAPI + Uvicorn** | `/analyze-message` API, live SSE stream, view projections |
| **Pydantic v2** | `DecisionRecord` contract + `parent`/`tns` projections |
| **spaCy** | directionality / linguistic feature extraction |
| **VADER (vaderSentiment)** | sentiment signal for the desensitization feature |
| **sentence-transformers** | embeddings for boundary-recycling + escalation velocity |
| **NumPy** | Bayesian stage tracker math |
| **pytest + httpx** | acceptance + contract test harness |

### Frontend — Next.js / React
| Tool | Role |
|------|------|
| **Next.js 16** (App Router, Turbopack) | parent dashboard, T&S panel, marketing landing |
| **React 19** | UI |
| **Tailwind CSS v4** | styling (CSS-first, no config file) |
| **Zustand** | client state |
| **Zod** | schema validation |
| **lucide-react** | icons |
| **OpenUI** (`@openuidev/*`) | generative-UI dashboard playground |

### Ingestion (optional, off the critical path)
- **`ingestion/photon/`** — Photon/Spectrum iMessage → Contract B adapter (TypeScript, dry-run by default).

---

## Sponsors & what we built with them

| Sponsor | How we used it |
|---------|----------------|
| **Anthropic (Claude)** | The **stage-likelihood synthesis layer**. Claude takes the message + the four locally-computed structural features and returns a probability distribution over the four grooming stages via `messages.parse()`. Crucially it is *one input among several, not the brain* — the structural features run before it and can overrule it. Offline feature-fallback keeps the demo running if the API is unavailable. |
| **Arize (Phoenix)** | **Decision observability / audit trail.** Every Claude synthesis call is captured as an OpenInference span (prompt, structural features in, stage likelihoods out, latency) via OTel auto-instrumentation. We log the **feature-vs-Claude disagreement** as first-class span attributes, making "the structure overruled the model" *queryable* in the Phoenix UI — our single best technical-credibility moment. |
| **Redis** | **Conversation state & arc tracking.** Holds per-session state across the message stream so the Bayesian tracker maintains a running prior. Coded behind a `StateStore` protocol — swaps to an in-memory `DictStateStore` with one line if Redis is down. |
| **Sentry** | **Error + alert-event monitoring.** Not a bare `init()` — it tracks real alert-fire events and pipeline failures so we can show live monitoring of the detection system itself. |
| **Deepgram** | **Dashboard Analysis Summarization and Text to Speech** Not a bare `init()` —  it allows the users to ask questions directly and get answers back from the generated dashboard, so it processes the analytics generated and is not just a summarizer. |


---

## Repository structure

```
lighthome/
├── CLAUDE.md                    # project context & working agreement (AI + team)
├── CONTRACTS.md                 # interface specs across role boundaries (A–E)
├── PRODUCT.md                   # users, brand, design principles
├── HANDOFF.md                   # session/state handoff notes
│
├── backend/                     # Person 1 — FastAPI backend + AI pipeline
│   ├── app/
│   │   ├── main.py              # Contract B endpoints + live SSE read path
│   │   ├── models.py            # Contract C: DecisionRecord + parent/tns views
│   │   ├── features.py          # the 4 feature extractors (run before Claude)
│   │   ├── claude_synthesis.py  # Claude messages.parse() + feature fallback
│   │   ├── bayes.py             # Bayesian stage tracker (prior → posterior)
│   │   ├── alert.py             # 4-condition alert engine
│   │   ├── pipeline.py          # assembles the DecisionRecord
│   │   ├── embeddings.py        # sentence-transformers helpers
│   │   ├── protocols.py         # StateStore / RecordSink interfaces (Contracts D/E)
│   │   ├── state.py             # Redis-backed conversation state
│   │   ├── record_store.py      # RecordSink implementation
│   │   ├── telemetry.py         # Arize/Phoenix tracing of the Claude call
│   │   ├── observability.py     # disagreement-signal logging + local trace sink
│   │   ├── integration_bridge.py# reconciles pipeline ↔ integration lane
│   │   └── demo_loader.py       # loads the 3 demo conversations
│   ├── scripts/                 # replay.py, sentry_test.py
│   ├── tests/                   # acceptance, contract, demo-conversation tests
│   └── requirements.txt
│
├── frontend/                    # Person 2 — Next.js dashboard + T&S panel
│   ├── app/
│   │   ├── (marketing)/         # lighthouse landing page
│   │   ├── dashboard/           # overview
│   │   ├── parent/              # parent view (no raw messages)
│   │   ├── platform/            # Trust & Safety panel
│   │   └── api/chat/            # gen-UI chat route
│   ├── components/              # ProbabilityBar, StageTimeline, AlertBadge,
│   │   │                        #   ConditionsTable, AppShell, genui/*, …
│   ├── lib/
│   │   ├── api.ts               # Contract B/C client + types
│   │   ├── demos.ts             # 3 demo scripts
│   │   ├── usePlayer.ts         # demo message-player state
│   │   └── openui/              # OpenUI generative-UI playground
│   └── public/demos/            # demo conversation JSONs
│
├── ingestion/photon/            # optional iMessage → Contract B adapter (TS)
├── demo/                        # 3 demo conversations (grooming / adult / teen)
├── fixtures/                    # sample_records.json (one record per state)
└── docs/                        # architecture, devpost, pitch, reconciliation
```

---

## Running it

```bash
# Backend — live pipeline + Phoenix tracing (reads backend/.env)
cd backend
python -m venv .venv && .venv/bin/pip install -r requirements.txt
.venv/bin/python -m uvicorn app.main:a

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 93 recognized source files, 408 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — 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
- Vercel (technology) — 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 (116 of 116)

```
.gitattributes
.gitignore
backend/.env.example
backend/.gitignore
backend/app/__init__.py
backend/app/alert.py
backend/app/bayes.py
backend/app/claude_synthesis.py
backend/app/demo_data.py
backend/app/demo_loader.py
backend/app/embeddings.py
backend/app/features.py
backend/app/integration_bridge.py
backend/app/main.py
backend/app/models.py
backend/app/observability.py
backend/app/pipeline.py
backend/app/protocols.py
backend/app/record_store.py
backend/app/state.py
backend/app/telemetry.py
backend/README.md
backend/requirements.txt
backend/scripts/replay.py
backend/scripts/sentry_test.py
backend/tests/__init__.py
backend/tests/conftest.py
backend/tests/fixtures/replay_grooming_sample.json
backend/tests/test_acceptance.py
backend/tests/test_contract.py
backend/tests/test_demo_conversations.py
backend/tests/test_integration_bridge.py
CLAUDE.md
CONTRACTS.md
demo/grooming_demo.json
demo/normal_adult_demo.json
demo/teen_relationship_demo.json
docs/architecture.md
docs/devpost.md
docs/pitch.md
docs/RECONCILIATION.md
fixtures/sample_records.json
frontend/.gitignore
frontend/AGENTS.md
frontend/app/(marketing)/landing-theme.css
frontend/app/(marketing)/layout.tsx
frontend/app/(marketing)/page.tsx
frontend/app/api/chat/route.ts
frontend/app/api/transcribe/route.ts
frontend/app/api/voice-summary/route.ts
frontend/app/dashboard/page.tsx
frontend/app/globals.css
frontend/app/layout.tsx
frontend/app/parent/[sessionId]/page.tsx
frontend/app/parent/page.tsx
frontend/app/platform/page.tsx
frontend/components/AlertBadge.tsx
frontend/components/AppShell.tsx
frontend/components/ConditionsTable.tsx
frontend/components/FeatureGrid.tsx
frontend/components/FlaggedMessages.tsx
frontend/components/genui/Composer.tsx
frontend/components/genui/ConversationPanel.tsx
frontend/components/genui/GenCanvas.tsx
frontend/components/genui/HistoryView.tsx
frontend/components/genui/VoiceSummaryButton.tsx
frontend/components/LandingBackdrop.tsx
frontend/components/LighthouseHero.tsx
frontend/components/LiveDot.tsx
frontend/components/Metric.tsx
frontend/components/Panel.tsx
frontend/components/PlayerControls.tsx
frontend/components/ProbabilityBar.tsx
frontend/components/RiskSparkline.tsx
frontend/components/SessionCard.tsx
frontend/components/SignalAccordion.tsx
frontend/components/SiteHeader.tsx
frontend/components/Spotlight.tsx
frontend/components/StageTimeline.tsx
frontend/components/StatCard.tsx
frontend/components/theme.ts
frontend/components/TransitionFeed.tsx
frontend/components/WhatChanged.tsx
frontend/eslint.config.mjs
frontend/generated/component-spec.json
frontend/lib/analytics.ts
frontend/lib/api.ts
frontend/lib/demos.ts
frontend/lib/openui/library.ts
frontend/lib/openui/llm-stream.ts
frontend/lib/openui/promptSpec.ts
frontend/lib/openui/response-parser.ts
frontend/lib/openui/store.ts
frontend/lib/session.ts
frontend/lib/testFeed.ts
frontend/lib/useLive.ts
frontend/lib/usePlayer.ts
frontend/next.config.ts
frontend/package.json
frontend/postcss.config.mjs
frontend/public/demos/grooming_demo.json
frontend/public/demos/normal_adult_demo.json
frontend/public/demos/teen_relationship_demo.json
frontend/README.md
frontend/tsconfig.json
HANDOFF.md
ingestion/photon/.env.example
ingestion/photon/.gitignore
ingestion/photon/package.json
ingestion/photon/README.md
ingestion/photon/src/backendClient.ts
ingestion/photon/src/index.ts
ingestion/photon/src/smoke.ts
ingestion/photon/tsconfig.json
PRODUCT.md
README.md
```

### Dependencies

- backend/requirements.txt: anthropic@>=0.40, arize-phoenix-otel@>=0.6, fastapi@>=0.110, httpx@>=0.27, numpy@>=1.26, openinference-instrumentation-anthropic@>=0.1, opentelemetry-exporter-otlp@>=1.27, opentelemetry-sdk@>=1.27, pydantic@>=2.6, pytest@>=8.0, python-dotenv@>=1.0, redis@>=5.0, sentence-transformers@>=2.6, sentry-sdk@>=2.0, spacy@>=3.7, uvicorn[standard]@>=0.29, vaderSentiment@>=3.3
- frontend/package.json: @anthropic-ai/sdk@^0.105.0, @openuidev/lang-core@^0.2.6, @openuidev/react-headless@^0.8.2, @openuidev/react-lang@^0.2.6, @openuidev/react-ui@^0.11.9, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.2.9, lucide-react@^1.21.0, next@16.2.9, openai@^6.44.0, react@19.2.4, react-dom@19.2.4, tailwindcss@^4, typescript@^5, zod@^4.4.3, zustand@^4.5.7
- ingestion/photon/package.json: @types/node@^20.11.0, dotenv@^17.4.2, spectrum-ts@^5.0.0, tsx@^4.7.0, typescript@^5.4.0

### Recent commits (newest first)

- Add Deepgram functionality to README
- Merge pull request #9 from minjunggho/landing-page
- feat: landing rework, gen-UI voice summary, repo README + backend tuning
- Merge pull request #8 from minjunggho/landing-page
- feat(frontend): marketing landing rework + gen-UI dashboard playground
- Merge pull request #7 from minjunggho/landing-page
- feat(frontend): lighthouse landing page at / (ported from mainframe)
- Merge pull request #6 from minjunggho/frontend-dashboard
- feat(frontend): dashboard redesign — app shell, session views, live components
- feat(frontend): parent dashboard + T&S panel + message player wired to live backend
- Merge pull request #4 from minjunggho/real-pipeline
- fix(backend): load .env on startup, normalize Phoenix OTLP endpoint, guard tests
- feat(backend): real detection pipeline — extractors + Claude synthesis + alert (PIPELINE_MODE=live)
- Merge pull request #3 from minjunggho/integration
- merge main into integration
- feat: add backend contract compatibility
- Merge pull request #2 from minjunggho/reconcile-integration
- feat(backend): reconcile person1 pipeline with integration (bridges + demo loader)
- Merge remote-tracking branch 'origin/integration' into reconcile-integration
- Merge pull request #1 from minjunggho/person1-backend

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

### PRODUCT.md

```markdown
# Product

## Register

product

## Users

Two operators of one detection system, each seeing a different projection of the
same `DecisionRecord`:

- **Parents** — non-technical, anxious, checking in. They need a calm,
  trustworthy read on whether their child's conversations are safe, with plain
  guidance. They never see raw messages (privacy by projection, enforced at the
  API). The job: *"is something wrong, and what do I do?"* — answered in seconds.
- **Trust & Safety analysts** — fluent in moderation tools, working a queue.
  They need the full record: transcript, the four structural features, and the
  four-condition alert logic. The job: *"why did this fire (or not), and can I
  defend the decision?"*

Plus a third audience for the demo itself: **hackathon judges**, watching the arc
unfold live on a projector. The timeline visualization — not the message content —
is the visual focus.

## Product Purpose

Lighthome detects the *shape* of an online grooming conversation — the structural
progression Trust → Isolation → Desensitization → Escalation — and alerts a
parent at the Stage 1→2 transition, before anything explicit is said. Four feature
extractors run independently of Claude, so the structure can overrule the model;
that disagreement is auditable. Success: the grooming case fires (~message 12),
and the two negative cases (friendly adult, teen relationship) stay green with the
reason visible on screen.

## Brand Personality

Calm, vigilant, approachable. Three words: **trustworthy, modern, humane.**
The visual register is a *soft watch console* — a clean, light, rounded
monitoring app (the whole UI floats as one rounded panel on a soft gray page,
with a light sidebar, hairline-divided columns, gentle shadows, and a friendly
blue accent). It feels current and unintimidating — a parent or analyst should
trust it on sight — while the content stays composed and never sensational.
Gravity lives in the data and the copy, not in a heavy or clinical skin.

## Anti-references

- **Dated / "enterprise" heaviness** — hard 1px rings on every card, dark
  command rails, cramped type, sharp corners, gray-on-gray. The build read "old";
  the fix is the soft light direction above.
- **Alarmist / true-crime aesthetic** — red everywhere, sirens, fear. The subject
  is grave; the tool stays composed.
- **Cheerful productivity-app tone** — the look is friendly and modern, but the
  voice is not playful. No confetti, mascots, or upbeat empty-state jokes.

## Design Principles

1. **The instrument is the hero.** The probability distribution, the risk
   trajectory, and the four-condition table are the credibility centerpieces.
   They get the space, contrast, and motion; everything else recedes.
2. **Composure over alarm.** A grave domain handled with a steady hand. Color and
   motion convey state and earn attention; they never sensationalize.
3. **Show the disagreement.** The whole technical claim is "features can overrule
   Claude." Make structure, mo
[truncated — 1055 more characters]
```

### HANDOFF.md

```markdown
# Lighthome — Session Handoff

Continuation notes for a fresh Claude Code session (restarting to pick up the
`impeccable` design skill). Project: **Lighthome / "First 45"** — detects the
*shape* of an online grooming conversation (the structural shift trust →
isolation → desensitization → escalation) and alerts a parent before anything
explicit is said. Read `CLAUDE.md` and `CONTRACTS.md` at the repo root first.

## ▶ Immediate next task
**Design pass on the dashboard using the `impeccable` skill.** The dashboard was
unreadable (a Tailwind v4 + dark-mode CSS bug painted a black canvas with faded
text). That bug is **already fixed** (`frontend/app/globals.css` — locked a light
theme, removed the `prefers-color-scheme: dark` override). The layout is now a
working app-shell (sidebar + topbar + stat cards + card grid) but wants a real
design polish: typography scale, spacing/density, color hierarchy, and the
data-viz (probability bar, risk timeline, conditions table, stat cards).

Run the skill against `frontend/` — the design-relevant files are listed below.

## Git state (same machine — working tree persists across restart)
- **On branch `frontend-dashboard`** (PR **#5 OPEN** → base `main`).
- **Uncommitted local changes** (the app-shell redesign + the CSS fix) — NOT yet
  committed/pushed:
  - modified: `frontend/app/globals.css`, `app/page.tsx`, `app/parent/page.tsx`, `app/platform/page.tsx`
  - new: `frontend/components/AppShell.tsx`, `frontend/components/StatCard.tsx`
  - Decide whether to commit these to PR #5 before/after the design pass.
- `main` tip: `f157aae` (PR #4 merged). PRs #1–#4 merged (backend + integration).

## What's done & merged (on `main`)
- **Backend pipeline (Person 1)** — `backend/app/`:
  - Real pipeline behind `PIPELINE_MODE=live`: `features.py` (4 extractors) →
    `claude_synthesis.py` (Claude `messages.parse` + offline feature-fallback) →
    `bayes.py` → `alert.py` → `pipeline.py` assembles the `DecisionRecord`.
  - `main.py` Contract B endpoints + live SSE read path
    (`/analyze-message`, `/session/reset`, `/sessions`, `/session/{id}/latest`,
    `/transcript`, `/stream`). Loads `.env`.
  - `models.py` = Contract C (`DecisionRecord` + parent/tns projections).
  - `telemetry.py` = Phoenix tracing of the Claude call (wired & verified).
  - Integration bridges + demo loader reconcile with Person 3's code.
  - Tests: `cd backend && .venv/bin/python -m pytest tests -q` → 22 passed (mock);
    `PIPELINE_MODE=live ... pytest` → 24 passed. Acceptance gate (grooming→alert,
    friendly→none, teen→none) is green.
- **Frontend (PR #5, this branch)** — `frontend/`: API client, demo player,
  parent dashboard, T&S panel, shared viz components. Builds clean.

## How to run the full demo (this machine)
```bash
# Backend (live pipeline + Phoenix tracing — reads backend/.env)
cd backend && .venv/bin/python -m uvicorn app.main:app --port 8000
# Frontend
cd frontend && npm install && npm run dev      # http://localhost:3000
```
- `ba
[truncated — 2418 more characters]
```

### backend/requirements.txt

```
# --- Mock / API (needed now, hour 0) ---
fastapi>=0.110
uvicorn[standard]>=0.29
pydantic>=2.6

# --- Real pipeline (hour 1-8) ---
anthropic>=0.40          # Claude synthesis layer
spacy>=3.7               # directionality / linguistic features
vaderSentiment>=3.3      # sentiment for desensitization signal
sentence-transformers>=2.6  # embeddings for boundary-recycle + velocity
numpy>=1.26

# --- Observability / state (Person 3 implements behind protocols) ---
redis>=5.0
sentry-sdk>=2.0
# Arize Phoenix tracing (OTel + OpenInference) — captures the Claude synthesis span
arize-phoenix-otel>=0.6
openinference-instrumentation-anthropic>=0.1
opentelemetry-sdk>=1.27
opentelemetry-exporter-otlp>=1.27

# --- Dev ---
python-dotenv>=1.0
httpx>=0.27              # test harness / endpoint tests
pytest>=8.0

```

### frontend/package.json

```
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint",
    "generate:prompt": "openui generate lib/openui/library.ts --json-schema --out generated/component-spec.json"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@openuidev/lang-core": "^0.2.6",
    "@openuidev/react-headless": "^0.8.2",
    "@openuidev/react-lang": "^0.2.6",
    "@openuidev/react-ui": "^0.11.9",
    "lucide-react": "^1.21.0",
    "next": "16.2.9",
    "openai": "^6.44.0",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "zod": "^4.4.3",
    "zustand": "^4.5.7"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.9",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### ingestion/photon/package.json

```
{
  "name": "photon-imessage-adapter",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Optional Photon/Spectrum iMessage -> Contract B ingestion adapter for Lighthome. Off the critical demo path.",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc -p tsconfig.json",
    "typecheck": "tsc -p tsconfig.json --noEmit"
  },
  "dependencies": {
    "dotenv": "^17.4.2",
    "spectrum-ts": "^5.0.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.0",
    "tsx": "^4.7.0",
    "typescript": "^5.4.0"
  }
}

```

### frontend/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: "Lighthome — grooming detection",
  description: "Detecting the shape of online grooming — before anything explicit is said.",
};

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 bg-surface text-ink">{children}</body>
    </html>
  );
}

```

### backend/app/main.py

```python
"""FastAPI backend — Contract B endpoints + live-demo read/stream path.

HOUR 0/1 (now): `/analyze-message` returns a MOCK schema-valid DecisionRecord so
Persons 2 & 3 are unblocked. The mock holds per-session turn state so a
conversation can be replayed from turn 0 and the probability bar visibly shifts.

LIVE DEMO: judges text the iMessage adapter, which POSTs to /analyze-message. The
dashboard is a different client, so it READS via /session/{id}/latest,
/session/{id}/transcript, or subscribes to /session/{id}/stream (SSE) to update
the instant a text lands.

HOUR 8: swap `mock_decision_record` for the real pipeline
(features -> Claude synthesis -> Bayesian update -> alert engine) behind this same
contract. Nothing in this file's request/response shape changes.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path

from dotenv import load_dotenv

# Load backend/.env so PIPELINE_MODE / ANTHROPIC_API_KEY / PHOENIX_* reach the app
# when served. override=False: a value already in the shell environment wins, so
# tests (and `PIPELINE_MODE=live ...`) can pin behavior regardless of .env.
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)

from fastapi import FastAPI, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse

from . import pipeline
from .demo_data import mock_decision_record
from .features import Message
from .models import AnalyzeRequest, DecisionRecord, ResetRequest, StageProbabilities, View
from .observability import init_sentry
from .record_store import InMemoryRecordSink

logger = logging.getLogger(__name__)

# "mock" (turn-count ramp, content-blind) | "live" (real extractors + synthesis)
PIPELINE_MODE = os.getenv("PIPELINE_MODE", "mock").lower()

# Sentry must init BEFORE the FastAPI app so its integration patches request
# handling. No-op without SENTRY_DSN; wires error monitoring + alert events.
init_sentry()

app = FastAPI(title="Lighthome backend", version="0.2.0")

# Person 2's Next.js dev server talks to this directly during the build.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Per-session prior (posterior chains turn-to-turn) and, in live mode, the
# conversation-so-far the extractors run over. In-process scaffolding; the real
# deployment keeps this in the StateStore (Contract D) via the bridge.
_PRIORS: dict[str, StageProbabilities] = {}
_HISTORY: dict[str, list[Message]] = {}

# RecordSink (Contract E). Person 1 only CALLS this. Default is the in-memory
# sink (mock/demo). Set STORE_BACKEND=integration to fan out to Person 3's Redis
# StateStore + Arize logger via the bridge — the read/stream endpoints below
# consume the same surface either way, so nothing else changes.
def _build_sink():
    if os.getenv("STORE_BACKEND", "memory").lower() != "integration":
        return InMemoryRecordSink()
    try:
        from .integration_bridge import BridgedRecordSink, run_async
        from .observability import ArizeLogger
        from .state import create_state_store

        store = run_async(create_state_store(os.getenv("REDIS_URL")))
        return BridgedRecordSink(store, ArizeLogger())
    except Exception:
        logger.exception("STORE_BACKEND=integration failed to initialize; "
                         "falling back to in-memory sink")
        return InMemoryRecordSink()


sink = _build_sink()


@app.get("/health")
def health() -> dict:
    return {"status": "ok", "mode": PIPELINE_MODE, "version": app.version}


@app.post("/analyze-message")
def analyze_message(req: AnalyzeRequest, view: View = Query("parent")) -> dict:
    """Contract B. Returns a DecisionRecord projected to `parent` or `tns`.

    PIPELINE_MODE=live runs the real pipeline (extractors -> Claude synthesis ->
    Bayesian update -> alert engine); otherwise the content-blind mock.
    """
    prior = _PRIORS.get(req.session_id)

    if PIPELINE_MODE == "live":
        msg: Message = {
            "turn": req.turn,
            "speaker": req.speaker,
            "text": req.text,
            "t_offset_sec": req.t_offset_sec,
        }
        history = _HISTORY.setdefault(req.session_id, [])
        history.append(msg)
        record = pipeline.analyze(req.session_id, history, prior)
    else:
        record = mock_decision_record(
            session_id=req.session_id, turn=req.turn, text=req.text, prior=prior
        )

    # The current posterior becomes the next message's prior.
    _PRIORS[req.session_id] = record.stage_probabilities
    sink.emit(record.model_dump())  # capture for the dashboard read/stream path
    return record.project(view)


@app.post("/session/reset")
def reset_session(req: ResetRequest) -> dict:
    """Replay control — wipe server state so a conversation restarts at turn 0."""
    _PRIORS.pop(req.session_id, None)
    _HISTORY.pop(req.session_id, None)
    sink.reset(req.session_id)
    return {"session_id": req.session_id, "reset": True}


# --- Live-demo read path (the dashboard consumes these) --------------------


@app.get("/sessions")
def list_sessions() -> list[dict]:
    """Active sessions, most-recently-active first. Lets the dashboard pick which
    judge's thread to follow (or auto-follow the latest texter)."""
    return sink.sessions()


@app.get("/session/{session_id}/latest")
def session_latest(session_id: str, view: View = Query("parent")) -> dict:
    """Most recent DecisionRecord for a session, projected to the view."""
    latest = sink.latest(session_id)
    if latest is None:
        return {}
    return DecisionRecord.model_validate(latest).project(view)


@app.get("/session/{session_id}/transcript")
def session_transcript(session_id: str, view: View = Query("tns")) -> list[dict]:
    """Ordered records for a session — feeds the T&S transcript panel."""
    return [DecisionRecord.model_validate(r).project(view) for r in sink.tr
[truncated — 1215 more characters]
```

### frontend/app/(marketing)/layout.tsx

```typescript
import { Manrope, Newsreader } from "next/font/google";

import "./landing-theme.css";

const manrope = Manrope({
  variable: "--font-manrope",
  subsets: ["latin"],
});

const newsreader = Newsreader({
  variable: "--font-newsreader",
  subsets: ["latin"],
});

/**
 * Layout for the public landing page ("/").
 *
 * Lives in the (marketing) route group so its theme — the redesign's
 * landing-theme.css plus the Manrope/Newsreader faces — is bundled for this
 * route only. The dashboard routes stay outside the group and keep the
 * "soft watch" theme from app/globals.css; the two never collide.
 */
export default function MarketingLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <div className={`${manrope.variable} ${newsreader.variable} lh-landing-root`}>
      {children}
    </div>
  );
}

```

### ingestion/photon/src/index.ts

```typescript
/**
 * Photon/Spectrum -> Contract B adapter (OPTIONAL, off the critical demo path).
 *
 * This is a thin INGESTION adapter, not part of the detection pipeline. It reads
 * inbound iMessages from a Spectrum app and republishes each one as a Contract B
 * `AnalyzeRequest` to the backend's POST /analyze-message. The backend is
 * unchanged: a real iMessage and a replayed demo-JSON message look identical to it.
 *
 * SCOPE / SAFETY (see CLAUDE.md framing):
 *   - Intended for YOUR OWN linked iMessage account only. Do not point this at
 *     anyone else's messages, and never at a minor's.
 *   - PHOTON_DRY_RUN=true (default) logs what it WOULD send and posts nothing.
 *   - Inbound text only; outbound (your own) and non-text content are skipped.
 *   - Optional ALLOWED_SPACES allowlist restricts which conversations are read.
 *
 * Run:  cp .env.example .env && npm install && npm run dev
 */

import "dotenv/config";
import { Spectrum, type Message, type Space } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers";
import { analyzeMessage, type AnalyzeRequest } from "./backendClient.js";

// --- config ---------------------------------------------------------------
const PROJECT_ID = requireEnv("PROJECT_ID");
const PROJECT_SECRET = requireEnv("PROJECT_SECRET");
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8000";
const VIEW = (process.env.VIEW ?? "tns") as "parent" | "tns";
const DRY_RUN = (process.env.PHOTON_DRY_RUN ?? "true").toLowerCase() !== "false";
const ALLOWED_SPACES = (process.env.ALLOWED_SPACES ?? "")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);

// Per-conversation turn counter + first-seen time, so we can build the
// `turn` and `t_offset_sec` fields the pipeline expects (Contract B).
const turns = new Map<string, number>();
const firstSeenMs = new Map<string, number>();

function requireEnv(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env var ${name} (see .env.example)`);
  return v;
}

function toRequest(spaceId: string, senderId: string, text: string, ts: number): AnalyzeRequest {
  if (!firstSeenMs.has(spaceId)) firstSeenMs.set(spaceId, ts);
  const turn = turns.get(spaceId) ?? 0;
  turns.set(spaceId, turn + 1);

  return {
    session_id: `imessage:${spaceId}`,
    turn,
    speaker: senderId,
    text,
    t_offset_sec: Math.max(0, Math.floor((ts - firstSeenMs.get(spaceId)!) / 1000)),
  };
}

async function main() {
  console.log(
    `[photon-adapter] backend=${BACKEND_URL} view=${VIEW} dryRun=${DRY_RUN}` +
      (ALLOWED_SPACES.length ? ` allowedSpaces=${ALLOWED_SPACES.join(",")}` : ""),
  );

  const app = await Spectrum({
    projectId: PROJECT_ID,
    projectSecret: PROJECT_SECRET,
    providers: [imessage.config()],
  });

  for await (const [space, message] of app.messages as AsyncIterable<[Space, Message]>) {
    // iMessage only, inbound text only — never the user's own outbound messages.
    if (message.platform.toLowerCase() !== "imessage") continue;
    if (message.direction === "outbound") continue;
    if (message.content.type !== "text") continue;
    if (ALLOWED_SPACES.length && !ALLOWED_SPACES.includes(space.id)) continue;

    const senderId = message.sender?.id ?? "unknown";
    const req = toRequest(space.id, senderId, message.content.text, message.timestamp.getTime());

    if (DRY_RUN) {
      console.log(
        `[dry-run] would POST turn=${req.turn} session=${req.session_id} len=${req.text.length}`,
      );
      continue;
    }

    try {
      const record = await analyzeMessage(BACKEND_URL, req, VIEW);
      console.log(
        `[ok] turn=${req.turn} session=${req.session_id} alert=${record?.alert?.level ?? "?"}`,
      );
    } catch (err) {
      // Ingestion failures must never crash the detector; log and continue.
      console.error(`[err] turn=${req.turn} session=${req.session_id}:`, err);
    }
  }
}

main().catch((err) => {
  console.error("[photon-adapter] fatal:", err);
  process.exit(1);
});

```

### frontend/app/parent/page.tsx

```typescript
"use client";

import { useCallback, useRef, useState } from "react";

import { AppShell } from "@/components/AppShell";
import { LiveDot } from "@/components/LiveDot";
import { SessionCard } from "@/components/SessionCard";
import { StatCard, StatGroup } from "@/components/StatCard";
import { ALERT_META } from "@/components/theme";
import { startAllFeeds, type FeedController } from "@/lib/testFeed";
import { useLiveSessions, useNow } from "@/lib/useLive";

export default function ParentConsole() {
  const { sessions, connected, error, loading } = useLiveSessions();
  const nowMs = useNow();

  const feedRef = useRef<FeedController | null>(null);
  const [feeding, setFeeding] = useState(false);

  const startFeed = useCallback(() => {
    feedRef.current?.stop();
    feedRef.current = startAllFeeds();
    setFeeding(true);
  }, []);

  const stopFeed = useCallback(() => {
    feedRef.current?.stop();
    feedRef.current = null;
    setFeeding(false);
  }, []);

  const alerts = sessions.filter((s) => s.alert_level === "alert").length;
  const watches = sessions.filter((s) => s.alert_level === "watch").length;
  const calm = sessions.filter((s) => s.alert_level === "none").length;

  return (
    <AppShell
      title="Watch console"
      subtitle="A simple, live read on every chat we're watching"
      actions={
        <div className="flex items-center gap-3">
          <LiveDot
            level={alerts ? "alert" : watches ? "watch" : "none"}
            label={connected ? "Live" : "Offline"}
          />
          <FeedButton feeding={feeding} onStart={startFeed} onStop={stopFeed} />
        </div>
      }
    >
      {!connected && !loading && (
        <div
          className="mb-5 rounded-xl p-4 text-sm"
          style={{ backgroundColor: ALERT_META.watch.bg, color: ALERT_META.watch.text }}
        >
          Can&apos;t reach the detector backend. Start it with{" "}
          <code className="rounded bg-black/5 px-1 py-0.5 text-[12px]">
            uvicorn app.main:app --port 8000
          </code>
          {" "}— the console reconnects automatically.
          {error && <span className="ml-1 opacity-70">({error})</span>}
        </div>
      )}

      <StatGroup>
        <StatCard label="Chats watched" value={`${sessions.length}`} hint="active conversations" />
        <StatCard label="Looks normal" value={`${calm}`} accent={ALERT_META.none.fill} />
        <StatCard label="Worth watching" value={`${watches}`} accent={ALERT_META.watch.fill} />
        <StatCard label="Needs attention" value={`${alerts}`} accent={ALERT_META.alert.fill} />
      </StatGroup>

      {sessions.length > 0 ? (
        <div className="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
          {sessions.map((s) => (
            <SessionCard key={s.session_id} summary={s} nowMs={nowMs} />
          ))}
        </div>
      ) : (
        <EmptyState
          connected={connected}
          loading={loading}
          feeding={feeding}
          onStart={startFeed}
        />
      )}
    </AppShell>
  );
}

function FeedButton({
  feeding,
  onStart,
  onStop,
}: {
  feeding: boolean;
  onStart: () => void;
  onStop: () => void;
}) {
  return (
    <button
      onClick={feeding ? onStop : onStart}
      className="rounded-lg bg-brand px-3 py-1.5 text-[13px] font-semibold text-brand-fg transition-colors hover:bg-brand-strong"
    >
      {feeding ? "Stop test feed" : "Start test feed"}
    </button>
  );
}

function EmptyState({
  connected,
  loading,
  feeding,
  onStart,
}: {
  connected: boolean;
  loading: boolean;
  feeding: boolean;
  onStart: () => void;
}) {
  if (loading) {
    return (
      <div className="mt-8 grid place-items-center rounded-2xl border border-dashed border-line-strong bg-surface/50 px-6 py-16 text-center text-sm text-ink-3">
        Connecting to the detector…
      </div>
    );
  }
  return (
    <div className="mt-8 grid place-items-center rounded-2xl border border-dashed border-line-strong bg-surface/50 px-6 py-16 text-center">
      <div className="max-w-md">
        <div className="mx-auto grid h-11 w-11 place-items-center rounded-full bg-brand-tint text-brand">
          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
            <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
            <circle cx="12" cy="12" r="3" />
          </svg>
        </div>
        <p className="mt-4 font-medium text-ink">No monitored conversations yet</p>
        <p className="mt-1 text-sm leading-relaxed text-ink-2">
          The console reads whatever flows through the detector — a linked iMessage
          thread via the ingestion adapter, or local test traffic. Start a test
          feed to watch three conversations run through the real pipeline live.
        </p>
        {connected && (
          <button
            onClick={onStart}
            disabled={feeding}
            className="mt-5 rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-brand-fg transition-colors hover:bg-brand-strong disabled:opacity-50"
          >
            {feeding ? "Feeding…" : "Start test feed"}
          </button>
        )}
      </div>
    </div>
  );
}

```

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