correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (#115) #116
@@ -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": "<id_string or null>", "reasoning": "<one sentence>"}'
|
||||
|
||||
_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)"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user