● Zion Boggan
repos/Sightline/backend/app/api/routes/profiles.py
zionboggan.com ↗
159 lines · python
History for this file →
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
    }