| # 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/<uuid>.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 `<div id="root">` 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<T>`** (`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. |
|
|