correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (#115) #116

Merged
logan merged 2 commits from fix/tiebreaker-manufactures-incidents into main 2026-09-07 16:59:46 -04:00
2 changed files with 36 additions and 5 deletions
Showing only changes of commit 1a631d65d0 - Show all commits
+22 -4
View File
@@ -91,11 +91,29 @@ 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["talkgroup_name"]: if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
lines.append(f"Talkgroup: {ctx['talkgroup_name']}") # 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)" 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": "<id_string or null>", "reasoning": "<one sentence>"}' _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
_RULES = """ _RULES = """
@@ -125,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str:
now = ctx["now"] now = ctx["now"]
recent = ctx["recent"] recent = ctx["recent"]
inc_block = ( 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)" if recent else "(none)"
) )
return ( return (
@@ -143,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
now = ctx["now"] now = ctx["now"]
recent = ctx["recent"] recent = ctx["recent"]
inc_block = ( 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)" if recent else "(none)"
) )
+14 -1
View File
@@ -14,7 +14,7 @@ from datetime import datetime, timezone
from app.internal.incident_correlator import ( from app.internal.incident_correlator import (
_extract_road_ids, _location_mentions_road_overlap, _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) 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) 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 "title:" not in s and "tg:" not in s and "loc:" not in s
assert s.startswith("id:x") 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"