Updates, big updates

incident_correlator.py — full rewrite: always runs on every call, fetches all active incidents cross-type, fast path collects all talkgroup matches and disambiguates by unit/vehicle overlap → location proximity → embedding, new location proximity path, slow path requires location corroboration, "Auto:" stripped from titles, "auto-generated" tag added, units/vehicles now accumulated on update
intelligence.py — resolved field in GPT schema, returned as 5th value
upload.py — both pipelines unpack 5-tuple, always call correlate, auto-resolve on resolved=True
summarizer.py — stale sweep runs each tick, resolves incidents idle for 90+ minutes
config.py — correlation_window_hours=2, embedding_similarity_threshold=0.93, location_proximity_km=0.5, incident_auto_resolve_minutes=90
This commit is contained in:
Logan
2026-04-19 22:53:53 -04:00
parent f9d4fcbc39
commit ba43796c51
9 changed files with 539 additions and 293 deletions
+5 -3
View File
@@ -22,9 +22,11 @@ class Settings(BaseSettings):
# Gemini (intelligence extraction, embeddings, incident summaries) # Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None gemini_api_key: Optional[str] = None
summary_interval_minutes: int = 2 # how often the summary loop runs summary_interval_minutes: int = 2 # how often the summary loop runs
correlation_window_hours: int = 1 # how far back to look for matching incidents correlation_window_hours: int = 2 # slow/location path: max hours since last call
embedding_similarity_threshold: float = 0.82 # cosine similarity cutoff for slow-path match embedding_similarity_threshold: float = 0.93 # slow-path cosine threshold (tiebreaker only)
location_proximity_km: float = 0.5 # radius for location-proximity matching
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase # Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None service_key: Optional[str] = None
+253 -143
View File
@@ -1,32 +1,29 @@
""" """
Hybrid incident correlation engine. Hybrid incident correlation engine.
Fast path — deterministic: Matching priority (in order):
Same system_id + talkgroup_id + incident_type within the correlation window.
This is the primary signal for P25 — dispatch assigns one talkgroup per incident.
Slow path embedding similarity: 1. Fast path — talkgroup + system match (any incident type, no time limit)
If fast path finds nothing, compare the new call's embedding against the Active-status gate is sufficient. If multiple active incidents share the same
centroid embedding of each open incident (running average of its call embeddings). talkgroup (e.g. busy shared channel), disambiguate by:
Match if cosine similarity >= threshold. a) Unit overlap — strongest signal, officer assigned to incident
b) Vehicle overlap — vehicle description shared across calls
c) Location proximity — geocoded coords closer to which incident
d) Embedding similarity against each candidate's centroid (tiebreaker)
Falls back to most-recently-updated on tie.
Background re-evaluation (driven by summarizer loop): 2. Location path — geocoded coords within `location_proximity_km` (time-limited)
Unmatched calls are re-checked periodically, catching mutual-aid cases where Primary mutual-aid signal: EMS + police at the same scene.
a second talkgroup gets pulled into an existing incident.
Incident document schema additions: 3. Slow path — embedding cosine similarity (time-limited, same type only)
talkgroup_ids: list[str] — all talkgroups that have contributed calls Requires similarity >= threshold AND location within 4× proximity radius.
location_mentions: list[str] — all location strings from calls (newest last) Never fires alone — location corroboration is mandatory.
location: str|None — best known location (newest non-null for now;
TODO: replace with Maps geocoding bbox comparison) Calls with no incident_type skip new-incident creation but still run paths 13,
vehicles: list[str] — deduplicated vehicle list across all calls so unclassified calls (short transport end, "en route", etc.) can link to an
units: list[str] — deduplicated unit list across all calls existing incident via talkgroup match.
severity: str — highest severity seen
summary_stale: bool — True when a new call is added
summary_last_run: str|None — ISO timestamp of last Gemini summary run
embedding: list[float] — running-average centroid of call embeddings
embedding_count: int — number of calls factored into the centroid
""" """
import math
import uuid import uuid
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
@@ -35,6 +32,10 @@ from app.internal import firestore as fstore
from app.config import settings from app.config import settings
# ─────────────────────────────────────────────────────────────────────────────
# Public entry point
# ─────────────────────────────────────────────────────────────────────────────
async def correlate_call( async def correlate_call(
call_id: str, call_id: str,
node_id: str, node_id: str,
@@ -48,83 +49,195 @@ async def correlate_call(
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
Returns the incident_id, or None if skipped (no type and no talkgroup match).
Returns the incident_id that was linked, or None if skipped.
""" """
if not incident_type: now = datetime.now(timezone.utc)
return None
now = datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours) window = timedelta(hours=settings.correlation_window_hours)
# Fetch all active incidents of the same type # Fetch all active incidents cross-type (mutual aid needs this)
candidates = await fstore.collection_list("incidents", status="active", type=incident_type) all_active = await fstore.collection_list("incidents", status="active")
recent = [inc for inc in all_active if _within_window(inc, now, window)]
# Filter to those within the correlation window # Fetch call doc once — reused for disambiguation, embedding merge, unit accumulation
active = [] call_doc = await fstore.doc_get("calls", call_id) or {}
for inc in candidates: call_embedding: Optional[list] = call_doc.get("embedding")
updated_raw = inc.get("updated_at", "") call_units: list[str] = call_doc.get("units") or []
try: call_vehicles: list[str] = call_doc.get("vehicles") or []
updated_dt = datetime.fromisoformat(str(updated_raw).replace("Z", "+00:00")) call_severity: str = call_doc.get("severity") or "unknown"
if updated_dt.tzinfo is None: # Use passed coords first (freshly geocoded), fall back to what's on the call doc
updated_dt = updated_dt.replace(tzinfo=timezone.utc) coords: Optional[dict] = location_coords or call_doc.get("location_coords")
if (now - updated_dt) <= window:
active.append(inc)
except Exception:
continue
# ----------------------------------------------------------------
# Fast path — talkgroup match
# ----------------------------------------------------------------
matched_incident: Optional[dict] = None matched_incident: Optional[dict] = None
# ── 1. Fast path: talkgroup match (any type, no time limit) ──────────────
if talkgroup_id is not None and system_id: if talkgroup_id is not None and system_id:
tg_str = str(talkgroup_id) tg_str = str(talkgroup_id)
for inc in active: tg_matches = [
if system_id in (inc.get("system_ids") or []) and tg_str in (inc.get("talkgroup_ids") or []): inc for inc in all_active
if system_id in (inc.get("system_ids") or [])
and tg_str in (inc.get("talkgroup_ids") or [])
]
if len(tg_matches) == 1:
matched_incident = tg_matches[0]
logger.info(
f"Correlator fast-path: call {call_id}{tg_matches[0]['incident_id']}"
)
elif len(tg_matches) > 1:
matched_incident = _disambiguate(
tg_matches, call_units, call_vehicles, coords, call_embedding
)
logger.info(
f"Correlator fast-path (disambig {len(tg_matches)} candidates): "
f"call {call_id}{matched_incident['incident_id']}"
)
# ── 2. Location path: proximity match (time-limited, cross-type) ─────────
if not matched_incident and coords:
for inc in recent:
inc_coords = inc.get("location_coords")
if not inc_coords:
continue
dist_km = _haversine_km(
coords["lat"], coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
if dist_km <= settings.location_proximity_km:
matched_incident = inc matched_incident = inc
logger.info(f"Correlator fast-path: call {call_id} → incident {inc['incident_id']} (tg match)") logger.info(
f"Correlator location-path: call {call_id}{inc['incident_id']} "
f"(dist={dist_km:.2f}km)"
)
break break
# ---------------------------------------------------------------- # ── 3. Slow path: embedding + location corroboration (time-limited, same type) ──
# Slow path — embedding similarity if not matched_incident and call_embedding and incident_type:
# ---------------------------------------------------------------- best_score = 0.0
if not matched_incident and active: best_inc: Optional[dict] = None
call_doc = await fstore.doc_get("calls", call_id) for inc in recent:
call_embedding = call_doc.get("embedding") if call_doc else None if inc.get("type") != incident_type:
if call_embedding: continue
best_score = 0.0 inc_embedding = inc.get("embedding")
best_inc = None if not inc_embedding:
for inc in active: continue
inc_embedding = inc.get("embedding") sim = _cosine_similarity(call_embedding, inc_embedding)
if not inc_embedding: if sim > best_score:
continue best_score = sim
score = _cosine_similarity(call_embedding, inc_embedding) best_inc = inc
if score > best_score:
best_score = score
best_inc = inc
if best_inc and best_score >= settings.embedding_similarity_threshold:
matched_incident = best_inc
logger.info(
f"Correlator slow-path: call {call_id} → incident {best_inc['incident_id']} "
f"(similarity={best_score:.3f})"
)
# ---------------------------------------------------------------- if best_inc and best_score >= settings.embedding_similarity_threshold:
# Update existing or create new inc_coords = best_inc.get("location_coords")
# ---------------------------------------------------------------- if coords and inc_coords:
dist_km = _haversine_km(
coords["lat"], coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
if dist_km <= settings.location_proximity_km * 4:
matched_incident = best_inc
logger.info(
f"Correlator slow-path: call {call_id}{best_inc['incident_id']} "
f"(sim={best_score:.3f}, dist={dist_km:.2f}km)"
)
# No coords available → slow path alone is not enough; skip
# ── Update existing or create new ────────────────────────────────────────
if matched_incident: if matched_incident:
incident_id = matched_incident["incident_id"] incident_id = matched_incident["incident_id"]
await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, location_coords, now) await _update_incident(
else: matched_incident, call_id, talkgroup_id, system_id, tags,
incident_id = await _create_incident( location, location_coords, call_units, call_vehicles, call_embedding, now,
call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, location_coords, now
) )
elif incident_type:
incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
tags, location, location_coords,
call_units, call_vehicles, call_embedding, call_severity, now,
)
else:
# Unclassified call, no talkgroup match found — nothing to do
return None
# Back-link the call
await fstore.doc_set("calls", call_id, {"incident_id": incident_id}) await fstore.doc_set("calls", call_id, {"incident_id": incident_id})
return incident_id return incident_id
# ─────────────────────────────────────────────────────────────────────────────
# Internal helpers
# ─────────────────────────────────────────────────────────────────────────────
def _within_window(inc: dict, now: datetime, window: timedelta) -> bool:
try:
dt = datetime.fromisoformat(
str(inc.get("updated_at", "")).replace("Z", "+00:00")
)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return (now - dt) <= window
except Exception:
return False
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2
)
return R * 2 * math.asin(math.sqrt(a))
def _disambiguate(
candidates: list[dict],
call_units: list[str],
call_vehicles: list[str],
call_coords: Optional[dict],
call_embedding: Optional[list],
) -> dict:
"""
Score each talkgroup-matched candidate and return the best.
Signals (descending weight): unit overlap, vehicle overlap,
location proximity, embedding similarity.
Ties broken by most-recently-updated.
"""
best = candidates[0]
best_score = -1.0
for inc in candidates:
score = 0.0
inc_units = set(inc.get("units") or [])
if inc_units and call_units and any(u in inc_units for u in call_units):
score += 10.0
inc_vehicles = set(inc.get("vehicles") or [])
if inc_vehicles and call_vehicles and any(v in inc_vehicles for v in call_vehicles):
score += 8.0
inc_coords = inc.get("location_coords")
if inc_coords and call_coords:
dist = _haversine_km(
call_coords["lat"], call_coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
score += 6.0 if dist < 1.0 else (2.0 if dist < 5.0 else 0.0)
inc_emb = inc.get("embedding")
if inc_emb and call_embedding:
score += _cosine_similarity(call_embedding, inc_emb) * 3.0
if score > best_score or (
score == best_score
and inc.get("updated_at", "") > best.get("updated_at", "")
):
best = inc
best_score = score
return best
async def _update_incident( async def _update_incident(
inc: dict, inc: dict,
call_id: str, call_id: str,
@@ -133,43 +246,48 @@ async def _update_incident(
tags: list[str], tags: list[str],
location: Optional[str], location: Optional[str],
location_coords: Optional[dict], location_coords: Optional[dict],
call_units: list[str],
call_vehicles: list[str],
call_embedding: Optional[list],
now: datetime, now: datetime,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
call_ids = inc.get("call_ids", []) call_ids = list(inc.get("call_ids") or [])
if call_id not in call_ids: if call_id not in call_ids:
call_ids.append(call_id) call_ids.append(call_id)
talkgroup_ids = inc.get("talkgroup_ids", []) talkgroup_ids = list(inc.get("talkgroup_ids") or [])
if talkgroup_id is not None and str(talkgroup_id) not in talkgroup_ids: if talkgroup_id is not None and str(talkgroup_id) not in talkgroup_ids:
talkgroup_ids.append(str(talkgroup_id)) talkgroup_ids.append(str(talkgroup_id))
system_ids = inc.get("system_ids", []) system_ids = list(inc.get("system_ids") or [])
if system_id and system_id not in system_ids: if system_id and system_id not in system_ids:
system_ids.append(system_id) system_ids.append(system_id)
# Merge tags (deduplicated) merged_tags = list(dict.fromkeys((inc.get("tags") or []) + tags))
merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags)) merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles))
# Location — append to mentions; update display location if new one is non-null location_mentions = list(inc.get("location_mentions") or [])
location_mentions = inc.get("location_mentions", [])
if location and location not in location_mentions: if location and location not in location_mentions:
location_mentions.append(location) location_mentions.append(location)
best_location = location if location else inc.get("location")
best_coords = location_coords if location_coords else inc.get("location_coords")
# Update centroid embedding best_location = location or inc.get("location")
embedding_updates = await _merge_embedding(inc, call_id) best_coords = location_coords or inc.get("location_coords")
updates = { embedding_updates = _merge_embedding_vecs(inc, call_embedding) if call_embedding else {}
"call_ids": call_ids,
"talkgroup_ids": talkgroup_ids, updates: dict = {
"system_ids": system_ids, "call_ids": call_ids,
"tags": merged_tags, "talkgroup_ids": talkgroup_ids,
"system_ids": system_ids,
"tags": merged_tags,
"units": merged_units,
"vehicles": merged_vehicles,
"location_mentions": location_mentions, "location_mentions": location_mentions,
"updated_at": now.isoformat(), "updated_at": now.isoformat(),
"summary_stale": True, "summary_stale": True,
**embedding_updates, **embedding_updates,
} }
if best_location: if best_location:
@@ -178,7 +296,7 @@ async def _update_incident(
updates["location_coords"] = best_coords updates["location_coords"] = best_coords
await fstore.doc_set("incidents", incident_id, updates) await fstore.doc_set("incidents", incident_id, updates)
logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}") logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
async def _create_incident( async def _create_incident(
@@ -190,72 +308,64 @@ async def _create_incident(
tags: list[str], tags: list[str],
location: Optional[str], location: Optional[str],
location_coords: Optional[dict], location_coords: Optional[dict],
call_units: list[str],
call_vehicles: list[str],
call_embedding: Optional[list],
call_severity: str,
now: datetime, now: datetime,
) -> str: ) -> str:
incident_id = str(uuid.uuid4()) incident_id = str(uuid.uuid4())
tg_label = talkgroup_name or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") tg_label = (
talkgroup_name
call_doc = await fstore.doc_get("calls", call_id) or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
call_embedding = call_doc.get("embedding") if call_doc else None )
call_vehicles = call_doc.get("vehicles", []) if call_doc else []
call_units = call_doc.get("units", []) if call_doc else []
call_severity = call_doc.get("severity", "unknown") if call_doc else "unknown"
doc = { doc = {
"incident_id": incident_id, "incident_id": incident_id,
"title": f"Auto: {incident_type.title()}{tg_label}", "title": f"{incident_type.title()}{tg_label}",
"type": incident_type, "type": incident_type,
"status": "active", "status": "active",
"location": location, "location": location,
"location_coords": location_coords, "location_coords": location_coords,
"location_mentions": [location] if location else [], "location_mentions": [location] if location else [],
"call_ids": [call_id], "call_ids": [call_id],
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
"system_ids": [system_id] if system_id else [], "system_ids": [system_id] if system_id else [],
"tags": tags, "tags": tags + ["auto-generated"],
"vehicles": call_vehicles, "units": call_units,
"units": call_units, "vehicles": call_vehicles,
"severity": call_severity, "severity": call_severity,
"summary": None, "summary": None,
"summary_stale": True, "summary_stale": True,
"summary_last_run": None, "summary_last_run": None,
"embedding": call_embedding, "embedding": call_embedding,
"embedding_count": 1 if call_embedding else 0, "embedding_count": 1 if call_embedding else 0,
"started_at": now.isoformat(), "started_at": now.isoformat(),
"updated_at": now.isoformat(), "updated_at": now.isoformat(),
} }
await fstore.doc_set("incidents", incident_id, doc, merge=False) await fstore.doc_set("incidents", incident_id, doc, merge=False)
logger.info(f"Correlator: created incident {incident_id} for call {call_id} ({incident_type})") logger.info(
f"Correlator: created incident {incident_id} for call {call_id} ({incident_type})"
)
return incident_id return incident_id
async def _merge_embedding(inc: dict, call_id: str) -> dict: def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
""" """Online running-average centroid: new_avg = (old_avg * n + new_vec) / (n+1)"""
Update the incident's centroid embedding with the new call's embedding.
Uses an online running-average: new_avg = (old_avg * n + new_vec) / (n + 1)
"""
import numpy as np import numpy as np
n = inc.get("embedding_count") or 0
call_doc = await fstore.doc_get("calls", call_id)
call_embedding = call_doc.get("embedding") if call_doc else None
if not call_embedding:
return {}
n = inc.get("embedding_count", 0)
old_embedding = inc.get("embedding") old_embedding = inc.get("embedding")
if old_embedding and n > 0: if old_embedding and n > 0:
old_vec = np.array(old_embedding, dtype=float) old_vec = np.array(old_embedding, dtype=float)
new_vec = np.array(call_embedding, dtype=float) new_vec = np.array(call_embedding, dtype=float)
updated = ((old_vec * n) + new_vec) / (n + 1) updated = ((old_vec * n) + new_vec) / (n + 1)
return {"embedding": updated.tolist(), "embedding_count": n + 1} return {"embedding": updated.tolist(), "embedding_count": n + 1}
else: return {"embedding": call_embedding, "embedding_count": 1}
return {"embedding": call_embedding, "embedding_count": 1}
def _cosine_similarity(a: list[float], b: list[float]) -> float: def _cosine_similarity(a: list[float], b: list[float]) -> float:
import numpy as np import numpy as np
va, vb = np.array(a, dtype=float), np.array(b, dtype=float) va, vb = np.array(a, dtype=float), np.array(b, dtype=float)
norm_a, norm_b = np.linalg.norm(va), np.linalg.norm(vb) norm_a, norm_b = np.linalg.norm(va), np.linalg.norm(vb)
if norm_a == 0 or norm_b == 0: if norm_a == 0 or norm_b == 0:
return 0.0 return 0.0
+8 -5
View File
@@ -22,6 +22,7 @@ Schema:
"vehicles": [vehicle descriptions mentioned, e.g. "Hyundai Tucson", "black sedan"], "vehicles": [vehicle descriptions mentioned, e.g. "Hyundai Tucson", "black sedan"],
"units": [unit IDs or officer numbers mentioned, e.g. "Unit 511", "Car 4"], "units": [unit IDs or officer numbers mentioned, e.g. "Unit 511", "Car 4"],
"severity": one of "minor" | "moderate" | "major" | "unknown", "severity": one of "minor" | "moderate" | "major" | "unknown",
"resolved": true if this call explicitly signals the incident is over ("Code 4", "in custody", "all clear", "fire out", "patient transported", "GOA", "scene clear", "10-42", "negative contact", "clear the scene"), false otherwise,
"transcript_corrected": "corrected full transcript string, or null if no corrections needed" "transcript_corrected": "corrected full transcript string, or null if no corrections needed"
}} }}
@@ -48,14 +49,15 @@ async def extract_tags(
system_id: Optional[str] = None, system_id: Optional[str] = None,
segments: Optional[list[dict]] = None, segments: Optional[list[dict]] = None,
node_id: Optional[str] = None, node_id: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict]]: ) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]:
""" """
Extract incident tags, type, location, and corrected transcript via GPT-4o mini. Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini.
Geocodes the extracted location string via Nominatim using the node's position as bias. Geocodes the extracted location string via Nominatim using the node's position as bias.
Returns: Returns:
(tags, primary_type, location_str, location_coords) (tags, primary_type, location_str, location_coords, resolved)
where location_coords is {"lat": float, "lng": float} or None. where location_coords is {"lat": float, "lng": float} or None,
and resolved is True when the transcript signals incident closure.
Side-effect: updates calls/{call_id} in Firestore with tags, location, Side-effect: updates calls/{call_id} in Firestore with tags, location,
location_coords, vehicles, units, severity, transcript_corrected; also stores embedding. location_coords, vehicles, units, severity, transcript_corrected; also stores embedding.
@@ -70,6 +72,7 @@ async def extract_tags(
vehicles: list[str] = result.get("vehicles") or [] vehicles: list[str] = result.get("vehicles") or []
units: list[str] = result.get("units") or [] units: list[str] = result.get("units") or []
severity: str = result.get("severity") or "unknown" severity: str = result.get("severity") or "unknown"
resolved: bool = bool(result.get("resolved", False))
transcript_corrected: Optional[str] = result.get("transcript_corrected") or None transcript_corrected: Optional[str] = result.get("transcript_corrected") or None
if incident_type in ("unknown", "other", ""): if incident_type in ("unknown", "other", ""):
@@ -112,7 +115,7 @@ async def extract_tags(
f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, " f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, "
f"corrected={transcript_corrected is not None}" f"corrected={transcript_corrected is not None}"
) )
return tags, incident_type, location, location_coords return tags, incident_type, location, location_coords, resolved
async def _geocode_location( async def _geocode_location(
+42 -7
View File
@@ -1,15 +1,14 @@
""" """
Background incident summary loop. Background incident summary loop.
Runs every SUMMARY_INTERVAL_MINUTES. Finds all active incidents with Runs every SUMMARY_INTERVAL_MINUTES. Two passes per tick:
summary_stale=True, fetches all their call transcripts, and calls Gemini 1. Summary pass — find stale incidents (summary_stale=True) and regenerate summaries.
once per incident to produce a concise factual summary. 2. Stale sweep — auto-resolve incidents with no new calls for incident_auto_resolve_minutes.
This is effectively "time since last call" because updated_at is stamped on every
By batching this way: Gemini is never called per-call — only periodically new linked call.
and only for incidents that have actually changed since the last run.
""" """
import asyncio import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -23,6 +22,7 @@ async def summarizer_loop() -> None:
await asyncio.sleep(interval) await asyncio.sleep(interval)
try: try:
await _run_summary_pass() await _run_summary_pass()
await _resolve_stale_incidents()
except Exception as e: except Exception as e:
logger.error(f"Summarizer pass failed: {e}") logger.error(f"Summarizer pass failed: {e}")
@@ -74,6 +74,41 @@ async def _summarize_incident(inc: dict) -> None:
await fstore.doc_set("incidents", incident_id, updates) await fstore.doc_set("incidents", incident_id, updates)
async def _resolve_stale_incidents() -> None:
"""Auto-resolve active incidents that have had no new calls for incident_auto_resolve_minutes."""
all_active = await fstore.collection_list("incidents", status="active")
if not all_active:
return
now = datetime.now(timezone.utc)
cutoff = timedelta(minutes=settings.incident_auto_resolve_minutes)
count = 0
for inc in all_active:
incident_id = inc.get("incident_id")
if not incident_id:
continue
try:
updated_dt = datetime.fromisoformat(
str(inc.get("updated_at", "")).replace("Z", "+00:00")
)
if updated_dt.tzinfo is None:
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
idle_minutes = (now - updated_dt).total_seconds() / 60
if idle_minutes > settings.incident_auto_resolve_minutes:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
logger.info(
f"Auto-resolved stale incident {incident_id} "
f"(idle {idle_minutes:.0f}m)"
)
count += 1
except Exception as e:
logger.warning(f"Stale sweep error for {incident_id}: {e}")
if count:
logger.info(f"Stale sweep: resolved {count} incident(s)")
def _sync_summarize(inc: dict, transcripts: list[str]) -> Optional[str]: def _sync_summarize(inc: dict, transcripts: list[str]) -> Optional[str]:
from app.config import settings from app.config import settings
from openai import OpenAI from openai import OpenAI
+34 -27
View File
@@ -95,24 +95,27 @@ async def _run_extraction_pipeline(
"""Run steps 2-4 of the intelligence pipeline using an existing transcript.""" """Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter from app.internal import intelligence, incident_correlator, alerter
tags, incident_type, location, location_coords = await intelligence.extract_tags( tags, incident_type, location, location_coords, resolved = await intelligence.extract_tags(
call_id, transcript, talkgroup_name, call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
node_id=node_id, node_id=node_id,
) )
if incident_type: incident_id = await incident_correlator.correlate_call(
await incident_correlator.correlate_call( call_id=call_id,
call_id=call_id, node_id=node_id,
node_id=node_id, system_id=system_id,
system_id=system_id, talkgroup_id=talkgroup_id,
talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
talkgroup_name=talkgroup_name, tags=tags,
tags=tags, incident_type=incident_type,
incident_type=incident_type, location=location,
location=location, location_coords=location_coords,
location_coords=location_coords, )
)
if resolved and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
await alerter.check_and_dispatch( await alerter.check_and_dispatch(
call_id=call_id, call_id=call_id,
@@ -153,26 +156,30 @@ async def _run_intelligence_pipeline(
incident_type: Optional[str] = None incident_type: Optional[str] = None
location: Optional[str] = None location: Optional[str] = None
location_coords: Optional[dict] = None location_coords: Optional[dict] = None
resolved: bool = False
if transcript: if transcript:
tags, incident_type, location, location_coords = await intelligence.extract_tags( tags, incident_type, location, location_coords, resolved = await intelligence.extract_tags(
call_id, transcript, talkgroup_name, call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
node_id=node_id, node_id=node_id,
) )
# Step 3: Incident correlation # Step 3: Incident correlation (always runs — unclassified calls can still link via talkgroup)
if incident_type: incident_id = await incident_correlator.correlate_call(
await incident_correlator.correlate_call( call_id=call_id,
call_id=call_id, node_id=node_id,
node_id=node_id, system_id=system_id,
system_id=system_id, talkgroup_id=talkgroup_id,
talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
talkgroup_name=talkgroup_name, tags=tags,
tags=tags, incident_type=incident_type,
incident_type=incident_type, location=location,
location=location, location_coords=location_coords,
location_coords=location_coords, )
)
if resolved and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript) # Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
await alerter.check_and_dispatch( await alerter.check_and_dispatch(
+180 -105
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useIncident } from "@/lib/useIncidents"; import { useIncident } from "@/lib/useIncidents";
@@ -10,6 +11,8 @@ import { CallRow } from "@/components/CallRow";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300", fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300", police: "bg-blue-900 text-blue-300",
@@ -37,18 +40,21 @@ function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
); );
} }
type Tab = "summary" | "units" | "details";
export default function IncidentDetailPage() { export default function IncidentDetailPage() {
const params = useParams(); const params = useParams();
const id = params.id as string; const id = params.id as string;
const router = useRouter(); const router = useRouter();
const { incident, loading } = useIncident(id); const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id); const { calls, loading: callsLoading } = useCallsByIncident(id);
const { systems } = useSystems(); const { systems } = useSystems();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [tab, setTab] = useState<Tab>("summary");
const [summarizing, setSummarizing] = useState(false); const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false); const [resolving, setResolving] = useState(false);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
@@ -66,15 +72,13 @@ export default function IncidentDetailPage() {
finally { setSummarizing(false); } finally { setSummarizing(false); }
} }
if (loading) { if (loading) return <p className="text-gray-500 text-sm font-mono p-6">Loading</p>;
return <p className="text-gray-500 text-sm font-mono p-6">Loading</p>; if (!incident) return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
}
if (!incident) { const displayTags = incident.tags.filter((t) => t !== "auto-generated");
return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
}
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{/* Back */} {/* Back */}
<button <button
onClick={() => router.back()} onClick={() => router.back()}
@@ -114,101 +118,172 @@ export default function IncidentDetailPage() {
)} )}
</div> </div>
{/* Summary */} {/* Tags */}
<section> {displayTags.length > 0 && (
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2"> <div className="flex flex-wrap gap-1">
Summary {displayTags.map((t) => (
</h2> <span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
{incident.summary ? ( {t}
<p className="text-sm text-gray-300 bg-gray-900 border border-gray-800 rounded-lg p-4 leading-relaxed"> </span>
{incident.summary} ))}
</p> </div>
) : ( )}
<p className="text-sm text-gray-600 font-mono italic">
No summary yet.{" "} {/* Map */}
{isAdmin && ( {incident.location_coords && (
<div style={{ height: "280px" }}>
<MapView nodes={[]} activeCalls={[]} incidents={[incident]} />
</div>
)}
{/* Two-panel body */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* Left: tabs — Summary / Units / Details */}
<div className="lg:col-span-2 bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col">
{/* Tab bar */}
<div className="flex border-b border-gray-800 shrink-0">
{(["summary", "units", "details"] as Tab[]).map((t) => (
<button <button
onClick={handleSummarize} key={t}
disabled={summarizing} onClick={() => setTab(t)}
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors" className={`flex-1 px-4 py-2.5 text-xs font-mono capitalize transition-colors ${
tab === t
? "text-white border-b-2 border-indigo-500 bg-gray-800/40"
: "text-gray-500 hover:text-gray-300"
}`}
> >
Generate now {t}
</button> </button>
)} ))}
</p>
)}
</section>
{/* Tags + Location */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Tags</h2>
{incident.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{incident.tags.map((t) => (
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
{t}
</span>
))}
</div>
) : (
<p className="text-gray-600 text-sm font-mono">No tags.</p>
)}
</section>
{incident.location && (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Location</h2>
<p className="text-sm text-gray-300 font-mono">{incident.location}</p>
</section>
)}
</div>
{/* Metadata */}
<div className="text-xs text-gray-600 font-mono flex flex-wrap gap-x-6 gap-y-1">
<span>Started: <span className="text-gray-400">{new Date(incident.started_at).toLocaleString()}</span></span>
<span>Updated: <span className="text-gray-400">{new Date(incident.updated_at).toLocaleString()}</span></span>
<span>Calls: <span className="text-gray-400">{incident.call_ids.length}</span></span>
{incident.talkgroup_ids?.length > 0 && (
<span>Talkgroups: <span className="text-gray-400">{incident.talkgroup_ids.join(", ")}</span></span>
)}
</div>
{/* Calls */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Calls</h2>
{callsLoading ? (
<p className="text-gray-600 text-sm font-mono">Loading calls</p>
) : calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{calls.map((c) => (
<CallRow
key={c.call_id}
call={c}
systemName={systemMap[c.system_id ?? ""]?.name}
isAdmin={isAdmin}
/>
))}
</tbody>
</table>
</div> </div>
)}
</section> {/* Tab content */}
<div className="p-4 flex-1 overflow-y-auto">
{tab === "summary" && (
incident.summary ? (
<p className="text-sm text-gray-300 leading-relaxed">{incident.summary}</p>
) : (
<p className="text-sm text-gray-600 font-mono italic">
No summary yet.{" "}
{isAdmin && (
<button
onClick={handleSummarize}
disabled={summarizing}
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
>
Generate now
</button>
)}
</p>
)
)}
{tab === "units" && (
<div className="space-y-4">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Units</p>
{incident.units?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{incident.units.map((u) => (
<span key={u} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{u}</span>
))}
</div>
) : (
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
)}
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Vehicles</p>
{incident.vehicles?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{incident.vehicles.map((v) => (
<span key={v} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{v}</span>
))}
</div>
) : (
<p className="text-xs text-gray-600 font-mono italic">None extracted.</p>
)}
</div>
</div>
)}
{tab === "details" && (
<div className="space-y-3 text-xs font-mono">
{incident.location && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Location</p>
<p className="text-gray-300">{incident.location}</p>
</div>
)}
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Started</p>
<p className="text-gray-300">{new Date(incident.started_at).toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Last activity</p>
<p className="text-gray-300">{new Date(incident.updated_at).toLocaleString()}</p>
</div>
{incident.talkgroup_ids?.length > 0 && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Talkgroups</p>
<p className="text-gray-300">{incident.talkgroup_ids.join(", ")}</p>
</div>
)}
{incident.severity && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Severity</p>
<p className="text-gray-300 capitalize">{incident.severity}</p>
</div>
)}
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1">Total calls</p>
<p className="text-gray-300">{incident.call_ids.length}</p>
</div>
</div>
)}
</div>
</div>
{/* Right: calls */}
<div className="lg:col-span-3">
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">
Calls ({calls.length})
</p>
{callsLoading ? (
<p className="text-gray-600 text-sm font-mono">Loading</p>
) : calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{calls.map((c) => (
<CallRow
key={c.call_id}
call={c}
systemName={systemMap[c.system_id ?? ""]?.name}
isAdmin={isAdmin}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -121,7 +121,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
{call.incident_id && ( {call.incident_id && (
<p className="text-xs font-mono text-indigo-400"> <p className="text-xs font-mono text-indigo-400">
Incident:{" "} Incident:{" "}
<a href="/incidents" className="underline hover:text-indigo-300"> <a href={`/incidents/${call.incident_id}`} className="underline hover:text-indigo-300">
{call.incident_id.slice(0, 8)} {call.incident_id.slice(0, 8)}
</a> </a>
</p> </p>
+13 -2
View File
@@ -67,12 +67,23 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
); );
const center: [number, number] = const center: [number, number] =
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35]; nodes.length > 0
? [nodes[0].lat, nodes[0].lon]
: plottedIncidents.length > 0
? plottedIncidents[0].pos
: [39.5, -98.35];
const zoom =
nodes.length > 0
? 10
: plottedIncidents.length > 0
? 14
: 4;
return ( return (
<MapContainer <MapContainer
center={center} center={center}
zoom={nodes.length > 0 ? 10 : 4} zoom={zoom}
className="w-full h-full rounded-lg" className="w-full h-full rounded-lg"
style={{ background: "#111827" }} style={{ background: "#111827" }}
> >
+3
View File
@@ -55,6 +55,9 @@ export interface IncidentRecord {
call_ids: string[]; call_ids: string[];
system_ids: string[]; system_ids: string[];
talkgroup_ids: string[]; talkgroup_ids: string[];
units: string[];
vehicles: string[];
severity: string | null;
started_at: string; started_at: string;
updated_at: string; updated_at: string;
summary: string | null; summary: string | null;