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
This commit is contained in:
Logan
2026-05-10 19:49:05 -04:00
parent 7e1b01a275
commit 8b660d8e10
9 changed files with 509 additions and 23 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ class Settings(BaseSettings):
location_proximity_km: float = 0.5 # radius for location-proximity matching 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 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 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 learning
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
+433 -17
View File
@@ -48,6 +48,84 @@ from app.config import settings
_DISPATCH_TG_RE = re.compile(r"\bdispatch\b|\bdisp\b", re.IGNORECASE) _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: def _tag_to_title(tag: str) -> str:
""" """
@@ -94,6 +172,7 @@ async def correlate_call(
create_if_new: bool = True, create_if_new: bool = True,
units: Optional[list[str]] = None, units: Optional[list[str]] = None,
vehicles: Optional[list[str]] = None, vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
@@ -123,6 +202,7 @@ async def correlate_call(
# scene-level breakdown. # scene-level breakdown.
call_units: list[str] = units if units is not None else (call_doc.get("units") or []) 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_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" call_severity: str = call_doc.get("severity") or "unknown"
# Use passed coords first (freshly geocoded), fall back to what's on the call doc # 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") coords: Optional[dict] = location_coords or call_doc.get("location_coords")
@@ -199,18 +279,67 @@ async def correlate_call(
f"from {candidate['incident_id']}; will attempt new incident" f"from {candidate['incident_id']}; will attempt new incident"
) )
elif len(tg_recent) > 1: elif len(tg_recent) > 1:
matched_incident = _disambiguate( candidate = _disambiguate(
tg_recent, call_units, call_vehicles, coords, call_embedding tg_recent, call_units, call_vehicles, coords, call_embedding
) )
corr_debug = { # Disambiguate picks the best candidate, but still verify the call
"corr_path": "fast/disambig", # actually fits before committing — a new unrelated call on a busy
"corr_incident_idle_min": round(_incident_idle_minutes(matched_incident, now), 1), # dispatch channel should create its own incident, not be force-merged.
"corr_candidates": len(tg_recent), if _call_fits_incident(
} candidate, call_units, call_vehicles, coords,
logger.info( settings.location_proximity_km, is_dispatch=is_dispatch,
f"Correlator fast-path (disambig {len(tg_recent)} candidates): " ):
f"call {call_id}{matched_incident['incident_id']}" 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) ───────── # ── 2. Location path: proximity match (time-limited, cross-type) ─────────
if not matched_incident and coords: if not matched_incident and coords:
@@ -331,16 +460,87 @@ async def correlate_call(
matched_incident, call_id, talkgroup_id, system_id, tags, matched_incident, call_id, talkgroup_id, system_id, tags,
location, location_coords, call_units, call_vehicles, call_embedding, now, location, location_coords, call_units, call_vehicles, call_embedding, now,
talkgroup_name=talkgroup_name, incident_type=incident_type, talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared,
) )
elif incident_type and create_if_new: elif create_if_new:
incident_id = await _create_incident( # If GPT returned no type (missing talkgroup context is common), attempt
call_id, incident_type, talkgroup_id, talkgroup_name, system_id, # to recover a type from the extracted tags before giving up on creation.
tags, location, location_coords, if not incident_type and tags:
call_units, call_vehicles, call_embedding, call_severity, now, incident_type = _infer_type_from_tags(tags)
) if incident_type:
corr_debug["corr_path"] = "new" 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: else:
# No match and either no type or creation suppressed — nothing to do # Creation suppressed (re-correlation sweep) — nothing to do
return None return None
# Persist the correlation decision to the call document so it can be # Persist the correlation decision to the call document so it can be
@@ -508,6 +708,7 @@ async def _update_incident(
now: datetime, now: datetime,
talkgroup_name: Optional[str] = None, talkgroup_name: Optional[str] = None,
incident_type: Optional[str] = None, incident_type: Optional[str] = None,
cleared_units: Optional[list[str]] = None,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -527,6 +728,19 @@ async def _update_incident(
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units)) merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles)) 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 []) location_mentions = list(inc.get("location_mentions") or [])
if location and location not in location_mentions: if location and location not in location_mentions:
location_mentions.append(location) location_mentions.append(location)
@@ -543,6 +757,8 @@ async def _update_incident(
"tags": merged_tags, "tags": merged_tags,
"units": merged_units, "units": merged_units,
"vehicles": merged_vehicles, "vehicles": merged_vehicles,
"units_active": units_active,
"units_cleared": units_cleared,
"location_mentions": location_mentions, "location_mentions": location_mentions,
"updated_at": now.isoformat(), "updated_at": now.isoformat(),
"summary_stale": True, "summary_stale": True,
@@ -574,6 +790,19 @@ async def _update_incident(
elif primary_tag: elif primary_tag:
updates["title"] = 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) await fstore.doc_set("incidents", incident_id, updates)
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}") logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
@@ -612,6 +841,7 @@ async def _create_incident(
doc = { doc = {
"incident_id": incident_id, "incident_id": incident_id,
"title": title, "title": title,
"incident_type": "master", # structural role; "child" set on demotion
"type": incident_type, "type": incident_type,
"status": "active", "status": "active",
"location": location, "location": location,
@@ -622,6 +852,8 @@ async def _create_incident(
"system_ids": [system_id] if system_id else [], "system_ids": [system_id] if system_id else [],
"tags": tags + ["auto-generated"], "tags": tags + ["auto-generated"],
"units": call_units, "units": call_units,
"units_active": list(call_units),
"units_cleared": [],
"vehicles": call_vehicles, "vehicles": call_vehicles,
"severity": call_severity, "severity": call_severity,
"summary": None, "summary": None,
@@ -652,6 +884,190 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
return {"embedding": call_embedding, "embedding_count": 1} 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: def _cosine_similarity(a: list[float], b: list[float]) -> float:
import numpy as np import numpy as np
va, vb = np.array(a, dtype=float), np.array(b, dtype=float) va, vb = np.array(a, dtype=float), np.array(b, dtype=float)
+7
View File
@@ -29,6 +29,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
location: most specific location string found, or empty string location: most specific location string found, or empty string
vehicles: list of vehicle descriptions mentioned vehicles: list of vehicle descriptions mentioned
units: list of unit IDs or officer numbers explicitly 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" severity: one of "minor" | "moderate" | "major" | "unknown"
resolved: true if this scene explicitly signals incident closure, false otherwise 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. 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.
@@ -42,6 +43,7 @@ Rules:
- 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". - 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. - 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". - 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. - 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. - 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.
@@ -129,6 +131,7 @@ async def extract_scenes(
location: Optional[str] = scene.get("location") or None location: Optional[str] = scene.get("location") or None
vehicles: list[str] = scene.get("vehicles") or [] vehicles: list[str] = scene.get("vehicles") or []
units: list[str] = scene.get("units") or [] units: list[str] = scene.get("units") or []
cleared_units: list[str] = scene.get("cleared_units") or []
severity: str = scene.get("severity") or "unknown" severity: str = scene.get("severity") or "unknown"
resolved: bool = bool(scene.get("resolved", False)) resolved: bool = bool(scene.get("resolved", False))
reassignment: bool = bool(scene.get("reassignment", False)) reassignment: bool = bool(scene.get("reassignment", False))
@@ -160,6 +163,7 @@ async def extract_scenes(
"location_coords": location_coords, "location_coords": location_coords,
"vehicles": vehicles, "vehicles": vehicles,
"units": units, "units": units,
"cleared_units": cleared_units,
"severity": severity, "severity": severity,
"resolved": resolved, "resolved": resolved,
"reassignment": reassignment, "reassignment": reassignment,
@@ -175,6 +179,7 @@ async def extract_scenes(
all_tags = list(dict.fromkeys(t for s in processed for t in s["tags"])) 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_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_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"]} updates: dict = {"tags": all_tags, "severity": primary["severity"]}
if primary["location"]: if primary["location"]:
@@ -183,6 +188,8 @@ async def extract_scenes(
updates["location_coords"] = primary["location_coords"] updates["location_coords"] = primary["location_coords"]
if all_units: if all_units:
updates["units"] = all_units updates["units"] = all_units
if all_cleared:
updates["cleared_units"] = all_cleared
if all_vehicles: if all_vehicles:
updates["vehicles"] = all_vehicles updates["vehicles"] = all_vehicles
if primary["embedding"]: if primary["embedding"]:
+5 -4
View File
@@ -109,10 +109,11 @@ class MQTTHandler:
updates["status"] = "online" updates["status"] = "online"
await fstore.doc_update("nodes", node_id, updates) await fstore.doc_update("nodes", node_id, updates)
# Release any orphaned Discord token when the node explicitly reports disconnected # NOTE: discord_connected in checkins is informational only — do NOT release the
if payload.get("discord_connected") is False: # token here. The bot watchdog reconnects on transient Discord drops, so a single
from app.routers.tokens import release_token # checkin with discord_connected=False during a brief reconnect window would
await release_token(node_id) # 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 # Status update
@@ -46,9 +46,15 @@ async def _run_sweep_pass() -> None:
("status", "==", "ended"), ("status", "==", "ended"),
("ended_at", ">=", cutoff), ("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 = [ orphans = [
c for c in recent_ended c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id") 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: if not orphans:
@@ -87,6 +93,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
incident_type = call.get("incident_type"), incident_type = call.get("incident_type"),
location = call.get("location"), location = call.get("location"),
location_coords= call.get("location_coords"), location_coords= call.get("location_coords"),
cleared_units = call.get("cleared_units") or [],
reference_time = started_at, # anchor window to when the call happened reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only 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}" f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
) )
return True 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 return False
+2
View File
@@ -102,6 +102,8 @@ async def _resolve_stale_incidents() -> None:
idle_minutes = (now - updated_dt).total_seconds() / 60 idle_minutes = (now - updated_dt).total_seconds() / 60
if idle_minutes > settings.incident_auto_resolve_minutes: if idle_minutes > settings.incident_auto_resolve_minutes:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) 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( logger.info(
f"Auto-resolved stale incident {incident_id} " f"Auto-resolved stale incident {incident_id} "
f"(idle {idle_minutes:.0f}m)" f"(idle {idle_minutes:.0f}m)"
+13 -1
View File
@@ -53,12 +53,24 @@ async def send_command(node_id: str, cmd: CommandPayload):
payload = cmd.model_dump(exclude_none=True) payload = cmd.model_dump(exclude_none=True)
if cmd.action == "discord_join": 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) token = await assign_token(node_id, preferred_token_id=preferred)
if not token: if not token:
raise HTTPException(503, "No Discord bot tokens available in the pool.") raise HTTPException(503, "No Discord bot tokens available in the pool.")
payload["token"] = token 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": elif cmd.action == "discord_leave":
await release_token(node_id) await release_token(node_id)
+28
View File
@@ -60,6 +60,34 @@ async def flush_tokens():
return {"released": len(results)} 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) @router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str): async def delete_token(token_id: str):
existing = await fstore.doc_get("bot_tokens", token_id) existing = await fstore.doc_get("bot_tokens", token_id)
+4
View File
@@ -125,11 +125,13 @@ async def _run_extraction_pipeline(
location_coords=scene["location_coords"], location_coords=scene["location_coords"],
units=corr_units, units=corr_units,
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
if scene["resolved"] and incident_id: if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) 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)") logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
if incident_ids: if incident_ids:
@@ -224,11 +226,13 @@ async def _run_intelligence_pipeline(
location_coords=scene["location_coords"], location_coords=scene["location_coords"],
units=corr_units, units=corr_units,
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
if scene["resolved"] and incident_id: if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {"status": "resolved"}) 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)") logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
# Correlator also runs for calls with no scenes (unclassified) to attempt # Correlator also runs for calls with no scenes (unclassified) to attempt