Transcription: config-driven OpenAI-compatible STT with OpenAI fallback (server-26#297, server-26#83)
STT_PROVIDER=auto picks Groq whisper-large-v3-turbo only when GROQ_API_KEY is set (else OpenAI, unchanged); compat covers any OpenAI-compatible server via STT_BASE_URL/STT_API_KEY. A failed primary falls back to OpenAI STT_MODEL on the same downloaded audio; the fallback reports to its own ai_health tier so a dead primary stays red. The non-OpenAI provider gets a token-budgeted glossary prompt (area places, local_knowledge, vocabulary; never ten-codes), guarded by a narrow prompt-echo check. A per-30s-window zlib compression check (Whisper's 2.4) adds a noise gate that survives correction being off (#83, partial). Call docs record stt_provider/model/audio_seconds/confidence. The sweep hold-back counts the fallback attempt. gate_chatter added to chatter_classifier (used by transcription to skip correction when CHATTER_GATE_ENABLED). GROQ_API_KEY templated with default ''. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7ecfca7648
commit
d485eae41a
@@ -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, "ai_health") as health, \
|
||||
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()
|
||||
health.report_healthy = AsyncMock()
|
||||
health.report_failure = 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"
|
||||
)
|
||||
|
||||
assert text == "units respond to the fire on Main Street", "the transcript must still be produced"
|
||||
tc.correct.assert_not_awaited()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user