● Zion Boggan
repos/Sightline/backend/app/services/reporting.py
zionboggan.com ↗
175 lines · python
History for this file →
1
"""Case report assembly. Builds a structured section model from an investigation, then
2
renders to Markdown / JSON / CSV. PDF uses the same section model (render hook left for a
3
print pipeline). Pure assembly from query results keeps it testable and format-agnostic."""
4
from __future__ import annotations
5
 
6
import csv
7
import io
8
import json
9
from datetime import datetime
10
 
11
from sqlalchemy import func, select
12
from sqlalchemy.orm import Session
13
 
14
from app.models import (
15
    Camera, EvidenceFile, Investigation, Note, PlateRead, Sighting, Vehicle,
16
    WatchlistHit, LicensePlate,
17
)
18
from app.services.analytics import movement_summary
19
 
20
 
21
REPORT_SECTIONS = [
22
    "executive_summary", "investigation_overview", "plate_vehicle_summary",
23
    "sighting_timeline", "movement_summary", "watchlist_hits",
24
    "key_findings", "evidence_appendix", "analyst_notes", "source_attribution",
25
]
26
 
27
 
28
def _reads_for_investigation(db: Session, inv_id: str) -> list[dict]:
29
    rows = db.execute(
30
        select(PlateRead.plate_norm, PlateRead.read_ts, PlateRead.camera_id,
31
               PlateRead.confidence, PlateRead.lat, PlateRead.lon)
32
        .join(Sighting, Sighting.plate_read_id == PlateRead.id)
33
        .where(Sighting.investigation_id == inv_id)
34
    ).all()
35
    return [{"plate_norm": r[0], "read_ts": r[1], "camera_id": r[2],
36
             "confidence": float(r[3]) if r[3] is not None else None,
37
             "lat": r[4], "lon": r[5]} for r in rows]
38
 
39
 
40
def build_report(db: Session, investigation_id: str, sections: list[str] | None = None) -> dict:
41
    inv = db.get(Investigation, investigation_id)
42
    if inv is None:
43
        raise ValueError("investigation not found")
44
    sections = sections or REPORT_SECTIONS
45
    reads = _reads_for_investigation(db, investigation_id)
46
    plates = sorted({r["plate_norm"] for r in reads})
47
 
48
    out: dict = {"title": f"Investigation Report - {inv.title}",
49
                 "generated_at": None, "investigation_id": inv.id, "sections": {}}
50
 
51
    if "executive_summary" in sections:
52
        out["sections"]["executive_summary"] = (
53
            f"Investigation '{inv.title}' (status: {inv.status}, priority: {inv.priority}) "
54
            f"covers {len(reads)} attached plate reads across {len(plates)} distinct plate(s)."
55
        )
56
    if "investigation_overview" in sections:
57
        out["sections"]["investigation_overview"] = {
58
            "title": inv.title, "status": inv.status, "priority": inv.priority,
59
            "summary": inv.summary, "organization": inv.organization,
60
        }
61
    if "plate_vehicle_summary" in sections:
62
        out["sections"]["plate_vehicle_summary"] = _plate_vehicle_summary(db, plates)
63
    if "sighting_timeline" in sections:
64
        out["sections"]["sighting_timeline"] = [
65
            {"plate_norm": r["plate_norm"], "read_ts": r["read_ts"].isoformat(),
66
             "camera_id": r["camera_id"]}
67
            for r in sorted(reads, key=lambda r: r["read_ts"])
68
        ]
69
    if "movement_summary" in sections:
70
        movements = {}
71
        by_plate: dict[str, list[dict]] = {}
72
        for r in reads:
73
            by_plate.setdefault(r["plate_norm"], []).append(r)
74
        for p, rs in by_plate.items():
75
            m = movement_summary(p, rs)
76
            movements[p] = {"total_reads": m.total_reads, "distinct_cameras": m.distinct_cameras,
77
                            "first_seen": m.first_seen.isoformat() if m.first_seen else None,
78
                            "last_seen": m.last_seen.isoformat() if m.last_seen else None,
79
                            "anomalies": m.anomalies}
80
        out["sections"]["movement_summary"] = movements
81
    if "watchlist_hits" in sections:
82
        hits = db.execute(
83
            select(WatchlistHit.matched_on, WatchlistHit.created_at, PlateRead.plate_norm)
84
            .join(PlateRead, PlateRead.id == WatchlistHit.plate_read_id)
85
            .join(Sighting, Sighting.plate_read_id == PlateRead.id)
86
            .where(Sighting.investigation_id == investigation_id)
87
        ).all()
88
        out["sections"]["watchlist_hits"] = [
89
            {"plate_norm": h[2], "matched_on": h[0], "at": h[1].isoformat() if h[1] else None}
90
            for h in hits
91
        ]
92
    if "evidence_appendix" in sections:
93
        ev = db.scalars(select(EvidenceFile).where(
94
            EvidenceFile.investigation_id == investigation_id)).all()
95
        out["sections"]["evidence_appendix"] = [
96
            {"filename": e.filename, "sha256": e.sha256, "size_bytes": e.size_bytes,
97
             "description": e.description} for e in ev
98
        ]
99
    if "analyst_notes" in sections:
100
        notes = db.scalars(select(Note).where(Note.investigation_id == investigation_id)).all()
101
        out["sections"]["analyst_notes"] = [{"body": n.body, "pinned": n.pinned} for n in notes]
102
    if "source_attribution" in sections:
103
        out["sections"]["source_attribution"] = _source_attribution(db, investigation_id)
104
    if "key_findings" in sections:
105
        out["sections"]["key_findings"] = _key_findings(reads)
106
 
107
    return out
108
 
109
 
110
def _plate_vehicle_summary(db: Session, plates: list[str]) -> list[dict]:
111
    summary = []
112
    for p in plates:
113
        plate = db.scalar(select(LicensePlate).where(LicensePlate.plate_norm == p))
114
        veh = db.scalar(select(Vehicle).where(Vehicle.primary_plate_id == plate.id)) if plate else None
115
        summary.append({
116
            "plate_norm": p, "state": plate.state if plate else None,
117
            "make": veh.make if veh else None, "model": veh.model if veh else None,
118
            "color": veh.color if veh else None, "body_type": veh.body_type if veh else None,
119
        })
120
    return summary
121
 
122
 
123
def _source_attribution(db: Session, inv_id: str) -> list[dict]:
124
    rows = db.execute(
125
        select(PlateRead.source_id, func.count())
126
        .join(Sighting, Sighting.plate_read_id == PlateRead.id)
127
        .where(Sighting.investigation_id == inv_id)
128
        .group_by(PlateRead.source_id)
129
    ).all()
130
    return [{"source_id": r[0], "read_count": r[1]} for r in rows]
131
 
132
 
133
def _key_findings(reads: list[dict]) -> list[str]:
134
    findings: list[str] = []
135
    by_plate: dict[str, list[dict]] = {}
136
    for r in reads:
137
        by_plate.setdefault(r["plate_norm"], []).append(r)
138
    for p, rs in by_plate.items():
139
        m = movement_summary(p, rs)
140
        if m.anomalies:
141
            findings.append(f"{p}: {len(m.anomalies)} impossible-travel anomaly(ies) - possible cloned/misread plate.")
142
        if m.dwell:
143
            longest = max(m.dwell, key=lambda d: d.duration_s)
144
            if longest.duration_s > 0:
145
                findings.append(f"{p}: longest dwell {int(longest.duration_s)}s at camera {longest.camera_id}.")
146
    return findings or ["No automated anomalies detected in attached sightings."]
147
 
148
 
149
def render_markdown(report: dict) -> str:
150
    lines = [f"# {report['title']}", ""]
151
    for key, content in report["sections"].items():
152
        lines.append(f"## {key.replace('_', ' ').title()}")
153
        if isinstance(content, str):
154
            lines.append(content)
155
        elif isinstance(content, list):
156
            for item in content:
157
                lines.append(f"- {json.dumps(item, default=str) if isinstance(item, dict) else item}")
158
        elif isinstance(content, dict):
159
            for k, v in content.items():
160
                lines.append(f"- **{k}**: {json.dumps(v, default=str) if isinstance(v, (dict, list)) else v}")
161
        lines.append("")
162
    return "\n".join(lines)
163
 
164
 
165
def render_json(report: dict) -> str:
166
    return json.dumps(report, indent=2, default=str)
167
 
168
 
169
def render_csv_timeline(report: dict) -> str:
170
    buf = io.StringIO()
171
    w = csv.writer(buf)
172
    w.writerow(["plate_norm", "read_ts", "camera_id"])
173
    for row in report["sections"].get("sighting_timeline", []):
174
        w.writerow([row["plate_norm"], row["read_ts"], row["camera_id"]])
175
    return buf.getvalue()