From e00909041811945c20edae6391e9ea5e10feb464 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 27 Sep 2026 19:42:16 -0400 Subject: [PATCH] Correlator prompt: show incident age/size, stop unit overlap chaining new jobs (#189) The tiebreaker forced ~all of the 09-27 rollover sink's bad links: idle resets on every link so a 2.5h, 40-call incident read as fresh, and the rules treated unit overlap as enough on a county channel where units work jobs back to back. Co-Authored-By: Claude Opus 5.5 --- drb-c2-core/app/internal/llm_correlator.py | 56 ++++++++++++++++------ drb-c2-core/tests/test_correlator_115.py | 13 +++++ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/drb-c2-core/app/internal/llm_correlator.py b/drb-c2-core/app/internal/llm_correlator.py index 32ed441..2b3e0be 100644 --- a/drb-c2-core/app/internal/llm_correlator.py +++ b/drb-c2-core/app/internal/llm_correlator.py @@ -31,18 +31,24 @@ from app.config import settings # Prompt helpers # ───────────────────────────────────────────────────────────────────────────── -def _fmt_idle(inc: dict, now: datetime) -> str: +def _fmt_since(raw, now: datetime) -> str: try: - raw = inc.get("updated_at") or inc.get("started_at") or "" - dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + dt = datetime.fromisoformat(str(raw or "").replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) - minutes = int((now - dt).total_seconds() / 60) - return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago" + # Clamped: the recorrelation sweep back-dates `now` (see + # _idle_gate_minutes), which would otherwise print "-12min". + minutes = max(int((now - dt).total_seconds() / 60), 0) + return f"{minutes}min" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m" except Exception: return "?" +def _fmt_idle(inc: dict, now: datetime) -> str: + since = _fmt_since(inc.get("updated_at") or inc.get("started_at"), now) + return since if since == "?" else f"{since} ago" + + 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, @@ -65,6 +71,19 @@ def _inc_summary(inc: dict, now: datetime) -> str: if tags: parts.append(f"tags:[{', '.join(tags[:4])}]") parts.append(f"idle:{_fmt_idle(inc, now)}") + # server-26#189: idle alone made a 2.5h-old job look as fresh as a + # 5-minute one — every link resets idle, so a busy channel's oldest + # incident always reads "2min ago" and keeps winning. Age and size let + # the model see it has become a catch-all. + if inc.get("started_at"): + from app.internal.incident_correlator import _incident_span_minutes + span = int(_incident_span_minutes(inc, now)) + parts.append(f"open:{span}min" if span < 60 else f"open:{span // 60}h{span % 60:02d}m") + n_calls = inc.get("substantive_call_count") + if n_calls is None: + n_calls = len(inc.get("call_ids") or []) + if n_calls: + parts.append(f"calls:{n_calls}") return " | ".join(parts) @@ -90,6 +109,8 @@ 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.get("reassignment"): + lines.append("Reassignment: yes — a unit is being sent to a NEW job") 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 @@ -116,16 +137,23 @@ def _prompt_incidents(recent: list[dict]) -> list[dict]: _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "", "reasoning": ""}' _RULES = """ -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.): +Rules (this system usually OVER-SPLITS — a real incident often gets shattered +into 5-10 duplicates, so lean toward "link" for follow-ups. The exception is a +call that reports a new event, below.): - 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. + TALKGROUP: 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) from units already on it. The bar for "link" on + the same talkgroup is LOW for follow-ups. +- EXCEPT: a call that REPORTS an event is "new" unless it names the same place + or subject as the incident: "another accident", "a second MVA", "getting + flagged down for", "just got a report of", "respond to
", a + reassignment. Patrol units on a shared channel work many jobs back to back, + so a unit already on an incident is not evidence that its next job is the + same one — especially once that incident has been open a long time (see + open:/calls:). - 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. diff --git a/drb-c2-core/tests/test_correlator_115.py b/drb-c2-core/tests/test_correlator_115.py index 24f46aa..e9b865c 100644 --- a/drb-c2-core/tests/test_correlator_115.py +++ b/drb-c2-core/tests/test_correlator_115.py @@ -65,3 +65,16 @@ def test_prompt_incidents_is_most_recently_active_first_and_capped(): # 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" + + +def test_inc_summary_shows_age_and_size_not_just_idle(): + # server-26#189: every link resets idle, so a 2.5h catch-all read as fresh. + s = _inc_summary({ + "incident_id": "sink", + "started_at": "2026-09-07T05:26:00+00:00", + "updated_at": "2026-09-07T07:58:00+00:00", + "call_ids": [f"c{n}" for n in range(40)], + }, NOW) + assert "idle:2min ago" in s + assert "open:2h34m" in s + assert "calls:40" in s