PawTrace / CODEBASE_GUIDE.md
Elliott Duke
Cleanup of the system and documentation
d7d2eaf
|
Raw
History Blame Contribute Delete
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)
  2. The big mental model
  3. Vocabulary you need first
  4. The end-to-end story of one match
  5. Backend β€” configuration & plumbing
  6. Backend β€” the data model (database tables)
  7. Backend β€” schemas (the API's data contracts)
  8. Backend β€” the four "swap points" (storage, ML, geo)
  9. Backend β€” services (the business logic)
  10. Backend β€” the API routers (HTTP endpoints)
  11. Backend β€” scripts, migrations, tests
  12. Frontend β€” setup & shared infrastructure
  13. Frontend β€” reusable components
  14. Frontend β€” pages (one per screen)
  15. How to run it
  16. Where to read next, depending on your goal

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, yields 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 Routes. 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, 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.