Correlator prompt: incident age/size + new-event rule (#189) #191
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user