correlation: rules decide, one cheap LLM call only when ambiguous; drop the consensus tiebreak (server-26#299)

The rules engine now commits every confident decision alone. _run_decision
marks a decision ambiguous (new_with_candidates, unfit_tg_candidate,
unverified_unit_link) and only then does upload._correlate make ONE
llm_correlator.decide call; a usable answer is committed, otherwise the rules
decision stands. Removed: _correlate_with_consensus, llm_correlator.tiebreak /
decisions_agree / tiebreak prompt, corr_smart_model, the correlation_smart
health tier, the LLM-orphan gate (_call_is_substanceless, effectively dead:
it could only fire on minor-severity untyped calls) and its sweep budget.
corr_consensus is now rules_only | llm | rules_fallback | rules_guard, with
corr_band naming why; admin debug and replay metrics read the new values.

Section fixes in the same pass:
- #115: no tiebreaker left to manufacture incidents; an LLM orphan of a
  rules "new" is honoured only for untyped, substanceless, routine/minor calls.
- #86: divergence veto bar moved to config (embedding_divergence_threshold);
  the band between it and the match bar is ambiguous, not a silent merge.
- #103: a unit link with no embedding is "unit_overlap_unverified" ->
  ambiguous, instead of skipping the veto and linking as verified.
- #189: unit-only and LLM links refused once an incident is open 90 min;
  unit links on incidents open >30 min are ambiguous; LLM links distance-
  guarded (incident_max_spread_km) and only to shown candidates.
- #190: severity steps down on an explicit stand-down phrase; new
  severity_peak keeps the monotonic max.
- #88: parse_llm_bool fails closed on "false"; scene closures honoured only
  on confident links or the incident the scene opened.
- #27: sweep budget is the scan window (wall clock), not 3 attempts.
- LLM prompt: "Reserve new" bullet no longer contradicts the #189 EXCEPT.
- updated_at never moves backward on a back-dated link.

Unmeasured: no live AI or replay (credits unfunded); band frequency, F1 and
thresholds are unverified. Must be re-scored on both answer keys in Replay
before merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:26:38 -04:00
co-authored by Claude Opus 5.5
parent f4d78aba77
commit 3246fc97b0
18 changed files with 1207 additions and 1062 deletions
+39 -16
View File
@@ -70,20 +70,16 @@ class Settings(BaseSettings):
# Gemini (intelligence extraction, embeddings, incident summaries)
gemini_api_key: Optional[str] = None
# Correlation consensus models
# corr_cheap_model — first-pass LLM correlator (runs on every call)
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
# Both IDs below were retired by Google and returned 404 on every call from
# some point before 2026-08-18 until they were corrected. Because a failed
# LLM call falls back to the rules decision, nothing broke loudly -- the
# entire LLM tier and the consensus tiebreak were simply dead in production
# while correlation behaviour was being tuned against rules-only output.
# Verify against https://ai.google.dev/gemini-api/docs/models before changing.
# Correlation LLM (server-26#299). The rules engine decides every confident
# case; this model is called at most ONCE per scene, and only when the rules
# flag the decision as ambiguous (incident_correlator._ambiguity). There is
# no second "smart" tier any more: the cheap/smart consensus tiebreak ran on
# ~15% of scenes and resolved nearly every disagreement as "new" (#115).
# The model ID was once retired by Google and 404'd on every call for days;
# a failed LLM call falls back to the rules decision, so nothing broke
# loudly. Verify against https://ai.google.dev/gemini-api/docs/models
# before changing.
corr_cheap_model: str = "gemini-3.6-flash" # was gemini-2.0-flash (shut down)
# gemini-2.5-pro was closed to new projects by 2026-09 (every tiebreak 404'd
# in the first replay run, server-26#170); Google lists no stable Pro model,
# so the smart tier is the newest stable Flash instead.
corr_smart_model: str = "gemini-3.8-flash" # was gemini-2.5-pro, gemini-1.5-pro
# Transcript correction (server-26#36). Runs inside transcription, once per
# transcribed call above MIN_WORDS_FOR_CORRECTION, so it is priced like STT
# rather than like the correlation tier — cheap model on purpose.
@@ -122,6 +118,35 @@ class Settings(BaseSettings):
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar)
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
# server-26#86: the content-divergence veto on a unit-overlap link. This is
# a DIVERGENCE bar, not a match bar, so it sits deliberately below
# embedding_similarity_threshold: two calls about different jobs on one
# dispatch channel still share ten-codes, unit IDs and address phrasing, so
# "not a match" (< 0.93) is not evidence of "a different job". Below this
# bar the link is vetoed; between the two bars the link is neither
# confirmed nor refused, so it is AMBIGUOUS and goes to the LLM (#299)
# instead of silently merging. 0.82 is the historical literal, unmeasured
# against real score distributions.
embedding_divergence_threshold: float = 0.82
# A unit-overlap link is only a confident one while the incident is fresh:
# idle under this many minutes (the old hard-coded 15 of the divergence
# veto) AND open under unit_link_confident_max_span_minutes. Past either,
# shared units alone are mechanism B (CORRELATION_REVIEW_0820.md) — patrol
# units work job after job on one channel — so the link is ambiguous.
unit_link_fresh_idle_minutes: int = 15
unit_link_confident_max_span_minutes: int = 30
# server-26#189: an incident open this long accepts no more WEAK links
# (unit overlap without location/content agreement, or an LLM link) — only
# vehicle, location or embedding evidence, until the hard
# incident_max_duration_minutes cap. The 09-27 "Rollover Entrapment"
# sink absorbed unrelated Ch 1 jobs for 2h34m this way. Thin replies are
# exempt: they never refresh updated_at, so they cannot keep a sink alive.
incident_weak_link_max_span_minutes: int = 90
# server-26#189: an LLM link may not pull a call geocoded farther than
# this from a non-pursuit incident's verified pin (the same 4x
# location_proximity_km the slow path accepts). The LLM tier had no
# distance check at all, which is how one incident drew a 37 km path.
incident_max_spread_km: float = 2.0
location_proximity_km: float = 0.5 # radius for location-proximity matching
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls (major / unknown severity)
@@ -147,9 +172,7 @@ class Settings(BaseSettings):
# at 9.7 min. Every correct thin attach in that dump was <= 3.4 min idle
# and every wrong one was >= 8.2, so 5 separates them with room on both
# sides. Genuine back-and-forth is handled by the 30-second tier-1 path
# above this. Also the escape hatch in routers/upload.py's LLM-orphan gate
# (_recent_incident_on_same_talkgroup, server-26#115) — check both call
# sites before retuning this.
# above this.
tg_dispatch_thin_idle_minutes: int = 5
# ── Hard caps: an incident past either of these stops accepting calls ──────
+1 -1
View File
@@ -32,7 +32,7 @@ from typing import Optional
from app.internal.logger import logger
from app.config import settings
TIERS = ("transcription", "correlation_cheap", "correlation_smart", "extraction")
TIERS = ("transcription", "correlation_cheap", "extraction")
# Consecutive failures a TRANSIENT condition must reach before it alerts.
# Permanent conditions skip this entirely and alert on failure #1.
@@ -18,8 +18,8 @@ SHADOW MODE ONLY. As of this module's introduction, nothing skips scene
extraction based on this verdict. `intelligence.extract_scenes` calls
`classify_chatter` purely to record the verdict on the call doc
(`chatter_classifier_verdict` / `chatter_classifier_reason`) so it becomes
observable in the next `/admin` correlation-debug dump, exactly like
`corr_gate_veto` (server-26#115 / PR #126). See the TODO at that call site for
observable in the next `/admin` correlation-debug dump (as `corr_gate_veto`
was, before server-26#299 removed that gate). See the TODO at that call site for
what has to be true before this flips live.
Precision over recall, deliberately. A false positive here — flagging a REAL
+347 -152
View File
@@ -43,6 +43,13 @@ Matching priority (in order):
Calls with no incident_type skip new-incident creation but still run paths 1–3,
so unclassified calls (short transport end, "en route", etc.) can link to an
existing incident via talkgroup match.
Decision flow (server-26#299): the rules engine decides every confident case on
its own. When it flags its decision as ambiguous (`_ambiguity`: opening an
incident while live candidates exist, orphaning an unfit same-talkgroup
follow-up, or linking on unverified shared units), routers/upload.py makes ONE
cheap LLM call (llm_correlator.decide) and commits its answer instead. There is
no second LLM tier and no consensus vote.
"""
import math
import re
@@ -65,26 +72,65 @@ _SEVERITY_RANK = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
def _max_severity(current: Optional[str], new: Optional[str]) -> str:
"""
Highest of two severities on the four-level ladder — the merge rule used
every time a call attaches to an existing incident.
Severity is monotonic by design: it only ever rises, never falls, as more
calls link. An incident briefly assessed "major" genuinely was major at
that moment; a later call that sounds calmer ("units clear", dispatcher
moving on) is evidence the SITUATION is winding down, not that the earlier
read was wrong. That's what `status`/`resolved_at` are for — resolution
retires an incident, it doesn't retroactively erase how serious it was.
Every severity-driven surface (worst-first incident rail, "Major only"
filter, map colour) exists to make sure a major event is never missed;
downgrading severity mid-incident would silently defeat that on the exact
incidents it exists to protect. A malformed/unrecognized value from either
side ranks as "routine" so it can never suppress a real escalation.
Highest of two severities on the four-level ladder. A malformed or
unrecognized value from either side ranks as "routine" so it can never
suppress a real escalation.
"""
current = current if current in _SEVERITY_RANK else "routine"
new = new if new in _SEVERITY_RANK else "routine"
return current if _SEVERITY_RANK[current] >= _SEVERITY_RANK[new] else new
# server-26#190 — explicit stand-down / unfounded dispositions. Deliberately a
# short list of phrases that only ever mean "there is less here than was
# reported"; a calmer-SOUNDING follow-up ("Engine 5 on scene", "copy") is not
# one, because extraction judges each transmission on its own words and a
# working fire's routine status traffic would otherwise talk it down to
# routine. Left out: "disregard"/"cancel" ("disregard my last" is a radio
# correction), "all clear" ("primary search all clear" on a working fire) and
# "code 4" (scene secure, not "nothing happened").
_STAND_DOWN_RE = re.compile(
r"\b(?:unfounded|false alarm|no (?:fire|flames|smoke showing)|nothing (?:showing|found|evident)"
r"|gone on arrival|g\.?o\.?a\.?|stand(?:ing)? down|stood down"
r"|fire(?:'s| is)? out)\b",
re.IGNORECASE,
)
def _is_stand_down(transcript: Optional[str]) -> bool:
return bool(transcript and _STAND_DOWN_RE.search(transcript))
def _severity_after_link(
inc: dict, call_severity: Optional[str], transcript: Optional[str],
) -> dict:
"""
The incident's severity fields after one more call links (server-26#190).
`severity` is the CURRENT read and drives the live surfaces (worst-first
rail, map colour, severity-scaled quiet timer). It rises immediately on
any worse call, and falls only on an explicit stand-down disposition
("they lit a fireplace... only smoke showing, no flames") — then to that
call's own severity. It used to only ever ratchet up, which left a false
Major on the wall for the full 90-minute Major quiet timer after units
reported nothing there.
`severity_peak` keeps the old monotonic meaning (the worst this incident
was ever judged), so history and any "was this ever major" question still
have an answer that later chatter cannot erase.
"""
current = inc.get("severity") if inc.get("severity") in _SEVERITY_RANK else "routine"
peak = _max_severity(inc.get("severity_peak"), current)
new = call_severity if call_severity in _SEVERITY_RANK else "routine"
out = {"severity_peak": _max_severity(peak, new)}
if _SEVERITY_RANK[new] < _SEVERITY_RANK[current] and _is_stand_down(transcript):
out["severity"] = new
out["severity_deescalated_from"] = current
else:
out["severity"] = _max_severity(current, new)
return out
# Maximum plausible ground speed for a moving incident (pursuit/transport).
# ~3 miles/min ≈ 180 mph — well above real pursuit speeds, but generous enough
# to tolerate GPS drift and call-timing jitter. Anything faster is a bad geocode
@@ -278,16 +324,88 @@ def has_event_substance(ctx: dict) -> bool:
a vehicle, a geocode, or a tag.
This is the substance half of the incident-creation gate (see
`_run_decision`, "Severity, not type, decides..."), factored out so the
consensus LLM-orphan gate in routers/upload.py mirrors it exactly and can
never drop a call the creation gate would have opened. `call_units` and
`location` are deliberately excluded — radio protocol puts a unit ID and a
place name in almost every transmission, so counting them as substance
makes the check trivially true.
`resolve_new_incident_type`). `call_units` and `location` are deliberately
excluded — radio protocol puts a unit ID and a place name in almost every
transmission, so counting them as substance makes the check trivially true.
"""
return bool(ctx.get("call_vehicles") or ctx.get("coords") or ctx.get("tags"))
def resolve_new_incident_type(ctx: dict) -> Optional[str]:
"""
The type a NEW incident opened by this call gets, or None when the call is
not incident-worthy and must orphan instead. The one creation gate, shared
by the rules engine and the LLM tier (llm_correlator._parse_response), so
an LLM "new" can neither open what the rules gate refuses nor be demoted
to orphan for lacking a type the rules gate would have defaulted.
Severity, not type, decides whether a call is incident-worthy. Requiring a
concrete incident_type meant a channel whose traffic never classifies —
transit/rail administration, records lookups, prisoner transports — could
never open a SECOND incident: every later call funnelled into whichever
incident was created first (2026-08-16: TG 9048, 28 calls / 49 min)
alongside 30+ permanent orphans. So anything the extractor judged a real
event (minor+), or that carries substance (`has_event_substance`), opens
an incident under the neutral "other" type. `call_units` and `location`
are NOT substance — the first version of this gate counted them, turned
"11-Victor, 72 at Holland Station" into its own incident, and left 37 of
50 incidents open, one call each.
"""
resolved = ctx.get("incident_type") or _infer_type_from_tags(ctx.get("tags") or [])
if resolved:
return resolved
if ctx.get("call_severity") in ("minor", "moderate", "major") or has_event_substance(ctx):
return "other"
return None
def is_clearance_only(ctx: dict) -> bool:
"""
"45-9, I'm clear." carries one fact: which unit is done. Only the rules
engine's unit match can say which incident that is; an LLM link here would
apply the clear to whatever incident it picked, so such a call is never
sent to the LLM tier.
"""
units = ctx.get("call_units") or []
cleared = ctx.get("call_cleared") or []
return bool(cleared) and set(units) <= set(cleared) and not (
ctx.get("tags") or ctx.get("location") or ctx.get("call_vehicles") or ctx.get("incident_type")
)
_LLM_TRUE = frozenset({"true", "yes", "1"})
_LLM_FALSE = frozenset({"false", "no", "0", "", "none", "null"})
def parse_llm_bool(value) -> bool:
"""
A yes/no field from an LLM, failing CLOSED (server-26#88).
`bool(value)` reads the JSON *string* "false" as True, so a model that
quoted its booleans would resolve whatever incident the scene linked into.
Only a real bool, or an exact true-ish token, counts as True; anything
else — "false", "maybe", a number, a dict — is False. For `resolved` and
`reassignment` a missed True costs a stale open incident or one missed
unit release; a false True closes a live incident or blanks a call's units.
Public because the extraction boundary (intelligence.py) is where it
belongs; routers/upload.py applies it too, so a value that reaches the
scene loop un-coerced is still validated there.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
token = value.strip().lower()
if token in _LLM_TRUE:
return True
if token not in _LLM_FALSE:
logger.warning(f"parse_llm_bool: unrecognised value {value!r} read as False")
return False
if value is not None:
logger.warning(f"parse_llm_bool: non-boolean {type(value).__name__} {value!r} read as False")
return False
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
"""Return an incident type inferred from tags, or None if ambiguous."""
for tag in tags:
@@ -483,7 +601,7 @@ def _resolve_incident_title(
of strictly higher severity. Rationale in the commit message; in short, an
incident's identity is the event that opened it, and the one situation
where the header must change is the one where things got worse. This
mirrors `_max_severity`: monotonic, never walked back by later chatter.
mirrors `severity_peak`: monotonic, never walked back by later chatter.
Two non-renames are still allowed, because neither replaces an event name:
• filling in a placeholder title on an incident that opened on a call
@@ -656,6 +774,43 @@ def _incident_at_capacity(inc: dict, now: datetime) -> Optional[str]:
return None
def _weak_link_capped(inc: dict, now: datetime) -> bool:
"""server-26#189 — past this span an incident takes only strong evidence."""
return _incident_span_minutes(inc, now) >= settings.incident_weak_link_max_span_minutes
def llm_candidates(ctx: dict) -> list[dict]:
"""
The incidents the LLM tier may see and link into: the rules engine's
`recent` pool minus anything at the hard caps or past the weak-link span
cap (server-26#189). Every LLM link is weak evidence — a model's reading
of two transcripts — so an incident that has been open long enough to
have become a catch-all is not offered to it at all.
"""
now = ctx["now"]
return [
inc for inc in ctx.get("recent") or []
if not _incident_at_capacity(inc, now) and not _weak_link_capped(inc, now)
]
def llm_link_allowed(inc: dict, ctx: dict) -> bool:
"""
Distance guard on an LLM-chosen link (server-26#189). When both the call
and a non-pursuit incident are geocoded and farther apart than
`incident_max_spread_km`, the model's link is refused: every rules path
already has a location check of its own, and the LLM tier had none.
"""
coords = ctx.get("coords")
pin = _verified_pin(inc)
if not coords or not pin:
return True
if set(inc.get("tags") or []) & _PURSUIT_TAGS:
return True
dist = _haversine_km(coords["lat"], coords["lng"], pin["lat"], pin["lng"])
return dist <= settings.incident_max_spread_km
def _drop_capped(incidents: list[dict], now: datetime) -> list[dict]:
"""Remove over-cap incidents from a candidate pool (see _incident_at_capacity)."""
kept: list[dict] = []
@@ -979,7 +1134,7 @@ def _run_decision(ctx: dict) -> dict:
# Hard caps, enforced regardless of which path would have matched. Normally
# a no-op because _build_context already filtered these out; re-applied here
# so the guarantee holds for any caller that assembles a ctx directly
# (tests, the LLM tiebreak path) rather than through _build_context.
# (tests) rather than through _build_context.
_capped_ids = {
inc.get("incident_id")
for inc in (list(ctx["all_active"]) + list(ctx["recent"]))
@@ -1013,6 +1168,10 @@ def _run_decision(ctx: dict) -> dict:
matched_incident: Optional[dict] = None
corr_debug: dict = {}
# Why the fast path refused a same-talkgroup candidate, when it did. A
# "no_signal" refusal is the one the rules cannot settle: a substantive
# call on a channel with a live incident and no evidence either way.
tg_reject: Optional[str] = None
# ── 1. Fast path: talkgroup match with recency gate ──────────────────────
#
@@ -1168,13 +1327,14 @@ def _run_decision(ctx: dict) -> dict:
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
"corr_fit_signal": fit_signal,
}
if fit_signal == "unit_overlap" and call_units:
if fit_signal.startswith("unit_") and call_units:
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
logger.info(
f"Correlator fast-path: call {call_id} → {candidate['incident_id']} "
f"(signal={fit_signal})"
)
else:
tg_reject = fit_signal
logger.info(
f"Correlator fast-path skipped: call {call_id} — {fit_signal} "
f"from {candidate['incident_id']}; will attempt new incident"
@@ -1207,13 +1367,14 @@ def _run_decision(ctx: dict) -> dict:
"corr_candidates": len(tg_recent),
"corr_fit_signal": fit_signal,
}
if fit_signal == "unit_overlap" and call_units:
if fit_signal.startswith("unit_") and call_units:
corr_debug["corr_matched_units"] = _matching_units(call_units, candidate.get("units"))
logger.info(
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
f"call {call_id} → {candidate['incident_id']} (signal={fit_signal})"
)
else:
tg_reject = fit_signal
logger.info(
f"Correlator fast-path disambig: no candidate fits call {call_id} "
f"across {len(tg_recent)} incidents ({fit_signal}) — will attempt new incident"
@@ -1249,28 +1410,27 @@ def _run_decision(ctx: dict) -> dict:
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
for inc in all_active
)
# Location conflict check: same unit at a clearly different location
# means the officer has moved to a new scene (e.g. dispatch sent
# them to a second call without an explicit reassignment signal).
location_conflict = False
if coords and not reassigned_away:
inc_coords_uc = best_unit_inc.get("location_coords")
if inc_coords_uc:
uc_dist = _haversine_km(
coords["lat"], coords["lng"],
inc_coords_uc["lat"], inc_coords_uc["lng"],
)
if uc_dist > settings.location_proximity_km:
location_conflict = True
logger.info(
f"Correlator unit-continuity skipped: call {call_id} — "
f"unit match but location conflict ({uc_dist:.2f}km) with "
f"{best_unit_inc['incident_id']}"
)
if not reassigned_away and not location_conflict:
# Same unit-overlap judgement as the fast path (_unit_fit): a
# location conflict means the officer has moved to a new scene, a
# sink past the weak-link span cap takes no unit-only links
# (server-26#189), and an old incident with no location or content
# agreement is linked but flagged unverified, i.e. ambiguous.
uc_fit, uc_signal = (False, "reassigned_away")
if not reassigned_away:
uc_fit, uc_signal = _unit_fit(
best_unit_inc, coords, call_embedding, now,
settings.location_proximity_km,
)
if not uc_fit:
logger.info(
f"Correlator unit-continuity skipped: call {call_id} vs "
f"{best_unit_inc['incident_id']} — {uc_signal}"
)
else:
matched_incident = best_unit_inc
corr_debug = {
"corr_path": "unit-continuity",
"corr_fit_signal": uc_signal,
"corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1),
# Unlike this file's other two paths (fast/single, fast/disambig),
# a match here is unit-driven by construction (call_unit_set &
@@ -1445,63 +1605,66 @@ def _run_decision(ctx: dict) -> dict:
# ── Decision output ───────────────────────────────────────────────────────
if matched_incident:
return {
"action": "link",
"matched_incident": matched_incident,
"incident_type": incident_type,
"corr_debug": corr_debug,
}
if not create_if_new:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
# Attempt type inference from tags before giving up on creation
resolved_type = incident_type
if not resolved_type and tags:
resolved_type = _infer_type_from_tags(tags)
if resolved_type:
action, resolved_type = "link", incident_type
elif not create_if_new:
action, resolved_type = "orphan", None
else:
resolved_type = resolve_new_incident_type(ctx)
action = "new" if resolved_type else "orphan"
if resolved_type and resolved_type != incident_type:
logger.info(
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
f"Correlator: call {call_id} has no incident_type — opening a "
f"{resolved_type!r} incident (severity={call_severity}, tags={tags})"
)
# Severity, not type, decides whether a call is incident-worthy.
#
# Requiring a concrete incident_type here meant a channel whose traffic never
# classifies — transit/rail administration, records lookups, prisoner
# transports — could never open a SECOND incident. Every later call on that
# talkgroup then funnelled into whichever incident happened to be created
# first, producing hour-long incidents made of unrelated transmissions
# (2026-08-16: TG 9048, 28 calls / 49 min) alongside 30+ permanent orphans.
#
# Anything the extractor judged a real event, or that carries any concrete
# content, now opens an incident under the neutral "other" type. Only
# content-free routine traffic is still left for the thin path to attach.
#
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
# ID in essentially every transmission and a place name in most of them, so
# including them made has_substance true almost always and the severity check
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
# Station" into its own incident and left 37 of 50 incidents open, one call
# each. A vehicle, a geocode, or a tag means the extractor found something
# beyond who was speaking and where they stood.
if not resolved_type:
has_substance = has_event_substance(ctx)
if call_severity in ("minor", "moderate", "major") or has_substance:
resolved_type = "other"
logger.info(
f"Correlator: call {call_id} has no incident_type — opening an 'other' "
f"incident (severity={call_severity}, substance={has_substance})"
)
if not resolved_type:
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
return {
"action": "new",
"matched_incident": None,
decision = {
"action": action,
"matched_incident": matched_incident,
"incident_type": resolved_type,
"corr_debug": corr_debug,
}
decision["ambiguous"] = _ambiguity(decision, tg_reject, ctx)
return decision
def _ambiguity(decision: dict, tg_reject: Optional[str], ctx: dict) -> Optional[str]:
"""
Why the rules decision is NOT confident enough to commit alone, or None
when it is (server-26#299). Only an ambiguous decision is sent to the LLM
tier, once; every other decision is the rules engine's alone.
Ambiguous:
• "new_with_candidates" — the rules would OPEN an incident while live,
LLM-eligible incidents exist. Opening is the expensive mistake: this
system over-splits, and rules-only correlation opened ~1.8x the
incidents per hour of LLM-assisted (#170, 2026-10-04 control window)
— duplicates of a running job and typed radio housekeeping.
• "unfit_tg_candidate" — the rules would ORPHAN a substantive call that
has a live incident on its own talkgroup but no evidence for or
against it (fit test "no_signal"): an unfit follow-up.
• "unverified_unit_link" — the rules would LINK on shared units alone
into an incident that is no longer fresh, with no location or content
agreement (mechanism B; #103: the content veto has nothing to measure
when the scene has no embedding). Between the divergence and match
embedding bars (#86) a link lands here too.
Never ambiguous: thin calls (nothing to reason about), clearance-only
calls (only the unit match can place a 10-8), link-only sweeps
(create_if_new=False — rules-only by design), or when the LLM would have
no candidates to choose from.
"""
if not ctx.get("create_if_new", True) or ctx.get("is_thin_call"):
return None
if is_clearance_only(ctx) or not llm_candidates(ctx):
return None
action = decision["action"]
if action == "new":
return "new_with_candidates"
if action == "orphan" and tg_reject == "no_signal":
return "unfit_tg_candidate"
if action == "link" and (decision["corr_debug"] or {}).get("corr_fit_signal") == "unit_overlap_unverified":
return "unverified_unit_link"
return None
# ─────────────────────────────────────────────────────────────────────────────
@@ -1514,7 +1677,7 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
server-26#96: every scene of a multi-scene call reaches this function
independently (upload.py's ``for scene_index, scene in enumerate(scenes):``
loop → _correlate_with_consensus → preview_correlation/apply_correlation),
loop → _correlate → preview_correlation/apply_correlation),
and every scene writes to the SAME call doc. Writing corr_debug only at
the flat top level meant scene 2's write silently clobbered scene 1's
corr_path/corr_consensus/etc — the call doc ended up describing a splice
@@ -1553,18 +1716,14 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]:
"incident_id": incident_id,
"corr_debug": corr_debug,
# server-26#139: this scene's OWN extracted incident_type/
# severity, as read by _call_is_substanceless's ctx at
# decision time — not the call doc's flat top-level field,
# which is last-scene-wins (server-26#96) and was the reason
# #138's "type" veto couldn't be told apart from cross-scene
# contamination without re-guessing from a live dump.
# severity, as read from ctx at decision time — not the call
# doc's flat top-level field, which is last-scene-wins
# (server-26#96).
# NOTE: unlike incident_type, call_severity is already
# coerced to "routine" when extraction emitted nothing
# (ctx build: `severity or "routine"`) — a scene reading
# "routine" here doesn't distinguish "extraction said
# routine" from "extraction said nothing". Don't split a
# severity veto the way #138 splits the type veto without
# accounting for that.
# routine" from "extraction said nothing".
"incident_type": ctx.get("incident_type"),
"severity": ctx.get("call_severity"),
}
@@ -1613,6 +1772,7 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
talkgroup_name=talkgroup_name, incident_type=incident_type,
cleared_units=call_cleared, refresh_activity=not thin_link,
call_severity=call_severity, call_srcaddr=call_srcaddr,
call_transcript=ctx.get("scene_transcript"),
)
return matched_incident["incident_id"]
@@ -1798,6 +1958,65 @@ def _disambiguate(
return best
def _unit_fit(
inc: dict,
call_coords: Optional[dict],
call_embedding: Optional[list],
now: Optional[datetime],
proximity_km: float,
) -> tuple[bool, str]:
"""
Judge a link whose evidence is a shared unit. Same officer usually means
same call — but on a shared dispatch channel a patrol unit works job after
job, so shared units alone grow chains (mechanism B,
CORRELATION_REVIEW_0820.md). In order:
• call and incident both geocoded: far apart → (False,
"unit_loc_conflict"), the officer moved to a new scene; close →
(True, "unit_location_match").
• embeddings agree at the match bar → (True, "unit_content_match").
• incident past the weak-link span cap (#189) → (False,
"weak_link_span_cap"): a long-running incident stops taking
unit-only evidence.
• incident fresh (idle < unit_link_fresh_idle_minutes) and young (open
< unit_link_confident_max_span_minutes) → (True, "unit_overlap"). No
content veto here: an update on the same scene often shares little
vocabulary with the dispatch and does not restate the address.
• not fresh, embeddings below the DIVERGENCE bar (#86) → (False,
"content_divergence").
• anything else → (True, "unit_overlap_unverified"): the rules keep the
link but cannot vouch for it, so the decision is ambiguous and goes
to the LLM tier. This is where a scene with no embedding lands
(#103) — it used to skip the veto and link as if verified, which
removed the only brake on the unit loop for ~93% of calls.
"""
inc_coords = inc.get("location_coords")
if call_coords and inc_coords:
dist_km = _haversine_km(
call_coords["lat"], call_coords["lng"],
inc_coords["lat"], inc_coords["lng"],
)
return (False, "unit_loc_conflict") if dist_km > proximity_km else (True, "unit_location_match")
sim: Optional[float] = None
inc_emb = inc.get("embedding")
if call_embedding and inc_emb:
sim = _cosine_similarity(call_embedding, inc_emb)
if sim >= settings.embedding_similarity_threshold:
return True, "unit_content_match"
if now is None:
return True, "unit_overlap_unverified"
if _weak_link_capped(inc, now):
return False, "weak_link_span_cap"
fresh = _idle_gate_minutes(inc, now) < settings.unit_link_fresh_idle_minutes
if fresh and _incident_span_minutes(inc, now) < settings.unit_link_confident_max_span_minutes:
return True, "unit_overlap"
if not fresh and sim is not None and sim < settings.embedding_divergence_threshold:
return False, "content_divergence"
return True, "unit_overlap_unverified"
def _call_fits_incident(
inc: dict,
call_units: list[str],
@@ -1812,18 +2031,14 @@ def _call_fits_incident(
Return (fits, signal) — fits is True when this call plausibly belongs to
the incident; signal names the specific evidence that drove the decision.
fits=True signals: "unit_overlap" | "vehicle_overlap" | "location_proximity"
fits=True signals: see `_unit_fit` for the unit_* family | "vehicle_overlap"
| "location_proximity"
fits=False signals: "unit_loc_conflict" | "content_divergence"
| "location_conflict" | "no_signal"
| "weak_link_span_cap" | "location_conflict" | "no_signal"
Evaluation order:
1. Unit overlap. Same officer = same call. Also runs a location-conflict
guard: geocoded on both sides and clearly different → the officer has
moved to a new scene, false positive. No geocode on the call (or on
the incident, with a call geocode) AND the incident is old (≥ 15 min)
→ content divergence as a location proxy (embedding similarity < 0.82
→ different scene). Skipped for recent incidents (< 15 min) — an
update without re-stating the address is normal.
1. Unit overlap — judged by `_unit_fit`, which also decides whether the
link is confident or only "unit_overlap_unverified" (ambiguous).
2. Vehicle overlap → True.
3. Location proximity. Both geocoded and close → True; far apart with no
other positive signal → False.
@@ -1850,44 +2065,9 @@ def _call_fits_incident(
inc_units = _unit_keys(inc.get("units"))
matched_units = _matching_units(call_units, inc.get("units"))
if matched_units:
if call_coords:
# Hard location conflict: geocoded on both sides and clearly different.
inc_coords_u = inc.get("location_coords")
if inc_coords_u:
dist_km = _haversine_km(
call_coords["lat"], call_coords["lng"],
inc_coords_u["lat"], inc_coords_u["lng"],
)
if dist_km > proximity_km:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but location_conflict dist={dist_km:.2f}km → unit_loc_conflict")
return False, "unit_loc_conflict"
elif call_embedding and idle_min >= 15:
# Call has geocode but incident doesn't — fall back to content
# divergence as a location proxy. Without this, stale incidents
# that never geocoded absorb unrelated calls purely on unit
# overlap (e.g. a patrol officer working a second scene 70 min
# after the original call).
inc_emb_u = inc.get("embedding")
if inc_emb_u:
sim = _cosine_similarity(call_embedding, inc_emb_u)
if sim < 0.82:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence (has_call_coords/no_inc_coords) sim={sim:.3f} → content_divergence")
return False, "content_divergence"
elif call_embedding and idle_min >= 15:
# No geocode available AND old incident: use content divergence as a
# location-proxy veto. After 15+ minutes an officer at a completely
# different scene will be discussing clearly different content.
# Skip this for recent incidents — an officer updating on the same
# scene without re-stating the address is normal and their update
# won't share much vocabulary with the original dispatch.
inc_emb_u = inc.get("embedding")
if inc_emb_u:
sim = _cosine_similarity(call_embedding, inc_emb_u)
if sim < 0.82:
logger.info(f" fits[{inc_id}]: unit_overlap({matched_units}) but content_divergence sim={sim:.3f} → content_divergence")
return False, "content_divergence"
logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → unit_overlap")
return True, "unit_overlap"
fit, signal = _unit_fit(inc, call_coords, call_embedding, now, proximity_km)
logger.info(f" fits[{inc_id}]: unit_overlap matched={matched_units} → {signal}")
return fit, signal
# ── 2. Vehicle overlap ────────────────────────────────────────────────────
inc_vehicles = set(inc.get("vehicles") or [])
@@ -2047,6 +2227,7 @@ async def _update_incident(
refresh_activity: bool = True,
call_severity: Optional[str] = None,
call_srcaddr: Optional[str] = None,
call_transcript: Optional[str] = None,
) -> None:
incident_id = inc["incident_id"]
@@ -2109,7 +2290,9 @@ async def _update_incident(
"units_cleared": units_cleared,
"location_mentions": location_mentions,
"summary_stale": True,
"severity": _max_severity(inc.get("severity"), call_severity),
# Current severity can step DOWN on an explicit stand-down; the peak
# never does (server-26#190, _severity_after_link).
**_severity_after_link(inc, call_severity, call_transcript),
# Always all three, always together — writing one without the others is
# what let the label and the pin drift apart (server-26#23).
**location_fields,
@@ -2123,7 +2306,18 @@ async def _update_incident(
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and
# thin traffic rides along without extending its life.
if refresh_activity:
updates["updated_at"] = _floor_at_started_at(inc, now).isoformat()
# Never move updated_at BACKWARD: a back-dated `now` (sweep, or a late
# upload's clock pin, server-26#301) would make a live incident look
# idle, failing on-time calls' recency gates and timer-closing it early.
when = _floor_at_started_at(inc, now)
try:
prev = datetime.fromisoformat(str(inc.get("updated_at") or "").replace("Z", "+00:00"))
if prev.tzinfo is None:
prev = prev.replace(tzinfo=timezone.utc)
when = max(when, prev)
except ValueError:
pass
updates["updated_at"] = when.isoformat()
if is_new_call: # a second scene of the same call is not a second call
updates["substantive_call_count"] = (
inc.get("substantive_call_count")
@@ -2233,6 +2427,7 @@ async def _create_incident(
"vehicles": call_vehicles,
"srcaddrs": [call_srcaddr] if call_srcaddr else [],
"severity": call_severity,
"severity_peak": call_severity,
"summary": None,
"summary_stale": True,
"summary_last_run": None,
+64 -145
View File
@@ -1,23 +1,23 @@
"""
LLM-based incident correlator using Gemini.
LLM incident correlator (Gemini, settings.corr_cheap_model).
Two functions are exposed:
decide(call_id, ctx) — cheap first-pass (corr_cheap_model)
tiebreak(rules_decision, llm_decision, ctx) — smart tiebreaker (corr_smart_model)
One function is exposed:
decide(call_id, ctx) — called by routers/upload.py ONLY when the rules
engine marked its decision ambiguous
(incident_correlator._ambiguity), at most once per
scene. server-26#299 removed the second "smart"
tiebreak tier and the cheap/smart consensus vote.
Both return a decision dict compatible with _run_decision() in incident_correlator:
It returns a decision dict compatible with incident_correlator._apply_decision:
{"action": "link"|"new"|"orphan",
"matched_incident": dict|None,
"incident_type": str|None,
"corr_debug": dict,
"reasoning": str} ← extra field for logging/tiebreak comparison
"reasoning": str}
decide() is skipped for thin calls (no content to reason about) and when
GEMINI_API_KEY is not set — in those cases returns None so the caller knows
to fall back to the rules decision.
Error handling: any Gemini failure returns None from decide() and the
rules_decision from tiebreak() so the pipeline never stalls.
or None — no API key, nothing to choose from, an answer that fails
validation, or any Gemini failure — and the caller then commits the rules
decision unchanged, so the pipeline never stalls on the LLM.
"""
import asyncio
from datetime import datetime, timezone
@@ -155,8 +155,9 @@ call that reports a new event, below.):
same one — especially once that incident has been open a long time (see
open:/calls:).
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
recent incident — a different place, different units, and a different subject,
not merely a different transmission about the same job.
recent incident — a different place or subject, not merely a different
transmission about the same job. Shared units alone do not make it the same
event (see EXCEPT above).
- "orphan" a call that is not an incident at all: radio checks, roll call,
a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare
acknowledgement. Do not open a "new" incident for these.
@@ -166,47 +167,15 @@ call that reports a new event, below.):
"""
def _build_decide_prompt(ctx: dict) -> str:
now = ctx["now"]
recent = ctx["recent"]
inc_block = (
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)"
)
def _build_decide_prompt(ctx: dict, candidates: list[dict]) -> str:
now = ctx["now"]
inc_block = "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(candidates))
return (
"You are an incident correlator for a public safety radio monitoring system.\n\n"
"A new radio call has arrived. Decide whether it belongs to an existing active incident, "
"represents a new incident, or should be orphaned (not enough information).\n\n"
"represents a new incident, or should be orphaned (not an incident at all).\n\n"
f"NEW CALL:\n{_call_block(ctx)}\n\n"
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n"
f"{_RULES}\n"
f"Respond with JSON only (no markdown):\n{_SCHEMA}"
)
def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict) -> str:
now = ctx["now"]
recent = ctx["recent"]
inc_block = (
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)"
)
def _fmt(d: dict, name: str) -> str:
action = d.get("action", "?")
inc = d.get("matched_incident")
inc_id = inc["incident_id"] if inc else (d.get("incident_id") or "null")
reason = d.get("reasoning") or (d.get("corr_debug") or {}).get("corr_fit_signal") or "—"
return f" {name}: action={action}, incident_id={inc_id}, reasoning={reason!r}"
return (
"You are a senior incident correlator for a public safety radio monitoring system.\n\n"
"Two correlation engines disagree. You must make the final decision.\n\n"
f"NEW CALL:\n{_call_block(ctx)}\n\n"
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n\n"
"DISAGREEMENT:\n"
f"{_fmt(rules_decision, 'Rules engine')}\n"
f"{_fmt(llm_decision, 'LLM correlator')}\n"
f"ACTIVE INCIDENTS ({len(candidates)} recent):\n{inc_block}\n"
f"{_RULES}\n"
f"Respond with JSON only (no markdown):\n{_SCHEMA}"
)
@@ -225,44 +194,48 @@ def _sync_gemini(model_name: str, prompt: str) -> dict:
# Decision parsing
# ─────────────────────────────────────────────────────────────────────────────
def _parse_response(raw: dict, ctx: dict) -> dict:
def _parse_response(raw: dict, ctx: dict, candidates: list[dict]) -> Optional[dict]:
"""
Convert raw Gemini JSON output to a decision dict compatible with _run_decision().
Resolves incident_id → full incident doc from ctx["all_active"].
Handles type inference for "new" actions the same way as the rules engine.
"""
from app.internal.incident_correlator import _infer_type_from_tags # same-package import
Convert raw Gemini JSON to a decision dict, or None when the answer is
unusable and the rules decision should stand.
action = raw.get("action", "orphan")
A "link" must name one of the candidates the model was shown — not any
active incident: the shown list is the one with the #189 weak-link cap
applied — and pass the distance guard (incident_correlator.
llm_link_allowed). A "new" takes its type from the same creation gate the
rules use, so it is refused (orphan) exactly when the rules would refuse
it. An unknown action is unusable, not an orphan: the old "default to
orphan" silently dropped calls on a malformed reply.
"""
from app.internal.incident_correlator import llm_link_allowed, resolve_new_incident_type
action = raw.get("action")
reasoning = raw.get("reasoning", "")
if action not in ("link", "new", "orphan"):
action = "orphan"
logger.warning(f"LLM correlator: unusable action {action!r} — keeping the rules decision")
return None
matched_incident: Optional[dict] = None
incident_type: Optional[str] = None
if action == "link":
inc_id = raw.get("incident_id")
if inc_id:
matched_incident = next(
(i for i in ctx["all_active"] if i.get("incident_id") == inc_id),
None,
)
matched_incident = next((i for i in candidates if i.get("incident_id") == inc_id), None)
if not matched_incident:
logger.warning(
f"LLM correlator: incident_id={inc_id!r} not in active incidents — orphaning"
f"LLM correlator: incident_id={inc_id!r} is not a shown candidate — keeping the rules decision"
)
action = "orphan"
incident_type: Optional[str] = None
if action in ("link", "new"):
return None
if not llm_link_allowed(matched_incident, ctx):
logger.info(
f"LLM correlator: link to {inc_id} refused — call is geocoded beyond "
f"incident_max_spread_km of its pin (server-26#189)"
)
return None
incident_type = ctx["incident_type"]
elif action == "new":
incident_type = resolve_new_incident_type(ctx)
if not incident_type:
incident_type = _infer_type_from_tags(ctx["tags"])
if action == "new" and not incident_type:
# Can't create an incident without a type — demote to orphan
action = "orphan"
matched_incident = None
return {
"action": action,
@@ -279,54 +252,34 @@ def _parse_response(raw: dict, ctx: dict) -> dict:
async def decide(call_id: str, ctx: dict) -> Optional[dict]:
"""
Run the cheap LLM correlator (corr_cheap_model) on the call.
Returns a decision dict or None if:
- GEMINI_API_KEY is not configured
- the call is thin (content-free — no value from LLM)
- there are no recent active incidents to reason about
- Gemini fails
Callers should treat None as "fall back to rules decision".
One cheap-model correlation decision for an ambiguous scene. Returns None
(keep the rules decision) when GEMINI_API_KEY is unset, there is no
candidate to show, the answer fails validation, or Gemini fails.
"""
from app.internal.incident_correlator import llm_candidates
if not settings.gemini_api_key:
return None
if ctx["is_thin_call"]:
return None # thin calls have no transcript/units/coords to reason about
if _is_clearance_only(ctx):
# "45-9, I'm clear." carries one fact: which unit is done. Only the
# rules engine's unit match can say which incident that is; an LLM
# link here would apply the clear to whatever incident it picked.
candidates = llm_candidates(ctx)
if not candidates:
return None
if not ctx["recent"]:
return None # no incidents to correlate against — rules handles new-only
try:
prompt = _build_decide_prompt(ctx)
prompt = _build_decide_prompt(ctx, candidates)
raw = await asyncio.to_thread(_sync_gemini, settings.corr_cheap_model, prompt)
decision = _parse_response(raw, ctx)
except Exception as e:
await _log_llm_failure("LLM correlator", "correlation_cheap", call_id, settings.corr_cheap_model, e)
return None
await ai_health.report_healthy("correlation_cheap")
decision = _parse_response(raw if isinstance(raw, dict) else {}, ctx, candidates)
if decision:
_id = (decision["matched_incident"] or {}).get("incident_id", "null")
logger.info(
f"LLM correlator ({settings.corr_cheap_model}): call {call_id} → "
f"action={decision['action']} incident={_id} "
f"reasoning={decision['reasoning']!r}"
)
await ai_health.report_healthy("correlation_cheap")
return decision
except Exception as e:
await _log_llm_failure("LLM correlator", "correlation_cheap", call_id, settings.corr_cheap_model, e)
return None
def _is_clearance_only(ctx: dict) -> bool:
units = ctx.get("call_units") or []
cleared = ctx.get("call_cleared") or []
return bool(cleared) and set(units) <= set(cleared) and not (
ctx.get("tags") or ctx.get("location") or ctx.get("call_vehicles") or ctx.get("incident_type")
)
return decision
_dead_models: set[str] = set()
@@ -350,7 +303,7 @@ async def _log_llm_failure(where: str, tier: str, call_id: str, model: str, exc:
if kind == "dead_model":
await _log_tier_down(where, tier, model, "model is unavailable",
"Update CORR_CHEAP_MODEL/CORR_SMART_MODEL in config.py", text)
"Update CORR_CHEAP_MODEL in config.py", text)
return
# A depleted balance reads as 429, the same status as an ordinary rate limit,
@@ -379,37 +332,3 @@ async def _log_tier_down(where: str, tier: str, model: str, problem: str, fix: s
f"{where}: {problem} ({model!r}) -- the LLM correlation tier is DISABLED "
f"and every call is falling back to rules-only. {fix}. API said: {text}"
)
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
"""
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
Falls back to rules_decision on any error.
"""
call_id = ctx["call_id"]
try:
prompt = _build_tiebreak_prompt(rules_decision, llm_decision, ctx)
raw = await asyncio.to_thread(_sync_gemini, settings.corr_smart_model, prompt)
decision = _parse_response(raw, ctx)
_id = (decision["matched_incident"] or {}).get("incident_id", "null")
logger.info(
f"LLM tiebreak ({settings.corr_smart_model}): call {call_id} → "
f"action={decision['action']} incident={_id} "
f"reasoning={decision['reasoning']!r}"
)
await ai_health.report_healthy("correlation_smart")
return decision
except Exception as e:
await _log_llm_failure("LLM tiebreak", "correlation_smart", call_id, settings.corr_smart_model, e)
return rules_decision
def decisions_agree(rules: dict, llm: dict) -> bool:
"""True if both decisions agree on action and (when action=="link") on the target incident."""
if rules["action"] != llm["action"]:
return False
if rules["action"] == "link":
r_id = (rules.get("matched_incident") or {}).get("incident_id")
l_id = (llm.get("matched_incident") or {}).get("incident_id")
return r_id == l_id
return True
+26 -26
View File
@@ -72,7 +72,7 @@ def pipeline_worst_case_seconds(n_scenes: int = _BOUND_SCENES) -> float:
correction = settings.gemini_timeout_seconds
extraction = openai_attempts * settings.openai_timeout_seconds
embeddings = n_scenes * openai_attempts * settings.openai_timeout_seconds
correlation = 2 * settings.gemini_timeout_seconds # decide + tiebreak, scene 0
correlation = settings.gemini_timeout_seconds # at most one LLM call, scene 0 (#299)
return stt + correction + extraction + embeddings + correlation + _NON_AI_SLACK_SECONDS
@@ -83,21 +83,25 @@ def _derive_min_minutes() -> int:
# 21 with the defaults (stt 120s, openai 60s x2 attempts, gemini 60s, 3 scenes).
MIN_MINUTES_SINCE_PIPELINE_START = _derive_min_minutes()
# Standard link-only retry budget before a call is tombstoned corr_path="unlinked".
MAX_SWEEP_ATTEMPTS = 3
# server-26#115 — a call the consensus LLM-orphan gate parked (llm=orphan vs
# rules=new, no substance) gets a longer budget. The gate fires before any
# incident for the job may exist, so the substantive call that would justify
# linking can land well after the standard ~6 min. Still link-only: a genuinely
# thin call must not mint an incident, and the rules creation gate would re-orphan
# it anyway.
GATED_ORPHAN_SWEEP_ATTEMPTS = 10
def _last_chance(call: dict, now: datetime) -> bool:
"""
server-26#27 — the retry budget is wall-clock, not an attempt count.
def _max_sweep_attempts(call: dict) -> int:
if call.get("corr_consensus") == "llm_orphan_gate":
return GATED_ORPHAN_SWEEP_ATTEMPTS
return MAX_SWEEP_ATTEMPTS
The old budget was 3 attempts, which with the #131 hold-back expired ~19
minutes after the pipeline started, while the incident the orphan belongs
to may be opened later than that (`f321b047` was tombstoned three minutes
after its own incident was created). An orphan is now retried on every
pass for as long as the scan query can see it, and tombstoned
corr_path="unlinked" only on its LAST visible pass: the one after which
its ended_at falls out of `recorrelation_scan_minutes`. A call with no
parseable ended_at gets no further retries.
"""
ended = _parse_dt(call.get("ended_at"))
if ended is None:
return True
remaining = timedelta(minutes=settings.recorrelation_scan_minutes) - (now - ended)
return remaining <= timedelta(minutes=settings.summary_interval_minutes)
async def recorrelation_loop() -> None:
@@ -149,9 +153,8 @@ async def _run_sweep_pass() -> None:
("status", "==", "ended"),
("ended_at", ">=", cutoff),
])
# corr_path="unlinked" is written after the attempt budget is exhausted.
# Allows a few retries so a welfare-check call can link to an escalation
# incident that is created a few minutes later, without sweeping 30× forever.
# corr_path="unlinked" is the tombstone written on an orphan's last
# visible pass (_last_chance); the scan window itself is the budget.
orphans = [
c for c in recent_ended
if not c.get("incident_ids") and not c.get("incident_id")
@@ -163,7 +166,6 @@ async def _run_sweep_pass() -> None:
# the thin path minutes later and attached to whatever was most recent —
# a second route into the over-merge the thin fix above addresses.
and not c.get("skip_reason")
and c.get("corr_sweep_count", 0) < _max_sweep_attempts(c)
and not _pipeline_likely_still_running(c, now)
]
@@ -173,14 +175,14 @@ async def _run_sweep_pass() -> None:
logger.info(f"Re-correlation sweep: {len(orphans)} orphaned call(s) to check")
linked = 0
for call in orphans:
if await _recorrelate_orphan(call):
if await _recorrelate_orphan(call, now):
linked += 1
if linked:
logger.info(f"Re-correlation sweep: linked {linked}/{len(orphans)} orphaned call(s)")
async def _recorrelate_orphan(call: dict) -> bool:
async def _recorrelate_orphan(call: dict, now: datetime) -> bool:
"""
Attempt to link a single orphaned call to an existing incident.
Returns True if a match was found and the call was linked.
@@ -223,12 +225,10 @@ async def _recorrelate_orphan(call: dict) -> bool:
)
return True
# Increment the attempt counter. Once the budget is reached the orphan filter
# above will stop picking this call up, and we write corr_path="unlinked" as
# a permanent tombstone.
attempts = call.get("corr_sweep_count", 0) + 1
update: dict = {"corr_sweep_count": attempts}
if attempts >= _max_sweep_attempts(call):
# The attempt count is kept for the admin debug view only; the budget is
# the scan window (_last_chance), after which the call is tombstoned.
update: dict = {"corr_sweep_count": call.get("corr_sweep_count", 0) + 1}
if _last_chance(call, now):
update["corr_path"] = "unlinked"
await fstore.doc_set("calls", call_id, update)
return False
+7 -2
View File
@@ -60,7 +60,8 @@ USD_WHISPER_PER_MIN = 0.006
USD_PER_EXTRACTION = 0.0005 # gpt-4o-mini scene extraction + embedding
USD_PER_CORRECTION = 0.0003 # Gemini flash transcript correction
USD_PER_GEOCODE = 0.005 # Google geocode, roughly one per located scene
USD_PER_LLM_CORRELATE = 0.0005 # Gemini flash consensus decision
USD_PER_LLM_CORRELATE = 0.0005 # one Gemini flash correlation decision (ambiguous scenes only)
_LLM_CONSENSUS = ("llm", "rules_guard", "agreed", "tiebreak", "llm_orphan_gate")
# Fields the pipeline writes onto a call doc. Stripped when a call is copied
# into the sandbox so the replay recomputes them instead of inheriting the
@@ -636,7 +637,11 @@ def compute_metrics(incidents: list[dict], calls: list[dict]) -> dict:
corr_path[r.get("corr_path") or "none"] += 1
consensus[r.get("corr_consensus") or "none"] += 1
linked = sum(1 for c in calls if c.get("incident_ids"))
llm = sum(n for k, n in consensus.items() if k not in ("none", "rules_only"))
# Scene decisions that made an LLM call (server-26#299: at most one each).
# "rules_fallback" is excluded — the call failed or was unusable, so it
# was mostly not billed. "agreed"/"tiebreak" are pre-#299 runs' values
# (a tiebreak was two calls, so old runs under-count by their tiebreaks).
llm = sum(n for k, n in consensus.items() if k in _LLM_CONSENSUS)
return {
"calls": len(calls),
"calls_linked": linked,
+16 -18
View File
@@ -109,9 +109,9 @@ async def debug_correlation(
"scene_index": scene_index,
"transcript": scene.get("transcript"),
"incident_id": scene.get("incident_id"),
# server-26#139: this scene's OWN incident_type/severity, as seen
# by _call_is_substanceless at decision time — not the call doc's
# flat top-level field, which is last-scene-wins (server-26#96).
# server-26#139: this scene's OWN incident_type/severity at decision
# time — not the call doc's flat top-level field, which is
# last-scene-wins (server-26#96).
"incident_type": scene.get("incident_type"),
"severity": scene.get("severity"),
"corr_path": corr_debug.get("corr_path"),
@@ -126,7 +126,7 @@ async def debug_correlation(
"corr_llm_reasoning": corr_debug.get("corr_llm_reasoning"),
"corr_llm_action": corr_debug.get("corr_llm_action"),
"corr_rules_action": corr_debug.get("corr_rules_action"),
"corr_gate_veto": corr_debug.get("corr_gate_veto"),
"corr_band": corr_debug.get("corr_band"),
}
def _call_summary(call: dict) -> dict:
@@ -173,20 +173,18 @@ async def debug_correlation(
"corr_matched_units": call.get("corr_matched_units"),
"corr_sweep_count": call.get("corr_sweep_count"),
"skip_reason": call.get("skip_reason"),
# LLM consensus tier fields — written by upload.py's
# _correlate_with_consensus / llm_correlator.py, but previously
# dropped here, making it impossible to tell from this endpoint
# whether the LLM correlation tier is actually running (server-26#24).
# LLM tier fields — written by upload.py's _correlate /
# llm_correlator.py, but previously dropped here, making it
# impossible to tell from this endpoint whether the LLM
# correlation tier is actually running (server-26#24).
"corr_consensus": call.get("corr_consensus"),
"corr_llm_reasoning": call.get("corr_llm_reasoning"),
"corr_llm_action": call.get("corr_llm_action"),
"corr_rules_action": call.get("corr_rules_action"),
# server-26#115 — why an llm=orphan/rules=new disagreement escalated
# to tiebreak instead of being gated (see upload.py's
# _call_is_substanceless). Present only on that disagreement shape;
# written here specifically so a live measurement window can read
# the reason instead of reconstructing it by hand from the dump.
"corr_gate_veto": call.get("corr_gate_veto"),
# server-26#299 — why the rules marked this decision ambiguous and
# sent it to the LLM (incident_correlator._ambiguity). Absent on a
# confident rules decision.
"corr_band": call.get("corr_band"),
# server-26#127 — shadow-mode upstream chatter classifier verdict.
# Written by intelligence.extract_scenes on every transcript that
# reaches real scene extraction (not on garbage/too-short skips).
@@ -298,7 +296,8 @@ async def debug_correlation(
orphans_by_tg[tg_key]["count"] += 1
if not o.get("incident_type") and not o.get("tags"):
orphans_by_tg[tg_key]["no_type_count"] += 1
if (o.get("corr_sweep_count") or 0) >= 3:
# Tombstoned by the sweep's wall-clock budget (server-26#27).
if o.get("corr_path") == "unlinked":
orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1
# ── Summary ───────────────────────────────────────────────────────────────
@@ -374,9 +373,8 @@ async def debug_correlation(
"corr_fit_signal": _tally(e.get("corr_fit_signal") for e in scene_entries),
"corr_consensus": _tally(e.get("corr_consensus") for e in scene_entries),
"corr_llm_action": _tally(e.get("corr_llm_action") for e in scene_entries),
# server-26#115 — this IS the number the escape-hatch fix exists to
# produce: why each llm=orphan/rules=new call escaped the gate.
"corr_gate_veto": _tally(e.get("corr_gate_veto") for e in scene_entries),
# server-26#299 — how often, and why, the LLM tier was consulted.
"corr_band": _tally(e.get("corr_band") for e in scene_entries),
# server-26#127 — shadow-mode chatter classifier. The target
# population is non-events, which land as orphans or single-call
# incidents, NOT as a slice of every linked call -- tally `orphans`
+175 -241
View File
@@ -112,99 +112,56 @@ async def upload_call_audio(
return {"url": gcs_uri}
# server-26#115 — the consensus LLM-orphan gate only fires when the call is
# genuinely substanceless. The earlier version tested `rules_decision["corr_debug"]`
# for a "positive signal", but corr_debug is EMPTY at preview time for
# action=="new" (corr_path:"new" is written at APPLY time), so that test was
# always False and the gate dropped real events — a major "extinguishing fire",
# geocoded calls, pursuit updates. The substance test now runs against `ctx`,
# which is fully populated at preview time.
# New-event signals: an LLM "orphan" may not drop a call carrying one when the
# rules would open an incident for it (server-26#299). A missed real event is
# worse than one extra incident, and the sweep is link-only, so a dropped event
# never comes back. These are the old #115 gate's vetoes, which existed because
# a looser gate dropped typed and geocoded fire calls.
_LLM_ORPHAN_VETO_SEVERITIES = ("moderate", "major")
def _recent_incident_on_same_talkgroup(ctx: dict) -> bool:
def _llm_orphan_vetoed(ctx: dict) -> bool:
from app.internal.incident_correlator import has_event_substance
return bool(
ctx.get("reassignment") or ctx.get("incident_type")
or ctx.get("call_severity") in _LLM_ORPHAN_VETO_SEVERITIES
or has_event_substance(ctx)
)
def _merge_llm_decision(rules: dict, llm: dict, ctx: dict) -> tuple[dict, str]:
"""
True when a recent incident is running on this call's own system +
talkgroup, within `settings.tg_dispatch_thin_idle_minutes` (5 min) —
applied uniformly regardless of the talkgroup's name (server-26#134).
Covers "unit dispatched, thin ack 10-30s later": the ack has no
substance of its own but plainly belongs to the job just opened.
Reads ctx["recent"] (the rules engine's own candidate list — no extra
Firestore read). That list is status=="active" incidents only, so an
already-resolved or capacity-capped same-talkgroup incident won't be
seen here even if chronologically recent (server-26#115, unresolved —
would need a dedicated non-status-filtered query).
Whether this limitation explains the 2/24 unexplained gate misses in the
window #3 measurement is UNANSWERED, not confirmed either way — a prior
pass here claimed a "confirmed explanation" for both that turned out to
be self-contradictory. Read `corr_gate_veto` (written to corr_debug on
every escalation of this exact disagreement shape — see the caller) in
the next measurement window instead of guessing from the raw dump again.
# TODO(server-26#115): add a talkgroup-scoped incident lookup (any
# status, no capacity filter) if a future measurement window pins a real
# gate miss on a resolved/capped same-talkgroup incident.
The decision to commit for an ambiguous scene, and the corr_consensus
value that says who made it: "llm", or "rules_guard" when the LLM's
orphan was refused for a call with a strong new-event signal.
"""
from app.internal.incident_correlator import _idle_gate_minutes
tg_id = ctx.get("talkgroup_id")
system_id = ctx.get("system_id")
if tg_id is None or not system_id:
return False
tg_str = str(tg_id)
now = ctx.get("now") or clock.now()
idle_limit = settings.tg_dispatch_thin_idle_minutes
for inc in ctx.get("recent") or []:
if system_id not in (inc.get("system_ids") or []):
continue
if tg_str not in (inc.get("talkgroup_ids") or []):
continue
if _idle_gate_minutes(inc, now) <= idle_limit:
return True
return False
if llm["action"] == "orphan" and rules["action"] == "new" and _llm_orphan_vetoed(ctx):
return rules, "rules_guard"
if (
llm["action"] == rules["action"] == "link"
and llm["matched_incident"]["incident_id"] == rules["matched_incident"]["incident_id"]
):
# The LLM confirmed the rules' own link: keep the rules' path/fit
# signal so corr_path histograms stay comparable across runs.
return rules, "llm"
final = {
"action": llm["action"],
"matched_incident": llm["matched_incident"],
"incident_type": llm["incident_type"],
"corr_debug": dict(llm.get("corr_debug") or {}),
}
if llm["action"] == "link":
# The rules' corr_path/fit signal described a different judgement
# (possibly a different incident); keep it under corr_rules_* only.
final["corr_debug"]["corr_path"] = "llm"
elif llm["action"] == "orphan" and rules["action"] == "link":
# Tombstone: the rules-only sweep would otherwise re-commit the very
# unverified unit link the LLM just refused.
final["corr_debug"]["corr_path"] = "llm_orphan"
return final, "llm"
def _call_is_substanceless(ctx: dict) -> tuple[bool, Optional[str]]:
"""
True when the call carries nothing that marks it as a real event:
• no resolved incident_type and not a reassignment, AND
• severity is not moderate/major, AND
• no vehicle, geocode or tag (incident_correlator.has_event_substance —
the same predicate the incident-creation gate uses), AND
• no recent incident already running on the same talkgroup.
Only then may the LLM-orphan gate drop the call without a tiebreak.
Returns (substanceless, veto_reason). veto_reason names whichever
condition kept the tiebreak alive ("type" | "reassignment" | "severity" |
"substance" | "recent_tg"), or None when the call is substanceless. The
caller writes this into corr_debug on the escalation path so a live
measurement window can see *why* each llm=orphan/rules=new call escaped
the gate instead of inferring it after the fact from the raw dump —
exactly the guesswork that produced a wrong "confirmed explanation" for
2 window-#3 misses on the first pass of this fix.
"""
from app.internal import incident_correlator
# The incident-creation gate skips the has_event_substance check entirely
# when a type resolved (incident_correlator._run_decision ~:1397), so a
# typed call — fire/medical/etc. — opens an incident on substance we do not
# re-check here. reassignment=True is dispatch pulling a unit onto a NEW
# job (units are blanked at :296 for exactly that reason): the strongest
# new-incident signal in the pipeline. Either one means "keep the tiebreak".
if ctx.get("incident_type"):
return False, "type"
if ctx.get("reassignment"):
return False, "reassignment"
if (ctx.get("call_severity") or "routine") in ("moderate", "major"):
return False, "severity"
if incident_correlator.has_event_substance(ctx):
return False, "substance"
if _recent_incident_on_same_talkgroup(ctx):
return False, "recent_tg"
return True, None
async def _correlate_with_consensus(
async def _correlate(
call_id: str,
node_id: str,
system_id: Optional[str],
@@ -222,19 +179,22 @@ async def _correlate_with_consensus(
severity: Optional[str] = None,
transcript: Optional[str] = None,
scene_index: int = 0,
) -> Optional[str]:
) -> tuple[Optional[str], dict]:
"""
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
If they agree the rules decision is committed directly.
If they disagree a smarter tiebreaker LLM makes the final call.
Correlate one scene (server-26#299): the rules engine decides; only when
it marks its decision ambiguous is ONE cheap LLM call made, and a usable
LLM answer is committed instead. Returns (incident_id, committed decision).
Falls back to rules-only when GEMINI_API_KEY is absent, the call is
content-free (thin), or any LLM call fails.
corr_consensus on the call doc says who decided:
"rules_only" — confident rules decision, no LLM call
"llm" — ambiguous, the LLM's answer was committed
"rules_fallback" — ambiguous, LLM unavailable/failed/unusable → rules
"rules_guard" — ambiguous, LLM said orphan on a call with a strong
new-event signal → rules "new" kept
and corr_band names why it was ambiguous.
``scene_index`` (server-26#96) — which scene of the call this is, from the
caller's ``enumerate(scenes)`` loop. Threaded through so the call doc's
per-scene ``scenes`` map records this scene's own corr_debug/transcript
instead of colliding with every other scene's write on the flat fields.
``scene_index`` (server-26#96) — which scene of the call this is, so the
call doc's per-scene ``scenes`` map records this scene's own decision.
"""
from app.internal import incident_correlator, llm_correlator
@@ -247,68 +207,119 @@ async def _correlate_with_consensus(
embedding=embedding, severity=severity, transcript=transcript,
scene_index=scene_index,
)
ctx = preview["ctx"]
rules_decision = preview["decision"]
ctx = preview["ctx"]
rules = preview["decision"]
band = rules.get("ambiguous")
llm_decision = await llm_correlator.decide(call_id, ctx)
if not band:
rules["corr_debug"]["corr_consensus"] = "rules_only"
return await incident_correlator.apply_correlation(preview), rules
if llm_decision is None:
# LLM unavailable, skipped (thin call), or errored — rules wins.
rules_decision["corr_debug"]["corr_consensus"] = "rules_only"
return await incident_correlator.apply_correlation(preview)
if llm_correlator.decisions_agree(rules_decision, llm_decision):
rules_decision["corr_debug"]["corr_consensus"] = "agreed"
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "")
return await incident_correlator.apply_correlation(preview)
# server-26#115 — LLM-orphan gate.
# When the cheap LLM says `orphan`, the rules engine says `new`, and the call
# is genuinely substanceless (routine severity, no vehicle/geocode/tag, and
# no incident already running on this talkgroup), resolve to `orphan` and DO
# NOT pay for the smart tiebreaker. A bare rules `new` there means only
# "nothing to link to" — trivially true for radio housekeeping (check-ins,
# roll call, 10-8/10-98) — and the tiebreaker rubber-stamped it ~21/21 of the
# time on exactly this disagreement (CORRELATION_REVIEW_0907b.md). Any real
# signal (severity, coords, tags, a live same-talkgroup incident) still
# escalates, so an event the LLM misreads as orphan is not lost.
is_orphan_vs_new = llm_decision["action"] == "orphan" and rules_decision["action"] == "new"
substanceless, gate_veto_reason = _call_is_substanceless(ctx) if is_orphan_vs_new else (False, None)
if is_orphan_vs_new and substanceless:
logger.info(
f"Consensus gate for call {call_id}: llm=orphan vs rules=new and call "
f"is substanceless — resolving orphan, skipping tiebreak"
)
gated = {
"action": "orphan",
"matched_incident": None,
"incident_type": None,
"corr_debug": dict(rules_decision.get("corr_debug") or {}),
}
gated["corr_debug"].update({
"corr_consensus": "llm_orphan_gate",
"corr_rules_action": rules_decision["action"],
"corr_llm_action": llm_decision["action"],
"corr_llm_reasoning": llm_decision.get("reasoning", ""),
})
return await incident_correlator.apply_correlation({"decision": gated, "ctx": ctx})
# Disagree — escalate to the smarter tiebreaker.
llm = await llm_correlator.decide(call_id, ctx)
if llm is None:
final, consensus = rules, "rules_fallback"
else:
final, consensus = _merge_llm_decision(rules, llm, ctx)
final["corr_debug"].update({
"corr_consensus": consensus,
"corr_band": band,
"corr_rules_action": rules["action"],
})
if llm is not None:
final["corr_debug"]["corr_llm_action"] = llm["action"]
final["corr_debug"]["corr_llm_reasoning"] = llm.get("reasoning", "")
logger.info(
f"Consensus disagreement for call {call_id}: "
f"rules={rules_decision['action']} vs llm={llm_decision['action']} — tiebreak"
f"Correlation for call {call_id} scene {scene_index}: ambiguous ({band}) — "
f"rules={rules['action']} llm={llm['action'] if llm else 'none'} → {consensus}"
)
final = await llm_correlator.tiebreak(rules_decision, llm_decision, ctx)
final["corr_debug"]["corr_consensus"] = "tiebreak"
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
if is_orphan_vs_new:
# server-26#115 — record *why* the llm=orphan/rules=new gate stood
# down instead of leaving a future measurement window to guess it
# from the raw dump (which produced a wrong "confirmed explanation"
# for 2/24 misses the first time around).
final["corr_debug"]["corr_gate_veto"] = gate_veto_reason
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx}), final
def _may_resolve(decision: dict) -> bool:
"""
server-26#88: honour a scene's `resolved` flag only on a link the
correlator was confident about, or on the incident this very scene opened.
A scene that attached by thin recency, or whose placement was ambiguous
(an LLM pick, or a rules fallback in the ambiguous band), has no business
closing an incident it may not belong to — that closed live incidents
while the event was still on the air.
"""
if decision.get("action") == "new":
return True
debug = decision.get("corr_debug") or {}
return (
decision.get("action") == "link"
and debug.get("corr_consensus") == "rules_only"
and debug.get("corr_path") != "fast/thin"
)
async def _correlate_scenes(
call_id: str,
node_id: str,
system_id: Optional[str],
talkgroup_id: Optional[int],
talkgroup_name: Optional[str],
scenes: list[dict],
) -> tuple[list[str], list[str]]:
"""
Correlate each scene of one call independently — a single recording can
carry several events on a busy channel — and apply each scene's LLM
closure. Returns (incident_ids, merged tags). Shared by the live pipeline,
replay (via _extract_and_correlate) and the transcript-PATCH path.
"""
from app.internal import incident_correlator
incident_ids: list[str] = []
all_tags: list[str] = []
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
# When dispatch is pulling a unit to a NEW call (reassignment), suppress
# unit overlap so the new scene doesn't chain into the unit's previous
# incident. parse_llm_bool (#88): a quoted "false" is not True.
is_reassignment = incident_correlator.parse_llm_bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id, decision = await _correlate(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if incident_id and incident_correlator.parse_llm_bool(scene.get("resolved")):
if not _may_resolve(decision):
logger.info(
f"Ignoring closure from call {call_id} scene {scene_index}: link into "
f"{incident_id} was not confident (server-26#88)"
)
continue
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
# One transmission read as "it's over" ("transport complete")
# closed the whole 09-22 bridge MVA at 14:44 and its next 56
# calls opened a second incident (server-26#170). Inferred from
# a single call, so provisional, like a timer close.
"reopenable": True,
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
return incident_ids, all_tags
async def _resolve_flags(system_id: Optional[str]):
@@ -335,7 +346,7 @@ async def _run_extraction_pipeline(
preserve_transcript_correction: bool = False,
) -> None:
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter
from app.internal import intelligence, alerter
flags, _flag = await _resolve_flags(system_id)
@@ -353,45 +364,9 @@ async def _run_extraction_pipeline(
)
# Step 3: Correlate each scene to an incident independently.
# server-26#96: scene_index is threaded through so each scene's
# corr_debug/transcript lands in its own entry of the call doc's
# `scenes` map instead of clobbering every other scene's write.
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
# When dispatch is pulling a unit to a NEW call (reassignment), suppress unit
# overlap so the new scene doesn't chain into the unit's previous incident.
is_reassignment = bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id = await _correlate_with_consensus(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
"reopenable": True, # provisional, see _extract_and_correlate
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
incident_ids, all_tags = await _correlate_scenes(
call_id, node_id, system_id, talkgroup_id, talkgroup_name, scenes,
)
else:
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id} (reprocess)")
@@ -430,7 +405,7 @@ async def _extract_and_correlate(
copy of it that can drift. Caller owns the correlation feature-flag check
and alerting.
"""
from app.internal import intelligence, incident_correlator
from app.internal import intelligence
# Step 2: Scene detection + intelligence extraction
if scenes is None:
@@ -443,50 +418,9 @@ async def _extract_and_correlate(
)
# Step 3: Correlate each scene independently.
# A single recording can produce multiple incidents on a busy channel.
incident_ids: list[str] = []
all_tags: list[str] = []
# server-26#96: scene_index is threaded through so each scene's
# corr_debug/transcript lands in its own entry of the call doc's
# `scenes` map instead of clobbering every other scene's write.
for scene_index, scene in enumerate(scenes):
all_tags.extend(scene["tags"])
is_reassignment = bool(scene.get("reassignment"))
corr_units = [] if is_reassignment else scene.get("units")
incident_id = await _correlate_with_consensus(
call_id=call_id,
node_id=node_id,
system_id=system_id,
talkgroup_id=talkgroup_id,
talkgroup_name=talkgroup_name,
tags=scene["tags"],
incident_type=scene["incident_type"],
location=scene["location"],
location_coords=scene["location_coords"],
units=corr_units,
vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
transcript=scene.get("transcript"),
scene_index=scene_index,
)
if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id)
if scene["resolved"] and incident_id:
await fstore.doc_set("incidents", incident_id, {
"status": "resolved",
"resolved_at": clock.now().isoformat(),
"resolved_via": "llm_closure",
# One transmission read as "it's over" ("transport complete")
# closed the whole 09-22 bridge MVA at 14:44 and its next 56
# calls opened a second incident (server-26#170). Inferred from
# a single call, so provisional, like a timer close.
"reopenable": True,
})
await incident_correlator.maybe_resolve_parent(incident_id)
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
incident_ids, all_tags = await _correlate_scenes(
call_id, node_id, system_id, talkgroup_id, talkgroup_name, scenes,
)
# Correlator also runs for calls with no scenes (unclassified) to attempt
# talkgroup-based linking even when no transcript could be produced.
@@ -502,7 +436,7 @@ async def _extract_and_correlate(
_call_doc = await fstore.doc_get("calls", call_id)
skip_reason = (_call_doc or {}).get("skip_reason")
if not skip_reason or skip_reason == "transcript_too_short":
incident_id = await _correlate_with_consensus(
incident_id, _ = await _correlate(
call_id=call_id,
node_id=node_id,
system_id=system_id,