Compare commits

..

7 Commits

Author SHA1 Message Date
Logan 4006232c85 Filter calls in ui 2026-05-10 22:17:20 -04:00
Logan 4c3b1fcc84 UI Updates 2026-05-10 21:47:34 -04:00
Logan 8b660d8e10 feat: incident correlation overhaul, signal-based auto-resolve, token fixes
Correlator
- Raise fast-path idle gate 30 → 90 min (tg_fast_path_idle_minutes)
- Fix disambiguate always-commits bug: run _call_fits_incident on winner
  before committing; fall through to new-incident creation if it fails
- Add unit-continuity path (path 1.5): matches all_active by shared unit
  IDs with a reassignment guard, bridges calls past the idle gate
- Add tag-based incident_type inference (_TAG_TYPE_HINTS) as GPT fallback,
  rescuing tagged calls that would have been dropped (616 observed orphans)
- Add master/child incident model: _create_master_incident, _demote_to_child,
  _add_child_to_master; new incidents stamped incident_type="master"
- Add cross-system parent detection (_find_cross_system_parent): two-signal
  scoring (road overlap=0.4, embedding≥0.78=0.3, proximity=0.3, threshold=0.5)
  wired into create-if-new path; creates master shell on first cross-system match
- Add maybe_resolve_parent: auto-resolves master when all children close;
  called from upload pipeline (LLM closure) and summarizer stale sweep
- Add signal-based auto-resolve via units_active/units_cleared tracking:
  GPT now extracts cleared_units per scene; _update_incident moves units
  between active/cleared lists and resolves the incident when active empties;
  stored on call doc for re-correlation sweep reuse
- Add _create_incident initialization of units_active/units_cleared fields

Re-correlation sweep
- Add corr_sweep_count + MAX_SWEEP_ATTEMPTS=3: orphans get 3 attempts
  then are tombstoned as corr_path="unlinked", ending the re-sweep loop
  (previously hammering each orphan 29-31 times per shift)

Intelligence extraction
- Add cleared_units to GPT prompt schema and rules
- Extract and propagate cleared_units per scene; merge across scenes;
  store on call doc for re-correlation sweep

Token management
- Fix token release bug: remove release_token call on discord_connected=False
  in MQTT checkin (transient Discord drops were orphaning bots mid-shift)
- Add PUT /tokens/{id}/prefer/{system_id} endpoint: lock a bot token to a
  system; pass _none as system_id to clear; stored bidirectionally on both
  token and system documents
- discord_join handler resolves preferred_token_id from system doc and passes
  system_name in MQTT payload
2026-05-10 19:49:05 -04:00
Logan 7e1b01a275 Updates to reduce firestore calls to try and stay in free tier
### Firestore read reductions

**1. `doc_get_cached()` in `firestore.py` — new 5-min TTL cache**
One place, benefits everything. System and node config documents almost never change during a monitoring session.

**2. System doc: 4 reads → 1 per call**
| Before | After |
|---|---|
| `upload.py` — `doc_get("systems")` for ai_flags | `doc_get_cached` |
| `transcription.py` — `get_vocabulary()` → `doc_get("systems")` | cache hit |
| `intelligence.py` — `get_vocabulary()` → `doc_get("systems")` | cache hit |
| `intelligence.py` — `doc_get("systems")` again for ten_codes | eliminated (reads same cached doc) |

**3. Node doc: cached in `_on_call_start` and `intelligence.py`**
The node is read every call event to get `assigned_system_id` and lat/lon for geocoding. Both now use the cache — node assignments and positions essentially never change at runtime.

**4. Node sweeper: 30s → 90s interval**
The sweeper was doing a full node collection scan 3× more often than necessary — the offline threshold is already 90s. Cuts sweeper reads by 66%.

**5. Vocabulary induction: scans all-time calls → last 7 days**
Previously fetched every ended call for a system (could be thousands). Now scoped to the last 7 days.

> **Note:** The vocabulary induction query `(system_id == X, ended_at >= cutoff)` needs a Firestore
> composite index on `(system_id ASC, ended_at ASC)`. When the induction loop first fires it will log
> an error with a Firebase Console link to create it in one click.
2026-05-04 02:05:00 -04:00
Logan 97f4286810 Add debugging 2026-05-04 01:46:56 -04:00
Logan e704df1a62 # app/internal/incident_correlator.py
- *`correlate_call`* — added units and vehicles optional params; when provided (per-scene from intelligence extraction), they take priority over the merged call-document values, preventing multi-scene unit contamination
- *Cross-TGID correlation path (2.5)* — *new path between location and slow paths*: when a call shares 2+ unit IDs with a recent same-system, same-type incident AND embedding similarity ≥ 0.85, it links them — catches multi-talkgroup pursuits like the bicycle search that split across dispatch/tactical/geographic channels
# `app/internal/intelligence.py`
- *`reassignment` field* — added to the GPT-4o-mini prompt schema and rules; `true` when dispatch is actively pulling a unit to a new, different call (not a status update or en route acknowledgement); returned in every processed scene dict
- *Tag location rule* — added explicit instruction to the prompt: tags must describe what happened, not where; place names, road names, and talkgroup names are explicitly forbidden as tags
# `app/routers/upload.py`
- Both scene correlation call sites (`_run_extraction_pipeline` and `_run_intelligence_pipeline`) now pass `units=corr_units` where `corr_units = [] if scene.get("reassignment") else scene.get("units") `— suppresses unit overlap matching when a unit is being reassigned to a new call, preventing chaining into their previous incident
- Both sites also pass `vehicles=scene.get("vehicles")` (per-scene vehicles, from the multi-scene units fix)
# `app/config.py`
- `embedding_cross_tg_threshold: float = 0.85` — threshold for the new cross-TGID path
2026-05-04 01:33:03 -04:00
Logan f6897566f8 Fix tags, titles, and hallucinations 2026-05-04 01:13:18 -04:00
29 changed files with 1253 additions and 169 deletions
+2 -1
View File
@@ -26,10 +26,11 @@ class Settings(BaseSettings):
correlation_window_hours: int = 2 # slow/location path: max hours since last call
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar)
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
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
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
tg_fast_path_idle_minutes: int = 30 # fast path: max minutes since incident last updated
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
# Vocabulary learning
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
+23
View File
@@ -1,4 +1,5 @@
import asyncio
import time as _time
from typing import Optional, Any
import firebase_admin
from firebase_admin import credentials, firestore as fs
@@ -6,6 +7,12 @@ from google.cloud.firestore_v1.base_query import FieldFilter
from app.config import settings
from app.internal.logger import logger
# ---------------------------------------------------------------------------
# In-memory TTL cache for rarely-changing documents (systems, nodes config)
# ---------------------------------------------------------------------------
# Key: "collection/doc_id" → (expires_at_monotonic, data_or_None)
_doc_cache: dict[str, tuple[float, Optional[dict]]] = {}
def _init_firebase():
if firebase_admin._apps:
@@ -79,3 +86,19 @@ async def collection_where(
async def doc_delete(collection: str, doc_id: str) -> None:
ref = db.collection(collection).document(doc_id)
await asyncio.to_thread(ref.delete)
async def doc_get_cached(collection: str, doc_id: str, ttl: float = 300.0) -> Optional[dict]:
"""
Like doc_get but backed by a short-lived in-memory TTL cache.
Use for documents that change rarely (systems config, node assignments).
Default TTL is 5 minutes — a write will be visible within that window.
"""
key = f"{collection}/{doc_id}"
now = _time.monotonic()
entry = _doc_cache.get(key)
if entry and now < entry[0]:
return entry[1]
data = await doc_get(collection, doc_id)
_doc_cache[key] = (now + ttl, data)
return data
+522 -18
View File
@@ -48,6 +48,92 @@ from app.config import settings
_DISPATCH_TG_RE = re.compile(r"\bdispatch\b|\bdisp\b", re.IGNORECASE)
# Matches route/road identifiers in location strings for cross-system parent detection.
# Groups: numbered routes (Route 202, NY-9, US-6, I-87, CR-35) and named parkways/highways.
_ROAD_RE = re.compile(
r"\b(?:route|rt\.?|rte\.?|us[-\s]?|state\s*route\s*|ny[-\s]?|i[-\s]?|cr[-\s]?|county\s*road\s*)\s*\d+\b"
r"|\b(?:tsp|taconic|thruway|parkway|turnpike|interstate)\b"
r"|\b\w+(?:\s+\w+)?\s+(?:street|avenue|road|drive|boulevard|lane|court|place|highway|pkwy|blvd|ave|rd|st|dr)\b",
re.IGNORECASE,
)
def _extract_road_ids(text: str) -> set[str]:
"""
Extract normalised road/route identifiers from a location string.
e.g. "suspect east on Route 202"{"route 202"}
"at Main Street and Oak Ave"{"main street", "oak ave"}
"""
return {
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
for m in _ROAD_RE.finditer(text)
}
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
"""True if the new call's location shares any road identifier with the incident's history."""
if not new_location or not inc_mentions:
return False
new_roads = _extract_road_ids(new_location)
if not new_roads:
return False
inc_roads: set[str] = set()
for mention in inc_mentions:
inc_roads |= _extract_road_ids(mention)
return bool(new_roads & inc_roads)
def _operational_types_compatible(type_a: Optional[str], type_b: Optional[str]) -> bool:
"""Police+police, ems+ems, fire+fire all match. Police+ems for co-response. Fire+anything for mutual aid."""
if not type_a or not type_b:
return False
if type_a == type_b:
return True
compatible_pairs = {frozenset({"police", "ems"}), frozenset({"fire", "ems"}), frozenset({"fire", "police"})}
return frozenset({type_a, type_b}) in compatible_pairs
# Tags that unambiguously imply a specific incident type, used as a fallback
# when GPT returns incident_type=None (typically due to missing talkgroup context).
# Only high-confidence, type-specific tags are listed — generic tags like
# "welfare-check" or "suspicious-activity" are omitted to avoid false typing.
_TAG_TYPE_HINTS: dict[str, str] = {
"active-fire": "fire",
"working-fire": "fire",
"structure-fire": "fire",
"brush-fire": "fire",
"smoke-investigation": "fire",
"fire-alarm": "fire",
"cardiac-arrest": "ems",
"unresponsive": "ems",
"medical-assistance": "ems",
"transport": "ems",
"courtesy-transport": "ems",
"mvc": "accident",
"mva": "accident",
"two-car-mva": "accident",
"traffic-stop": "police",
"shots-fired": "police",
"vehicle-pursuit": "police",
"pursuit": "police",
}
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
"""Return an incident type inferred from tags, or None if ambiguous."""
for tag in tags:
t = _TAG_TYPE_HINTS.get(tag.lower())
if t:
return t
return None
def _tag_to_title(tag: str) -> str:
"""
Convert a hyphenated tag to title case without the str.title() apostrophe bug.
e.g. "lower-macy's""Lower Macy's" (not "Lower Macy'S")
"""
return " ".join(w.capitalize() for w in tag.replace("-", " ").split())
def _is_dispatch_channel(talkgroup_name: Optional[str]) -> bool:
"""True when the talkgroup is a shared dispatch backbone (not a tactical/working channel)."""
@@ -84,6 +170,9 @@ async def correlate_call(
location_coords: Optional[dict] = None,
reference_time: Optional[datetime] = None,
create_if_new: bool = True,
units: Optional[list[str]] = None,
vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None,
) -> Optional[str]:
"""
Link call_id to an existing incident or create a new one.
@@ -107,13 +196,19 @@ async def correlate_call(
# 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 []
# Prefer explicitly passed units/vehicles (per-scene, from intelligence extraction)
# over the call doc, which merges units from ALL scenes in a multi-scene recording.
# Falling back to the call doc is correct for recorrelation sweeps where we have no
# scene-level breakdown.
call_units: list[str] = units if units is not None else (call_doc.get("units") or [])
call_vehicles: list[str] = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared: list[str] = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") 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")
matched_incident: Optional[dict] = None
corr_debug: dict = {}
# A "thin" call carries no scene-identifying information — it is a pure
# status transmission (10-4, en route, acknowledgement). Detected by the
@@ -156,6 +251,10 @@ async def correlate_call(
# Status/ack call — no scene data to reason about.
# Attach to whichever recent incident was most recently active on this TGID.
matched_incident = max(tg_recent, key=lambda inc: inc.get("updated_at", ""))
corr_debug = {
"corr_path": "fast/thin",
"corr_incident_idle_min": round(_incident_idle_minutes(matched_incident, now), 1),
}
logger.info(
f"Correlator fast-path (thin→last TGID incident): "
f"call {call_id}{matched_incident['incident_id']}"
@@ -167,6 +266,10 @@ async def correlate_call(
settings.location_proximity_km, is_dispatch=is_dispatch,
):
matched_incident = candidate
corr_debug = {
"corr_path": "fast/single",
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
}
logger.info(
f"Correlator fast-path: call {call_id}{candidate['incident_id']}"
)
@@ -176,13 +279,67 @@ async def correlate_call(
f"from {candidate['incident_id']}; will attempt new incident"
)
elif len(tg_recent) > 1:
matched_incident = _disambiguate(
candidate = _disambiguate(
tg_recent, call_units, call_vehicles, coords, call_embedding
)
logger.info(
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
f"call {call_id}{matched_incident['incident_id']}"
# Disambiguate picks the best candidate, but still verify the call
# actually fits before committing — a new unrelated call on a busy
# dispatch channel should create its own incident, not be force-merged.
if _call_fits_incident(
candidate, call_units, call_vehicles, coords,
settings.location_proximity_km, is_dispatch=is_dispatch,
):
matched_incident = candidate
corr_debug = {
"corr_path": "fast/disambig",
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
"corr_candidates": len(tg_recent),
}
logger.info(
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
f"call {call_id}{candidate['incident_id']}"
)
else:
logger.info(
f"Correlator fast-path disambig: no candidate fits call {call_id} "
f"across {len(tg_recent)} incidents — will attempt new incident"
)
# ── 1.5. Unit-continuity path: same officer, not reassigned ─────────────────
#
# Handles long calls (bookings, transports, late scene clearance) where the
# 90-min idle gate has fired but the officer is still on the same call.
# Searches ALL active incidents — no idle gate, no time limit.
#
# Reassignment guard: if the same unit appears in a MORE recently updated
# incident, the officer has moved on and we don't link back to the old one.
# This correctly handles officers dispatched to a second call mid-shift.
if not matched_incident and call_units and system_id:
call_unit_set = set(call_units)
unit_candidates = [
inc for inc in all_active
if system_id in (inc.get("system_ids") or [])
and call_unit_set & set(inc.get("units") or [])
]
if unit_candidates:
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
reassigned_away = any(
inc["incident_id"] != best_unit_inc["incident_id"]
and call_unit_set & set(inc.get("units") or [])
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
for inc in all_active
)
if not reassigned_away:
matched_incident = best_unit_inc
corr_debug = {
"corr_path": "unit-continuity",
"corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1),
}
logger.info(
f"Correlator unit-continuity: call {call_id}"
f"{best_unit_inc['incident_id']} "
f"(idle {_incident_idle_minutes(best_unit_inc, now):.0f}min)"
)
# ── 2. Location path: proximity match (time-limited, cross-type) ─────────
if not matched_incident and coords:
@@ -196,12 +353,54 @@ async def correlate_call(
)
if dist_km <= settings.location_proximity_km:
matched_incident = inc
corr_debug = {"corr_path": "location", "corr_distance_km": round(dist_km, 3)}
logger.info(
f"Correlator location-path: call {call_id}{inc['incident_id']} "
f"(dist={dist_km:.2f}km)"
)
break
# ── 2.5. Cross-TG path: same department, overlapping units, moderate similarity ──
#
# Catches pursuits / searches that span multiple talkgroup IDs within the same
# department (e.g. dispatch → tactical → geographic channel). The fast path
# is TGID-scoped so it never links these. Two conditions together provide
# strong evidence of the same scene without needing location:
# • 2+ shared unit IDs (same officers working the same call)
# • embedding similarity >= cross-TG threshold (same subject matter)
# Requiring 2+ shared units prevents single-officer false positives.
if not matched_incident and call_embedding and incident_type and call_units and system_id:
call_unit_set = set(call_units)
best_cross_score = 0.0
best_cross_inc: Optional[dict] = None
for inc in recent:
if inc.get("type") != incident_type:
continue
if system_id not in (inc.get("system_ids") or []):
continue
inc_units_set = set(inc.get("units") or [])
if len(call_unit_set & inc_units_set) < 2:
continue
inc_embedding = inc.get("embedding")
if not inc_embedding:
continue
sim = _cosine_similarity(call_embedding, inc_embedding)
if sim > best_cross_score:
best_cross_score = sim
best_cross_inc = inc
if best_cross_inc and best_cross_score >= settings.embedding_cross_tg_threshold:
matched_incident = best_cross_inc
shared = len(call_unit_set & set(best_cross_inc.get("units") or []))
corr_debug = {
"corr_path": "cross-tg",
"corr_score": round(best_cross_score, 4),
"corr_shared_units": shared,
}
logger.info(
f"Correlator cross-TG path: call {call_id}{best_cross_inc['incident_id']} "
f"(sim={best_cross_score:.3f}, shared_units={shared})"
)
# ── 3. Slow path: embedding similarity (time-limited, same type) ──────────
#
# Two tiers:
@@ -232,6 +431,11 @@ async def correlate_call(
)
if dist_km <= settings.location_proximity_km * 4:
matched_incident = best_inc
corr_debug = {
"corr_path": "slow",
"corr_score": round(best_score, 4),
"corr_distance_km": round(dist_km, 3),
}
logger.info(
f"Correlator slow-path: call {call_id}{best_inc['incident_id']} "
f"(sim={best_score:.3f}, dist={dist_km:.2f}km)"
@@ -240,6 +444,10 @@ async def correlate_call(
# High-confidence semantic match; geocode unavailable on one or
# both sides — content similarity alone is sufficient evidence.
matched_incident = best_inc
corr_debug = {
"corr_path": "slow/no-location",
"corr_score": round(best_score, 4),
}
logger.info(
f"Correlator slow-path (high-confidence, no location): "
f"call {call_id}{best_inc['incident_id']} (sim={best_score:.3f})"
@@ -252,17 +460,97 @@ async def correlate_call(
matched_incident, call_id, talkgroup_id, system_id, tags,
location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared,
)
elif incident_type and create_if_new:
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,
)
elif create_if_new:
# If GPT returned no type (missing talkgroup context is common), attempt
# to recover a type from the extracted tags before giving up on creation.
if not incident_type and tags:
incident_type = _infer_type_from_tags(tags)
if incident_type:
logger.info(
f"Correlator: inferred incident_type={incident_type!r} from tags "
f"{tags} for call {call_id} (no GPT type)"
)
if not incident_type:
# No type and none inferred — nothing to create
return None
# ── Cross-system parent detection ─────────────────────────────────────
# Before creating a standalone incident, check whether this call belongs
# to an incident already opened by a different agency (multi-agency chase,
# mutual aid, etc.). If a parent candidate is found:
# • The existing candidate is demoted to a child (incident_type → "child")
# • A new master shell is created linking both children
# • The new call's incident is created as a second child of the master
cross_parent: Optional[dict] = None
if system_id:
cross_parent = await _find_cross_system_parent(
system_id=system_id,
incident_type=incident_type,
location=location,
location_coords=coords,
call_embedding=call_embedding,
recent=recent,
)
if cross_parent:
existing_child_id = cross_parent["incident_id"]
existing_master_id = cross_parent.get("parent_incident_id")
# Create the new agency's child incident first
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,
)
if existing_master_id:
# Candidate is already a child — link new child to the existing master
await _demote_to_child(incident_id, existing_master_id)
await _add_child_to_master(existing_master_id, incident_id, now)
corr_debug["corr_path"] = "new/cross-system-child"
logger.info(
f"Correlator cross-system: call {call_id} → new child {incident_id} "
f"under existing master {existing_master_id}"
)
else:
# Candidate is a standalone master — create master shell, demote both
master_id = await _create_master_incident(
first_child_id=existing_child_id,
second_child_id=incident_id,
operational_type=incident_type,
location=cross_parent.get("location") or location,
location_coords=cross_parent.get("location_coords") or coords,
now=now,
)
await _demote_to_child(existing_child_id, master_id)
await _demote_to_child(incident_id, master_id)
corr_debug["corr_path"] = "new/cross-system-master"
logger.info(
f"Correlator cross-system: created master {master_id}, "
f"demoted {existing_child_id} + new {incident_id} as children"
)
else:
# Normal single-agency incident creation
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,
)
corr_debug["corr_path"] = "new"
else:
# No match and either no type or creation suppressed — nothing to do
# Creation suppressed (re-correlation sweep) — nothing to do
return None
# Persist the correlation decision to the call document so it can be
# inspected in Firestore or the admin UI without log-scraping.
if corr_debug:
try:
await fstore.doc_set("calls", call_id, corr_debug)
except Exception as e:
logger.warning(f"Could not write corr_debug for call {call_id}: {e}")
return incident_id
@@ -420,6 +708,7 @@ async def _update_incident(
now: datetime,
talkgroup_name: Optional[str] = None,
incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None,
) -> None:
incident_id = inc["incident_id"]
@@ -439,6 +728,19 @@ async def _update_incident(
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles))
# Unit activity tracking: units_active / units_cleared
# units_active = units currently on scene; units_cleared = units back in service
units_active = list(inc.get("units_active") or [])
units_cleared = list(inc.get("units_cleared") or [])
for u in call_units:
if u not in units_cleared and u not in units_active:
units_active.append(u)
for u in (cleared_units or []):
if u in units_active:
units_active.remove(u)
if u not in units_cleared:
units_cleared.append(u)
location_mentions = list(inc.get("location_mentions") or [])
if location and location not in location_mentions:
location_mentions.append(location)
@@ -455,6 +757,8 @@ async def _update_incident(
"tags": merged_tags,
"units": merged_units,
"vehicles": merged_vehicles,
"units_active": units_active,
"units_cleared": units_cleared,
"location_mentions": location_mentions,
"updated_at": now.isoformat(),
"summary_stale": True,
@@ -474,18 +778,31 @@ async def _update_incident(
# Routine status calls (type=None) do not clobber the title.
if incident_type:
content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
tg_label = (
talkgroup_name
or (f"TGID {talkgroup_id}" if talkgroup_id else inc.get("title", "").split("")[-1])
)
if primary_tag and best_location:
if primary_tag and best_location and primary_tag.lower() != best_location.lower():
updates["title"] = f"{primary_tag} at {best_location}"
elif primary_tag and tg_label:
updates["title"] = f"{primary_tag}{tg_label}"
elif primary_tag:
updates["title"] = primary_tag
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
# Requires at least one unit to have explicitly signalled back-in-service so we
# don't fire on incidents where units were never tracked (no unit mentions at all).
if units_cleared and not units_active:
updates["status"] = "resolved"
await fstore.doc_set("incidents", incident_id, updates)
logger.info(
f"Correlator: signal-resolved incident {incident_id} "
f"(call {call_id} — all {len(units_cleared)} unit(s) clear)"
)
await maybe_resolve_parent(incident_id)
return
await fstore.doc_set("incidents", incident_id, updates)
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
@@ -513,17 +830,18 @@ async def _create_incident(
# Build a descriptive title from tags + location when available
content_tags = [t for t in tags if t != "auto-generated"]
primary_tag = content_tags[0].replace("-", " ").title() if content_tags else None
if primary_tag and location:
primary_tag = _tag_to_title(content_tags[0]) if content_tags else None
if primary_tag and location and primary_tag.lower() != location.lower():
title = f"{primary_tag} at {location}"
elif primary_tag:
title = f"{primary_tag}{tg_label}"
else:
title = f"{incident_type.title()}{tg_label}"
title = f"{_tag_to_title(incident_type)}{tg_label}"
doc = {
"incident_id": incident_id,
"title": title,
"incident_type": "master", # structural role; "child" set on demotion
"type": incident_type,
"status": "active",
"location": location,
@@ -534,6 +852,8 @@ async def _create_incident(
"system_ids": [system_id] if system_id else [],
"tags": tags + ["auto-generated"],
"units": call_units,
"units_active": list(call_units),
"units_cleared": [],
"vehicles": call_vehicles,
"severity": call_severity,
"summary": None,
@@ -564,6 +884,190 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
return {"embedding": call_embedding, "embedding_count": 1}
async def _create_master_incident(
first_child_id: str,
second_child_id: str,
operational_type: str,
location: Optional[str],
location_coords: Optional[dict],
now: datetime,
) -> str:
"""
Create a master shell incident linking two child incidents.
The master owns no calls directly — it is a grouping record.
Returns the new master incident_id.
"""
master_id = str(uuid.uuid4())
doc = {
"incident_id": master_id,
"title": f"Multi-agency {operational_type} incident",
"incident_type": "master",
"type": operational_type,
"status": "active",
"location": location,
"location_coords": location_coords,
"child_incident_ids": [first_child_id, second_child_id],
"parent_incident_id": None,
"call_ids": [],
"talkgroup_ids": [],
"system_ids": [],
"tags": [],
"units": [],
"vehicles": [],
"severity": "unknown",
"summary": None,
"summary_stale": True,
"summary_last_run": None,
"embedding": None,
"embedding_count": 0,
"has_updates": False,
"started_at": now.isoformat(),
"updated_at": now.isoformat(),
}
await fstore.doc_set("incidents", master_id, doc, merge=False)
logger.info(f"Correlator: created master incident {master_id} linking {first_child_id} + {second_child_id}")
return master_id
async def _demote_to_child(incident_id: str, parent_id: str) -> None:
"""Demote a standalone master incident to a child by setting incident_type and parent reference."""
await fstore.doc_set("incidents", incident_id, {
"incident_type": "child",
"parent_incident_id": parent_id,
})
logger.info(f"Correlator: demoted incident {incident_id} → child of master {parent_id}")
async def _add_child_to_master(master_id: str, child_id: str, now: datetime) -> None:
"""Append a new child to an existing master's child_incident_ids list."""
master = await fstore.doc_get("incidents", master_id)
if not master:
return
children = list(master.get("child_incident_ids") or [])
if child_id not in children:
children.append(child_id)
updates: dict = {"child_incident_ids": children, "updated_at": now.isoformat()}
# Re-open a resolved master when a new child is added (retroactive link)
if master.get("status") == "resolved":
updates["has_updates"] = True
await fstore.doc_set("incidents", master_id, updates)
async def maybe_resolve_parent(incident_id: str) -> None:
"""
Called after resolving a child incident.
If all siblings under the same master are also resolved, auto-resolve the master.
Safe to call on non-child incidents — exits immediately when there's no parent.
"""
inc = await fstore.doc_get("incidents", incident_id)
if not inc:
return
parent_id = inc.get("parent_incident_id")
if not parent_id:
return # standalone or already a master — nothing to propagate
parent = await fstore.doc_get("incidents", parent_id)
if not parent or parent.get("status") == "resolved":
return # master already closed
child_ids: list[str] = parent.get("child_incident_ids") or []
if not child_ids:
return
for cid in child_ids:
if cid == incident_id:
continue # the one we just resolved
child = await fstore.doc_get("incidents", cid)
if not child or child.get("status") != "resolved":
return # at least one sibling still active
# All children resolved — close the master
await fstore.doc_set("incidents", parent_id, {"status": "resolved"})
logger.info(
f"Auto-resolved master incident {parent_id} "
f"(all {len(child_ids)} child(ren) resolved)"
)
async def _find_cross_system_parent(
system_id: str,
incident_type: Optional[str],
location: Optional[str],
location_coords: Optional[dict],
call_embedding: Optional[list],
recent: list[dict],
) -> Optional[dict]:
"""
Scan active incidents from OTHER systems for a cross-agency parent candidate.
Match criteria (need at least two signals firing together):
A. Road/route identifier overlap between the new call's location and the
incident's accumulated location_mentions. Any shared route number or
road name is a strong positive — two agencies don't randomly share the
same road name in the same window.
B. Content embedding similarity ≥ 0.78 (lower than same-system slow path
because we're linking, not merging).
C. Geocoded proximity — 1 km for static scenes (≤2 location_mentions),
3 km for dynamic/moving scenes (chase, expanding perimeter).
Returns the best matching incident (master or standalone), or None.
Child incidents are resolved to their parent before matching so we always
attach to the master level.
"""
best_inc: Optional[dict] = None
best_score = 0.0
for inc in recent:
# Only cross-system candidates
if system_id in (inc.get("system_ids") or []):
continue
if not _operational_types_compatible(incident_type, inc.get("type")):
continue
# If the candidate is already a child, resolve to its parent
if inc.get("incident_type") == "child" and inc.get("parent_incident_id"):
parent = await fstore.doc_get("incidents", inc["parent_incident_id"])
if parent and parent.get("status") == "active":
inc = parent
else:
continue
score = 0.0
inc_mentions: list[str] = inc.get("location_mentions") or []
# Signal A — road/route identifier overlap (0.4).
# Shared route numbers are a strong signal but not conclusive alone.
if location and _location_mentions_road_overlap(location, inc_mentions):
score += 0.4
# Signal B — content embedding similarity ≥ 0.78 (0.3 flat bonus).
inc_embedding = inc.get("embedding")
if call_embedding and inc_embedding:
if _cosine_similarity(call_embedding, inc_embedding) >= 0.78:
score += 0.3
# Signal C — geocoded proximity (0.3).
# Dynamic scenes (3+ location mentions = chase/moving perimeter) use 3 km;
# static mutual aid (≤2 mentions) uses 1 km.
inc_coords = inc.get("location_coords")
if location_coords and inc_coords:
dist_km = _haversine_km(
location_coords["lat"], location_coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
radius = 3.0 if len(inc_mentions) >= 3 else 1.0
if dist_km <= radius:
score += 0.3
# threshold = 0.5 → requires at least two signals (A+B, A+C, or B+C).
# No single signal alone can clear the bar.
if score >= 0.5 and score > best_score:
best_score = score
best_inc = inc
return best_inc
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)
+17 -7
View File
@@ -29,18 +29,22 @@ Response format — a JSON object with a "scenes" array. Each scene:
location: most specific location string found, or empty string
vehicles: list of vehicle descriptions mentioned
units: list of unit IDs or officer numbers explicitly mentioned
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
severity: one of "minor" | "moderate" | "major" | "unknown"
resolved: true if this scene explicitly signals incident closure, false otherwise
reassignment: true if dispatch is actively pulling a unit away from their current assignment to respond to a new, different call — e.g. "Baker, can you clear and respond to...", "Adam, break from that and go to...". False if the unit is simply reporting in, updating status, or continuing their current assignment.
transcript_corrected: corrected text for this scene's transmissions only, or null
Rules:
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Empty string if none.
- tags: specific, lowercase, hyphenated. Do not repeat incident_type as a tag.
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
- units: only identifiers explicitly mentioned, not inferred.
- Do not invent details not present in the transcript.
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
- transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several""10-4", misheard street names, garbled unit IDs). Keep all radio language as-is — do NOT decode codes into plain English. Return null if accurate.
System: {system_id}
@@ -97,12 +101,11 @@ async def extract_scenes(
vocabulary: list[str] = []
ten_codes: dict[str, str] = {}
if system_id:
from app.internal.vocabulary_learner import get_vocabulary
vocab_data = await get_vocabulary(system_id)
vocabulary = vocab_data.get("vocabulary") or []
system_doc = await fstore.doc_get("systems", system_id)
# Single cached read — vocabulary and ten_codes live on the same document.
system_doc = await fstore.doc_get_cached("systems", system_id)
if system_doc:
ten_codes = system_doc.get("ten_codes") or {}
vocabulary = system_doc.get("vocabulary") or []
ten_codes = system_doc.get("ten_codes") or {}
raw_scenes: list[dict] = await asyncio.to_thread(
_sync_extract,
@@ -116,7 +119,7 @@ async def extract_scenes(
node_lat: Optional[float] = None
node_lon: Optional[float] = None
if node_id:
node_doc = await fstore.doc_get("nodes", node_id)
node_doc = await fstore.doc_get_cached("nodes", node_id)
if node_doc:
node_lat = node_doc.get("lat")
node_lon = node_doc.get("lon")
@@ -128,8 +131,10 @@ async def extract_scenes(
location: Optional[str] = scene.get("location") or None
vehicles: list[str] = scene.get("vehicles") or []
units: list[str] = scene.get("units") or []
cleared_units: list[str] = scene.get("cleared_units") or []
severity: str = scene.get("severity") or "unknown"
resolved: bool = bool(scene.get("resolved", False))
reassignment: bool = bool(scene.get("reassignment", False))
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
segment_indices: Optional[list] = scene.get("segment_indices")
@@ -158,8 +163,10 @@ async def extract_scenes(
"location_coords": location_coords,
"vehicles": vehicles,
"units": units,
"cleared_units": cleared_units,
"severity": severity,
"resolved": resolved,
"reassignment": reassignment,
"transcript_corrected": transcript_corrected,
"segment_indices": segment_indices,
"embedding": embedding,
@@ -172,6 +179,7 @@ async def extract_scenes(
all_tags = list(dict.fromkeys(t for s in processed for t in s["tags"]))
all_units = list(dict.fromkeys(u for s in processed for u in s["units"]))
all_vehicles = list(dict.fromkeys(v for s in processed for v in s["vehicles"]))
all_cleared = list(dict.fromkeys(u for s in processed for u in s["cleared_units"]))
updates: dict = {"tags": all_tags, "severity": primary["severity"]}
if primary["location"]:
@@ -180,6 +188,8 @@ async def extract_scenes(
updates["location_coords"] = primary["location_coords"]
if all_units:
updates["units"] = all_units
if all_cleared:
updates["cleared_units"] = all_cleared
if all_vehicles:
updates["vehicles"] = all_vehicles
if primary["embedding"]:
+8 -7
View File
@@ -109,10 +109,11 @@ class MQTTHandler:
updates["status"] = "online"
await fstore.doc_update("nodes", node_id, updates)
# Release any orphaned Discord token when the node explicitly reports disconnected
if payload.get("discord_connected") is False:
from app.routers.tokens import release_token
await release_token(node_id)
# NOTE: discord_connected in checkins is informational only — do NOT release the
# token here. The bot watchdog reconnects on transient Discord drops, so a single
# checkin with discord_connected=False during a brief reconnect window would
# incorrectly free the token while the bot is still active. Token release is
# handled exclusively by the discord_leave command and the node offline sweeper.
# ------------------------------------------------------------------
# Status update
@@ -143,8 +144,8 @@ class MQTTHandler:
if not call_id:
return
# Look up assigned system for this node
node = await fstore.doc_get("nodes", node_id)
# Look up assigned system for this node (cached — assignment rarely changes)
node = await fstore.doc_get_cached("nodes", node_id)
system_id = node.get("assigned_system_id") if node else None
started_at_raw = payload.get("started_at")
@@ -157,7 +158,7 @@ class MQTTHandler:
# Prefer the name from OP25 metadata; fall back to the system config
tgid_name = payload.get("tgid_name") or ""
if not tgid_name and system_id and payload.get("tgid"):
system_doc = await fstore.doc_get("systems", system_id)
system_doc = await fstore.doc_get_cached("systems", system_id)
if system_doc:
tgid_int = int(payload["tgid"])
for tg in system_doc.get("config", {}).get("talkgroups", []):
+1 -1
View File
@@ -4,7 +4,7 @@ from app.config import settings
from app.internal.logger import logger
from app.internal import firestore as fstore
SWEEP_INTERVAL = 30 # seconds
SWEEP_INTERVAL = 90 # seconds — matches node_offline_threshold; no gain in checking faster
async def sweeper_loop():
@@ -46,9 +46,15 @@ async def _run_sweep_pass() -> None:
("status", "==", "ended"),
("ended_at", ">=", cutoff),
])
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures.
# Allows a few retries so a welfare-check call can link to an escalation
# incident that is created a few minutes later, without sweeping 30× forever.
MAX_SWEEP_ATTEMPTS = 3
orphans = [
c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id")
and not c.get("corr_path") # skip calls already exhausted
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
]
if not orphans:
@@ -87,6 +93,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
incident_type = call.get("incident_type"),
location = call.get("location"),
location_coords= call.get("location_coords"),
cleared_units = call.get("cleared_units") or [],
reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only
)
@@ -97,6 +104,15 @@ async def _recorrelate_orphan(call: dict) -> bool:
f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
)
return True
# Increment the attempt counter. Once MAX_SWEEP_ATTEMPTS is reached the
# orphan filter above will stop picking this call up, and we write
# corr_path="unlinked" as a permanent tombstone.
attempts = call.get("corr_sweep_count", 0) + 1
update: dict = {"corr_sweep_count": attempts}
if attempts >= 3:
update["corr_path"] = "unlinked"
await fstore.doc_set("calls", call_id, update)
return False
+2
View File
@@ -102,6 +102,8 @@ async def _resolve_stale_incidents() -> None:
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"})
from app.internal.incident_correlator import maybe_resolve_parent
await maybe_resolve_parent(incident_id)
logger.info(
f"Auto-resolved stale incident {incident_id} "
f"(idle {idle_minutes:.0f}m)"
+6 -2
View File
@@ -123,8 +123,6 @@ def _sync_transcribe(
response_format="verbose_json",
temperature=0,
)
text = response.text.strip() or None
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
#
# 1. Trailing silence / static — Whisper fills silence past real content with
@@ -142,6 +140,12 @@ def _sync_transcribe(
and s.start < audio_duration
and getattr(s, "no_speech_prob", 0.0) < 0.8
]
# Reconstruct text from non-hallucinated segments only so the two stay
# in sync. If every segment was filtered (e.g. pure static or repeated
# prompt-word hallucination like "Standby. Standby. Standby..."), text
# becomes None which prevents the intelligence pipeline from running on
# hallucinated content.
text = " ".join(s["text"] for s in segments) or None
return text, segments
finally:
try:
+10 -4
View File
@@ -196,8 +196,8 @@ async def remove_term(system_id: str, term: str) -> None:
async def get_vocabulary(system_id: str) -> dict:
"""Return vocabulary and pending terms for a system."""
doc = await fstore.doc_get("systems", system_id)
"""Return vocabulary and pending terms for a system (TTL-cached, 5 min)."""
doc = await fstore.doc_get_cached("systems", system_id)
if not doc:
return {"vocabulary": [], "vocabulary_pending": [], "vocabulary_bootstrapped": False}
return {
@@ -281,8 +281,14 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
system_name = system_doc.get("name", "Unknown")
existing_vocab: list[str] = system_doc.get("vocabulary") or []
# Fetch recent ended calls for this system
all_calls = await fstore.collection_list("calls", system_id=system_id, status="ended")
# Fetch calls from the last 7 days only — avoids scanning the entire history.
# Active calls have ended_at=None and are excluded by the range filter automatically.
# Needs a composite index on (system_id ASC, ended_at ASC).
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
all_calls = await fstore.collection_where("calls", [
("system_id", "==", system_id),
("ended_at", ">=", cutoff),
])
if not all_calls:
return
+13 -1
View File
@@ -53,12 +53,24 @@ async def send_command(node_id: str, cmd: CommandPayload):
payload = cmd.model_dump(exclude_none=True)
if cmd.action == "discord_join":
preferred = payload.pop("preferred_token_id", None)
# Resolve system doc once — used for preferred token and presence name.
system_doc = None
system_id = node.get("assigned_system_id")
if system_id:
system_doc = await fstore.doc_get_cached("systems", system_id)
# Explicit preferred_token_id in the request beats the system-level preference.
preferred = payload.pop("preferred_token_id", None) or (system_doc or {}).get("preferred_token_id")
token = await assign_token(node_id, preferred_token_id=preferred)
if not token:
raise HTTPException(503, "No Discord bot tokens available in the pool.")
payload["token"] = token
# Pass system name so the bot can set its Discord presence on join.
system_name = (system_doc or {}).get("name")
if system_name:
payload["system_name"] = system_name
elif cmd.action == "discord_leave":
await release_token(node_id)
+28
View File
@@ -60,6 +60,34 @@ async def flush_tokens():
return {"released": len(results)}
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(token_id: str, system_id: str):
"""
Mark this token as the preferred bot for a system.
When a discord_join is issued for any node in that system, this token
is tried first before falling back to the general pool.
Pass system_id="_none" to clear the preference.
"""
existing = await fstore.doc_get("bot_tokens", token_id)
if not existing:
raise HTTPException(404, "Token not found.")
if system_id == "_none":
# Clear any existing preference on the system that pointed to this token.
system_doc = await fstore.doc_get("systems", existing.get("preferred_for_system_id", ""))
if system_doc:
await fstore.doc_set("systems", existing["preferred_for_system_id"], {"preferred_token_id": None})
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
return {"ok": True, "preferred_for_system_id": None}
system_doc = await fstore.doc_get("systems", system_id)
if not system_doc:
raise HTTPException(404, "System not found.")
# Set preference on both sides for easy lookup in either direction.
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
return {"ok": True, "preferred_for_system_id": system_id}
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str):
existing = await fstore.doc_get("bot_tokens", token_id)
+13 -1
View File
@@ -110,6 +110,9 @@ async def _run_extraction_pipeline(
all_tags: list[str] = []
for scene in scenes:
all_tags.extend(scene["tags"])
# When dispatch is pulling a unit to a NEW call (reassignment), suppress unit
# overlap so the new scene doesn't chain into the unit's previous incident.
corr_units = [] if scene.get("reassignment") else scene.get("units")
incident_id = await incident_correlator.correlate_call(
call_id=call_id,
node_id=node_id,
@@ -120,11 +123,15 @@ async def _run_extraction_pipeline(
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
if incident_ids:
@@ -165,7 +172,7 @@ async def _run_intelligence_pipeline(
# but global flag=False beats everything (master switch).
system_ai_flags: dict = {}
if system_id:
sys_doc = await fstore.doc_get("systems", system_id)
sys_doc = await fstore.doc_get_cached("systems", system_id)
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
def _flag(name: str) -> bool:
@@ -206,6 +213,7 @@ async def _run_intelligence_pipeline(
if flags["correlation_enabled"]:
for scene in scenes:
all_tags.extend(scene["tags"])
corr_units = [] if scene.get("reassignment") else scene.get("units")
incident_id = await incident_correlator.correlate_call(
call_id=call_id,
node_id=node_id,
@@ -216,11 +224,15 @@ async def _run_intelligence_pipeline(
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
# Correlator also runs for calls with no scenes (unclassified) to attempt
+1 -1
View File
@@ -98,7 +98,7 @@ export default function AdminPage() {
if (!isAdmin) return null;
return (
<div className="p-6 max-w-2xl mx-auto space-y-8">
<div className="max-w-2xl space-y-8">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<section className="space-y-3">
+1 -1
View File
@@ -197,7 +197,7 @@ export default function AlertsPage() {
const unacked = alerts.filter((a) => !a.acknowledged);
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="space-y-6">
<div className="flex items-center gap-4">
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
{unacked.length > 0 && (
+179 -11
View File
@@ -1,10 +1,69 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useCalls } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { CallRow } from "@/components/CallRow";
import { useAuth } from "@/components/AuthProvider";
import type { CallRecord } from "@/lib/types";
const inputCls =
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
const q = filters.query.trim().toLowerCase();
const tgid = filters.tgid.trim();
const fromMs = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const toMs = filters.dateTo ? new Date(filters.dateTo + "T23:59:59").getTime() : null;
return calls.filter((c) => {
// System filter
if (filters.systemId && c.system_id !== filters.systemId) return false;
// TGID filter (exact match on the number)
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
// Date range
const ts = new Date(c.started_at).getTime();
if (fromMs !== null && ts < fromMs) return false;
if (toMs !== null && ts > toMs) return false;
// Free-text: talkgroup name, node_id, transcript, tags
if (q) {
const hay = [
c.talkgroup_name ?? "",
c.node_id,
c.transcript ?? "",
c.transcript_corrected ?? "",
...(c.tags ?? []),
].join(" ").toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
interface Filters {
query: string;
tgid: string;
systemId: string;
dateFrom: string;
dateTo: string;
}
const DEFAULT_FILTERS: Filters = {
query: "",
tgid: "",
systemId: "",
dateFrom: "",
dateTo: "",
};
function isActive(f: Filters) {
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
}
export default function CallsPage() {
const [limitCount, setLimitCount] = useState(100);
@@ -13,22 +72,128 @@ export default function CallsPage() {
const { isAdmin } = useAuth();
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
const active = calls.filter((c) => c.status === "active");
const ended = calls.filter((c) => c.status === "ended");
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const [showFilters, setShowFilters] = useState(false);
function set<K extends keyof Filters>(key: K, value: string) {
setFilters((f) => ({ ...f, [key]: value }));
}
const active = calls.filter((c) => c.status === "active");
const ended = calls.filter((c) => c.status === "ended");
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
const activeFilters = isActive(filters);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
<button
onClick={() => setShowFilters((v) => !v)}
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
activeFilters
? "border-indigo-600 bg-indigo-950 text-indigo-300"
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
}`}
>
{showFilters ? "Hide filters" : "Filter"}
{activeFilters && " •"}
</button>
</div>
</div>
{/* Filter bar */}
{showFilters && (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Text search */}
<div className="lg:col-span-2">
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
<input
type="text"
value={filters.query}
onChange={(e) => set("query", e.target.value)}
placeholder="fire, Engine 5, dispatch…"
className={inputCls}
/>
</div>
{/* TGID */}
<div>
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
<input
type="number"
value={filters.tgid}
onChange={(e) => set("tgid", e.target.value)}
placeholder="e.g. 9048"
className={inputCls}
/>
</div>
{/* System */}
<div>
<label className="text-xs text-gray-500 block mb-1">System</label>
<select
value={filters.systemId}
onChange={(e) => set("systemId", e.target.value)}
className={inputCls}
>
<option value="">All systems</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>{s.name}</option>
))}
</select>
</div>
{/* Date from */}
<div>
<label className="text-xs text-gray-500 block mb-1">From date</label>
<input
type="date"
value={filters.dateFrom}
onChange={(e) => set("dateFrom", e.target.value)}
className={inputCls}
/>
</div>
{/* Date to */}
<div>
<label className="text-xs text-gray-500 block mb-1">To date</label>
<input
type="date"
value={filters.dateTo}
onChange={(e) => set("dateTo", e.target.value)}
className={inputCls}
/>
</div>
</div>
{activeFilters && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-gray-500 font-mono">
{filtered.length} of {ended.length} calls match
</p>
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
>
Clear all
</button>
</div>
)}
</div>
)}
{/* Live calls — never filtered */}
{active.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
Live ({active.length})
</h2>
<div className="overflow-x-auto">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
@@ -51,17 +216,20 @@ export default function CallsPage() {
</section>
)}
{/* History */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
History
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
</h2>
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading</p>
) : ended.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
) : filtered.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
</p>
) : (
<>
<div className="overflow-x-auto">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
@@ -74,13 +242,13 @@ export default function CallsPage() {
</tr>
</thead>
<tbody>
{ended.map((c) => (
{filtered.map((c) => (
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
))}
</tbody>
</table>
</div>
{ended.length >= limitCount && (
{!activeFilters && ended.length >= limitCount && (
<button
onClick={() => setLimitCount((n) => n + 100)}
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
+1 -1
View File
@@ -86,7 +86,7 @@ export default function DashboardPage() {
{calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
) : (
<div className="overflow-x-auto">
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
+99
View File
@@ -4,6 +4,105 @@
@import 'leaflet/dist/leaflet.css';
/* ── Base ─────────────────────────────────────────────────────────────────── */
html, body {
@apply bg-gray-950 text-gray-100 font-mono;
}
/* ── Light mode overrides ─────────────────────────────────────────────────── */
/*
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
* text-gray-xxx). Rather than adding dark: prefixes everywhere, we remap those
* classes here when the html element doesn't carry the .dark class.
*/
/* Structural backgrounds */
html:not(.dark),
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
/* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
/* Text */
html:not(.dark) .text-white { color: #0f172a !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
/* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
/* Hover text */
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
/* Fire / Error */
html:not(.dark) .bg-red-900 { background-color: #fef2f2 !important; }
html:not(.dark) .bg-red-950 { background-color: #fff1f2 !important; }
html:not(.dark) .text-red-300 { color: #b91c1c !important; }
html:not(.dark) .text-red-400 { color: #dc2626 !important; }
html:not(.dark) .border-red-800 { border-color: #fca5a5 !important; }
/* Police */
html:not(.dark) .bg-blue-900 { background-color: #eff6ff !important; }
html:not(.dark) .bg-blue-950 { background-color: #eff6ff !important; }
html:not(.dark) .text-blue-300 { color: #1d4ed8 !important; }
html:not(.dark) .border-blue-800 { border-color: #93c5fd !important; }
/* EMS */
html:not(.dark) .bg-yellow-900 { background-color: #fefce8 !important; }
html:not(.dark) .bg-yellow-950 { background-color: #fefce8 !important; }
html:not(.dark) .text-yellow-300 { color: #a16207 !important; }
html:not(.dark) .text-yellow-400 { color: #ca8a04 !important; }
/* Accident / Recording */
html:not(.dark) .bg-orange-900 { background-color: #fff7ed !important; }
html:not(.dark) .bg-orange-950 { background-color: #fff7ed !important; }
html:not(.dark) .text-orange-300 { color: #c2410c !important; }
html:not(.dark) .text-orange-400 { color: #ea580c !important; }
html:not(.dark) .border-orange-800 { border-color: #fdba74 !important; }
/* Active / Online */
html:not(.dark) .bg-green-900 { background-color: #f0fdf4 !important; }
html:not(.dark) .bg-green-950 { background-color: #f0fdf4 !important; }
html:not(.dark) .text-green-300 { color: #15803d !important; }
html:not(.dark) .text-green-400 { color: #16a34a !important; }
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
/* Unconfigured / Info */
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
/* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select,
html:not(.dark) textarea {
color: #0f172a;
}
html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder {
color: #94a3b8;
}
+1 -1
View File
@@ -253,7 +253,7 @@ export default function IncidentsPage() {
}
return (
<div className="p-6 max-w-7xl mx-auto space-y-8">
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
+12 -5
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Nav } from "@/components/Nav";
import { AuthProvider } from "@/components/AuthProvider";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
export const metadata: Metadata = {
@@ -10,12 +11,18 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="dark">
<html lang="en">
<head>
{/* Prevent flash of wrong theme before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var t=localStorage.getItem('drb-theme');if(t!=='light')document.documentElement.classList.add('dark');}catch(e){}})();` }} />
</head>
<body className="min-h-screen bg-gray-950">
<AuthProvider>
<Nav />
<main className="p-6">{children}</main>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<Nav />
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);
+2 -14
View File
@@ -50,26 +50,14 @@ export default function MapPage() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
<div className="flex items-center gap-4 text-xs font-mono text-gray-400">
<span><span className="text-green-400"></span> Online</span>
<span><span className="text-orange-400 animate-pulse"></span> Recording</span>
<span><span className="text-indigo-400"></span> Unconfigured</span>
<span><span className="text-gray-600"></span> Offline</span>
<span className="border-l border-gray-700 pl-4"><span className="text-red-500"></span> Fire</span>
<span><span className="text-blue-500"></span> Police</span>
<span><span className="text-yellow-500"></span> EMS</span>
<span><span className="text-orange-500"></span> Accident</span>
</div>
</div>
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
{loading ? (
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
Loading map
</div>
) : (
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
</div>
)}
+1 -1
View File
@@ -719,7 +719,7 @@ export default function SystemsPage() {
}
return (
<div className="space-y-6 max-w-3xl">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button
+1 -1
View File
@@ -70,7 +70,7 @@ export default function TokensPage() {
if (authLoading || !isAdmin) return null;
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
+23
View File
@@ -138,6 +138,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</div>
)}
{/* Correlation debug — admin only */}
{isAdmin && call.corr_path && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
<span>corr:</span>
<span className="text-gray-400">{call.corr_path}</span>
{call.corr_incident_idle_min != null && (
<span>idle {call.corr_incident_idle_min}min</span>
)}
{call.corr_score != null && (
<span>sim={call.corr_score.toFixed(3)}</span>
)}
{call.corr_distance_km != null && (
<span>dist={call.corr_distance_km}km</span>
)}
{call.corr_shared_units != null && (
<span>{call.corr_shared_units} shared units</span>
)}
{call.corr_candidates != null && (
<span>{call.corr_candidates} candidates</span>
)}
</div>
)}
{/* Transcript */}
{editing ? (
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
+98 -59
View File
@@ -1,6 +1,6 @@
"use client";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import { MapContainer, TileLayer, Marker, Popup, LayersControl, FeatureGroup } from "react-leaflet";
import L from "leaflet";
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
@@ -59,7 +59,6 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
activeCalls.map((c) => [c.node_id, c])
);
// Only show incidents that have been geocoded (location_coords set by the server).
const plottedIncidents = incidents.flatMap((inc) =>
inc.location_coords
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
@@ -81,64 +80,104 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
: 4;
return (
<MapContainer
center={center}
zoom={zoom}
className="w-full h-full rounded-lg"
style={{ background: "#111827" }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
<div className="relative w-full h-full">
<MapContainer
center={center}
zoom={zoom}
className="w-full h-full rounded-lg"
style={{ background: "#111827" }}
>
<LayersControl position="topright">
{/* Base layers */}
<LayersControl.BaseLayer checked name="Dark">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
{/* Node markers */}
{nodes.map((node) => (
<Marker
key={node.node_id}
position={[node.lat, node.lon]}
icon={nodeIcon(node.status)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{node.name}</p>
<p className="text-xs text-gray-500">{node.node_id}</p>
<p className="text-xs mt-1 capitalize">{node.status}</p>
{activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-1">
TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
{activeByNode[node.node_id].talkgroup_name}
</p>
)}
</div>
</Popup>
</Marker>
))}
{/* Overlay: Nodes */}
<LayersControl.Overlay checked name="Nodes">
<FeatureGroup>
{nodes.map((node) => (
<Marker
key={node.node_id}
position={[node.lat, node.lon]}
icon={nodeIcon(node.status)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{node.name}</p>
<p className="text-xs text-gray-500">{node.node_id}</p>
<p className="text-xs mt-1 capitalize">{node.status}</p>
{activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-1">
TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
{activeByNode[node.node_id].talkgroup_name}
</p>
)}
</div>
</Popup>
</Marker>
))}
</FeatureGroup>
</LayersControl.Overlay>
{/* Incident markers — positioned at the node covering the incident's system */}
{plottedIncidents.map(({ inc, pos }) => (
<Marker
key={inc.incident_id}
position={pos}
icon={incidentIcon(inc.type)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
{inc.type ?? "other"}
</p>
<p className="text-xs mt-1 capitalize">{inc.status}</p>
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
View incident
</a>
</div>
</Popup>
</Marker>
))}
</MapContainer>
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
{plottedIncidents.map(({ inc, pos }) => (
<Marker
key={inc.incident_id}
position={pos}
icon={incidentIcon(inc.type)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
{inc.type ?? "other"}
</p>
<p className="text-xs mt-1 capitalize">{inc.status}</p>
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
View incident
</a>
</div>
</Popup>
</Marker>
))}
</FeatureGroup>
</LayersControl.Overlay>
</LayersControl>
</MapContainer>
{/* Legend overlay — inside the map wrapper, above tiles */}
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
<div className="flex items-center gap-2"><span className="text-green-400"></span> Online</div>
<div className="flex items-center gap-2"><span className="text-orange-400"></span> Recording</div>
<div className="flex items-center gap-2"><span className="text-indigo-400"></span> Unconfigured</div>
<div className="flex items-center gap-2"><span className="text-gray-500"></span> Offline</div>
<div className="border-t border-gray-800 my-0.5" />
<div className="flex items-center gap-2"><span className="text-red-500"></span> Fire</div>
<div className="flex items-center gap-2"><span className="text-blue-500"></span> Police</div>
<div className="flex items-center gap-2"><span className="text-yellow-500"></span> EMS</div>
<div className="flex items-center gap-2"><span className="text-orange-500"></span> Accident</div>
</div>
</div>
);
}
+131 -32
View File
@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
import { useTheme } from "@/components/ThemeProvider";
const links = [
{ href: "/dashboard", label: "Dashboard" },
@@ -21,48 +23,145 @@ const adminLinks = [
{ href: "/admin", label: "Admin" },
];
function SunIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
);
}
function MoonIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
);
}
export function Nav() {
const { user, isAdmin, signOut } = useAuth();
const pathname = usePathname();
const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts();
const { theme, toggle } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
if (!user) return null;
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
function navLinkClass(href: string) {
return `text-sm font-mono transition-colors shrink-0 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
}`;
}
return (
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
<Link
key={href}
href={href}
className={`text-sm font-mono transition-colors shrink-0 ${
pathname.startsWith(href)
? "text-white"
: "text-gray-500 hover:text-gray-300"
}`}
>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="ml-auto shrink-0">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out
</button>
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
{/* Main bar */}
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
{/* Desktop links */}
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
{allLinks.map(({ href, label }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
</div>
<div className="ml-auto flex items-center gap-3 shrink-0">
{/* Theme toggle */}
<button
onClick={toggle}
className="text-gray-500 hover:text-gray-300 transition-colors"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
{/* Sign out (desktop) */}
<button
onClick={signOut}
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out
</button>
{/* Hamburger (mobile) */}
<button
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
aria-label="Toggle menu"
>
{mobileOpen ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
)}
</button>
</div>
</div>
{/* Mobile drawer */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
{allLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
onClick={() => setMobileOpen(false)}
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
pathname.startsWith(href) ? "text-white" : "text-gray-500"
}`}
>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="border-t border-gray-800 pt-3 mt-1">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out
</button>
</div>
</div>
)}
</nav>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light";
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
theme: "dark",
toggle: () => {},
});
export function useTheme() {
return useContext(ThemeContext);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const saved = localStorage.getItem("drb-theme") as Theme | null;
if (saved === "light") setTheme("light");
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("drb-theme", theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }}>
{children}
</ThemeContext.Provider>
);
}
+7
View File
@@ -56,6 +56,13 @@ export interface CallRecord {
location: string | null;
tags: string[];
status: "active" | "ended";
// Correlation debug — written by the correlator, present after a call is linked
corr_path?: string | null;
corr_score?: number | null;
corr_distance_km?: number | null;
corr_incident_idle_min?: number | null;
corr_shared_units?: number | null;
corr_candidates?: number | null;
}
export interface IncidentRecord {
+1
View File
@@ -5,6 +5,7 @@ const config: Config = {
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
darkMode: ["class"],
theme: {
extend: {
fontFamily: {