● Zion Boggan
repos/Sightline/docs/DESIGN.md
zionboggan.com ↗
403 lines · markdown
History for this file →
1
# Sightline - ALPR / Flock Intelligence Platform
2
 
3
**Design & Architecture Document**
4
Author: (portfolio project) · Status: MVP build · Stack: Next.js + FastAPI + PostgreSQL + Redis/Celery
5
 
6
> Sightline is a private intelligence aggregation platform built around Automatic License
7
> Plate Recognition (ALPR) data. It ingests Flock-style ALPR exports, normalizes and
8
> deduplicates them, maps camera networks, correlates vehicle sightings into investigation
9
> timelines, manages watchlists and alerts, generates analyst reports, and enriches
10
> investigations with passive OSINT. ALPR is the core product; OSINT is a supporting module.
11
 
12
---
13
 
14
## 1. Executive Summary
15
 
16
Investigators working with ALPR networks (Flock Safety, Motorola Vigilant, Genetec AutoVu,
17
Rekor, and homegrown camera meshes) drown in raw plate-read exports: millions of timestamped
18
rows with inconsistent plate formatting, camera identifiers, and coordinate precision. Turning
19
that firehose into a defensible investigation - *who was where, when, and how they moved* -
20
is manual, slow, and error-prone.
21
 
22
Sightline is the system of record between the raw export and the analyst's conclusion. It
23
provides a normalized data model, a deterministic ingest/dedup pipeline, geospatial camera
24
mapping, plate/partial-plate search, timeline and route reconstruction, watchlist alerting,
25
an entity-relationship graph, evidence chain-of-custody, and exportable case reports. Every
26
write is attributed to a source and recorded in an immutable audit log, so findings are
27
traceable and the platform is defensible in a privacy-sensitive context.
28
 
29
**Design stance on responsibility:** because ALPR is privacy-sensitive, access control, full
30
audit logging, source attribution, and configurable retention are *first-class* requirements,
31
not afterthoughts. The platform assumes the operator already holds the data lawfully and is
32
accountable for its use; Sightline's job is to make that use traceable and disciplined.
33
 
34
## 2. Product Vision
35
 
36
A single-tenant-per-deployment (multi-org capable) investigation platform that an analyst can
37
stand up with `docker compose up`, point at a folder of Flock CSV/JSON exports, and within
38
minutes be searching plates, watching a camera map light up with sightings, and assembling a
39
timeline into a court-presentable report. The OSINT enrichment layer adds context (domains,
40
infrastructure, public footprint) when an investigation crosses into the digital world, but it
41
never overshadows the ALPR core.
42
 
43
Differentiators:
44
- **Deterministic, idempotent ingest** - re-importing the same export never double-counts.
45
- **Raw preservation + normalized projection** - every normalized row points back to the exact
46
  raw bytes it came from, for evidentiary traceability.
47
- **Movement intelligence** - route reconstruction, dwell detection, camera-to-camera transit
48
  graphs, frequency/co-travel analysis, not just a searchable table.
49
- **Pluggable enrichment** - a uniform enricher contract so new OSINT sources drop in without
50
  touching core code.
51
 
52
## 3. MVP Scope
53
 
54
**In (MVP):**
55
- Auth + RBAC (admin / analyst / viewer), JWT sessions, audit logging.
56
- Investigation CRUD; attach sightings, notes, evidence.
57
- ALPR ingest: CSV + JSON parsers, column auto-mapping, normalization, dedup, raw preservation.
58
- Cameras, camera locations, camera groups; geospatial storage; camera map API.
59
- Plate / partial-plate / vehicle / time-window / camera / location search.
60
- Sighting timeline + route reconstruction + first/last-seen + frequency analytics.
61
- Watchlists, watchlist entries, alert rules, watchlist-hit generation on ingest.
62
- Entity graph (plates↔vehicles↔sightings↔cameras↔incidents) read API.
63
- Evidence upload with hash + source attribution.
64
- Report builder (JSON/CSV/Markdown; PDF structure defined) .
65
- OSINT enrichment plugins: DNS, RDAP/WHOIS, cert-transparency, IP/ASN, security.txt,
66
  robots.txt, GitHub public metadata (the rest are stubbed against the same contract).
67
- Docker Compose dev environment, Alembic migrations, seed/sample data, pipeline tests.
68
 
69
**Delivered beyond the original MVP cutline:** PDF parser (pdfminer.six), PDF report export
70
(reportlab), live API connectors (JSON-REST + webhook), real-time stream ingest, S3-compatible
71
storage backend, OIDC/SSO login, API-key auth, evidence chain-of-custody, cascade-safe retention
72
enforcement (admin + scheduled). **Still deferred (true post-MVP):** multi-tenant billing,
73
mobile client, Mapbox vector styling, OCR for scanned (non-text-layer) PDFs.
74
 
75
## 4. Full Architecture
76
 
77
```
78
                         ┌──────────────────────────────────────────┐
79
                         │            Next.js / React (TS)            │
80
                         │  Dashboard · Investigations · Camera Map   │
81
                         │  Plate Search · Timeline · Watchlists ·    │
82
                         │  Entity Graph · OSINT Panel · Reports      │
83
                         └───────────────┬──────────────────────────┘
84
                                         │  HTTPS / REST + JWT
85
                         ┌───────────────▼──────────────────────────┐
86
                         │                FastAPI                     │
87
                         │  Auth/RBAC · Investigations · Search ·     │
88
                         │  Imports · Cameras · Watchlists · Graph ·  │
89
                         │  Reports · Enrichment · Audit middleware   │
90
                         └───┬───────────────┬───────────────┬───────┘
91
                             │               │               │
92
                  ┌──────────▼───┐   ┌───────▼───────┐  ┌────▼─────────┐
93
                  │ PostgreSQL    │   │ Redis + Celery │  │ Object store │
94
                  │ (PostGIS-     │   │ ingest jobs,   │  │ local FS dev │
95
                  │  ready, FTS,  │   │ enrichment,    │  │ S3 prod      │
96
                  │  pg_trgm)     │   │ report render  │  │ (evidence)   │
97
                  └───────────────┘   └───────┬───────┘  └──────────────┘
98
                                              │
99
                              ┌───────────────▼───────────────┐
100
                              │ Parser pipeline (CSV/JSON/PDF) │
101
                              │ Normalizer · Deduper · Analytics│
102
                              │ Enricher plugins (OSINT)        │
103
                              └────────────────────────────────┘
104
```
105
 
106
Request flow for ingest: upload → store raw blob + `imports` row → enqueue Celery job →
107
parser detects schema → per-row normalize → dedup against `(plate_norm, camera_id, read_ts)`
108
key → insert `plate_reads`/`sightings` linked to raw → evaluate watchlists → emit
109
`watchlist_hits` + alerts → write `audit_logs`. All synchronous APIs (search, timeline, graph)
110
read the normalized projection only.
111
 
112
## 5. Text Architecture Diagram (data lifecycle)
113
 
114
```
115
RAW EXPORT (csv/json/pdf/manual)
116
   │  bytes preserved verbatim
117
   ▼
118
imports(raw_blob_ref, sha256, source_id, status)          ← evidentiary anchor
119
   │
120
   ▼  parser → row dicts
121
normalize(): plate→UPPER/strip, ts→tz-aware UTC, geo→(lat,lon),
122
             direction→enum, confidence→0..1, camera_id→source-scoped
123
   │
124
   ▼  dedup key = (org, plate_norm, camera_id, read_ts_bucketed)
125
plate_reads(raw_read jsonb, normalized cols, import_id, dedup_hash UNIQUE)
126
   │           │
127
   │           └── upsert vehicle (by plate) , upsert camera (by source camera id)
128
   ▼
129
sightings(plate_read_id, vehicle_id, camera_id, location, ts)  ← investigation-attachable
130
   │
131
   ├── watchlist match → watchlist_hits → alerts
132
   ├── analytics (first/last seen, route, dwell, frequency, co-travel)
133
   └── entity_relationships (graph projection)
134
```
135
 
136
## 6. Database Schema
137
 
138
PostgreSQL. Geospatial via PostGIS when available, with a lat/lon + GiST-on-point fallback so
139
the schema runs on stock Postgres for the portfolio demo. Full-text via `tsvector`; partial
140
plate via `pg_trgm` GIN index. Times are `timestamptz` (UTC) with original tz preserved in raw.
141
 
142
Core tables (abridged DDL; full SQLAlchemy models in `backend/app/models/`):
143
 
144
```sql
145
-- identity & access
146
users(id uuid pk, email citext unique, hashed_password, full_name, role_id fk,
147
      is_active bool, created_at, updated_at)
148
roles(id, name unique, permissions jsonb)               -- admin/analyst/viewer
149
api_keys(id, user_id fk, name, key_hash, scopes jsonb, last_used_at, revoked_at, created_at)
150
audit_logs(id bigserial, actor_user_id, action, entity_type, entity_id, ip,
151
           metadata jsonb, created_at)  -- INDEX(entity_type, entity_id), INDEX(created_at)
152
 
153
-- sources & ingest
154
sources(id, name, system_type, organization, jurisdiction, trust_level, created_at)
155
imports(id, source_id fk, filename, content_type, sha256 unique, raw_blob_ref,
156
        row_count, status, error, created_by, created_at)  -- raw preservation
157
enrichment_jobs(id, investigation_id, enricher, target, status, result jsonb,
158
                confidence, raw_response_ref, created_at, finished_at)
159
 
160
-- cameras / geo
161
locations(id, label, address, lat double precision, lon double precision,
162
          geom geography(Point,4326) NULL, jurisdiction, created_at)  -- GiST(geom)
163
cameras(id, source_id fk, external_camera_id, name, direction_facing, location_id fk,
164
        camera_group_id fk, metadata jsonb, created_at)
165
        UNIQUE(source_id, external_camera_id)              -- source-scoped identity
166
camera_groups(id, name, organization, description)
167
 
168
-- plates / vehicles / reads / sightings
169
license_plates(id, plate_raw, plate_norm, state, plate_type, created_at)
170
               UNIQUE(plate_norm, state); GIN(plate_norm gin_trgm_ops)  -- partial search
171
vehicles(id, primary_plate_id fk, make, model, color, body_type, year, notes,
172
         possible_duplicate_of fk NULL, created_at)
173
plate_reads(id bigserial, import_id fk, source_id fk, camera_id fk, plate_id fk,
174
            plate_norm, read_ts timestamptz, direction, confidence numeric(4,3),
175
            lane, hotlist_hit bool, raw_read jsonb, dedup_hash bytea UNIQUE,
176
            incident_ref text, created_at)
177
            INDEX(plate_norm), INDEX(read_ts), INDEX(camera_id),
178
            INDEX(read_ts, camera_id), GIN(to_tsvector(raw))
179
sightings(id bigserial, plate_read_id fk, vehicle_id fk, camera_id fk, location_id fk,
180
          investigation_id fk NULL, read_ts timestamptz, confidence, created_at)
181
          INDEX(vehicle_id, read_ts), INDEX(investigation_id), INDEX(read_ts)
182
 
183
-- investigations & case material
184
investigations(id, title, status, priority, summary, lead_user_id, organization,
185
               created_at, updated_at)  -- status: open/active/closed/archived
186
targets(id, investigation_id fk, kind, label, plate_id fk NULL, vehicle_id fk NULL,
187
        osint_entity_id fk NULL, notes, created_at)
188
incidents(id, external_ref, title, kind, occurred_at, location_id fk, description, created_at)
189
notes(id, investigation_id fk, author_id, body, pinned bool, created_at)
190
tags(id, name unique, color); taggings(tag_id, entity_type, entity_id)  -- polymorphic
191
evidence_files(id, investigation_id fk, filename, content_type, size_bytes, sha256,
192
               blob_ref, source_id fk, description, uploaded_by, created_at)
193
 
194
-- watchlists & alerts
195
watchlists(id, name, organization, kind, active bool, created_by, created_at)
196
watchlist_entries(id, watchlist_id fk, plate_norm NULL, partial_plate NULL,
197
                  vehicle_desc jsonb NULL, reason, expires_at, created_at)
198
watchlist_hits(id, watchlist_entry_id fk, plate_read_id fk, sighting_id fk,
199
               matched_on, created_at)  -- INDEX(created_at)
200
alert_rules(id, watchlist_id fk NULL, condition jsonb, channel, throttle_seconds, active)
201
alerts(id, alert_rule_id fk, watchlist_hit_id fk, status, payload jsonb, created_at)
202
 
203
-- graph & osint
204
osint_entities(id, kind, value, attributes jsonb, first_seen, last_seen, created_at)
205
               UNIQUE(kind, value)
206
entity_relationships(id, src_type, src_id, dst_type, dst_id, relation, weight,
207
                     evidence_ref, confidence, created_at)
208
                     INDEX(src_type, src_id), INDEX(dst_type, dst_id)
209
 
210
-- output
211
reports(id, investigation_id fk, title, format, sections jsonb, rendered_ref,
212
        created_by, created_at)
213
exports(id, investigation_id fk, kind, format, filters jsonb, file_ref, created_by, created_at)
214
```
215
 
216
**Migration strategy:** Alembic, one base revision establishing the schema, `pg_trgm` +
217
(optional) `postgis` extensions created conditionally in the first migration. Forward-only in
218
dev; reversible downgrades authored for each revision. Seed data loaded via an idempotent
219
`seed.py` (upserts, safe to re-run).
220
 
221
## 7. API Route Design (REST, `/api/v1`)
222
 
223
```
224
Auth        POST   /auth/register            POST /auth/login (->JWT)   GET /auth/me
225
Users/RBAC  GET    /users  POST /users  PATCH /users/{id}  (admin)
226
            GET    /roles
227
Sources     GET/POST /sources
228
Imports     POST   /imports            (multipart upload -> enqueue)
229
            GET    /imports  GET /imports/{id}  (status, row_count, errors)
230
Cameras     GET/POST /cameras   GET /cameras/{id}   GET /cameras/map?bbox=&group=
231
            GET    /camera-groups  POST /camera-groups
232
Plates      GET    /plates/search?q=&partial=&state=
233
            GET    /plates/{plate_norm}             (profile + sightings)
234
Vehicles    GET    /vehicles/{id}  PATCH /vehicles/{id}  (merge duplicates)
235
Sightings   GET    /sightings?plate=&camera=&from=&to=&bbox=&investigation=
236
Search      POST   /search          (unified: plate/partial/vehicle/time/geo/camera/incident)
237
Timeline    GET    /investigations/{id}/timeline
238
Analytics   GET    /vehicles/{id}/movement     (route, dwell, frequency, co-travel)
239
            GET    /cameras/{id}/transits       (camera-to-camera graph)
240
Invest.     GET/POST /investigations  GET/PATCH/DELETE /investigations/{id}
241
            POST   /investigations/{id}/sightings   POST /{id}/notes  POST /{id}/evidence
242
            GET    /investigations/{id}/graph       (entity graph)
243
Watchlists  GET/POST /watchlists  POST /watchlists/{id}/entries
244
            GET    /watchlist-hits?from=&to=
245
Alerts      GET    /alerts  PATCH /alerts/{id}  (ack/resolve)
246
Enrichment  POST   /enrichment/run  {investigation_id, enricher, target}
247
            GET    /enrichment/jobs/{id}    GET /enrichers   (registry)
248
Graph       GET    /graph?root_type=&root_id=&depth=
249
Reports     POST   /investigations/{id}/reports   GET /reports/{id}   GET /reports/{id}/download
250
Exports     POST   /investigations/{id}/exports   GET /exports/{id}/download
251
Audit       GET    /audit?entity_type=&entity_id=&actor=&from=&to=   (admin/analyst)
252
Admin       GET    /admin/settings  PATCH /admin/settings   (retention, rate limits)
253
Health      GET    /health  GET /health/ready
254
```
255
 
256
Auth: `Authorization: Bearer <JWT>` or `X-API-Key`. RBAC enforced by a dependency that checks
257
`role.permissions` against a per-route required-permission. Every mutating route writes an
258
`audit_logs` row via middleware.
259
 
260
## 8. Parser / Import Pipeline
261
 
262
1. **Upload** (`POST /imports`): stream file to blob store, compute SHA-256, create `imports`
263
   row (`status=received`). If a prior import shares the SHA-256 → short-circuit (idempotent).
264
2. **Enqueue** Celery `process_import(import_id)`.
265
3. **Detect**: sniff content-type + header row; choose `CsvFlockParser` / `JsonFlockParser` /
266
   `PdfReportParser`. Parsers implement a common `iter_rows() -> Iterable[RawRead]`.
267
4. **Column mapping**: heuristic mapper handles Flock/Vigilant/Genetec header variants
268
   (`plate`, `Plate`, `license_plate`, `lp`, `tag`; `timestamp`, `read_time`, `captured_at`;
269
   `camera`, `device_id`, `cam`; `lat/lon`, `latitude/longitude`, `gps`). Unmapped columns are
270
   kept in `raw_read` jsonb.
271
5. **Normalize** (`services/normalize.py`): plate → uppercase, strip non-alphanumerics, fold
272
   look-alikes optionally; timestamp → tz-aware UTC (parse offset or apply source tz);
273
   confidence → 0..1; direction → {N,NE,E,SE,S,SW,W,NW,UNKNOWN}; coordinates validated to
274
   plausible ranges; camera resolved/created by `(source, external_camera_id)`.
275
6. **Dedup**: `dedup_hash = sha256(org | plate_norm | camera_id | read_ts_to_second)`. Unique
276
   constraint makes re-ingest a no-op; near-duplicate (same plate+camera within N seconds)
277
   collapses to one read with a `burst_count`.
278
7. **Persist**: insert `plate_reads` (linked to `import_id`), upsert `license_plates` +
279
   `vehicles`, create `sightings`.
280
8. **Watchlist eval**: match each new read against active `watchlist_entries`
281
   (exact, partial-prefix, vehicle-desc) → `watchlist_hits` → `alerts` (throttled).
282
9. **Finalize**: update `imports.row_count/status`, write `audit_logs`, surface errors per-row
283
   in `imports.error` without failing the whole batch (row-level resilience).
284
 
285
## 9. ALPR Analytics Design (`services/analytics/`)
286
 
287
- **first/last seen**: `MIN/MAX(read_ts)` per `vehicle_id`, optionally bounded by bbox/time.
288
- **sightings by camera / time window / near incident**: grouped counts; "near incident" uses
289
  geospatial distance + time delta around an `incident`.
290
- **route reconstruction**: order a vehicle's sightings by `read_ts`, emit ordered camera→camera
291
  legs with elapsed time, distance (haversine), and implied speed; flag implausible legs
292
  (speed > threshold ⇒ possible plate misread or cloned plate).
293
- **dwell detection**: consecutive reads at the same camera/location cluster within a window ⇒
294
  dwell episodes (arrive_ts, depart_ts, duration).
295
- **camera-to-camera movement / transit graph**: weighted directed edges between cameras by
296
  observed transitions; powers "where does this vehicle usually go next."
297
- **common locations / frequency**: histogram of cameras/locations per vehicle; identifies home
298
  /work anchor points by time-of-day clustering.
299
- **co-travel**: vehicles repeatedly seen at the same camera within ±N seconds across multiple
300
  days ⇒ candidate associates (with a false-positive guard on high-traffic cameras).
301
- **possible duplicate vehicles**: same plate_norm with divergent vehicle descriptions, or
302
  impossible-travel ⇒ flag `possible_duplicate_of`.
303
- **confidence scoring**: per-read confidence carried through; aggregate finding confidence =
304
  function of read confidences, corroborating cameras, and source trust_level.
305
 
306
## 10. Watchlist & Alert Design
307
 
308
- **Watchlist entry kinds**: exact plate, partial/prefix plate, vehicle description (make/color
309
  /body), and OSINT entity. Entries can expire (`expires_at`).
310
- **Evaluation**: runs inline during ingest (new reads) and on-demand (backfill against history).
311
- **Alert rules**: `condition` jsonb (e.g. `{first_seen_in_area: true}`,
312
  `{camera_group: "downtown"}`, `{min_confidence: 0.85}`) bound to a watchlist; `throttle_seconds`
313
  prevents alert storms; `channel` = in-app (MVP), webhook/email (extensible).
314
- **Hit → alert**: a `watchlist_hit` records what matched (`matched_on`); an `alert` is emitted
315
  if a rule passes throttling. Alerts have ack/resolve lifecycle, visible in the Alert Center.
316
 
317
## 11. Frontend Page Plan (Next.js App Router)
318
 
319
| Route | Page | Key components |
320
|---|---|---|
321
| `/` | Dashboard | KPI cards, recent hits, ingest status, alert feed |
322
| `/investigations` | Investigation list | table, filters, status chips |
323
| `/investigations/[id]` | Investigation detail | tabs: overview, sightings, timeline, graph, evidence, notes, report |
324
| `/import` | ALPR import | drag-drop upload, source picker, mapping preview, job status |
325
| `/map` | Camera map | Leaflet, camera markers, sighting heat, bbox query |
326
| `/cameras/[id]` | Camera detail | reads over time, transit graph, location |
327
| `/search` | Plate search | unified search form, partial-plate, results table |
328
| `/vehicles/[id]` | Vehicle profile | reads, route map, frequency, co-travel |
329
| `/investigations/[id]/timeline` | Timeline | vertical time-ordered sightings + map sync |
330
| `/watchlists` | Watchlist manager | lists, entries, expiries |
331
| `/alerts` | Alert center | live hits, ack/resolve |
332
| `/evidence/[id]` | Evidence viewer | file preview, hash, chain-of-custody |
333
| `/graph` | Entity graph | Cytoscape/React Flow node-link |
334
| `/enrichment` | OSINT panel | run enricher, view normalized entities + evidence |
335
| `/reports` | Report builder | section toggles, preview, export |
336
| `/audit` | Audit log viewer | filterable immutable log |
337
| `/admin` | Admin/settings | users, roles, retention, API keys, rate limits |
338
 
339
Shared: API client (`lib/api.ts`) with JWT handling, typed DTOs mirroring backend schemas,
340
auth guard, role-gated nav.
341
 
342
## 12. File Tree
343
 
344
```
345
Sightline/
346
├── docker-compose.yml          # postgres, redis, api, worker, frontend
347
├── .env.example
348
├── README.md
349
├── docs/DESIGN.md              # this document
350
├── backend/
351
│   ├── pyproject.toml / requirements.txt
352
│   ├── alembic.ini · alembic/env.py · alembic/versions/0001_initial.py
353
│   ├── seed.py                 # idempotent sample-data loader
354
│   ├── app/
355
│   │   ├── main.py             # FastAPI app, middleware, router mount
356
│   │   ├── core/{config,security,permissions,logging}.py
357
│   │   ├── db/{base,session}.py
358
│   │   ├── models/*.py         # SQLAlchemy: all core entities
359
│   │   ├── schemas/*.py        # Pydantic DTOs
360
│   │   ├── api/deps.py · api/routes/*.py   # routers per domain
361
│   │   ├── services/
362
│   │   │   ├── normalize.py · dedup.py · ingest.py
363
│   │   │   ├── parsers/{base,csv_flock,json_flock,pdf_report}.py
364
│   │   │   ├── analytics/{movement,frequency,cotravel,dwell}.py
365
│   │   │   ├── watchlist.py · reporting.py
366
│   │   │   └── enrichment/{base,registry,dns,rdap,crtsh,ipasn,securitytxt,robots,github}.py
367
│   │   └── workers/{celery_app,tasks}.py
368
│   ├── sample_data/flock_sample.csv · flock_sample.json
369
│   └── tests/test_pipeline.py · test_analytics.py
370
└── frontend/
371
    ├── package.json · next.config.js · tsconfig.json
372
    └── src/{app/*, components/*, lib/api.ts}
373
```
374
 
375
## 13. Implementation Plan
376
 
377
1. **Foundation** - config, DB session, base models, security/JWT, RBAC permissions. *(done in MVP)*
378
2. **Schema & migration** - all SQLAlchemy models + Alembic base revision + seed. *(done)*
379
3. **Ingest core** - parsers, normalize, dedup, ingest service, import API, Celery task. *(done)*
380
4. **Search & analytics** - search API, timeline, movement/frequency/co-travel/dwell. *(done)*
381
5. **Watchlists & alerts** - entry matching during ingest, hits, alert rules. *(done)*
382
6. **Graph & enrichment** - entity-relationship projection, enricher contract + plugins. *(done)*
383
7. **Reports & exports** - section assembly, JSON/CSV/Markdown render, PDF structure. *(done)*
384
8. **Frontend** - API client, auth, dashboard, search, map, timeline, investigation detail. *(skeleton)*
385
9. **Hardening** - rate limiting, input validation, retention jobs, backups, tests. *(partial)*
386
 
387
MVP cutline = steps 1-7 functional and tested end-to-end on sample data; step 8 a navigable
388
skeleton wired to the live API; step 9 the documented-and-partially-implemented hardening floor.
389
 
390
## 14. Implementation (code)
391
 
392
See the repository. The backend ALPR pipeline (ingest → normalize → dedup → search →
393
analytics → watchlist) is implemented and covered by `backend/tests/`. Run the demo:
394
 
395
```bash
396
cp .env.example .env
397
docker compose up -d postgres redis
398
cd backend && pip install -r requirements.txt
399
alembic upgrade head && python seed.py
400
uvicorn app.main:app --reload         # API at http://localhost:8000/docs
401
# or full stack:
402
docker compose up --build
403
```