From 1a631d65d0e4a6731e196a93a8f42c070c121bc2 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 7 Sep 2026 16:59:29 -0400 Subject: [PATCH] =?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"