|
Download CODEBASE_GUIDE.md from Duke410/PawTrace: direct link, hf CLI and curl.
- Browser
- Download file 70.5 kB
-
https://huggingface.co/spaces/Duke410/PawTrace/resolve/main/CODEBASE_GUIDE.md
- Command line
-
hf download hf://spaces/Duke410/PawTrace/CODEBASE_GUIDE.md
-
curl -L -o CODEBASE_GUIDE.md https://huggingface.co/spaces/Duke410/PawTrace/resolve/main/CODEBASE_GUIDE.md
70.5 kB
| # 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` | 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). | |
| - **`get_storage()`**: returns a single cached backend instance based on `settings.storage_backend`. | |
| The `global _backend` pattern is a simple lazy singleton. Only `local` is implemented; any other | |
| value raises immediately at startup rather than failing on the first upload. Adding cloud storage | |
| means writing a class that satisfies the `StorageBackend` protocol and selecting it here β nothing | |
| else in the codebase touches the filesystem directly. | |
| **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. | |