File size: 70,533 Bytes
6213763 de1e3fc 6213763 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 79688a8 de1e3fc 6213763 de1e3fc d7d2eaf de1e3fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 | # PawTrace β Codebase Guide
> A file-by-file walkthrough of the entire application, written for someone who is still
> learning to code. It assumes **no prior knowledge of this project**. Read it top to bottom
> and you should be able to explain how every piece works and how the pieces fit together.
---
## Table of contents
1. [What this app is (the 60-second version)](#1-what-this-app-is)
2. [The big mental model](#2-the-big-mental-model)
3. [Vocabulary you need first](#3-vocabulary-you-need-first)
4. [The end-to-end story of one match](#4-the-end-to-end-story-of-one-match)
5. [Backend β configuration & plumbing](#5-backend--configuration--plumbing)
6. [Backend β the data model (database tables)](#6-backend--the-data-model)
7. [Backend β schemas (the API's data contracts)](#7-backend--schemas)
8. [Backend β the four "swap points" (storage, ML, geo)](#8-backend--the-four-swap-points)
9. [Backend β services (the business logic)](#9-backend--services)
10. [Backend β the API routers (HTTP endpoints)](#10-backend--the-api-routers)
11. [Backend β scripts, migrations, tests](#11-backend--scripts-migrations-tests)
12. [Frontend β setup & shared infrastructure](#12-frontend--setup--shared-infrastructure)
13. [Frontend β reusable components](#13-frontend--reusable-components)
14. [Frontend β pages (one per screen)](#14-frontend--pages)
15. [How to run it](#15-how-to-run-it)
16. [Where to read next, depending on your goal](#16-where-to-read-next)
---
## 1. What this app is
**PawTrace** is a website that helps reunite lost dogs with their owners. There are two sides:
- **Owners** register their dogs (with photos). If a dog goes missing, the owner opens a
"lost case." The system searches all the **found/sighted** dogs that strangers have reported and
shows the owner the most visually similar ones.
- **Finders** (people who find or spot a stray) upload a photo of the dog they found β **no account
required**. The system instantly searches all currently-**lost** dogs and, if there's a strong
match, emails that dog's owner.
The "magic" is **photo similarity matching**: every uploaded photo is turned into a list of numbers
(a "vector" / "embedding"), and two photos are considered similar if their vectors point in nearly
the same direction. Matches are always **suggestions** β a human must confirm them. The system also
filters by **geographic distance** (ZIP code proximity), because a dog lost in Washington DC is
unlikely to be the dog found in Seattle.
There are three reference documents at the repo root that this guide complements:
- `PROJECT_SPEC_dog_reunification_V1.md` β the original "build brief" (what to build and why). The
code is full of comments like `(spec Β§9.3)` that point back to sections of this file.
- `DECISIONS.md` β a log of choices the developer made where the spec left options open.
- `README.md` β quick start / run instructions.
---
## 2. The big mental model
The project has two halves that run as **separate programs**:
```
βββββββββββββββββββββββββββββββ HTTP requests ββββββββββββββββββββββββββββββββ
β FRONTEND (the browser) β ββββββββββββββββββββββββΊ β BACKEND (the server) β
β React + TypeScript β ββββββββββββββββββββββββ β FastAPI (Python) β
β folder: /frontend β JSON responses β folder: /backend β
β β β β
β Renders screens, forms, β β Validates requests, runs β
β buttons. Talks to the β β the matching logic, talks β
β backend over HTTP. β β to the database & files. β
βββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
```
- The **frontend** is what the user sees: pages, buttons, photo uploads. It cannot do anything by
itself β it asks the backend for data and tells the backend when the user does something.
- The **backend** holds all the real logic: it stores data in a database (SQLite), saves uploaded
images to disk, computes photo similarity, and decides who's allowed to see what.
They communicate over **HTTP** using **JSON** (a text format for structured data). During local
development the frontend runs on `http://localhost:5173` and the backend on
`http://127.0.0.1:8000`; the frontend's dev server "proxies" (forwards) API calls to the backend so
they appear to come from the same place (see `frontend/vite.config.ts`).
The backend is deliberately built around **four "swap points"** β places where a simple default
implementation can be replaced by a fancier one later *without changing any other code*:
| Swap point | Implementations | Deployed demo runs | File |
|-----------------|--------------------------------------------------|--------------------|------|
| **Embedder** | `MockEmbedder`, `HFEmbedder`, `ReIDEmbedder` | `ReIDEmbedder` | `backend/app/ml/embedder.py` |
| **BreedClassifier** | `MockBreedClassifier`, `HFBreedClassifier` | `HFBreedClassifier`| `backend/app/ml/breed.py` |
| **VectorIndex** | `NumpyBruteForceIndex` (FAISS / sqlite-vec later) | brute force | `backend/app/ml/index.py` |
| **StorageBackend** | `LocalStorage` (`S3Storage` later) | local disk | `backend/app/storage/backend.py` |
| **Notifier** | `ConsoleNotifier`, `SMTPNotifier` | console | `backend/app/services/notify.py` |
Each swap point is defined as an **interface** (a promise about which methods exist) plus one or
more implementations. Code elsewhere only depends on the interface, so the concrete choice can be
flipped with a config setting.
---
## 3. Vocabulary you need first
- **API (endpoint / route):** a URL on the backend that does one thing, e.g. `POST /auth/login`.
"POST/GET/PATCH/DELETE" are HTTP *methods* meaning roughly create / read / update / delete.
- **ORM (SQLAlchemy):** library that lets you work with database rows as Python objects instead of
writing raw SQL. A "model" class maps to a database table.
- **Schema (Pydantic):** a class describing the *shape* of data coming in or going out of the API
(which fields, which types). FastAPI uses these to validate requests and format responses.
- **Embedding / vector:** a fixed-length list of numbers representing an image. Similar images β
similar vectors. Here every vector is **L2-normalized** (scaled to length 1) so that comparing two
vectors with a *dot product* gives their **cosine similarity** (a number from -1 to 1; closer to 1
= more similar).
- **JWT (token):** after you log in, the server gives you a signed string (a "JSON Web Token"). Your
browser sends it with every request to prove who you are. The server doesn't store sessions; it
just verifies the signature β this is called "stateless" auth.
- **Dependency injection (FastAPI `Depends`):** instead of each endpoint creating its own database
connection or looking up the current user, it *declares* "I need a DB session" / "I need the
logged-in user" and FastAPI supplies them. You'll see `Depends(get_db)` everywhere.
---
## 4. The end-to-end story of one match
Before the file-by-file detail, here's the single most important flow β a finder reports a found
dog and it matches someone's lost dog. Following this once makes the rest of the code click.
1. **Finder fills the form** in the browser (`frontend/src/pages/ReportFound.tsx`) β photo + ZIP +
contact info β and hits submit.
2. The frontend packs it into a `FormData` object and calls `POST /cases/found`
(`frontend/src/api.ts` β `api.createReport`).
3. The backend endpoint `create_found_case` (`backend/app/api/cases.py:237`) receives it, then calls
the shared helper `_create_found_sighted` (`backend/app/api/cases.py:131`).
4. That helper: rate-limits the request, requires contact info, creates an `UnknownDog` row, then
for each photo calls `process_and_store_picture` (`backend/app/services/images.py:58`).
5. **The image pipeline** validates the photo, strips its EXIF/GPS data, shrinks it, saves a JPEG +
thumbnail to disk via `LocalStorage`, then runs the **embedder** to produce a vector and saves an
`Embedding` row.
6. A `Case` row (type `found`) is created, and `run_matching_for_case`
(`backend/app/services/matching.py:84`) runs: it gathers all currently-**lost** known dogs within
the ZIP radius, scores each one's photos against the found dog's photo (cosine similarity), keeps
the ones above `REVIEW_THRESHOLD`, ranks them, and saves the top N as `Match` rows.
7. If the #1 match is strong enough (`STRONG_THRESHOLD`), `notify_owner_of_strong_match`
(`backend/app/services/matching.py:210`) emails that dog's owner (printed to the console by
default).
8. The endpoint returns the case + the ranked matches + vet/shelter guidance as JSON.
9. The frontend shows a success screen with `MatchCard` components and (for found dogs) what to do
next (`ReportFound.tsx`, the `if (result)` block).
10. Later, the owner logs in, opens the case (`CaseDetail.tsx`), reviews the candidates, and clicks
**Confirm** β which calls `POST /matches/{id}/confirm` (`backend/app/api/matches.py:34`),
marking both dogs "reunited" and the case "resolved."
Keep this flow in mind as you read the files below.
---
## 5. Backend β configuration & plumbing
These files are the app's skeleton: how it reads settings, connects to the database, and boots up.
### `backend/app/config.py` β all the tunable settings
**Purpose:** one central place for every configurable value (database location, thresholds, image
limits, secrets). Values come from environment variables or a `.env` file, with sensible defaults so
the app runs with zero configuration.
- **`class Settings(BaseSettings)`** (`config.py:16`): Uses `pydantic-settings`, which automatically
reads each attribute from an environment variable of the same name (case-insensitive). For example
the attribute `review_threshold` is filled from a `REVIEW_THRESHOLD` env var if present.
- `model_config` (`config.py:17`) tells it to load from a `.env` file and to ignore unknown env
vars (`extra="ignore"`).
- The fields are grouped by area: database, storage, embedder/ML, **matching thresholds**
(`config.py:33` β explicitly flagged as *placeholders to tune*, not known-correct values),
radius levels, image limits, notifications, SMS (off), auth/JWT, geo, and misc.
- **`_parse_radius_levels`** (`config.py:72`) and **`_parse_cors`** (`config.py:79`) are
*validators*. Env vars are always strings, so a value like `"0,10,25,50,100,-1"` needs to be
split into a Python list of ints. `mode="before"` means "run this before normal type-checking."
This is a small but important design touch: the spec demanded that every threshold/radius be a
*named config value*, never a magic number hardcoded in logic.
- **`media_path`** (`config.py:87`) and **`max_image_bytes`** (`config.py:91`) are computed
properties β convenience values derived from the raw settings (e.g. MB β bytes).
- **`get_settings()`** (`config.py:95`): wrapped in `@lru_cache` so the settings object is built
only once and reused (a lightweight singleton). `settings = get_settings()` (`config.py:100`)
creates that single instance that the whole app imports.
**Connects to:** essentially everything β almost every backend file does `from ..config import
settings`.
### `backend/app/db.py` β the database connection
**Purpose:** create the database "engine" and hand out database "sessions" (short-lived connections
used to run queries within one request).
- **Directory creation** (`db.py:17`): if using SQLite (the default), make sure the folder for the
`.db` file exists before connecting.
- **`engine`** (`db.py:24`): the core object that knows how to talk to the database. `connect_args`
with `check_same_thread: False` (`db.py:21`) is a SQLite-specific tweak that lets the connection be
used across threads (FastAPI needs this).
- **`_enable_sqlite_fk`** (`db.py:27`): an event listener that runs `PRAGMA foreign_keys=ON` on each
new SQLite connection. SQLite ignores foreign-key constraints by default; this turns enforcement
on so the database actually rejects orphaned references.
- **`SessionLocal`** (`db.py:36`): a factory that produces new `Session` objects.
`expire_on_commit=False` means objects you've loaded stay usable after a commit (handy because
endpoints often return data right after committing).
- **`get_db()`** (`db.py:39`): a generator used as a FastAPI dependency. It opens a session, `yield`s
it to the endpoint, and **always closes it afterward** (the `finally` block) even if an error
occurs. Every endpoint that touches the DB receives its session via `db: Session = Depends(get_db)`.
**Connects to:** `config.py` (for `database_url`), every API router, and `services` that need DB
access.
### `backend/app/main.py` β the application entry point
**Purpose:** build the FastAPI `app` object, wire up middleware, error handling, all the routers,
and the static-file server. This is what `uvicorn app.main:app` runs.
- **`lifespan`** (`main.py:22`): an async context manager that runs **once at startup**. It creates
the media folder and calls `Base.metadata.create_all` to auto-create all database tables. (The
comment notes that in production you'd use Alembic migrations instead, but for the MVP this is
simpler.)
- **`app = FastAPI(...)`** (`main.py:30`): creates the application with a title/version (which also
populates the auto-generated API docs at `/docs`).
- **CORS middleware** (`main.py:37`): "Cross-Origin Resource Sharing." Because the frontend
(`localhost:5173`) and backend (`127.0.0.1:8000`) are different origins, browsers block requests
between them unless the server explicitly allows it. The allowed origins come from
`settings.cors_origins`.
- **Error handlers** (`main.py:47` and `main.py:55`): these guarantee a *consistent error shape*. Any
HTTP error returns `{"error": {"code": ..., "message": ...}}`, and validation errors (422) include
a `details` list. The frontend's `api.ts` relies on this shape to extract error messages. (There's
a test for it: `test_error_envelope_shape` in `test_auth.py`.)
- **Routers** (`main.py:64`β`70`): `include_router` attaches each group of endpoints (auth, users,
dogs, cases, matches, admin, geo). Each router lives in its own file under `app/api/`.
- **Static media mount** (`main.py:74`): serves the saved image files under the `/media/...` URL so
the browser can display them directly.
**Connects to:** all the routers, `config`, `db`, and the models.
### `backend/app/security.py` β passwords & login tokens
**Purpose:** everything about proving identity β hashing passwords, creating/verifying JWT tokens,
and the FastAPI dependencies that fetch the "current user."
- **`hash_password`** (`security.py:27`) / **`verify_password`** (`security.py:31`): use **bcrypt** to
one-way-hash passwords. You never store the real password β only the hash. `verify_password`
re-hashes the attempt and checks it matches. Note the `[:_BCRYPT_MAX]` truncation to 72 bytes
(`security.py:24`): bcrypt only looks at the first 72 bytes, so they truncate consistently in both
functions to avoid a subtle mismatch bug. `verify_password` returns `False` instead of crashing on
malformed input β defensive design.
- **`create_access_token`** (`security.py:38`): builds a JWT containing the user id (`sub`) and an
expiry (`exp`), signed with the secret from settings.
- **`_decode_user_id`** (`security.py:46`): reverses it β verifies the signature and returns the user
id, or `None` if the token is invalid/expired. Catching the exceptions and returning `None` keeps
callers simple.
- **`get_current_user_optional`** (`security.py:56`): a dependency that returns the logged-in `User`
**or `None`** if there's no/invalid token. This is the key to "anonymous finders allowed" β note
`HTTPBearer(auto_error=False)` (`security.py:21`) which means a missing token is *not* an automatic
error.
- **`get_current_user`** (`security.py:68`): wraps the optional one and **raises 401** if there's no
user. Endpoints that require login use this.
- **`require_admin`** (`security.py:80`): builds on `get_current_user` and raises 403 unless the
user's role is `admin`. Used by the admin endpoints.
**Connects to:** `config` (JWT secret), `db` + `User` model (to load the user), and is consumed by
every protected endpoint via `Depends(...)`.
---
## 6. Backend β the data model
These files define the database tables. They all live in `backend/app/models/`. Each class
inheriting `Base` becomes a table; each `Mapped[...]` attribute becomes a column.
### `backend/app/models/base.py` β shared foundations & enums
**Purpose:** the declarative `Base` class, a timestamp mixin, and all the **enums** (fixed sets of
allowed string values like statuses and types).
- **`class Base(DeclarativeBase)`** (`base.py:15`): the parent class for every model. SQLAlchemy
collects all subclasses' table definitions onto `Base.metadata`, which is how
`create_all`/Alembic know what tables to build.
- **`TimestampMixin`** (`base.py:23`): adds `created_at` and `updated_at` columns to any model that
inherits it. `default=_utcnow` sets them in Python; `onupdate=_utcnow` bumps `updated_at` on every
change. A *mixin* is a small reusable class you add via multiple inheritance β here it avoids
repeating the timestamp columns on every table.
- **The enums** (`base.py:38`β`94`): each is `(str, enum.Enum)` so the value is stored in the DB as a
plain string (e.g. `"lost"`) but referenced in code as `CaseType.lost`. Important ones:
- `UserRole`: `owner` / `finder` / `admin`.
- `KnownDogStatus`: `home` β `lost` β `reunited`.
- `UnknownDogStatus`: `pending` / `lost` (sighted, not in custody) / `at_shelter` / `claimed` /
`reunited` β the comments (`base.py:51`) document exactly what each means.
- `CaseType`: `lost` / `found` / `sighted`; `CaseStatus`: `open` β `matched` β `resolved` /
`closed`.
- `SubjectType`: `known` / `unknown` β used to say which *kind* of dog a picture or match refers to
(this is the "polymorphic" link explained below).
- `MatchStatus`, `NotificationChannel`, `NotificationStatus`.
**Connects to:** imported by every other model file; the enums are also reused by the Pydantic
schemas and the matching service.
### `backend/app/models/__init__.py` β the model registry
**Purpose:** importing this package (`from app.models import ...`) imports *all* model classes,
which registers every table on `Base.metadata`. If a model file isn't imported, its table won't be
created. The `__all__` list (`__init__.py:11`) declares the public names.
### `backend/app/models/user.py` β the `users` table
**Purpose:** registered accounts (owners and admins).
- **`class User`** (`user.py:9`): inherits `Base` and `TimestampMixin`.
- `email` is `unique=True, index=True` (`user.py:14`) β the login identifier; indexing makes
lookups fast.
- `password_hash` is **nullable** (`user.py:18`) because the data model allows for lightweight
accounts without a usable password (though in practice finders skip accounts entirely).
- `role` uses `SAEnum(UserRole, native_enum=False)` (`user.py:19`). `native_enum=False` stores the
value as a plain string column rather than a database-native ENUM type β more portable across
databases.
- **`dogs` relationship** (`user.py:23`): links a user to their `KnownDog` rows.
`cascade="all, delete-orphan"` means deleting a user deletes their dogs too. `back_populates`
pairs with the `owner` attribute on `KnownDog` so both sides stay in sync in memory.
**Connects to:** `KnownDog` (owns dogs), `Case` (a case's `person_id`), `Match.reviewed_by`,
`Notification.user_id`.
### `backend/app/models/dog.py` β `known_dogs` and `unknown_dogs`
**Purpose:** two tables, one file. A **`KnownDog`** is a dog an owner registered. An **`UnknownDog`**
is a stray someone found/sighted whose identity isn't known yet.
- **`class KnownDog`** (`dog.py:9`):
- `owner_id` (`dog.py:15`) is a foreign key to `users.id`.
- Appearance fields: `breed`, `age` (kept as a flexible string like "β3 yrs"), `color`, `size`
(a `DogSize` enum). `color` and `size` matter because the matcher uses them as a cheap pre-filter.
- `last_known_zip` (`dog.py:25`) is set when the dog is reported lost β it's the location the
matcher uses for distance filtering.
- `status` (`dog.py:26`) defaults to `home`.
- `owner` relationship (`dog.py:30`) is the other half of `User.dogs`.
- **`class UnknownDog`** (`dog.py:33`):
- Has *estimated* fields (`est_breed`, `est_age`) since nobody knows for sure.
- `current_zip` (`dog.py:47`) is where the dog is now β used for distance filtering on this side.
- **`current_location_detail`** (`dog.py:49`): the shelter/vet name. The comment stresses this is
**never exposed publicly** (privacy, spec Β§14). You'll see this enforced later in `hydrate.py`
and the `UnknownDogOut` schema.
**Connects to:** `User` (owner), `Case` (a case points to one known or one unknown dog), `Picture`
(by `subject_type`/`subject_id` convention), and the matching service.
### `backend/app/models/case.py` β the `cases` table
**Purpose:** a `Case` is one "incident" β a dog reported lost, found, or sighted. It ties together a
person (or anonymous finder contact), a dog, a location, a date, and a status.
- **`class Case`** (`case.py:11`):
- `person_id` (`case.py:18`) is **nullable** β null for anonymous finders.
- `finder_name/finder_email/finder_phone` (`case.py:22`): contact details stored directly on the
case when there's no account.
- `known_dog_id` and `unknown_dog_id` (`case.py:26`, `:29`): a lost case links to a `KnownDog`; a
found/sighted case links to an `UnknownDog`. Only one is set.
- `type` (`case.py:33`): lost/found/sighted.
- `event_zip` (`case.py:34`, indexed) and `event_date`: where/when it happened.
- **`search_radius_miles`** (`case.py:38`): the *current* widening level. Starts at the first
radius level and grows when the user clicks "Widen search."
- `status` (`case.py:40`): open/matched/resolved/closed.
**Connects to:** `User`, `KnownDog`/`UnknownDog`, `Match` (matches belong to a case), and is the
central object the matching service operates on.
### `backend/app/models/picture.py` β the `pictures` table
**Purpose:** one row per stored image. Crucially, **the image bytes are NOT in the database** β only
a file path (the actual file lives on disk via the storage backend).
- **`class Picture`** (`picture.py:11`):
- **`subject_type` + `subject_id`** (`picture.py:20`, `:23`): this is a **polymorphic association**.
Instead of two separate "known_dog_pictures" and "unknown_dog_pictures" tables, one table serves
both: `subject_type` says which kind (`known`/`unknown`) and `subject_id` is that dog's id. The
comment calls `subject_id` an "FK-by-convention" β it's not a real database foreign key (it can't
be, since it points to one of two tables), so the application is responsible for integrity.
- `file_path` / `thumb_path` (`picture.py:24`, `:25`): relative keys into the storage backend.
- `is_primary` (`picture.py:29`): marks the representative photo shown in lists/cards.
- `is_probably_not_dog` (`picture.py:30`): a flag from the original design, meant to warn that an
upload may not show a dog while still storing it. Nothing sets it today β the planned detector was
never built, and the breed model cannot stand in for one (see `ml/breed.py` below).
- **`embeddings` relationship** (`picture.py:37`): one picture can have multiple embeddings (one per
ML model version), with cascade delete.
**Connects to:** `Embedding` (one-to-many), and logically to `KnownDog`/`UnknownDog` via the
subject convention. Created by the image pipeline (`services/images.py`).
### `backend/app/models/embedding.py` β the `embeddings` table
**Purpose:** stores the numeric vector for one picture under one ML model. Kept in its own table
(rather than a column on `pictures`) so you can recompute vectors with a new model **without losing
the old ones** β vital for the "swap in a better model later" goal.
- **`class Embedding`** (`embedding.py:20`):
- `__table_args__` with a **`UniqueConstraint`** (`embedding.py:29`) on
`(picture_id, model_name, model_version)`: a given picture can have only one vector per model
version (no duplicates).
- `dim` (`embedding.py:39`): the vector length (64 for the mock, 2,048 for the ResNet-101 models).
- **`vector`** (`embedding.py:40`): stored as `LargeBinary` (raw bytes). The comment notes it's
float32 and **L2-normalized at write time** so cosine similarity is just a dot product.
- **`as_array()`** (`embedding.py:49`): converts the stored bytes back into a NumPy array for math.
- **`to_bytes()`** (`embedding.py:52`, a `@staticmethod`): converts a NumPy array into the bytes to
store. The image pipeline calls `Embedding.to_bytes(vec)` when saving.
**Connects to:** `Picture` (parent), the embedder (`ml/embedder.py`) which produces the vectors, and
the matching service which reads them back via `as_array()`.
### `backend/app/models/match.py` β the `matches` table
**Purpose:** a saved candidate match β "case X's dog might be the same as dog Y, with similarity
score Z." These are the rows a human later confirms or rejects.
- **`class Match`** (`match.py:20`):
- `case_id` (`match.py:26`): which case this match belongs to.
- `candidate_type` + `candidate_id` (`match.py:27`, `:30`): the matched dog, again using the
polymorphic `known`/`unknown` convention.
- `candidate_case_id` (`match.py:31`): the *other* case the candidate dog came from, if any β used
so confirming a match can resolve both cases.
- `similarity_score` (`match.py:34`) and `rank` (`match.py:35`, 1 = best).
- `model_name`/`model_version` (`match.py:36`): which embedder produced it β so old matches from a
different model are distinguishable.
- `status` (`match.py:38`): pending/confirmed/rejected. `reviewed` (`match.py:41`) and
`reviewed_by` (`match.py:42`) record the human decision.
**Connects to:** `Case` (parent + candidate case), `User` (reviewer), `KnownDog`/`UnknownDog`
(candidate). Created by `services/matching.py`, read/updated by `api/matches.py` and `api/admin.py`.
### `backend/app/models/notification.py` β the `notifications` table
**Purpose:** an audit record of every notification the system tried to send (e.g. "emailed Alice
about a match"). Storing these lets you see what was sent and whether it succeeded.
- **`class Notification`** (`notification.py:18`): links optionally to a `user`, `case`, and `match`;
records the `channel` (email/sms), `status` (queued/sent/failed), `to_address`, the `payload`
(the rendered message text), and `sent_at`.
**Connects to:** written by `send_notification` in `services/notify.py`.
---
## 7. Backend β schemas
Schemas (in `backend/app/schemas/`) are **Pydantic models** that describe the JSON going in and out
of the API. They're separate from the database models on purpose: the database model might have
private fields (like `password_hash` or `current_location_detail`) that should never appear in an
API response. Schemas are the controlled "public view."
A recurring detail: `model_config = ConfigDict(from_attributes=True)` lets a schema be built directly
from a database object via `Schema.model_validate(db_object)` β Pydantic reads the matching
attributes off the ORM object.
### `backend/app/schemas/common.py`
- **`Page[T]`** (`common.py:10`): a generic wrapper for paginated lists β `items`, `total`, `limit`,
`offset`. Generic (`Generic[T]`) means `Page[KnownDog]`, `Page[Case]`, etc. all reuse it.
- **`Message`** (`common.py:17`): a trivial `{ "detail": "..." }` response (e.g. logout).
### `backend/app/schemas/auth.py`
- **`RegisterRequest`** (`auth.py:8`): registration input. Note `Field(min_length=8, ...)` on the
password and `EmailStr` (validates email format β requires the `email-validator` package).
- **`LoginRequest`** (`auth.py:16`), **`TokenResponse`** (`auth.py:21`, the `{access_token, token_type}`
returned on login/register).
- **`UserOut`** (`auth.py:26`): the safe view of a user β note it **omits `password_hash`**.
- **`UserUpdate`** (`auth.py:37`): all-optional fields for `PATCH /users/me`.
### `backend/app/schemas/dog.py`
- **`PictureOut`** (`dog.py:10`): a picture for the API. It adds **`url`** and **`thumb_url`**
(`dog.py:23`, `:24`) which aren't database columns β they're filled in by `hydrate_picture` in
`api/helpers.py` so the frontend gets ready-to-use image URLs.
- **`KnownDogCreate`** (`dog.py:27`) / **`KnownDogUpdate`** (`dog.py:37`): create vs. update inputs
(update is all-optional).
- **`KnownDogOut`** (`dog.py:47`): full owner-facing dog view, including its `pictures`.
- **`UnknownDogOut`** (`dog.py:65`): the **privacy-safe** view of a found/sighted dog. The docstring
and the field list make the point: it has `current_zip` (ZIP-level only) but **no**
`current_location_detail`. This is privacy enforced by *what the schema includes*.
### `backend/app/schemas/case.py`
- **`LostCaseCreate`** (`case.py:10`): opening a lost case. Either supply `known_dog_id` for an
existing dog, **or** the `new_dog_*` fields to register a dog inline in the same step.
- **`FoundSightedCreate`** (`case.py:26`): a found/sighted report. Includes finder contact fields and
`current_location_detail` (the private shelter name). *(Note: the actual found/sighted endpoints
use individual `Form(...)` parameters rather than this schema because they're multipart uploads β
this schema documents the conceptual shape.)*
- **`CaseUpdate`** (`case.py:48`): edit notes or close a case.
- **`MatchOut`** (`case.py:53`): a match for the API. The interesting field is
**`candidate: dict | None`** (`case.py:67`) β a flexible bag holding the hydrated, privacy-filtered
candidate dog (filled by `api/hydrate.py`).
- **`CaseOut`** (`case.py:70`): the case view.
- **`FoundReportResponse`** (`case.py:87`): the combined response for create-lost / create-found /
widen β the case, its matches, and optional `vet_guidance`. The frontend's `FoundReportResponse`
TypeScript type mirrors this exactly.
**Connects to:** the API routers (which declare these as `response_model=...`), and the frontend's
`types.ts` mirrors them by hand.
---
## 8. Backend β the four swap points
These are the pluggable interfaces from the mental model. Each defines an abstract interface plus a
default implementation, and a `get_*()` function that returns the configured one.
### `backend/app/storage/backend.py` β file storage
**Purpose:** decide *where* image files physically live. Default = local disk; the seam allows cloud
storage (S3) later.
- **`class StorageBackend(ABC)`** (`backend.py:14`): the interface. `ABC` = "Abstract Base Class";
the `@abstractmethod` decorators mean any subclass **must** implement `save`, `open`, `delete`,
`abs_path`. You can't instantiate the abstract class directly.
- **`class LocalStorage`** (`backend.py:32`): writes files under a root media directory.
- **`_full(key)`** (`backend.py:37`): turns a relative key into an absolute path **and guards
against path traversal** β it refuses keys that would escape the media root (e.g. `../../etc/...`).
A small but real security measure.
- `save` / `open` / `delete` / `abs_path` (`backend.py:44`β`59`): the obvious file operations.
`abs_path` returns the on-disk path (used so the embedder can read the file directly).
- **`class S3Storage`** (`backend.py:62`): a **stub** β every method is empty and the constructor
raises `NotImplementedError`. It exists to show exactly what you'd implement for cloud storage.
`# pragma: no cover` tells the test-coverage tool to ignore it.
- **`get_storage()`** (`backend.py:77`): returns a single cached backend instance based on
`settings.storage_backend`. The `global _backend` pattern is a simple lazy singleton.
**Connects to:** `services/images.py` (saves photos), `api/dogs.py` (deletes photos), and the
`/media` static mount in `main.py`.
### `backend/app/ml/embedder.py` β turning images into vectors
**Purpose:** the heart of the "AI." Converts an image file into an L2-normalized vector. Three
implementations ship: a deterministic stand-in for tests, and two real models. The deployed demo
runs `ReIDEmbedder`.
- **`class Embedder(Protocol)`** (`embedder.py:18`): the interface, written as a `Protocol` (Python's
"structural typing" β any class with `name`, `version`, `dim`, and an `embed` method counts,
without explicitly inheriting). `embed(image_paths)` returns one vector per path.
- **`_l2_normalize`** (`embedder.py:28`): scales a vector to length 1 (so dot product = cosine).
Handles the zero-vector edge case to avoid dividing by zero.
- **`class MockEmbedder`** (`embedder.py:39`): selected by `EMBEDDER=mock`, and what the test suite
uses.
- **`_vector_for`**: opens the image, shrinks it to 32Γ32 grayscale, hashes the raw pixels with
SHA-256, then seeds a random-number generator with that hash to produce a stable vector.
**Same image β same vector every time.** The docstring is explicit that
*similar-but-not-identical* images are **not** close β this is intentional. It exists so the
whole pipeline runs and is testable without GPUs, weights, or network access; it is not meant to
produce real-world matches. (This is why seed/test data reuses the *same image seed* to force a
match β see `scripts/seed.py`.)
- **`class HFEmbedder`** (`embedder.py:73`): selected by `EMBEDDER=hf`. Uses the pooled
pre-classifier features of the HuggingFace breed model as the vector. When the breed classifier is
the same HF repo, it can return the embedding and the breed softmax from **one** forward pass,
which is what `scripts/process_dataset.py` relies on.
- **`class ReIDEmbedder`** (`embedder.py:159`): selected by `EMBEDDER=reid`, and what the deployed
demo runs. Loads the fine-tuned checkpoint at `REID_MODEL_PATH` into the same ResNet base and emits
the L2-normalized 2,048-dim pooled features. Preprocessing matches **training** (Resize 224 +
ImageNet normalization), not the HF image processor β a mismatch here silently degrades every
score. This model produces no breed labels; those come from the separate classifier.
- **`get_embedder()`** (`embedder.py:207`) and **`reset_embedder_cache()`** (`embedder.py:219`):
lazy singleton selection by config, plus a test hook to force re-selection after changing settings.
Both real embedders **lazily import** torch/transformers inside `__init__`, so the mock path never
needs those heavy packages installed. Both carry `# pragma: no cover` because tests run on the mock.
**Connects to:** `services/images.py` (embeds on upload), `services/matching.py` (reads the active
model name/version), and `scripts/eval_matching.py`.
### `backend/app/ml/index.py` β searching vectors
**Purpose:** given a query vector and a list of candidate vectors, return the most similar ones.
- **`class VectorIndex(Protocol)`** (`index.py:14`): the interface β a `search(query, candidates,
top_k)` method returning `(id, similarity)` pairs.
- **`class NumpyBruteForceIndex`** (`index.py:25`): the default. **`search`** (`index.py:26`) stacks
all candidate vectors into a matrix and computes `mat @ q` (matrix-times-vector = a dot product
with each candidate). Because vectors are normalized, those dot products *are* cosine similarities.
Then it sorts descending and takes the top `k`. "Brute force" = compare against everything;
perfectly fine and exact at this app's small scale.
- **`get_index()`** (`index.py:45`): the cached singleton.
**Connects to:** `services/matching.py` (uses it for scoring).
### `backend/app/ml/breed.py` β predicting a breed
**Purpose:** guess a dog's breed from a photo. This is a **separate model** from the one that matches
individual dogs, and its labels are never used to rank photo-search results.
- **`class BreedClassifier(Protocol)`** (`breed.py:24`): the interface β `predict(image_paths)`
returns `(label, score)` pairs per image.
- **`normalize_label`** (`breed.py:41`): tidies raw HuggingFace class names (underscores, casing) into
something displayable, and gives the breed gate a stable key to compare against.
- **`class MockBreedClassifier`** (`breed.py:51`): deterministic stand-in, same rationale as the mock
embedder β tests run offline with no weights.
- **`class HFBreedClassifier`** (`breed.py:95`): the real one, a 120-class ResNet-101 breed model
pulled from HuggingFace on first use. This is what the deployed demo runs.
- **`get_breed_classifier()`** (`breed.py:140`) / **`reset_breed_classifier_cache()`**
(`breed.py:150`): the cached singleton and its test hook.
**A limitation worth knowing:** with 120 classes and no "none of these" option, softmax must pick a
winner, so out-of-distribution inputs (noise, a blank square, a photo of text) can score extremely
high on some breed. A confidence threshold cannot separate those from genuine photos, because real
dogs also score in the high 90s. The interface states the limitation rather than filtering.
**Connects to:** `services/images.py` (predicts on upload, alongside embedding),
`services/matching.py` (the optional breed gate on the *case matcher*, not on photo search).
### `backend/app/ml/__init__.py` and `storage/__init__.py`
These just re-export the public names so other modules can write `from ..ml import get_embedder,
get_index` cleanly.
---
## 9. Backend β services
Services hold the **business logic** β the real work, separated from the HTTP layer so it can be
tested and reused. Located in `backend/app/services/`.
### `backend/app/services/geo.py` β distance between ZIP codes
**Purpose:** convert ZIP codes to latitude/longitude and compute real-world distances, which drives
the radius filtering.
- **`class GeoService`** (`geo.py:15`):
- **`__init__`** (`geo.py:16`) + **`_load`** (`geo.py:22`): read the bundled
`data/zip_centroids.csv` once into a dict mapping ZIP β (lat, lng). It tolerates bad rows.
- **`_norm`** (`geo.py:36`): normalizes a ZIP to 5 digits (zero-padded), so `"7030"` and `"07030"`
match.
- **`centroid`** (`geo.py:42`): look up a ZIP's coordinates.
- **`distance_miles`** (`geo.py:46`): returns the great-circle distance between two ZIPs, or `None`
if either ZIP is unknown.
- **`_haversine`** (`geo.py:54`): the haversine formula β standard math for distance between two
points on a sphere (Earth), here returning miles.
- **`within_radius`** (`geo.py:62`): the function the matcher actually calls. `radius == -1` means
"nationwide / no filter β always True." **Key design choice: if a ZIP is unknown it returns
`True` (fail-open)** so missing geo data never *hides* a possible match β the app prioritizes
recall plus human review over precision. (Tested in `test_geo.py`.)
- **`get_geo()`** (`geo.py:78`): cached singleton (`@lru_cache`).
**Connects to:** `services/matching.py` (filtering), `api/geo.py` (the distance/shelter endpoints),
and reads the file path from `config`.
### `backend/app/services/images.py` β the image pipeline
**Purpose:** the full "what happens when a photo is uploaded" pipeline (spec Β§8): validate β
normalize β store β thumbnail β embed.
- **`ImageValidationError`** (`images.py:24`): a custom exception; routers catch it and turn it into
an HTTP 400.
- **`_validate_and_decode`** (`images.py:28`): checks the byte size against the limit, then uses
Pillow's `verify()` for a cheap integrity check. Note it **re-opens** the image afterward
(`images.py:36`) because `verify()` leaves the image object unusable β a real Pillow gotcha.
- **`_normalize`** (`images.py:42`): `exif_transpose` bakes in the correct rotation and, by not
copying EXIF, **drops GPS/PII metadata** (a privacy requirement). Converts to RGB and downscales so
the longest side β€ the configured max.
- **`_encode`** (`images.py:52`): re-encodes to JPEG bytes.
- **`process_and_store_picture`** (`images.py:58`): the orchestrator, called by the routers.
1. Validate + normalize (`images.py:67`).
2. Build a unique storage key like `known/42/<uuid>.jpg` (`images.py:72`) and save the main image.
3. Make and save a 320Γ320 thumbnail (`images.py:77`).
4. Insert the `Picture` row and `db.flush()` to assign its id (`images.py:92`) β `flush` sends the
INSERT to the DB but doesn't commit the transaction yet.
5. Read the saved file back, run the embedder, and insert an `Embedding` row (`images.py:96`). It
embeds from the *stored* file so the vector reflects exactly what was persisted. The
`if abs_path is not None` guard accommodates non-local storage backends.
Note it does **not** commit β the calling endpoint commits, so multiple photos + matching can all
succeed or fail together as one transaction.
**Connects to:** `storage` (save files), `ml` (embed), the `Picture`/`Embedding` models, and is
called by `api/dogs.py` and `api/cases.py`.
### `backend/app/services/notify.py` β sending notifications
**Purpose:** send an email (or pretend to) and record it. Default prints to the console.
- **`class Notifier(ABC)`** (`notify.py:24`): interface with a single `send(to, subject, body)`.
- **`ConsoleNotifier`** (`notify.py:30`): logs and prints the email β the dev default, so no real
mail is sent.
- **`SMTPNotifier`** (`notify.py:37`): sends real email via SMTP, only used when configured. Returns
`False` on failure rather than crashing.
- **`get_notifier()`** (`notify.py:56`): picks SMTP only if `notifier=smtp` *and* a host is set;
otherwise console.
- **`send_notification`** (`notify.py:62`): the function the rest of the app calls. It first writes a
`Notification` row with status `queued`, attempts the send, then updates the row to `sent` or
`failed` and stamps `sent_at`. This guarantees an audit trail regardless of outcome.
**Connects to:** `services/matching.py` (triggers emails on strong matches), the `Notification`
model.
### `backend/app/services/matching.py` β the core matching engine
**Purpose:** the most important service. Given a case, find and rank candidate dogs, save them as
`Match` rows, update the case status, and trigger notifications. (spec Β§9.)
- **`CandidateScore`** (`matching.py:33`): a small dataclass holding a candidate's type, id, score,
and originating case id while ranking.
- **`_active_model`** (`matching.py:41`): returns the current embedder's `(name, version)` so we only
compare vectors made by the *same* model.
- **`_vectors_for_subject`** (`matching.py:46`): loads all embedding vectors for one dog's pictures,
filtered to the active model. Joins `Embedding` β `Picture` and filters by subject + model.
- **`_metadata_compatible`** (`matching.py:63`): the cheap appearance gate. It only excludes a
candidate on a **definite conflict** (both have a color and they differ, or both have a size and
they differ). Missing values never exclude β again, prioritizing recall.
- **`_dog_level_score`** (`matching.py:73`): a dog may have several photos. This computes the **max**
cosine similarity over every (query photo Γ candidate photo) pair. Using the max makes it robust to
extra or bad photos β one good matching angle is enough.
- **`run_matching_for_case`** (`matching.py:84`): the orchestrator. Step by step:
1. **Resolve the query subject** (`matching.py:94`): for a lost case the query is the known dog;
for found/sighted it's the unknown dog. Grabs its color/size for the metadata gate. Returns `[]`
early if there's no dog or no embedded photos yet.
2. **Build the candidate pool** (`matching.py:113`):
- A lost (known) dog searches the **unknown** pool with status in
`pending`/`lost`/`at_shelter` (`matching.py:116`).
- A found/sighted (unknown) dog searches the **known** pool with status `lost`
(`matching.py:145`).
- For each candidate: skip if outside the ZIP radius (`geo.within_radius`), skip on a metadata
conflict, skip if it has no vectors, otherwise compute its dog-level score. It also looks up
the candidate's own case id so a confirm can resolve both sides.
3. **Filter, sort, cap** (`matching.py:172`): keep scores β₯ `REVIEW_THRESHOLD`, sort descending,
take the top `TOP_N`.
4. **Persist** (`matching.py:177`): delete this case's prior *pending* matches (so re-running
replaces stale results but preserves confirmed/rejected ones), then insert fresh ranked `Match`
rows.
5. **Drive status** (`matching.py:199`): if there are matches and the case is still `open`, bump it
to `matched`. It **never auto-resolves** β a human must confirm.
- **`is_strong`** (`matching.py:206`): true only if the rank-1 match's score β₯ `STRONG_THRESHOLD`.
- **`notify_owner_of_strong_match`** (`matching.py:210`): for a found/sighted case whose top match is
a strong *known* dog, email that dog's owner. The email body (`matching.py:232`) deliberately
contains only a case link and **no finder address** β contact stays mediated.
- **`rematch_open_lost_cases_against`** (`matching.py:244`): when a *new* found/sighted dog appears,
re-run every open/matched lost case so existing owners can be alerted to the newcomer. Runs
synchronously for the MVP; the docstring notes a background job is the future path.
**Connects to:** `ml` (embedder + index), `geo`, `notify`, all the dog/case/match models, and is
called by `api/cases.py` and `api/dogs.py`.
---
## 10. Backend β the API routers
Each file in `backend/app/api/` defines an `APIRouter` β a group of related endpoints. They are the
HTTP "surface" of the app; they validate input (via schemas), enforce permissions, call services,
and shape responses. All are attached to the app in `main.py`.
### `backend/app/api/helpers.py` β shared router utilities
- **`media_url`** (`helpers.py:19`): turns a stored file key into a `/media/...` URL.
- **`hydrate_picture`** (`helpers.py:23`): converts a `Picture` row into a `PictureOut` schema and
fills in `url`/`thumb_url`.
- **`pictures_for`** (`helpers.py:30`): fetches all pictures for a subject, primary first, hydrated.
- **`rate_limit`** (`helpers.py:43`): a **basic in-memory fixed-window rate limiter**. It keeps a
`deque` of recent request timestamps per client IP (`_buckets`, `helpers.py:40`), drops entries
older than 60 seconds, and raises HTTP 429 if the count exceeds the configured limit. The comment
context tells you this is intentionally simple (per-process, resets on restart) β fine for the MVP,
not for a clustered production deployment.
### `backend/app/api/hydrate.py` β privacy-aware candidate hydration
**Purpose:** turn a `Match` into a `MatchOut` with its `candidate` dict filled in β **enforcing
privacy**. This is where the rule "never leak the shelter/vet location" is implemented for matches.
- **`build_match_out`** (`hydrate.py:17`): if the candidate is an unknown dog, it includes
`current_zip` (ZIP-level) but **deliberately omits `current_location_detail`** (`hydrate.py:22`).
If it's a known dog, it includes appearance fields and `last_known_zip` but no owner contact.
Either way it attaches the candidate's hydrated pictures. There's a dedicated test
(`test_unknown_dog_location_detail_not_exposed`) proving the secret never appears.
### `backend/app/api/auth.py` β register / login / me
Router prefix `/auth`.
- **`register`** (`auth.py:27`): rejects duplicate emails (409), hashes the password, creates an
owner, commits, and returns a fresh JWT.
- **`login`** (`auth.py:46`): verifies email + password, returns a JWT, or 401. Note the combined
check (`auth.py:49`) avoids revealing whether the email exists.
- **`logout`** (`auth.py:54`): a no-op for stateless JWT β the client just discards the token. Exists
for API symmetry.
- **`me`** (`auth.py:60`): returns the current user (requires auth).
### `backend/app/api/users.py` β the current user's profile
Router prefix `/users`.
- **`get_me`** (`users.py:14`): return the profile.
- **`update_me`** (`users.py:19`): patch name/phone/zip. `model_dump(exclude_unset=True)`
(`users.py:25`) means only fields the client actually sent are updated β partial update done right.
### `backend/app/api/dogs.py` β managing owned dogs & their photos
Router prefix `/dogs`. All endpoints require login.
- **`_get_owned_dog`** (`dogs.py:20`): the authorization helper β 404 if missing, 403 unless you own
it (or are an admin). Reused by every dog endpoint.
- **`_to_out`** (`dogs.py:29`): build a `KnownDogOut` with its pictures attached.
- **`create_dog`** (`dogs.py:35`), **`list_my_dogs`** (`dogs.py:48`, paginated and clamped to β€100),
**`get_dog`** (`dogs.py:69`), **`update_dog`** (`dogs.py:78`).
- **`upload_photos`** (`dogs.py:93`): the busy one.
- Enforces the per-dog photo cap (`dogs.py:106`).
- Runs `process_and_store_picture` for each file, marking the first photo of a fresh dog as primary
(`dogs.py:120`). Catches `ImageValidationError` β 400.
- **Then** (`dogs.py:131`) checks whether this dog has an open lost case and, if so, re-runs
matching β because new photos can produce new matches (spec Β§8 step 6). Note the imports are
*inside* the function (`dogs.py:127`) to avoid circular-import problems at module load.
- Commits once at the end.
- **`delete_photo`** (`dogs.py:147`): validates ownership and that the picture belongs to this dog,
deletes the files from storage, then deletes the row.
### `backend/app/api/cases.py` β the heart of the workflows
Router prefix `/cases`. This is the largest router; it implements lost/found/sighted creation,
listing, widening, and updates.
- **`_first_radius`** (`cases.py:52`) / **`_next_radius`** (`cases.py:56`): read the configured
`radius_levels` list to find the starting radius and the next one when widening.
- **`_owns_case`** (`cases.py:65`): authorization β owner or admin only.
- **`create_lost_case`** (`cases.py:76`): requires login.
- Either validates the existing `known_dog_id` (and ownership) or **inline-creates** a new dog from
the `new_dog_*` fields (`cases.py:92`).
- Marks the dog `lost` and sets its `last_known_zip` (`cases.py:103`).
- Creates the `Case`, runs matching, commits, and returns case + matches.
- **`_create_found_sighted`** (`cases.py:131`): the shared engine for both found and sighted reports
(so the two endpoints don't duplicate logic).
- Rate-limits (`cases.py:153`).
- **Requires contact info when anonymous** (`cases.py:156`) β 400 otherwise.
- Requires at least one photo and enforces the photo cap.
- Computes the unknown dog's initial status (`cases.py:169`): a *found* dog with a shelter detail β
`at_shelter`; a plain *found* β `pending`; a *sighted* dog β `lost` (seen but not in custody).
- Creates the `UnknownDog`, stores its photos, creates the `Case` (copying logged-in user's name/
email as finder contact if present, `cases.py:203`).
- Runs matching, notifies owners of strong matches, **and** re-matches open lost cases against this
new dog (`cases.py:218`).
- For *found* reports, attaches `vet_guidance` from `nearby_shelters` (`cases.py:228`).
- **`create_found_case`** (`cases.py:237`) and **`create_sighted_case`** (`cases.py:281`): the actual
endpoints. They take **`Form(...)` fields + `File(...)`** because uploads are `multipart/form-data`,
not JSON. They use `get_current_user_optional` so anonymous users are allowed. The sighted endpoint
passes `None` for the found-only "where is it now" fields.
- **`list_my_cases`** (`cases.py:325`), **`get_case`** (`cases.py:348`, ownership-checked),
**`get_case_matches`** (`cases.py:362`, returns hydrated matches ordered by rank).
- **`widen_case`** (`cases.py:381`): advance to the next radius level (400 if already widest) and
re-run matching β letting an owner cast a wider net.
- **`update_case`** (`cases.py:405`): edit notes and/or close the case.
### `backend/app/api/matches.py` β confirm / reject a match
Router prefix `/matches`. Requires login.
- **`_load_owned_match`** (`matches.py:22`): fetches the match + its case and checks ownership/admin.
- **`confirm_match`** (`matches.py:34`): the resolution logic. Marks the match `confirmed`, then
**resolves the case and marks both dogs `reunited`** β handling both directions (the candidate is
an unknown dog vs. a known dog), and also resolves the candidate's own case if there is one
(`matches.py:64`). This is what "drives the case toward resolution" concretely means.
- **`reject_match`** (`matches.py:74`): marks the match `rejected` and records the reviewer; leaves
the case open so other candidates can still be confirmed.
### `backend/app/api/shelters.py` β static vet/shelter guidance
- **`nearby_shelters`** (`shelters.py:29`): returns a small static list of national resources plus
(if a ZIP is given) a generic "animal control for ZIP X" entry. The module docstring is honest that
this is **not** a real geolocated directory β it's MVP guidance, with real per-ZIP data flagged as
future work.
### `backend/app/api/geo.py` β geo/utility endpoints
- **`get_nearby_shelters`** (`geo.py:11`): wraps `nearby_shelters`.
- **`get_distance`** (`geo.py:16`): returns miles between two ZIPs (handy for debugging the matcher).
- **`healthz`** (`geo.py:22`): a trivial health check returning `{"status": "ok"}` β used to verify
the server is up.
### `backend/app/api/admin.py` β the thin admin slice
Router prefix `/admin`. Every endpoint uses `Depends(require_admin)`.
- **`pending_matches`** (`admin.py:18`): all matches awaiting review.
- **`all_cases`** (`admin.py:29`): every case in the system.
- **`close_case`** (`admin.py:38`): force-close any case.
- **`flag_spam`** (`admin.py:53`): mark a match rejected (the spam action). Note an admin can act on
any match, unlike a regular user.
---
## 11. Backend β scripts, migrations, tests
### `backend/scripts/make_sample_images.py`
- **`make_image(seed, size)`** (`make_sample_images.py:13`): generates a deterministic, colorful
"dog-ish" JPEG from a numeric seed β same seed β same image bytes. No network, no real photos. This
determinism is what lets the mock embedder produce *predictable* matches in seeds/tests (two
subjects given the same seed get identical vectors β a guaranteed strong match).
### `backend/scripts/seed.py`
- **`run()`** (`seed.py:27`): populates a fresh database so the app is demo-ready. It skips if data
already exists (`seed.py:31`). It creates an admin, two owners (Alice, Bob), Alice's **lost** dog
"Rex" in DC, a **found** dog near DC built with the *same image seed as Rex* (`seed.py:93`, so the
mock embedder yields a strong match), and a distant Seattle sighting that should *not* match within
a tight radius. Finally it runs matching for Rex's case so the demo opens with a match already
waiting. Prints the seeded logins. Run with `python -m scripts.seed`.
### `backend/scripts/eval_matching.py`
- **Purpose:** the threshold-tuning harness (spec Β§9.6). Given a CSV of labeled image pairs
(same-dog vs different-dog), it measures how well the *current* embedder separates them.
- **`_load_pairs`** (`eval_matching.py:25`): read the CSV.
- **`evaluate`** (`eval_matching.py:37`): embed every pair, compute cosine similarity, then sweep 101
thresholds computing precision/recall and a trapezoidal **ROC-AUC**. This is the objective basis on
which `REVIEW_THRESHOLD`/`STRONG_THRESHOLD` should eventually be set (rather than the placeholder
defaults).
- **`main`** (`eval_matching.py:72`): CLI entry β `python -m scripts.eval_matching pairs.csv`.
### `backend/alembic/env.py` and `backend/alembic.ini`
- **Purpose:** Alembic is the database **migration** tool β for evolving the schema over time in
production (instead of the dev-only `create_all`). `env.py` points Alembic at the app's
`settings.database_url` and `Base.metadata` so generated migrations match the models, with
`render_as_batch=True` for SQLite compatibility. `alembic.ini` is its config/logging. (The
`versions/` folder is currently empty β no migrations have been generated yet, consistent with the
MVP using `create_all`.)
### `backend/tests/` β the test suite
Run with `pytest`. Uses the deterministic mock embedder so results are reproducible.
- **`conftest.py`**: shared fixtures. Crucially it sets environment variables **before importing the
app** (`conftest.py:12`) β pointing at a throwaway temp SQLite DB and media dir, forcing the mock
embedder and console notifier, and raising the rate limit so tests don't trip it. **`_fresh_db`**
(`conftest.py:31`, `autouse=True`) drops and recreates all tables around every test for isolation.
**`client`** (`conftest.py:39`) is FastAPI's `TestClient`. **`owner_token`** (`conftest.py:55`)
registers a user and returns a JWT; **`auth`** (`conftest.py:70`) builds the `Authorization` header.
- **`test_auth.py`**: registration, duplicate-email rejection, wrong password, auth-required, and the
error-envelope shape.
- **`test_dogs.py`**: create/list a dog, that uploading a photo creates a picture + embedding, that
non-images are rejected (400), and that you can't access someone else's dog (403).
- **`test_geo.py`**: close ZIPs are near, coast-to-coast is far, radius levels work, and unknown ZIPs
fail open.
- **`test_matching.py`**: the end-to-end ones β a found report matches a lost dog (and gets vet
guidance), anonymous reports require contact, a distant dog doesn't match until you widen to
nationwide, confirming a match resolves the case, the private location detail is never exposed, and
you can't view another person's case matches. These tests are an excellent **executable
specification** of the whole system β read them to confirm your understanding.
---
## 12. Frontend β setup & shared infrastructure
The frontend is a **React + TypeScript Single-Page Application (SPA)** built with **Vite** and styled
with **Tailwind CSS**. "Single-page" means the browser loads one HTML page and JavaScript swaps the
visible content as you navigate β no full page reloads.
### `frontend/package.json`
Declares dependencies (`react`, `react-dom`, `react-router-dom` for routing) and scripts: `npm run
dev` (start the dev server), `build`, `preview`. Tailwind/PostCSS/TypeScript are dev dependencies.
### `frontend/vite.config.ts`
Configures the dev server on port 5173 and **proxies** API paths (`/auth`, `/dogs`, `/cases`,
`/media`, etc.) to the backend at `127.0.0.1:8000` (`vite.config.ts:9`). This is why the frontend can
call `/cases/found` directly without worrying about CORS or full URLs during development.
### `frontend/index.html` + `frontend/src/main.tsx`
- `index.html` is the single page; it has an empty `<div id="root">` and loads `main.tsx`.
- **`main.tsx`** mounts React into that div and wraps the whole app in three providers
(`main.tsx:9`): `React.StrictMode` (dev safety checks), `BrowserRouter` (enables URL routing), and
`AuthProvider` (makes login state available everywhere).
### `frontend/src/App.tsx` β routing
**Purpose:** map URLs to page components.
- **`RequireAuth`** (`App.tsx:17`): a guard component. While auth state is loading it shows "Loadingβ¦";
if there's no user it **redirects to `/login`**; otherwise it renders the protected page.
- **`App`** (`App.tsx:24`): wraps everything in `Layout` and declares all the `Route`s. Public routes:
home, login, register, report found/sighted. Protected routes (wrapped in `RequireAuth`): my dogs,
dog detail, report lost, my cases, case detail, admin. The `path="*"` route (`App.tsx:81`) redirects
unknown URLs home. Notice `ReportFound` is reused for both `found` and `sighted` via a `kind` prop.
### `frontend/src/types.ts` β TypeScript types
**Purpose:** hand-written TypeScript interfaces that **mirror the backend Pydantic schemas** so the
editor can type-check API data. The comment (`types.ts:1`) notes these could be auto-generated from
the backend's OpenAPI schema, but are hand-written for clarity. If you change a backend schema, update
the matching type here.
### `frontend/src/api.ts` β the typed fetch wrapper
**Purpose:** one place that knows how to talk to the backend. Every component calls `api.something()`
instead of using `fetch` directly.
- **Token helpers** (`api.ts:13`): store/read the JWT in `localStorage` under `pawtrace_token`.
- **`ApiError`** (`api.ts:21`): a custom error carrying the HTTP status.
- **`request<T>`** (`api.ts:29`): the core. It attaches the `Authorization: Bearer` header if logged
in (`api.ts:31`), sets JSON content-type **unless the body is `FormData`** (`api.ts:33`, important
for file uploads), handles 204 No Content, and on failure extracts the message from the backend's
`{error:{message}}` envelope and throws `ApiError`.
- **`api`** (`api.ts:46`): the catalog of typed methods grouped by area (auth, dogs, cases, matches,
geo). For example `createReport` (`api.ts:68`) sends `FormData` to `/cases/found|sighted`;
`uploadDogPhotos` (`api.ts:59`) builds `FormData` from a `File[]`.
### `frontend/src/auth.tsx` β global login state
**Purpose:** a React **Context** that holds the current user and the login/register/logout functions,
so any component can call `useAuth()`.
- **`AuthProvider`** (`auth.tsx:15`): on mount, if a token exists it calls `api.me()` to load the user
(and clears the token if it's invalid) β this is how you stay logged in across page refreshes.
`login`/`register` (`auth.tsx:31`, `:37`) store the token then fetch the user; `logout`
(`auth.tsx:43`) clears both.
- **`useAuth`** (`auth.tsx:55`): the hook components use; it throws if used outside the provider (a
helpful guard).
### `frontend/src/index.css` + `tailwind.config.js` + `postcss.config.js`
- **`index.css`** pulls in Tailwind and defines reusable component classes with `@apply` (`.btn`,
`.btn-primary`, `.input`, `.card`, `.badge`, etc.) so markup stays tidy (`btn-primary` instead of a
dozen utility classes).
- **`tailwind.config.js`** defines the custom `brand` blue color palette and tells Tailwind which
files to scan.
- **`postcss.config.js`** wires Tailwind + autoprefixer into the build.
---
## 13. Frontend β reusable components
In `frontend/src/components/`. These are the shared building blocks used across pages.
### `Layout.tsx`
The page chrome: a sticky header with the logo and navigation (which changes based on whether you're
logged in and whether you're an admin β `Layout.tsx:26`, `:34`), the main content area, and a footer
that restates the privacy/"matches are suggestions" message. `navClass` (`Layout.tsx:5`) highlights
the active nav link.
### `ConfidenceBar.tsx`
A small visual bar for a similarity score (`ConfidenceBar.tsx:2`). It converts the 0β1 score to a
percentage and picks a color and label β **Strong** (β₯80%, green), **Possible** (β₯60%, yellow), or
**Weak** (gray). It uses `role="progressbar"` for accessibility. This is the UI embodiment of "show
similarity clearly, as a suggestion."
### `MatchCard.tsx`
Displays one candidate match (`MatchCard.tsx:11`): the candidate's primary photo (falling back to a
placeholder), a title, breed/color/ZIP line, the `ConfidenceBar`, and β only for **pending** matches
when handlers are provided β **Confirm / Not my dog** buttons (`MatchCard.tsx:56`). The `act` helper
(`MatchCard.tsx:18`) disables the buttons while the request is in flight (the `busy` state). Reused on
the case detail page, the finder result screen, and the admin page.
### `PhotoUpload.tsx`
A multi-photo picker with live previews (`PhotoUpload.tsx:10`). Key detail: the hidden file input uses
`accept="image/*" capture="environment"` (`PhotoUpload.tsx:51`) so **on a phone it opens the rear
camera directly** β a core requirement for finders in the field. It enforces a max count, shows
removable thumbnails (built from `URL.createObjectURL`), and calls `onChange` with the current `File[]`
so the parent form can submit them.
---
## 14. Frontend β pages
In `frontend/src/pages/`. One component per screen. They follow a consistent pattern: local state via
`useState`, data loading via `useEffect` + `api.*`, and a `busy`/`error` pair for form submission.
### `Home.tsx`
The landing page (`Home.tsx:4`). Three calls to action ("I found a dog", "My dog is lost" / "Register
my dog" depending on login, "I sighted a dog") and a simple 3-step explainer.
### `Login.tsx` / `Register.tsx`
Standard auth forms. Each holds form state, calls `useAuth().login`/`register` on submit, shows an
error on failure, and navigates to `/dogs` on success (`Login.tsx:19`, `Register.tsx:22`).
`Register.tsx` collects name/email/password/ZIP/optional phone.
### `MyDogs.tsx`
Lists the owner's dogs as cards with a status badge (`MyDogs.tsx:48`), plus a collapsible
**`AddDogForm`** (`MyDogs.tsx:76`) that creates the dog and then uploads any chosen photos
(`MyDogs.tsx:94`) before reloading the list. Empty and loading states are handled.
### `DogDetail.tsx`
Shows one dog's details and photos, with a **Report lost** button (or a "Reported lost" badge if it
already is β `DogDetail.tsx:39`). Has its own `PhotoUpload` so you can add more photos after creation
(`DogDetail.tsx:68`); uploading reloads the dog. The little `Field` helper (`DogDetail.tsx:79`) renders
a label/value pair, showing "β" when empty.
### `ReportLost.tsx`
The owner's lost-case form (`ReportLost.tsx:7`). It lets you pick an existing dog **or** create one
inline (the `mode` toggle, `ReportLost.tsx:10`), captures last-seen ZIP/date/notes, calls
`api.createLost`, and navigates to the new case page. It auto-selects "new dog" mode if you have no
dogs yet (`ReportLost.tsx:22`).
### `ReportFound.tsx`
The finder flow, reused for both **found** and **sighted** via the `kind` prop (`ReportFound.tsx:11`).
Highlights:
- Builds a `FormData` (because of the photos) and only includes found-only fields for `found`, and
finder-contact fields only when not logged in (`ReportFound.tsx:50`, `:55`).
- After submit it switches to a **results view** (the `if (result)` block, `ReportFound.tsx:69`)
showing a friendly summary, the `vet_guidance` list for found dogs, and a `MatchCard` per match
(read-only β no confirm buttons, since the finder isn't the owner).
- The shelter/vet field is clearly labeled "kept private" (`ReportFound.tsx:162`), matching the
backend's privacy handling.
### `MyCases.tsx`
Lists the user's cases with type, ZIP, date, radius label, and a status badge
(`MyCases.tsx:33`). "nationwide" is shown when the radius is `-1` (`MyCases.tsx:38`).
### `CaseDetail.tsx`
The case workspace (`CaseDetail.tsx:7`). It loads the case and its matches in parallel
(`CaseDetail.tsx:16`), and provides the full owner workflow:
- **Widen search** (`CaseDetail.tsx:26`) β disabled at nationwide.
- **Confirm / reject** each match via `MatchCard` handlers (`CaseDetail.tsx:38`), reloading after.
- **Save notes** and **Close case** (`CaseDetail.tsx:46`, `:50`).
- A persistent reminder that matches are suggestions to confirm only if confident
(`CaseDetail.tsx:93`).
### `Admin.tsx`
The thin admin screen (`Admin.tsx:6`). It lists pending matches and offers a **Flag as spam** button
per match. Note it calls the `/admin/...` endpoints with `fetch` directly (reading the token from
`localStorage`) rather than going through `api.ts` (`Admin.tsx:14`) β a small inconsistency, since the
admin endpoints simply weren't added to the shared `api` object.
---
## 15. How to run it
From the `README.md`, condensed:
**Backend** (from `backend/`):
```
python -m venv .venv
.venv\Scripts\Activate.ps1 # Windows PowerShell
pip install -r requirements.txt
python -m scripts.seed # demo data
uvicorn app.main:app --reload --port 8000
```
API docs are auto-generated at `http://127.0.0.1:8000/docs`. Tests: `pytest -q`.
**Frontend** (from `frontend/`):
```
npm install
npm run dev # http://localhost:5173
```
Seeded logins: the demo seeder creates an owner (with lost dog Rex and a match waiting) and an
admin. Passwords are generated at seed time and printed once, never hardcoded.
---
## 16. Where to read next
Depending on what you want to be able to explain:
- **"How does the AI matching work?"** β `ml/embedder.py` β `ml/index.py` β `services/matching.py`,
then `tests/test_matching.py` to see it proven.
- **"How does a request flow through the system?"** β re-read [section 4](#4-the-end-to-end-story-of-one-match),
then follow `api/cases.py` into `services/`.
- **"How is privacy enforced?"** β `schemas/dog.py` (`UnknownDogOut`), `api/hydrate.py`,
`services/images.py` (`_normalize` strips EXIF), and the privacy tests.
- **"How does login work?"** β `security.py` β `api/auth.py` β frontend `auth.tsx` + `api.ts`.
- **"How does the UI work?"** β `App.tsx` (routing) β `auth.tsx` (state) β a page like
`CaseDetail.tsx` β the components it uses.
A good way to cement understanding: pick one of the `tests/test_matching.py` scenarios, then trace
every file it touches from the HTTP call down to the database and back. By the end you'll be able to
narrate the whole system confidently.
|