| 1 | """Profile / entity-resolution engine. Builds a unified dossier for a SUBJECT from data already |
| 2 | in the system (their plates -> reads -> sightings across cameras -> movement timeline + map + |
| 3 | detections). |
| 4 | |
| 5 | Consent guardrail: a runtime scope switch. In 'consented' mode (default) the engine only resolves |
| 6 | and builds profiles for whitelisted, consented subjects - it cannot touch anyone else's data. |
| 7 | Flip to 'all' to operate over all ingested subjects. |
| 8 | """ |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | from fastapi import APIRouter, Depends, HTTPException |
| 12 | from pydantic import BaseModel |
| 13 | from sqlalchemy import func, or_, select |
| 14 | from sqlalchemy.orm import Session |
| 15 | |
| 16 | from app.api.deps import Perm, get_db, require |
| 17 | from app.models import ( |
| 18 | AppSetting, AuditLog, Camera, Detection, LicensePlate, Location, Person, PlateRead, User, |
| 19 | ) |
| 20 | |
| 21 | router = APIRouter(prefix="/profiles", tags=["profiles"]) |
| 22 | |
| 23 | SCOPE_KEY = "profile_scope" |
| 24 | |
| 25 | |
| 26 | def _norm(p: str) -> str: |
| 27 | return "".join(ch for ch in (p or "").upper() if ch.isalnum()) |
| 28 | |
| 29 | |
| 30 | def get_scope(db: Session) -> str: |
| 31 | s = db.get(AppSetting, SCOPE_KEY) |
| 32 | return (s.value or {}).get("mode", "consented") if s else "consented" |
| 33 | |
| 34 | |
| 35 | def set_scope(db: Session, mode: str) -> None: |
| 36 | s = db.get(AppSetting, SCOPE_KEY) or AppSetting(key=SCOPE_KEY, value={}) |
| 37 | s.value = {"mode": mode} |
| 38 | db.add(s) |
| 39 | db.commit() |
| 40 | |
| 41 | |
| 42 | @router.get("/scope") |
| 43 | def read_scope(db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 44 | mode = get_scope(db) |
| 45 | consented = db.scalar(select(func.count()).select_from(Person).where(Person.consented.is_(True))) or 0 |
| 46 | return {"mode": mode, "consented_subjects": consented, |
| 47 | "modes": ["consented", "all"], |
| 48 | "description": ("Restricted to consented subjects only - cannot touch anyone else's data." |
| 49 | if mode == "consented" else "Operating over ALL ingested subjects.")} |
| 50 | |
| 51 | |
| 52 | class ScopeReq(BaseModel): |
| 53 | mode: str |
| 54 | |
| 55 | |
| 56 | @router.put("/scope") |
| 57 | def update_scope(req: ScopeReq, db: Session = Depends(get_db), user: User = Depends(require(Perm.ADMIN))): |
| 58 | if req.mode not in ("consented", "all"): |
| 59 | raise HTTPException(400, "mode must be 'consented' or 'all'") |
| 60 | set_scope(db, req.mode) |
| 61 | db.add(AuditLog(actor_user_id=user.id, action="profile.scope_change", |
| 62 | entity_type="settings", entity_id=None, meta={"mode": req.mode})) |
| 63 | db.commit() |
| 64 | return {"mode": req.mode} |
| 65 | |
| 66 | |
| 67 | class SubjectReq(BaseModel): |
| 68 | full_name: str |
| 69 | plates: list[str] = [] |
| 70 | consented: bool = True |
| 71 | notes: str | None = None |
| 72 | |
| 73 | |
| 74 | @router.post("/subjects", status_code=201) |
| 75 | def create_subject(req: SubjectReq, db: Session = Depends(get_db), user: User = Depends(require(Perm.WRITE))): |
| 76 | """Register a consented subject (e.g. yourself, your brother) with their plate(s).""" |
| 77 | plates = [_norm(p) for p in req.plates if _norm(p)] |
| 78 | p = Person(full_name=req.full_name, consented=req.consented, |
| 79 | associated_plate=plates[0] if plates else None, associated_plates=plates, |
| 80 | notes=req.notes, threat_level="none") |
| 81 | db.add(p) |
| 82 | db.flush() |
| 83 | db.add(AuditLog(actor_user_id=user.id, action="profile.subject_create", |
| 84 | entity_type="person", entity_id=p.id, meta={"name": req.full_name, "consented": req.consented})) |
| 85 | db.commit() |
| 86 | return {"id": p.id, "full_name": p.full_name, "plates": plates, "consented": p.consented} |
| 87 | |
| 88 | |
| 89 | @router.get("/search") |
| 90 | def search_profiles(q: str, db: Session = Depends(get_db), _: User = Depends(require(Perm.READ))): |
| 91 | scope = get_scope(db) |
| 92 | query = select(Person).where(Person.full_name.ilike(f"%{q}%")) |
| 93 | if scope == "consented": |
| 94 | query = query.where(Person.consented.is_(True)) |
| 95 | rows = db.scalars(query.limit(50)).all() |
| 96 | return {"scope": scope, |
| 97 | "results": [{"id": p.id, "full_name": p.full_name, "consented": p.consented, |
| 98 | "plates": p.associated_plates or ([p.associated_plate] if p.associated_plate else [])} |
| 99 | for p in rows]} |
| 100 | |
| 101 | |
| 102 | def _plates_for(p: Person) -> list[str]: |
| 103 | pl = list(p.associated_plates or []) |
| 104 | if p.associated_plate and p.associated_plate not in pl: |
| 105 | pl.append(p.associated_plate) |
| 106 | return [_norm(x) for x in pl if _norm(x)] |
| 107 | |
| 108 | |
| 109 | @router.get("/{person_id}") |
| 110 | def build_profile(person_id: str, db: Session = Depends(get_db), user: User = Depends(require(Perm.READ))): |
| 111 | """Build the dossier: identity + vehicles + cross-state movement (timeline + map) + detections.""" |
| 112 | scope = get_scope(db) |
| 113 | p = db.get(Person, person_id) |
| 114 | if not p: |
| 115 | raise HTTPException(404, "subject not found") |
| 116 | if scope == "consented" and not p.consented: |
| 117 | raise HTTPException(403, "profile scope is locked to consented subjects; this subject is not consented") |
| 118 | |
| 119 | plates = _plates_for(p) |
| 120 | reads = [] |
| 121 | if plates: |
| 122 | rows = db.execute( |
| 123 | select(PlateRead.plate_norm, PlateRead.read_ts, PlateRead.confidence, |
| 124 | Camera.name, Camera.operator, Camera.state, Camera.area, Location.lat, Location.lon) |
| 125 | .join(Camera, Camera.id == PlateRead.camera_id, isouter=True) |
| 126 | .join(Location, Location.id == Camera.location_id, isouter=True) |
| 127 | .where(PlateRead.plate_norm.in_(plates)) |
| 128 | .order_by(PlateRead.read_ts) |
| 129 | ).all() |
| 130 | reads = [{"plate_norm": r[0], "read_ts": r[1], "confidence": float(r[2]) if r[2] is not None else None, |
| 131 | "camera": r[3], "operator": r[4], "state": r[5], "area": r[6], |
| 132 | "lat": r[7], "lon": r[8]} for r in rows] |
| 133 | |
| 134 | states = sorted({r["state"] for r in reads if r["state"]}) |
| 135 | cities = sorted({r["area"] for r in reads if r["area"]}) |
| 136 | detections = db.scalars(select(Detection).where(Detection.plate_norm.in_(plates)) |
| 137 | .order_by(Detection.read_ts.desc()).limit(50)).all() if plates else [] |
| 138 | |
| 139 | db.add(AuditLog(actor_user_id=user.id, action="profile.build", |
| 140 | entity_type="person", entity_id=p.id, meta={"scope": scope, "reads": len(reads)})) |
| 141 | db.commit() |
| 142 | |
| 143 | return { |
| 144 | "scope": scope, |
| 145 | "subject": {"id": p.id, "full_name": p.full_name, "consented": p.consented, |
| 146 | "plates": plates, "sex": p.sex, "build": p.build, "hair": p.hair, |
| 147 | "threat_level": p.threat_level, "notes": p.notes}, |
| 148 | "summary": {"total_reads": len(reads), |
| 149 | "first_seen": reads[0]["read_ts"] if reads else None, |
| 150 | "last_seen": reads[-1]["read_ts"] if reads else None, |
| 151 | "states_visited": states, "cities_visited": cities, |
| 152 | "distinct_cameras": len({r["camera"] for r in reads if r["camera"]})}, |
| 153 | "timeline": reads, |
| 154 | "map_points": [{"lat": r["lat"], "lon": r["lon"], "read_ts": r["read_ts"], |
| 155 | "camera": r["camera"], "state": r["state"]} for r in reads if r["lat"]], |
| 156 | "detections": [{"read_ts": d.read_ts, "clothing_top": d.clothing_top, |
| 157 | "clothing_bottom": d.clothing_bottom, "articles": d.articles, |
| 158 | "description": d.description} for d in detections], |
| 159 | } |