# Project export: SnapCycle

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: Recycle like a smart person
- Devpost: https://devpost.com/software/rrr-s98zwc
- GitHub: https://github.com/prithsk/RRR
- Video: https://www.youtube.com/embed/Z8rMbCB_Mno?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (Finalist)
- Team: 4 GitHub contributor(s) — coderBYC (6 commits), Cursor (5 commits), Claude Opus 4.8 (1 commits), Joongeun (1 commits)

## Devpost submission (written by the team)

### Inspiration

Every year during college move-out season, the curbs fill up with piles of mattresses, desks, chairs, and electronics – a lot of it perfectly donatable or recyclable – that fester in the outdoors, sometimes for weeks. As rising college sophomores moving out on our own for the first time, we all experienced and saw friends experience the difficulty of responsibly disposing of bulky household items. There was just so much information scattered across websites, and it was a hassle to find a disposal pathway (donation, free bulky waste/mattress/e-waste collection services, junk haulers) that was available, cheap, and easy to utilize. The information gap was exacerbated by the fact that we needed to study for finals and pack before our leases ended. Of the 87 people that Joon surveyed this spring, 89% were unaware of existing waste disposal services, yet 58% of them said they would use one or more of these services had they been aware.

### What it does

Getting rid of a mattress, a printer, or a half-used can of paint shouldn't take an afternoon of googling conflicting city pages. SnapCycle turns it into a single photo. After getting onboarded (entering your address and zip code), you snap the item you want to dispose of, and the app identifies it, finds every legitimate local way to get rid of it, and lays them out as ranked, tappable cards: donation, free city bulky-waste pickup, recycling collectives, household-hazardous-waste and e-waste drop-offs, and paid junk haulers. Each card carries the factors that drive the decision: cost, environmental friendliness, whether there's doorfront pickup, and how far you'd have to drive if there isn't. A filter lets you sort by whatever matters most to you, and the cards reorder instantly. If the photo is unclear (two items in frame), the agent asks a quick clarifying question instead of guessing. And if nothing's available nearby, it tells you and offers to widen the search rather than dead-ending. Tapping a card is where it goes from information cards to done-for-you. The app spawns an action agent that schedules the disposal for you: it auto-fills the donation or service web form, displays a tap-to-call number for city and collective scheduling, or, for junk haulers messages the three closest, highest-rated haulers with a photo of your item.

### How we built it

The system runs in three stages. A knowledge layer uses a Browserbase browsing agent to crawl local recycling ordinances, municipal bulky-waste pickup programs, donation centers, free mattress/household hazardous waste/e-waste collectives, and hauler listings, then structures and embeds them into a RAG vector store keyed on item category and location, making the app's suggestions specific to where the user actually lives instead of generic advice. A scan is handled by a perception-and-retrieval agent running Gemini 2.5 Flash with its state managed in Redis. The agent identifies the item, runs a disambiguation loop when confidence is low, then queries the RAG store with the item plus location to assemble and rank the cards – including a no-results fallback that re-queries on a broadened scope when nothing turns up locally. When the user picks a card, our Simular action agent executes the chosen pathway, branching on how that option can be scheduled: web-form automation for donation and service sites, a tap-to-call number for city and collective scheduling, and the Twilio hauler-bidding flow, where it sends the item photo to the top three haulers (based on closeness and rating) on Twilio and renders their quotes as they stream in, continuously re-sorted low to high so the user can proceed with anyone before all three reply. The frontend is a mobile-first interface built around the card grid, the priority filter, and the live-updating quote list. Arize was used to monitor the agent's outputs. We used the data to optimize the prompt output and the input from agent to agent to create a system that minimizes token usage while increasing agent output accuracy. Sentry was used to check if the app's code was functioning properly, mainly node-related frontend issues that would interfere with overall workflows that could not be found using traditional smoke tests. Claude and Claude Code were used to plan and generate the agentic workflow flowchart.

### Challenges we ran into

IP address for frontend and backend kept changing, making it hard to send request and debugging. Accessing Yelp via a virtual browser on the app with Playwright was an issue Integrating 5 sponsor API's throughout the stack

### Accomplishments we're proud of

There are plenty of information aggregators online, but to our knowledge, we are the first to take a step further by helping users actually follow through with the disposal option that best suits their needs and preferences. End-to-end flow from camera → AI identify → decision → live disposal pathway discovery → saved item in one app. Service discovery that is adaptive to locality and the specific item in question Dividing the labor effectively to leverage each of our strengths and experiences

### What we learned

A software that dumps aggregated information to students who are not is not enough. It must guide them

### What's next

Add an onboarding screen that takes in the user's address and zip code, and subsequently, runs the browserbase agent once (after this initial run, a browserbase agent doesn't run again) – right now, browserbase curates a RAG database for each image uploaded Incorporate Redis memory to identify trends in users' preferred method of disposal (e.g. someone who prefers donation over junk hauling) Add a voice agent that the user can converse with in multiple languages instead of typing things out

## README (from the GitHub repository)

# Snapcycle

A mobile app that helps you responsibly get rid of large, awkward, or non-traditional
items. Photograph an item, let AI identify it, and the app figures out the **simplest
correct path**: throw it in the right home bin, or — if it needs special handling —
research **real local disposal pathways** (donation, city bulky pickup, junk haulers,
mattress/e-waste/HHW collectives, reuse events) grounded in **live web search**. It can
fill in a hauler's booking form for you while you watch, or **text every local junk
hauler at once** and stream their price quotes back in real time. A profile tracks how
many items you've donated, sold, and kept out of the landfill.

Built with **Expo / React Native** + **Supabase**, and a **Python / FastAPI** backend
that orchestrates a multi-agent pipeline over **Google Gemini**, **Browserbase**,
**Redis** (cache + vector RAG), and **Twilio** (hauler SMS bids) — with full tracing via
**Arize Phoenix** and silent-failure capture via **Sentry**.

---

## How it works (end-to-end flow)

```
                     ┌──────────────────────── Expo app (src/) ────────────────────────┐
  Onboarding  ──────▶│ permissions → address/zip → "build my local guide"              │
  (once per loc.)    └──────────────────────────────┬──────────────────────────────────┘
                                                     │ POST /api/research
                                                     ▼
                              Browserbase search × N + Gemini  ──▶  persistent location
                              (curbside, bulky, HHW/e-waste,          RAG in Redis
                               donation, mattress, reuse…)            (location_rag:{zip})

  Scan item ─▶ /api/identify (Gemini vision) ─▶ Confirm (+ optional "it's broken" note)
        │
        ▼
  /api/triage ──┬─ disposableAtHome → "put it in the {trash|recycling}"  (home-disposal)
   (RAG-grounded)│
                └─ needs special pathway
                        │
                        ▼
  /api/disposal-options ── Browserbase (category-aware search) + RAG + Gemini ──▶ ranked
                          DisposalCards (donation / city_bulky_pickup / junk_haulers /
                          recycling_collective / hhw / ewaste), Redis-cached
                        │
                        ▼  user taps a card
  /api/card-detail ── two cooperating Redis agents:
        Agent 1 research_card_detail (next steps + eligibility constraints, RAG + fetch)
        Agent 2 decide_action       (summary + recommendation, routes the UI mode)
                        │
        ┌───────────────┼───────────────────┬───────────────────────────────┐
        ▼               ▼                   ▼                               ▼
   mode "summary"   mode "form"        mode "phone"               junk-haulers path
   (read + link)    Agent S fills the   show number to call        ┌─────────────────────┐
                    booking form in a                              │ /api/haulers/bids   │
                    live Browserbase                               │ Browserbase finds   │
                    browser (review +                              │ local haulers w/    │
                    submit yourself)                               │ phones → Twilio      │
                                                                   │ texts them ALL →     │
                                                                   │ replies stream back  │
                                                                   │ as price quotes      │
                                                                   └─────────────────────┘
                        │
                        ▼
   /api/chat ── ask follow-up questions, grounded in the location RAG + on-screen cards
                        │
                        ▼
   confirm-disposal ──▶ recorded to Supabase (history + profile stats)
```

The **donate / sell / discard** framing, profile stats, and history all live in Supabase;
everything AI- or web-grounded runs on the backend so the Gemini / Browserbase / Twilio
keys never ship in the app.

---

## Architecture

```
┌─────────────────────────┐         ┌──────────────────────────────────────────┐
│  Expo mobile app (src/) │         │  Python FastAPI backend (backend/)         │
│                         │         │                                            │
│  • Supabase auth        │──JWT───▶│  deps/auth        verify Supabase JWT      │
│  • Expo Router flow      │         │  rrr_identify      Gemini vision           │
│  • disposal-context      │         │  rrr_location_research  Browserbase+Gemini │
│  • onboarding-context    │         │  rrr_triage        home-bin vs special     │
│  • WebView live views    │         │  rrr_disposal      cards (Browserbase+RAG) │
│  • bid quote polling     │         │  rrr_card_agent    2 Redis agents          │
│                         │         │  rrr_chat          RAG-grounded chat        │
│                         │         │  agent_s           Agent S form-fill        │
│                         │         │  rrr_haulers       Browserbase hauler disc. │
│                         │         │  twilio_bids/_sms  SMS bid blast + webhook  │
└─────────────────────────┘         └───────────────┬────────────────────────────┘
          │                                          │
          ▼                       ┌──────────┬───────┼──────────┬──────────────┐
   Supabase (Postgres+Storage)    ▼          ▼       ▼          ▼              ▼
                              Gemini API  Browserbase  Redis Stack       Twilio (SMS)
                              (vision +   (web search  (cache-aside +    blast haulers,
                               text +      + fetch +    vector RAG +     collect quote
                               embeddings) live sess.)  location RAG)    replies via webhook

  Observability:  Arize Phoenix (OpenInference tracing of every agent/LLM stage)
                  Sentry (captures silent failures hidden behind graceful fallbacks)
```

- **Gemini** does vision identification, all text synthesis, and 3072-dim embeddings.
- **Browserbase** powers live web search + page fetch (so disposal options and haulers are
  *real*, not hallucinated) and full cloud browser **sessions** that Agent S drives via
  Playwright over CDP, streamed to the phone in a WebView.
- **Redis Stack** is three things: a cache-aside layer (TTL'd), a **vector index** for
  semantic item-query matching, and the **persistent location RAG** (no TTL) built once
  during onboarding and shared by triage, cards, card-detail, and chat. It's also the
  source of truth for live bid sessions (so the SMS webhook and the polling GET agree).
- **Twilio** runs the junk-hauler **bids**: after Browserbase discovers local haulers with
  phone numbers, the backend texts them all a templated quote request in parallel; each
  reply hits an inbound webhook, gets parsed for a price, and streams into the UI.
- **Agent S** (`agent_s.py`) fills a pickup/booking form in a live Browserbase browser. It
  is intentionally conservative — it **never submits**; the user reviews and submits in the
  live view.
- **Graceful degradation everywhere**: no Twilio number, Browserbase fetch misses, Gemini
  JSON-parse failures, Redis/geoip errors all fall back cleanly — and each is reported to
  **Sentry** via `capture_silent_failure()` so smooth UX never hides a real failure. See
  [`sentry.md`](./sentry.md).

---

## Backend endpoints

| Endpoint | Agent / service | Purpose |
| --- | --- | --- |
| `GET  /health` | — | Public health check |
| `POST /api/research` | `rrr_location_research` | One-time onboarding research → persistent location RAG in Redis |
| `POST /api/identify` | `rrr_identify` | Gemini vision item identification (structured JSON) |
| `POST /api/triage` | `rrr_triage` | First pass: home trash/recycling vs. a special pathway (RAG-grounded) |
| `POST /api/disposal-options` | `rrr_disposal` | Brows

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 141 recognized source files, 468 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Redis (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (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
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (120 of 168)

```
.claude/settings.json
.env.example
.gitignore
.vscode/extensions.json
.vscode/settings.json
AGENTS.md
app.json
assets/expo.icon/icon.json
backend/.gitignore
backend/app/__init__.py
backend/app/.env.example
backend/app/agent/__init__.py
backend/app/agent/disposal_agent.py
backend/app/agent/disposal_prompts.py
backend/app/agent/orchestrator.py
backend/app/agent/prompts.py
backend/app/agent/research_prompts.py
backend/app/agent/schemas.py
backend/app/api/__init__.py
backend/app/api/location.py
backend/app/api/recycle.py
backend/app/api/rrr.py
backend/app/config.py
backend/app/data/ann_arbor.json
backend/app/data/berkeley.json
backend/app/data/stanford.json
backend/app/data/ucla.json
backend/app/deps/auth.py
backend/app/Dockerfile
backend/app/main.py
backend/app/observability.py
backend/app/schemas/rrr.py
backend/app/services/__init__.py
backend/app/services/agent_s.py
backend/app/services/browserbase_research.py
backend/app/services/browserbase.py
backend/app/services/cache.py
backend/app/services/embeddings.py
backend/app/services/gemini.py
backend/app/services/geoip.py
backend/app/services/instruction_agent.py
backend/app/services/item_classifier.py
backend/app/services/item_index.py
backend/app/services/location.py
backend/app/services/rag.py
backend/app/services/rrr_card_agent.py
backend/app/services/rrr_chat.py
backend/app/services/rrr_disposal.py
backend/app/services/rrr_haulers.py
backend/app/services/rrr_identify.py
backend/app/services/rrr_location_research.py
backend/app/services/rrr_schedule.py
backend/app/services/rrr_service_discovery.py
backend/app/services/rrr_triage.py
backend/app/services/rules_cache.py
backend/app/services/search.py
backend/app/services/twilio_bids.py
backend/app/services/twilio_sms.py
backend/app/services/user_preference_memory.py
backend/app/services/vision.py
backend/Dockerfile
backend/evals/__init__.py
backend/evals/cluster_failures.py
backend/evals/generate_traces.py
backend/evals/run_artifacts/haulers.json
backend/evals/run_artifacts/r_batt.json
backend/evals/run_artifacts/r_fuzzy.json
backend/evals/run_artifacts/r_pizza1.json
backend/evals/run_artifacts/r_pizza2.json
backend/evals/run_artifacts/recycle_pizza_cold.json
backend/evals/run_artifacts/recycle_pizza_warm.json
backend/evals/run_artifacts/sentry_events.json
backend/evals/run_evals.py
backend/evals/sentry_probe.py
backend/PHOENIX_TRACK.md
backend/README.md
backend/requirements.txt
backend/scripts/seed_redis_demo.py
backend/scripts/test_browserbase_fetch.py
backend/scripts/test_cache_aside.py
CLAUDE.md
eslint.config.js
LICENSE
package.json
README.md
scripts/reset-project.js
sentry.md
src/app/_layout.tsx
src/app/(auth)/_layout.tsx
src/app/(auth)/login.tsx
src/app/(auth)/signup.tsx
src/app/(tabs)/_layout.tsx
src/app/(tabs)/history.tsx
src/app/(tabs)/index.tsx
src/app/(tabs)/profile.tsx
src/app/camera.tsx
src/app/flow/_layout.tsx
src/app/flow/action.tsx
src/app/flow/agent-form.tsx
src/app/flow/chat.tsx
src/app/flow/confirm-disposal.tsx
src/app/flow/confirm.tsx
src/app/flow/home-disposal.tsx
src/app/flow/location.tsx
src/app/flow/processing.tsx
src/app/flow/results.tsx
src/app/flow/triage.tsx
src/app/item/[id].tsx
src/app/onboarding.tsx
src/app/preferences.tsx
src/components/chat/chat-bubble.tsx
src/components/chat/message-input.tsx
src/components/disposal/agent-message.tsx
src/components/disposal/disposal-card.tsx
src/components/disposal/filter-dropdown.tsx
src/components/disposal/hauler-row.tsx
src/components/effects/confetti.skia.tsx
src/components/effects/confetti.tsx
src/components/external-link.tsx
src/components/flow/option-button.tsx
[48 more files omitted for size]
```

### Dependencies

- backend/requirements.txt: arize-phoenix@>=7.0.0, arize-phoenix-client@>=1.0.0, arize-phoenix-evals@>=0.17.0, arize-phoenix-otel@>=0.6.0, browserbase@>=1.0.0, fastapi@>=0.115.0, google-genai@>=1.0.0, google-generativeai@>=0.8.0, gui-agents@>=0.1.0, httpx@>=0.28.0, pandas@>=2.0.0, playwright@>=1.47.0, pydantic-settings@>=2.6.0, python-dotenv@>=1.0.0, python-multipart@>=0.0.9, redis@>=5.0.0, sentry-sdk[fastapi]@>=2.0.0, twilio@>=9.0.0, uvicorn[standard]@>=0.32.0
- package.json: @expo-google-fonts/fraunces@^0.4.1, @expo-google-fonts/hanken-grotesk@^0.4.3, @expo/ui@~0.2.0-beta.9, @expo/vector-icons@^15.0.3, @react-native-async-storage/async-storage@2.2.0, @react-navigation/native@^7.3.3, @sentry/react-native@~7.2.0, @shopify/react-native-skia@2.2.12, @supabase/supabase-js@^2.108.2, @types/react@~19.1.10, eslint@^9.0.0, eslint-config-expo@~10.0.0, expo@^54.0.0, expo-camera@~17.0.10, expo-constants@~18.0.13, expo-device@~8.0.10, expo-file-system@~19.0.23, expo-font@~14.0.12, expo-glass-effect@~0.1.10, expo-haptics@~15.0.8, expo-image@~3.0.11, expo-image-manipulator@~14.0.8, expo-image-picker@~17.0.11, expo-linear-gradient@~15.0.8, expo-linking@~8.0.12, expo-location@~19.0.7, expo-router@~6.0.24, expo-splash-screen@~31.0.13, expo-status-bar@~3.0.9, expo-symbols@~1.0.8, expo-system-ui@~6.0.9, expo-web-browser@~15.0.11, react@19.1.0, react-dom@19.1.0, react-native@0.81.5, react-native-gesture-handler@~2.28.0, react-native-reanimated@~4.1.1, react-native-safe-area-context@~5.6.0, react-native-screens@~4.16.0, react-native-web@~0.21.0, react-native-webview@13.15.0, react-native-worklets@0.5.1, typescript@~5.9.2

### Recent commits (newest first)

- Add Redis agent memory for learned user preferences.
- Rename app to Snapcycle (READMEs + Expo display name)
- Replace Yelp hauler outreach with Twilio SMS/MMS bids
- Add backend Dockerfile and Redis demo seed script.
- Update README to current architecture (agentic pipeline, Twilio bids, observability)
- Merge remote-tracking branch 'origin/master'
- Fix disposal research coverage, broken-item routing, chat scroll, onboarding reset, Agent S Yelp outreach
- Wire FastAPI Sentry integrations and add debug probe endpoint.
- Merge remote-tracking branch 'origin/master'
- Add Arize Phoenix tracing and Sentry silent-failure observability
- Fix backend config extra-env crash and onboarding nav race
- Add agentic disposal upgrade: triage, two-agent RAG, chat, Agent S forms, onboarding
- Fix camera hooks order: hoist capturing state above permission gate
- Add disposal flow: save-to-history, timeouts, camera fix, item route
- Update README for Python/FastAPI + Gemini + Browserbase + Redis architecture
- Add dev-only login prefill via EXPO_PUBLIC_DEV_EMAIL/PASSWORD
- Fix Gemini identify: structured JSON output, disable thinking, guard raw blobs
- Fix Expo error handling and document backend setup for mobile devices.
- Rebalance decision logic so donate intent doesn't override sell/discard signals
- Add Python backend README.

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

### CLAUDE.md

```markdown
@AGENTS.md

```

### AGENTS.md

```markdown
# Expo HAS CHANGED

Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.

```

### package.json

```
{
  "name": "rrr2",
  "main": "expo-router/entry",
  "version": "1.0.0",
  "dependencies": {
    "@expo-google-fonts/fraunces": "^0.4.1",
    "@expo-google-fonts/hanken-grotesk": "^0.4.3",
    "@expo/ui": "~0.2.0-beta.9",
    "@expo/vector-icons": "^15.0.3",
    "@react-native-async-storage/async-storage": "2.2.0",
    "@react-navigation/native": "^7.3.3",
    "@sentry/react-native": "~7.2.0",
    "@shopify/react-native-skia": "2.2.12",
    "@supabase/supabase-js": "^2.108.2",
    "expo": "^54.0.0",
    "expo-camera": "~17.0.10",
    "expo-constants": "~18.0.13",
    "expo-device": "~8.0.10",
    "expo-file-system": "~19.0.23",
    "expo-font": "~14.0.12",
    "expo-glass-effect": "~0.1.10",
    "expo-haptics": "~15.0.8",
    "expo-image": "~3.0.11",
    "expo-image-manipulator": "~14.0.8",
    "expo-image-picker": "~17.0.11",
    "expo-linear-gradient": "~15.0.8",
    "expo-linking": "~8.0.12",
    "expo-location": "~19.0.7",
    "expo-router": "~6.0.24",
    "expo-splash-screen": "~31.0.13",
    "expo-status-bar": "~3.0.9",
    "expo-symbols": "~1.0.8",
    "expo-system-ui": "~6.0.9",
    "expo-web-browser": "~15.0.11",
    "react": "19.1.0",
    "react-dom": "19.1.0",
    "react-native": "0.81.5",
    "react-native-gesture-handler": "~2.28.0",
    "react-native-reanimated": "~4.1.1",
    "react-native-safe-area-context": "~5.6.0",
    "react-native-screens": "~4.16.0",
    "react-native-web": "~0.21.0",
    "react-native-webview": "13.15.0",
    "react-native-worklets": "0.5.1"
  },
  "devDependencies": {
    "@types/react": "~19.1.10",
    "eslint": "^9.0.0",
    "eslint-config-expo": "~10.0.0",
    "typescript": "~5.9.2"
  },
  "scripts": {
    "start": "expo start",
    "reset-project": "node ./scripts/reset-project.js",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "lint": "expo lint"
  },
  "private": true
}

```

### backend/Dockerfile

```
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

```

### backend/requirements.txt

```
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic-settings>=2.6.0
google-genai>=1.0.0
httpx>=0.28.0
browserbase>=1.0.0
python-dotenv>=1.0.0
redis>=5.0.0
python-multipart>=0.0.9
# Twilio — SMS blast to local junk haulers + inbound reply webhook (the bids UX)
twilio>=9.0.0
# Agent S form-filling over a Browserbase cloud session.
# playwright drives the remote browser over CDP; run `playwright install chromium`.
playwright>=1.47.0
# Simular Agent S engine (optional desktop driver). Install to enable the
# gui-agents code path; the Gemini+Playwright loop works without it.
gui-agents>=0.1.0 ; python_version >= "3.10"

# Sentry — captures silent failures hidden behind graceful-degradation fallbacks
sentry-sdk[fastapi]>=2.0.0

# --- Arize Phoenix (OSS) tracing + evals ---
arize-phoenix-otel>=0.6.0
# NOTE on auto-instrumentation under Python 3.14 + google-genai 2.9.0:
#   - openinference-instrumentation-google-genai 1.1.0 (only 3.14-compatible build) expects
#     google.genai._interactions, which doesn't exist in google-genai 2.9.0 → cannot attach.
#     Gemini calls are instead captured by MANUAL OpenInference LLM spans (app/observability.py
#     ::llm_span, applied in gemini.py + vision.py).
#   - httpx + redis auto-instrumentation require Python <3.14.
#   On Python <=3.13 you can uncomment these for richer auto child spans:
# openinference-instrumentation-google-genai>=0.1.0
# openinference-instrumentation-httpx>=0.1.0
# openinference-instrumentation-redis>=0.1.0
# Evals + client (llm_classify, GeminiModel, px.Client, SpanEvaluations)
arize-phoenix-evals>=0.17.0
arize-phoenix-client>=1.0.0
arize-phoenix>=7.0.0
# Judge LLM backend for phoenix.evals GeminiModel
google-generativeai>=0.8.0
pandas>=2.0.0

```

### backend/app/Dockerfile

```
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

```

### backend/app/main.py

```python
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

from app.api import location, recycle, rrr
from app.config import settings
from app.observability import setup_tracing
from app.services.cache import close_redis, init_redis
from app.services.item_index import init_item_index

# Initialize Sentry BEFORE the app + instrumented libraries are first used, so the
# capture_exception() calls sprinkled through the silent-failure fallbacks have a
# live client to report to. No-ops cleanly when SENTRY_DSN is unset.
if settings.sentry_dsn:
    import sentry_sdk
    from sentry_sdk.integrations.fastapi import FastApiIntegration
    from sentry_sdk.integrations.starlette import StarletteIntegration

    sentry_sdk.init(
        dsn=settings.sentry_dsn,
        environment=settings.sentry_environment,
        traces_sample_rate=settings.sentry_traces_sample_rate,
        send_default_pii=False,
        integrations=[
            StarletteIntegration(transaction_style="endpoint"),
            FastApiIntegration(transaction_style="endpoint"),
        ],
    )

# Register Phoenix tracing before the app + instrumented libraries are first used.
setup_tracing()


@asynccontextmanager
async def lifespan(app: FastAPI):
    await init_redis()
    await init_item_index()
    yield
    await close_redis()


app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(_request: Request, exc: StarletteHTTPException):
    """Expo client reads `error.message`; FastAPI defaults to `detail`."""
    message = exc.detail if isinstance(exc.detail, str) else "Request failed"
    return JSONResponse(status_code=exc.status_code, content={"message": message})


@app.exception_handler(RequestValidationError)
async def validation_exception_handler(_request: Request, _exc: RequestValidationError):
    return JSONResponse(status_code=422, content={"message": "Invalid request body"})


app.include_router(recycle.router, prefix="/api/recycle", tags=["recycle"])
app.include_router(location.router, prefix="/api/location", tags=["location"])
app.include_router(rrr.router, prefix="/api", tags=["rrr"])


@app.get("/health")
async def health():
    return {"ok": True, "status": "ok"}


@app.get("/debug/sentry-test")
async def sentry_test():
    """Send a test event to Sentry. Remove or protect in production."""
    import sentry_sdk

    sentry_sdk.capture_message("RRR backend Sentry test ping", level="info")
    return {"sent": True, "sentry_enabled": bool(settings.sentry_dsn)}

```

### src/app/_layout.tsx

```typescript
import { useEffect } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack, useRouter, useSegments, useRootNavigationState } from 'expo-router';
import {
  useFonts,
  Fraunces_600SemiBold,
  Fraunces_700Bold,
} from '@expo-google-fonts/fraunces';
import {
  HankenGrotesk_400Regular,
  HankenGrotesk_500Medium,
  HankenGrotesk_700Bold,
} from '@expo-google-fonts/hanken-grotesk';

import { AuthProvider } from '@/contexts/auth-context';
import { DisposalProvider } from '@/contexts/disposal-context';
import { OnboardingProvider, useOnboarding } from '@/contexts/onboarding-context';
import { useAuth } from '@/hooks/use-auth';
import { Colors } from '@/constants/theme';
import { initSentry, Sentry } from '@/lib/sentry';

initSentry();

const NavTheme = {
  ...DefaultTheme,
  colors: {
    ...DefaultTheme.colors,
    background: Colors.light.background,
    card: Colors.light.background,
    text: Colors.light.text,
    primary: Colors.light.primary,
    border: Colors.light.borderSoft,
  },
};

function AuthGate() {
  const { user, loading } = useAuth();
  const { completed: onboarded, loading: onboardingLoading } = useOnboarding();
  const segments = useSegments();
  const router = useRouter();
  const rootState = useRootNavigationState();

  useEffect(() => {
    // Wait until the root navigator is actually mounted, otherwise the
    // replace below targets a navigator that doesn't exist yet.
    if (!rootState?.key) return;
    if (loading || onboardingLoading) return;

    const inAuthGroup = segments[0] === '(auth)';
    const inOnboarding = segments[0] === 'onboarding';

    if (!user && !inAuthGroup) {
      router.replace('/(auth)/login' as any);
    } else if (user && !onboarded && !inOnboarding) {
      router.replace('/onboarding' as any);
    } else if (user && onboarded && (inAuthGroup || inOnboarding)) {
      router.replace('/(tabs)' as any);
    }
  }, [rootState?.key, user, loading, onboarded, onboardingLoading, segments]);

  if (loading || (user && onboardingLoading)) {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="(auth)" />
      <Stack.Screen name="onboarding" />
      <Stack.Screen name="(tabs)" />
      <Stack.Screen name="camera" options={{ presentation: 'fullScreenModal' }} />
      <Stack.Screen name="flow" />
      <Stack.Screen name="item/[id]" />
    </Stack>
  );
}

function RootLayout() {
  const [fontsLoaded] = useFonts({
    Fraunces_600SemiBold,
    Fraunces_700Bold,
    HankenGrotesk_400Regular,
    HankenGrotesk_500Medium,
    HankenGrotesk_700Bold,
  });

  if (!fontsLoaded) {
    return (
      <View
        style={{
          flex: 1,
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: Colors.light.background,
        }}
      >
        <ActivityIndicator size="large" color={Colors.light.primary} />
      </View>
    );
  }

  return (
    <ThemeProvider value={NavTheme}>
      <AuthProvider>
        <OnboardingProvider>
          <DisposalProvider>
            <AuthGate />
          </DisposalProvider>
        </OnboardingProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}

export default Sentry.wrap(RootLayout);

```

### src/app/(auth)/_layout.tsx

```typescript
import { Stack } from 'expo-router';

export default function AuthLayout() {
  return (
    <Stack screenOptions={{ headerShown: false, animation: 'fade' }}>
      <Stack.Screen name="login" />
      <Stack.Screen name="signup" />
    </Stack>
  );
}

```

### src/app/(tabs)/_layout.tsx

```typescript
import { Tabs } from 'expo-router';
import { SymbolView } from 'expo-symbols';

import { useTheme } from '@/hooks/use-theme';

export default function TabsLayout() {
  const theme = useTheme();

  return (
    <Tabs
      screenOptions={{
        headerShown: false,
        tabBarActiveTintColor: theme.primary,
        tabBarInactiveTintColor: theme.textSecondary,
        tabBarStyle: {
          backgroundColor: theme.background,
          borderTopColor: theme.border,
        },
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <SymbolView
              name="house.fill"
              tintColor={color}
              size={size}
            />
          ),
        }}
      />
      <Tabs.Screen
        name="history"
        options={{
          title: 'History',
          tabBarIcon: ({ color, size }) => (
            <SymbolView
              name="clock.fill"
              tintColor={color}
              size={size}
            />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <SymbolView
              name="person.fill"
              tintColor={color}
              size={size}
            />
          ),
        }}
      />
    </Tabs>
  );
}

```

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