Skip to content

Commit cea762d

Browse files
fix(plugins/sarvam): thread language_probability into SpeechData.confidence (#5830)
1 parent 4a34632 commit cea762d

2 files changed

Lines changed: 173 additions & 1 deletion

File tree

livekit-plugins/livekit-plugins-sarvam/livekit/plugins/sarvam/stt.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import asyncio
2323
import enum
2424
import json
25+
import logging
2526
import os
2627
import platform
2728
import weakref
@@ -343,6 +344,32 @@ def _get_urls_for_model(model: str) -> tuple[str, str]:
343344
return SARVAM_STT_BASE_URL, SARVAM_STT_STREAMING_URL
344345

345346

347+
def _extract_confidence(
348+
payload: dict,
349+
instance_logger: logging.Logger,
350+
) -> float:
351+
"""Read Sarvam's ``language_probability`` from a response payload.
352+
353+
Returns the value as a float when present and numeric. Falls back to
354+
``1.0`` when the field is absent, ``None``, or has an unexpected type
355+
(defensive — the field is documented for the REST endpoint but not
356+
explicitly for streaming, so contract drift is logged for visibility).
357+
"""
358+
value = payload.get("language_probability")
359+
# bool is a subclass of int — exclude explicitly so that an accidental
360+
# JSON `false` doesn't silently become ``confidence=0.0``. Same pattern
361+
# as livekit-plugins-slng/.../stt.py.
362+
if isinstance(value, (int, float)) and not isinstance(value, bool):
363+
return float(value)
364+
if value is not None:
365+
instance_logger.debug(
366+
"Unexpected language_probability type: %s (value=%r); falling back to confidence=1.0",
367+
type(value).__name__,
368+
value,
369+
)
370+
return 1.0
371+
372+
346373
def _calculate_audio_duration(
347374
buffer: AudioBuffer,
348375
) -> float: # TODO: Copied from livekit/agents/utils/audio.py, check if it can be reused
@@ -698,7 +725,7 @@ async def _recognize_impl(
698725
text=transcript_text,
699726
start_time=start_time,
700727
end_time=end_time,
701-
confidence=1.0, # Sarvam doesn't provide confidence score in this response
728+
confidence=_extract_confidence(response_json, self._logger),
702729
)
703730
]
704731

@@ -1477,6 +1504,7 @@ async def _handle_transcript_data(self, data: dict) -> None:
14771504
text=transcript_text,
14781505
start_time=transcript_data.get("speech_start", 0.0),
14791506
end_time=transcript_data.get("speech_end", 0.0),
1507+
confidence=_extract_confidence(transcript_data, self._logger),
14801508
)
14811509

14821510
# Create final transcript event with request_id
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for SpeechData.confidence threading from Sarvam's language_probability.
2+
3+
Verifies that both the REST and WS paths thread ``language_probability`` from
4+
Sarvam's response into ``SpeechData.confidence`` (instead of the previous
5+
hardcoded ``1.0``), with a defensive fallback when the field is absent or has
6+
an unexpected type.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
from unittest.mock import MagicMock
13+
14+
import pytest
15+
16+
from livekit.agents import stt
17+
from livekit.plugins.sarvam.stt import SpeechStream
18+
19+
# ---------------------------------------------------------------------------
20+
# Helpers — build a minimal STT instance + fake the channel/logger/state that
21+
# `_handle_transcript_data` touches. We bypass __init__ so the test doesn't
22+
# need an API key, an HTTP session, or a real WebSocket.
23+
# ---------------------------------------------------------------------------
24+
25+
26+
def _make_stream_under_test() -> tuple[SpeechStream, list[Any]]:
27+
"""Construct a minimal SpeechStream and collect its emitted events.
28+
29+
Returns ``(stream_instance, captured_events)`` where each event sent via
30+
``send_nowait`` is appended to ``captured_events``. We bypass ``__init__``
31+
so the test doesn't need an API key, an HTTP session, or a real WebSocket.
32+
"""
33+
instance = SpeechStream.__new__(SpeechStream)
34+
captured: list[Any] = []
35+
event_ch = MagicMock()
36+
event_ch.send_nowait = captured.append
37+
instance._event_ch = event_ch # type: ignore[attr-defined]
38+
instance._logger = MagicMock() # type: ignore[attr-defined]
39+
instance._build_log_context = lambda: {} # type: ignore[attr-defined]
40+
instance._server_request_id = None # type: ignore[attr-defined]
41+
instance._opts = MagicMock(language="en-IN") # type: ignore[attr-defined]
42+
return instance, captured
43+
44+
45+
def _ws_message(**transcript_overrides: Any) -> dict:
46+
"""Build the outer WS message dict expected by ``_handle_transcript_data``.
47+
48+
Default shape mirrors a Saaras v3 streaming final-transcript chunk.
49+
"""
50+
transcript_data: dict[str, Any] = {
51+
"transcript": "नमस्ते",
52+
"language_code": "hi-IN",
53+
"speech_start": 0.0,
54+
"speech_end": 1.2,
55+
"metrics": {"audio_duration": 1.2},
56+
"request_id": "req-test",
57+
}
58+
transcript_data.update(transcript_overrides)
59+
return {"type": "data", "data": transcript_data}
60+
61+
62+
def _final_event(captured: list[Any]) -> stt.SpeechEvent:
63+
finals = [ev for ev in captured if ev.type == stt.SpeechEventType.FINAL_TRANSCRIPT]
64+
assert finals, "no FINAL_TRANSCRIPT event emitted"
65+
return finals[0]
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# WS path — happy cases
70+
# ---------------------------------------------------------------------------
71+
72+
73+
@pytest.mark.parametrize(
74+
"language_probability, expected_confidence",
75+
[
76+
(0.87, 0.87),
77+
(1.0, 1.0),
78+
(0.0, 0.0),
79+
(0.5, 0.5),
80+
(0.123, 0.123),
81+
],
82+
)
83+
async def test_ws_threads_language_probability_into_confidence(
84+
language_probability: float, expected_confidence: float
85+
) -> None:
86+
"""WS path must thread Sarvam's language_probability into SpeechData.confidence."""
87+
instance, captured = _make_stream_under_test()
88+
await instance._handle_transcript_data(_ws_message(language_probability=language_probability))
89+
final = _final_event(captured)
90+
assert final.alternatives[0].confidence == pytest.approx(expected_confidence)
91+
92+
93+
# ---------------------------------------------------------------------------
94+
# WS path — defensive fallback cases (absent / null / wrong type)
95+
# ---------------------------------------------------------------------------
96+
97+
98+
async def test_ws_missing_language_probability_falls_back_to_1_0() -> None:
99+
"""When the field is absent, confidence falls back to 1.0 (no crash)."""
100+
instance, captured = _make_stream_under_test()
101+
# _ws_message() helper omits language_probability by default
102+
await instance._handle_transcript_data(_ws_message())
103+
final = _final_event(captured)
104+
assert final.alternatives[0].confidence == 1.0
105+
106+
107+
async def test_ws_null_language_probability_falls_back_to_1_0() -> None:
108+
"""Explicit null also falls back to 1.0."""
109+
instance, captured = _make_stream_under_test()
110+
await instance._handle_transcript_data(_ws_message(language_probability=None))
111+
final = _final_event(captured)
112+
assert final.alternatives[0].confidence == 1.0
113+
114+
115+
@pytest.mark.parametrize("bad_value", ["0.95", [], {}, object(), True, False])
116+
async def test_ws_unexpected_type_falls_back_to_1_0(bad_value: Any) -> None:
117+
"""String / list / dict / object / bool → confidence falls back to 1.0 with a debug log.
118+
119+
bool is included because Python's ``bool`` is a subclass of ``int``;
120+
without an explicit guard a JSON ``false`` from Sarvam would silently
121+
become ``confidence=0.0`` and wrongly flag a valid transcript as low
122+
confidence. Same defensive pattern as ``livekit-plugins-slng``.
123+
"""
124+
instance, captured = _make_stream_under_test()
125+
await instance._handle_transcript_data(_ws_message(language_probability=bad_value))
126+
final = _final_event(captured)
127+
assert final.alternatives[0].confidence == 1.0
128+
# The defensive branch logs a debug warning so contract drift is visible.
129+
assert instance._logger.debug.called # type: ignore[attr-defined]
130+
131+
132+
# ---------------------------------------------------------------------------
133+
# WS path — out-of-range values pass through verbatim (clamping is not this
134+
# layer's job; downstream consumers can clamp if they need to).
135+
# ---------------------------------------------------------------------------
136+
137+
138+
@pytest.mark.parametrize("value", [-0.5, 1.5, 2.0])
139+
async def test_ws_out_of_range_values_passed_through(value: float) -> None:
140+
"""Out-of-[0,1] values are passed through verbatim (no clamping)."""
141+
instance, captured = _make_stream_under_test()
142+
await instance._handle_transcript_data(_ws_message(language_probability=value))
143+
final = _final_event(captured)
144+
assert final.alternatives[0].confidence == pytest.approx(value)

0 commit comments

Comments
 (0)