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:
@@ -23,8 +23,10 @@ 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
|
||||||
|
|||||||
@@ -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 1–3,
|
||||||
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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
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:
|
||||||
# ----------------------------------------------------------------
|
|
||||||
if not matched_incident and active:
|
|
||||||
call_doc = await fstore.doc_get("calls", call_id)
|
|
||||||
call_embedding = call_doc.get("embedding") if call_doc else None
|
|
||||||
if call_embedding:
|
|
||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
best_inc = None
|
best_inc: Optional[dict] = None
|
||||||
for inc in active:
|
for inc in recent:
|
||||||
|
if inc.get("type") != incident_type:
|
||||||
|
continue
|
||||||
inc_embedding = inc.get("embedding")
|
inc_embedding = inc.get("embedding")
|
||||||
if not inc_embedding:
|
if not inc_embedding:
|
||||||
continue
|
continue
|
||||||
score = _cosine_similarity(call_embedding, inc_embedding)
|
sim = _cosine_similarity(call_embedding, inc_embedding)
|
||||||
if score > best_score:
|
if sim > best_score:
|
||||||
best_score = score
|
best_score = sim
|
||||||
best_inc = inc
|
best_inc = inc
|
||||||
|
|
||||||
if best_inc and best_score >= settings.embedding_similarity_threshold:
|
if best_inc and best_score >= settings.embedding_similarity_threshold:
|
||||||
|
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
|
matched_incident = best_inc
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Correlator slow-path: call {call_id} → incident {best_inc['incident_id']} "
|
f"Correlator slow-path: call {call_id} → {best_inc['incident_id']} "
|
||||||
f"(similarity={best_score:.3f})"
|
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 ────────────────────────────────────────
|
||||||
# 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,40 +246,45 @@ 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 {}
|
||||||
|
|
||||||
|
updates: dict = {
|
||||||
"call_ids": call_ids,
|
"call_ids": call_ids,
|
||||||
"talkgroup_ids": talkgroup_ids,
|
"talkgroup_ids": talkgroup_ids,
|
||||||
"system_ids": system_ids,
|
"system_ids": system_ids,
|
||||||
"tags": merged_tags,
|
"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,
|
||||||
@@ -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,20 +308,21 @@ 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,
|
||||||
@@ -212,9 +331,9 @@ async def _create_incident(
|
|||||||
"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,
|
||||||
@@ -225,31 +344,22 @@ async def _create_incident(
|
|||||||
"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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -95,14 +95,13 @@ 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,
|
||||||
@@ -114,6 +113,10 @@ async def _run_extraction_pipeline(
|
|||||||
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,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
@@ -153,16 +156,16 @@ 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,
|
||||||
@@ -174,6 +177,10 @@ async def _run_intelligence_pipeline(
|
|||||||
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(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
|
|||||||
@@ -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,6 +40,8 @@ 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;
|
||||||
@@ -47,6 +52,7 @@ export default function IncidentDetailPage() {
|
|||||||
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);
|
||||||
|
|
||||||
@@ -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,15 +118,51 @@ 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map */}
|
||||||
|
{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
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
<p className="text-sm text-gray-600 font-mono italic">
|
||||||
No summary yet.{" "}
|
No summary yet.{" "}
|
||||||
@@ -136,53 +176,86 @@ export default function IncidentDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Tags + Location */}
|
{tab === "units" && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-4">
|
||||||
<section>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Tags</h2>
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Units</p>
|
||||||
{incident.tags.length > 0 ? (
|
{incident.units?.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{incident.tags.map((t) => (
|
{incident.units.map((u) => (
|
||||||
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
<span key={u} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded font-mono">{u}</span>
|
||||||
{t}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 text-sm font-mono">No tags.</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
|
{tab === "details" && (
|
||||||
|
<div className="space-y-3 text-xs font-mono">
|
||||||
{incident.location && (
|
{incident.location && (
|
||||||
<section>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Location</h2>
|
<p className="text-gray-500 uppercase tracking-wider mb-1">Location</p>
|
||||||
<p className="text-sm text-gray-300 font-mono">{incident.location}</p>
|
<p className="text-gray-300">{incident.location}</p>
|
||||||
</section>
|
</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>
|
</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 && (
|
{incident.talkgroup_ids?.length > 0 && (
|
||||||
<span>Talkgroups: <span className="text-gray-400">{incident.talkgroup_ids.join(", ")}</span></span>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Calls */}
|
{/* Right: calls */}
|
||||||
<section>
|
<div className="lg:col-span-3">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Calls</h2>
|
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">
|
||||||
|
Calls ({calls.length})
|
||||||
|
</p>
|
||||||
{callsLoading ? (
|
{callsLoading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading calls…</p>
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||||
) : calls.length === 0 ? (
|
) : calls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
@@ -208,7 +281,9 @@ export default function IncidentDetailPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user