| 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 | ``` |