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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user