Merge feat/297-cheaper-ai into feat/299-correlation-simplify

Resolve upload.py (no-scene fallback uses the new _correlate with #298's
non_event_chatter reason) and chatter_classifier docstring (gate built, off by
default). Fold intelligence._llm_bool into incident_correlator.parse_llm_bool (#88).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-27 20:31:54 -04:00
co-authored by Claude Opus 5.5
22 changed files with 2046 additions and 231 deletions
+169 -81
View File
@@ -1,7 +1,8 @@
"""
GPT-4o-mini intelligence extraction from call transcripts.
Intelligence extraction from call transcripts.
Sends the transcript to GPT-4o-mini with a structured prompt that detects
Sends the transcript to the extraction model (settings.extraction_model,
gpt-4o-mini by default) with a structured prompt that detects
whether the recording contains one or multiple distinct scenes (back-to-back
dispatch conversations on a busy channel). Returns a list of scene dicts —
one per detected incident. Most calls produce a single scene.
@@ -12,12 +13,14 @@ import asyncio
import json
import math
import re
from collections import OrderedDict
from typing import Optional
from app.internal.logger import logger
from app.internal import firestore as fstore
from app.internal import ai_health
from app.internal import area_context
from app.internal.chatter_classifier import classify_chatter
from app.internal import incident_correlator
from app.internal.chatter_classifier import classify_chatter, gate_chatter
# Location validity is defined once, by the module that owns the incident's
# location/pin invariant. incident_correlator does not import this module, so
# this is not a cycle.
@@ -218,11 +221,12 @@ async def extract_scenes(
pass
return []
# server-26#127 — SHADOW MODE ONLY. Computes whether this transcript looks
# server-26#127 — the shadow verdict. Computes whether this transcript looks
# like non-event radio housekeeping (roll call, bare 10-4/10-8/98
# acknowledgements, unit check-ins) and records the verdict on the call
# doc, but does NOT skip extraction anywhere below — every path runs
# exactly as it did before this landed. Deliberately ahead of the ≤5-word
# acknowledgements, unit check-ins) and records it on the call doc. The
# live cost gate (server-26#298, gate_chatter below) is a stricter view of
# the same classifier; this verdict is recorded unchanged so the /admin
# tallies keep measuring the same thing. Deliberately ahead of the ≤5-word
# skip: most bare acknowledgements ARE ≤5 words, and the first pass of
# this feature put the classifier after that return, so it never saw the
# bulk of its own target population — a review backtest against three
@@ -231,13 +235,9 @@ async def extract_scenes(
# roughly a fifth of the real catch rate. Computing it once, here, and
# folding the result into whichever skip/continue path runs below fixes
# that without adding a second Firestore write.
# TODO(server-26#127): flip this from shadow to live (skip extraction and
# write skip_reason="non_event_chatter" instead of just recording the
# verdict) once a live shadow-mode window confirms 0 false positives on
# real production traffic — pay particular attention to whole-transcript
# vs contains-anywhere matching for "roll call" and to digit-hyphen street
# addresses (e.g. "72-Holland"), both flagged as classifier risks that the
# dump backtest could not surface on its own.
# Residual risk of the live gate, unchanged from the #127 review: a
# digit-hyphen street address ("72-Holland") alone in a transmission reads
# as a unit ID. CHATTER_GATE_ENABLED=false restores shadow mode.
chatter_is_chatter, chatter_reason = classify_chatter(transcript)
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
@@ -277,6 +277,40 @@ async def extract_scenes(
"tags": tags, "incident_type": typ, "severity": sev}]
return []
# server-26#298 — the gate is live. Radio housekeeping has no incident
# content for the extraction model to find, so it no longer pays for an
# extraction, an embedding per scene and a geocode to come back as a thin
# routine scene. gate_chatter is classify_chatter with roll_call narrowed
# to whole-transcript roll calls (the #127 review's false-positive shape).
# The free rule paths still run, exactly as on the <=5-word path above.
from app.config import settings
gated, gate_reason = gate_chatter(transcript) if settings.chatter_gate_enabled else (False, None)
if gated:
logger.info(
f"Intelligence: call {call_id} — non-event chatter ({gate_reason}), skipping extraction"
)
cleared_unit = _short_clearance_unit(transcript)
tags, typ, sev = _self_initiated_backstop(transcript, [], None, "routine", talkgroup_name)
updates = {
"skip_reason": "non_event_chatter",
"severity": sev if tags else "routine",
"chatter_classifier_verdict": chatter_is_chatter,
"chatter_classifier_reason": chatter_reason,
}
if cleared_unit:
updates["units"] = [cleared_unit]
updates["cleared_units"] = [cleared_unit]
try:
await fstore.doc_set("calls", call_id, updates)
except Exception:
pass
if cleared_unit:
return [_clearance_scene(transcript, cleared_unit)]
if tags:
return [{**_clearance_scene(transcript, ""), "units": [], "cleared_units": [],
"tags": tags, "incident_type": typ, "severity": sev}]
return []
try:
await fstore.doc_set("calls", call_id, {
"chatter_classifier_verdict": chatter_is_chatter,
@@ -294,13 +328,14 @@ async def extract_scenes(
except Exception as e:
text = str(e)
kind = ai_health.classify(text)
logger.warning(f"GPT-4o-mini extraction failed for call {call_id}: {text}")
from app.config import settings as _s
logger.warning(f"Extraction ({_s.extraction_model}) failed for call {call_id}: {text}")
await ai_health.report_degraded(
"extraction", "openai", "gpt-4o-mini",
"extraction", "openai", _s.extraction_model,
{"billing": "the OpenAI account is out of credit",
"dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"),
{"billing": "Top up OpenAI billing",
"dead_model": "Update the extraction model in intelligence.py"}.get(kind, "Usually transient"),
"dead_model": "Update EXTRACTION_MODEL"}.get(kind, "Usually transient"),
permanent=kind != "transient",
)
return []
@@ -364,8 +399,10 @@ async def extract_scenes(
severity: str = scene.get("severity") or "routine"
if severity == "unknown":
severity = "routine"
resolved: bool = bool(scene.get("resolved", False))
reassignment: bool = bool(scene.get("reassignment", False))
# server-26#88: bool("false") is True, which let a model answering
# with a JSON string resolve an incident it merely linked into.
resolved: bool = incident_correlator.parse_llm_bool(scene.get("resolved"))
reassignment: bool = incident_correlator.parse_llm_bool(scene.get("reassignment"))
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
segment_indices: Optional[list] = scene.get("segment_indices")
@@ -729,7 +766,6 @@ async def _geocode_location(
touch the anchor path at all — an anchor's own radius is always authoritative
when one has been resolved.
"""
import httpx
from app.config import settings
if not settings.google_maps_api_key:
@@ -755,70 +791,122 @@ async def _geocode_location(
f"{ref_lat - delta},{ref_lon - delta}"
f"|{ref_lat + delta},{ref_lon + delta}"
)
params = {
"address": location_str,
"bounds": bounds,
"region": "us",
"key": settings.google_maps_api_key,
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://maps.googleapis.com/maps/api/geocode/json",
params=params,
)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK" or not data.get("results"):
return None
result = data["results"][0]
location_type = result.get("geometry", {}).get("location_type", "")
# Reject only APPROXIMATE — a region/city boundary centroid, which is
# what an ungeocodable string degrades to and is genuinely useless.
#
# ROOFTOP-only was too strict and emptied the map: dispatch names
# places the way people speak, and Google returns GEOMETRIC_CENTER for
# exactly those forms — intersections ("Lake Street and Veterans
# Memorial Drive") and named POIs ("Brewster Station"). Both are
# precise enough to plot and to proximity-match; requiring a street
# address threw away nearly every real dispatch location, leaving only
# numbered addresses geocoded.
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
logger.info(
f"Geocoding rejected '{location_str}' — imprecise result "
f"(location_type={location_type!r}), returning None"
)
return None
loc = result["geometry"]["location"]
lat, lng = float(loc["lat"]), float(loc["lng"])
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
if dist_km > max_km:
# server-26#159: the node-distance bound is a proxy for "is
# this plausible" that only makes sense when the node's own
# position is our best guess at the area — never when the
# query already names a different region on its own terms.
if not (ref_label == "node" and trust_named_region):
logger.warning(
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
)
return None
logger.info(
f"Geocoding '{location_str}' → ({lat:.4f}, {lng:.4f}) is "
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
f"accepted anyway: the query names its own region, not the node's"
)
coords = {"lat": lat, "lng": lng, "location_type": location_type}
result = await _geocode_lookup(location_str, bounds)
if result is None:
return None
location_type = result.get("geometry", {}).get("location_type", "")
# Reject only APPROXIMATE — a region/city boundary centroid, which is
# what an ungeocodable string degrades to and is genuinely useless.
#
# ROOFTOP-only was too strict and emptied the map: dispatch names
# places the way people speak, and Google returns GEOMETRIC_CENTER for
# exactly those forms — intersections ("Lake Street and Veterans
# Memorial Drive") and named POIs ("Brewster Station"). Both are
# precise enough to plot and to proximity-match; requiring a street
# address threw away nearly every real dispatch location, leaving only
# numbered addresses geocoded.
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
logger.info(
f"Geocoded '{location_str}' → {coords} "
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
f"Geocoding rejected '{location_str}' — imprecise result "
f"(location_type={location_type!r}), returning None"
)
return coords
return None
loc = result["geometry"]["location"]
lat, lng = float(loc["lat"]), float(loc["lng"])
dist_km = _geo_dist_km(ref_lat, ref_lon, lat, lng)
if dist_km > max_km:
# server-26#159: the node-distance bound is a proxy for "is
# this plausible" that only makes sense when the node's own
# position is our best guess at the area — never when the
# query already names a different region on its own terms.
if not (ref_label == "node" and trust_named_region):
logger.warning(
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
f"— {dist_km:.1f}km from {ref_label} exceeds {max_km:.1f}km"
)
return None
logger.info(
f"Geocoding '{location_str}' → ({lat:.4f}, {lng:.4f}) is "
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
f"accepted anyway: the query names its own region, not the node's"
)
coords = {"lat": lat, "lng": lng, "location_type": location_type}
logger.info(
f"Geocoded '{location_str}' → {coords} "
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
)
return coords
except Exception as e:
logger.warning(f"Geocoding failed for '{location_str}': {e}")
return None
# server-26#298: geocode answers, keyed by the exact request (address +
# bounds). Only the first result's geometry is kept — the minimum the checks
# in _geocode_location read — so the cache holds coordinates, not Maps
# content. The distance/precision checks still run per call on a hit, so a
# cached answer is judged against whatever anchor or node asks.
_geocode_cache: "OrderedDict[tuple[str, str], tuple[float, Optional[dict]]]" = OrderedDict()
geocode_cache_stats = {"hits": 0, "misses": 0}
def clear_geocode_cache() -> None:
_geocode_cache.clear()
geocode_cache_stats.update(hits=0, misses=0)
async def _geocode_lookup(address: str, bounds: str) -> Optional[dict]:
"""
The Geocoding API's first result for `address` inside `bounds`, or None
when it has none. Cached in process (settings.geocode_cache_*). A network
error or non-200 raises and is NOT cached — the next call retries.
"""
import time
import httpx
from app.config import settings
key = (address.strip().lower(), bounds)
now = time.monotonic()
hit = _geocode_cache.get(key)
if hit is not None:
expires, value = hit
if expires > now:
_geocode_cache.move_to_end(key)
geocode_cache_stats["hits"] += 1
return value
_geocode_cache.pop(key, None)
geocode_cache_stats["misses"] += 1
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://maps.googleapis.com/maps/api/geocode/json",
params={"address": address, "bounds": bounds, "region": "us",
"key": settings.google_maps_api_key},
)
r.raise_for_status()
data = r.json()
status = data.get("status")
value: Optional[dict] = None
if status == "OK" and data.get("results"):
geometry = data["results"][0].get("geometry") or {}
value = {"geometry": {"location": geometry.get("location") or {},
"location_type": geometry.get("location_type", "")}}
ttl = settings.geocode_cache_ttl_seconds
elif status == "ZERO_RESULTS":
ttl = settings.geocode_cache_negative_ttl_seconds
else:
# OVER_QUERY_LIMIT / REQUEST_DENIED / UNKNOWN_ERROR: a key or quota
# problem, not an answer about this address.
return None
if ttl > 0 and settings.geocode_cache_max_entries > 0:
_geocode_cache[key] = (now + ttl, value)
while len(_geocode_cache) > settings.geocode_cache_max_entries:
_geocode_cache.popitem(last=False)
return value
def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
"""
Extract the municipality name from a talkgroup name.
@@ -943,7 +1031,7 @@ def _sync_extract(
ten_codes: Optional[dict[str, str]] = None,
unit_format_hint: Optional[str] = None,
) -> list[dict]:
"""Call GPT-4o-mini and return a list of scene dicts."""
"""Call the extraction model and return a list of scene dicts."""
from app.config import settings
from app.internal.ai_clients import openai_client
@@ -965,7 +1053,7 @@ def _sync_extract(
try:
client = openai_client()
response = client.chat.completions.create(
model="gpt-4o-mini",
model=settings.extraction_model,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
@@ -980,7 +1068,7 @@ def _sync_extract(
return [raw]
except json.JSONDecodeError as e:
logger.warning(f"GPT-4o-mini returned non-JSON: {e}")
logger.warning(f"Extraction model returned non-JSON: {e}")
return []
# Any other exception is the API call itself failing (no credit, rate
# limit, outage) and propagates to extract_scenes, which reports it to
@@ -990,7 +1078,7 @@ def _sync_extract(
def _sync_embed(text: str) -> Optional[list[float]]:
"""Generate a text-embedding-3-small vector for semantic similarity."""
"""Generate an embedding (settings.embedding_model) for semantic similarity."""
from app.config import settings
from app.internal.ai_clients import openai_client
@@ -999,7 +1087,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
try:
client = openai_client()
result = client.embeddings.create(model="text-embedding-3-small", input=text)
result = client.embeddings.create(model=settings.embedding_model, input=text)
return result.data[0].embedding
except Exception as e:
logger.warning(f"Embedding generation failed: {e}")