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:
@@ -36,8 +36,49 @@ NWS_USER_AGENT=DRB (you@example.com)
|
|||||||
# Comma list of NWS state/marine area codes served
|
# Comma list of NWS state/marine area codes served
|
||||||
NWS_AREA=NY
|
NWS_AREA=NY
|
||||||
|
|
||||||
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
# OpenAI — transcription (whisper-1, and the STT fallback), extraction, embeddings
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
# OpenAI transcription model: whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||||
|
STT_MODEL=whisper-1
|
||||||
|
|
||||||
|
# STT provider (server-26#297). Any OpenAI-compatible /audio/transcriptions API.
|
||||||
|
# auto (default): groq if GROQ_API_KEY is set, compat if STT_BASE_URL is set,
|
||||||
|
# otherwise openai — so leaving these blank keeps today's OpenAI behaviour.
|
||||||
|
STT_PROVIDER=auto
|
||||||
|
# Groq: whisper-large-v3-turbo, $0.04/audio-hour (min 10s billed per request).
|
||||||
|
GROQ_API_KEY=
|
||||||
|
# Model on the non-OpenAI provider.
|
||||||
|
STT_COMPAT_MODEL=whisper-large-v3-turbo
|
||||||
|
# Override the endpoint/key (blank = provider default: https://api.groq.com/openai/v1
|
||||||
|
# and GROQ_API_KEY). Set both for a self-hosted OpenAI-compatible server.
|
||||||
|
STT_BASE_URL=
|
||||||
|
STT_API_KEY=
|
||||||
|
# Retry on OpenAI STT_MODEL when the provider above fails (needs OPENAI_API_KEY).
|
||||||
|
STT_FALLBACK_TO_OPENAI=true
|
||||||
|
# Area places / local names / vocabulary in the STT prompt (non-OpenAI provider only).
|
||||||
|
STT_VOCAB_PROMPT_ENABLED=true
|
||||||
|
# Estimated prompt token budget (Whisper keeps only the last 224).
|
||||||
|
STT_PROMPT_MAX_TOKENS=200
|
||||||
|
|
||||||
|
# Scene extraction / embeddings models (server-26#298)
|
||||||
|
EXTRACTION_MODEL=gpt-4o-mini
|
||||||
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
# Skip correction + extraction for radio housekeeping (roll call, bare 10-4s).
|
||||||
|
# Default false = shadow mode (verdict recorded, nothing skipped). Turn on only
|
||||||
|
# after a shadow window shows no real events flagged (server-26#127).
|
||||||
|
CHATTER_GATE_ENABLED=false
|
||||||
|
# In-process geocode cache (Maps terms allow caching lat/lng <= 30 days)
|
||||||
|
GEOCODE_CACHE_TTL_SECONDS=86400
|
||||||
|
GEOCODE_CACHE_NEGATIVE_TTL_SECONDS=3600
|
||||||
|
GEOCODE_CACHE_MAX_ENTRIES=5000
|
||||||
|
|
||||||
|
# Push-alert confidence gate (server-26#300). Below the floor -> held (admin-only
|
||||||
|
# GET /alerts/held). High-severity matches also need N calls on the incident or
|
||||||
|
# the high confidence bar.
|
||||||
|
ALERT_MIN_CONFIDENCE=0.5
|
||||||
|
ALERT_HIGH_SEVERITY_MIN_CONFIDENCE=0.85
|
||||||
|
ALERT_CORROBORATION_MIN_CALLS=2
|
||||||
|
ALERT_STT_REFERENCE=0.3
|
||||||
SUMMARY_INTERVAL_MINUTES=15
|
SUMMARY_INTERVAL_MINUTES=15
|
||||||
CORRELATION_WINDOW_HOURS=4
|
CORRELATION_WINDOW_HOURS=4
|
||||||
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||||
|
|||||||
@@ -28,8 +28,39 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# OpenAI (STT + intelligence)
|
# OpenAI (STT + intelligence)
|
||||||
openai_api_key: Optional[str] = None
|
openai_api_key: Optional[str] = None
|
||||||
|
# The OpenAI transcription model. Used when OpenAI is the STT provider AND
|
||||||
|
# as the fallback when another provider fails (server-26#297).
|
||||||
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||||
|
|
||||||
|
# ── STT provider (server-26#297) ──────────────────────────────────────────
|
||||||
|
# Any OpenAI-compatible /audio/transcriptions endpoint. Groq serves
|
||||||
|
# whisper-large-v3-turbo at $0.04/audio-hour against whisper-1's $0.36, same
|
||||||
|
# API (min billed length 10s per request, prompt capped at 224 tokens --
|
||||||
|
# console.groq.com/docs/speech-to-text, checked 2026-10-27).
|
||||||
|
# auto -- groq when GROQ_API_KEY is set, compat when STT_BASE_URL is
|
||||||
|
# set, else openai. The default, so a deploy without either
|
||||||
|
# behaves exactly as before.
|
||||||
|
# openai -- OpenAI only (stt_model).
|
||||||
|
# groq -- Groq (stt_compat_model), OpenAI fallback.
|
||||||
|
# compat -- any other OpenAI-compatible server: stt_base_url + stt_api_key
|
||||||
|
# + stt_compat_model (e.g. a self-hosted faster-whisper, #296).
|
||||||
|
stt_provider: str = "auto"
|
||||||
|
stt_base_url: Optional[str] = None # None -> the provider's own default
|
||||||
|
stt_api_key: Optional[str] = None # None -> GROQ_API_KEY / OPENAI_API_KEY
|
||||||
|
groq_api_key: Optional[str] = None
|
||||||
|
stt_compat_model: str = "whisper-large-v3-turbo" # model on a non-OpenAI provider
|
||||||
|
# Retry a failed non-OpenAI request once on OpenAI stt_model. Needs
|
||||||
|
# OPENAI_API_KEY; without it a primary failure just means no transcript.
|
||||||
|
stt_fallback_to_openai: bool = True
|
||||||
|
# Domain vocabulary in the STT prompt (area places, local_knowledge terms,
|
||||||
|
# vocabulary). Applied to the non-OpenAI provider only: whisper-1 echoed
|
||||||
|
# vocabulary into silence before (see transcription.py), so the OpenAI
|
||||||
|
# path -- including the fallback -- keeps today's fixed prompt.
|
||||||
|
stt_vocab_prompt_enabled: bool = True
|
||||||
|
# Whisper keeps only the LAST 224 prompt tokens and silently drops the
|
||||||
|
# rest. The prompt is built against this estimated budget, with headroom.
|
||||||
|
stt_prompt_max_tokens: int = 200
|
||||||
|
|
||||||
# AI request timeouts (server-26#153). Without these the OpenAI SDK waits
|
# AI request timeouts (server-26#153). Without these the OpenAI SDK waits
|
||||||
# up to 600s per attempt and retries twice, and the google-genai client
|
# up to 600s per attempt and retries twice, and the google-genai client
|
||||||
# waits forever, so a hung provider call had no upper bound at all — and
|
# waits forever, so a hung provider call had no upper bound at all — and
|
||||||
@@ -48,8 +79,31 @@ class Settings(BaseSettings):
|
|||||||
# google-genai does not retry unless told to, so this is the whole bound.
|
# google-genai does not retry unless told to, so this is the whole bound.
|
||||||
gemini_timeout_seconds: float = 60.0
|
gemini_timeout_seconds: float = 60.0
|
||||||
|
|
||||||
|
# Scene extraction + embeddings (server-26#298). Were literals in
|
||||||
|
# intelligence.py; any OpenAI chat model that supports JSON mode works.
|
||||||
|
extraction_model: str = "gpt-4o-mini"
|
||||||
|
embedding_model: str = "text-embedding-3-small"
|
||||||
|
# Skip correction + extraction (+ embedding, geocoding) for transcripts
|
||||||
|
# chatter_classifier.gate_chatter calls radio housekeeping. OFF by default:
|
||||||
|
# a gated call carries no units, so it is thin and takes the no-fit-check
|
||||||
|
# thin attach (mechanism A) instead of the fit-tested path or the #115
|
||||||
|
# orphan gate, and server-26#127 set "0 false positives in a live shadow
|
||||||
|
# window" as the bar for going live. Read chatter_classifier_verdict off a
|
||||||
|
# shadow window first, then set CHATTER_GATE_ENABLED=true.
|
||||||
|
chatter_gate_enabled: bool = False
|
||||||
|
|
||||||
# Google Maps (geocoding)
|
# Google Maps (geocoding)
|
||||||
google_maps_api_key: Optional[str] = None
|
google_maps_api_key: Optional[str] = None
|
||||||
|
# In-process cache of Geocoding API answers (server-26#298). Dispatch
|
||||||
|
# repeats the same address on every follow-up call of a job, and the
|
||||||
|
# place verifier re-geocodes the same nouns. In-process rather than
|
||||||
|
# Firestore: a Firestore read costs about what it saves, adds latency on
|
||||||
|
# every miss, and would persist Maps content -- the Maps terms allow
|
||||||
|
# caching lat/lng for at most 30 days, and an in-memory TTL can never
|
||||||
|
# outlive that. Lost on restart, which only costs a few repeat lookups.
|
||||||
|
geocode_cache_ttl_seconds: int = 24 * 60 * 60
|
||||||
|
geocode_cache_negative_ttl_seconds: int = 60 * 60 # ZERO_RESULTS etc.
|
||||||
|
geocode_cache_max_entries: int = 5000
|
||||||
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
|
# 511NY developer key (server-26#183/#198). Issued with the NYSDOT Developer
|
||||||
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
|
# Access Agreement grant; the keyless legacy API is gone since the 511x cutover.
|
||||||
ny511_api_key: Optional[str] = None
|
ny511_api_key: Optional[str] = None
|
||||||
@@ -105,6 +159,30 @@ class Settings(BaseSettings):
|
|||||||
# else's PII. Both gates must be open before any snippet leaves.
|
# else's PII. Both gates must be open before any snippet leaves.
|
||||||
alert_transcript_snippet_enabled: bool = False
|
alert_transcript_snippet_enabled: bool = False
|
||||||
place_verify_max_per_call: int = 3
|
place_verify_max_per_call: int = 3
|
||||||
|
# ── Alert confidence gate (server-26#300) ─────────────────────────────────
|
||||||
|
# Nothing in the pipeline emits a confidence, so the alerter derives one:
|
||||||
|
# how the rule matched (talkgroup 1.0 / extracted tag 0.8 / raw transcript
|
||||||
|
# text only 0.5 -- the CrimeRadar "fire alarm" -> "firearm" failure was a
|
||||||
|
# transcript match) scaled down when Whisper's own per-token probability
|
||||||
|
# (calls.stt_confidence) is below alert_stt_reference. Below the floor ->
|
||||||
|
# held, never pushed. High-severity matches also need the incident to
|
||||||
|
# have alert_corroboration_min_calls calls, or a confidence at or above
|
||||||
|
# alert_high_severity_min_confidence; held ones are released when a
|
||||||
|
# later call corroborates the incident. Starting values, not measured.
|
||||||
|
alert_min_confidence: float = 0.5
|
||||||
|
alert_high_severity_min_confidence: float = 0.85
|
||||||
|
alert_corroboration_min_calls: int = 2
|
||||||
|
# Low on purpose: exp(avg_logprob) on vocoded radio audio is often well
|
||||||
|
# under 0.5 for speech that is right, and Groq/OpenAI logprobs are not on
|
||||||
|
# a shared scale. Only clearly garbled audio should pull the score down.
|
||||||
|
alert_stt_reference: float = 0.3
|
||||||
|
alert_high_severity_terms: list[str] = [
|
||||||
|
"shots-fired", "shots fired", "shooting", "shot", "gun", "firearm", "weapon",
|
||||||
|
"stabbing", "stabbed", "officer-down", "officer down", "10-13",
|
||||||
|
"officer-needs-assistance", "active-shooter", "hostage", "barricade",
|
||||||
|
"explosion", "structure-fire", "structure fire", "working-fire",
|
||||||
|
"entrapment", "cardiac-arrest", "vehicle-pursuit", "pursuit",
|
||||||
|
]
|
||||||
# How close a candidate has to sound before it may rewrite a transcript.
|
# How close a candidate has to sound before it may rewrite a transcript.
|
||||||
# Below this, Places Text Search will confidently hand back the nearest
|
# Below this, Places Text Search will confidently hand back the nearest
|
||||||
# business for any garbage string.
|
# business for any garbage string.
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ def openai_client(timeout: Optional[float] = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stt_client(api_key: Optional[str], base_url: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Synchronous client for an OpenAI-compatible transcription endpoint
|
||||||
|
(server-26#297) -- OpenAI itself, Groq, or a self-hosted server. Same
|
||||||
|
timeout/retry limits as every other client here, with STT's own timeout.
|
||||||
|
"""
|
||||||
|
from openai import OpenAI
|
||||||
|
kwargs = {}
|
||||||
|
if base_url:
|
||||||
|
kwargs["base_url"] = base_url
|
||||||
|
return OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
timeout=settings.stt_timeout_seconds,
|
||||||
|
max_retries=settings.openai_max_retries,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def async_openai_client(timeout: Optional[float] = None):
|
def async_openai_client(timeout: Optional[float] = None):
|
||||||
"""Async OpenAI client, same limits as openai_client()."""
|
"""Async OpenAI client, same limits as openai_client()."""
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
Alert dispatch engine.
|
Alert dispatch engine.
|
||||||
|
|
||||||
Loads enabled alert rules from Firestore and checks each one against the call's
|
Loads enabled alert rules from Firestore and checks each one against the call's
|
||||||
talkgroup ID, tags, and transcript. On a match:
|
talkgroup ID, tags, and transcript. On a match that passes the confidence
|
||||||
|
gate (server-26#300, `_assess`):
|
||||||
1. Creates an AlertEvent document in Firestore.
|
1. Creates an AlertEvent document in Firestore.
|
||||||
2. Optionally POSTs a Discord webhook message if the rule has one configured.
|
2. Optionally POSTs a Discord webhook message if the rule has one configured.
|
||||||
|
A match that fails the gate is written to `alert_holds` instead -- never
|
||||||
|
pushed, readable by admins (GET /alerts/held) -- and a hold that was waiting
|
||||||
|
only on corroboration is released when a later call brings its incident to
|
||||||
|
alert_corroboration_min_calls calls.
|
||||||
|
|
||||||
|
WHY A GATE: CrimeRadar pushed "shots fired at an elementary school" after STT
|
||||||
|
heard "fire alarm" as "firearm". A keyword hit in raw transcript text is the
|
||||||
|
weakest evidence this pipeline has, and a push alert is the one surface that
|
||||||
|
cannot be taken back.
|
||||||
|
|
||||||
Raw transcript text is withheld from both by default -- see _snippet_allowed
|
Raw transcript text is withheld from both by default -- see _snippet_allowed
|
||||||
and server-26#85.
|
and server-26#85.
|
||||||
@@ -54,6 +64,8 @@ async def check_and_dispatch(
|
|||||||
# Loop-invariant: every rule here belongs to the same org, so the opt-in is
|
# Loop-invariant: every rule here belongs to the same org, so the opt-in is
|
||||||
# resolved once rather than per match.
|
# resolved once rather than per match.
|
||||||
snippet_allowed = await _snippet_allowed(org_id)
|
snippet_allowed = await _snippet_allowed(org_id)
|
||||||
|
evidence = await _evidence(call_doc or {})
|
||||||
|
pushed_rules: set[str] = set()
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
|
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
|
||||||
@@ -63,6 +75,12 @@ async def check_and_dispatch(
|
|||||||
alert_id = str(uuid.uuid4())
|
alert_id = str(uuid.uuid4())
|
||||||
snippet = _snippet(transcript) if snippet_allowed else None
|
snippet = _snippet(transcript) if snippet_allowed else None
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
verdict = _assess(matched_keywords, tags, evidence)
|
||||||
|
if not verdict["push"]:
|
||||||
|
await _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id,
|
||||||
|
talkgroup_name, matched_keywords, snippet, now, verdict, evidence)
|
||||||
|
continue
|
||||||
|
pushed_rules.add(rule.get("rule_id", ""))
|
||||||
event = {
|
event = {
|
||||||
"alert_id": alert_id,
|
"alert_id": alert_id,
|
||||||
"org_id": org_id,
|
"org_id": org_id,
|
||||||
@@ -76,6 +94,9 @@ async def check_and_dispatch(
|
|||||||
"transcript_snippet": snippet,
|
"transcript_snippet": snippet,
|
||||||
"triggered_at": now,
|
"triggered_at": now,
|
||||||
"acknowledged": False,
|
"acknowledged": False,
|
||||||
|
"confidence": verdict["confidence"],
|
||||||
|
"high_severity": verdict["high_severity"],
|
||||||
|
"corroborating_calls": evidence["max_calls"],
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -92,6 +113,192 @@ async def check_and_dispatch(
|
|||||||
if webhook_url:
|
if webhook_url:
|
||||||
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
|
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
|
||||||
|
|
||||||
|
await _release_corroborated(rules, evidence, call_id, pushed_rules)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Confidence gate (server-26#300) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
# How a rule matched, as evidence. Talkgroup activity is metadata, not
|
||||||
|
# recognised speech, so it cannot be misheard.
|
||||||
|
_W_TALKGROUP = 1.0
|
||||||
|
_W_TAG = 0.8 # the extraction model put it in tags
|
||||||
|
_W_TRANSCRIPT = 0.6 # substring of raw STT text only (above the 0.5 default floor)
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_term(term: str) -> str:
|
||||||
|
return (term or "").strip().lower().replace("-", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_high_severity_term(term: str) -> bool:
|
||||||
|
wanted = {_norm_term(t) for t in settings.alert_high_severity_terms}
|
||||||
|
return _norm_term(term) in wanted
|
||||||
|
|
||||||
|
|
||||||
|
async def _evidence(call_doc: dict) -> dict:
|
||||||
|
"""What the call doc and its incidents say about how much to trust it."""
|
||||||
|
incident_ids = list(call_doc.get("incident_ids") or [])
|
||||||
|
max_calls = 0
|
||||||
|
max_severity = call_doc.get("severity")
|
||||||
|
severity_rank = {"routine": 0, "minor": 1, "moderate": 2, "major": 3}
|
||||||
|
for iid in incident_ids:
|
||||||
|
try:
|
||||||
|
inc = await fstore.doc_get("incidents", iid) or {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not read incident {iid}: {e}")
|
||||||
|
continue
|
||||||
|
max_calls = max(max_calls, len(inc.get("call_ids") or []))
|
||||||
|
if severity_rank.get(inc.get("severity"), -1) > severity_rank.get(max_severity, -1):
|
||||||
|
max_severity = inc.get("severity")
|
||||||
|
stt = call_doc.get("stt_confidence")
|
||||||
|
return {
|
||||||
|
"incident_ids": incident_ids,
|
||||||
|
"max_calls": max_calls,
|
||||||
|
"severity": max_severity,
|
||||||
|
"stt_confidence": stt if isinstance(stt, (int, float)) else None,
|
||||||
|
"not_speech": bool(call_doc.get("transcript_not_speech")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _assess(matched: list[str], tags: list[str], evidence: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Push or hold, and why. Pure, so the gate is testable without Firestore.
|
||||||
|
|
||||||
|
confidence = match weight x STT factor. The STT factor is 1.0 unless
|
||||||
|
Whisper's own mean token probability is under alert_stt_reference, and
|
||||||
|
1.0 when there is none (a json-only model) rather than guessing.
|
||||||
|
"""
|
||||||
|
content = [m for m in matched if not m.startswith("talkgroup:")]
|
||||||
|
if not content:
|
||||||
|
weight = _W_TALKGROUP
|
||||||
|
elif any(m in (tags or []) for m in content):
|
||||||
|
weight = _W_TAG
|
||||||
|
else:
|
||||||
|
weight = _W_TRANSCRIPT
|
||||||
|
|
||||||
|
stt = evidence.get("stt_confidence")
|
||||||
|
stt_factor = 1.0
|
||||||
|
if content and stt is not None and settings.alert_stt_reference > 0:
|
||||||
|
stt_factor = min(1.0, max(0.0, stt / settings.alert_stt_reference))
|
||||||
|
if content and evidence.get("not_speech"):
|
||||||
|
stt_factor = 0.0 # the corrector called this recogniser noise
|
||||||
|
confidence = round(weight * stt_factor, 3)
|
||||||
|
|
||||||
|
high = bool(content) and (
|
||||||
|
any(_is_high_severity_term(m) for m in content)
|
||||||
|
or any(_is_high_severity_term(t) for t in (tags or []))
|
||||||
|
or evidence.get("severity") == "major"
|
||||||
|
)
|
||||||
|
corroborated = evidence.get("max_calls", 0) >= settings.alert_corroboration_min_calls
|
||||||
|
|
||||||
|
if confidence < settings.alert_min_confidence:
|
||||||
|
return {"push": False, "confidence": confidence, "high_severity": high,
|
||||||
|
"hold_reason": "low_confidence"}
|
||||||
|
if high and not corroborated and confidence < settings.alert_high_severity_min_confidence:
|
||||||
|
return {"push": False, "confidence": confidence, "high_severity": high,
|
||||||
|
"hold_reason": "needs_corroboration"}
|
||||||
|
return {"push": True, "confidence": confidence, "high_severity": high, "hold_reason": None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _hold(alert_id, rule, org_id, call_id, node_id, talkgroup_id, talkgroup_name,
|
||||||
|
matched_keywords, snippet, now, verdict, evidence) -> None:
|
||||||
|
"""Record a gated match where admins can see it, and push nothing."""
|
||||||
|
hold = {
|
||||||
|
"alert_id": alert_id,
|
||||||
|
"org_id": org_id,
|
||||||
|
"rule_id": rule.get("rule_id", ""),
|
||||||
|
"rule_name": rule.get("name", ""),
|
||||||
|
"call_id": call_id,
|
||||||
|
"node_id": node_id,
|
||||||
|
"talkgroup_id": talkgroup_id,
|
||||||
|
"talkgroup_name": talkgroup_name or "",
|
||||||
|
"matched_keywords": matched_keywords,
|
||||||
|
"transcript_snippet": snippet,
|
||||||
|
"triggered_at": now,
|
||||||
|
"status": "held",
|
||||||
|
"hold_reason": verdict["hold_reason"],
|
||||||
|
"confidence": verdict["confidence"],
|
||||||
|
"high_severity": verdict["high_severity"],
|
||||||
|
# One incident to wait on: the first the call linked to. A call that
|
||||||
|
# linked to none can never be corroborated, so it stays held.
|
||||||
|
"incident_id": (evidence["incident_ids"] or [None])[0],
|
||||||
|
"corroborating_calls": evidence["max_calls"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await fstore.doc_set("alert_holds", alert_id, hold, merge=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not save held alert: {e}")
|
||||||
|
return
|
||||||
|
logger.info(
|
||||||
|
f"Alert HELD ({verdict['hold_reason']}, confidence={verdict['confidence']}, "
|
||||||
|
f"high_severity={verdict['high_severity']}, calls={evidence['max_calls']}): "
|
||||||
|
f"rule='{rule.get('name')}' call={call_id} keywords={matched_keywords}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _release_corroborated(rules: list[dict], evidence: dict, call_id: str,
|
||||||
|
pushed_rules: set[str]) -> None:
|
||||||
|
"""
|
||||||
|
Push holds that were waiting only on corroboration once their incident
|
||||||
|
has enough calls. A low-confidence hold is never released this way: a
|
||||||
|
second call on the incident says the event is real, not that the first
|
||||||
|
call was heard right. A rule this call already pushed for supersedes its
|
||||||
|
hold instead of alerting twice.
|
||||||
|
"""
|
||||||
|
if evidence["max_calls"] < settings.alert_corroboration_min_calls:
|
||||||
|
return
|
||||||
|
by_id = {r.get("rule_id", ""): r for r in rules}
|
||||||
|
for iid in evidence["incident_ids"]:
|
||||||
|
try:
|
||||||
|
holds = await fstore.collection_list("alert_holds", incident_id=iid, status="held")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not read holds for incident {iid}: {e}")
|
||||||
|
continue
|
||||||
|
for hold in holds:
|
||||||
|
if hold.get("hold_reason") != "needs_corroboration":
|
||||||
|
continue
|
||||||
|
rule = by_id.get(hold.get("rule_id", ""))
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
if rule is None or hold.get("rule_id", "") in pushed_rules:
|
||||||
|
status = "superseded" if rule is not None else "rule_gone"
|
||||||
|
await _set_hold_status(hold, status, call_id, now)
|
||||||
|
continue
|
||||||
|
event = {k: hold.get(k) for k in (
|
||||||
|
"alert_id", "org_id", "rule_id", "rule_name", "call_id", "node_id",
|
||||||
|
"talkgroup_id", "talkgroup_name", "matched_keywords",
|
||||||
|
"transcript_snippet", "confidence", "high_severity",
|
||||||
|
)}
|
||||||
|
event.update({
|
||||||
|
"triggered_at": now,
|
||||||
|
"acknowledged": False,
|
||||||
|
"released_from_hold": True,
|
||||||
|
"corroborated_by_call_id": call_id,
|
||||||
|
"corroborating_calls": evidence["max_calls"],
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
await fstore.doc_set("alert_events", hold["alert_id"], event, merge=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not release held alert {hold.get('alert_id')}: {e}")
|
||||||
|
continue
|
||||||
|
await _set_hold_status(hold, "released", call_id, now)
|
||||||
|
pushed_rules.add(hold.get("rule_id", ""))
|
||||||
|
logger.info(
|
||||||
|
f"Alert released from hold: rule='{hold.get('rule_name')}' incident={iid} "
|
||||||
|
f"corroborated by call {call_id} ({evidence['max_calls']} calls)"
|
||||||
|
)
|
||||||
|
webhook_url = rule.get("discord_webhook")
|
||||||
|
if webhook_url:
|
||||||
|
await _post_webhook(webhook_url, rule.get("name", ""), hold.get("talkgroup_name"),
|
||||||
|
hold.get("matched_keywords") or [], hold.get("transcript_snippet"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_hold_status(hold: dict, status: str, call_id: str, now: str) -> None:
|
||||||
|
try:
|
||||||
|
await fstore.doc_update("alert_holds", hold["alert_id"], {
|
||||||
|
"status": status, "resolved_at": now, "resolved_by_call_id": call_id,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not update hold {hold.get('alert_id')}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def _snippet_allowed(org_id: Optional[str]) -> bool:
|
async def _snippet_allowed(org_id: Optional[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ This module is that classifier. It is a PURE function of the transcript text —
|
|||||||
no Firestore, no LLM call, no side effects — so it is cheap to run on every
|
no Firestore, no LLM call, no side effects — so it is cheap to run on every
|
||||||
transcript and cheap to test against real dumps offline.
|
transcript and cheap to test against real dumps offline.
|
||||||
|
|
||||||
SHADOW MODE ONLY. As of this module's introduction, nothing skips scene
|
A COST GATE SINCE server-26#298, through `gate_chatter`, not
|
||||||
extraction based on this verdict. `intelligence.extract_scenes` calls
|
`classify_chatter` — built but OFF by default (`chatter_gate_enabled`) until a
|
||||||
`classify_chatter` purely to record the verdict on the call doc
|
shadow window shows gated calls don't over-merge. `classify_chatter` is still
|
||||||
(`chatter_classifier_verdict` / `chatter_classifier_reason`) so it becomes
|
the shadow verdict recorded on every call doc (`chatter_classifier_verdict` /
|
||||||
observable in the next `/admin` correlation-debug dump (as `corr_gate_veto`
|
`chatter_classifier_reason`), so the `/admin` tallies keep measuring the same
|
||||||
was, before server-26#299 removed that gate). See the TODO at that call site for
|
thing. The gate is stricter on the one shape the #127 review flagged as a live
|
||||||
what has to be true before this flips live.
|
false-positive risk: `roll_call` matches "roll call" anywhere, so "Delayed for
|
||||||
|
roll call, then respond to a structure fire at 12 Main Street" is a roll_call
|
||||||
|
verdict. The gate only skips a roll call whose remaining words are callsigns
|
||||||
|
and roll-call phrasing. When enabled, gated transcripts skip transcript
|
||||||
|
correction and scene extraction (and with it embedding and geocoding); the free
|
||||||
|
rule paths (clearance parse, self-initiated/plate backstop) still run.
|
||||||
|
|
||||||
Precision over recall, deliberately. A false positive here — flagging a REAL
|
Precision over recall, deliberately. A false positive here — flagging a REAL
|
||||||
event as chatter — would, once live, silently mean that event never gets a
|
event as chatter — would, once live, silently mean that event never gets a
|
||||||
@@ -133,3 +138,36 @@ def classify_chatter(transcript: Optional[str]) -> tuple[bool, Optional[str]]:
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
return True, "bare_acknowledgement"
|
return True, "bare_acknowledgement"
|
||||||
|
|
||||||
|
|
||||||
|
# Words a roll call is made of, beyond the filler/designator/phonetic sets.
|
||||||
|
_ROLL_CALL_WORDS = frozenset({
|
||||||
|
"roll", "call", "all", "cars", "car", "patrol", "patrols", "troop", "staff",
|
||||||
|
"concludes", "time", "came", "because", "volunteer", "nyc", "kl", "henry",
|
||||||
|
"here", "present", "s", "m", "it",
|
||||||
|
})
|
||||||
|
# A roll call may name a place or two ("Post 4, Ossining"); more than this many
|
||||||
|
# leftover words means something else is being said in the same transmission.
|
||||||
|
_ROLL_CALL_MAX_LEFTOVER = 2
|
||||||
|
|
||||||
|
|
||||||
|
def gate_chatter(transcript: Optional[str]) -> tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Whether to skip paid AI for this transcript (server-26#298).
|
||||||
|
|
||||||
|
`classify_chatter`'s verdict, with roll_call narrowed: the transcript must
|
||||||
|
be roll-call content end to end, not a roll call mentioned on the way to
|
||||||
|
something real. Precision over recall still — a false positive here means
|
||||||
|
a real event never gets a scene.
|
||||||
|
"""
|
||||||
|
is_chatter, reason = classify_chatter(transcript)
|
||||||
|
if not is_chatter:
|
||||||
|
return False, None
|
||||||
|
if reason == "roll_call":
|
||||||
|
leftover = [
|
||||||
|
t for t in _tokenize(transcript or "")
|
||||||
|
if not _is_filler_token(t) and t not in _ROLL_CALL_WORDS
|
||||||
|
]
|
||||||
|
if len(leftover) > _ROLL_CALL_MAX_LEFTOVER:
|
||||||
|
return False, None
|
||||||
|
return True, reason
|
||||||
|
|||||||
@@ -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
|
whether the recording contains one or multiple distinct scenes (back-to-back
|
||||||
dispatch conversations on a busy channel). Returns a list of scene dicts —
|
dispatch conversations on a busy channel). Returns a list of scene dicts —
|
||||||
one per detected incident. Most calls produce a single scene.
|
one per detected incident. Most calls produce a single scene.
|
||||||
@@ -12,12 +13,14 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
from collections import OrderedDict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal import ai_health
|
from app.internal import ai_health
|
||||||
from app.internal import area_context
|
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 validity is defined once, by the module that owns the incident's
|
||||||
# location/pin invariant. incident_correlator does not import this module, so
|
# location/pin invariant. incident_correlator does not import this module, so
|
||||||
# this is not a cycle.
|
# this is not a cycle.
|
||||||
@@ -218,11 +221,12 @@ async def extract_scenes(
|
|||||||
pass
|
pass
|
||||||
return []
|
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
|
# like non-event radio housekeeping (roll call, bare 10-4/10-8/98
|
||||||
# acknowledgements, unit check-ins) and records the verdict on the call
|
# acknowledgements, unit check-ins) and records it on the call doc. The
|
||||||
# doc, but does NOT skip extraction anywhere below — every path runs
|
# live cost gate (server-26#298, gate_chatter below) is a stricter view of
|
||||||
# exactly as it did before this landed. Deliberately ahead of the ≤5-word
|
# 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
|
# 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
|
# this feature put the classifier after that return, so it never saw the
|
||||||
# bulk of its own target population — a review backtest against three
|
# 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
|
# roughly a fifth of the real catch rate. Computing it once, here, and
|
||||||
# folding the result into whichever skip/continue path runs below fixes
|
# folding the result into whichever skip/continue path runs below fixes
|
||||||
# that without adding a second Firestore write.
|
# that without adding a second Firestore write.
|
||||||
# TODO(server-26#127): flip this from shadow to live (skip extraction and
|
# Residual risk of the live gate, unchanged from the #127 review: a
|
||||||
# write skip_reason="non_event_chatter" instead of just recording the
|
# digit-hyphen street address ("72-Holland") alone in a transmission reads
|
||||||
# verdict) once a live shadow-mode window confirms 0 false positives on
|
# as a unit ID. CHATTER_GATE_ENABLED=false restores shadow mode.
|
||||||
# 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.
|
|
||||||
chatter_is_chatter, chatter_reason = classify_chatter(transcript)
|
chatter_is_chatter, chatter_reason = classify_chatter(transcript)
|
||||||
|
|
||||||
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
|
# 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}]
|
"tags": tags, "incident_type": typ, "severity": sev}]
|
||||||
return []
|
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:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, {
|
await fstore.doc_set("calls", call_id, {
|
||||||
"chatter_classifier_verdict": chatter_is_chatter,
|
"chatter_classifier_verdict": chatter_is_chatter,
|
||||||
@@ -294,13 +328,14 @@ async def extract_scenes(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
text = str(e)
|
text = str(e)
|
||||||
kind = ai_health.classify(text)
|
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(
|
await ai_health.report_degraded(
|
||||||
"extraction", "openai", "gpt-4o-mini",
|
"extraction", "openai", _s.extraction_model,
|
||||||
{"billing": "the OpenAI account is out of credit",
|
{"billing": "the OpenAI account is out of credit",
|
||||||
"dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"),
|
"dead_model": "model is unavailable"}.get(kind, f"extraction failed: {text[:200]}"),
|
||||||
{"billing": "Top up OpenAI billing",
|
{"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",
|
permanent=kind != "transient",
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
@@ -364,8 +399,10 @@ async def extract_scenes(
|
|||||||
severity: str = scene.get("severity") or "routine"
|
severity: str = scene.get("severity") or "routine"
|
||||||
if severity == "unknown":
|
if severity == "unknown":
|
||||||
severity = "routine"
|
severity = "routine"
|
||||||
resolved: bool = bool(scene.get("resolved", False))
|
# server-26#88: bool("false") is True, which let a model answering
|
||||||
reassignment: bool = bool(scene.get("reassignment", False))
|
# 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
|
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
||||||
segment_indices: Optional[list] = scene.get("segment_indices")
|
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
|
touch the anchor path at all — an anchor's own radius is always authoritative
|
||||||
when one has been resolved.
|
when one has been resolved.
|
||||||
"""
|
"""
|
||||||
import httpx
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
if not settings.google_maps_api_key:
|
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}"
|
||||||
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
result = await _geocode_lookup(location_str, bounds)
|
||||||
r = await client.get(
|
if result is None:
|
||||||
"https://maps.googleapis.com/maps/api/geocode/json",
|
return None
|
||||||
params=params,
|
location_type = result.get("geometry", {}).get("location_type", "")
|
||||||
)
|
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
||||||
r.raise_for_status()
|
# what an ungeocodable string degrades to and is genuinely useless.
|
||||||
data = r.json()
|
#
|
||||||
if data.get("status") != "OK" or not data.get("results"):
|
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
||||||
return None
|
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
||||||
result = data["results"][0]
|
# exactly those forms — intersections ("Lake Street and Veterans
|
||||||
location_type = result.get("geometry", {}).get("location_type", "")
|
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
||||||
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
# precise enough to plot and to proximity-match; requiring a street
|
||||||
# what an ungeocodable string degrades to and is genuinely useless.
|
# address threw away nearly every real dispatch location, leaving only
|
||||||
#
|
# numbered addresses geocoded.
|
||||||
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
||||||
# 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}
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoded '{location_str}' → {coords} "
|
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||||
return None
|
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]:
|
def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Extract the municipality name from a talkgroup name.
|
Extract the municipality name from a talkgroup name.
|
||||||
@@ -943,7 +1031,7 @@ def _sync_extract(
|
|||||||
ten_codes: Optional[dict[str, str]] = None,
|
ten_codes: Optional[dict[str, str]] = None,
|
||||||
unit_format_hint: Optional[str] = None,
|
unit_format_hint: Optional[str] = None,
|
||||||
) -> list[dict]:
|
) -> 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.config import settings
|
||||||
from app.internal.ai_clients import openai_client
|
from app.internal.ai_clients import openai_client
|
||||||
|
|
||||||
@@ -965,7 +1053,7 @@ def _sync_extract(
|
|||||||
try:
|
try:
|
||||||
client = openai_client()
|
client = openai_client()
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model="gpt-4o-mini",
|
model=settings.extraction_model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
response_format={"type": "json_object"},
|
response_format={"type": "json_object"},
|
||||||
)
|
)
|
||||||
@@ -980,7 +1068,7 @@ def _sync_extract(
|
|||||||
return [raw]
|
return [raw]
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
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 []
|
return []
|
||||||
# Any other exception is the API call itself failing (no credit, rate
|
# Any other exception is the API call itself failing (no credit, rate
|
||||||
# limit, outage) and propagates to extract_scenes, which reports it to
|
# 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]]:
|
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.config import settings
|
||||||
from app.internal.ai_clients import openai_client
|
from app.internal.ai_clients import openai_client
|
||||||
|
|
||||||
@@ -999,7 +1087,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
client = openai_client()
|
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
|
return result.data[0].embedding
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Embedding generation failed: {e}")
|
logger.warning(f"Embedding generation failed: {e}")
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ permanently improves the reference data for that channel.
|
|||||||
|
|
||||||
NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not
|
NO ANCHOR MEANS SKIP, NOT ACCEPT. An anchor too wide to discriminate is not
|
||||||
stored at all (see `area_context`), and without one this module returns
|
stored at all (see `area_context`), and without one this module returns
|
||||||
immediately. A statewide radius would confirm anything inside it, which is worse
|
immediately. A channel nobody described may still get one from its own
|
||||||
|
talkgroup name plus the node's state (server-26#160, `_talkgroup_anchor`). A statewide radius would confirm anything inside it, which is worse
|
||||||
than not checking — it looks like verification and is not.
|
than not checking — it looks like verification and is not.
|
||||||
|
|
||||||
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
|
THE FREE TIER RUNS FIRST. A sound-alike among the terms the operator already
|
||||||
@@ -169,6 +170,50 @@ def _known_soundalike(heard: str, area: dict) -> Optional[dict]:
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
async def _talkgroup_anchor(
|
||||||
|
talkgroup_name: Optional[str], node_id: Optional[str],
|
||||||
|
) -> tuple[Optional[dict], str]:
|
||||||
|
"""
|
||||||
|
An anchor derived from the talkgroup's own name plus the receiving node's
|
||||||
|
state, or (None, ""). Same width rule as an operator anchor
|
||||||
|
(area_context.resolve_anchor drops anything wider than
|
||||||
|
area_anchor_max_radius_km, and caches per place for the process).
|
||||||
|
|
||||||
|
A STATE IS REQUIRED. "Springfield PD" alone geocodes to whichever
|
||||||
|
Springfield Google prefers, and an anchor in the wrong half of the
|
||||||
|
country would let Maps rewrite real local streets into sound-alikes from
|
||||||
|
there (the failure server-26#36 added `state` to prevent). The node's
|
||||||
|
reverse-geocoded state is coarse enough to be right even when the node
|
||||||
|
sits far from the area the channel covers.
|
||||||
|
"""
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.intelligence import _get_node_state, _municipality_from_tg
|
||||||
|
|
||||||
|
muni = _municipality_from_tg(talkgroup_name)
|
||||||
|
if not muni or not node_id:
|
||||||
|
return None, ""
|
||||||
|
node = await fstore.doc_get_cached("nodes", node_id) or {}
|
||||||
|
lat, lon = node.get("lat"), node.get("lon")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return None, ""
|
||||||
|
state = await _get_node_state(node_id, lat, lon)
|
||||||
|
if not state:
|
||||||
|
return None, ""
|
||||||
|
place = {"municipality": muni, "state": state}
|
||||||
|
resolved = await area_context.resolve_anchor(place)
|
||||||
|
if not resolved:
|
||||||
|
return None, ""
|
||||||
|
try:
|
||||||
|
anchor = {
|
||||||
|
"lat": float(resolved["center"]["lat"]),
|
||||||
|
"lng": float(resolved["center"]["lng"]),
|
||||||
|
"radius_km": float(resolved["radius_km"]),
|
||||||
|
}
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None, ""
|
||||||
|
return anchor, f"{muni}, {state}"
|
||||||
|
|
||||||
|
|
||||||
# -- Public --------------------------------------------------------------------
|
# -- Public --------------------------------------------------------------------
|
||||||
|
|
||||||
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
|
def _substitute(text: str, swaps: list[tuple[str, str]]) -> str:
|
||||||
@@ -186,6 +231,8 @@ async def verify(
|
|||||||
tg_area: Optional[dict],
|
tg_area: Optional[dict],
|
||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
talkgroup_id: Optional[Any] = None,
|
talkgroup_id: Optional[Any] = None,
|
||||||
|
talkgroup_name: Optional[str] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> tuple[Optional[str], Optional[list[dict]]]:
|
) -> tuple[Optional[str], Optional[list[dict]]]:
|
||||||
"""
|
"""
|
||||||
Check the corrector's location nouns against the talkgroup's anchor.
|
Check the corrector's location nouns against the talkgroup's anchor.
|
||||||
@@ -197,12 +244,18 @@ async def verify(
|
|||||||
if not settings.place_verification_enabled or not locations:
|
if not settings.place_verification_enabled or not locations:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
area = area_context.effective(system_area, tg_area)
|
||||||
anchor = area_context.anchor_for(system_area, tg_area)
|
anchor = area_context.anchor_for(system_area, tg_area)
|
||||||
|
suffix = _place_suffix(area)
|
||||||
|
if not anchor and not area_context.has_place(area):
|
||||||
|
# server-26#160: nobody described this channel, but its own name may
|
||||||
|
# (e.g. "New York City - NYPD Citywide 2 Patch") — the same signal
|
||||||
|
# #159 trusted in intelligence.py's geocoder. Only when an operator set
|
||||||
|
# NOTHING: a place that is set but has no anchor was too wide or is
|
||||||
|
# stale, and that skip stays load-bearing.
|
||||||
|
anchor, suffix = await _talkgroup_anchor(talkgroup_name, node_id)
|
||||||
if not anchor:
|
if not anchor:
|
||||||
return None, None # load-bearing: no anchor means skip, never accept
|
return None, None # load-bearing: no anchor means skip, never accept
|
||||||
|
|
||||||
area = area_context.effective(system_area, tg_area)
|
|
||||||
suffix = _place_suffix(area)
|
|
||||||
swaps: list[tuple[str, str]] = []
|
swaps: list[tuple[str, str]] = []
|
||||||
proposals: list[dict] = []
|
proposals: list[dict] = []
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ def pipeline_worst_case_seconds(n_scenes: int = _BOUND_SCENES) -> float:
|
|||||||
_run_intelligence_pipeline."""
|
_run_intelligence_pipeline."""
|
||||||
openai_attempts = 1 + max(0, settings.openai_max_retries)
|
openai_attempts = 1 + max(0, settings.openai_max_retries)
|
||||||
stt_rounds = 2 if settings.stt_retry_on_degenerate else 1
|
stt_rounds = 2 if settings.stt_retry_on_degenerate else 1
|
||||||
stt = stt_rounds * openai_attempts * settings.stt_timeout_seconds
|
# A failed primary STT provider falls back to OpenAI (server-26#297), so
|
||||||
|
# the worst case is every configured target timing out in turn.
|
||||||
|
from app.internal.transcription import stt_targets
|
||||||
|
stt_providers = max(1, len(stt_targets()))
|
||||||
|
stt = stt_providers * stt_rounds * openai_attempts * settings.stt_timeout_seconds
|
||||||
# Transcript correction (inside transcribe_call). gemini.generate_json only
|
# Transcript correction (inside transcribe_call). gemini.generate_json only
|
||||||
# re-issues a request when the model rejects a thinking level — a fast
|
# re-issues a request when the model rejects a thinking level — a fast
|
||||||
# 400, not a timeout — and google-genai itself does not retry.
|
# 400, not a timeout — and google-genai itself does not retry.
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ _DERIVED = {
|
|||||||
"vehicles", "cleared_units", "severity", "incident_type", "type",
|
"vehicles", "cleared_units", "severity", "incident_type", "type",
|
||||||
"embedding", "skip_reason", "intelligence_started_at", "reassignment",
|
"embedding", "skip_reason", "intelligence_started_at", "reassignment",
|
||||||
"resolved", "has_updates", "audio_url",
|
"resolved", "has_updates", "audio_url",
|
||||||
|
# server-26#297: which STT produced the transcript, and its confidence —
|
||||||
|
# a re-transcription that comes back empty must not keep the live values.
|
||||||
|
"stt_provider", "stt_model", "stt_audio_seconds", "stt_confidence",
|
||||||
}
|
}
|
||||||
_DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_")
|
_DERIVED_PREFIXES = ("corr_", "chatter_classifier_", "eval_")
|
||||||
_TRANSCRIPT_FIELDS = (
|
_TRANSCRIPT_FIELDS = (
|
||||||
@@ -407,6 +410,7 @@ async def _prepare(call: dict, mode: str, source_scenes: dict[str, dict]) -> dic
|
|||||||
transcript, segments = await transcription.transcribe_call(
|
transcript, segments = await transcription.transcribe_call(
|
||||||
call_id, call["audio_gcs_uri"], talkgroup_name,
|
call_id, call["audio_gcs_uri"], talkgroup_name,
|
||||||
system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"),
|
system_id=call.get("system_id"), talkgroup_id=call.get("talkgroup_id"),
|
||||||
|
node_id=call.get("node_id"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
transcript, segments = _stored_input(call)
|
transcript, segments = _stored_input(call)
|
||||||
|
|||||||
@@ -116,11 +116,37 @@ def _dedupe(items: list[str]) -> list[str]:
|
|||||||
# mishearing this pass exists to fix (server-26#162).
|
# mishearing this pass exists to fix (server-26#162).
|
||||||
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
|
_CODE_TOKEN_RE = re.compile(r"\b\d{1,3}(?:-\d{1,3})+\b")
|
||||||
|
|
||||||
|
# Bare numbers (the rest of server-26#162): the same call had a bare "7"
|
||||||
|
# turned into "ShotSpotter". A unit number, a post, a house number or a count
|
||||||
|
# the recogniser DID hear must survive, in order. A number may still be
|
||||||
|
# added, because a sound-alike can legitimately be one ("back to Rose" ->
|
||||||
|
# "back to Route 9"). "4th" is not bare (it is a word).
|
||||||
|
_BARE_NUMBER_RE = re.compile(r"(?<![\w-])\d+(?![\w-])")
|
||||||
|
|
||||||
|
# "NEVER add information" is the other prompt rule nothing checked. A
|
||||||
|
# correction substitutes sound-alikes, so it may shift the word count a little
|
||||||
|
# ("Holmes Street and 4th and Rowe" -> "Home Street and Forest Ave"); growing
|
||||||
|
# by more than this is the model writing, not correcting.
|
||||||
|
_MAX_ADDED_WORDS = 4
|
||||||
|
_MAX_GROWTH_RATIO = 0.25
|
||||||
|
|
||||||
|
|
||||||
def _code_tokens(text: str) -> list[str]:
|
def _code_tokens(text: str) -> list[str]:
|
||||||
return _CODE_TOKEN_RE.findall(text or "")
|
return _CODE_TOKEN_RE.findall(text or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _lost_numbers(original: str, corrected: str) -> bool:
|
||||||
|
"""True unless every bare number of `original` is still in `corrected`, in order."""
|
||||||
|
remaining = iter(_BARE_NUMBER_RE.findall(corrected or ""))
|
||||||
|
return not all(any(n == m for m in remaining) for n in _BARE_NUMBER_RE.findall(original or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _grew(original: str, corrected: str) -> bool:
|
||||||
|
n_orig = len((original or "").split())
|
||||||
|
n_new = len((corrected or "").split())
|
||||||
|
return n_new - n_orig > max(_MAX_ADDED_WORDS, int(n_orig * _MAX_GROWTH_RATIO))
|
||||||
|
|
||||||
|
|
||||||
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
def _talkgroup_entry(system_doc: dict, talkgroup_id: Optional[int]) -> dict:
|
||||||
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
"""The config.talkgroups[] entry for this talkgroup, or {}."""
|
||||||
if talkgroup_id is None:
|
if talkgroup_id is None:
|
||||||
@@ -241,6 +267,7 @@ async def correct(
|
|||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
talkgroup_id: Optional[int] = None,
|
talkgroup_id: Optional[int] = None,
|
||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> tuple[Optional[str], Optional[list[dict]], bool]:
|
) -> tuple[Optional[str], Optional[list[dict]], bool]:
|
||||||
"""
|
"""
|
||||||
Second-opinion pass over a transcript.
|
Second-opinion pass over a transcript.
|
||||||
@@ -330,6 +357,8 @@ async def correct(
|
|||||||
context.get("tg_area"),
|
context.get("tg_area"),
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
talkgroup_id=talkgroup_id,
|
talkgroup_id=talkgroup_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
node_id=node_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Place verification failed for call {call_id}: {e}")
|
logger.warning(f"Place verification failed for call {call_id}: {e}")
|
||||||
@@ -354,6 +383,18 @@ async def correct(
|
|||||||
f"discarding the joined correction"
|
f"discarding the joined correction"
|
||||||
)
|
)
|
||||||
corrected = None
|
corrected = None
|
||||||
|
if corrected is not None and _lost_numbers(text, corrected):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcript correction for call {call_id} dropped or changed a number — "
|
||||||
|
f"discarding the joined correction"
|
||||||
|
)
|
||||||
|
corrected = None
|
||||||
|
if corrected is not None and _grew(text, corrected):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcript correction for call {call_id} grew the transcript "
|
||||||
|
f"({len(text.split())} -> {len(corrected.split())} words) — discarding the joined correction"
|
||||||
|
)
|
||||||
|
corrected = None
|
||||||
if corrected_segments is not None:
|
if corrected_segments is not None:
|
||||||
for seg, orig in zip(corrected_segments, segments or []):
|
for seg, orig in zip(corrected_segments, segments or []):
|
||||||
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
|
if _code_tokens(seg["text"]) != _code_tokens(orig.get("text", "")):
|
||||||
@@ -363,6 +404,13 @@ async def correct(
|
|||||||
)
|
)
|
||||||
corrected_segments = None
|
corrected_segments = None
|
||||||
break
|
break
|
||||||
|
if _lost_numbers(orig.get("text", ""), seg["text"]) or _grew(orig.get("text", ""), seg["text"]):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcript correction for call {call_id} grew a segment or dropped "
|
||||||
|
f"a number in one — discarding segment corrections"
|
||||||
|
)
|
||||||
|
corrected_segments = None
|
||||||
|
break
|
||||||
|
|
||||||
if corrected or corrected_segments or not_speech:
|
if corrected or corrected_segments or not_speech:
|
||||||
changed = raw.get("changed") or []
|
changed = raw.get("changed") or []
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Speech-to-text transcription for recorded calls using OpenAI Whisper.
|
Speech-to-text transcription for recorded calls.
|
||||||
|
|
||||||
Audio is downloaded from GCS then sent to the Whisper API. Falls back to
|
Audio is downloaded from GCS once, then sent to an OpenAI-compatible
|
||||||
returning None on any failure so the intelligence pipeline can still run.
|
/audio/transcriptions endpoint: OpenAI whisper-1 by default, or a cheaper
|
||||||
|
provider (Groq whisper-large-v3-turbo, a self-hosted server) when configured,
|
||||||
|
with OpenAI as the fallback when that provider fails (server-26#297). Returns
|
||||||
|
None on any failure so the intelligence pipeline can still run.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal import ai_health
|
from app.internal import ai_health
|
||||||
from app.internal import transcript_correction
|
from app.internal import transcript_correction
|
||||||
|
from app.internal import area_context
|
||||||
|
from app.internal.chatter_classifier import gate_chatter
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
||||||
@@ -43,6 +50,19 @@ _RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
|
|||||||
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
|
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
|
||||||
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
|
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
|
||||||
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
|
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
|
||||||
|
# Whisper's own failure test (openai/whisper transcribe.py, compression_ratio_
|
||||||
|
# threshold=2.4): text that gzips this well is a loop, whatever its shape. The
|
||||||
|
# two checks above only know two loop shapes; this catches the rest (two
|
||||||
|
# phrases alternating, a list re-emitted) without a model call, so a noise
|
||||||
|
# gate survives with transcript correction switched off (server-26#83). Only
|
||||||
|
# on longer text -- a short string has too little to compress to judge.
|
||||||
|
_MAX_COMPRESSION_RATIO = 2.4
|
||||||
|
_MIN_CHARS_FOR_COMPRESSION = 200
|
||||||
|
# Prompt echo (server-26#297): on silence Whisper can emit its own prompt.
|
||||||
|
# With a vocabulary glossary in the prompt that reads as a list of local
|
||||||
|
# street names. See _prompt_echo for why this is kept narrow.
|
||||||
|
_ECHO_MIN_TERMS = 3
|
||||||
|
_ECHO_WORD_RATIO = 0.6
|
||||||
|
|
||||||
|
|
||||||
def _ten_code_run(text: str) -> bool:
|
def _ten_code_run(text: str) -> bool:
|
||||||
@@ -77,7 +97,74 @@ def _phrase_loop(text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
def _compression_ratio(text: str) -> float:
|
||||||
|
import zlib
|
||||||
|
raw = text.encode("utf-8")
|
||||||
|
return len(raw) / max(1, len(zlib.compress(raw)))
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_echo(text: str, glossary: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
True if the transcript is the prompt's own glossary read back.
|
||||||
|
|
||||||
|
Deliberately narrow (review of server-26#297): a real location readback
|
||||||
|
("Main Street, Croton Avenue, Route 9") names glossary terms too, and
|
||||||
|
that is where map pins come from. So either the lead-in itself came back
|
||||||
|
("Local names: ..."), or nearly the whole glossary did, in prompt order,
|
||||||
|
with little else around it.
|
||||||
|
"""
|
||||||
|
if not text or not glossary:
|
||||||
|
return False
|
||||||
|
low = text.lower()
|
||||||
|
if "local names" in low:
|
||||||
|
return True
|
||||||
|
if len(glossary) < _ECHO_MIN_TERMS:
|
||||||
|
return False
|
||||||
|
positions = []
|
||||||
|
for t in glossary:
|
||||||
|
m = re.search(rf"\b{re.escape(t.lower())}\b", low)
|
||||||
|
if m:
|
||||||
|
positions.append(m.start())
|
||||||
|
if len(positions) < max(_ECHO_MIN_TERMS, math.ceil(0.8 * len(glossary))):
|
||||||
|
return False
|
||||||
|
if positions != sorted(positions):
|
||||||
|
return False
|
||||||
|
words = re.findall(r"[\w'-]+", low)
|
||||||
|
term_words = {w for t in glossary for w in re.findall(r"[\w'-]+", t.lower())}
|
||||||
|
return bool(words) and sum(1 for w in words if w in term_words) / len(words) >= _ECHO_WORD_RATIO
|
||||||
|
|
||||||
|
|
||||||
|
def _compression_loop(text: str, segments: list[dict]) -> bool:
|
||||||
|
"""
|
||||||
|
Whisper applies its 2.4 threshold per 30s decode window, and zlib's ratio
|
||||||
|
grows with length, so judging a whole 5-minute call at once would drop
|
||||||
|
busy real traffic. Judge ~30s windows of segments (or ~500-char chunks of
|
||||||
|
a segment-less transcript) and call it a loop only when at least half of
|
||||||
|
the windows long enough to judge are loops.
|
||||||
|
"""
|
||||||
|
windows: list[str] = []
|
||||||
|
if segments:
|
||||||
|
cur, start = [], None
|
||||||
|
for s in segments:
|
||||||
|
st = float(s.get("start") or 0.0)
|
||||||
|
if start is None:
|
||||||
|
start = st
|
||||||
|
if st - start > 30.0 and cur:
|
||||||
|
windows.append(" ".join(cur))
|
||||||
|
cur, start = [], st
|
||||||
|
cur.append(s.get("text") or "")
|
||||||
|
if cur:
|
||||||
|
windows.append(" ".join(cur))
|
||||||
|
else:
|
||||||
|
windows = [text[i:i + 500] for i in range(0, len(text), 500)]
|
||||||
|
judged = [w for w in windows if len(w) >= _MIN_CHARS_FOR_COMPRESSION]
|
||||||
|
if not judged:
|
||||||
|
return False
|
||||||
|
loops = sum(1 for w in judged if _compression_ratio(w) > _MAX_COMPRESSION_RATIO)
|
||||||
|
return loops * 2 >= len(judged)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_degenerate(text: str, segments: list[dict], glossary: Optional[list[str]] = None) -> bool:
|
||||||
"""True if a transcript looks like Whisper output rather than radio traffic.
|
"""True if a transcript looks like Whisper output rather than radio traffic.
|
||||||
|
|
||||||
Applied AFTER the per-segment no_speech_prob filter, which does not catch
|
Applied AFTER the per-segment no_speech_prob filter, which does not catch
|
||||||
@@ -88,6 +175,10 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
|||||||
return False
|
return False
|
||||||
if _ten_code_run(text) or _phrase_loop(text):
|
if _ten_code_run(text) or _phrase_loop(text):
|
||||||
return True
|
return True
|
||||||
|
if _compression_loop(text, segments):
|
||||||
|
return True
|
||||||
|
if _prompt_echo(text, glossary or []):
|
||||||
|
return True
|
||||||
# Near-identical segments repeated across the whole recording.
|
# Near-identical segments repeated across the whole recording.
|
||||||
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
|
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
|
||||||
normalised = {s["text"].strip().lower() for s in segments}
|
normalised = {s["text"].strip().lower() for s in segments}
|
||||||
@@ -96,7 +187,155 @@ def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
|
||||||
|
|
||||||
|
# ── Providers (server-26#297) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GROQ_BASE_URL = "https://api.groq.com/openai/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SttTarget:
|
||||||
|
"""One OpenAI-compatible transcription endpoint to try."""
|
||||||
|
provider: str # "openai" | "groq" | "compat"
|
||||||
|
model: str
|
||||||
|
api_key: Optional[str]
|
||||||
|
base_url: Optional[str] # None -> the OpenAI SDK default
|
||||||
|
vocab_prompt: bool # whether the domain-vocabulary prompt is sent
|
||||||
|
tier: str # ai_health tier this target reports to
|
||||||
|
|
||||||
|
|
||||||
|
def stt_targets() -> list[SttTarget]:
|
||||||
|
"""
|
||||||
|
The endpoints to try, in order: the configured provider, then OpenAI as
|
||||||
|
fallback. Empty when nothing usable is configured.
|
||||||
|
|
||||||
|
`stt_provider="auto"` resolves to Groq only when GROQ_API_KEY is set, so a
|
||||||
|
deploy that has not provisioned the key yet keeps transcribing on OpenAI
|
||||||
|
exactly as before.
|
||||||
|
"""
|
||||||
|
provider = (settings.stt_provider or "auto").strip().lower()
|
||||||
|
if provider == "auto":
|
||||||
|
if settings.groq_api_key:
|
||||||
|
provider = "groq"
|
||||||
|
elif settings.stt_base_url:
|
||||||
|
provider = "compat" # an explicit endpoint is a compat server
|
||||||
|
else:
|
||||||
|
provider = "openai"
|
||||||
|
|
||||||
|
openai_target = SttTarget(
|
||||||
|
provider="openai", model=settings.stt_model, api_key=settings.openai_api_key,
|
||||||
|
base_url=None, vocab_prompt=False, tier="transcription",
|
||||||
|
)
|
||||||
|
if provider not in ("groq", "compat"):
|
||||||
|
if provider != "openai":
|
||||||
|
logger.warning(f"Unknown STT_PROVIDER {settings.stt_provider!r} — using OpenAI")
|
||||||
|
return [openai_target] if settings.openai_api_key else []
|
||||||
|
|
||||||
|
if provider == "groq":
|
||||||
|
key = settings.stt_api_key or settings.groq_api_key
|
||||||
|
base = settings.stt_base_url or _GROQ_BASE_URL
|
||||||
|
else:
|
||||||
|
# A self-hosted server may not check the key, but the SDK insists on one.
|
||||||
|
key = settings.stt_api_key or "unused"
|
||||||
|
base = settings.stt_base_url
|
||||||
|
|
||||||
|
targets: list[SttTarget] = []
|
||||||
|
if key and base:
|
||||||
|
targets.append(SttTarget(
|
||||||
|
provider=provider, model=settings.stt_compat_model, api_key=key, base_url=base,
|
||||||
|
vocab_prompt=settings.stt_vocab_prompt_enabled, tier="transcription",
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
logger.warning(f"STT provider {provider!r} has no API key/base URL — using OpenAI only")
|
||||||
|
if settings.openai_api_key and (not targets or settings.stt_fallback_to_openai):
|
||||||
|
tier = "transcription_fallback" if targets else "transcription"
|
||||||
|
targets.append(SttTarget(**{**openai_target.__dict__, "tier": tier}))
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prompt (server-26#297) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_CODE_SHAPED = re.compile(r"^\s*\d{1,3}(?:-\d{1,3})+\s*$")
|
||||||
|
_MAX_TERM_CHARS = 40
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tokens(text: str) -> int:
|
||||||
|
"""
|
||||||
|
Conservative token estimate for Whisper's GPT-2 tokenizer, without the
|
||||||
|
tokenizer: one per punctuation mark and per digit, ~4 letters per token
|
||||||
|
for words. Over-counts real English slightly, which is the safe side —
|
||||||
|
Whisper drops prompt tokens past 224 from the FRONT, silently.
|
||||||
|
"""
|
||||||
|
n = 0
|
||||||
|
for piece in re.findall(r"[A-Za-z]+|\d|[^\sA-Za-z\d]", text or ""):
|
||||||
|
n += max(1, math.ceil(len(piece) / 4)) if piece[0].isalpha() else 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _glossary_terms(context: Optional[dict]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Local names worth spelling for the recogniser, most specific first:
|
||||||
|
the talkgroup/system local_knowledge terms, then vocabulary.
|
||||||
|
|
||||||
|
Never ten-codes. An enumerated run of codes in the prompt is what made
|
||||||
|
Whisper count "10-4. 10-5. 10-6." over silence (see _WHISPER_PROMPT), so
|
||||||
|
the ten_codes reference and any code-shaped vocabulary term stay out.
|
||||||
|
"""
|
||||||
|
if not context:
|
||||||
|
return []
|
||||||
|
area = context.get("area") or {}
|
||||||
|
terms = [e.get("term") or "" for e in (area.get("local_knowledge") or []) if isinstance(e, dict)]
|
||||||
|
terms += [t for t in (context.get("vocabulary") or []) if isinstance(t, str)]
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for t in terms:
|
||||||
|
t = t.strip()
|
||||||
|
key = t.lower()
|
||||||
|
if not t or key in seen or len(t) > _MAX_TERM_CHARS or _CODE_SHAPED.match(t):
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_stt_prompt(
|
||||||
|
talkgroup_name: Optional[str], context: Optional[dict] = None,
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
|
"""
|
||||||
|
The STT prompt, and the glossary terms that made it in.
|
||||||
|
|
||||||
|
Without `context` this is exactly the prompt OpenAI has always been sent.
|
||||||
|
With it, the channel's area and local names go in front of the fixed
|
||||||
|
radio-speech priming, cut to settings.stt_prompt_max_tokens. A glossary
|
||||||
|
is how Whisper's prompting guide says to fix the spelling of proper
|
||||||
|
nouns; the priming text stays last because it is the part that sets style.
|
||||||
|
"""
|
||||||
|
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||||
|
if not context:
|
||||||
|
return tg_prefix + _WHISPER_PROMPT, []
|
||||||
|
|
||||||
|
area = context.get("area") or {}
|
||||||
|
place = ", ".join(str(area[f]) for f in area_context.PLACE_FIELDS if area.get(f))
|
||||||
|
head = tg_prefix + (f"{place}. " if place else "")
|
||||||
|
budget = settings.stt_prompt_max_tokens - _estimate_tokens(head + _WHISPER_PROMPT)
|
||||||
|
|
||||||
|
chosen: list[str] = []
|
||||||
|
for term in _glossary_terms(context):
|
||||||
|
trial = "Local names: " + ", ".join(chosen + [term]) + ". "
|
||||||
|
if _estimate_tokens(trial) > budget:
|
||||||
|
continue # a shorter later term may still fit
|
||||||
|
chosen.append(term)
|
||||||
|
glossary = ("Local names: " + ", ".join(chosen) + ". ") if chosen else ""
|
||||||
|
prompt = head + glossary + _WHISPER_PROMPT
|
||||||
|
if _estimate_tokens(prompt) > settings.stt_prompt_max_tokens:
|
||||||
|
return tg_prefix + _WHISPER_PROMPT, []
|
||||||
|
return prompt, chosen
|
||||||
|
|
||||||
|
|
||||||
|
# ── Failure reporting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _log_transcribe_failure(call_id: str, exc: Exception, target: SttTarget) -> None:
|
||||||
"""
|
"""
|
||||||
Log a transcription failure, escalating a permanent condition to ERROR
|
Log a transcription failure, escalating a permanent condition to ERROR
|
||||||
once (via app.internal.ai_health, which also drives the /health/ai
|
once (via app.internal.ai_health, which also drives the /health/ai
|
||||||
@@ -115,47 +354,71 @@ async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
|||||||
"""
|
"""
|
||||||
text = str(exc)
|
text = str(exc)
|
||||||
kind = ai_health.classify(text)
|
kind = ai_health.classify(text)
|
||||||
|
who = f"{target.provider} ({target.model})"
|
||||||
|
billing_url = {
|
||||||
|
"openai": "https://platform.openai.com/settings/organization/billing",
|
||||||
|
"groq": "https://console.groq.com/settings/billing",
|
||||||
|
}.get(target.provider, "the provider's billing page")
|
||||||
|
|
||||||
if kind == "billing":
|
if kind == "billing":
|
||||||
problem = "the OpenAI account cannot be billed"
|
problem = f"the {target.provider} account cannot be billed"
|
||||||
fix = "top up at https://platform.openai.com/settings/organization/billing"
|
fix = f"top up at {billing_url}"
|
||||||
logger.error(
|
logger.error(
|
||||||
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
|
f"Transcription: the {target.provider} account cannot be billed -- calls on this "
|
||||||
"now stored with no transcript, so extraction, correlation and "
|
f"provider get no transcript (OpenAI fallback, if configured, is carrying them). "
|
||||||
"incidents are all dead downstream. Top up at "
|
f"Top up at {billing_url}. API said: {text}"
|
||||||
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
|
|
||||||
)
|
)
|
||||||
await ai_health.report_degraded(
|
await ai_health.report_degraded(
|
||||||
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
target.tier, target.provider, target.model, problem, fix, permanent=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if kind == "dead_model":
|
if kind == "dead_model":
|
||||||
problem = "the STT model is unavailable"
|
problem = "the STT model is unavailable"
|
||||||
fix = "update STT_MODEL in config.py"
|
fix = "update STT_MODEL / STT_COMPAT_MODEL"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Transcription: the configured model ({settings.stt_model!r}) is unavailable "
|
f"Transcription: the configured model {who} is unavailable -- calls on this provider "
|
||||||
"-- EVERY call is now stored with no transcript, so extraction, correlation "
|
f"get no transcript. Update STT_MODEL / STT_COMPAT_MODEL. API said: {text}"
|
||||||
f"and incidents are all dead downstream. Update STT_MODEL in config.py. API said: {text}"
|
|
||||||
)
|
)
|
||||||
await ai_health.report_degraded(
|
await ai_health.report_degraded(
|
||||||
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
target.tier, target.provider, target.model, problem, fix, permanent=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.warning(f"Transcription failed for call {call_id}: {text}")
|
logger.warning(f"Transcription via {who} failed for call {call_id}: {text}")
|
||||||
await ai_health.report_degraded(
|
await ai_health.report_degraded(
|
||||||
"transcription", "openai", settings.stt_model,
|
target.tier, target.provider, target.model,
|
||||||
"transient API error", "no action needed unless this persists", permanent=False,
|
"transient API error", "no action needed unless this persists", permanent=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipeline entry point ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _transcribe_with(
|
||||||
|
call_id: str, tmp_path: str, target: SttTarget, prompt: str, glossary: list[str],
|
||||||
|
) -> dict:
|
||||||
|
"""One target, with the one retry on degenerate output. Raises on API failure."""
|
||||||
|
result = await asyncio.to_thread(_sync_transcribe_file, tmp_path, target, prompt, glossary)
|
||||||
|
# A hallucination is a coin-flip, not a property of the clip: call
|
||||||
|
# e49ea32c produced a 56-word ten-code counting run on one attempt and
|
||||||
|
# ordinary speech on the next, same audio and temperature=0. Discarding
|
||||||
|
# on the first bad roll threw away a recoverable transcript, so spend
|
||||||
|
# one more request before giving up.
|
||||||
|
if result["degenerate"] and settings.stt_retry_on_degenerate:
|
||||||
|
logger.info(f"Retrying transcription for call {call_id} after degenerate output")
|
||||||
|
result = await asyncio.to_thread(_sync_transcribe_file, tmp_path, target, prompt, glossary)
|
||||||
|
if result["degenerate"]:
|
||||||
|
logger.warning(f"Transcription for call {call_id} was degenerate twice — giving up")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_call(
|
async def transcribe_call(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
gcs_uri: str,
|
gcs_uri: str,
|
||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
system_id: Optional[str] = None,
|
system_id: Optional[str] = None,
|
||||||
talkgroup_id: Optional[int] = None,
|
talkgroup_id: Optional[int] = None,
|
||||||
|
node_id: Optional[str] = None,
|
||||||
) -> tuple[Optional[str], list[dict]]:
|
) -> tuple[Optional[str], list[dict]]:
|
||||||
"""
|
"""
|
||||||
Transcribe audio at the given GCS URI and store the result in Firestore.
|
Transcribe audio at the given GCS URI and store the result in Firestore.
|
||||||
@@ -167,36 +430,78 @@ async def transcribe_call(
|
|||||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
|
targets = stt_targets()
|
||||||
|
if not targets:
|
||||||
|
logger.warning("No STT provider configured (OPENAI_API_KEY / GROQ_API_KEY unset) — transcription disabled.")
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
context: Optional[dict] = None
|
||||||
|
if any(t.vocab_prompt for t in targets):
|
||||||
|
try:
|
||||||
|
context = await transcript_correction.resolve_context(system_id, talkgroup_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not resolve STT vocabulary for call {call_id}: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transcript, segments, degenerate = await asyncio.to_thread(
|
tmp_path = await asyncio.to_thread(_download, gcs_uri)
|
||||||
_sync_transcribe, gcs_uri, talkgroup_name
|
|
||||||
)
|
|
||||||
# A hallucination is a coin-flip, not a property of the clip: call
|
|
||||||
# e49ea32c produced a 56-word ten-code counting run on one attempt and
|
|
||||||
# ordinary speech on the next, same audio and temperature=0. Discarding
|
|
||||||
# on the first bad roll threw away a recoverable transcript, so spend
|
|
||||||
# one more request before giving up.
|
|
||||||
if degenerate and settings.stt_retry_on_degenerate:
|
|
||||||
logger.info(f"Retrying transcription for call {call_id} after degenerate output")
|
|
||||||
transcript, segments, degenerate = await asyncio.to_thread(
|
|
||||||
_sync_transcribe, gcs_uri, talkgroup_name
|
|
||||||
)
|
|
||||||
if degenerate:
|
|
||||||
logger.warning(
|
|
||||||
f"Transcription for call {call_id} was degenerate twice — giving up"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await _log_transcribe_failure(call_id, e)
|
# Not a provider failure: classify() would read a GCS "not found" as a
|
||||||
|
# retired model and page someone to change STT_MODEL.
|
||||||
|
logger.warning(f"Could not download audio for call {call_id} ({gcs_uri}): {e}")
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
result: Optional[dict] = None
|
||||||
|
used: Optional[SttTarget] = None
|
||||||
|
try:
|
||||||
|
for i, target in enumerate(targets):
|
||||||
|
prompt, glossary = build_stt_prompt(talkgroup_name, context if target.vocab_prompt else None)
|
||||||
|
try:
|
||||||
|
result = await _transcribe_with(call_id, tmp_path, target, prompt, glossary)
|
||||||
|
except Exception as e:
|
||||||
|
await _log_transcribe_failure(call_id, e, target)
|
||||||
|
if i + 1 < len(targets):
|
||||||
|
logger.warning(
|
||||||
|
f"Transcription for call {call_id}: {target.provider} failed, "
|
||||||
|
f"falling back to {targets[i + 1].provider} ({targets[i + 1].model})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
used = target
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if used is None or result is None:
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
# No exception means the provider call itself succeeded (this also
|
# No exception means the provider call itself succeeded (this also
|
||||||
# covers transcripts discarded as degenerate/hallucinated output —
|
# covers transcripts discarded as degenerate/hallucinated output —
|
||||||
# that's a filtering decision, not a provider failure), so the
|
# that's a filtering decision, not a provider failure), so that tier is
|
||||||
# transcription tier is healthy and any prior degradation clears.
|
# healthy and any prior degradation clears. A fallback success clears
|
||||||
await ai_health.report_healthy("transcription")
|
# only the fallback tier: the primary is still broken and must stay red.
|
||||||
|
await ai_health.report_healthy(used.tier)
|
||||||
|
|
||||||
|
transcript: Optional[str] = result["text"]
|
||||||
|
segments: list[dict] = result["segments"]
|
||||||
|
|
||||||
if transcript:
|
if transcript:
|
||||||
updates: dict = {"transcript": transcript}
|
updates: dict = {
|
||||||
|
"transcript": transcript,
|
||||||
|
# Which endpoint produced this text, and how much audio it billed,
|
||||||
|
# so cost (server-26#45) and an A/B of providers can be read off
|
||||||
|
# the call docs instead of guessed.
|
||||||
|
"stt_provider": used.provider,
|
||||||
|
"stt_model": used.model,
|
||||||
|
}
|
||||||
|
if result.get("duration") is not None:
|
||||||
|
updates["stt_audio_seconds"] = result["duration"]
|
||||||
|
if result.get("confidence") is not None:
|
||||||
|
# Mean per-token probability, exp(avg_logprob) weighted by segment
|
||||||
|
# length. The only STT-side confidence there is; the alert gate
|
||||||
|
# reads it (server-26#300).
|
||||||
|
updates["stt_confidence"] = result["confidence"]
|
||||||
if segments:
|
if segments:
|
||||||
updates["segments"] = segments
|
updates["segments"] = segments
|
||||||
|
|
||||||
@@ -212,17 +517,30 @@ async def transcribe_call(
|
|||||||
from app.internal.feature_flags import resolve_flags
|
from app.internal.feature_flags import resolve_flags
|
||||||
_, _ai_flag = await resolve_flags(system_id)
|
_, _ai_flag = await resolve_flags(system_id)
|
||||||
|
|
||||||
|
# server-26#298: radio housekeeping (roll call, a bare "Post 4, 10-8,
|
||||||
|
# copy") has no proper noun to correct, and extraction skips it too
|
||||||
|
# (intelligence.extract_scenes), so a correction pass here is pure
|
||||||
|
# spend. Same pure classifier both places, so they cannot disagree.
|
||||||
|
is_chatter, chatter_reason = (
|
||||||
|
gate_chatter(transcript) if settings.chatter_gate_enabled else (False, None)
|
||||||
|
)
|
||||||
|
|
||||||
corrected, corrected_segments, not_speech = (None, None, False)
|
corrected, corrected_segments, not_speech = (None, None, False)
|
||||||
if _ai_flag("transcript_correction_enabled"):
|
if not _ai_flag("transcript_correction_enabled"):
|
||||||
|
logger.info(
|
||||||
|
f"Transcript correction disabled — saving raw transcript for call {call_id}"
|
||||||
|
)
|
||||||
|
elif is_chatter:
|
||||||
|
logger.info(
|
||||||
|
f"Transcript correction skipped for call {call_id}: chatter ({chatter_reason})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
corrected, corrected_segments, not_speech = await transcript_correction.correct(
|
corrected, corrected_segments, not_speech = await transcript_correction.correct(
|
||||||
call_id, transcript, segments,
|
call_id, transcript, segments,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
talkgroup_id=talkgroup_id,
|
talkgroup_id=talkgroup_id,
|
||||||
talkgroup_name=talkgroup_name,
|
talkgroup_name=talkgroup_name,
|
||||||
)
|
node_id=node_id,
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"Transcript correction disabled — saving raw transcript for call {call_id}"
|
|
||||||
)
|
)
|
||||||
if corrected_segments:
|
if corrected_segments:
|
||||||
# Raw stays as evidence; the corrected copy is what extraction reads.
|
# Raw stays as evidence; the corrected copy is what extraction reads.
|
||||||
@@ -241,7 +559,7 @@ async def transcribe_call(
|
|||||||
try:
|
try:
|
||||||
await fstore.doc_set("calls", call_id, updates)
|
await fstore.doc_set("calls", call_id, updates)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Transcript saved for call {call_id} "
|
f"Transcript saved for call {call_id} via {used.provider}/{used.model} "
|
||||||
f"({len(transcript)} chars, {len(segments)} segment(s)"
|
f"({len(transcript)} chars, {len(segments)} segment(s)"
|
||||||
f"{', corrected' if corrected and not not_speech else ''})"
|
f"{', corrected' if corrected and not not_speech else ''})"
|
||||||
)
|
)
|
||||||
@@ -259,27 +577,10 @@ async def transcribe_call(
|
|||||||
return transcript, segments
|
return transcript, segments
|
||||||
|
|
||||||
|
|
||||||
def _sync_transcribe(
|
def _download(gcs_uri: str) -> str:
|
||||||
gcs_uri: str,
|
"""Download the recording to a temp file once; the caller deletes it."""
|
||||||
talkgroup_name: Optional[str] = None,
|
|
||||||
) -> tuple[Optional[str], list[dict], bool]:
|
|
||||||
"""Download audio from GCS and transcribe with OpenAI Whisper.
|
|
||||||
|
|
||||||
Third element is True when output was DISCARDED as degenerate, which the
|
|
||||||
caller distinguishes from ordinary silence so it can retry — the same clip
|
|
||||||
can hallucinate on one attempt and transcribe on the next.
|
|
||||||
"""
|
|
||||||
from google.cloud import storage as gcs
|
from google.cloud import storage as gcs
|
||||||
from google.oauth2 import service_account
|
from google.oauth2 import service_account
|
||||||
from app.config import settings
|
|
||||||
from app.internal.ai_clients import openai_client
|
|
||||||
|
|
||||||
if not settings.openai_api_key:
|
|
||||||
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
|
|
||||||
# Tuple, not a bare None: the caller unpacks two values, so returning
|
|
||||||
# None here raised a TypeError that surfaced as a misleading
|
|
||||||
# "Transcription failed" instead of the real missing-key warning.
|
|
||||||
return None, [], False
|
|
||||||
|
|
||||||
without_scheme = gcs_uri[len("gs://"):]
|
without_scheme = gcs_uri[len("gs://"):]
|
||||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||||
@@ -293,77 +594,116 @@ def _sync_transcribe(
|
|||||||
else:
|
else:
|
||||||
gcs_client = gcs.Client()
|
gcs_client = gcs.Client()
|
||||||
|
|
||||||
bucket = gcs_client.bucket(bucket_name)
|
blob = gcs_client.bucket(bucket_name).blob(blob_path)
|
||||||
blob = bucket.blob(blob_path)
|
|
||||||
|
|
||||||
suffix = os.path.splitext(blob_path)[1] or ".mp3"
|
suffix = os.path.splitext(blob_path)[1] or ".mp3"
|
||||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
blob.download_to_filename(tmp_path)
|
blob.download_to_filename(tmp_path)
|
||||||
|
except Exception:
|
||||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
|
||||||
# Vocabulary is intentionally excluded from the Whisper prompt.
|
|
||||||
# whisper-1 treats the prompt as a transcription prior and echoes
|
|
||||||
# vocabulary terms into noise/silence, polluting downstream extraction.
|
|
||||||
# Vocabulary context is applied in the GPT extraction step instead,
|
|
||||||
# where it is used as reference rather than a transcription prior.
|
|
||||||
prompt = tg_prefix + _WHISPER_PROMPT
|
|
||||||
|
|
||||||
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob).
|
|
||||||
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
|
|
||||||
use_verbose = settings.stt_model == "whisper-1"
|
|
||||||
|
|
||||||
# Own timeout, not openai_timeout_seconds: this request uploads the
|
|
||||||
# whole recording (server-26#153).
|
|
||||||
stt_client = openai_client(timeout=settings.stt_timeout_seconds)
|
|
||||||
with open(tmp_path, "rb") as f:
|
|
||||||
response = stt_client.audio.transcriptions.create(
|
|
||||||
model=settings.stt_model,
|
|
||||||
file=f,
|
|
||||||
language="en",
|
|
||||||
prompt=prompt,
|
|
||||||
response_format="verbose_json" if use_verbose else "json",
|
|
||||||
temperature=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if use_verbose:
|
|
||||||
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
|
||||||
#
|
|
||||||
# 1. Trailing silence / static — Whisper fills silence past real content with
|
|
||||||
# sequential radio codes (10-4, 10-5...). Clamped by audio duration.
|
|
||||||
#
|
|
||||||
# 2. Leading silence — OP25 recordings typically have a short silence at the
|
|
||||||
# start before the first PTT press. Whisper sometimes hallucinates filler
|
|
||||||
# words or codes over this silence. Detected via no_speech_prob > 0.8
|
|
||||||
# (Whisper's own confidence that a segment contains no real speech).
|
|
||||||
audio_duration: float = getattr(response, "duration", None) or float("inf")
|
|
||||||
segments = [
|
|
||||||
{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()}
|
|
||||||
for s in (response.segments or [])
|
|
||||||
if s.text.strip()
|
|
||||||
and s.start < audio_duration
|
|
||||||
and getattr(s, "no_speech_prob", 0.0) < 0.8
|
|
||||||
]
|
|
||||||
# Reconstruct text from non-hallucinated segments only so the two stay
|
|
||||||
# in sync. If every segment was filtered, text becomes None which prevents
|
|
||||||
# the intelligence pipeline from running on hallucinated content.
|
|
||||||
text = " ".join(s["text"] for s in segments) or None
|
|
||||||
if _is_degenerate(text or "", segments):
|
|
||||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
|
||||||
return None, [], True
|
|
||||||
return text, segments, False
|
|
||||||
else:
|
|
||||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
|
||||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
|
||||||
text = (response.text or "").strip() or None
|
|
||||||
if _is_degenerate(text or "", []):
|
|
||||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
|
||||||
return None, [], True
|
|
||||||
return text, [], False
|
|
||||||
finally:
|
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
raise
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _segment_confidence(raw_segments: list) -> Optional[float]:
|
||||||
|
"""exp(avg_logprob) per segment, weighted by segment length; None if absent."""
|
||||||
|
total, weight = 0.0, 0.0
|
||||||
|
for s in raw_segments:
|
||||||
|
lp = getattr(s, "avg_logprob", None)
|
||||||
|
if lp is None:
|
||||||
|
continue
|
||||||
|
w = max(0.01, float(getattr(s, "end", 0) or 0) - float(getattr(s, "start", 0) or 0))
|
||||||
|
total += math.exp(float(lp)) * w
|
||||||
|
weight += w
|
||||||
|
return round(total / weight, 3) if weight else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_transcribe_file(
|
||||||
|
tmp_path: str, target: SttTarget, prompt: str, glossary: Optional[list[str]] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Transcribe one downloaded file on one target.
|
||||||
|
|
||||||
|
Returns {"text", "segments", "degenerate", "duration", "confidence"}.
|
||||||
|
`degenerate` is True when output was DISCARDED as degenerate, which the
|
||||||
|
caller distinguishes from ordinary silence so it can retry — the same clip
|
||||||
|
can hallucinate on one attempt and transcribe on the next. Raises on any
|
||||||
|
API failure, so the caller can fall back to the next target.
|
||||||
|
"""
|
||||||
|
from app.internal.ai_clients import stt_client
|
||||||
|
|
||||||
|
# verbose_json (per-segment timestamps + no_speech_prob) is a Whisper
|
||||||
|
# feature: whisper-1 on OpenAI, every whisper-* model on Groq. OpenAI's
|
||||||
|
# gpt-4o-transcribe / gpt-4o-mini-transcribe only support json/text.
|
||||||
|
use_verbose = target.model.startswith("whisper")
|
||||||
|
|
||||||
|
client = stt_client(target.api_key, target.base_url)
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
response = client.audio.transcriptions.create(
|
||||||
|
model=target.model,
|
||||||
|
file=f,
|
||||||
|
language="en",
|
||||||
|
prompt=prompt,
|
||||||
|
response_format="verbose_json" if use_verbose else "json",
|
||||||
|
temperature=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_verbose:
|
||||||
|
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
||||||
|
#
|
||||||
|
# 1. Trailing silence / static — Whisper fills silence past real content with
|
||||||
|
# sequential radio codes (10-4, 10-5...). Clamped by audio duration.
|
||||||
|
#
|
||||||
|
# 2. Leading silence — OP25 recordings typically have a short silence at the
|
||||||
|
# start before the first PTT press. Whisper sometimes hallucinates filler
|
||||||
|
# words or codes over this silence. Detected via no_speech_prob > 0.8
|
||||||
|
# (Whisper's own confidence that a segment contains no real speech).
|
||||||
|
duration = getattr(response, "duration", None)
|
||||||
|
audio_duration: float = duration or float("inf")
|
||||||
|
kept = [
|
||||||
|
s for s in (response.segments or [])
|
||||||
|
if (_seg(s, "text") or "").strip()
|
||||||
|
and (_seg(s, "start") or 0.0) < audio_duration
|
||||||
|
and (_seg(s, "no_speech_prob") or 0.0) < 0.8
|
||||||
|
]
|
||||||
|
segments = [
|
||||||
|
{"start": round(_seg(s, "start") or 0.0, 2), "end": round(_seg(s, "end") or 0.0, 2),
|
||||||
|
"text": _seg(s, "text").strip()}
|
||||||
|
for s in kept
|
||||||
|
]
|
||||||
|
# Reconstruct text from non-hallucinated segments only so the two stay
|
||||||
|
# in sync. If every segment was filtered, text becomes None which prevents
|
||||||
|
# the intelligence pipeline from running on hallucinated content.
|
||||||
|
text = " ".join(s["text"] for s in segments) or None
|
||||||
|
base = {"duration": duration, "confidence": _segment_confidence([_SegView(s) for s in kept])}
|
||||||
|
if _is_degenerate(text or "", segments, glossary):
|
||||||
|
logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
|
||||||
|
return {"text": None, "segments": [], "degenerate": True, **base}
|
||||||
|
return {"text": text, "segments": segments, "degenerate": False, **base}
|
||||||
|
|
||||||
|
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||||
|
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||||
|
text = (response.text or "").strip() or None
|
||||||
|
if _is_degenerate(text or "", [], glossary):
|
||||||
|
logger.info(f"Discarded hallucinated transcript from {target.provider}: {(text or '')[:80]!r}")
|
||||||
|
return {"text": None, "segments": [], "degenerate": True, "duration": None, "confidence": None}
|
||||||
|
return {"text": text, "segments": [], "degenerate": False, "duration": None, "confidence": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _seg(s, field: str):
|
||||||
|
"""Segment field from an SDK object or a plain dict (OpenAI-compatible
|
||||||
|
servers do not all come back through the same pydantic model)."""
|
||||||
|
if isinstance(s, dict):
|
||||||
|
return s.get(field)
|
||||||
|
return getattr(s, field, None)
|
||||||
|
|
||||||
|
|
||||||
|
class _SegView:
|
||||||
|
"""Attribute view over a segment, for _segment_confidence."""
|
||||||
|
def __init__(self, s):
|
||||||
|
self.avg_logprob = _seg(s, "avg_logprob")
|
||||||
|
self.start = _seg(s, "start")
|
||||||
|
self.end = _seg(s, "end")
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ async def list_alerts(
|
|||||||
return await fstore.collection_list("alert_events", **filters)
|
return await fstore.collection_list("alert_events", **filters)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts/held")
|
||||||
|
async def list_held_alerts(
|
||||||
|
status: Optional[str] = Query("held", description="held | released | superseded | rule_gone; empty = all"),
|
||||||
|
org_id: Optional[str] = Query(None, description="Platform-admin only — omit for every org."),
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Rule matches the confidence gate kept from being pushed (server-26#300).
|
||||||
|
Admin-only: a held match is by definition one we do not trust enough to
|
||||||
|
show, so it does not belong on any customer surface (and alert_holds has
|
||||||
|
no Firestore rule, so the browser cannot read it directly either).
|
||||||
|
"""
|
||||||
|
filters = {}
|
||||||
|
if status:
|
||||||
|
filters["status"] = status
|
||||||
|
if org_id:
|
||||||
|
filters["org_id"] = org_id
|
||||||
|
holds = await fstore.collection_list("alert_holds", **filters)
|
||||||
|
return sorted(holds, key=lambda h: h.get("triggered_at") or "", reverse=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alerts/{alert_id}/acknowledge")
|
@router.post("/alerts/{alert_id}/acknowledge")
|
||||||
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
async def acknowledge_alert(alert_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||||
doc = await fstore.doc_get("alert_events", alert_id)
|
doc = await fstore.doc_get("alert_events", alert_id)
|
||||||
|
|||||||
@@ -454,7 +454,10 @@ async def _extract_and_correlate(
|
|||||||
if not scenes:
|
if not scenes:
|
||||||
_call_doc = await fstore.doc_get("calls", call_id)
|
_call_doc = await fstore.doc_get("calls", call_id)
|
||||||
skip_reason = (_call_doc or {}).get("skip_reason")
|
skip_reason = (_call_doc or {}).get("skip_reason")
|
||||||
if not skip_reason or skip_reason == "transcript_too_short":
|
# non_event_chatter (server-26#298) is the same population as
|
||||||
|
# transcript_too_short, just longer: housekeeping that skipped the
|
||||||
|
# paid extraction but may still be a same-talkgroup follow-up.
|
||||||
|
if not skip_reason or skip_reason in ("transcript_too_short", "non_event_chatter"):
|
||||||
incident_id, _ = await _correlate(
|
incident_id, _ = await _correlate(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
@@ -465,6 +468,11 @@ async def _extract_and_correlate(
|
|||||||
incident_type=None,
|
incident_type=None,
|
||||||
location=None,
|
location=None,
|
||||||
location_coords=None,
|
location_coords=None,
|
||||||
|
# server-26#104: the call doc's own severity, as
|
||||||
|
# recorrelation_sweep passes it. None resolved to "routine",
|
||||||
|
# which turned a reprocessed not-thin call into a thin one
|
||||||
|
# that attaches with no fit check.
|
||||||
|
severity=(_call_doc or {}).get("severity"),
|
||||||
)
|
)
|
||||||
if incident_id:
|
if incident_id:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
@@ -566,7 +574,7 @@ async def _run_intelligence_pipeline(
|
|||||||
if _flag("stt_enabled"):
|
if _flag("stt_enabled"):
|
||||||
transcript, segments = await transcription.transcribe_call(
|
transcript, segments = await transcription.transcribe_call(
|
||||||
call_id, gcs_uri, talkgroup_name,
|
call_id, gcs_uri, talkgroup_name,
|
||||||
system_id=system_id, talkgroup_id=talkgroup_id,
|
system_id=system_id, talkgroup_id=talkgroup_id, node_id=node_id,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
||||||
|
|||||||
@@ -96,3 +96,13 @@ def _no_real_discord(monkeypatch):
|
|||||||
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_offline))
|
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(_offline))
|
||||||
yield
|
yield
|
||||||
discord_rest.reset()
|
discord_rest.reset()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_geocode_cache():
|
||||||
|
"""The geocode cache (server-26#298) is process-wide; a test that stubs
|
||||||
|
Maps must never be answered by a previous test's stub."""
|
||||||
|
from app.internal import intelligence
|
||||||
|
intelligence.clear_geocode_cache()
|
||||||
|
yield
|
||||||
|
intelligence.clear_geocode_cache()
|
||||||
|
|||||||
@@ -219,15 +219,21 @@ async def test_transcript_correction_is_skipped_when_its_flag_is_off():
|
|||||||
patch.object(transcription, "fstore") as fs, \
|
patch.object(transcription, "fstore") as fs, \
|
||||||
patch.object(transcription, "ai_health") as health, \
|
patch.object(transcription, "ai_health") as health, \
|
||||||
patch.object(transcription, "transcript_correction") as tc, \
|
patch.object(transcription, "transcript_correction") as tc, \
|
||||||
patch("asyncio.to_thread", AsyncMock(return_value=("units respond", [], False))):
|
patch.object(transcription.settings, "openai_api_key", "k"), \
|
||||||
|
patch.object(transcription.settings, "groq_api_key", None), \
|
||||||
|
patch.object(transcription, "_download", lambda uri: "/nonexistent.mp3"), \
|
||||||
|
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
|
||||||
|
"text": "units respond to the fire on Main Street", "segments": [],
|
||||||
|
"degenerate": False, "duration": 3.0, "confidence": None}):
|
||||||
fs.doc_set = AsyncMock()
|
fs.doc_set = AsyncMock()
|
||||||
health.report_healthy = AsyncMock()
|
health.report_healthy = AsyncMock()
|
||||||
health.report_failure = AsyncMock()
|
health.report_failure = AsyncMock()
|
||||||
tc.correct = AsyncMock()
|
tc.correct = AsyncMock()
|
||||||
await transcription.transcribe_call(
|
text, _ = await transcription.transcribe_call(
|
||||||
"call-1", "gs://bucket/call-1.mp3", "PD Dispatch", system_id="sys-1"
|
"call-1", "gs://bucket/call-1.mp3", "PD Dispatch", system_id="sys-1"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert text == "units respond to the fire on Main Street", "the transcript must still be produced"
|
||||||
tc.correct.assert_not_awaited()
|
tc.correct.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
server-26#300 — confidence gate before any push alert.
|
||||||
|
|
||||||
|
CrimeRadar pushed "shots fired at an elementary school" after STT heard "fire
|
||||||
|
alarm" as "firearm". A single uncorroborated transcript hit on a
|
||||||
|
high-severity term must be held, not pushed; a corroborated incident releases
|
||||||
|
it; low-confidence matches never push.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.internal import alerter
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
|
||||||
|
def _ev(**over):
|
||||||
|
base = {"incident_ids": [], "max_calls": 0, "severity": None,
|
||||||
|
"stt_confidence": None, "not_speech": False}
|
||||||
|
base.update(over)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# ── the pure decision ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_talkgroup_only_rule_always_pushes():
|
||||||
|
v = alerter._assess(["talkgroup:9048"], [], _ev(stt_confidence=0.1))
|
||||||
|
assert v["push"] and v["confidence"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_only_firearm_single_call_is_held():
|
||||||
|
v = alerter._assess(["firearm"], [], _ev())
|
||||||
|
assert not v["push"] and v["hold_reason"] == "needs_corroboration"
|
||||||
|
assert v["high_severity"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_tagged_shots_fired_still_needs_corroboration():
|
||||||
|
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=1))
|
||||||
|
assert not v["push"] and v["confidence"] == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_corroborated_high_severity_pushes():
|
||||||
|
v = alerter._assess(["shots-fired"], ["shots-fired"], _ev(max_calls=2))
|
||||||
|
assert v["push"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_major_incident_makes_any_content_match_high_severity():
|
||||||
|
v = alerter._assess(["school"], [], _ev(severity="major"))
|
||||||
|
assert v["high_severity"] and not v["push"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_tag_match_pushes():
|
||||||
|
v = alerter._assess(["mva"], ["mva"], _ev(max_calls=1))
|
||||||
|
assert v["push"] and not v["high_severity"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_low_stt_confidence_holds_even_ordinary_matches():
|
||||||
|
v = alerter._assess(["mva"], ["mva"], _ev(stt_confidence=0.1))
|
||||||
|
assert not v["push"] and v["hold_reason"] == "low_confidence"
|
||||||
|
assert v["confidence"] == round(0.8 * 0.1 / 0.3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_typical_radio_logprob_does_not_hold_a_transcript_match():
|
||||||
|
"""exp(avg_logprob) of ~0.4 is normal on vocoded audio (review of #300)."""
|
||||||
|
v = alerter._assess(["mva"], [], _ev(stt_confidence=0.4))
|
||||||
|
assert v["push"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_speech_never_pushes_content():
|
||||||
|
v = alerter._assess(["mva"], ["mva"], _ev(not_speech=True))
|
||||||
|
assert not v["push"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── end to end through Firestore ────────────────────────────────────────────
|
||||||
|
|
||||||
|
RULE = {"rule_id": "r1", "name": "Weapons", "enabled": True, "org_id": "org-1",
|
||||||
|
"keywords": ["firearm", "shots-fired"], "discord_webhook": "https://discord.example/wh"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs(monkeypatch):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
fake.put("alert_rules", "r1", dict(RULE))
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _posts():
|
||||||
|
posted = []
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
async def post(self, url, json=None, **k):
|
||||||
|
posted.append(json)
|
||||||
|
|
||||||
|
return posted, patch("httpx.AsyncClient", lambda *a, **k: _Client())
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(call_id, tags, transcript):
|
||||||
|
await alerter.check_and_dispatch(call_id=call_id, node_id="n1", talkgroup_id=1,
|
||||||
|
talkgroup_name="PD Dispatch", tags=tags, transcript=transcript)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_crimeradar_case_is_held_then_released_by_corroboration(fs):
|
||||||
|
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1"], "severity": "moderate"})
|
||||||
|
posted, http = _posts()
|
||||||
|
with http:
|
||||||
|
await _dispatch("c1", [], "report of a firearm at the elementary school")
|
||||||
|
assert posted == [], "a single uncorroborated firearm hit must not push"
|
||||||
|
assert fs.data.get("alert_events", {}) == {}
|
||||||
|
(hold,) = fs.data["alert_holds"].values()
|
||||||
|
assert hold["status"] == "held" and hold["hold_reason"] == "needs_corroboration"
|
||||||
|
assert hold["incident_id"] == "inc-1"
|
||||||
|
|
||||||
|
# A second call joins the incident; it does not itself mention a firearm.
|
||||||
|
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"], "severity": "moderate"})
|
||||||
|
with http:
|
||||||
|
await _dispatch("c2", [], "units on scene, stand by")
|
||||||
|
assert len(posted) == 1 and "Weapons" in posted[0]["content"]
|
||||||
|
(event,) = fs.data["alert_events"].values()
|
||||||
|
assert event["released_from_hold"] is True and event["corroborated_by_call_id"] == "c2"
|
||||||
|
assert fs.get("alert_holds", hold["alert_id"])["status"] == "released"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_low_confidence_hold_is_not_released_by_corroboration(fs):
|
||||||
|
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"], "stt_confidence": 0.1})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
|
||||||
|
posted, http = _posts()
|
||||||
|
with http:
|
||||||
|
await _dispatch("c1", [], "firearm")
|
||||||
|
(hold,) = fs.data["alert_holds"].values()
|
||||||
|
assert hold["hold_reason"] == "low_confidence"
|
||||||
|
|
||||||
|
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
|
||||||
|
with http:
|
||||||
|
await _dispatch("c2", [], "units on scene")
|
||||||
|
assert posted == []
|
||||||
|
assert fs.get("alert_holds", hold["alert_id"])["status"] == "held"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rule_pushed_now_supersedes_its_hold_instead_of_alerting_twice(fs):
|
||||||
|
fs.put("calls", "c1", {"org_id": "org-1", "incident_ids": ["inc-1"]})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1"]})
|
||||||
|
posted, http = _posts()
|
||||||
|
with http:
|
||||||
|
await _dispatch("c1", ["shots-fired"], "shots fired")
|
||||||
|
fs.put("calls", "c2", {"org_id": "org-1", "incident_ids": ["inc-1"]})
|
||||||
|
fs.put("incidents", "inc-1", {"call_ids": ["c1", "c2"]})
|
||||||
|
with http:
|
||||||
|
await _dispatch("c2", ["shots-fired"], "more shots fired")
|
||||||
|
assert len(posted) == 1
|
||||||
|
(hold,) = fs.data["alert_holds"].values()
|
||||||
|
assert hold["status"] == "superseded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_held_alerts_are_listed_for_admins(fs):
|
||||||
|
from app.routers import alerts as alerts_router
|
||||||
|
fs.put("alert_holds", "h1", {"alert_id": "h1", "status": "held", "triggered_at": "2026-10-27T01:00:00"})
|
||||||
|
fs.put("alert_holds", "h2", {"alert_id": "h2", "status": "released", "triggered_at": "2026-10-27T02:00:00"})
|
||||||
|
out = await alerts_router.list_held_alerts(status="held", org_id=None, _={})
|
||||||
|
assert [h["alert_id"] for h in out] == ["h1"]
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
server-26#298 — the chatter pre-filter gates every paid call.
|
||||||
|
|
||||||
|
Radio housekeeping (roll call, a bare "Post 4, 10-8, 10-4") used to go through
|
||||||
|
transcript correction, scene extraction, an embedding per scene and a geocode,
|
||||||
|
only to come back as a thin routine scene. Now it pays for none of them; the
|
||||||
|
free rule paths (clearance parse, self-initiated/plate backstop) still run.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import intelligence, transcription
|
||||||
|
from app.internal.chatter_classifier import gate_chatter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _gate_on():
|
||||||
|
"""The gate ships OFF (shadow) until a live window clears it (#127);
|
||||||
|
these tests exercise it switched on."""
|
||||||
|
with patch.object(settings, "chatter_gate_enabled", True):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class _NoPaidCalls:
|
||||||
|
"""Fail the test if any paid extraction/embedding/geocode call happens."""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise AssertionError("a paid AI call was made for chatter")
|
||||||
|
|
||||||
|
async def _aboom(*a, **k):
|
||||||
|
raise AssertionError("a paid geocode was made for chatter")
|
||||||
|
|
||||||
|
self.ps = [
|
||||||
|
patch.object(intelligence, "_sync_extract", _boom),
|
||||||
|
patch.object(intelligence, "_sync_embed", _boom),
|
||||||
|
patch.object(intelligence, "_geocode_lookup", _aboom),
|
||||||
|
]
|
||||||
|
for p in self.ps:
|
||||||
|
p.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
for p in self.ps:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract(transcript, talkgroup_name="PD Dispatch"):
|
||||||
|
saved: dict = {}
|
||||||
|
|
||||||
|
async def _doc_set(coll, doc_id, data, merge=True):
|
||||||
|
saved.update(data)
|
||||||
|
|
||||||
|
with patch.object(intelligence.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
|
||||||
|
patch.object(intelligence.fstore, "doc_set", _doc_set):
|
||||||
|
scenes = await intelligence.extract_scenes("call-1", transcript, talkgroup_name)
|
||||||
|
return scenes, saved
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bare_acknowledgement_over_five_words_skips_extraction():
|
||||||
|
with _NoPaidCalls():
|
||||||
|
scenes, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
|
||||||
|
assert scenes == []
|
||||||
|
assert saved["skip_reason"] == "non_event_chatter"
|
||||||
|
assert saved["severity"] == "routine"
|
||||||
|
assert saved["chatter_classifier_verdict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_whole_roll_call_skips_extraction():
|
||||||
|
roll = ("Car 100, roll call. Henry 1. Henry 1. Sam 1. Sam 1. 45 Baker. 45 Baker. "
|
||||||
|
"11 Adam. Adam. 11 Baker. Baker. Staff 1. 1. Staff 2.")
|
||||||
|
with _NoPaidCalls():
|
||||||
|
scenes, saved = await _extract(roll)
|
||||||
|
assert scenes == [] and saved["skip_reason"] == "non_event_chatter"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_roll_call_mentioned_on_the_way_to_a_real_job_still_extracts():
|
||||||
|
"""The #127 review's false-positive shape must still reach the model."""
|
||||||
|
text = "Delayed for roll call, then respond to a structure fire at 12 Main Street"
|
||||||
|
assert gate_chatter(text) == (False, None)
|
||||||
|
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||||
|
await _extract(text)
|
||||||
|
ext.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plate_read_chatter_still_becomes_a_stop_for_free():
|
||||||
|
text = "Lincoln Charlie Robert 7-4-0-7, Lincoln Charlie Robert 7-4-0-7."
|
||||||
|
with _NoPaidCalls():
|
||||||
|
scenes, saved = await _extract(text)
|
||||||
|
assert len(scenes) == 1 and scenes[0]["tags"] == ["traffic-stop"]
|
||||||
|
assert saved["severity"] == "minor"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gate_off_restores_shadow_mode():
|
||||||
|
with patch.object(settings, "chatter_gate_enabled", False), \
|
||||||
|
patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||||
|
_, saved = await _extract("10-4, 10-4 Central, 98. 10-4, 98.")
|
||||||
|
ext.assert_called_once()
|
||||||
|
assert saved["chatter_classifier_verdict"] is True, "the shadow verdict is still recorded"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_event_is_not_gated():
|
||||||
|
with patch.object(intelligence, "_sync_extract", return_value=[]) as ext:
|
||||||
|
await _extract("Shots fired, officer down, requesting backup immediately at the school.")
|
||||||
|
ext.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcription_skips_correction_for_chatter():
|
||||||
|
flags = {"transcript_correction_enabled": True}
|
||||||
|
with patch.object(settings, "openai_api_key", "k"), \
|
||||||
|
patch.object(settings, "groq_api_key", None), \
|
||||||
|
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
|
||||||
|
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
|
||||||
|
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
|
||||||
|
"degenerate": False, "duration": 2.0, "confidence": None}), \
|
||||||
|
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
|
||||||
|
patch.object(transcription, "ai_health") as health, \
|
||||||
|
patch.object(transcription.transcript_correction, "correct", AsyncMock()) as correct, \
|
||||||
|
patch("app.internal.feature_flags.resolve_flags",
|
||||||
|
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
|
||||||
|
health.report_healthy = AsyncMock()
|
||||||
|
text, _ = await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
|
||||||
|
assert text == "Post 4, 10-8. 10-4. Copy, 98."
|
||||||
|
correct.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chatter_still_reaches_the_free_thin_correlation_path():
|
||||||
|
"""non_event_chatter joins transcript_too_short in upload.py's no-scene
|
||||||
|
fallback (rules-only for a thin call — no LLM)."""
|
||||||
|
from app.routers import upload
|
||||||
|
|
||||||
|
with patch.object(upload, "fstore") as fs, \
|
||||||
|
patch.object(upload, "_correlate", AsyncMock(return_value=(None, None))) as corr:
|
||||||
|
fs.doc_get = AsyncMock(return_value={"skip_reason": "non_event_chatter", "severity": "routine"})
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await upload._extract_and_correlate(
|
||||||
|
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
|
||||||
|
talkgroup_name="PD Dispatch", transcript="10-4 Central, 98.", scenes=[],
|
||||||
|
)
|
||||||
|
corr.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_scene_fallback_passes_the_call_doc_severity():
|
||||||
|
"""server-26#104: a reprocessed call whose doc says 'major' must not be
|
||||||
|
re-correlated as 'routine' (thin, no fit check)."""
|
||||||
|
from app.routers import upload
|
||||||
|
|
||||||
|
with patch.object(upload, "fstore") as fs, \
|
||||||
|
patch.object(upload, "_correlate", AsyncMock(return_value=(None, None))) as corr:
|
||||||
|
fs.doc_get = AsyncMock(return_value={"severity": "major"})
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await upload._extract_and_correlate(
|
||||||
|
call_id="c1", node_id="n1", system_id="s1", talkgroup_id=1,
|
||||||
|
talkgroup_name="PD Dispatch", transcript="whatever", scenes=[],
|
||||||
|
)
|
||||||
|
assert corr.await_args.kwargs["severity"] == "major"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gate_off_still_corrects_chatter():
|
||||||
|
"""With the gate off, transcription must not skip correction either."""
|
||||||
|
flags = {"transcript_correction_enabled": True}
|
||||||
|
with patch.object(settings, "chatter_gate_enabled", False), \
|
||||||
|
patch.object(settings, "openai_api_key", "k"), \
|
||||||
|
patch.object(settings, "groq_api_key", None), \
|
||||||
|
patch.object(transcription, "_download", lambda uri: "/nonexistent.flac"), \
|
||||||
|
patch.object(transcription, "_sync_transcribe_file", lambda *a, **k: {
|
||||||
|
"text": "Post 4, 10-8. 10-4. Copy, 98.", "segments": [],
|
||||||
|
"degenerate": False, "duration": 2.0, "confidence": None}), \
|
||||||
|
patch.object(transcription.fstore, "doc_set", AsyncMock()), \
|
||||||
|
patch.object(transcription, "ai_health") as health, \
|
||||||
|
patch.object(transcription.transcript_correction, "correct",
|
||||||
|
AsyncMock(return_value=(None, None, False))) as correct, \
|
||||||
|
patch("app.internal.feature_flags.resolve_flags",
|
||||||
|
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
|
||||||
|
health.report_healthy = AsyncMock()
|
||||||
|
await transcription.transcribe_call("call-1", "gs://b/c.flac", "PD Dispatch")
|
||||||
|
correct.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_ships_off():
|
||||||
|
from app.config import Settings
|
||||||
|
assert Settings.model_fields["chatter_gate_enabled"].default is False
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
server-26#298 — geocode result cache in front of the Google Geocoding API,
|
||||||
|
and server-26#160 — place verification gets an anchor from the talkgroup's
|
||||||
|
own name when nobody configured one.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.internal.intelligence as intel
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import place_verifier as pv
|
||||||
|
|
||||||
|
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
|
||||||
|
|
||||||
|
|
||||||
|
class _Counter:
|
||||||
|
def __init__(self, payload=None, exc=None):
|
||||||
|
self.calls = 0
|
||||||
|
self.payload = payload
|
||||||
|
self.exc = exc
|
||||||
|
|
||||||
|
def patch(self):
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self):
|
||||||
|
if outer.exc:
|
||||||
|
raise outer.exc
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return outer.payload
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
async def __aenter__(self): return self
|
||||||
|
async def __aexit__(self, *a): return False
|
||||||
|
|
||||||
|
async def get(self, *a, **k):
|
||||||
|
outer.calls += 1
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
return patch("httpx.AsyncClient", lambda *a, **k: _Client())
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(lat=41.161, lng=-73.861):
|
||||||
|
return {"status": "OK", "results": [{"geometry": {
|
||||||
|
"location": {"lat": lat, "lng": lng}, "location_type": "ROOFTOP"}}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _key():
|
||||||
|
with patch.object(settings, "google_maps_api_key", "test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeat_address_is_one_request():
|
||||||
|
c = _Counter(_ok())
|
||||||
|
with c.patch():
|
||||||
|
a = await intel._geocode_location("12 Main Street, Ossining", anchor=ANCHOR)
|
||||||
|
b = await intel._geocode_location("12 main street, ossining ", anchor=ANCHOR)
|
||||||
|
assert a == b == {"lat": 41.161, "lng": -73.861, "location_type": "ROOFTOP"}
|
||||||
|
assert c.calls == 1
|
||||||
|
assert intel.geocode_cache_stats == {"hits": 1, "misses": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cached_answer_is_still_judged_against_the_caller_anchor():
|
||||||
|
c = _Counter(_ok())
|
||||||
|
with c.patch():
|
||||||
|
assert await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||||
|
far = {"lat": 0.0, "lng": 0.0, "radius_km": 10.0}
|
||||||
|
# Different bounds -> different key, so a separate request — never a
|
||||||
|
# cached answer leaking past a different area's distance check.
|
||||||
|
assert await intel._geocode_location("12 Main Street", anchor=far) is None
|
||||||
|
assert c.calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_zero_results_is_cached_but_errors_are_not():
|
||||||
|
c = _Counter({"status": "ZERO_RESULTS", "results": []})
|
||||||
|
with c.patch():
|
||||||
|
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
|
||||||
|
await intel._geocode_location("Cool Parts, Illinois", anchor=ANCHOR)
|
||||||
|
assert c.calls == 1
|
||||||
|
|
||||||
|
denied = _Counter({"status": "REQUEST_DENIED"})
|
||||||
|
with denied.patch():
|
||||||
|
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
|
||||||
|
await intel._geocode_location("Snowden Avenue", anchor=ANCHOR)
|
||||||
|
assert denied.calls == 2, "a key/quota failure is not an answer about the address"
|
||||||
|
|
||||||
|
boom = _Counter(_ok(), exc=RuntimeError("503"))
|
||||||
|
with boom.patch():
|
||||||
|
await intel._geocode_location("Croton Point", anchor=ANCHOR)
|
||||||
|
await intel._geocode_location("Croton Point", anchor=ANCHOR)
|
||||||
|
assert boom.calls == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ttl_expiry_and_size_bound():
|
||||||
|
c = _Counter(_ok())
|
||||||
|
with c.patch(), patch.object(settings, "geocode_cache_ttl_seconds", 0):
|
||||||
|
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||||
|
await intel._geocode_location("12 Main Street", anchor=ANCHOR)
|
||||||
|
assert c.calls == 2, "TTL 0 disables the cache"
|
||||||
|
|
||||||
|
c = _Counter(_ok())
|
||||||
|
with c.patch(), patch.object(settings, "geocode_cache_max_entries", 2):
|
||||||
|
for street in ("A Street", "B Street", "C Street"):
|
||||||
|
await intel._geocode_location(street, anchor=ANCHOR)
|
||||||
|
assert len(intel._geocode_cache) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── server-26#160 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unconfigured_channel_gets_an_anchor_from_its_talkgroup_name():
|
||||||
|
resolved = {"center": {"lat": 40.71, "lng": -74.0}, "radius_km": 25.0}
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def _geo(query, anchor):
|
||||||
|
seen["query"], seen["anchor"] = query, anchor
|
||||||
|
return {"lat": 40.7, "lng": -74.0}
|
||||||
|
|
||||||
|
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||||
|
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
|
||||||
|
patch.object(intel, "_get_node_state", AsyncMock(return_value="New York")), \
|
||||||
|
patch.object(pv.area_context, "resolve_anchor", AsyncMock(return_value=resolved)) as res, \
|
||||||
|
patch.object(pv, "_geocode_in_anchor", _geo):
|
||||||
|
await pv.verify("c1", "Respond to Lexington Avenue", None, ["Lexington Avenue"],
|
||||||
|
{}, {}, talkgroup_name="New York City PD", node_id="node-1")
|
||||||
|
res.assert_awaited_once_with({"municipality": "New York City", "state": "New York"})
|
||||||
|
assert seen["anchor"] == {"lat": 40.71, "lng": -74.0, "radius_km": 25.0}
|
||||||
|
assert seen["query"] == "Lexington Avenue, New York City, New York"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_state_means_no_derived_anchor():
|
||||||
|
"""'Springfield PD' alone could anchor anywhere — skip, never guess."""
|
||||||
|
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||||
|
patch.object(intel.fstore, "doc_get_cached", AsyncMock(return_value={"lat": 41.2, "lon": -73.8})), \
|
||||||
|
patch.object(intel, "_get_node_state", AsyncMock(return_value="")), \
|
||||||
|
patch.object(pv, "_geocode_in_anchor") as geo:
|
||||||
|
out = await pv.verify("c1", "Respond to Main Street", None, ["Main Street"],
|
||||||
|
{}, {}, talkgroup_name="Springfield PD", node_id="node-1")
|
||||||
|
assert out == (None, None)
|
||||||
|
geo.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_configured_place_without_anchor_still_skips():
|
||||||
|
"""A place that is set but too wide (no anchor stored) keeps the
|
||||||
|
load-bearing skip; the talkgroup-name fallback is only for unset areas."""
|
||||||
|
with patch.object(pv.settings, "place_verification_enabled", True), \
|
||||||
|
patch.object(pv, "_talkgroup_anchor", AsyncMock()) as derived, \
|
||||||
|
patch.object(pv, "_geocode_in_anchor") as geo:
|
||||||
|
out = await pv.verify("c1", "Main Street", None, ["Main Street"],
|
||||||
|
{"state": "Colorado"}, {}, talkgroup_name="Denver PD", node_id="n")
|
||||||
|
assert out == (None, None)
|
||||||
|
derived.assert_not_awaited()
|
||||||
|
geo.assert_not_called()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""server-26#88 — resolved/reassignment are parsed, not bool()-coerced."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.internal.incident_correlator import parse_llm_bool as _llm_bool
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value,expected", [
|
||||||
|
(True, True), (False, False),
|
||||||
|
("true", True), ("TRUE", True), (" Yes ", True), ("yes", True),
|
||||||
|
("false", False), ("False", False), ("no", False), ("NO", False),
|
||||||
|
("", False), ("maybe", False), (None, False), (1, False), (0, False), ([], False),
|
||||||
|
])
|
||||||
|
def test_llm_bool(value, expected):
|
||||||
|
assert _llm_bool(value) is expected
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
server-26#297 — config-driven STT provider with OpenAI as the fallback.
|
||||||
|
|
||||||
|
The default must stay OpenAI until a Groq key exists (so prod does not break
|
||||||
|
before the key is provisioned), a failing primary must fall back to whisper-1
|
||||||
|
without losing the call, and the vocabulary prompt must never carry a
|
||||||
|
ten-code series (the hallucination documented in transcription.py).
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.internal import transcription as tr
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**over):
|
||||||
|
base = dict(stt_provider="auto", groq_api_key=None, openai_api_key="sk-openai",
|
||||||
|
stt_api_key=None, stt_base_url=None, stt_fallback_to_openai=True,
|
||||||
|
stt_model="whisper-1", stt_compat_model="whisper-large-v3-turbo")
|
||||||
|
base.update(over)
|
||||||
|
return [patch.object(settings, k, v) for k, v in base.items()]
|
||||||
|
|
||||||
|
|
||||||
|
class _patched:
|
||||||
|
def __init__(self, **over):
|
||||||
|
self.ps = _settings(**over)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
for p in self.ps:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
for p in self.ps:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
|
||||||
|
# ── provider resolution ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_default_without_groq_key_is_openai_only():
|
||||||
|
with _patched():
|
||||||
|
targets = tr.stt_targets()
|
||||||
|
assert [(t.provider, t.model, t.tier) for t in targets] == [("openai", "whisper-1", "transcription")]
|
||||||
|
assert targets[0].vocab_prompt is False, "the OpenAI path keeps today's fixed prompt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_groq_key_makes_groq_primary_with_openai_fallback():
|
||||||
|
with _patched(groq_api_key="gsk"):
|
||||||
|
targets = tr.stt_targets()
|
||||||
|
assert [(t.provider, t.model) for t in targets] == [
|
||||||
|
("groq", "whisper-large-v3-turbo"), ("openai", "whisper-1")]
|
||||||
|
assert targets[0].base_url == "https://api.groq.com/openai/v1"
|
||||||
|
assert targets[0].api_key == "gsk"
|
||||||
|
assert targets[1].tier == "transcription_fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_can_be_switched_off():
|
||||||
|
with _patched(groq_api_key="gsk", stt_fallback_to_openai=False):
|
||||||
|
assert [t.provider for t in tr.stt_targets()] == ["groq"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_openai_key_means_no_fallback():
|
||||||
|
with _patched(groq_api_key="gsk", openai_api_key=None):
|
||||||
|
assert [t.provider for t in tr.stt_targets()] == ["groq"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compat_server_from_base_url():
|
||||||
|
with _patched(stt_base_url="http://whisper.local/v1", stt_compat_model="large-v3"):
|
||||||
|
targets = tr.stt_targets()
|
||||||
|
assert targets[0].provider == "compat" and targets[0].base_url == "http://whisper.local/v1"
|
||||||
|
assert targets[0].model == "large-v3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_groq_without_key_degrades_to_openai():
|
||||||
|
with _patched(stt_provider="groq"):
|
||||||
|
assert [t.provider for t in tr.stt_targets()] == ["openai"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_configured_is_empty():
|
||||||
|
with _patched(openai_api_key=None):
|
||||||
|
assert tr.stt_targets() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── fallback in transcribe_call ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ok(text="Engine 5 responding to a structure fire on Main Street"):
|
||||||
|
return {"text": text, "segments": [{"start": 0.0, "end": 3.0, "text": text}],
|
||||||
|
"degenerate": False, "duration": 3.0, "confidence": 0.81}
|
||||||
|
|
||||||
|
|
||||||
|
async def _transcribe(sync_impl, **over):
|
||||||
|
saved: dict = {}
|
||||||
|
|
||||||
|
async def _doc_set(coll, doc_id, data, merge=True):
|
||||||
|
saved.update(data)
|
||||||
|
|
||||||
|
flags = {"transcript_correction_enabled": False}
|
||||||
|
with _patched(**over), \
|
||||||
|
patch.object(tr, "_download", lambda uri: "/nonexistent.flac"), \
|
||||||
|
patch.object(tr, "_sync_transcribe_file", sync_impl), \
|
||||||
|
patch.object(tr.transcript_correction, "resolve_context", AsyncMock(return_value={})), \
|
||||||
|
patch.object(tr.fstore, "doc_set", _doc_set), \
|
||||||
|
patch.object(tr, "ai_health") as health, \
|
||||||
|
patch("app.internal.feature_flags.resolve_flags",
|
||||||
|
AsyncMock(return_value=(flags, lambda k: flags.get(k, True)))):
|
||||||
|
health.report_healthy = AsyncMock()
|
||||||
|
health.report_degraded = AsyncMock()
|
||||||
|
health.classify = lambda text: "transient"
|
||||||
|
result = await tr.transcribe_call("call-1", "gs://b/call-1.flac", "PD Dispatch", system_id="sys-1")
|
||||||
|
return result, saved, health
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_primary_failure_falls_back_to_openai():
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def sync(path, target, prompt, glossary=None):
|
||||||
|
seen.append(target.provider)
|
||||||
|
if target.provider == "groq":
|
||||||
|
raise RuntimeError("503 Service Unavailable")
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
(text, segments), saved, health = await _transcribe(sync, groq_api_key="gsk")
|
||||||
|
assert seen == ["groq", "openai"]
|
||||||
|
assert text.startswith("Engine 5")
|
||||||
|
assert saved["stt_provider"] == "openai" and saved["stt_model"] == "whisper-1"
|
||||||
|
# The primary stays red; only the fallback tier is marked healthy.
|
||||||
|
assert health.report_degraded.await_args.args[:3] == ("transcription", "groq", "whisper-large-v3-turbo")
|
||||||
|
health.report_healthy.assert_awaited_once_with("transcription_fallback")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_primary_success_records_provider_and_cost_inputs():
|
||||||
|
(text, _), saved, health = await _transcribe(lambda *a, **k: _ok(), groq_api_key="gsk")
|
||||||
|
assert saved["stt_provider"] == "groq"
|
||||||
|
assert saved["stt_model"] == "whisper-large-v3-turbo"
|
||||||
|
assert saved["stt_audio_seconds"] == 3.0
|
||||||
|
assert saved["stt_confidence"] == 0.81
|
||||||
|
health.report_healthy.assert_awaited_once_with("transcription")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_every_target_failing_returns_nothing():
|
||||||
|
def sync(*a, **k):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
(text, segments), saved, health = await _transcribe(sync, groq_api_key="gsk")
|
||||||
|
assert (text, segments) == (None, [])
|
||||||
|
assert health.report_degraded.await_count == 2
|
||||||
|
health.report_healthy.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_config_never_calls_groq():
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def sync(path, target, prompt, glossary=None):
|
||||||
|
seen.append((target.provider, target.base_url))
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
await _transcribe(sync)
|
||||||
|
assert seen == [("openai", None)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── prompt ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CONTEXT = {
|
||||||
|
"area": {"municipality": "Ossining", "county": "Westchester", "state": "New York",
|
||||||
|
"local_knowledge": [{"term": "Snowden Avenue"}, {"term": "Sing Sing", "meaning": "prison"}]},
|
||||||
|
"vocabulary": ["10-13", "5-David", "Croton Point", "Snowden Avenue"],
|
||||||
|
"ten_codes": {"10-4": "ack", "10-13": "officer needs assistance"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_context_is_the_legacy_prompt():
|
||||||
|
prompt, glossary = tr.build_stt_prompt("PD Dispatch", None)
|
||||||
|
assert prompt == "Talkgroup: PD Dispatch. " + tr._WHISPER_PROMPT
|
||||||
|
assert glossary == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_vocabulary_prompt_carries_places_but_never_ten_codes():
|
||||||
|
prompt, glossary = tr.build_stt_prompt("PD Dispatch", CONTEXT)
|
||||||
|
assert "Ossining, Westchester, New York" in prompt
|
||||||
|
assert glossary == ["Snowden Avenue", "Sing Sing", "5-David", "Croton Point"]
|
||||||
|
assert "10-13" not in prompt, "a code-shaped vocabulary term must stay out"
|
||||||
|
assert "officer needs assistance" not in prompt
|
||||||
|
assert prompt.endswith(tr._WHISPER_PROMPT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_stays_inside_the_token_budget():
|
||||||
|
big = {"area": {}, "vocabulary": [f"Placename{i} Boulevard" for i in range(200)]}
|
||||||
|
with patch.object(settings, "stt_prompt_max_tokens", 200):
|
||||||
|
prompt, glossary = tr.build_stt_prompt("PD Dispatch", big)
|
||||||
|
assert tr._estimate_tokens(prompt) <= 200
|
||||||
|
assert 0 < len(glossary) < 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_estimate_is_conservative_for_radio_text():
|
||||||
|
# GPT-2 tokenises this at ~25 tokens; the estimate must not be lower.
|
||||||
|
assert tr._estimate_tokens("Engine 5 responding to a structure fire on Main Street, 10-4.") >= 20
|
||||||
|
|
||||||
|
|
||||||
|
# ── degenerate output the vocabulary prompt could add ───────────────────────
|
||||||
|
|
||||||
|
def test_prompt_echo_is_discarded():
|
||||||
|
glossary = ["Snowden Avenue", "Sing Sing", "Croton Point", "Main Street"]
|
||||||
|
assert tr._is_degenerate("Snowden Avenue, Sing Sing, Croton Point, Main Street.", [], glossary)
|
||||||
|
assert tr._is_degenerate("Local names: Snowden Avenue, Sing Sing.", [], glossary)
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_real_street_in_a_sentence_is_not_an_echo():
|
||||||
|
glossary = ["Snowden Avenue", "Sing Sing", "Croton Point"]
|
||||||
|
real = "Car 4 respond to Snowden Avenue for a report of a disabled vehicle."
|
||||||
|
assert not tr._is_degenerate(real, [], glossary)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compression_ratio_catches_an_alternating_loop():
|
||||||
|
loop = "Dispatch, copy. Unit 4, go ahead. " * 12
|
||||||
|
assert not tr._phrase_loop(loop), "the old checks miss this shape"
|
||||||
|
assert tr._is_degenerate(loop, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_long_traffic_passes_the_compression_check():
|
||||||
|
real = (
|
||||||
|
"Headquarters to 5 David, respond to 12 Snowden Avenue for a report of a "
|
||||||
|
"two-car accident with injuries, caller states one vehicle is overturned. "
|
||||||
|
"5 David copies, en route from Route 9. Be advised EMS has been notified "
|
||||||
|
"and Ossining Fire is responding as well. 5 David on scene, two vehicles, "
|
||||||
|
"one patient complaining of neck pain, requesting a tow for one."
|
||||||
|
)
|
||||||
|
assert len(real) >= tr._MIN_CHARS_FOR_COMPRESSION
|
||||||
|
assert not tr._is_degenerate(real, [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_hold_back_counts_the_fallback_attempt():
|
||||||
|
from app.internal import recorrelation_sweep
|
||||||
|
with _patched():
|
||||||
|
single = recorrelation_sweep.pipeline_worst_case_seconds()
|
||||||
|
with _patched(groq_api_key="gsk"):
|
||||||
|
double = recorrelation_sweep.pipeline_worst_case_seconds()
|
||||||
|
stt_one = (2 if settings.stt_retry_on_degenerate else 1) * (1 + settings.openai_max_retries) \
|
||||||
|
* settings.stt_timeout_seconds
|
||||||
|
assert double - single == stt_one
|
||||||
|
assert double / 60 < settings.recorrelation_scan_minutes, "the sweep must still be able to recover the call"
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_readback_is_not_an_echo():
|
||||||
|
"""Review of #297: intersection/address readbacks are where pins come from."""
|
||||||
|
glossary = ["Route 9", "Croton Avenue", "North Highland Place", "Main Street", "Ossining"]
|
||||||
|
for real in ("Route 9 and Croton Avenue, North Highland Place.",
|
||||||
|
"Main Street, Croton Avenue, Route 9, Ossining."):
|
||||||
|
assert not tr._is_degenerate(real, [], glossary), real
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_busy_call_is_judged_per_window():
|
||||||
|
seg_text = "Engine 5 responding, Ladder 2 responding, Medic 1 en route to Main Street. "
|
||||||
|
segments = [{"start": float(i * 5), "end": float(i * 5 + 4), "text": seg_text} for i in range(60)]
|
||||||
|
text = " ".join(s["text"] for s in segments)
|
||||||
|
# One repeated real update for 5 minutes still loops within every window,
|
||||||
|
# so this is degenerate — but a varied call with one looping window out of three is not.
|
||||||
|
assert tr._is_degenerate(text, segments)
|
||||||
|
real = [
|
||||||
|
"Headquarters to 5 David, respond to 12 Snowden Avenue for a two-car accident.",
|
||||||
|
"5 David copies, en route from Route 9, ETA four minutes.",
|
||||||
|
"Be advised EMS has been notified and Ossining Fire is responding as well.",
|
||||||
|
"5 David on scene, two vehicles, one patient complaining of neck pain.",
|
||||||
|
"Requesting a tow for the blue sedan, it is blocking the northbound lane.",
|
||||||
|
"Copy, tow is being contacted, any further units needed at this time?",
|
||||||
|
"Negative, traffic is backing up toward Croton Point, send one for control.",
|
||||||
|
"6 Henry, can you clear the larceny and assist 5 David on Snowden?",
|
||||||
|
"6 Henry clearing, show me en route, coming down from North Highland.",
|
||||||
|
"Medic 1 on location, patient is being packaged for transport to Phelps.",
|
||||||
|
"Fire is standing by for fluids, no entrapment, both drivers out of the cars.",
|
||||||
|
"Received, all units be advised the roadway is now closed southbound.",
|
||||||
|
]
|
||||||
|
loop = "Dispatch, copy. Unit 4, go ahead. " * 12
|
||||||
|
varied = [{"start": float(i * 5), "end": float(i * 5 + 4), "text": t} for i, t in enumerate(real)]
|
||||||
|
varied.append({"start": 70.0, "end": 98.0, "text": loop}) # its own window
|
||||||
|
assert not tr._is_degenerate(" ".join(s["text"] for s in varied), varied)
|
||||||
@@ -260,7 +260,10 @@ async def test_segment_code_change_discards_segments_only():
|
|||||||
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
|
"segments": ["Headquarters, 10-13.", "Show it out to Ossining.", "360 north, back to Route 9."],
|
||||||
}
|
}
|
||||||
with _system(), _gemini(payload):
|
with _system(), _gemini(payload):
|
||||||
text, segs, _ = await tc.correct("c1", "x y z w", SEGS, system_id="sys-1", talkgroup_id=9048)
|
# A realistic joined original (code-free, like the correction): a
|
||||||
|
# placeholder like "x y z w" now trips the server-26#162 growth guard.
|
||||||
|
text, segs, _ = await tc.correct("c1", "Shout out to Optum, back to Rose.", SEGS,
|
||||||
|
system_id="sys-1", talkgroup_id=9048)
|
||||||
assert segs is None
|
assert segs is None
|
||||||
assert text == "Show it out to Ossining, back to Route 9."
|
assert text == "Show it out to Ossining, back to Route 9."
|
||||||
|
|
||||||
@@ -279,3 +282,30 @@ async def test_reference_data_reaches_the_prompt():
|
|||||||
assert "Sing Sing — state prison" in p, "a term without its meaning is half the information"
|
assert "Sing Sing — state prison" in p, "a term without its meaning is half the information"
|
||||||
assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
|
assert "Ossining, Westchester, New York" in p, "state must reach the prompt (server-26#36)"
|
||||||
assert "10-13=officer needs assistance" in p
|
assert "10-13=officer needs assistance" in p
|
||||||
|
|
||||||
|
|
||||||
|
# ── server-26#162, the rest: bare numbers and invented words ────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_bare_number_turned_into_a_word_is_rejected():
|
||||||
|
"""The live case: a bare "7" came back as "ShotSpotter"."""
|
||||||
|
payload = {"corrected": "Car ShotSpotter on Home Street and Forest Ave."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
|
||||||
|
assert text is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_place_fix_that_keeps_its_numbers_still_passes():
|
||||||
|
payload = {"corrected": "Car 7 on Home Street and Forest Ave."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct("c1", "Car 7 on Holmes Street and 4th and Rowe.", None, system_id="sys-1")
|
||||||
|
assert text == "Car 7 on Home Street and Forest Ave."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_correction_that_writes_new_sentences_is_rejected():
|
||||||
|
payload = {"corrected": "Car 7 on Main Street. Suspect is armed and fled northbound in a red sedan."}
|
||||||
|
with _system(), _gemini(payload):
|
||||||
|
text, _, _ = await tc.correct("c1", "Car 7 on Mane Street.", None, system_id="sys-1")
|
||||||
|
assert text is None
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ PUBLIC_API_URL=https://api.{{ domain }}
|
|||||||
OPENAI_API_KEY={{ vault_openai_api_key }}
|
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||||
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||||
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||||
|
# STT provider (server-26#297). With this set, STT_PROVIDER=auto moves
|
||||||
|
# transcription to Groq whisper-large-v3-turbo with OpenAI as the fallback;
|
||||||
|
# default('') keeps a vault that predates the key on OpenAI, unchanged.
|
||||||
|
GROQ_API_KEY={{ vault_groq_api_key | default('') }}
|
||||||
|
|
||||||
SERVICE_KEY={{ vault_service_key }}
|
SERVICE_KEY={{ vault_service_key }}
|
||||||
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
|
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
|
||||||
|
|||||||
Reference in New Issue
Block a user