diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index fa7b138..a759d47 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -22,9 +22,11 @@ class Settings(BaseSettings): # Gemini (intelligence extraction, embeddings, incident summaries) gemini_api_key: Optional[str] = None - summary_interval_minutes: int = 2 # how often the summary loop runs - correlation_window_hours: int = 1 # how far back to look for matching incidents - embedding_similarity_threshold: float = 0.82 # cosine similarity cutoff for slow-path match + summary_interval_minutes: int = 2 # how often the summary loop runs + correlation_window_hours: int = 2 # slow/location path: max hours since last call + 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 service_key: Optional[str] = None diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 3e5d476..e790b48 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -1,32 +1,29 @@ """ Hybrid incident correlation engine. -Fast path — deterministic: - Same system_id + talkgroup_id + incident_type within the correlation window. - This is the primary signal for P25 — dispatch assigns one talkgroup per incident. +Matching priority (in order): -Slow path — embedding similarity: - If fast path finds nothing, compare the new call's embedding against the - centroid embedding of each open incident (running average of its call embeddings). - Match if cosine similarity >= threshold. +1. Fast path — talkgroup + system match (any incident type, no time limit) + Active-status gate is sufficient. If multiple active incidents share the same + talkgroup (e.g. busy shared channel), disambiguate by: + 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): - Unmatched calls are re-checked periodically, catching mutual-aid cases where - a second talkgroup gets pulled into an existing incident. +2. Location path — geocoded coords within `location_proximity_km` (time-limited) + Primary mutual-aid signal: EMS + police at the same scene. -Incident document schema additions: - talkgroup_ids: list[str] — all talkgroups that have contributed calls - location_mentions: list[str] — all location strings from calls (newest last) - location: str|None — best known location (newest non-null for now; - TODO: replace with Maps geocoding bbox comparison) - vehicles: list[str] — deduplicated vehicle list across all calls - units: list[str] — deduplicated unit list across all calls - 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 +3. Slow path — embedding cosine similarity (time-limited, same type only) + Requires similarity >= threshold AND location within 4× proximity radius. + Never fires alone — location corroboration is mandatory. + +Calls with no incident_type skip new-incident creation but still run paths 1–3, +so unclassified calls (short transport end, "en route", etc.) can link to an +existing incident via talkgroup match. """ +import math import uuid from datetime import datetime, timezone, timedelta from typing import Optional @@ -35,6 +32,10 @@ from app.internal import firestore as fstore from app.config import settings +# ───────────────────────────────────────────────────────────────────────────── +# Public entry point +# ───────────────────────────────────────────────────────────────────────────── + async def correlate_call( call_id: str, node_id: str, @@ -48,83 +49,195 @@ async def correlate_call( ) -> Optional[str]: """ Link call_id to an existing incident or create a new one. - - Returns the incident_id that was linked, or None if skipped. + Returns the incident_id, or None if skipped (no type and no talkgroup match). """ - if not incident_type: - return None - - now = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) window = timedelta(hours=settings.correlation_window_hours) - # Fetch all active incidents of the same type - candidates = await fstore.collection_list("incidents", status="active", type=incident_type) + # Fetch all active incidents cross-type (mutual aid needs this) + 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 - active = [] - for inc in candidates: - updated_raw = inc.get("updated_at", "") - try: - updated_dt = datetime.fromisoformat(str(updated_raw).replace("Z", "+00:00")) - if updated_dt.tzinfo is None: - updated_dt = updated_dt.replace(tzinfo=timezone.utc) - if (now - updated_dt) <= window: - active.append(inc) - except Exception: - continue + # Fetch call doc once — reused for disambiguation, embedding merge, unit accumulation + call_doc = await fstore.doc_get("calls", call_id) or {} + call_embedding: Optional[list] = call_doc.get("embedding") + call_units: list[str] = call_doc.get("units") or [] + call_vehicles: list[str] = call_doc.get("vehicles") or [] + call_severity: str = call_doc.get("severity") or "unknown" + # Use passed coords first (freshly geocoded), fall back to what's on the call doc + coords: Optional[dict] = location_coords or call_doc.get("location_coords") - # ---------------------------------------------------------------- - # Fast path — talkgroup match - # ---------------------------------------------------------------- matched_incident: Optional[dict] = None + + # ── 1. Fast path: talkgroup match (any type, no time limit) ────────────── if talkgroup_id is not None and system_id: - tg_str = str(talkgroup_id) - for inc in active: - if system_id in (inc.get("system_ids") or []) and tg_str in (inc.get("talkgroup_ids") or []): + tg_str = str(talkgroup_id) + tg_matches = [ + 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 - 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 - # ---------------------------------------------------------------- - # Slow path — embedding similarity - # ---------------------------------------------------------------- - 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_inc = None - for inc in active: - inc_embedding = inc.get("embedding") - if not inc_embedding: - continue - score = _cosine_similarity(call_embedding, inc_embedding) - 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})" - ) + # ── 3. Slow path: embedding + location corroboration (time-limited, same type) ── + if not matched_incident and call_embedding and incident_type: + best_score = 0.0 + best_inc: Optional[dict] = None + for inc in recent: + if inc.get("type") != incident_type: + continue + inc_embedding = inc.get("embedding") + if not inc_embedding: + continue + sim = _cosine_similarity(call_embedding, inc_embedding) + if sim > best_score: + best_score = sim + best_inc = inc - # ---------------------------------------------------------------- - # Update existing or create new - # ---------------------------------------------------------------- + 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 + 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: incident_id = matched_incident["incident_id"] - await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, location_coords, now) - else: - incident_id = await _create_incident( - call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, location_coords, now + await _update_incident( + matched_incident, call_id, talkgroup_id, system_id, tags, + location, location_coords, call_units, call_vehicles, call_embedding, 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}) 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( inc: dict, call_id: str, @@ -133,43 +246,48 @@ async def _update_incident( tags: list[str], location: Optional[str], location_coords: Optional[dict], + call_units: list[str], + call_vehicles: list[str], + call_embedding: Optional[list], now: datetime, ) -> None: 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: 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: 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: system_ids.append(system_id) - # Merge tags (deduplicated) - merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags)) + merged_tags = list(dict.fromkeys((inc.get("tags") or []) + 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 = inc.get("location_mentions", []) + location_mentions = list(inc.get("location_mentions") or []) if location and location not in location_mentions: 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 - embedding_updates = await _merge_embedding(inc, call_id) + best_location = location or inc.get("location") + best_coords = location_coords or inc.get("location_coords") - updates = { - "call_ids": call_ids, - "talkgroup_ids": talkgroup_ids, - "system_ids": system_ids, - "tags": merged_tags, + embedding_updates = _merge_embedding_vecs(inc, call_embedding) if call_embedding else {} + + updates: dict = { + "call_ids": call_ids, + "talkgroup_ids": talkgroup_ids, + "system_ids": system_ids, + "tags": merged_tags, + "units": merged_units, + "vehicles": merged_vehicles, "location_mentions": location_mentions, - "updated_at": now.isoformat(), - "summary_stale": True, + "updated_at": now.isoformat(), + "summary_stale": True, **embedding_updates, } if best_location: @@ -178,7 +296,7 @@ async def _update_incident( updates["location_coords"] = best_coords 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( @@ -190,72 +308,64 @@ async def _create_incident( tags: list[str], location: Optional[str], location_coords: Optional[dict], + call_units: list[str], + call_vehicles: list[str], + call_embedding: Optional[list], + call_severity: str, now: datetime, ) -> str: incident_id = str(uuid.uuid4()) - tg_label = talkgroup_name or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") - - call_doc = await fstore.doc_get("calls", call_id) - 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" + tg_label = ( + talkgroup_name + or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup") + ) doc = { - "incident_id": incident_id, - "title": f"Auto: {incident_type.title()} — {tg_label}", - "type": incident_type, - "status": "active", - "location": location, - "location_coords": location_coords, + "incident_id": incident_id, + "title": f"{incident_type.title()} — {tg_label}", + "type": incident_type, + "status": "active", + "location": location, + "location_coords": location_coords, "location_mentions": [location] if location else [], - "call_ids": [call_id], - "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], - "system_ids": [system_id] if system_id else [], - "tags": tags, - "vehicles": call_vehicles, - "units": call_units, - "severity": call_severity, - "summary": None, - "summary_stale": True, - "summary_last_run": None, - "embedding": call_embedding, - "embedding_count": 1 if call_embedding else 0, - "started_at": now.isoformat(), - "updated_at": now.isoformat(), + "call_ids": [call_id], + "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], + "system_ids": [system_id] if system_id else [], + "tags": tags + ["auto-generated"], + "units": call_units, + "vehicles": call_vehicles, + "severity": call_severity, + "summary": None, + "summary_stale": True, + "summary_last_run": None, + "embedding": call_embedding, + "embedding_count": 1 if call_embedding else 0, + "started_at": now.isoformat(), + "updated_at": now.isoformat(), } 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 -async def _merge_embedding(inc: dict, call_id: str) -> dict: - """ - 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) - """ +def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict: + """Online running-average centroid: new_avg = (old_avg * n + new_vec) / (n+1)""" import numpy as np - - 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) + n = inc.get("embedding_count") or 0 old_embedding = inc.get("embedding") - if old_embedding and n > 0: old_vec = np.array(old_embedding, dtype=float) new_vec = np.array(call_embedding, dtype=float) updated = ((old_vec * n) + new_vec) / (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: 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) if norm_a == 0 or norm_b == 0: return 0.0 diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index 14c95ee..9653145 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -22,6 +22,7 @@ Schema: "vehicles": [vehicle descriptions mentioned, e.g. "Hyundai Tucson", "black sedan"], "units": [unit IDs or officer numbers mentioned, e.g. "Unit 511", "Car 4"], "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" }} @@ -48,14 +49,15 @@ async def extract_tags( system_id: Optional[str] = None, segments: Optional[list[dict]] = 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. Returns: - (tags, primary_type, location_str, location_coords) - where location_coords is {"lat": float, "lng": float} or None. + (tags, primary_type, location_str, location_coords, resolved) + 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, 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 [] units: list[str] = result.get("units") or [] severity: str = result.get("severity") or "unknown" + resolved: bool = bool(result.get("resolved", False)) transcript_corrected: Optional[str] = result.get("transcript_corrected") or None 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"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( diff --git a/drb-c2-core/app/internal/summarizer.py b/drb-c2-core/app/internal/summarizer.py index 11054c2..46b61a2 100644 --- a/drb-c2-core/app/internal/summarizer.py +++ b/drb-c2-core/app/internal/summarizer.py @@ -1,15 +1,14 @@ """ Background incident summary loop. -Runs every SUMMARY_INTERVAL_MINUTES. Finds all active incidents with -summary_stale=True, fetches all their call transcripts, and calls Gemini -once per incident to produce a concise factual summary. - -By batching this way: Gemini is never called per-call — only periodically -and only for incidents that have actually changed since the last run. +Runs every SUMMARY_INTERVAL_MINUTES. Two passes per tick: + 1. Summary pass — find stale incidents (summary_stale=True) and regenerate summaries. + 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 + new linked call. """ import asyncio -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Optional from app.internal.logger import logger from app.internal import firestore as fstore @@ -23,6 +22,7 @@ async def summarizer_loop() -> None: await asyncio.sleep(interval) try: await _run_summary_pass() + await _resolve_stale_incidents() except Exception as 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) +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]: from app.config import settings from openai import OpenAI diff --git a/drb-c2-core/app/routers/upload.py b/drb-c2-core/app/routers/upload.py index 20c8cd2..422c5be 100644 --- a/drb-c2-core/app/routers/upload.py +++ b/drb-c2-core/app/routers/upload.py @@ -95,24 +95,27 @@ async def _run_extraction_pipeline( """Run steps 2-4 of the intelligence pipeline using an existing transcript.""" 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, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, node_id=node_id, ) - if incident_type: - await incident_correlator.correlate_call( - call_id=call_id, - node_id=node_id, - system_id=system_id, - talkgroup_id=talkgroup_id, - talkgroup_name=talkgroup_name, - tags=tags, - incident_type=incident_type, - location=location, - location_coords=location_coords, - ) + incident_id = await incident_correlator.correlate_call( + call_id=call_id, + node_id=node_id, + system_id=system_id, + talkgroup_id=talkgroup_id, + talkgroup_name=talkgroup_name, + tags=tags, + incident_type=incident_type, + location=location, + 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( call_id=call_id, @@ -153,26 +156,30 @@ async def _run_intelligence_pipeline( incident_type: Optional[str] = None location: Optional[str] = None location_coords: Optional[dict] = None + resolved: bool = False 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, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, node_id=node_id, ) - # Step 3: Incident correlation - if incident_type: - await incident_correlator.correlate_call( - call_id=call_id, - node_id=node_id, - system_id=system_id, - talkgroup_id=talkgroup_id, - talkgroup_name=talkgroup_name, - tags=tags, - incident_type=incident_type, - location=location, - location_coords=location_coords, - ) + # Step 3: Incident correlation (always runs — unclassified calls can still link via talkgroup) + incident_id = await incident_correlator.correlate_call( + call_id=call_id, + node_id=node_id, + system_id=system_id, + talkgroup_id=talkgroup_id, + talkgroup_name=talkgroup_name, + tags=tags, + incident_type=incident_type, + location=location, + 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) await alerter.check_and_dispatch( diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx index 70fe363..a67b0b4 100644 --- a/drb-frontend/app/incidents/[id]/page.tsx +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -1,5 +1,6 @@ "use client"; +import dynamic from "next/dynamic"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useIncident } from "@/lib/useIncidents"; @@ -10,6 +11,8 @@ import { CallRow } from "@/components/CallRow"; import { c2api } from "@/lib/c2api"; import type { IncidentRecord } from "@/lib/types"; +const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); + const TYPE_COLORS: Record = { fire: "bg-red-900 text-red-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() { - const params = useParams(); - const id = params.id as string; - const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const router = useRouter(); - const { incident, loading } = useIncident(id); + const { incident, loading } = useIncident(id); const { calls, loading: callsLoading } = useCallsByIncident(id); - const { systems } = useSystems(); - const { isAdmin } = useAuth(); + const { systems } = useSystems(); + const { isAdmin } = useAuth(); + const [tab, setTab] = useState("summary"); 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])); @@ -66,15 +72,13 @@ export default function IncidentDetailPage() { finally { setSummarizing(false); } } - if (loading) { - return

Loading…

; - } - if (!incident) { - return

Incident not found.

; - } + if (loading) return

Loading…

; + if (!incident) return

Incident not found.

; + + const displayTags = incident.tags.filter((t) => t !== "auto-generated"); return ( -
+
{/* Back */}
- {/* Summary */} -
-

- Summary -

- {incident.summary ? ( -

- {incident.summary} -

- ) : ( -

- No summary yet.{" "} - {isAdmin && ( + {/* Tags */} + {displayTags.length > 0 && ( +

+ {displayTags.map((t) => ( + + {t} + + ))} +
+ )} + + {/* Map */} + {incident.location_coords && ( +
+ +
+ )} + + {/* Two-panel body */} +
+ + {/* Left: tabs — Summary / Units / Details */} +
+ {/* Tab bar */} +
+ {(["summary", "units", "details"] as Tab[]).map((t) => ( - )} -

- )} -
- - {/* Tags + Location */} -
-
-

Tags

- {incident.tags.length > 0 ? ( -
- {incident.tags.map((t) => ( - - {t} - - ))} -
- ) : ( -

No tags.

- )} -
- - {incident.location && ( -
-

Location

-

{incident.location}

-
- )} -
- - {/* Metadata */} -
- Started: {new Date(incident.started_at).toLocaleString()} - Updated: {new Date(incident.updated_at).toLocaleString()} - Calls: {incident.call_ids.length} - {incident.talkgroup_ids?.length > 0 && ( - Talkgroups: {incident.talkgroup_ids.join(", ")} - )} -
- - {/* Calls */} -
-

Calls

- {callsLoading ? ( -

Loading calls…

- ) : calls.length === 0 ? ( -

No calls linked yet.

- ) : ( -
- - - - - - - - - - - - - - {calls.map((c) => ( - - ))} - -
TimeTalkgroupSystemNodeDurationAudio
+ ))}
- )} -
+ + {/* Tab content */} +
+ {tab === "summary" && ( + incident.summary ? ( +

{incident.summary}

+ ) : ( +

+ No summary yet.{" "} + {isAdmin && ( + + )} +

+ ) + )} + + {tab === "units" && ( +
+
+

Units

+ {incident.units?.length > 0 ? ( +
+ {incident.units.map((u) => ( + {u} + ))} +
+ ) : ( +

None extracted.

+ )} +
+
+

Vehicles

+ {incident.vehicles?.length > 0 ? ( +
+ {incident.vehicles.map((v) => ( + {v} + ))} +
+ ) : ( +

None extracted.

+ )} +
+
+ )} + + {tab === "details" && ( +
+ {incident.location && ( +
+

Location

+

{incident.location}

+
+ )} +
+

Started

+

{new Date(incident.started_at).toLocaleString()}

+
+
+

Last activity

+

{new Date(incident.updated_at).toLocaleString()}

+
+ {incident.talkgroup_ids?.length > 0 && ( +
+

Talkgroups

+

{incident.talkgroup_ids.join(", ")}

+
+ )} + {incident.severity && ( +
+

Severity

+

{incident.severity}

+
+ )} +
+

Total calls

+

{incident.call_ids.length}

+
+
+ )} +
+
+ + {/* Right: calls */} +
+

+ Calls ({calls.length}) +

+ {callsLoading ? ( +

Loading…

+ ) : calls.length === 0 ? ( +

No calls linked yet.

+ ) : ( +
+ + + + + + + + + + + + + + {calls.map((c) => ( + + ))} + +
TimeTalkgroupSystemNodeDurationAudio
+
+ )} +
+ + ); } diff --git a/drb-frontend/components/CallRow.tsx b/drb-frontend/components/CallRow.tsx index eb6214e..bc3133c 100644 --- a/drb-frontend/components/CallRow.tsx +++ b/drb-frontend/components/CallRow.tsx @@ -121,7 +121,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) { {call.incident_id && (

Incident:{" "} - + {call.incident_id.slice(0, 8)}…

diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 247432c..17db9f9 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -67,12 +67,23 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) { ); 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 ( 0 ? 10 : 4} + zoom={zoom} className="w-full h-full rounded-lg" style={{ background: "#111827" }} > diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 7c522fd..f4ce559 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -55,6 +55,9 @@ export interface IncidentRecord { call_ids: string[]; system_ids: string[]; talkgroup_ids: string[]; + units: string[]; + vehicles: string[]; + severity: string | null; started_at: string; updated_at: string; summary: string | null;