correlator/intelligence: close the incident-clearance gap (dispatch-to-10-8 lifecycle)
Two independent fixes, found by tracing why incidents never actually close
(only 2/281 incidents in 5 correlation debug windows ever got a non-empty
units_cleared; 183 resolved via the 90-min idle sweep instead of a real clear):
1. Pattern B (re-dispatch accept, no explicit 10-8): reassignment=True already
fired correctly and suppressed the unit from re-linking to its prior
incident, but nothing ever released the unit FROM that incident — it just
sat "active" until the idle sweep timed it out. _release_reassigned_units
now scans other active incidents for unit overlap on a reassignment and
clears the unit there, reusing the same units_active/units_cleared merge
(factored out as _apply_unit_clearance) that explicit 10-8 extraction uses.
2. Pattern A (self-clear) extraction was inconsistent for two reasons: no
per-system unit ID format awareness anywhere in the pipeline (formats vary
by department with zero shared convention), and the cleared_units prompt
rule only accepted a unit self-reporting, missing dispatch confirming a
unit's status back to them. Added system.unit_format_hint (owner-authored
free text, GET/PUT /systems/{id}/unit-format, no auto-induction yet) fed
into the extraction prompt, and broadened the cleared_units rule while
still requiring an identifiable unit ID (guards against bare "10-8"/"clear"
noise, including Whisper hallucination runs already caught upstream by
_is_garbage_transcript).
Verified: 401 pass, 0 fail (local Linux venv ~/venvs/drb-5c — see CLAUDE.md
testing-reality note).
server-26#pending — not yet filed, Gitea unreachable from this sandbox.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a9197709f8
commit
fb0bb15c22
@@ -1452,6 +1452,8 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
|
|||||||
equivalent to reading the flat fields today.
|
equivalent to reading the flat fields today.
|
||||||
"""
|
"""
|
||||||
incident_id = await _apply_decision(decision, ctx)
|
incident_id = await _apply_decision(decision, ctx)
|
||||||
|
if ctx.get("reassignment"):
|
||||||
|
await _release_reassigned_units(ctx, incident_id)
|
||||||
corr_debug = decision.get("corr_debug") or {}
|
corr_debug = decision.get("corr_debug") or {}
|
||||||
if corr_debug:
|
if corr_debug:
|
||||||
scene_index = ctx.get("scene_index", 0)
|
scene_index = ctx.get("scene_index", 0)
|
||||||
@@ -1861,6 +1863,71 @@ def _call_fits_incident(
|
|||||||
return False, "no_signal"
|
return False, "no_signal"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_unit_clearance(inc: dict, cleared: list[str]) -> tuple[list[str], list[str], bool]:
|
||||||
|
"""
|
||||||
|
Merge `cleared` into inc's units_active/units_cleared. Shared by
|
||||||
|
_update_incident (explicit 10-8/back-in-service extraction) and
|
||||||
|
_release_reassigned_units (server-26#<pending> pattern B: a unit accepting
|
||||||
|
a new dispatch, reassignment=True, is real-world evidence they're off
|
||||||
|
their prior call even without an explicit clearance phrase).
|
||||||
|
|
||||||
|
Returns (units_active, units_cleared, auto_resolved) — auto_resolved is
|
||||||
|
True when every tracked unit has now cleared, matching the resolve gate
|
||||||
|
at the bottom of _update_incident.
|
||||||
|
"""
|
||||||
|
units_active = list(inc.get("units_active") or [])
|
||||||
|
units_cleared = list(inc.get("units_cleared") or [])
|
||||||
|
for u in cleared:
|
||||||
|
if u in units_active:
|
||||||
|
units_active.remove(u)
|
||||||
|
if u not in units_cleared:
|
||||||
|
units_cleared.append(u)
|
||||||
|
auto_resolved = bool(units_cleared) and not units_active
|
||||||
|
return units_active, units_cleared, auto_resolved
|
||||||
|
|
||||||
|
|
||||||
|
async def _release_reassigned_units(ctx: dict, exclude_incident_id: Optional[str]) -> None:
|
||||||
|
"""
|
||||||
|
server-26#<pending>: reassignment=True means a unit is accepting a NEW
|
||||||
|
dispatch — real-world evidence they're off whatever they were on before,
|
||||||
|
even when they never say an explicit 10-8/clear phrase (dispatch: "are
|
||||||
|
you able to clear and take a run at X" / unit: "10-4" carries no
|
||||||
|
self-reported clearance language intelligence.py's cleared_units
|
||||||
|
extraction looks for). Without this, that unit's prior incident is only
|
||||||
|
ever closed by the 90-minute idle sweep, not a real clear.
|
||||||
|
|
||||||
|
Scoped to OTHER active incidents (exclude_incident_id keeps this call's
|
||||||
|
own outcome untouched) with unit overlap in units_active — mirrors the
|
||||||
|
unit-continuity candidate scan at :1142 but releases instead of links.
|
||||||
|
"""
|
||||||
|
call_units = ctx.get("call_units")
|
||||||
|
if not call_units:
|
||||||
|
return
|
||||||
|
system_id = ctx.get("system_id")
|
||||||
|
now = ctx["now"]
|
||||||
|
unit_set = _unit_keys(call_units)
|
||||||
|
for inc in ctx.get("all_active") or []:
|
||||||
|
if inc.get("incident_id") == exclude_incident_id:
|
||||||
|
continue
|
||||||
|
if system_id and system_id not in (inc.get("system_ids") or []):
|
||||||
|
continue
|
||||||
|
matched = [u for u in (inc.get("units_active") or []) if _normalize_unit(u) in unit_set]
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
units_active, units_cleared, auto_resolved = _apply_unit_clearance(inc, matched)
|
||||||
|
updates = {"units_active": units_active, "units_cleared": units_cleared}
|
||||||
|
if auto_resolved:
|
||||||
|
updates["status"] = "resolved"
|
||||||
|
updates["resolved_at"] = now.isoformat()
|
||||||
|
await fstore.doc_set("incidents", inc["incident_id"], updates)
|
||||||
|
logger.info(
|
||||||
|
f"Correlator: reassignment released unit(s) {matched} from incident "
|
||||||
|
f"{inc['incident_id']}" + (" (auto-resolved)" if auto_resolved else "")
|
||||||
|
)
|
||||||
|
if auto_resolved:
|
||||||
|
await maybe_resolve_parent(inc["incident_id"])
|
||||||
|
|
||||||
|
|
||||||
async def _update_incident(
|
async def _update_incident(
|
||||||
inc: dict,
|
inc: dict,
|
||||||
call_id: str,
|
call_id: str,
|
||||||
@@ -1904,11 +1971,8 @@ async def _update_incident(
|
|||||||
for u in call_units:
|
for u in call_units:
|
||||||
if u not in units_cleared and u not in units_active:
|
if u not in units_cleared and u not in units_active:
|
||||||
units_active.append(u)
|
units_active.append(u)
|
||||||
for u in (cleared_units or []):
|
inc_with_active_update = {**inc, "units_active": units_active, "units_cleared": units_cleared}
|
||||||
if u in units_active:
|
units_active, units_cleared, _ = _apply_unit_clearance(inc_with_active_update, cleared_units or [])
|
||||||
units_active.remove(u)
|
|
||||||
if u not in units_cleared:
|
|
||||||
units_cleared.append(u)
|
|
||||||
|
|
||||||
# The incident's label and its pin are resolved together, as one value.
|
# The incident's label and its pin are resolved together, as one value.
|
||||||
location = clean_location(location)
|
location = clean_location(location)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
|||||||
Rules:
|
Rules:
|
||||||
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Dispatch-provided addresses take priority over unit-reported positions. Empty string if none.
|
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Dispatch-provided addresses take priority over unit-reported positions. Empty string if none.
|
||||||
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
||||||
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
|
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text. If a unit ID format is given below, use it to recognise a unit spoken in a shortened or partial form (e.g. just the phonetic name alone) as the same unit — but still only extract what is actually said, never fabricate the full form.
|
||||||
- Do not invent details not present in the transcript.
|
- Do not invent details not present in the transcript.
|
||||||
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel and nothing in the transcript contradicts it, return "police" — do NOT fall back to "other" merely because the transmission is administrative. Reserve "other" for traffic that genuinely belongs to no emergency service (rail operations, public works, utility coordination). Reserve "unknown" for transcripts too garbled to place at all.
|
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When the channel is a police channel and nothing in the transcript contradicts it, return "police" — do NOT fall back to "other" merely because the transmission is administrative. Reserve "other" for traffic that genuinely belongs to no emergency service (rail operations, public works, utility coordination). Reserve "unknown" for transcripts too garbled to place at all.
|
||||||
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
||||||
@@ -74,12 +74,12 @@ Rules:
|
|||||||
"major" — life safety or major property loss: structure fire, vehicle pursuit, shots fired, entrapment, cardiac arrest, officer needing assistance.
|
"major" — life safety or major property loss: structure fire, vehicle pursuit, shots fired, entrapment, cardiac arrest, officer needing assistance.
|
||||||
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
||||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
- cleared_units: include a unit whose back-in-service/available status is stated in this recording — either the unit self-reporting (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above) OR dispatch confirming that SPECIFIC unit's status back to them (e.g. the unit asks "how do you show me" and dispatch replies "showing you available" / "in service"). The unit ID must be identifiable either way — a bare "clear" or "10-8" with no unit attached to it is NOT clearance; do not guess which unit said it. Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||||
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
|
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
|
||||||
|
|
||||||
System: {system_id}
|
System: {system_id}
|
||||||
Talkgroup: {talkgroup_name}
|
Talkgroup: {talkgroup_name}
|
||||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
{ten_codes_block}{vocabulary_block}{unit_format_block}{transcript_block}"""
|
||||||
|
|
||||||
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
|
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
|
||||||
# rather than only in the prompt so a model that invents a value cannot write it
|
# rather than only in the prompt so a model that invents a value cannot write it
|
||||||
@@ -156,6 +156,23 @@ def _build_ten_codes_block(ten_codes: dict[str, str]) -> str:
|
|||||||
return f"Department ten-codes:\n{lines}\n\n"
|
return f"Department ten-codes:\n{lines}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_unit_format_block(unit_format_hint: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
server-26#<pending> — unit ID formats vary per department (e.g. Yorktown:
|
||||||
|
"<district>-<phonetic>", "5-David", sometimes spoken as bare "David";
|
||||||
|
County: "<location>-<number>", "SAM-1", "airport-3", "parks-4") with no
|
||||||
|
shared pattern across systems. Without a per-system hint, the model has
|
||||||
|
no way to recognise a unit ID it hasn't seen phrased that way before, and
|
||||||
|
that failure compounds into cleared_units and reassignment detection,
|
||||||
|
both of which depend on first recognising which token IS the unit.
|
||||||
|
Owner-authored free text per system (systems/{id}.unit_format_hint via
|
||||||
|
PUT /systems/{id}/unit-format) — no auto-induction yet.
|
||||||
|
"""
|
||||||
|
if not unit_format_hint:
|
||||||
|
return ""
|
||||||
|
return f"This system's unit ID format: {unit_format_hint}\n\n"
|
||||||
|
|
||||||
|
|
||||||
async def extract_scenes(
|
async def extract_scenes(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
transcript: str,
|
transcript: str,
|
||||||
@@ -182,12 +199,15 @@ async def extract_scenes(
|
|||||||
"""
|
"""
|
||||||
vocabulary: list[str] = []
|
vocabulary: list[str] = []
|
||||||
ten_codes: dict[str, str] = {}
|
ten_codes: dict[str, str] = {}
|
||||||
|
unit_format_hint: str = ""
|
||||||
if system_id:
|
if system_id:
|
||||||
# Single cached read — vocabulary and ten_codes live on the same document.
|
# Single cached read — vocabulary, ten_codes and unit_format_hint all
|
||||||
|
# live on the same document.
|
||||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||||
if system_doc:
|
if system_doc:
|
||||||
vocabulary = system_doc.get("vocabulary") or []
|
vocabulary = system_doc.get("vocabulary") or []
|
||||||
ten_codes = system_doc.get("ten_codes") or {}
|
ten_codes = system_doc.get("ten_codes") or {}
|
||||||
|
unit_format_hint = system_doc.get("unit_format_hint") or ""
|
||||||
|
|
||||||
if _is_garbage_transcript(transcript):
|
if _is_garbage_transcript(transcript):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -254,6 +274,7 @@ async def extract_scenes(
|
|||||||
raw_scenes: list[dict] = await asyncio.to_thread(
|
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||||
_sync_extract,
|
_sync_extract,
|
||||||
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
||||||
|
unit_format_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not raw_scenes:
|
if not raw_scenes:
|
||||||
@@ -677,6 +698,7 @@ def _sync_extract(
|
|||||||
segments: Optional[list[dict]],
|
segments: Optional[list[dict]],
|
||||||
vocabulary: Optional[list[str]] = None,
|
vocabulary: Optional[list[str]] = None,
|
||||||
ten_codes: Optional[dict[str, str]] = None,
|
ten_codes: Optional[dict[str, str]] = None,
|
||||||
|
unit_format_hint: Optional[str] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Call GPT-4o-mini and return a list of scene dicts."""
|
"""Call GPT-4o-mini and return a list of scene dicts."""
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -694,6 +716,7 @@ def _sync_extract(
|
|||||||
system_id=system_id or "unknown",
|
system_id=system_id or "unknown",
|
||||||
ten_codes_block=_build_ten_codes_block(ten_codes or {}),
|
ten_codes_block=_build_ten_codes_block(ten_codes or {}),
|
||||||
vocabulary_block=build_gpt_vocab_block(vocabulary or []),
|
vocabulary_block=build_gpt_vocab_block(vocabulary or []),
|
||||||
|
unit_format_block=_build_unit_format_block(unit_format_hint),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class TenCodesBody(BaseModel):
|
|||||||
ten_codes: Dict[str, str]
|
ten_codes: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitFormatBody(BaseModel):
|
||||||
|
unit_format_hint: str
|
||||||
|
|
||||||
|
|
||||||
class PendingTermBody(BaseModel):
|
class PendingTermBody(BaseModel):
|
||||||
talkgroup_id: int
|
talkgroup_id: int
|
||||||
term: str
|
term: str
|
||||||
@@ -155,6 +159,38 @@ async def update_ten_codes(
|
|||||||
return {"ok": True, "ten_codes": body.ten_codes}
|
return {"ok": True, "ten_codes": body.ten_codes}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unit ID format hint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{system_id}/unit-format")
|
||||||
|
async def get_unit_format(system_id: str):
|
||||||
|
"""Return the unit-ID format hint for a system."""
|
||||||
|
system = await fstore.doc_get("systems", system_id)
|
||||||
|
if not system:
|
||||||
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
return {"unit_format_hint": system.get("unit_format_hint") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{system_id}/unit-format")
|
||||||
|
async def update_unit_format(
|
||||||
|
system_id: str,
|
||||||
|
body: UnitFormatBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Set the free-text unit-ID format hint fed into intelligence.py's
|
||||||
|
extraction prompt (server-26#<pending>). Departments have no shared unit
|
||||||
|
ID convention — e.g. "5-David"/bare "David" vs "SAM-1"/"airport-3" — and
|
||||||
|
the extraction prompt has no way to recognise a format it hasn't been
|
||||||
|
told about. Own route for the same reason ten-codes has one: not carried
|
||||||
|
by the systems form, so folding it into PUT /{id} would wipe it.
|
||||||
|
"""
|
||||||
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
await fstore.doc_update("systems", system_id, {"unit_format_hint": body.unit_format_hint})
|
||||||
|
return {"ok": True, "unit_format_hint": body.unit_format_hint}
|
||||||
|
|
||||||
|
|
||||||
# ── Area context ──────────────────────────────────────────────────────────────
|
# ── Area context ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/{system_id}/area-context")
|
@router.get("/{system_id}/area-context")
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
server-26#<pending> — pattern B clearance: a unit accepting a NEW dispatch
|
||||||
|
("dispatch: are you able to clear and take a run at X / unit: 10-4") carries
|
||||||
|
no self-reported clearance language intelligence.py's cleared_units
|
||||||
|
extraction looks for (that only catches pattern A, "Unit 7, 10-8"). Before
|
||||||
|
this fix, reassignment=True only ever suppressed the unit from re-linking to
|
||||||
|
their prior incident (upload.py's corr_units=[] on reassignment) — nothing
|
||||||
|
ever released them from it, so it sat "active" until the 90-minute idle
|
||||||
|
sweep timed it out instead of being marked cleared by a real event.
|
||||||
|
|
||||||
|
`_release_reassigned_units` closes that gap: when a scene is a reassignment,
|
||||||
|
scan the OTHER active incidents for unit overlap and release the unit there,
|
||||||
|
using the same units_active/units_cleared merge (`_apply_unit_clearance`)
|
||||||
|
that explicit 10-8 extraction already used via `_update_incident`.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.internal.incident_correlator import (
|
||||||
|
_apply_unit_clearance, _release_reassigned_units,
|
||||||
|
)
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 20, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _incident(incident_id="inc-1", units_active=None, units_cleared=None,
|
||||||
|
system_ids=("sys-1",), **overrides):
|
||||||
|
inc = {
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"system_ids": list(system_ids),
|
||||||
|
"units_active": list(units_active or []),
|
||||||
|
"units_cleared": list(units_cleared or []),
|
||||||
|
"status": "active",
|
||||||
|
"updated_at": (NOW - timedelta(minutes=5)).isoformat(),
|
||||||
|
}
|
||||||
|
inc.update(overrides)
|
||||||
|
return inc
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(call_units, all_active, system_id="sys-1", now=NOW):
|
||||||
|
return {"call_units": call_units, "all_active": all_active, "system_id": system_id, "now": now}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _apply_unit_clearance — pure merge logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_clearance_moves_unit_from_active_to_cleared():
|
||||||
|
inc = _incident(units_active=["6-3"], units_cleared=[])
|
||||||
|
active, cleared, resolved = _apply_unit_clearance(inc, ["6-3"])
|
||||||
|
assert active == []
|
||||||
|
assert cleared == ["6-3"]
|
||||||
|
assert resolved is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearance_leaves_other_active_units_alone():
|
||||||
|
inc = _incident(units_active=["6-3", "6-7"], units_cleared=[])
|
||||||
|
active, cleared, resolved = _apply_unit_clearance(inc, ["6-3"])
|
||||||
|
assert active == ["6-7"]
|
||||||
|
assert cleared == ["6-3"]
|
||||||
|
assert resolved is False # 6-7 still active
|
||||||
|
|
||||||
|
|
||||||
|
def test_clearing_a_unit_not_tracked_as_active_is_a_noop_for_active_list():
|
||||||
|
inc = _incident(units_active=["6-7"], units_cleared=[])
|
||||||
|
active, cleared, resolved = _apply_unit_clearance(inc, ["ghost-unit"])
|
||||||
|
assert active == ["6-7"]
|
||||||
|
assert cleared == ["ghost-unit"]
|
||||||
|
assert resolved is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_units_ever_tracked_does_not_auto_resolve():
|
||||||
|
# An incident that never had a unit signal at all — clearing nothing
|
||||||
|
# must not manufacture a resolve.
|
||||||
|
inc = _incident(units_active=[], units_cleared=[])
|
||||||
|
active, cleared, resolved = _apply_unit_clearance(inc, [])
|
||||||
|
assert resolved is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _release_reassigned_units — reassignment releases the unit from its
|
||||||
|
# PRIOR incident, scoped correctly, without touching that incident's calls
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_clears_unit_from_prior_incident():
|
||||||
|
prior = _incident(incident_id="inc-prior", units_active=["6-3", "6-7"])
|
||||||
|
ctx = _ctx(call_units=["6-3"], all_active=[prior])
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id="inc-new")
|
||||||
|
|
||||||
|
assert len(doc_sets) == 1
|
||||||
|
collection, doc_id, data = doc_sets[0]
|
||||||
|
assert collection == "incidents" and doc_id == "inc-prior"
|
||||||
|
assert data["units_active"] == ["6-7"]
|
||||||
|
assert data["units_cleared"] == ["6-3"]
|
||||||
|
assert "status" not in data # 6-7 still active — not auto-resolved
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_auto_resolves_when_last_unit_clears():
|
||||||
|
prior = _incident(incident_id="inc-prior", units_active=["6-3"])
|
||||||
|
ctx = _ctx(call_units=["6-3"], all_active=[prior])
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
async def fake_doc_get(collection, doc_id):
|
||||||
|
return None # no parent — maybe_resolve_parent exits immediately
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
mock_fstore.doc_get = fake_doc_get
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id=None)
|
||||||
|
|
||||||
|
collection, doc_id, data = doc_sets[0]
|
||||||
|
assert data["status"] == "resolved"
|
||||||
|
assert "resolved_at" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_never_touches_the_calls_own_incident():
|
||||||
|
# The call's own decision (link/new) already handled its own incident —
|
||||||
|
# excluding it here prevents double-writing or self-clearing on it.
|
||||||
|
same = _incident(incident_id="inc-new", units_active=["6-3"])
|
||||||
|
ctx = _ctx(call_units=["6-3"], all_active=[same])
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id="inc-new")
|
||||||
|
|
||||||
|
assert doc_sets == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_does_not_cross_systems():
|
||||||
|
other_system = _incident(incident_id="inc-other-sys", units_active=["6-3"], system_ids=("sys-2",))
|
||||||
|
ctx = _ctx(call_units=["6-3"], all_active=[other_system], system_id="sys-1")
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id=None)
|
||||||
|
|
||||||
|
assert doc_sets == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_with_no_call_units_is_a_noop():
|
||||||
|
prior = _incident(incident_id="inc-prior", units_active=["6-3"])
|
||||||
|
ctx = _ctx(call_units=[], all_active=[prior])
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id=None)
|
||||||
|
|
||||||
|
assert doc_sets == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reassignment_matches_units_by_normalized_key():
|
||||||
|
# "5-David" vs "5David" — same unit, different transcription — must
|
||||||
|
# still match via the existing _normalize_unit key, not exact string eq.
|
||||||
|
prior = _incident(incident_id="inc-prior", units_active=["5-David"])
|
||||||
|
ctx = _ctx(call_units=["5 David"], all_active=[prior])
|
||||||
|
|
||||||
|
doc_sets = []
|
||||||
|
async def fake_doc_set(collection, doc_id, data, merge=True):
|
||||||
|
doc_sets.append((collection, doc_id, data))
|
||||||
|
async def fake_doc_get(collection, doc_id):
|
||||||
|
return None # no parent — maybe_resolve_parent exits immediately
|
||||||
|
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_set = fake_doc_set
|
||||||
|
mock_fstore.doc_get = fake_doc_get
|
||||||
|
await _release_reassigned_units(ctx, exclude_incident_id=None)
|
||||||
|
|
||||||
|
assert len(doc_sets) == 1
|
||||||
|
assert doc_sets[0][2]["units_cleared"] == ["5-David"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
server-26#<pending> — no per-system unit-ID format awareness existed anywhere
|
||||||
|
in the pipeline (vocabulary_learner's "known local terms" is a flat glossary,
|
||||||
|
not a structured format). Departments use incompatible unit ID conventions
|
||||||
|
(Yorktown: "5-David", sometimes spoken as bare "David"; County:
|
||||||
|
"SAM-1"/"airport-3"/"parks-4", a location word + number) and the extraction
|
||||||
|
prompt had no way to be told which one a given system uses. This pins the
|
||||||
|
prompt-block builder and the template wiring that carries it.
|
||||||
|
"""
|
||||||
|
from app.internal.intelligence import (
|
||||||
|
_PROMPT_TEMPLATE, _build_unit_format_block, _build_ten_codes_block,
|
||||||
|
_build_transcript_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_hint_produces_no_block():
|
||||||
|
assert _build_unit_format_block(None) == ""
|
||||||
|
assert _build_unit_format_block("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hint_is_labelled_and_fed_to_the_model_verbatim():
|
||||||
|
block = _build_unit_format_block(
|
||||||
|
"Yorktown: <district>-<phonetic name>, e.g. 5-David. Sometimes spoken as just the name alone."
|
||||||
|
)
|
||||||
|
assert "unit ID format" in block
|
||||||
|
assert "5-David" in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_renders_with_all_blocks_including_empty_unit_format():
|
||||||
|
# Regression guard: a missing placeholder in .format() raises KeyError at
|
||||||
|
# request time, not import time — this is the cheapest way to catch that
|
||||||
|
# before it reaches a live call.
|
||||||
|
rendered = _PROMPT_TEMPLATE.format(
|
||||||
|
transcript_block=_build_transcript_block("1. Test.", None),
|
||||||
|
talkgroup_name="Test TG",
|
||||||
|
system_id="sys-1",
|
||||||
|
ten_codes_block=_build_ten_codes_block({}),
|
||||||
|
vocabulary_block="",
|
||||||
|
unit_format_block=_build_unit_format_block(""),
|
||||||
|
)
|
||||||
|
assert "Test TG" in rendered
|
||||||
|
assert "1. Test." in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_template_renders_with_a_populated_unit_format_block():
|
||||||
|
rendered = _PROMPT_TEMPLATE.format(
|
||||||
|
transcript_block=_build_transcript_block("1. Test.", None),
|
||||||
|
talkgroup_name="Test TG",
|
||||||
|
system_id="sys-1",
|
||||||
|
ten_codes_block=_build_ten_codes_block({}),
|
||||||
|
vocabulary_block="",
|
||||||
|
unit_format_block=_build_unit_format_block("County: <location>-<number>, e.g. SAM-1, airport-3."),
|
||||||
|
)
|
||||||
|
assert "SAM-1" in rendered
|
||||||
Reference in New Issue
Block a user