Let C2 name a talkgroup it already knows
84 of the 100 incidents in the 2026-08-23 dump were titled "Ems — TGID 9048"
or "Other — TGID 9600" -- the fallback, not a description. The title is the
incident's name everywhere it appears: list rows, map pins, Discord alerts.
_create_incident builds it from a content tag and a talkgroup label, and the
label was collapsing to "TGID {id}" because talkgroup_name arrived as None.
It is a plain form field on /upload, forwarded untouched into correlation, and
the node only sends it when OP25 had the name in its loaded tags file -- which
is exactly the case C2 can cover from its own systems collection, where all 125
talkgroup definitions live.
The lookup already existed, on the other path: mqtt_handler resolved it from
the system config on call_start. So the call document held the right name while
the pipeline that titles the incident ignored it. That asymmetry is the bug.
internal/talkgroups.py is now the one implementation -- caller's hint, then the
call document, then the system config -- and both paths use it.
_run_intelligence_pipeline resolves once at the funnel /upload and
/calls/{id}/reprocess share, so the dispatch-channel test, scene extraction and
the title all see a real name. When the call document was the thing missing it,
the resolved name is written back, so the archive and the orphan panel stop
showing a bare TGID too.
Also gives fast/thin a corr_fit_signal. It is 63% of all links and was the only
path writing none, so corr_fit_signal was absent on 295 of 309 calls and the
admin debug view's distribution panel read empty -- looking broken when it was
faithfully reporting that the dominant path records nothing. It now says
thin_recency, which is what actually decided it.
Closes server-26#34. Refs server-26#35 -- the tier's 3.5% invocation rate is a
cost/benefit question, not a bug, and stays open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a278e2215a
commit
039a06dc72
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Unit tests for talkgroup name resolution.
|
||||
|
||||
From the 2026-08-23 correlation dump: 84 of 100 incidents were titled
|
||||
"Ems — TGID 9048" rather than "Ems — Ossining Police Dispatch", because
|
||||
/upload took `talkgroup_name` from a multipart form field the node only fills
|
||||
when OP25 already had the name — and never fell back to the system config the
|
||||
way mqtt_handler's call_start path did. server-26#34.
|
||||
|
||||
resolve() is the single implementation both paths now share. These tests pin
|
||||
its preference order, since the whole bug was one caller skipping a step.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.internal import talkgroups
|
||||
|
||||
SYSTEM = {
|
||||
"config": {
|
||||
"talkgroups": [
|
||||
{"id": 9048, "name": "Ossining - Police Dispatch"},
|
||||
{"id": 9600, "name": "MTA PD Districts 6/7/11 - Police Dispatch"},
|
||||
{"id": 9563, "name": ""}, # present but unnamed
|
||||
{"id": "9211", "name": "Ardsley"}, # id stored as a string
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _system(doc=SYSTEM):
|
||||
return patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock(return_value=doc))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hint_wins_over_everything():
|
||||
"""OP25 knew the name — no Firestore read at all."""
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
got = await talkgroups.resolve("sys-1", 9048, hint="Whatever OP25 Said")
|
||||
assert got == "Whatever OP25 Said"
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_doc_used_before_system_config():
|
||||
"""The call document already carries the name written at call_start."""
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
got = await talkgroups.resolve(
|
||||
"sys-1", 9048, hint=None, call_doc={"talkgroup_name": "From Call Doc"}
|
||||
)
|
||||
assert got == "From Call Doc"
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_system_config():
|
||||
"""The case that was broken: nothing upstream knew the name, C2 did."""
|
||||
with _system():
|
||||
got = await talkgroups.resolve("sys-1", 9048, hint=None, call_doc={})
|
||||
assert got == "Ossining - Police Dispatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_call_doc_name_does_not_block_the_lookup():
|
||||
"""A falsy talkgroup_name on the call doc must not short-circuit."""
|
||||
with _system():
|
||||
got = await talkgroups.resolve(
|
||||
"sys-1", 9600, hint=None, call_doc={"talkgroup_name": ""}
|
||||
)
|
||||
assert got == "MTA PD Districts 6/7/11 - Police Dispatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_talkgroup_ids_in_config_still_match():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 9211) == "Ardsley"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"system_id, tgid",
|
||||
[(None, 9048), ("sys-1", None), (None, None)],
|
||||
)
|
||||
async def test_missing_inputs_return_none_without_reading(system_id, tgid):
|
||||
with patch.object(talkgroups.fstore, "doc_get_cached", AsyncMock()) as m:
|
||||
assert await talkgroups.resolve(system_id, tgid) is None
|
||||
m.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_talkgroup_returns_none_so_caller_keeps_tgid_fallback():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 1234) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_entry_with_empty_string_returns_none():
|
||||
"""An entry that exists but has no name is not a name."""
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", 9563) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_system_document_returns_none():
|
||||
with _system(doc=None):
|
||||
assert await talkgroups.resolve("sys-1", 9048) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unparseable_talkgroup_id_returns_none():
|
||||
with _system():
|
||||
assert await talkgroups.resolve("sys-1", "not-a-number") is None
|
||||
Reference in New Issue
Block a user