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
- What this app is (the 60-second version)
- The big mental model
- Vocabulary you need first
- The end-to-end story of one match
- Backend β configuration & plumbing
- Backend β the data model (database tables)
- Backend β schemas (the API's data contracts)
- Backend β the four "swap points" (storage, ML, geo)
- Backend β services (the business logic)
- Backend β the API routers (HTTP endpoints)
- Backend β scripts, migrations, tests
- Frontend β setup & shared infrastructure
- Frontend β reusable components
- Frontend β pages (one per screen)
- How to run it
- 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 seeDepends(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.
- Finder fills the form in the browser (
frontend/src/pages/ReportFound.tsx) β photo + ZIP + contact info β and hits submit. - The frontend packs it into a
FormDataobject and callsPOST /cases/found(frontend/src/api.tsβapi.createReport). - 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). - That helper: rate-limits the request, requires contact info, creates an
UnknownDogrow, then for each photo callsprocess_and_store_picture(backend/app/services/images.py:58). - 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 anEmbeddingrow. - A
Caserow (typefound) is created, andrun_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 aboveREVIEW_THRESHOLD, ranks them, and saves the top N asMatchrows. - 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). - The endpoint returns the case + the ranked matches + vet/shelter guidance as JSON.
- The frontend shows a success screen with
MatchCardcomponents and (for found dogs) what to do next (ReportFound.tsx, theif (result)block). - Later, the owner logs in, opens the case (
CaseDetail.tsx), reviews the candidates, and clicks Confirm β which callsPOST /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): Usespydantic-settings, which automatically reads each attribute from an environment variable of the same name (case-insensitive). For example the attributereview_thresholdis filled from aREVIEW_THRESHOLDenv var if present.model_config(config.py:17) tells it to load from a.envfile 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) andmax_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_cacheso 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.dbfile exists before connecting. engine(db.py:24): the core object that knows how to talk to the database.connect_argswithcheck_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 runsPRAGMA foreign_keys=ONon 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 newSessionobjects.expire_on_commit=Falsemeans 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 (thefinallyblock) even if an error occurs. Every endpoint that touches the DB receives its session viadb: 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 callsBase.metadata.create_allto 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 fromsettings.cors_origins. - Error handlers (
main.py:47andmain.py:55): these guarantee a consistent error shape. Any HTTP error returns{"error": {"code": ..., "message": ...}}, and validation errors (422) include adetailslist. The frontend'sapi.tsrelies on this shape to extract error messages. (There's a test for it:test_error_envelope_shapeintest_auth.py.) - Routers (
main.py:64β70):include_routerattaches each group of endpoints (auth, users, dogs, cases, matches, admin, geo). Each router lives in its own file underapp/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_passwordre-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_passwordreturnsFalseinstead 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, orNoneif the token is invalid/expired. Catching the exceptions and returningNonekeeps callers simple.get_current_user_optional(security.py:56): a dependency that returns the logged-inUserorNoneif there's no/invalid token. This is the key to "anonymous finders allowed" β noteHTTPBearer(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 onget_current_userand raises 403 unless the user's role isadmin. 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 ontoBase.metadata, which is howcreate_all/Alembic know what tables to build.TimestampMixin(base.py:23): addscreated_atandupdated_atcolumns to any model that inherits it.default=_utcnowsets them in Python;onupdate=_utcnowbumpsupdated_aton 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 asCaseType.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): inheritsBaseandTimestampMixin.emailisunique=True, index=True(user.py:14) β the login identifier; indexing makes lookups fast.password_hashis nullable (user.py:18) because the data model allows for lightweight accounts without a usable password (though in practice finders skip accounts entirely).roleusesSAEnum(UserRole, native_enum=False)(user.py:19).native_enum=Falsestores the value as a plain string column rather than a database-native ENUM type β more portable across databases.dogsrelationship (user.py:23): links a user to theirKnownDogrows.cascade="all, delete-orphan"means deleting a user deletes their dogs too.back_populatespairs with theownerattribute onKnownDogso 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 tousers.id.- Appearance fields:
breed,age(kept as a flexible string like "β3 yrs"),color,size(aDogSizeenum).colorandsizematter 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 tohome.ownerrelationship (dog.py:30) is the other half ofUser.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 inhydrate.pyand theUnknownDogOutschema.
- Has estimated fields (
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_idandunknown_dog_id(case.py:26,:29): a lost case links to aKnownDog; a found/sighted case links to anUnknownDog. Only one is set.type(case.py:33): lost/found/sighted.event_zip(case.py:34, indexed) andevent_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_typesays which kind (known/unknown) andsubject_idis that dog's id. The comment callssubject_idan "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 (seeml/breed.pybelow).embeddingsrelationship (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 aUniqueConstraint(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 asLargeBinary(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 callsEmbedding.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 polymorphicknown/unknownconvention.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) andrank(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) andreviewed_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 auser,case, andmatch; records thechannel(email/sms),status(queued/sent/failed),to_address, thepayload(the rendered message text), andsent_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]) meansPage[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. NoteField(min_length=8, ...)on the password andEmailStr(validates email format β requires theemail-validatorpackage).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 omitspassword_hash.UserUpdate(auth.py:37): all-optional fields forPATCH /users/me.
backend/app/schemas/dog.py
PictureOut(dog.py:10): a picture for the API. It addsurlandthumb_url(dog.py:23,:24) which aren't database columns β they're filled in byhydrate_pictureinapi/helpers.pyso 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 itspictures.UnknownDogOut(dog.py:65): the privacy-safe view of a found/sighted dog. The docstring and the field list make the point: it hascurrent_zip(ZIP-level only) but nocurrent_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 supplyknown_dog_idfor an existing dog, or thenew_dog_*fields to register a dog inline in the same step.FoundSightedCreate(case.py:26): a found/sighted report. Includes finder contact fields andcurrent_location_detail(the private shelter name). (Note: the actual found/sighted endpoints use individualForm(...)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 iscandidate: dict | None(case.py:67) β a flexible bag holding the hydrated, privacy-filtered candidate dog (filled byapi/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 optionalvet_guidance. The frontend'sFoundReportResponseTypeScript 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@abstractmethoddecorators mean any subclass must implementsave,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_pathreturns 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 raisesNotImplementedError. It exists to show exactly what you'd implement for cloud storage.# pragma: no covertells the test-coverage tool to ignore it.get_storage()(backend.py:77): returns a single cached backend instance based onsettings.storage_backend. Theglobal _backendpattern 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 aProtocol(Python's "structural typing" β any class withname,version,dim, and anembedmethod 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 byEMBEDDER=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 β seescripts/seed.py.)
class HFEmbedder(embedder.py:73): selected byEMBEDDER=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 whatscripts/process_dataset.pyrelies on.class ReIDEmbedder(embedder.py:159): selected byEMBEDDER=reid, and what the deployed demo runs. Loads the fine-tuned checkpoint atREID_MODEL_PATHinto 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) andreset_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 β asearch(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 computesmat @ 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 topk. "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 bundleddata/zip_centroids.csvonce 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, orNoneif 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 == -1means "nationwide / no filter β always True." Key design choice: if a ZIP is unknown it returnsTrue(fail-open) so missing geo data never hides a possible match β the app prioritizes recall plus human review over precision. (Tested intest_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'sverify()for a cheap integrity check. Note it re-opens the image afterward (images.py:36) becauseverify()leaves the image object unusable β a real Pillow gotcha._normalize(images.py:42):exif_transposebakes 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.- Validate + normalize (
images.py:67). - Build a unique storage key like
known/42/<uuid>.jpg(images.py:72) and save the main image. - Make and save a 320Γ320 thumbnail (
images.py:77). - Insert the
Picturerow anddb.flush()to assign its id (images.py:92) βflushsends the INSERT to the DB but doesn't commit the transaction yet. - Read the saved file back, run the embedder, and insert an
Embeddingrow (images.py:96). It embeds from the stored file so the vector reflects exactly what was persisted. Theif abs_path is not Noneguard 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.
- Validate + normalize (
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 singlesend(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. ReturnsFalseon failure rather than crashing.get_notifier()(notify.py:56): picks SMTP only ifnotifier=smtpand a host is set; otherwise console.send_notification(notify.py:62): the function the rest of the app calls. It first writes aNotificationrow with statusqueued, attempts the send, then updates the row tosentorfailedand stampssent_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. JoinsEmbeddingβPictureand 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:- 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. - 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.
- A lost (known) dog searches the unknown pool with status in
- Filter, sort, cap (
matching.py:172): keep scores β₯REVIEW_THRESHOLD, sort descending, take the topTOP_N. - 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 rankedMatchrows. - Drive status (
matching.py:199): if there are matches and the case is stillopen, bump it tomatched. It never auto-resolves β a human must confirm.
- Resolve the query subject (
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 aPicturerow into aPictureOutschema and fills inurl/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 adequeof 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 includescurrent_zip(ZIP-level) but deliberately omitscurrent_location_detail(hydrate.py:22). If it's a known dog, it includes appearance fields andlast_known_zipbut 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 aKnownDogOutwith 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_picturefor each file, marking the first photo of a fresh dog as primary (dogs.py:120). CatchesImageValidationErrorβ 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.
- Enforces the per-dog photo cap (
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 configuredradius_levelslist 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 thenew_dog_*fields (cases.py:92). - Marks the dog
lostand sets itslast_known_zip(cases.py:103). - Creates the
Case, runs matching, commits, and returns case + matches.
- Either validates the existing
_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 theCase(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_guidancefromnearby_shelters(cases.py:228).
- Rate-limits (
create_found_case(cases.py:237) andcreate_sighted_case(cases.py:281): the actual endpoints. They takeForm(...)fields +File(...)because uploads aremultipart/form-data, not JSON. They useget_current_user_optionalso anonymous users are allowed. The sighted endpoint passesNonefor 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 matchconfirmed, then resolves the case and marks both dogsreunitedβ 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 matchrejectedand 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): wrapsnearby_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 withpython -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 whichREVIEW_THRESHOLD/STRONG_THRESHOLDshould 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.pypoints Alembic at the app'ssettings.database_urlandBase.metadataso generated migrations match the models, withrender_as_batch=Truefor SQLite compatibility.alembic.iniis its config/logging. (Theversions/folder is currently empty β no migrations have been generated yet, consistent with the MVP usingcreate_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'sTestClient.owner_token(conftest.py:55) registers a user and returns a JWT;auth(conftest.py:70) builds theAuthorizationheader.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.htmlis the single page; it has an empty<div id="root">and loadsmain.tsx.main.tsxmounts React into that div and wraps the whole app in three providers (main.tsx:9):React.StrictMode(dev safety checks),BrowserRouter(enables URL routing), andAuthProvider(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 inLayoutand declares all theRoutes. Public routes: home, login, register, report found/sighted. Protected routes (wrapped inRequireAuth): my dogs, dog detail, report lost, my cases, case detail, admin. Thepath="*"route (App.tsx:81) redirects unknown URLs home. NoticeReportFoundis reused for bothfoundandsightedvia akindprop.
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 inlocalStorageunderpawtrace_token. ApiError(api.ts:21): a custom error carrying the HTTP status.request<T>(api.ts:29): the core. It attaches theAuthorization: Bearerheader if logged in (api.ts:31), sets JSON content-type unless the body isFormData(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 throwsApiError.api(api.ts:46): the catalog of typed methods grouped by area (auth, dogs, cases, matches, geo). For examplecreateReport(api.ts:68) sendsFormDatato/cases/found|sighted;uploadDogPhotos(api.ts:59) buildsFormDatafrom aFile[].
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 callsapi.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.csspulls in Tailwind and defines reusable component classes with@apply(.btn,.btn-primary,.input,.card,.badge, etc.) so markup stays tidy (btn-primaryinstead of a dozen utility classes).tailwind.config.jsdefines the custombrandblue color palette and tells Tailwind which files to scan.postcss.config.jswires 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 forfound, 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, thevet_guidancelist for found dogs, and aMatchCardper 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
MatchCardhandlers (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, thentests/test_matching.pyto see it proven. - "How does a request flow through the system?" β re-read section 4,
then follow
api/cases.pyintoservices/. - "How is privacy enforced?" β
schemas/dog.py(UnknownDogOut),api/hydrate.py,services/images.py(_normalizestrips EXIF), and the privacy tests. - "How does login work?" β
security.pyβapi/auth.pyβ frontendauth.tsx+api.ts. - "How does the UI work?" β
App.tsx(routing) βauth.tsx(state) β a page likeCaseDetail.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.