No AI client set a timeout: OpenAI's SDK waits 600s per attempt and retries twice (timeouts included); google-genai waits forever. A hung provider call had no upper bound, so recorrelation_sweep's 15-minute pipeline hold-back was a guess against an unbounded tail. - config: stt_timeout_seconds (120), openai_timeout_seconds (60), openai_max_retries (1), gemini_timeout_seconds (60). - app/internal/ai_clients.py builds every OpenAI/AsyncOpenAI client with them (transcription, extraction, embeddings, summarizer, vocabulary learner, trip chat). gemini.py's shared client gets HttpOptions(timeout=ms). - A timeout raises like any other provider error, so existing fallbacks are unchanged: rules-only correlation, rules decision on tiebreak, raw transcript on correction, ai_health "transient". - MIN_MINUTES_SINCE_PIPELINE_START is now derived from those timeouts (pipeline_worst_case_seconds, 3-scene budget + 2 min non-AI slack), floor 15: 21 min with the defaults. Loop warns if it reaches the scan window. - tests/test_ai_timeouts.py: client limits, a real hung-socket request that gives up in <5s, timeout fallback per tier, the derived bound, and a guard against any new OpenAI( outside ai_clients.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""
|
|
Speech-to-text transcription for recorded calls using OpenAI Whisper.
|
|
|
|
Audio is downloaded from GCS then sent to the Whisper API. Falls back to
|
|
returning None on any failure so the intelligence pipeline can still run.
|
|
"""
|
|
import asyncio
|
|
import re
|
|
import tempfile
|
|
import os
|
|
from typing import Optional
|
|
from app.internal.logger import logger
|
|
from app.internal import firestore as fstore
|
|
from app.internal import ai_health
|
|
from app.internal import transcript_correction
|
|
from app.config import settings
|
|
|
|
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
|
# Writing it as actual radio speech primes the vocabulary toward P25 codes
|
|
# and phrasing before the model hears the audio.
|
|
#
|
|
# DO NOT put an enumerated run of ten-codes in here. The original version of
|
|
# this prompt opened with "10-4. 10-23. 10-20. 10-97. 10-8. ..." and Whisper,
|
|
# treating that as text it should continue, filled noisy or silent audio with
|
|
# sequences like "10-4. 10-5. 10-6. ... 10-99." Those hallucinations sailed
|
|
# straight past the no_speech_prob filter below, because the model is highly
|
|
# confident the continuation it invented is speech. Codes appear here only
|
|
# singly and inside a sentence, where there is no series to extend.
|
|
_WHISPER_PROMPT = (
|
|
"Dispatch, go ahead. Copy that, en route. Show me on scene. "
|
|
"Be advised, units responding. Negative, stand by. "
|
|
"Post 4, I'm out. Received, thank you. "
|
|
"Engine and ladder responding to a structure fire. "
|
|
"Medic on scene with one patient. "
|
|
"Vehicle accident with injuries, MVA. "
|
|
"Show me 10-8 and clear."
|
|
)
|
|
|
|
# Degenerate-output detection (see _is_degenerate). Tuned to catch Whisper's
|
|
# repetition failure mode without discarding terse but real radio traffic.
|
|
_MIN_CODES_FOR_RUN = 6 # ten-codes needed before a run is even considered
|
|
_RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
|
|
_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
|
|
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
|
|
|
|
|
|
def _ten_code_run(text: str) -> bool:
|
|
"""True if the text is mostly a counting run of ten-codes.
|
|
|
|
Real traffic uses ten-codes constantly, but never in ascending order — a
|
|
dispatcher does not say "10-4, 10-5, 10-6". An arithmetic series is the
|
|
signature of Whisper continuing a pattern rather than hearing one.
|
|
"""
|
|
numbers = [int(n) for n in re.findall(r"\b10-(\d{1,2})\b", text)]
|
|
if len(numbers) < _MIN_CODES_FOR_RUN:
|
|
return False
|
|
steps = [b - a for a, b in zip(numbers, numbers[1:])]
|
|
ascending = sum(1 for s in steps if s == 1)
|
|
return steps and (ascending / len(steps)) >= _RUN_RATIO
|
|
|
|
|
|
def _phrase_loop(text: str) -> bool:
|
|
"""True if one short phrase repeats far more than speech plausibly would.
|
|
|
|
Catches the other repetition mode, e.g. "Dispatch, do you copy?" emitted
|
|
a dozen times over static.
|
|
"""
|
|
parts = [p.strip().lower() for p in re.split(r"[.!?]", text) if p.strip()]
|
|
if len(parts) <= _MAX_PHRASE_REPEATS:
|
|
return False
|
|
repeats = 1
|
|
for prev, cur in zip(parts, parts[1:]):
|
|
repeats = repeats + 1 if cur == prev else 1
|
|
if repeats > _MAX_PHRASE_REPEATS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
|
"""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
|
|
these: the model reports high confidence in text it invented by continuing
|
|
a pattern, so the only tell is the shape of the output itself.
|
|
"""
|
|
if not text:
|
|
return False
|
|
if _ten_code_run(text) or _phrase_loop(text):
|
|
return True
|
|
# Near-identical segments repeated across the whole recording.
|
|
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
|
|
normalised = {s["text"].strip().lower() for s in segments}
|
|
if len(normalised) / len(segments) <= _UNIQUE_RATIO:
|
|
return True
|
|
return False
|
|
|
|
|
|
async def _log_transcribe_failure(call_id: str, exc: Exception) -> None:
|
|
"""
|
|
Log a transcription failure, escalating a permanent condition to ERROR
|
|
once (via app.internal.ai_health, which also drives the /health/ai
|
|
endpoint and the Discord degradation alert) and reporting it to the
|
|
shared registry either way.
|
|
|
|
Transcription failing returns None and the pipeline carries on by design, so
|
|
a per-call WARNING is invisible: no transcript means no extraction, which
|
|
means no incident, and the only symptom is calls quietly arriving empty. A
|
|
network blip is genuinely a warning. An exhausted balance is not -- it will
|
|
not fix itself and it takes the whole pipeline down with it, so it says so
|
|
once, loudly, and names the fix.
|
|
|
|
The same failure mode already bit the Gemini correlator twice (a retired
|
|
model ID, then a depleted balance), which is why this is worth the code.
|
|
"""
|
|
text = str(exc)
|
|
kind = ai_health.classify(text)
|
|
|
|
if kind == "billing":
|
|
problem = "the OpenAI account cannot be billed"
|
|
fix = "top up at https://platform.openai.com/settings/organization/billing"
|
|
logger.error(
|
|
"Transcription: the OpenAI account cannot be billed -- EVERY call is "
|
|
"now stored with no transcript, so extraction, correlation and "
|
|
"incidents are all dead downstream. Top up at "
|
|
f"https://platform.openai.com/settings/organization/billing. API said: {text}"
|
|
)
|
|
await ai_health.report_degraded(
|
|
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
|
)
|
|
return
|
|
|
|
if kind == "dead_model":
|
|
problem = "the STT model is unavailable"
|
|
fix = "update STT_MODEL in config.py"
|
|
logger.error(
|
|
f"Transcription: the configured model ({settings.stt_model!r}) is unavailable "
|
|
"-- EVERY call is now stored with no transcript, so extraction, correlation "
|
|
f"and incidents are all dead downstream. Update STT_MODEL in config.py. API said: {text}"
|
|
)
|
|
await ai_health.report_degraded(
|
|
"transcription", "openai", settings.stt_model, problem, fix, permanent=True
|
|
)
|
|
return
|
|
|
|
logger.warning(f"Transcription failed for call {call_id}: {text}")
|
|
await ai_health.report_degraded(
|
|
"transcription", "openai", settings.stt_model,
|
|
"transient API error", "no action needed unless this persists", permanent=False,
|
|
)
|
|
|
|
|
|
async def transcribe_call(
|
|
call_id: str,
|
|
gcs_uri: str,
|
|
talkgroup_name: Optional[str] = None,
|
|
system_id: Optional[str] = None,
|
|
talkgroup_id: Optional[int] = None,
|
|
) -> tuple[Optional[str], list[dict]]:
|
|
"""
|
|
Transcribe audio at the given GCS URI and store the result in Firestore.
|
|
|
|
Returns:
|
|
(transcript, segments) — segments is a list of {start, end, text} dicts,
|
|
one per detected transmission. Empty list if transcription failed.
|
|
"""
|
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
|
return None, []
|
|
|
|
try:
|
|
transcript, segments, degenerate = await asyncio.to_thread(
|
|
_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:
|
|
await _log_transcribe_failure(call_id, e)
|
|
return None, []
|
|
|
|
# No exception means the provider call itself succeeded (this also
|
|
# covers transcripts discarded as degenerate/hallucinated output —
|
|
# that's a filtering decision, not a provider failure), so the
|
|
# transcription tier is healthy and any prior degradation clears.
|
|
await ai_health.report_healthy("transcription")
|
|
|
|
if transcript:
|
|
updates: dict = {"transcript": transcript}
|
|
if segments:
|
|
updates["segments"] = segments
|
|
|
|
# Second opinion, before anything downstream sees the text. Whisper
|
|
# mishears proper nouns confidently, and extraction/embedding/
|
|
# correlation all consume the transcript — correcting it afterwards
|
|
# (which is where it used to live, inside the extraction prompt) meant
|
|
# every one of them reasoned over known-bad text. server-26#36.
|
|
# Correction is a second model call plus a Places lookup per proposed
|
|
# location, so it is real spend that used to be reachable only through
|
|
# an env var and an ansible run. That made an "STT-only" evaluation
|
|
# window not STT-only, and its cost unattributable (server-26#76, #45).
|
|
from app.internal.feature_flags import resolve_flags
|
|
_, _ai_flag = await resolve_flags(system_id)
|
|
|
|
corrected, corrected_segments, not_speech = (None, None, False)
|
|
if _ai_flag("transcript_correction_enabled"):
|
|
corrected, corrected_segments, not_speech = await transcript_correction.correct(
|
|
call_id, transcript, segments,
|
|
system_id=system_id,
|
|
talkgroup_id=talkgroup_id,
|
|
talkgroup_name=talkgroup_name,
|
|
)
|
|
else:
|
|
logger.info(
|
|
f"Transcript correction disabled — saving raw transcript for call {call_id}"
|
|
)
|
|
if corrected_segments:
|
|
# Raw stays as evidence; the corrected copy is what extraction reads.
|
|
updates["segments_corrected"] = corrected_segments
|
|
if not_speech:
|
|
# The corrector sees what _is_degenerate misses — novel repetition
|
|
# shapes rather than the two it pattern-matches. Keep the raw text
|
|
# (it is evidence) but do not let it reach extraction as fact.
|
|
updates["transcript_not_speech"] = True
|
|
logger.info(
|
|
f"Corrector flagged call {call_id} as recogniser noise: {transcript[:80]!r}"
|
|
)
|
|
elif corrected:
|
|
updates["transcript_corrected"] = corrected
|
|
|
|
try:
|
|
await fstore.doc_set("calls", call_id, updates)
|
|
logger.info(
|
|
f"Transcript saved for call {call_id} "
|
|
f"({len(transcript)} chars, {len(segments)} segment(s)"
|
|
f"{', corrected' if corrected and not not_speech else ''})"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Could not save transcript for {call_id}: {e}")
|
|
|
|
if not_speech:
|
|
return None, []
|
|
# Hand the corrected copies downstream. extract_scenes prefers numbered
|
|
# segments over the joined transcript, so returning corrected text with
|
|
# raw segments would have thrown the correction away on every call with
|
|
# more than one transmission.
|
|
return corrected or transcript, corrected_segments or segments
|
|
|
|
return transcript, segments
|
|
|
|
|
|
def _sync_transcribe(
|
|
gcs_uri: str,
|
|
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.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://"):]
|
|
bucket_name, blob_path = without_scheme.split("/", 1)
|
|
|
|
if settings.gcp_credentials_path:
|
|
creds = service_account.Credentials.from_service_account_file(
|
|
settings.gcp_credentials_path,
|
|
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
)
|
|
gcs_client = gcs.Client(credentials=creds)
|
|
else:
|
|
gcs_client = gcs.Client()
|
|
|
|
bucket = gcs_client.bucket(bucket_name)
|
|
blob = bucket.blob(blob_path)
|
|
|
|
suffix = os.path.splitext(blob_path)[1] or ".mp3"
|
|
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
blob.download_to_filename(tmp_path)
|
|
|
|
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:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|