From 3a944f35c1da00dfee74aa677593e9243d7d25e6 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 7 Sep 2026 16:53:03 -0400 Subject: [PATCH 1/2] correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (server-26#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-09-07 measurement window (CORRELATION_REVIEW_0907.md) showed the consensus tiebreaker was the dominant over-split driver: it ran on 45% of calls and resolved link/orphan disagreements as "new" ~24/25 of the time, shattering one Mohegan Park car-alarm job into 9 incidents and opening ~7 incidents from radio checks / roll calls. Two causes, two fixes: 1. `_inc_summary` gave the model `id|type|loc|units|tags|idle` — no title, no talkgroup. It literally could not see that two "car alarms, Mohegan Park Ave/Avenue" incidents on TG 9560 were the same. Now includes the incident title (the strongest same-event signal) and talkgroup. 2. `_RULES` told the model "orphan when in doubt — conservative is always correct". For a system that over-splits, that is backwards: a wrong link is cheap, a duplicate incident is the failure. Rewritten to: prefer link for a plausible same-talkgroup continuation (low bar), reserve "new" for a genuinely different event, and explicitly "orphan" non-incidents (radio checks, roll call, 10-8/10-98, mileage logs). Plus `_extract_road_ids` now canonicalises street-type synonyms (Avenue→ave, Street→st, Road→rd, ...), so "Mohegan Park Avenue" and "Mohegan Park Ave" share a road id — that one difference was splitting the car-alarm incident. +tests/test_correlator_115.py. Full c2-core suite green (sandboxed venv). Bigger levers deferred to follow-ups: the consensus escalation itself (should a cheap-LLM "orphan" ever reach a tiebreak?), a first-class road-overlap fit signal in _call_fits_incident, geocode coverage. Co-Authored-By: Claude Sonnet 5 --- .../app/internal/incident_correlator.py | 25 +++++++-- drb-c2-core/app/internal/llm_correlator.py | 34 ++++++++++-- drb-c2-core/tests/test_correlator_115.py | 54 +++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 drb-c2-core/tests/test_correlator_115.py diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index f94c2bf..53a7922 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -108,16 +108,31 @@ _ROAD_RE = re.compile( ) +# Street-type synonyms collapsed to one token so "Mohegan Park Avenue" and +# "Mohegan Park Ave" produce the same road id (server-26#115 — that one +# difference was splitting a car-alarm incident into two). +_ROAD_SUFFIX_CANON = { + "avenue": "ave", "street": "st", "road": "rd", "drive": "dr", + "boulevard": "blvd", "lane": "ln", "court": "ct", "place": "pl", + "highway": "hwy", "parkway": "pkwy", +} + + 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"} + "at Main Street and Oak Ave" → {"main st", "oak ave"} """ - return { - re.sub(r"[\s.\-]+", " ", m.group().lower()).strip() - for m in _ROAD_RE.finditer(text) - } + ids: set[str] = set() + for m in _ROAD_RE.finditer(text): + key = re.sub(r"[\s.\-]+", " ", m.group().lower()).strip() + parts = key.split() + if parts and parts[-1] in _ROAD_SUFFIX_CANON: + parts[-1] = _ROAD_SUFFIX_CANON[parts[-1]] + key = " ".join(parts) + ids.add(key) + return ids def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool: diff --git a/drb-c2-core/app/internal/llm_correlator.py b/drb-c2-core/app/internal/llm_correlator.py index a5ef817..05178fe 100644 --- a/drb-c2-core/app/internal/llm_correlator.py +++ b/drb-c2-core/app/internal/llm_correlator.py @@ -45,7 +45,18 @@ def _fmt_idle(inc: dict, now: datetime) -> str: def _inc_summary(inc: dict, now: datetime) -> str: + # server-26#115: the model was given no title and no talkgroup, so it + # could not tell that "car alarms, Mohegan Park Ave" and "car alarms, + # Mohegan Park Avenue" on the same channel were one incident — it defaulted + # to "new". Title is the single strongest human-readable signal for "is + # this the same event"; talkgroup is what makes same-channel continuation + # obvious. parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"] + tgs = inc.get("talkgroup_ids") or [] + if tgs: + parts.append(f"tg:[{', '.join(str(t) for t in tgs[:3])}]") + if inc.get("title"): + parts.append(f"title:{inc['title']!r}") if inc.get("location"): parts.append(f"loc:{inc['location']}") units = inc.get("units") or [] @@ -88,11 +99,24 @@ def _call_block(ctx: dict) -> str: _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "", "reasoning": ""}' _RULES = """ -Rules: -- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes. -- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough. -- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene. -- "orphan" when in doubt — conservative is always correct. +Rules (this system OVER-SPLITS — a real incident routinely gets shattered into +5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure +mode. Bias accordingly.): +- Prefer "link" when the call plausibly continues a recent incident ON THE SAME + TALKGROUP: same or overlapping units, the same or an adjacent location (treat + "Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus + the same street is the same place), the same subject/vehicle/case number, or a + follow-up beat ("units clearing", "negative contact", "tow en route", "event + number 214-201", a status update) to an incident that is only a few minutes + idle. The bar for "link" on the same talkgroup is LOW. +- Reserve "new" for a call that clearly describes a DIFFERENT event from every + recent incident — a different place, different units, and a different subject, + not merely a different transmission about the same job. +- "orphan" a call that is not an incident at all: radio checks, roll call, + a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare + acknowledgement. Do not open a "new" incident for these. +- A call on a DIFFERENT talkgroup than an incident still requires unit overlap + or a geocoded/location match — topic similarity alone is not enough there. - Do NOT link just because both calls involve police or both mention a road. """ diff --git a/drb-c2-core/tests/test_correlator_115.py b/drb-c2-core/tests/test_correlator_115.py new file mode 100644 index 0000000..2d8e671 --- /dev/null +++ b/drb-c2-core/tests/test_correlator_115.py @@ -0,0 +1,54 @@ +""" +server-26#115 — the tiebreaker manufactured incidents because it was blind to +what would tell it two incidents are one. + +Two low-risk supports for the reframed prompt: + 1. `_extract_road_ids` collapses street-type synonyms, so "Mohegan Park Ave" + and "Mohegan Park Avenue" share a road id (they were splitting one + car-alarm incident into two). + 2. `_inc_summary` now carries the incident title and talkgroup, the two + signals the model needs to recognise a same-channel continuation. +""" +from datetime import datetime, timezone + +from app.internal.incident_correlator import ( + _extract_road_ids, _location_mentions_road_overlap, +) +from app.internal.llm_correlator import _inc_summary + +NOW = datetime(2026, 9, 7, 8, 0, 0, tzinfo=timezone.utc) + + +def test_avenue_and_ave_are_the_same_road_id(): + assert _extract_road_ids("Mohegan Park Avenue") == _extract_road_ids("Mohegan Park Ave") + assert _extract_road_ids("191 Broadway Street") == _extract_road_ids("191 Broadway St") + assert _extract_road_ids("North State Road") == _extract_road_ids("North State Rd") + + +def test_road_overlap_matches_across_the_synonym(): + assert _location_mentions_road_overlap("multiple car alarms Mohegan Park Avenue", + ["patrol to Mohegan Park Ave"]) is True + # still discriminates genuinely different streets + assert _location_mentions_road_overlap("Oak Avenue", ["Elm Avenue"]) is False + + +def test_inc_summary_carries_title_and_talkgroup(): + s = _inc_summary({ + "incident_id": "abc123", + "type": "police", + "talkgroup_ids": [9560], + "title": "Nuisance Alarm at Mohegan Park Ave", + "location": "Mohegan Park Ave", + "units": ["Headquarters"], + "tags": ["car-alarm"], + "updated_at": NOW.isoformat(), + }, NOW) + assert "title:'Nuisance Alarm at Mohegan Park Ave'" in s + assert "tg:[9560]" in s + assert "id:abc123" in s + + +def test_inc_summary_omits_missing_optional_fields(): + s = _inc_summary({"incident_id": "x", "updated_at": NOW.isoformat()}, NOW) + assert "title:" not in s and "tg:" not in s and "loc:" not in s + assert s.startswith("id:x") -- 2.54.0 From 1a631d65d0e4a6731e196a93a8f42c070c121bc2 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 7 Sep 2026 16:59:29 -0400 Subject: [PATCH 2/2] =?UTF-8?q?correlator:=20address=20#116=20review=20?= =?UTF-8?q?=E2=80=94=20call=20talkgroup=20id=20in=20the=20prompt,=20sort?= =?UTF-8?q?=20candidates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drb-correlation-review: ship, with two bounds the low-bar link rule needs. 1. _call_block emitted only the talkgroup NAME while _inc_summary emits numeric tg ids, so the "same talkgroup" precondition in _RULES was unevaluable and the low link bar applied unconditionally. _call_block now prints "Talkgroup: (id )". 2. ctx["recent"] is an unordered Firestore slice with no order_by; a busy 2h window (~40 active incidents) showed the model an arbitrary half of the candidates. _prompt_incidents() sorts by updated_at desc before the [:20] cap — also makes each row's idle: field monotonic. +2 tests. Full c2-core suite green (sandboxed venv). Review follow-ups (not blockers): _parse_response demotes an unresolvable link to orphan (drops the call) rather than falling back to rules — now on rising link volume; the 45% tiebreak escalation rate / smart-model cost is untouched; _ROAD_RE swallows leading tokens so "10 Parker Street" still won't road-overlap "Parker St". Co-Authored-By: Claude Sonnet 5 --- drb-c2-core/app/internal/llm_correlator.py | 26 ++++++++++++++++++---- drb-c2-core/tests/test_correlator_115.py | 15 ++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/drb-c2-core/app/internal/llm_correlator.py b/drb-c2-core/app/internal/llm_correlator.py index 05178fe..e63aa97 100644 --- a/drb-c2-core/app/internal/llm_correlator.py +++ b/drb-c2-core/app/internal/llm_correlator.py @@ -91,11 +91,29 @@ def _call_block(ctx: dict) -> str: lines.append(f"Units: {ctx['call_units']}") if ctx["call_vehicles"]: lines.append(f"Vehicles: {ctx['call_vehicles']}") - if ctx["talkgroup_name"]: - lines.append(f"Talkgroup: {ctx['talkgroup_name']}") + if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None: + # Both the name and the id — _inc_summary emits numeric tg ids, so the + # id is what makes the "same talkgroup" rule in _RULES evaluable + # (server-26#115 review). + tgid = ctx.get("talkgroup_id") + name = ctx["talkgroup_name"] or "?" + lines.append(f"Talkgroup: {name}" + (f" (id {tgid})" if tgid is not None else "")) return "\n".join(lines) if lines else "(no details)" +def _prompt_incidents(recent: list[dict]) -> list[dict]: + """The ≤20 candidates shown to the model, most-recently-active first. + + `ctx["recent"]` is an unordered slice of a Firestore result with no + order_by, so a busy 2h window (~40 active incidents) meant the model saw + an arbitrary half of the candidates (server-26#115 review). Sorting by + updated_at desc also makes each row's `idle:` field monotonic. + """ + def _key(inc: dict): + return str(inc.get("updated_at") or inc.get("started_at") or "") + return sorted(recent, key=_key, reverse=True)[:20] + + _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "", "reasoning": ""}' _RULES = """ @@ -125,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str: now = ctx["now"] recent = ctx["recent"] inc_block = ( - "\n".join(_inc_summary(inc, now) for inc in recent[:20]) + "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent)) if recent else "(none)" ) return ( @@ -143,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict) now = ctx["now"] recent = ctx["recent"] inc_block = ( - "\n".join(_inc_summary(inc, now) for inc in recent[:20]) + "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent)) if recent else "(none)" ) diff --git a/drb-c2-core/tests/test_correlator_115.py b/drb-c2-core/tests/test_correlator_115.py index 2d8e671..24f46aa 100644 --- a/drb-c2-core/tests/test_correlator_115.py +++ b/drb-c2-core/tests/test_correlator_115.py @@ -14,7 +14,7 @@ from datetime import datetime, timezone from app.internal.incident_correlator import ( _extract_road_ids, _location_mentions_road_overlap, ) -from app.internal.llm_correlator import _inc_summary +from app.internal.llm_correlator import _inc_summary, _prompt_incidents NOW = datetime(2026, 9, 7, 8, 0, 0, tzinfo=timezone.utc) @@ -52,3 +52,16 @@ def test_inc_summary_omits_missing_optional_fields(): s = _inc_summary({"incident_id": "x", "updated_at": NOW.isoformat()}, NOW) assert "title:" not in s and "tg:" not in s and "loc:" not in s assert s.startswith("id:x") + + +def test_prompt_incidents_is_most_recently_active_first_and_capped(): + recent = [ + {"incident_id": f"i{n}", "updated_at": f"2026-09-07T0{n}:00:00+00:00"} + for n in range(1, 8) + ] + ordered = _prompt_incidents(recent) + assert [i["incident_id"] for i in ordered] == ["i7", "i6", "i5", "i4", "i3", "i2", "i1"] + assert len(_prompt_incidents(recent * 5)) == 20 + # falls back to started_at when updated_at is absent, and never raises + assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()}, + {"incident_id": "b"}])[0]["incident_id"] == "a" -- 2.54.0