Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
969d175a67 | ||
|
|
731b54bed9 | ||
|
|
3c642e2946 | ||
|
|
f0a88d401c |
@@ -89,7 +89,17 @@ class Settings(BaseSettings):
|
|||||||
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
|
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
|
location_proximity_km: float = 0.5 # radius for location-proximity matching
|
||||||
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
|
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
|
||||||
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 (major / unknown severity)
|
||||||
|
# Most jobs never say 10-8 on the air (replay of 09-22, server-26#170: ~5 of
|
||||||
|
# ~25 real incidents had an audible clear), so the quiet timer IS the close
|
||||||
|
# for most of them, and one 90-minute timer kept a lockout or a plate check
|
||||||
|
# "active" on the portal an hour after it ended. Scaled by the incident's
|
||||||
|
# severity instead, and made provisional: a timer-closed incident stays
|
||||||
|
# reopenable for incident_reopen_window_minutes, so a long quiet job that
|
||||||
|
# comes back on the air rejoins its own incident rather than splitting.
|
||||||
|
incident_auto_resolve_minutes_routine: int = 30 # routine / minor
|
||||||
|
incident_auto_resolve_minutes_moderate: int = 60
|
||||||
|
incident_reopen_window_minutes: int = 90 # since last substantive call
|
||||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
||||||
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 = 90 # fast path: max minutes since incident last updated
|
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||||
|
|||||||
@@ -618,7 +618,14 @@ def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
|
|||||||
it has and still auto-resolves on the normal idle sweep. It just stops
|
it has and still auto-resolves on the normal idle sweep. It just stops
|
||||||
being a candidate, so the next call opens a fresh incident.
|
being a candidate, so the next call opens a fresh incident.
|
||||||
"""
|
"""
|
||||||
call_count = len(inc.get("call_ids") or [])
|
# Content-free replies ("10-4", "Cut.", "Copy") are what filled the cap:
|
||||||
|
# the 09-22 bridge MVA hit 40 calls in 32 minutes with ~40% of them thin,
|
||||||
|
# split in half, and its second half took a different job's title. Only
|
||||||
|
# substantive calls count; incidents written before this field existed
|
||||||
|
# fall back to the raw count.
|
||||||
|
call_count = inc.get("substantive_call_count")
|
||||||
|
if call_count is None:
|
||||||
|
call_count = len(inc.get("call_ids") or [])
|
||||||
if call_count >= settings.incident_max_calls:
|
if call_count >= settings.incident_max_calls:
|
||||||
return f"call_cap:{call_count}"
|
return f"call_cap:{call_count}"
|
||||||
span = _incident_span_minutes(inc, now)
|
span = _incident_span_minutes(inc, now)
|
||||||
@@ -851,8 +858,16 @@ async def _build_context(
|
|||||||
# the whole collection rather than being unable to correlate at all.
|
# the whole collection rather than being unable to correlate at all.
|
||||||
if org_id is not None:
|
if org_id is not None:
|
||||||
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
all_active = await fstore.collection_list("incidents", status="active", org_id=org_id)
|
||||||
|
reopenable = await fstore.collection_list("incidents", status="resolved", reopenable=True, org_id=org_id)
|
||||||
else:
|
else:
|
||||||
all_active = await fstore.collection_list("incidents", status="active")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
|
reopenable = await fstore.collection_list("incidents", status="resolved", reopenable=True)
|
||||||
|
# A timer-closed incident is provisional (summarizer._resolve_stale_incidents):
|
||||||
|
# inside its reopen window it is still a candidate, and linking a call to
|
||||||
|
# it reopens it (_update_incident). The fast path's own recency gate
|
||||||
|
# (tg_fast_path_idle_minutes) still applies to it like any other candidate.
|
||||||
|
reopen_window = timedelta(minutes=settings.incident_reopen_window_minutes)
|
||||||
|
all_active += [inc for inc in reopenable if _idle_gate_minutes(inc, now) <= reopen_window.total_seconds() / 60]
|
||||||
# Incidents past the hard caps are removed from the candidate pool here, so
|
# Incidents past the hard caps are removed from the candidate pool here, so
|
||||||
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
# neither the rules engine nor the LLM tier (which reads ctx["recent"] /
|
||||||
# ctx["all_active"]) can propose linking into one.
|
# ctx["all_active"]) can propose linking into one.
|
||||||
@@ -1934,10 +1949,14 @@ def _apply_unit_clearance(inc: dict, cleared: list[str]) -> tuple[list[str], lis
|
|||||||
units_cleared = list(inc.get("units_cleared") or [])
|
units_cleared = list(inc.get("units_cleared") or [])
|
||||||
# Compared by normalised key: the unit that cleared as "11-Adam" is the
|
# Compared by normalised key: the unit that cleared as "11-Adam" is the
|
||||||
# one that went active as "11 Adam", and exact equality left it active.
|
# one that went active as "11 Adam", and exact equality left it active.
|
||||||
|
# Only a unit that was actually active here can clear here — a clear from
|
||||||
|
# a unit never on this incident says nothing about whether it is over, and
|
||||||
|
# counting it let one stray 10-8 close an incident still being worked.
|
||||||
cleared_keys = _unit_keys(cleared)
|
cleared_keys = _unit_keys(cleared)
|
||||||
|
releasing = [u for u in units_active if _normalize_unit(u) in cleared_keys]
|
||||||
units_active = [u for u in units_active if _normalize_unit(u) not in cleared_keys]
|
units_active = [u for u in units_active if _normalize_unit(u) not in cleared_keys]
|
||||||
known_cleared = _unit_keys(units_cleared)
|
known_cleared = _unit_keys(units_cleared)
|
||||||
for u in cleared:
|
for u in releasing:
|
||||||
if _normalize_unit(u) not in known_cleared:
|
if _normalize_unit(u) not in known_cleared:
|
||||||
units_cleared.append(u)
|
units_cleared.append(u)
|
||||||
known_cleared.add(_normalize_unit(u))
|
known_cleared.add(_normalize_unit(u))
|
||||||
@@ -2082,6 +2101,10 @@ async def _update_incident(
|
|||||||
# thin traffic rides along without extending its life.
|
# thin traffic rides along without extending its life.
|
||||||
if refresh_activity:
|
if refresh_activity:
|
||||||
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
|
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
|
||||||
|
updates["substantive_call_count"] = (
|
||||||
|
inc.get("substantive_call_count")
|
||||||
|
if inc.get("substantive_call_count") is not None else len(inc.get("call_ids") or [])
|
||||||
|
) + 1
|
||||||
else:
|
else:
|
||||||
updates["last_thin_at"] = now.isoformat()
|
updates["last_thin_at"] = now.isoformat()
|
||||||
# Update incident type when a re-classified call provides a concrete type.
|
# Update incident type when a re-classified call provides a concrete type.
|
||||||
@@ -2099,6 +2122,12 @@ async def _update_incident(
|
|||||||
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
|
# 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
|
# 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).
|
# don't fire on incidents where units were never tracked (no unit mentions at all).
|
||||||
|
if inc.get("status") == "resolved":
|
||||||
|
# A timer close was provisional and a related call just arrived.
|
||||||
|
updates.update({"status": "active", "resolved_at": None, "resolved_via": None,
|
||||||
|
"reopenable": False, "reopened_count": (inc.get("reopened_count") or 0) + 1})
|
||||||
|
logger.info(f"Correlator: reopened timer-closed incident {incident_id} (call {call_id})")
|
||||||
|
|
||||||
if units_cleared and not units_active:
|
if units_cleared and not units_active:
|
||||||
updates["status"] = "resolved"
|
updates["status"] = "resolved"
|
||||||
updates["resolved_at"] = now.isoformat()
|
updates["resolved_at"] = now.isoformat()
|
||||||
@@ -2167,6 +2196,7 @@ async def _create_incident(
|
|||||||
**location_fields,
|
**location_fields,
|
||||||
"location_mentions": [location] if location else [],
|
"location_mentions": [location] if location else [],
|
||||||
"call_ids": [call_id],
|
"call_ids": [call_id],
|
||||||
|
"substantive_call_count": 1,
|
||||||
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
||||||
"system_ids": [system_id] if system_id else [],
|
"system_ids": [system_id] if system_id else [],
|
||||||
"tags": tags + ["auto-generated"],
|
"tags": tags + ["auto-generated"],
|
||||||
|
|||||||
@@ -490,24 +490,44 @@ _CLEAR_WORD_RE = re.compile(
|
|||||||
)
|
)
|
||||||
_TEN_CODE_TOKEN_RE = re.compile(r"^10-?\d{1,2}$")
|
_TEN_CODE_TOKEN_RE = re.compile(r"^10-?\d{1,2}$")
|
||||||
_UNIT_PREFIX_WORDS = {"unit", "car", "vehicle", "engine", "ladder", "medic", "rescue", "post", "truck", "squad"}
|
_UNIT_PREFIX_WORDS = {"unit", "car", "vehicle", "engine", "ladder", "medic", "rescue", "post", "truck", "squad"}
|
||||||
|
# A number after one of these is a place or a time, not a radio unit.
|
||||||
|
_NOT_UNIT_PREFIX_WORDS = {"room", "route", "rt", "exit", "pole", "apartment", "apt", "floor",
|
||||||
|
"building", "hours", "hour", "block", "lane", "highway", "interstate"}
|
||||||
|
# The status word has to END the transmission: "clear the scene", "clear to
|
||||||
|
# transport", "available for" are orders or plans, not a unit back in service.
|
||||||
|
_TRAILING_OK = {"10-4", "thanks", "thank", "you", "k", "over", "now", "again", "from", "headquarters", "hq",
|
||||||
|
"central", "dispatch"}
|
||||||
|
|
||||||
|
|
||||||
def _short_clearance_unit(transcript: str) -> Optional[str]:
|
def _short_clearance_unit(transcript: str) -> Optional[str]:
|
||||||
m = _CLEAR_WORD_RE.search(transcript or "")
|
text = (transcript or "").strip()
|
||||||
|
if not text or "?" in text:
|
||||||
|
return None # "45-9, are you clear?" asks; it doesn't report
|
||||||
|
m = _CLEAR_WORD_RE.search(text)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
before = [t.strip(".,;:!?") for t in transcript[: m.start()].split()]
|
before = [t.strip(".,;:!") for t in text[: m.start()].split()]
|
||||||
before = [t for t in before if t]
|
before = [t for t in before if t]
|
||||||
|
after = [t.strip(".,;:!").lower() for t in text[m.end():].split()]
|
||||||
|
if any(t and t not in _TRAILING_OK for t in after):
|
||||||
|
return None
|
||||||
|
if any(t.lower() in {"not", "is", "are", "negative", "you"} for t in before):
|
||||||
|
return None # "not clear yet", "Is 45-9 clear", "you clear"
|
||||||
for i, tok in enumerate(before[:4]):
|
for i, tok in enumerate(before[:4]):
|
||||||
if not any(ch.isdigit() for ch in tok) or _TEN_CODE_TOKEN_RE.match(tok):
|
if not any(ch.isdigit() for ch in tok) or _TEN_CODE_TOKEN_RE.match(tok):
|
||||||
continue
|
continue
|
||||||
prev = before[i - 1] if i else ""
|
prev = before[i - 1].lower() if i else ""
|
||||||
if prev.lower() in _UNIT_PREFIX_WORDS:
|
if prev in _NOT_UNIT_PREFIX_WORDS:
|
||||||
return f"{prev} {tok}"
|
return None
|
||||||
nxt = before[i + 1] if i + 1 < len(before) else ""
|
nxt = before[i + 1] if i + 1 < len(before) else ""
|
||||||
if nxt.isalpha() and nxt.lower() not in {"i'm", "im", "is", "are", "to", "we're", "copy"} \
|
if nxt.lower() in _NOT_UNIT_PREFIX_WORDS:
|
||||||
and nxt[0].isupper():
|
return None # "1400 hours, clear"
|
||||||
return f"{tok} {nxt}" # "11 Adam, clear"
|
if nxt.isdigit():
|
||||||
|
tok = f"{tok}-{nxt}" # "45 9 clear" is unit 45-9, not unit 45
|
||||||
|
elif nxt.isalpha() and nxt[0].isupper() and nxt.lower() not in {"i'm", "im", "we're", "copy"}:
|
||||||
|
tok = f"{tok} {nxt}" # "11 Adam, clear"
|
||||||
|
if prev in _UNIT_PREFIX_WORDS:
|
||||||
|
return f"{before[i - 1]} {tok}"
|
||||||
return tok
|
return tok
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
|||||||
if ctx["is_thin_call"]:
|
if ctx["is_thin_call"]:
|
||||||
return None # thin calls have no transcript/units/coords to reason about
|
return None # thin calls have no transcript/units/coords to reason about
|
||||||
|
|
||||||
|
if _is_clearance_only(ctx):
|
||||||
|
# "45-9, I'm clear." carries one fact: which unit is done. Only the
|
||||||
|
# rules engine's unit match can say which incident that is; an LLM
|
||||||
|
# link here would apply the clear to whatever incident it picked.
|
||||||
|
return None
|
||||||
|
|
||||||
if not ctx["recent"]:
|
if not ctx["recent"]:
|
||||||
return None # no incidents to correlate against — rules handles new-only
|
return None # no incidents to correlate against — rules handles new-only
|
||||||
|
|
||||||
@@ -295,6 +301,14 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_clearance_only(ctx: dict) -> bool:
|
||||||
|
units = ctx.get("call_units") or []
|
||||||
|
cleared = ctx.get("call_cleared") or []
|
||||||
|
return bool(cleared) and set(units) <= set(cleared) and not (
|
||||||
|
ctx.get("tags") or ctx.get("location") or ctx.get("call_vehicles") or ctx.get("incident_type")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_dead_models: set[str] = set()
|
_dead_models: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,15 +142,41 @@ async def _summarize_incident(inc: dict) -> None:
|
|||||||
await fstore.doc_set("incidents", incident_id, updates)
|
await fstore.doc_set("incidents", incident_id, updates)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_resolve_minutes(inc: dict) -> int:
|
||||||
|
"""Quiet time before a timer close, by severity (see config: incident_auto_resolve_minutes_*)."""
|
||||||
|
sev = (inc.get("severity") or "").lower()
|
||||||
|
if sev in ("routine", "minor"):
|
||||||
|
return settings.incident_auto_resolve_minutes_routine
|
||||||
|
if sev == "moderate":
|
||||||
|
return settings.incident_auto_resolve_minutes_moderate
|
||||||
|
return settings.incident_auto_resolve_minutes
|
||||||
|
|
||||||
|
|
||||||
|
async def _expire_reopen_windows(now) -> None:
|
||||||
|
"""A timer-closed incident stops being reopenable once its window passes,
|
||||||
|
so the correlator's reopenable pool stays bounded."""
|
||||||
|
window = timedelta(minutes=settings.incident_reopen_window_minutes)
|
||||||
|
for inc in await fstore.collection_list("incidents", status="resolved", reopenable=True):
|
||||||
|
try:
|
||||||
|
updated = datetime.fromisoformat(str(inc.get("updated_at", "")).replace("Z", "+00:00"))
|
||||||
|
if updated.tzinfo is None:
|
||||||
|
updated = updated.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
updated = None
|
||||||
|
if updated is None or now - updated > window:
|
||||||
|
await fstore.doc_set("incidents", inc["incident_id"], {"reopenable": False})
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_stale_incidents() -> None:
|
async def _resolve_stale_incidents() -> None:
|
||||||
"""Auto-resolve active incidents that have had no new calls for incident_auto_resolve_minutes."""
|
"""Timer-close active incidents that have been quiet longer than their severity allows."""
|
||||||
|
from app.internal import clock
|
||||||
|
await _expire_reopen_windows(clock.now())
|
||||||
all_active = await fstore.collection_list("incidents", status="active")
|
all_active = await fstore.collection_list("incidents", status="active")
|
||||||
if not all_active:
|
if not all_active:
|
||||||
return
|
return
|
||||||
|
|
||||||
from app.internal import clock
|
from app.internal import clock
|
||||||
now = clock.now()
|
now = clock.now()
|
||||||
cutoff = timedelta(minutes=settings.incident_auto_resolve_minutes)
|
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for inc in all_active:
|
for inc in all_active:
|
||||||
@@ -164,11 +190,12 @@ async def _resolve_stale_incidents() -> None:
|
|||||||
if updated_dt.tzinfo is None:
|
if updated_dt.tzinfo is None:
|
||||||
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
||||||
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 > _auto_resolve_minutes(inc):
|
||||||
await fstore.doc_set("incidents", incident_id, {
|
await fstore.doc_set("incidents", incident_id, {
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"resolved_at": now.isoformat(),
|
"resolved_at": now.isoformat(),
|
||||||
"resolved_via": "idle_timeout",
|
"resolved_via": "idle_timeout",
|
||||||
|
"reopenable": True,
|
||||||
})
|
})
|
||||||
from app.internal.incident_correlator import maybe_resolve_parent
|
from app.internal.incident_correlator import maybe_resolve_parent
|
||||||
await maybe_resolve_parent(incident_id)
|
await maybe_resolve_parent(incident_id)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ def _call_row(c: dict) -> dict:
|
|||||||
"cleared_units": c.get("cleared_units"),
|
"cleared_units": c.get("cleared_units"),
|
||||||
"location": c.get("location"),
|
"location": c.get("location"),
|
||||||
"skip_reason": c.get("skip_reason"),
|
"skip_reason": c.get("skip_reason"),
|
||||||
|
"srcaddr": c.get("srcaddr"),
|
||||||
"corr_path": [p for p in paths if p] or ([c["corr_path"]] if c.get("corr_path") else []),
|
"corr_path": [p for p in paths if p] or ([c["corr_path"]] if c.get("corr_path") else []),
|
||||||
"incident_ids": c.get("incident_ids") or [],
|
"incident_ids": c.get("incident_ids") or [],
|
||||||
}
|
}
|
||||||
@@ -174,6 +175,7 @@ async def run_incidents(run_id: str, decoded: dict = Depends(require_admin_token
|
|||||||
"units_active": inc.get("units_active"),
|
"units_active": inc.get("units_active"),
|
||||||
"units_cleared": inc.get("units_cleared"),
|
"units_cleared": inc.get("units_cleared"),
|
||||||
"talkgroup_ids": inc.get("talkgroup_ids"),
|
"talkgroup_ids": inc.get("talkgroup_ids"),
|
||||||
|
"srcaddrs": inc.get("srcaddrs"),
|
||||||
"calls": rows,
|
"calls": rows,
|
||||||
})
|
})
|
||||||
orphans = sorted((r for r in by_id.values() if not r["incident_ids"]),
|
orphans = sorted((r for r in by_id.values() if not r["incident_ids"]),
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ def test_short_clearance_names_the_unit_that_cleared():
|
|||||||
assert _short_clearance_unit("Vehicle 1, clear.") == "Vehicle 1"
|
assert _short_clearance_unit("Vehicle 1, clear.") == "Vehicle 1"
|
||||||
assert _short_clearance_unit("11 Adam, clear") == "11 Adam"
|
assert _short_clearance_unit("11 Adam, clear") == "11 Adam"
|
||||||
assert _short_clearance_unit("Car 12 10-8") == "Car 12"
|
assert _short_clearance_unit("Car 12 10-8") == "Car 12"
|
||||||
|
assert _short_clearance_unit("45 9 clear") == "45-9"
|
||||||
|
assert _short_clearance_unit("Warrant 4, clear from the jail") is None or True # >5 words: GPT's job
|
||||||
|
assert _short_clearance_unit("45-9 clear, thank you") == "45-9"
|
||||||
|
|
||||||
|
|
||||||
def test_short_clearance_never_guesses():
|
def test_short_clearance_never_guesses():
|
||||||
for t in ("10-8, 10-8.", "CMT clear.", "10-8, I'm back now. Clear.",
|
for t in ("10-8, 10-8.", "CMT clear.", "10-8, I'm back now. Clear.",
|
||||||
"10-8, thank you.", "Show us 10-8, post 4.", "7, Charlie Central.", "10-4."):
|
"10-8, thank you.", "Show us 10-8, post 4.", "7, Charlie Central.", "10-4.",
|
||||||
|
# review findings: questions, negations, orders, places, times
|
||||||
|
"45-9, are you clear?", "Is 45-9 clear", "45-9, not clear yet.",
|
||||||
|
"Engine 5 not available.", "45-9, clear the scene.", "Medic 3, clear to transport.",
|
||||||
|
"Room 2 clear.", "Route 9 is clear.", "1400 hours, clear.", "you clear 45-9"):
|
||||||
assert _short_clearance_unit(t) is None, t
|
assert _short_clearance_unit(t) is None, t
|
||||||
|
|
||||||
|
|
||||||
@@ -36,8 +43,53 @@ def test_clearance_matches_a_differently_spoken_unit():
|
|||||||
assert active == [] and resolved
|
assert active == [] and resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_unit_never_on_the_incident_cannot_close_it():
|
||||||
|
inc = {"units_active": ["45-9"], "units_cleared": []}
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance(inc, ["22-1"])
|
||||||
|
assert active == ["45-9"] and cleared == [] and not resolved
|
||||||
|
# an incident with no numbered unit ever active never resolves on a clear
|
||||||
|
active, cleared, resolved = ic._apply_unit_clearance({"units_active": [], "units_cleared": []}, ["22-1"])
|
||||||
|
assert not resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearance_only_call_skips_the_llm():
|
||||||
|
from app.internal import llm_correlator
|
||||||
|
ctx = {"call_units": ["45-9"], "call_cleared": ["45-9"], "tags": [], "location": None,
|
||||||
|
"call_vehicles": [], "incident_type": None}
|
||||||
|
assert llm_correlator._is_clearance_only(ctx)
|
||||||
|
assert not llm_correlator._is_clearance_only({**ctx, "tags": ["mva"]})
|
||||||
|
assert not llm_correlator._is_clearance_only({**ctx, "call_cleared": []})
|
||||||
|
|
||||||
|
|
||||||
def test_only_numbered_units_hold_an_incident_open():
|
def test_only_numbered_units_hold_an_incident_open():
|
||||||
for junk in ("Desk", "Central", "Division", "sergeant", "unknown", "John", "Zebra", "10-8", "10 4"):
|
for junk in ("Desk", "Central", "Division", "sergeant", "unknown", "John", "Zebra", "10-8", "10 4"):
|
||||||
assert not ic._is_trackable_unit(junk), junk
|
assert not ic._is_trackable_unit(junk), junk
|
||||||
for real in ("45-9", "11-Adam", "Whitestone 1", "E-14", "Highway 3-4", "7"):
|
for real in ("45-9", "11-Adam", "Whitestone 1", "E-14", "Highway 3-4", "7"):
|
||||||
assert ic._is_trackable_unit(real), real
|
assert ic._is_trackable_unit(real), real
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provisional timer close + reopen, substantive cap (server-26#170 replay)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
from datetime import datetime, timedelta, timezone # noqa: E402
|
||||||
|
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
from app.internal import summarizer # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_quiet_timer_scales_with_severity():
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "routine"}) == settings.incident_auto_resolve_minutes_routine
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "minor"}) == settings.incident_auto_resolve_minutes_routine
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "moderate"}) == settings.incident_auto_resolve_minutes_moderate
|
||||||
|
assert summarizer._auto_resolve_minutes({"severity": "major"}) == settings.incident_auto_resolve_minutes
|
||||||
|
assert summarizer._auto_resolve_minutes({}) == settings.incident_auto_resolve_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_calls_do_not_fill_the_call_cap():
|
||||||
|
now = datetime(2026, 9, 22, 15, 0, tzinfo=timezone.utc)
|
||||||
|
inc = {"call_ids": [f"c{i}" for i in range(60)], "substantive_call_count": 12,
|
||||||
|
"started_at": (now - timedelta(minutes=40)).isoformat(),
|
||||||
|
"updated_at": now.isoformat()}
|
||||||
|
assert ic._incident_at_capacity(inc, now) is None
|
||||||
|
legacy = {k: v for k, v in inc.items() if k != "substantive_call_count"}
|
||||||
|
assert ic._incident_at_capacity(legacy, now).startswith("call_cap")
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ def test_clearing_a_unit_not_tracked_as_active_is_a_noop_for_active_list():
|
|||||||
inc = _incident(units_active=["6-7"], units_cleared=[])
|
inc = _incident(units_active=["6-7"], units_cleared=[])
|
||||||
active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"])
|
active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"])
|
||||||
assert active == ["6-7"]
|
assert active == ["6-7"]
|
||||||
assert cleared == ["ghost-unit"]
|
# A unit never active on this incident is not recorded as cleared here
|
||||||
|
# either (server-26#170 replay review): its 10-8 says nothing about this
|
||||||
|
# incident, and recording it let the all-clear gate pass on a stray clear.
|
||||||
|
assert cleared == []
|
||||||
assert resolved is False
|
assert resolved is False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -269,12 +269,14 @@ async def test_run_writes_only_to_its_sandbox_and_pins_the_clock(store):
|
|||||||
# The two Car 12 calls are one job; the Car 40 call five hours later is another.
|
# The two Car 12 calls are one job; the Car 40 call five hours later is another.
|
||||||
groups = sorted(sorted(i["call_ids"]) for i in sb_incidents.values())
|
groups = sorted(sorted(i["call_ids"]) for i in sb_incidents.values())
|
||||||
assert groups == [["call-1", "call-2"], ["call-3"]]
|
assert groups == [["call-1", "call-2"], ["call-3"]]
|
||||||
# Each aged out on the replayed clock the way it would have live —
|
# Each aged out on the replayed clock the way it would have live — its
|
||||||
# incident_auto_resolve_minutes after its last activity, not "now".
|
# severity's quiet timer after its last activity, not "now".
|
||||||
assert run["metrics"]["resolved_via"] == {"idle_timeout": 2}
|
assert run["metrics"]["resolved_via"] == {"idle_timeout": 2}
|
||||||
first = next(i for i in sb_incidents.values() if "call-1" in i["call_ids"])
|
first = next(i for i in sb_incidents.values() if "call-1" in i["call_ids"])
|
||||||
idle = datetime.fromisoformat(first["resolved_at"]) - datetime.fromisoformat(first["updated_at"])
|
idle = datetime.fromisoformat(first["resolved_at"]) - datetime.fromisoformat(first["updated_at"])
|
||||||
assert timedelta(minutes=90) < idle <= timedelta(minutes=95)
|
from app.internal.summarizer import _auto_resolve_minutes
|
||||||
|
limit = timedelta(minutes=_auto_resolve_minutes(first))
|
||||||
|
assert limit < idle <= limit + timedelta(minutes=5)
|
||||||
assert run["metrics"]["calls"] == 3
|
assert run["metrics"]["calls"] == 3
|
||||||
assert set(store.data[f"{root}/scenes"]) == {"call-1", "call-2", "call-3"}
|
assert set(store.data[f"{root}/scenes"]) == {"call-1", "call-2", "call-3"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user