diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 95a01f2..f707902 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -1934,10 +1934,14 @@ def _apply_unit_clearance(inc: dict, cleared: list[str]) -> tuple[list[str], lis units_cleared = list(inc.get("units_cleared") or []) # 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. + # 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) + 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] known_cleared = _unit_keys(units_cleared) - for u in cleared: + for u in releasing: if _normalize_unit(u) not in known_cleared: units_cleared.append(u) known_cleared.add(_normalize_unit(u)) diff --git a/drb-c2-core/app/internal/intelligence.py b/drb-c2-core/app/internal/intelligence.py index 90b641a..b1814a4 100644 --- a/drb-c2-core/app/internal/intelligence.py +++ b/drb-c2-core/app/internal/intelligence.py @@ -490,24 +490,44 @@ _CLEAR_WORD_RE = re.compile( ) _TEN_CODE_TOKEN_RE = re.compile(r"^10-?\d{1,2}$") _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]: - 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: 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] + 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]): if not any(ch.isdigit() for ch in tok) or _TEN_CODE_TOKEN_RE.match(tok): continue - prev = before[i - 1] if i else "" - if prev.lower() in _UNIT_PREFIX_WORDS: - return f"{prev} {tok}" + prev = before[i - 1].lower() if i else "" + if prev in _NOT_UNIT_PREFIX_WORDS: + return None 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"} \ - and nxt[0].isupper(): - return f"{tok} {nxt}" # "11 Adam, clear" + if nxt.lower() in _NOT_UNIT_PREFIX_WORDS: + return None # "1400 hours, 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 None diff --git a/drb-c2-core/app/internal/llm_correlator.py b/drb-c2-core/app/internal/llm_correlator.py index e63aa97..aecea99 100644 --- a/drb-c2-core/app/internal/llm_correlator.py +++ b/drb-c2-core/app/internal/llm_correlator.py @@ -275,6 +275,12 @@ async def decide(call_id: str, ctx: dict) -> Optional[dict]: if ctx["is_thin_call"]: 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"]: 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 +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() diff --git a/drb-c2-core/app/routers/replay.py b/drb-c2-core/app/routers/replay.py index 30e5297..fd38c1a 100644 --- a/drb-c2-core/app/routers/replay.py +++ b/drb-c2-core/app/routers/replay.py @@ -140,6 +140,7 @@ def _call_row(c: dict) -> dict: "cleared_units": c.get("cleared_units"), "location": c.get("location"), "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 []), "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_cleared": inc.get("units_cleared"), "talkgroup_ids": inc.get("talkgroup_ids"), + "srcaddrs": inc.get("srcaddrs"), "calls": rows, }) orphans = sorted((r for r in by_id.values() if not r["incident_ids"]), diff --git a/drb-c2-core/tests/test_clearance_tracking.py b/drb-c2-core/tests/test_clearance_tracking.py index 72a8c69..dbe84c2 100644 --- a/drb-c2-core/tests/test_clearance_tracking.py +++ b/drb-c2-core/tests/test_clearance_tracking.py @@ -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("11 Adam, clear") == "11 Adam" 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(): 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 @@ -36,6 +43,24 @@ def test_clearance_matches_a_differently_spoken_unit(): 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(): for junk in ("Desk", "Central", "Division", "sergeant", "unknown", "John", "Zebra", "10-8", "10 4"): assert not ic._is_trackable_unit(junk), junk diff --git a/drb-c2-core/tests/test_correlator_reassignment_clearance.py b/drb-c2-core/tests/test_correlator_reassignment_clearance.py index eb3001b..48c0afe 100644 --- a/drb-c2-core/tests/test_correlator_reassignment_clearance.py +++ b/drb-c2-core/tests/test_correlator_reassignment_clearance.py @@ -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=[]) active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"]) 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