config/ai_features was not the switch it was documented to be. Three paths spent money with it off, and one path read it wrong, so per-system opt-outs did not opt anything out. - Correlation in the ingest pipeline tested the raw global flag instead of the per-system resolution. With a system opted out, extraction was skipped but the no-scenes fallback still correlated the call with empty tags, taking the thin/recency path and attaching it to whatever incident was most recent on that system. The opt-out did not disable correlation, it disabled good correlation and left the worst kind running. (#75) - Transcript correction ran on every transcribed call gated only by an env var, spending Gemini tokens and a Places lookup per proposed location. An "STT-only" window was never STT-only and its cost could not be attributed. Now behind transcript_correction_enabled. (#76) - _run_extraction_pipeline and the vocabulary learner, both reachable from PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81) The flag resolver now lives in feature_flags.resolve_flags() rather than as a local helper in upload.py. Three copies of that logic is how #75 happened. PATCH /calls/{id}/transcript now refuses with 409 when correlation is off. That route wipes tags, severity, location, units, embedding and unlinks the call from every incident before queueing re-extraction. Gating extraction alone would have made it destructive-only in the standing flags-off configuration: the call left blank and orphaned forever, with the route still answering 200. The wipe and the rebuild are one transaction in intent, so it refuses before the first write. Also: the summarizer's stale-incident sweep is no longer behind summaries_enabled. It is pure Firestore with no model call in it, and gating it meant nothing auto-resolved while AI was off - so every incident stayed active forever and the candidate set every correlation reads kept growing. transcript_correction_enabled is documented as NOT a pure cost lever. The corrector is also the noise gate that sets not_speech; with it off, recogniser noise reaches extraction as a real transcript, comes back thin, and auto-attaches. Never open an evaluation window with correction off and correlation on. 14 tests added covering flag precedence, both pipeline paths, the 409, the correction gate and the summarizer no-op. Suite: 264 passed. Refs #75, #76, #81, #45.
368 lines
16 KiB
Python
368 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 openai import OpenAI
|
|
from app.config import settings
|
|
|
|
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"
|
|
|
|
openai_client = OpenAI(api_key=settings.openai_api_key)
|
|
with open(tmp_path, "rb") as f:
|
|
response = openai_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
|