Correlator prompt: incident age/size + new-event rule (#189) #191

Merged
logan merged 1 commits from fix/189-tiebreak-sink into main 2026-09-27 22:56:18 -04:00
2 changed files with 55 additions and 14 deletions
Showing only changes of commit e009090418 - Show all commits
+42 -14
View File
@@ -31,18 +31,24 @@ from app.config import settings
# Prompt helpers # Prompt helpers
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
def _fmt_idle(inc: dict, now: datetime) -> str: def _fmt_since(raw, now: datetime) -> str:
try: try:
raw = inc.get("updated_at") or inc.get("started_at") or "" dt = datetime.fromisoformat(str(raw or "").replace("Z", "+00:00"))
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
if dt.tzinfo is None: if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc) dt = dt.replace(tzinfo=timezone.utc)
minutes = int((now - dt).total_seconds() / 60) # Clamped: the recorrelation sweep back-dates `now` (see
return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago" # _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: except Exception:
return "?" 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: def _inc_summary(inc: dict, now: datetime) -> str:
# server-26#115: the model was given no title and no talkgroup, so it # 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, # 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: if tags:
parts.append(f"tags:[{', '.join(tags[:4])}]") parts.append(f"tags:[{', '.join(tags[:4])}]")
parts.append(f"idle:{_fmt_idle(inc, now)}") 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) return " | ".join(parts)
@@ -90,6 +109,8 @@ def _call_block(ctx: dict) -> str:
lines.append(f"Units: {ctx['call_units']}") lines.append(f"Units: {ctx['call_units']}")
if ctx["call_vehicles"]: if ctx["call_vehicles"]:
lines.append(f"Vehicles: {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: 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 # 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 # 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": "<id_string or null>", "reasoning": "<one sentence>"}' _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
_RULES = """ _RULES = """
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into Rules (this system usually OVER-SPLITS — a real incident often gets shattered
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure into 5-10 duplicates, so lean toward "link" for follow-ups. The exception is a
mode. Bias accordingly.): call that reports a new event, below.):
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME - 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 TALKGROUP: the same or an adjacent location (treat "Ave"/"Avenue",
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus "St"/"Street", "Rd"/"Road" as identical; a house number plus the same street
the same street is the same place), the same subject/vehicle/case number, or a is the same place), the same subject/vehicle/case number, or a follow-up beat
follow-up beat ("units clearing", "negative contact", "tow en route", "event ("units clearing", "negative contact", "tow en route", "event number
number 214-201", a status update) to an incident that is only a few minutes 214-201", a status update) from units already on it. The bar for "link" on
idle. The bar for "link" on the same talkgroup is LOW. 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 <address>", 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 - Reserve "new" for a call that clearly describes a DIFFERENT event from every
recent incident — a different place, different units, and a different subject, recent incident — a different place, different units, and a different subject,
not merely a different transmission about the same job. not merely a different transmission about the same job.
+13
View File
@@ -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 # falls back to started_at when updated_at is absent, and never raises
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()}, assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
{"incident_id": "b"}])[0]["incident_id"] == "a" {"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