# PawTrace — Codebase Guide > A file-by-file walkthrough of the entire application, written for someone who is still > learning to code. It assumes **no prior knowledge of this project**. Read it top to bottom > and you should be able to explain how every piece works and how the pieces fit together. --- ## Table of contents 1. [What this app is (the 60-second version)](#1-what-this-app-is) 2. [The big mental model](#2-the-big-mental-model) 3. [Vocabulary you need first](#3-vocabulary-you-need-first) 4. [The end-to-end story of one match](#4-the-end-to-end-story-of-one-match) 5. [Backend — configuration & plumbing](#5-backend--configuration--plumbing) 6. [Backend — the data model (database tables)](#6-backend--the-data-model) 7. [Backend — schemas (the API's data contracts)](#7-backend--schemas) 8. [Backend — the four "swap points" (storage, ML, geo)](#8-backend--the-four-swap-points) 9. [Backend — services (the business logic)](#9-backend--services) 10. [Backend — the API routers (HTTP endpoints)](#10-backend--the-api-routers) 11. [Backend — scripts, migrations, tests](#11-backend--scripts-migrations-tests) 12. [Frontend — setup & shared infrastructure](#12-frontend--setup--shared-infrastructure) 13. [Frontend — reusable components](#13-frontend--reusable-components) 14. [Frontend — pages (one per screen)](#14-frontend--pages) 15. [How to run it](#15-how-to-run-it) 16. [Where to read next, depending on your goal](#16-where-to-read-next) --- ## 1. What this app is **PawTrace** is a website that helps reunite lost dogs with their owners. There are two sides: - **Owners** register their dogs (with photos). If a dog goes missing, the owner opens a "lost case." The system searches all the **found/sighted** dogs that strangers have reported and shows the owner the most visually similar ones. - **Finders** (people who find or spot a stray) upload a photo of the dog they found — **no account required**. The system instantly searches all currently-**lost** dogs and, if there's a strong match, emails that dog's owner. The "magic" is **photo similarity matching**: every uploaded photo is turned into a list of numbers (a "vector" / "embedding"), and two photos are considered similar if their vectors point in nearly the same direction. Matches are always **suggestions** — a human must confirm them. The system also filters by **geographic distance** (ZIP code proximity), because a dog lost in Washington DC is unlikely to be the dog found in Seattle. There are three reference documents at the repo root that this guide complements: - `PROJECT_SPEC_dog_reunification_V1.md` — the original "build brief" (what to build and why). The code is full of comments like `(spec §9.3)` that point back to sections of this file. - `DECISIONS.md` — a log of choices the developer made where the spec left options open. - `README.md` — quick start / run instructions. --- ## 2. The big mental model The project has two halves that run as **separate programs**: ``` ┌─────────────────────────────┐ HTTP requests ┌──────────────────────────────┐ │ FRONTEND (the browser) │ ───────────────────────► │ BACKEND (the server) │ │ React + TypeScript │ ◄─────────────────────── │ FastAPI (Python) │ │ folder: /frontend │ JSON responses │ folder: /backend │ │ │ │ │ │ Renders screens, forms, │ │ Validates requests, runs │ │ buttons. Talks to the │ │ the matching logic, talks │ │ backend over HTTP. │ │ to the database & files. │ └─────────────────────────────┘ └──────────────────────────────┘ ``` - The **frontend** is what the user sees: pages, buttons, photo uploads. It cannot do anything by itself — it asks the backend for data and tells the backend when the user does something. - The **backend** holds all the real logic: it stores data in a database (SQLite), saves uploaded images to disk, computes photo similarity, and decides who's allowed to see what. They communicate over **HTTP** using **JSON** (a text format for structured data). During local development the frontend runs on `http://localhost:5173` and the backend on `http://127.0.0.1:8000`; the frontend's dev server "proxies" (forwards) API calls to the backend so they appear to come from the same place (see `frontend/vite.config.ts`). The backend is deliberately built around **four "swap points"** — places where a simple default implementation can be replaced by a fancier one later *without changing any other code*: | Swap point | Implementations | Deployed demo runs | File | |-----------------|--------------------------------------------------|--------------------|------| | **Embedder** | `MockEmbedder`, `HFEmbedder`, `ReIDEmbedder` | `ReIDEmbedder` | `backend/app/ml/embedder.py` | | **BreedClassifier** | `MockBreedClassifier`, `HFBreedClassifier` | `HFBreedClassifier`| `backend/app/ml/breed.py` | | **VectorIndex** | `NumpyBruteForceIndex` (FAISS / sqlite-vec later) | brute force | `backend/app/ml/index.py` | | **StorageBackend** | `LocalStorage` (`S3Storage` later) | local disk | `backend/app/storage/backend.py` | | **Notifier** | `ConsoleNotifier`, `SMTPNotifier` | console | `backend/app/services/notify.py` | Each swap point is defined as an **interface** (a promise about which methods exist) plus one or more implementations. Code elsewhere only depends on the interface, so the concrete choice can be flipped with a config setting. --- ## 3. Vocabulary you need first - **API (endpoint / route):** a URL on the backend that does one thing, e.g. `POST /auth/login`. "POST/GET/PATCH/DELETE" are HTTP *methods* meaning roughly create / read / update / delete. - **ORM (SQLAlchemy):** library that lets you work with database rows as Python objects instead of writing raw SQL. A "model" class maps to a database table. - **Schema (Pydantic):** a class describing the *shape* of data coming in or going out of the API (which fields, which types). FastAPI uses these to validate requests and format responses. - **Embedding / vector:** a fixed-length list of numbers representing an image. Similar images → similar vectors. Here every vector is **L2-normalized** (scaled to length 1) so that comparing two vectors with a *dot product* gives their **cosine similarity** (a number from -1 to 1; closer to 1 = more similar). - **JWT (token):** after you log in, the server gives you a signed string (a "JSON Web Token"). Your browser sends it with every request to prove who you are. The server doesn't store sessions; it just verifies the signature — this is called "stateless" auth. - **Dependency injection (FastAPI `Depends`):** instead of each endpoint creating its own database connection or looking up the current user, it *declares* "I need a DB session" / "I need the logged-in user" and FastAPI supplies them. You'll see `Depends(get_db)` everywhere. --- ## 4. The end-to-end story of one match Before the file-by-file detail, here's the single most important flow — a finder reports a found dog and it matches someone's lost dog. Following this once makes the rest of the code click. 1. **Finder fills the form** in the browser (`frontend/src/pages/ReportFound.tsx`) — photo + ZIP + contact info — and hits submit. 2. The frontend packs it into a `FormData` object and calls `POST /cases/found` (`frontend/src/api.ts` → `api.createReport`). 3. The backend endpoint `create_found_case` (`backend/app/api/cases.py:237`) receives it, then calls the shared helper `_create_found_sighted` (`backend/app/api/cases.py:131`). 4. That helper: rate-limits the request, requires contact info, creates an `UnknownDog` row, then for each photo calls `process_and_store_picture` (`backend/app/services/images.py:58`). 5. **The image pipeline** validates the photo, strips its EXIF/GPS data, shrinks it, saves a JPEG + thumbnail to disk via `LocalStorage`, then runs the **embedder** to produce a vector and saves an `Embedding` row. 6. A `Case` row (type `found`) is created, and `run_matching_for_case` (`backend/app/services/matching.py:84`) runs: it gathers all currently-**lost** known dogs within the ZIP radius, scores each one's photos against the found dog's photo (cosine similarity), keeps the ones above `REVIEW_THRESHOLD`, ranks them, and saves the top N as `Match` rows. 7. If the #1 match is strong enough (`STRONG_THRESHOLD`), `notify_owner_of_strong_match` (`backend/app/services/matching.py:210`) emails that dog's owner (printed to the console by default). 8. The endpoint returns the case + the ranked matches + vet/shelter guidance as JSON. 9. The frontend shows a success screen with `MatchCard` components and (for found dogs) what to do next (`ReportFound.tsx`, the `if (result)` block). 10. Later, the owner logs in, opens the case (`CaseDetail.tsx`), reviews the candidates, and clicks **Confirm** — which calls `POST /matches/{id}/confirm` (`backend/app/api/matches.py:34`), marking both dogs "reunited" and the case "resolved." Keep this flow in mind as you read the files below. --- ## 5. Backend — configuration & plumbing These files are the app's skeleton: how it reads settings, connects to the database, and boots up. ### `backend/app/config.py` — all the tunable settings **Purpose:** one central place for every configurable value (database location, thresholds, image limits, secrets). Values come from environment variables or a `.env` file, with sensible defaults so the app runs with zero configuration. - **`class Settings(BaseSettings)`** (`config.py:16`): Uses `pydantic-settings`, which automatically reads each attribute from an environment variable of the same name (case-insensitive). For example the attribute `review_threshold` is filled from a `REVIEW_THRESHOLD` env var if present. - `model_config` (`config.py:17`) tells it to load from a `.env` file and to ignore unknown env vars (`extra="ignore"`). - The fields are grouped by area: database, storage, embedder/ML, **matching thresholds** (`config.py:33` — explicitly flagged as *placeholders to tune*, not known-correct values), radius levels, image limits, notifications, SMS (off), auth/JWT, geo, and misc. - **`_parse_radius_levels`** (`config.py:72`) and **`_parse_cors`** (`config.py:79`) are *validators*. Env vars are always strings, so a value like `"0,10,25,50,100,-1"` needs to be split into a Python list of ints. `mode="before"` means "run this before normal type-checking." This is a small but important design touch: the spec demanded that every threshold/radius be a *named config value*, never a magic number hardcoded in logic. - **`media_path`** (`config.py:87`) and **`max_image_bytes`** (`config.py:91`) are computed properties — convenience values derived from the raw settings (e.g. MB → bytes). - **`get_settings()`** (`config.py:95`): wrapped in `@lru_cache` so the settings object is built only once and reused (a lightweight singleton). `settings = get_settings()` (`config.py:100`) creates that single instance that the whole app imports. **Connects to:** essentially everything — almost every backend file does `from ..config import settings`. ### `backend/app/db.py` — the database connection **Purpose:** create the database "engine" and hand out database "sessions" (short-lived connections used to run queries within one request). - **Directory creation** (`db.py:17`): if using SQLite (the default), make sure the folder for the `.db` file exists before connecting. - **`engine`** (`db.py:24`): the core object that knows how to talk to the database. `connect_args` with `check_same_thread: False` (`db.py:21`) is a SQLite-specific tweak that lets the connection be used across threads (FastAPI needs this). - **`_enable_sqlite_fk`** (`db.py:27`): an event listener that runs `PRAGMA foreign_keys=ON` on each new SQLite connection. SQLite ignores foreign-key constraints by default; this turns enforcement on so the database actually rejects orphaned references. - **`SessionLocal`** (`db.py:36`): a factory that produces new `Session` objects. `expire_on_commit=False` means objects you've loaded stay usable after a commit (handy because endpoints often return data right after committing). - **`get_db()`** (`db.py:39`): a generator used as a FastAPI dependency. It opens a session, `yield`s it to the endpoint, and **always closes it afterward** (the `finally` block) even if an error occurs. Every endpoint that touches the DB receives its session via `db: Session = Depends(get_db)`. **Connects to:** `config.py` (for `database_url`), every API router, and `services` that need DB access. ### `backend/app/main.py` — the application entry point **Purpose:** build the FastAPI `app` object, wire up middleware, error handling, all the routers, and the static-file server. This is what `uvicorn app.main:app` runs. - **`lifespan`** (`main.py:22`): an async context manager that runs **once at startup**. It creates the media folder and calls `Base.metadata.create_all` to auto-create all database tables. (The comment notes that in production you'd use Alembic migrations instead, but for the MVP this is simpler.) - **`app = FastAPI(...)`** (`main.py:30`): creates the application with a title/version (which also populates the auto-generated API docs at `/docs`). - **CORS middleware** (`main.py:37`): "Cross-Origin Resource Sharing." Because the frontend (`localhost:5173`) and backend (`127.0.0.1:8000`) are different origins, browsers block requests between them unless the server explicitly allows it. The allowed origins come from `settings.cors_origins`. - **Error handlers** (`main.py:47` and `main.py:55`): these guarantee a *consistent error shape*. Any HTTP error returns `{"error": {"code": ..., "message": ...}}`, and validation errors (422) include a `details` list. The frontend's `api.ts` relies on this shape to extract error messages. (There's a test for it: `test_error_envelope_shape` in `test_auth.py`.) - **Routers** (`main.py:64`–`70`): `include_router` attaches each group of endpoints (auth, users, dogs, cases, matches, admin, geo). Each router lives in its own file under `app/api/`. - **Static media mount** (`main.py:74`): serves the saved image files under the `/media/...` URL so the browser can display them directly. **Connects to:** all the routers, `config`, `db`, and the models. ### `backend/app/security.py` — passwords & login tokens **Purpose:** everything about proving identity — hashing passwords, creating/verifying JWT tokens, and the FastAPI dependencies that fetch the "current user." - **`hash_password`** (`security.py:27`) / **`verify_password`** (`security.py:31`): use **bcrypt** to one-way-hash passwords. You never store the real password — only the hash. `verify_password` re-hashes the attempt and checks it matches. Note the `[:_BCRYPT_MAX]` truncation to 72 bytes (`security.py:24`): bcrypt only looks at the first 72 bytes, so they truncate consistently in both functions to avoid a subtle mismatch bug. `verify_password` returns `False` instead of crashing on malformed input — defensive design. - **`create_access_token`** (`security.py:38`): builds a JWT containing the user id (`sub`) and an expiry (`exp`), signed with the secret from settings. - **`_decode_user_id`** (`security.py:46`): reverses it — verifies the signature and returns the user id, or `None` if the token is invalid/expired. Catching the exceptions and returning `None` keeps callers simple. - **`get_current_user_optional`** (`security.py:56`): a dependency that returns the logged-in `User` **or `None`** if there's no/invalid token. This is the key to "anonymous finders allowed" — note `HTTPBearer(auto_error=False)` (`security.py:21`) which means a missing token is *not* an automatic error. - **`get_current_user`** (`security.py:68`): wraps the optional one and **raises 401** if there's no user. Endpoints that require login use this. - **`require_admin`** (`security.py:80`): builds on `get_current_user` and raises 403 unless the user's role is `admin`. Used by the admin endpoints. **Connects to:** `config` (JWT secret), `db` + `User` model (to load the user), and is consumed by every protected endpoint via `Depends(...)`. --- ## 6. Backend — the data model These files define the database tables. They all live in `backend/app/models/`. Each class inheriting `Base` becomes a table; each `Mapped[...]` attribute becomes a column. ### `backend/app/models/base.py` — shared foundations & enums **Purpose:** the declarative `Base` class, a timestamp mixin, and all the **enums** (fixed sets of allowed string values like statuses and types). - **`class Base(DeclarativeBase)`** (`base.py:15`): the parent class for every model. SQLAlchemy collects all subclasses' table definitions onto `Base.metadata`, which is how `create_all`/Alembic know what tables to build. - **`TimestampMixin`** (`base.py:23`): adds `created_at` and `updated_at` columns to any model that inherits it. `default=_utcnow` sets them in Python; `onupdate=_utcnow` bumps `updated_at` on every change. A *mixin* is a small reusable class you add via multiple inheritance — here it avoids repeating the timestamp columns on every table. - **The enums** (`base.py:38`–`94`): each is `(str, enum.Enum)` so the value is stored in the DB as a plain string (e.g. `"lost"`) but referenced in code as `CaseType.lost`. Important ones: - `UserRole`: `owner` / `finder` / `admin`. - `KnownDogStatus`: `home` → `lost` → `reunited`. - `UnknownDogStatus`: `pending` / `lost` (sighted, not in custody) / `at_shelter` / `claimed` / `reunited` — the comments (`base.py:51`) document exactly what each means. - `CaseType`: `lost` / `found` / `sighted`; `CaseStatus`: `open` → `matched` → `resolved` / `closed`. - `SubjectType`: `known` / `unknown` — used to say which *kind* of dog a picture or match refers to (this is the "polymorphic" link explained below). - `MatchStatus`, `NotificationChannel`, `NotificationStatus`. **Connects to:** imported by every other model file; the enums are also reused by the Pydantic schemas and the matching service. ### `backend/app/models/__init__.py` — the model registry **Purpose:** importing this package (`from app.models import ...`) imports *all* model classes, which registers every table on `Base.metadata`. If a model file isn't imported, its table won't be created. The `__all__` list (`__init__.py:11`) declares the public names. ### `backend/app/models/user.py` — the `users` table **Purpose:** registered accounts (owners and admins). - **`class User`** (`user.py:9`): inherits `Base` and `TimestampMixin`. - `email` is `unique=True, index=True` (`user.py:14`) — the login identifier; indexing makes lookups fast. - `password_hash` is **nullable** (`user.py:18`) because the data model allows for lightweight accounts without a usable password (though in practice finders skip accounts entirely). - `role` uses `SAEnum(UserRole, native_enum=False)` (`user.py:19`). `native_enum=False` stores the value as a plain string column rather than a database-native ENUM type — more portable across databases. - **`dogs` relationship** (`user.py:23`): links a user to their `KnownDog` rows. `cascade="all, delete-orphan"` means deleting a user deletes their dogs too. `back_populates` pairs with the `owner` attribute on `KnownDog` so both sides stay in sync in memory. **Connects to:** `KnownDog` (owns dogs), `Case` (a case's `person_id`), `Match.reviewed_by`, `Notification.user_id`. ### `backend/app/models/dog.py` — `known_dogs` and `unknown_dogs` **Purpose:** two tables, one file. A **`KnownDog`** is a dog an owner registered. An **`UnknownDog`** is a stray someone found/sighted whose identity isn't known yet. - **`class KnownDog`** (`dog.py:9`): - `owner_id` (`dog.py:15`) is a foreign key to `users.id`. - Appearance fields: `breed`, `age` (kept as a flexible string like "≈3 yrs"), `color`, `size` (a `DogSize` enum). `color` and `size` matter because the matcher uses them as a cheap pre-filter. - `last_known_zip` (`dog.py:25`) is set when the dog is reported lost — it's the location the matcher uses for distance filtering. - `status` (`dog.py:26`) defaults to `home`. - `owner` relationship (`dog.py:30`) is the other half of `User.dogs`. - **`class UnknownDog`** (`dog.py:33`): - Has *estimated* fields (`est_breed`, `est_age`) since nobody knows for sure. - `current_zip` (`dog.py:47`) is where the dog is now — used for distance filtering on this side. - **`current_location_detail`** (`dog.py:49`): the shelter/vet name. The comment stresses this is **never exposed publicly** (privacy, spec §14). You'll see this enforced later in `hydrate.py` and the `UnknownDogOut` schema. **Connects to:** `User` (owner), `Case` (a case points to one known or one unknown dog), `Picture` (by `subject_type`/`subject_id` convention), and the matching service. ### `backend/app/models/case.py` — the `cases` table **Purpose:** a `Case` is one "incident" — a dog reported lost, found, or sighted. It ties together a person (or anonymous finder contact), a dog, a location, a date, and a status. - **`class Case`** (`case.py:11`): - `person_id` (`case.py:18`) is **nullable** — null for anonymous finders. - `finder_name/finder_email/finder_phone` (`case.py:22`): contact details stored directly on the case when there's no account. - `known_dog_id` and `unknown_dog_id` (`case.py:26`, `:29`): a lost case links to a `KnownDog`; a found/sighted case links to an `UnknownDog`. Only one is set. - `type` (`case.py:33`): lost/found/sighted. - `event_zip` (`case.py:34`, indexed) and `event_date`: where/when it happened. - **`search_radius_miles`** (`case.py:38`): the *current* widening level. Starts at the first radius level and grows when the user clicks "Widen search." - `status` (`case.py:40`): open/matched/resolved/closed. **Connects to:** `User`, `KnownDog`/`UnknownDog`, `Match` (matches belong to a case), and is the central object the matching service operates on. ### `backend/app/models/picture.py` — the `pictures` table **Purpose:** one row per stored image. Crucially, **the image bytes are NOT in the database** — only a file path (the actual file lives on disk via the storage backend). - **`class Picture`** (`picture.py:11`): - **`subject_type` + `subject_id`** (`picture.py:20`, `:23`): this is a **polymorphic association**. Instead of two separate "known_dog_pictures" and "unknown_dog_pictures" tables, one table serves both: `subject_type` says which kind (`known`/`unknown`) and `subject_id` is that dog's id. The comment calls `subject_id` an "FK-by-convention" — it's not a real database foreign key (it can't be, since it points to one of two tables), so the application is responsible for integrity. - `file_path` / `thumb_path` (`picture.py:24`, `:25`): relative keys into the storage backend. - `is_primary` (`picture.py:29`): marks the representative photo shown in lists/cards. - `is_probably_not_dog` (`picture.py:30`): a flag from the original design, meant to warn that an upload may not show a dog while still storing it. Nothing sets it today — the planned detector was never built, and the breed model cannot stand in for one (see `ml/breed.py` below). - **`embeddings` relationship** (`picture.py:37`): one picture can have multiple embeddings (one per ML model version), with cascade delete. **Connects to:** `Embedding` (one-to-many), and logically to `KnownDog`/`UnknownDog` via the subject convention. Created by the image pipeline (`services/images.py`). ### `backend/app/models/embedding.py` — the `embeddings` table **Purpose:** stores the numeric vector for one picture under one ML model. Kept in its own table (rather than a column on `pictures`) so you can recompute vectors with a new model **without losing the old ones** — vital for the "swap in a better model later" goal. - **`class Embedding`** (`embedding.py:20`): - `__table_args__` with a **`UniqueConstraint`** (`embedding.py:29`) on `(picture_id, model_name, model_version)`: a given picture can have only one vector per model version (no duplicates). - `dim` (`embedding.py:39`): the vector length (64 for the mock, 2,048 for the ResNet-101 models). - **`vector`** (`embedding.py:40`): stored as `LargeBinary` (raw bytes). The comment notes it's float32 and **L2-normalized at write time** so cosine similarity is just a dot product. - **`as_array()`** (`embedding.py:49`): converts the stored bytes back into a NumPy array for math. - **`to_bytes()`** (`embedding.py:52`, a `@staticmethod`): converts a NumPy array into the bytes to store. The image pipeline calls `Embedding.to_bytes(vec)` when saving. **Connects to:** `Picture` (parent), the embedder (`ml/embedder.py`) which produces the vectors, and the matching service which reads them back via `as_array()`. ### `backend/app/models/match.py` — the `matches` table **Purpose:** a saved candidate match — "case X's dog might be the same as dog Y, with similarity score Z." These are the rows a human later confirms or rejects. - **`class Match`** (`match.py:20`): - `case_id` (`match.py:26`): which case this match belongs to. - `candidate_type` + `candidate_id` (`match.py:27`, `:30`): the matched dog, again using the polymorphic `known`/`unknown` convention. - `candidate_case_id` (`match.py:31`): the *other* case the candidate dog came from, if any — used so confirming a match can resolve both cases. - `similarity_score` (`match.py:34`) and `rank` (`match.py:35`, 1 = best). - `model_name`/`model_version` (`match.py:36`): which embedder produced it — so old matches from a different model are distinguishable. - `status` (`match.py:38`): pending/confirmed/rejected. `reviewed` (`match.py:41`) and `reviewed_by` (`match.py:42`) record the human decision. **Connects to:** `Case` (parent + candidate case), `User` (reviewer), `KnownDog`/`UnknownDog` (candidate). Created by `services/matching.py`, read/updated by `api/matches.py` and `api/admin.py`. ### `backend/app/models/notification.py` — the `notifications` table **Purpose:** an audit record of every notification the system tried to send (e.g. "emailed Alice about a match"). Storing these lets you see what was sent and whether it succeeded. - **`class Notification`** (`notification.py:18`): links optionally to a `user`, `case`, and `match`; records the `channel` (email/sms), `status` (queued/sent/failed), `to_address`, the `payload` (the rendered message text), and `sent_at`. **Connects to:** written by `send_notification` in `services/notify.py`. --- ## 7. Backend — schemas Schemas (in `backend/app/schemas/`) are **Pydantic models** that describe the JSON going in and out of the API. They're separate from the database models on purpose: the database model might have private fields (like `password_hash` or `current_location_detail`) that should never appear in an API response. Schemas are the controlled "public view." A recurring detail: `model_config = ConfigDict(from_attributes=True)` lets a schema be built directly from a database object via `Schema.model_validate(db_object)` — Pydantic reads the matching attributes off the ORM object. ### `backend/app/schemas/common.py` - **`Page[T]`** (`common.py:10`): a generic wrapper for paginated lists — `items`, `total`, `limit`, `offset`. Generic (`Generic[T]`) means `Page[KnownDog]`, `Page[Case]`, etc. all reuse it. - **`Message`** (`common.py:17`): a trivial `{ "detail": "..." }` response (e.g. logout). ### `backend/app/schemas/auth.py` - **`RegisterRequest`** (`auth.py:8`): registration input. Note `Field(min_length=8, ...)` on the password and `EmailStr` (validates email format — requires the `email-validator` package). - **`LoginRequest`** (`auth.py:16`), **`TokenResponse`** (`auth.py:21`, the `{access_token, token_type}` returned on login/register). - **`UserOut`** (`auth.py:26`): the safe view of a user — note it **omits `password_hash`**. - **`UserUpdate`** (`auth.py:37`): all-optional fields for `PATCH /users/me`. ### `backend/app/schemas/dog.py` - **`PictureOut`** (`dog.py:10`): a picture for the API. It adds **`url`** and **`thumb_url`** (`dog.py:23`, `:24`) which aren't database columns — they're filled in by `hydrate_picture` in `api/helpers.py` so the frontend gets ready-to-use image URLs. - **`KnownDogCreate`** (`dog.py:27`) / **`KnownDogUpdate`** (`dog.py:37`): create vs. update inputs (update is all-optional). - **`KnownDogOut`** (`dog.py:47`): full owner-facing dog view, including its `pictures`. - **`UnknownDogOut`** (`dog.py:65`): the **privacy-safe** view of a found/sighted dog. The docstring and the field list make the point: it has `current_zip` (ZIP-level only) but **no** `current_location_detail`. This is privacy enforced by *what the schema includes*. ### `backend/app/schemas/case.py` - **`LostCaseCreate`** (`case.py:10`): opening a lost case. Either supply `known_dog_id` for an existing dog, **or** the `new_dog_*` fields to register a dog inline in the same step. - **`FoundSightedCreate`** (`case.py:26`): a found/sighted report. Includes finder contact fields and `current_location_detail` (the private shelter name). *(Note: the actual found/sighted endpoints use individual `Form(...)` parameters rather than this schema because they're multipart uploads — this schema documents the conceptual shape.)* - **`CaseUpdate`** (`case.py:48`): edit notes or close a case. - **`MatchOut`** (`case.py:53`): a match for the API. The interesting field is **`candidate: dict | None`** (`case.py:67`) — a flexible bag holding the hydrated, privacy-filtered candidate dog (filled by `api/hydrate.py`). - **`CaseOut`** (`case.py:70`): the case view. - **`FoundReportResponse`** (`case.py:87`): the combined response for create-lost / create-found / widen — the case, its matches, and optional `vet_guidance`. The frontend's `FoundReportResponse` TypeScript type mirrors this exactly. **Connects to:** the API routers (which declare these as `response_model=...`), and the frontend's `types.ts` mirrors them by hand. --- ## 8. Backend — the four swap points These are the pluggable interfaces from the mental model. Each defines an abstract interface plus a default implementation, and a `get_*()` function that returns the configured one. ### `backend/app/storage/backend.py` — file storage **Purpose:** decide *where* image files physically live. Default = local disk; the seam allows cloud storage (S3) later. - **`class StorageBackend(ABC)`** (`backend.py:14`): the interface. `ABC` = "Abstract Base Class"; the `@abstractmethod` decorators mean any subclass **must** implement `save`, `open`, `delete`, `abs_path`. You can't instantiate the abstract class directly. - **`class LocalStorage`** (`backend.py:32`): writes files under a root media directory. - **`_full(key)`** (`backend.py:37`): turns a relative key into an absolute path **and guards against path traversal** — it refuses keys that would escape the media root (e.g. `../../etc/...`). A small but real security measure. - `save` / `open` / `delete` / `abs_path` (`backend.py:44`–`59`): the obvious file operations. `abs_path` returns the on-disk path (used so the embedder can read the file directly). - **`class S3Storage`** (`backend.py:62`): a **stub** — every method is empty and the constructor raises `NotImplementedError`. It exists to show exactly what you'd implement for cloud storage. `# pragma: no cover` tells the test-coverage tool to ignore it. - **`get_storage()`** (`backend.py:77`): returns a single cached backend instance based on `settings.storage_backend`. The `global _backend` pattern is a simple lazy singleton. **Connects to:** `services/images.py` (saves photos), `api/dogs.py` (deletes photos), and the `/media` static mount in `main.py`. ### `backend/app/ml/embedder.py` — turning images into vectors **Purpose:** the heart of the "AI." Converts an image file into an L2-normalized vector. Three implementations ship: a deterministic stand-in for tests, and two real models. The deployed demo runs `ReIDEmbedder`. - **`class Embedder(Protocol)`** (`embedder.py:18`): the interface, written as a `Protocol` (Python's "structural typing" — any class with `name`, `version`, `dim`, and an `embed` method counts, without explicitly inheriting). `embed(image_paths)` returns one vector per path. - **`_l2_normalize`** (`embedder.py:28`): scales a vector to length 1 (so dot product = cosine). Handles the zero-vector edge case to avoid dividing by zero. - **`class MockEmbedder`** (`embedder.py:39`): selected by `EMBEDDER=mock`, and what the test suite uses. - **`_vector_for`**: opens the image, shrinks it to 32×32 grayscale, hashes the raw pixels with SHA-256, then seeds a random-number generator with that hash to produce a stable vector. **Same image → same vector every time.** The docstring is explicit that *similar-but-not-identical* images are **not** close — this is intentional. It exists so the whole pipeline runs and is testable without GPUs, weights, or network access; it is not meant to produce real-world matches. (This is why seed/test data reuses the *same image seed* to force a match — see `scripts/seed.py`.) - **`class HFEmbedder`** (`embedder.py:73`): selected by `EMBEDDER=hf`. Uses the pooled pre-classifier features of the HuggingFace breed model as the vector. When the breed classifier is the same HF repo, it can return the embedding and the breed softmax from **one** forward pass, which is what `scripts/process_dataset.py` relies on. - **`class ReIDEmbedder`** (`embedder.py:159`): selected by `EMBEDDER=reid`, and what the deployed demo runs. Loads the fine-tuned checkpoint at `REID_MODEL_PATH` into the same ResNet base and emits the L2-normalized 2,048-dim pooled features. Preprocessing matches **training** (Resize 224 + ImageNet normalization), not the HF image processor — a mismatch here silently degrades every score. This model produces no breed labels; those come from the separate classifier. - **`get_embedder()`** (`embedder.py:207`) and **`reset_embedder_cache()`** (`embedder.py:219`): lazy singleton selection by config, plus a test hook to force re-selection after changing settings. Both real embedders **lazily import** torch/transformers inside `__init__`, so the mock path never needs those heavy packages installed. Both carry `# pragma: no cover` because tests run on the mock. **Connects to:** `services/images.py` (embeds on upload), `services/matching.py` (reads the active model name/version), and `scripts/eval_matching.py`. ### `backend/app/ml/index.py` — searching vectors **Purpose:** given a query vector and a list of candidate vectors, return the most similar ones. - **`class VectorIndex(Protocol)`** (`index.py:14`): the interface — a `search(query, candidates, top_k)` method returning `(id, similarity)` pairs. - **`class NumpyBruteForceIndex`** (`index.py:25`): the default. **`search`** (`index.py:26`) stacks all candidate vectors into a matrix and computes `mat @ q` (matrix-times-vector = a dot product with each candidate). Because vectors are normalized, those dot products *are* cosine similarities. Then it sorts descending and takes the top `k`. "Brute force" = compare against everything; perfectly fine and exact at this app's small scale. - **`get_index()`** (`index.py:45`): the cached singleton. **Connects to:** `services/matching.py` (uses it for scoring). ### `backend/app/ml/breed.py` — predicting a breed **Purpose:** guess a dog's breed from a photo. This is a **separate model** from the one that matches individual dogs, and its labels are never used to rank photo-search results. - **`class BreedClassifier(Protocol)`** (`breed.py:24`): the interface — `predict(image_paths)` returns `(label, score)` pairs per image. - **`normalize_label`** (`breed.py:41`): tidies raw HuggingFace class names (underscores, casing) into something displayable, and gives the breed gate a stable key to compare against. - **`class MockBreedClassifier`** (`breed.py:51`): deterministic stand-in, same rationale as the mock embedder — tests run offline with no weights. - **`class HFBreedClassifier`** (`breed.py:95`): the real one, a 120-class ResNet-101 breed model pulled from HuggingFace on first use. This is what the deployed demo runs. - **`get_breed_classifier()`** (`breed.py:140`) / **`reset_breed_classifier_cache()`** (`breed.py:150`): the cached singleton and its test hook. **A limitation worth knowing:** with 120 classes and no "none of these" option, softmax must pick a winner, so out-of-distribution inputs (noise, a blank square, a photo of text) can score extremely high on some breed. A confidence threshold cannot separate those from genuine photos, because real dogs also score in the high 90s. The interface states the limitation rather than filtering. **Connects to:** `services/images.py` (predicts on upload, alongside embedding), `services/matching.py` (the optional breed gate on the *case matcher*, not on photo search). ### `backend/app/ml/__init__.py` and `storage/__init__.py` These just re-export the public names so other modules can write `from ..ml import get_embedder, get_index` cleanly. --- ## 9. Backend — services Services hold the **business logic** — the real work, separated from the HTTP layer so it can be tested and reused. Located in `backend/app/services/`. ### `backend/app/services/geo.py` — distance between ZIP codes **Purpose:** convert ZIP codes to latitude/longitude and compute real-world distances, which drives the radius filtering. - **`class GeoService`** (`geo.py:15`): - **`__init__`** (`geo.py:16`) + **`_load`** (`geo.py:22`): read the bundled `data/zip_centroids.csv` once into a dict mapping ZIP → (lat, lng). It tolerates bad rows. - **`_norm`** (`geo.py:36`): normalizes a ZIP to 5 digits (zero-padded), so `"7030"` and `"07030"` match. - **`centroid`** (`geo.py:42`): look up a ZIP's coordinates. - **`distance_miles`** (`geo.py:46`): returns the great-circle distance between two ZIPs, or `None` if either ZIP is unknown. - **`_haversine`** (`geo.py:54`): the haversine formula — standard math for distance between two points on a sphere (Earth), here returning miles. - **`within_radius`** (`geo.py:62`): the function the matcher actually calls. `radius == -1` means "nationwide / no filter → always True." **Key design choice: if a ZIP is unknown it returns `True` (fail-open)** so missing geo data never *hides* a possible match — the app prioritizes recall plus human review over precision. (Tested in `test_geo.py`.) - **`get_geo()`** (`geo.py:78`): cached singleton (`@lru_cache`). **Connects to:** `services/matching.py` (filtering), `api/geo.py` (the distance/shelter endpoints), and reads the file path from `config`. ### `backend/app/services/images.py` — the image pipeline **Purpose:** the full "what happens when a photo is uploaded" pipeline (spec §8): validate → normalize → store → thumbnail → embed. - **`ImageValidationError`** (`images.py:24`): a custom exception; routers catch it and turn it into an HTTP 400. - **`_validate_and_decode`** (`images.py:28`): checks the byte size against the limit, then uses Pillow's `verify()` for a cheap integrity check. Note it **re-opens** the image afterward (`images.py:36`) because `verify()` leaves the image object unusable — a real Pillow gotcha. - **`_normalize`** (`images.py:42`): `exif_transpose` bakes in the correct rotation and, by not copying EXIF, **drops GPS/PII metadata** (a privacy requirement). Converts to RGB and downscales so the longest side ≤ the configured max. - **`_encode`** (`images.py:52`): re-encodes to JPEG bytes. - **`process_and_store_picture`** (`images.py:58`): the orchestrator, called by the routers. 1. Validate + normalize (`images.py:67`). 2. Build a unique storage key like `known/42/.jpg` (`images.py:72`) and save the main image. 3. Make and save a 320×320 thumbnail (`images.py:77`). 4. Insert the `Picture` row and `db.flush()` to assign its id (`images.py:92`) — `flush` sends the INSERT to the DB but doesn't commit the transaction yet. 5. Read the saved file back, run the embedder, and insert an `Embedding` row (`images.py:96`). It embeds from the *stored* file so the vector reflects exactly what was persisted. The `if abs_path is not None` guard accommodates non-local storage backends. Note it does **not** commit — the calling endpoint commits, so multiple photos + matching can all succeed or fail together as one transaction. **Connects to:** `storage` (save files), `ml` (embed), the `Picture`/`Embedding` models, and is called by `api/dogs.py` and `api/cases.py`. ### `backend/app/services/notify.py` — sending notifications **Purpose:** send an email (or pretend to) and record it. Default prints to the console. - **`class Notifier(ABC)`** (`notify.py:24`): interface with a single `send(to, subject, body)`. - **`ConsoleNotifier`** (`notify.py:30`): logs and prints the email — the dev default, so no real mail is sent. - **`SMTPNotifier`** (`notify.py:37`): sends real email via SMTP, only used when configured. Returns `False` on failure rather than crashing. - **`get_notifier()`** (`notify.py:56`): picks SMTP only if `notifier=smtp` *and* a host is set; otherwise console. - **`send_notification`** (`notify.py:62`): the function the rest of the app calls. It first writes a `Notification` row with status `queued`, attempts the send, then updates the row to `sent` or `failed` and stamps `sent_at`. This guarantees an audit trail regardless of outcome. **Connects to:** `services/matching.py` (triggers emails on strong matches), the `Notification` model. ### `backend/app/services/matching.py` — the core matching engine **Purpose:** the most important service. Given a case, find and rank candidate dogs, save them as `Match` rows, update the case status, and trigger notifications. (spec §9.) - **`CandidateScore`** (`matching.py:33`): a small dataclass holding a candidate's type, id, score, and originating case id while ranking. - **`_active_model`** (`matching.py:41`): returns the current embedder's `(name, version)` so we only compare vectors made by the *same* model. - **`_vectors_for_subject`** (`matching.py:46`): loads all embedding vectors for one dog's pictures, filtered to the active model. Joins `Embedding` → `Picture` and filters by subject + model. - **`_metadata_compatible`** (`matching.py:63`): the cheap appearance gate. It only excludes a candidate on a **definite conflict** (both have a color and they differ, or both have a size and they differ). Missing values never exclude — again, prioritizing recall. - **`_dog_level_score`** (`matching.py:73`): a dog may have several photos. This computes the **max** cosine similarity over every (query photo × candidate photo) pair. Using the max makes it robust to extra or bad photos — one good matching angle is enough. - **`run_matching_for_case`** (`matching.py:84`): the orchestrator. Step by step: 1. **Resolve the query subject** (`matching.py:94`): for a lost case the query is the known dog; for found/sighted it's the unknown dog. Grabs its color/size for the metadata gate. Returns `[]` early if there's no dog or no embedded photos yet. 2. **Build the candidate pool** (`matching.py:113`): - A lost (known) dog searches the **unknown** pool with status in `pending`/`lost`/`at_shelter` (`matching.py:116`). - A found/sighted (unknown) dog searches the **known** pool with status `lost` (`matching.py:145`). - For each candidate: skip if outside the ZIP radius (`geo.within_radius`), skip on a metadata conflict, skip if it has no vectors, otherwise compute its dog-level score. It also looks up the candidate's own case id so a confirm can resolve both sides. 3. **Filter, sort, cap** (`matching.py:172`): keep scores ≥ `REVIEW_THRESHOLD`, sort descending, take the top `TOP_N`. 4. **Persist** (`matching.py:177`): delete this case's prior *pending* matches (so re-running replaces stale results but preserves confirmed/rejected ones), then insert fresh ranked `Match` rows. 5. **Drive status** (`matching.py:199`): if there are matches and the case is still `open`, bump it to `matched`. It **never auto-resolves** — a human must confirm. - **`is_strong`** (`matching.py:206`): true only if the rank-1 match's score ≥ `STRONG_THRESHOLD`. - **`notify_owner_of_strong_match`** (`matching.py:210`): for a found/sighted case whose top match is a strong *known* dog, email that dog's owner. The email body (`matching.py:232`) deliberately contains only a case link and **no finder address** — contact stays mediated. - **`rematch_open_lost_cases_against`** (`matching.py:244`): when a *new* found/sighted dog appears, re-run every open/matched lost case so existing owners can be alerted to the newcomer. Runs synchronously for the MVP; the docstring notes a background job is the future path. **Connects to:** `ml` (embedder + index), `geo`, `notify`, all the dog/case/match models, and is called by `api/cases.py` and `api/dogs.py`. --- ## 10. Backend — the API routers Each file in `backend/app/api/` defines an `APIRouter` — a group of related endpoints. They are the HTTP "surface" of the app; they validate input (via schemas), enforce permissions, call services, and shape responses. All are attached to the app in `main.py`. ### `backend/app/api/helpers.py` — shared router utilities - **`media_url`** (`helpers.py:19`): turns a stored file key into a `/media/...` URL. - **`hydrate_picture`** (`helpers.py:23`): converts a `Picture` row into a `PictureOut` schema and fills in `url`/`thumb_url`. - **`pictures_for`** (`helpers.py:30`): fetches all pictures for a subject, primary first, hydrated. - **`rate_limit`** (`helpers.py:43`): a **basic in-memory fixed-window rate limiter**. It keeps a `deque` of recent request timestamps per client IP (`_buckets`, `helpers.py:40`), drops entries older than 60 seconds, and raises HTTP 429 if the count exceeds the configured limit. The comment context tells you this is intentionally simple (per-process, resets on restart) — fine for the MVP, not for a clustered production deployment. ### `backend/app/api/hydrate.py` — privacy-aware candidate hydration **Purpose:** turn a `Match` into a `MatchOut` with its `candidate` dict filled in — **enforcing privacy**. This is where the rule "never leak the shelter/vet location" is implemented for matches. - **`build_match_out`** (`hydrate.py:17`): if the candidate is an unknown dog, it includes `current_zip` (ZIP-level) but **deliberately omits `current_location_detail`** (`hydrate.py:22`). If it's a known dog, it includes appearance fields and `last_known_zip` but no owner contact. Either way it attaches the candidate's hydrated pictures. There's a dedicated test (`test_unknown_dog_location_detail_not_exposed`) proving the secret never appears. ### `backend/app/api/auth.py` — register / login / me Router prefix `/auth`. - **`register`** (`auth.py:27`): rejects duplicate emails (409), hashes the password, creates an owner, commits, and returns a fresh JWT. - **`login`** (`auth.py:46`): verifies email + password, returns a JWT, or 401. Note the combined check (`auth.py:49`) avoids revealing whether the email exists. - **`logout`** (`auth.py:54`): a no-op for stateless JWT — the client just discards the token. Exists for API symmetry. - **`me`** (`auth.py:60`): returns the current user (requires auth). ### `backend/app/api/users.py` — the current user's profile Router prefix `/users`. - **`get_me`** (`users.py:14`): return the profile. - **`update_me`** (`users.py:19`): patch name/phone/zip. `model_dump(exclude_unset=True)` (`users.py:25`) means only fields the client actually sent are updated — partial update done right. ### `backend/app/api/dogs.py` — managing owned dogs & their photos Router prefix `/dogs`. All endpoints require login. - **`_get_owned_dog`** (`dogs.py:20`): the authorization helper — 404 if missing, 403 unless you own it (or are an admin). Reused by every dog endpoint. - **`_to_out`** (`dogs.py:29`): build a `KnownDogOut` with its pictures attached. - **`create_dog`** (`dogs.py:35`), **`list_my_dogs`** (`dogs.py:48`, paginated and clamped to ≤100), **`get_dog`** (`dogs.py:69`), **`update_dog`** (`dogs.py:78`). - **`upload_photos`** (`dogs.py:93`): the busy one. - Enforces the per-dog photo cap (`dogs.py:106`). - Runs `process_and_store_picture` for each file, marking the first photo of a fresh dog as primary (`dogs.py:120`). Catches `ImageValidationError` → 400. - **Then** (`dogs.py:131`) checks whether this dog has an open lost case and, if so, re-runs matching — because new photos can produce new matches (spec §8 step 6). Note the imports are *inside* the function (`dogs.py:127`) to avoid circular-import problems at module load. - Commits once at the end. - **`delete_photo`** (`dogs.py:147`): validates ownership and that the picture belongs to this dog, deletes the files from storage, then deletes the row. ### `backend/app/api/cases.py` — the heart of the workflows Router prefix `/cases`. This is the largest router; it implements lost/found/sighted creation, listing, widening, and updates. - **`_first_radius`** (`cases.py:52`) / **`_next_radius`** (`cases.py:56`): read the configured `radius_levels` list to find the starting radius and the next one when widening. - **`_owns_case`** (`cases.py:65`): authorization — owner or admin only. - **`create_lost_case`** (`cases.py:76`): requires login. - Either validates the existing `known_dog_id` (and ownership) or **inline-creates** a new dog from the `new_dog_*` fields (`cases.py:92`). - Marks the dog `lost` and sets its `last_known_zip` (`cases.py:103`). - Creates the `Case`, runs matching, commits, and returns case + matches. - **`_create_found_sighted`** (`cases.py:131`): the shared engine for both found and sighted reports (so the two endpoints don't duplicate logic). - Rate-limits (`cases.py:153`). - **Requires contact info when anonymous** (`cases.py:156`) — 400 otherwise. - Requires at least one photo and enforces the photo cap. - Computes the unknown dog's initial status (`cases.py:169`): a *found* dog with a shelter detail → `at_shelter`; a plain *found* → `pending`; a *sighted* dog → `lost` (seen but not in custody). - Creates the `UnknownDog`, stores its photos, creates the `Case` (copying logged-in user's name/ email as finder contact if present, `cases.py:203`). - Runs matching, notifies owners of strong matches, **and** re-matches open lost cases against this new dog (`cases.py:218`). - For *found* reports, attaches `vet_guidance` from `nearby_shelters` (`cases.py:228`). - **`create_found_case`** (`cases.py:237`) and **`create_sighted_case`** (`cases.py:281`): the actual endpoints. They take **`Form(...)` fields + `File(...)`** because uploads are `multipart/form-data`, not JSON. They use `get_current_user_optional` so anonymous users are allowed. The sighted endpoint passes `None` for the found-only "where is it now" fields. - **`list_my_cases`** (`cases.py:325`), **`get_case`** (`cases.py:348`, ownership-checked), **`get_case_matches`** (`cases.py:362`, returns hydrated matches ordered by rank). - **`widen_case`** (`cases.py:381`): advance to the next radius level (400 if already widest) and re-run matching — letting an owner cast a wider net. - **`update_case`** (`cases.py:405`): edit notes and/or close the case. ### `backend/app/api/matches.py` — confirm / reject a match Router prefix `/matches`. Requires login. - **`_load_owned_match`** (`matches.py:22`): fetches the match + its case and checks ownership/admin. - **`confirm_match`** (`matches.py:34`): the resolution logic. Marks the match `confirmed`, then **resolves the case and marks both dogs `reunited`** — handling both directions (the candidate is an unknown dog vs. a known dog), and also resolves the candidate's own case if there is one (`matches.py:64`). This is what "drives the case toward resolution" concretely means. - **`reject_match`** (`matches.py:74`): marks the match `rejected` and records the reviewer; leaves the case open so other candidates can still be confirmed. ### `backend/app/api/shelters.py` — static vet/shelter guidance - **`nearby_shelters`** (`shelters.py:29`): returns a small static list of national resources plus (if a ZIP is given) a generic "animal control for ZIP X" entry. The module docstring is honest that this is **not** a real geolocated directory — it's MVP guidance, with real per-ZIP data flagged as future work. ### `backend/app/api/geo.py` — geo/utility endpoints - **`get_nearby_shelters`** (`geo.py:11`): wraps `nearby_shelters`. - **`get_distance`** (`geo.py:16`): returns miles between two ZIPs (handy for debugging the matcher). - **`healthz`** (`geo.py:22`): a trivial health check returning `{"status": "ok"}` — used to verify the server is up. ### `backend/app/api/admin.py` — the thin admin slice Router prefix `/admin`. Every endpoint uses `Depends(require_admin)`. - **`pending_matches`** (`admin.py:18`): all matches awaiting review. - **`all_cases`** (`admin.py:29`): every case in the system. - **`close_case`** (`admin.py:38`): force-close any case. - **`flag_spam`** (`admin.py:53`): mark a match rejected (the spam action). Note an admin can act on any match, unlike a regular user. --- ## 11. Backend — scripts, migrations, tests ### `backend/scripts/make_sample_images.py` - **`make_image(seed, size)`** (`make_sample_images.py:13`): generates a deterministic, colorful "dog-ish" JPEG from a numeric seed — same seed → same image bytes. No network, no real photos. This determinism is what lets the mock embedder produce *predictable* matches in seeds/tests (two subjects given the same seed get identical vectors → a guaranteed strong match). ### `backend/scripts/seed.py` - **`run()`** (`seed.py:27`): populates a fresh database so the app is demo-ready. It skips if data already exists (`seed.py:31`). It creates an admin, two owners (Alice, Bob), Alice's **lost** dog "Rex" in DC, a **found** dog near DC built with the *same image seed as Rex* (`seed.py:93`, so the mock embedder yields a strong match), and a distant Seattle sighting that should *not* match within a tight radius. Finally it runs matching for Rex's case so the demo opens with a match already waiting. Prints the seeded logins. Run with `python -m scripts.seed`. ### `backend/scripts/eval_matching.py` - **Purpose:** the threshold-tuning harness (spec §9.6). Given a CSV of labeled image pairs (same-dog vs different-dog), it measures how well the *current* embedder separates them. - **`_load_pairs`** (`eval_matching.py:25`): read the CSV. - **`evaluate`** (`eval_matching.py:37`): embed every pair, compute cosine similarity, then sweep 101 thresholds computing precision/recall and a trapezoidal **ROC-AUC**. This is the objective basis on which `REVIEW_THRESHOLD`/`STRONG_THRESHOLD` should eventually be set (rather than the placeholder defaults). - **`main`** (`eval_matching.py:72`): CLI entry — `python -m scripts.eval_matching pairs.csv`. ### `backend/alembic/env.py` and `backend/alembic.ini` - **Purpose:** Alembic is the database **migration** tool — for evolving the schema over time in production (instead of the dev-only `create_all`). `env.py` points Alembic at the app's `settings.database_url` and `Base.metadata` so generated migrations match the models, with `render_as_batch=True` for SQLite compatibility. `alembic.ini` is its config/logging. (The `versions/` folder is currently empty — no migrations have been generated yet, consistent with the MVP using `create_all`.) ### `backend/tests/` — the test suite Run with `pytest`. Uses the deterministic mock embedder so results are reproducible. - **`conftest.py`**: shared fixtures. Crucially it sets environment variables **before importing the app** (`conftest.py:12`) — pointing at a throwaway temp SQLite DB and media dir, forcing the mock embedder and console notifier, and raising the rate limit so tests don't trip it. **`_fresh_db`** (`conftest.py:31`, `autouse=True`) drops and recreates all tables around every test for isolation. **`client`** (`conftest.py:39`) is FastAPI's `TestClient`. **`owner_token`** (`conftest.py:55`) registers a user and returns a JWT; **`auth`** (`conftest.py:70`) builds the `Authorization` header. - **`test_auth.py`**: registration, duplicate-email rejection, wrong password, auth-required, and the error-envelope shape. - **`test_dogs.py`**: create/list a dog, that uploading a photo creates a picture + embedding, that non-images are rejected (400), and that you can't access someone else's dog (403). - **`test_geo.py`**: close ZIPs are near, coast-to-coast is far, radius levels work, and unknown ZIPs fail open. - **`test_matching.py`**: the end-to-end ones — a found report matches a lost dog (and gets vet guidance), anonymous reports require contact, a distant dog doesn't match until you widen to nationwide, confirming a match resolves the case, the private location detail is never exposed, and you can't view another person's case matches. These tests are an excellent **executable specification** of the whole system — read them to confirm your understanding. --- ## 12. Frontend — setup & shared infrastructure The frontend is a **React + TypeScript Single-Page Application (SPA)** built with **Vite** and styled with **Tailwind CSS**. "Single-page" means the browser loads one HTML page and JavaScript swaps the visible content as you navigate — no full page reloads. ### `frontend/package.json` Declares dependencies (`react`, `react-dom`, `react-router-dom` for routing) and scripts: `npm run dev` (start the dev server), `build`, `preview`. Tailwind/PostCSS/TypeScript are dev dependencies. ### `frontend/vite.config.ts` Configures the dev server on port 5173 and **proxies** API paths (`/auth`, `/dogs`, `/cases`, `/media`, etc.) to the backend at `127.0.0.1:8000` (`vite.config.ts:9`). This is why the frontend can call `/cases/found` directly without worrying about CORS or full URLs during development. ### `frontend/index.html` + `frontend/src/main.tsx` - `index.html` is the single page; it has an empty `
` and loads `main.tsx`. - **`main.tsx`** mounts React into that div and wraps the whole app in three providers (`main.tsx:9`): `React.StrictMode` (dev safety checks), `BrowserRouter` (enables URL routing), and `AuthProvider` (makes login state available everywhere). ### `frontend/src/App.tsx` — routing **Purpose:** map URLs to page components. - **`RequireAuth`** (`App.tsx:17`): a guard component. While auth state is loading it shows "Loading…"; if there's no user it **redirects to `/login`**; otherwise it renders the protected page. - **`App`** (`App.tsx:24`): wraps everything in `Layout` and declares all the `Route`s. Public routes: home, login, register, report found/sighted. Protected routes (wrapped in `RequireAuth`): my dogs, dog detail, report lost, my cases, case detail, admin. The `path="*"` route (`App.tsx:81`) redirects unknown URLs home. Notice `ReportFound` is reused for both `found` and `sighted` via a `kind` prop. ### `frontend/src/types.ts` — TypeScript types **Purpose:** hand-written TypeScript interfaces that **mirror the backend Pydantic schemas** so the editor can type-check API data. The comment (`types.ts:1`) notes these could be auto-generated from the backend's OpenAPI schema, but are hand-written for clarity. If you change a backend schema, update the matching type here. ### `frontend/src/api.ts` — the typed fetch wrapper **Purpose:** one place that knows how to talk to the backend. Every component calls `api.something()` instead of using `fetch` directly. - **Token helpers** (`api.ts:13`): store/read the JWT in `localStorage` under `pawtrace_token`. - **`ApiError`** (`api.ts:21`): a custom error carrying the HTTP status. - **`request`** (`api.ts:29`): the core. It attaches the `Authorization: Bearer` header if logged in (`api.ts:31`), sets JSON content-type **unless the body is `FormData`** (`api.ts:33`, important for file uploads), handles 204 No Content, and on failure extracts the message from the backend's `{error:{message}}` envelope and throws `ApiError`. - **`api`** (`api.ts:46`): the catalog of typed methods grouped by area (auth, dogs, cases, matches, geo). For example `createReport` (`api.ts:68`) sends `FormData` to `/cases/found|sighted`; `uploadDogPhotos` (`api.ts:59`) builds `FormData` from a `File[]`. ### `frontend/src/auth.tsx` — global login state **Purpose:** a React **Context** that holds the current user and the login/register/logout functions, so any component can call `useAuth()`. - **`AuthProvider`** (`auth.tsx:15`): on mount, if a token exists it calls `api.me()` to load the user (and clears the token if it's invalid) — this is how you stay logged in across page refreshes. `login`/`register` (`auth.tsx:31`, `:37`) store the token then fetch the user; `logout` (`auth.tsx:43`) clears both. - **`useAuth`** (`auth.tsx:55`): the hook components use; it throws if used outside the provider (a helpful guard). ### `frontend/src/index.css` + `tailwind.config.js` + `postcss.config.js` - **`index.css`** pulls in Tailwind and defines reusable component classes with `@apply` (`.btn`, `.btn-primary`, `.input`, `.card`, `.badge`, etc.) so markup stays tidy (`btn-primary` instead of a dozen utility classes). - **`tailwind.config.js`** defines the custom `brand` blue color palette and tells Tailwind which files to scan. - **`postcss.config.js`** wires Tailwind + autoprefixer into the build. --- ## 13. Frontend — reusable components In `frontend/src/components/`. These are the shared building blocks used across pages. ### `Layout.tsx` The page chrome: a sticky header with the logo and navigation (which changes based on whether you're logged in and whether you're an admin — `Layout.tsx:26`, `:34`), the main content area, and a footer that restates the privacy/"matches are suggestions" message. `navClass` (`Layout.tsx:5`) highlights the active nav link. ### `ConfidenceBar.tsx` A small visual bar for a similarity score (`ConfidenceBar.tsx:2`). It converts the 0–1 score to a percentage and picks a color and label — **Strong** (≥80%, green), **Possible** (≥60%, yellow), or **Weak** (gray). It uses `role="progressbar"` for accessibility. This is the UI embodiment of "show similarity clearly, as a suggestion." ### `MatchCard.tsx` Displays one candidate match (`MatchCard.tsx:11`): the candidate's primary photo (falling back to a placeholder), a title, breed/color/ZIP line, the `ConfidenceBar`, and — only for **pending** matches when handlers are provided — **Confirm / Not my dog** buttons (`MatchCard.tsx:56`). The `act` helper (`MatchCard.tsx:18`) disables the buttons while the request is in flight (the `busy` state). Reused on the case detail page, the finder result screen, and the admin page. ### `PhotoUpload.tsx` A multi-photo picker with live previews (`PhotoUpload.tsx:10`). Key detail: the hidden file input uses `accept="image/*" capture="environment"` (`PhotoUpload.tsx:51`) so **on a phone it opens the rear camera directly** — a core requirement for finders in the field. It enforces a max count, shows removable thumbnails (built from `URL.createObjectURL`), and calls `onChange` with the current `File[]` so the parent form can submit them. --- ## 14. Frontend — pages In `frontend/src/pages/`. One component per screen. They follow a consistent pattern: local state via `useState`, data loading via `useEffect` + `api.*`, and a `busy`/`error` pair for form submission. ### `Home.tsx` The landing page (`Home.tsx:4`). Three calls to action ("I found a dog", "My dog is lost" / "Register my dog" depending on login, "I sighted a dog") and a simple 3-step explainer. ### `Login.tsx` / `Register.tsx` Standard auth forms. Each holds form state, calls `useAuth().login`/`register` on submit, shows an error on failure, and navigates to `/dogs` on success (`Login.tsx:19`, `Register.tsx:22`). `Register.tsx` collects name/email/password/ZIP/optional phone. ### `MyDogs.tsx` Lists the owner's dogs as cards with a status badge (`MyDogs.tsx:48`), plus a collapsible **`AddDogForm`** (`MyDogs.tsx:76`) that creates the dog and then uploads any chosen photos (`MyDogs.tsx:94`) before reloading the list. Empty and loading states are handled. ### `DogDetail.tsx` Shows one dog's details and photos, with a **Report lost** button (or a "Reported lost" badge if it already is — `DogDetail.tsx:39`). Has its own `PhotoUpload` so you can add more photos after creation (`DogDetail.tsx:68`); uploading reloads the dog. The little `Field` helper (`DogDetail.tsx:79`) renders a label/value pair, showing "—" when empty. ### `ReportLost.tsx` The owner's lost-case form (`ReportLost.tsx:7`). It lets you pick an existing dog **or** create one inline (the `mode` toggle, `ReportLost.tsx:10`), captures last-seen ZIP/date/notes, calls `api.createLost`, and navigates to the new case page. It auto-selects "new dog" mode if you have no dogs yet (`ReportLost.tsx:22`). ### `ReportFound.tsx` The finder flow, reused for both **found** and **sighted** via the `kind` prop (`ReportFound.tsx:11`). Highlights: - Builds a `FormData` (because of the photos) and only includes found-only fields for `found`, and finder-contact fields only when not logged in (`ReportFound.tsx:50`, `:55`). - After submit it switches to a **results view** (the `if (result)` block, `ReportFound.tsx:69`) showing a friendly summary, the `vet_guidance` list for found dogs, and a `MatchCard` per match (read-only — no confirm buttons, since the finder isn't the owner). - The shelter/vet field is clearly labeled "kept private" (`ReportFound.tsx:162`), matching the backend's privacy handling. ### `MyCases.tsx` Lists the user's cases with type, ZIP, date, radius label, and a status badge (`MyCases.tsx:33`). "nationwide" is shown when the radius is `-1` (`MyCases.tsx:38`). ### `CaseDetail.tsx` The case workspace (`CaseDetail.tsx:7`). It loads the case and its matches in parallel (`CaseDetail.tsx:16`), and provides the full owner workflow: - **Widen search** (`CaseDetail.tsx:26`) — disabled at nationwide. - **Confirm / reject** each match via `MatchCard` handlers (`CaseDetail.tsx:38`), reloading after. - **Save notes** and **Close case** (`CaseDetail.tsx:46`, `:50`). - A persistent reminder that matches are suggestions to confirm only if confident (`CaseDetail.tsx:93`). ### `Admin.tsx` The thin admin screen (`Admin.tsx:6`). It lists pending matches and offers a **Flag as spam** button per match. Note it calls the `/admin/...` endpoints with `fetch` directly (reading the token from `localStorage`) rather than going through `api.ts` (`Admin.tsx:14`) — a small inconsistency, since the admin endpoints simply weren't added to the shared `api` object. --- ## 15. How to run it From the `README.md`, condensed: **Backend** (from `backend/`): ``` python -m venv .venv .venv\Scripts\Activate.ps1 # Windows PowerShell pip install -r requirements.txt python -m scripts.seed # demo data uvicorn app.main:app --reload --port 8000 ``` API docs are auto-generated at `http://127.0.0.1:8000/docs`. Tests: `pytest -q`. **Frontend** (from `frontend/`): ``` npm install npm run dev # http://localhost:5173 ``` Seeded logins: the demo seeder creates an owner (with lost dog Rex and a match waiting) and an admin. Passwords are generated at seed time and printed once, never hardcoded. --- ## 16. Where to read next Depending on what you want to be able to explain: - **"How does the AI matching work?"** → `ml/embedder.py` → `ml/index.py` → `services/matching.py`, then `tests/test_matching.py` to see it proven. - **"How does a request flow through the system?"** → re-read [section 4](#4-the-end-to-end-story-of-one-match), then follow `api/cases.py` into `services/`. - **"How is privacy enforced?"** → `schemas/dog.py` (`UnknownDogOut`), `api/hydrate.py`, `services/images.py` (`_normalize` strips EXIF), and the privacy tests. - **"How does login work?"** → `security.py` → `api/auth.py` → frontend `auth.tsx` + `api.ts`. - **"How does the UI work?"** → `App.tsx` (routing) → `auth.tsx` (state) → a page like `CaseDetail.tsx` → the components it uses. A good way to cement understanding: pick one of the `tests/test_matching.py` scenarios, then trace every file it touches from the HTTP call down to the database and back. By the end you'll be able to narrate the whole system confidently.