mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
feat(stt): unify language resolution across all STT providers
Class-level fix for the 'STT transcribes the wrong language' issue family (#55551, #50181 and siblings). Previously language handling was per-provider chaos: local honoured stt.local.language, Groq/OpenAI/Mistral/DeepInfra sent no language hint at all, xAI silently forced 'en', ElevenLabs used its own language_code key, and there was no global setting. - New _resolve_stt_language() helper: stt.<provider>.language > stt.language (new global key) > HERMES_LOCAL_STT_LANGUAGE > auto-detect. - Threaded through ALL providers: local, local_command, groq, openai, mistral, xai, elevenlabs, deepinfra (shared OpenAI handler), command providers, and plugin dispatch. - xAI no longer forces English when nothing is configured (auto-detect). - Mistral Voxtral now receives a language hint when configured. - stt.groq.model is now honoured from config (previously env-only). - DEFAULT_CONFIG gains stt.language, stt.groq, stt.xai, stt.mistral.language. - Tests: tests/tools/test_stt_language_resolution.py (11 tests, sabotage- verified) + full transcription suite green (236 passed). Builds on cherry-picked contributor work from #19786 (@zombopanda), #23161 (@materemias), #50684 (@BlackishGreen33).
This commit is contained in:
parent
7e75752516
commit
a10bd49ddd
7 changed files with 207 additions and 39 deletions
|
|
@ -1128,8 +1128,10 @@ stt:
|
|||
model: "base" # tiny | base | small | medium | large-v3 | turbo
|
||||
# language: "" # auto-detect; set to "en", "es", "fr", etc. to force
|
||||
# initial_prompt: "" # Optional faster-whisper prompt, e.g. bias Chinese output to simplified Chinese
|
||||
# language: "" # GLOBAL language hint for every STT provider (per-provider language wins)
|
||||
# groq:
|
||||
# language: "" # blank = use HERMES_LOCAL_STT_LANGUAGE if set, else auto-detect
|
||||
# model: "whisper-large-v3-turbo"
|
||||
# language: "" # blank = stt.language > HERMES_LOCAL_STT_LANGUAGE > auto-detect
|
||||
openai:
|
||||
model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
language: "" # auto-detect; set to "en", "es", "fr", etc. to force
|
||||
|
|
|
|||
1
contributors/emails/materemias@gmail.com
Normal file
1
contributors/emails/materemias@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
materemias
|
||||
1
contributors/emails/zombopanda@gmail.com
Normal file
1
contributors/emails/zombopanda@gmail.com
Normal file
|
|
@ -0,0 +1 @@
|
|||
zombopanda
|
||||
|
|
@ -2322,17 +2322,28 @@ DEFAULT_CONFIG = {
|
|||
# Set false to keep STT for the agent while suppressing that user-facing echo.
|
||||
"echo_transcripts": True,
|
||||
"provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) | "deepinfra"
|
||||
# Global language hint applied to EVERY provider unless a per-provider
|
||||
# language overrides it. Empty = auto-detect. ISO-639-1 ("en", "es", ...).
|
||||
"language": "",
|
||||
"local": {
|
||||
"model": "base", # tiny, base, small, medium, large-v3
|
||||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||||
"initial_prompt": "",
|
||||
},
|
||||
"groq": {
|
||||
"model": "whisper-large-v3-turbo", # whisper-large-v3, whisper-large-v3-turbo, distil-whisper-large-v3-en
|
||||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||||
},
|
||||
"openai": {
|
||||
"model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
|
||||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||||
},
|
||||
"mistral": {
|
||||
"model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602
|
||||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||||
},
|
||||
"xai": {
|
||||
"language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force
|
||||
},
|
||||
"elevenlabs": {
|
||||
"model_id": "scribe_v2", # scribe_v2, scribe_v1
|
||||
|
|
|
|||
123
tests/tools/test_stt_language_resolution.py
Normal file
123
tests/tools/test_stt_language_resolution.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Tests for the unified STT language resolution (_resolve_stt_language).
|
||||
|
||||
Class-level contract: EVERY provider resolves its language hint through one
|
||||
helper with the order:
|
||||
|
||||
stt.<provider>.language > stt.language (global) > HERMES_LOCAL_STT_LANGUAGE > None
|
||||
|
||||
Regression coverage for the "STT transcribes the wrong language" issue class
|
||||
(#55551, #50181 and siblings):
|
||||
- xAI no longer silently forces "en" when nothing is configured
|
||||
- the global ``stt.language`` key reaches every provider
|
||||
- per-provider language still wins over the global key
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.transcription_tools import _resolve_stt_language
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_lang_env(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False)
|
||||
|
||||
|
||||
class TestResolveSttLanguage:
|
||||
def test_provider_language_wins(self):
|
||||
cfg = {"language": "en", "groq": {"language": "he"}}
|
||||
assert _resolve_stt_language("groq", cfg) == "he"
|
||||
|
||||
def test_global_language_fallback(self):
|
||||
cfg = {"language": "hu", "groq": {}}
|
||||
assert _resolve_stt_language("groq", cfg) == "hu"
|
||||
|
||||
def test_global_language_reaches_provider_without_section(self):
|
||||
cfg = {"language": "uk"}
|
||||
for provider in ("local", "groq", "openai", "mistral", "xai", "elevenlabs", "deepinfra"):
|
||||
assert _resolve_stt_language(provider, cfg) == "uk", provider
|
||||
|
||||
def test_env_var_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_LOCAL_STT_LANGUAGE", "de")
|
||||
assert _resolve_stt_language("openai", {}) == "de"
|
||||
|
||||
def test_auto_detect_when_nothing_set(self):
|
||||
assert _resolve_stt_language("xai", {}) is None
|
||||
|
||||
def test_blank_strings_are_skipped(self):
|
||||
cfg = {"language": " ", "groq": {"language": ""}}
|
||||
assert _resolve_stt_language("groq", cfg) is None
|
||||
|
||||
def test_extra_keys_alias(self):
|
||||
cfg = {"elevenlabs": {"language_code": "spa"}}
|
||||
assert _resolve_stt_language("elevenlabs", cfg, extra_keys=("language_code",)) == "spa"
|
||||
|
||||
def test_null_provider_section(self):
|
||||
# YAML `stt.groq: null` must not crash
|
||||
cfg = {"groq": None, "language": "fr"}
|
||||
assert _resolve_stt_language("groq", cfg) == "fr"
|
||||
|
||||
def test_value_is_stripped(self):
|
||||
cfg = {"language": " ja "}
|
||||
assert _resolve_stt_language("local", cfg) == "ja"
|
||||
|
||||
|
||||
class TestXaiNoForcedEnglish:
|
||||
"""xAI previously defaulted language to "en" — auto-detect must be the default."""
|
||||
|
||||
def test_no_language_sent_by_default(self, tmp_path, monkeypatch):
|
||||
import tools.transcription_tools as tt
|
||||
audio = tmp_path / "a.ogg"
|
||||
audio.write_bytes(b"x")
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"text": "hola", "language": "es", "duration": 1.0}
|
||||
|
||||
import requests as _requests
|
||||
|
||||
def fake_post(url, **kwargs):
|
||||
captured["data"] = kwargs.get("data")
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(_requests, "post", fake_post)
|
||||
with patch.object(tt, "_load_stt_config", return_value={}), \
|
||||
patch("tools.xai_http.resolve_xai_http_credentials",
|
||||
return_value={"api_key": "xai-test", "base_url": "https://api.x.ai/v1"}):
|
||||
result = tt._transcribe_xai(str(audio), "grok-stt")
|
||||
|
||||
assert result["success"] is True
|
||||
assert "language" not in (captured["data"] or {}), \
|
||||
"xAI must auto-detect when no language is configured (was forced to 'en')"
|
||||
|
||||
def test_global_language_reaches_xai(self, tmp_path, monkeypatch):
|
||||
import tools.transcription_tools as tt
|
||||
audio = tmp_path / "a.ogg"
|
||||
audio.write_bytes(b"x")
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return {"text": "ok", "language": "he", "duration": 1.0}
|
||||
|
||||
import requests as _requests
|
||||
|
||||
monkeypatch.setattr(
|
||||
_requests, "post",
|
||||
lambda url, **kw: captured.update(data=kw.get("data")) or _Resp(),
|
||||
)
|
||||
with patch.object(tt, "_load_stt_config", return_value={"language": "he"}), \
|
||||
patch("tools.xai_http.resolve_xai_http_credentials",
|
||||
return_value={"api_key": "xai-test", "base_url": "https://api.x.ai/v1"}):
|
||||
result = tt._transcribe_xai(str(audio), "grok-stt")
|
||||
|
||||
assert result["success"] is True
|
||||
assert captured["data"]["language"] == "he"
|
||||
|
|
@ -136,6 +136,38 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
|
|||
return is_truthy_value(enabled, default=True)
|
||||
|
||||
|
||||
def _resolve_stt_language(
|
||||
provider_key: str,
|
||||
stt_config: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
extra_keys: tuple = (),
|
||||
) -> Optional[str]:
|
||||
"""Resolve the language hint for an STT provider (class-level, all providers).
|
||||
|
||||
Resolution order (first non-empty wins):
|
||||
1. ``stt.<provider>.language`` (plus any *extra_keys* aliases, e.g.
|
||||
ElevenLabs' historical ``language_code``)
|
||||
2. ``stt.language`` — global default for every provider
|
||||
3. ``HERMES_LOCAL_STT_LANGUAGE`` env var (legacy escape hatch)
|
||||
4. ``None`` — let the provider auto-detect
|
||||
|
||||
Returns a stripped ISO-639-1-ish code or None. Never returns "".
|
||||
"""
|
||||
if stt_config is None:
|
||||
stt_config = _load_stt_config()
|
||||
provider_cfg = _get_stt_section(stt_config, provider_key)
|
||||
candidates = [provider_cfg.get("language")]
|
||||
for key in extra_keys:
|
||||
candidates.append(provider_cfg.get(key))
|
||||
if isinstance(stt_config, dict):
|
||||
candidates.append(stt_config.get("language"))
|
||||
candidates.append(os.getenv(LOCAL_STT_LANGUAGE_ENV))
|
||||
for candidate in candidates:
|
||||
if isinstance(candidate, str) and candidate.strip():
|
||||
return candidate.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _has_openai_audio_backend() -> bool:
|
||||
"""Return True when OpenAI audio can use config credentials, env credentials, or the managed gateway."""
|
||||
try:
|
||||
|
|
@ -672,7 +704,7 @@ def _transcribe_command_stt(
|
|||
output_format = _get_command_stt_output_format(config)
|
||||
language = (
|
||||
config.get("language")
|
||||
or stt_config.get("language")
|
||||
or _resolve_stt_language(provider_name, stt_config)
|
||||
or DEFAULT_COMMAND_STT_LANGUAGE
|
||||
)
|
||||
model = model_override or config.get("model") or ""
|
||||
|
|
@ -1154,13 +1186,10 @@ def _transcribe_local(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
_local_model = _load_local_whisper_model(model_name)
|
||||
_local_model_name = model_name
|
||||
|
||||
# Language: config.yaml (stt.local.language) > env var > auto-detect.
|
||||
local_config = _load_stt_config().get("local") or {}
|
||||
_forced_lang = (
|
||||
local_config.get("language")
|
||||
or os.getenv(LOCAL_STT_LANGUAGE_ENV)
|
||||
or None
|
||||
)
|
||||
# Language: stt.local.language > stt.language > env var > auto-detect.
|
||||
stt_config = _load_stt_config()
|
||||
local_config = stt_config.get("local") or {}
|
||||
_forced_lang = _resolve_stt_language("local", stt_config)
|
||||
transcribe_kwargs = {"beam_size": 5}
|
||||
if _forced_lang:
|
||||
transcribe_kwargs["language"] = _forced_lang
|
||||
|
|
@ -1241,12 +1270,8 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
|
|||
),
|
||||
}
|
||||
|
||||
# Language: config.yaml (stt.local.language) > env var > "en" default.
|
||||
language = (
|
||||
(_load_stt_config().get("local") or {}).get("language")
|
||||
or os.getenv(LOCAL_STT_LANGUAGE_ENV)
|
||||
or DEFAULT_LOCAL_STT_LANGUAGE
|
||||
)
|
||||
# Language: stt.local.language > stt.language > env var > "en" default.
|
||||
language = _resolve_stt_language("local") or DEFAULT_LOCAL_STT_LANGUAGE
|
||||
normalized_model = _normalize_local_command_model(model_name)
|
||||
|
||||
try:
|
||||
|
|
@ -1309,8 +1334,9 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
"""Transcribe using Groq Whisper API (free tier available).
|
||||
|
||||
Honours an optional ISO-639-1 language hint resolved from
|
||||
``stt.groq.language`` (config.yaml) or ``HERMES_LOCAL_STT_LANGUAGE``
|
||||
(env). When neither is set, Groq Whisper auto-detects.
|
||||
``stt.groq.language`` > ``stt.language`` (config.yaml) >
|
||||
``HERMES_LOCAL_STT_LANGUAGE`` (env). When none is set, Groq
|
||||
Whisper auto-detects.
|
||||
"""
|
||||
api_key = get_env_value("GROQ_API_KEY")
|
||||
if not api_key:
|
||||
|
|
@ -1324,10 +1350,7 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
logger.info("Model %s not available on Groq, using %s", model_name, DEFAULT_GROQ_STT_MODEL)
|
||||
model_name = DEFAULT_GROQ_STT_MODEL
|
||||
|
||||
groq_cfg = _load_stt_config().get("groq") or {}
|
||||
language = str(
|
||||
groq_cfg.get("language") or os.getenv(LOCAL_STT_LANGUAGE_ENV) or ""
|
||||
).strip() or None
|
||||
language = _resolve_stt_language("groq")
|
||||
|
||||
try:
|
||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||
|
|
@ -1395,10 +1418,9 @@ def _transcribe_openai(
|
|||
return {"success": False, "transcript": "", "error": str(exc)}
|
||||
base_url = base_url or fallback_base
|
||||
|
||||
# Language: config.yaml (stt.openai.language) > auto-detect.
|
||||
# Language: stt.<provider>.language > stt.language > env > auto-detect.
|
||||
# Explicit language hint improves accuracy for non-English languages.
|
||||
stt_config = _load_stt_config()
|
||||
language = stt_config.get("openai", {}).get("language")
|
||||
language = _resolve_stt_language(provider_label)
|
||||
|
||||
if not _HAS_OPENAI:
|
||||
return {"success": False, "transcript": "", "error": "openai package not installed"}
|
||||
|
|
@ -1475,10 +1497,15 @@ def _transcribe_mistral(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
|
||||
with Mistral(api_key=api_key) as client:
|
||||
with open(file_path, "rb") as audio_file:
|
||||
result = client.audio.transcriptions.complete(
|
||||
model=model_name,
|
||||
file={"content": audio_file, "file_name": Path(file_path).name},
|
||||
)
|
||||
complete_kwargs: Dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"file": {"content": audio_file, "file_name": Path(file_path).name},
|
||||
}
|
||||
# Language: stt.mistral.language > stt.language > env > auto.
|
||||
language = _resolve_stt_language("mistral")
|
||||
if language:
|
||||
complete_kwargs["language"] = language
|
||||
result = client.audio.transcriptions.complete(**complete_kwargs)
|
||||
|
||||
transcript_text = _extract_transcript_text(result)
|
||||
logger.info(
|
||||
|
|
@ -1525,11 +1552,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
or creds.get("base_url")
|
||||
or XAI_STT_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
language = str(
|
||||
xai_config.get("language")
|
||||
or os.getenv(LOCAL_STT_LANGUAGE_ENV)
|
||||
or DEFAULT_LOCAL_STT_LANGUAGE
|
||||
).strip()
|
||||
language = _resolve_stt_language("xai", stt_config) or ""
|
||||
# .get("format", True) already defaults to True when the key is absent;
|
||||
# is_truthy_value only normalizes truthy/falsy strings from config.
|
||||
use_format = is_truthy_value(xai_config.get("format", True))
|
||||
|
|
@ -1620,7 +1643,9 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]:
|
|||
or get_env_value("ELEVENLABS_STT_BASE_URL")
|
||||
or ELEVENLABS_STT_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
language_code = str(elevenlabs_config.get("language_code") or "").strip()
|
||||
language_code = _resolve_stt_language(
|
||||
"elevenlabs", stt_config, extra_keys=("language_code",)
|
||||
) or ""
|
||||
tag_audio_events = is_truthy_value(elevenlabs_config.get("tag_audio_events", False))
|
||||
diarize = is_truthy_value(elevenlabs_config.get("diarize", False))
|
||||
|
||||
|
|
@ -1797,7 +1822,8 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
|
|||
return _transcribe_local_command(file_path, model_name)
|
||||
|
||||
if provider == "groq":
|
||||
model_name = model or DEFAULT_GROQ_STT_MODEL
|
||||
groq_cfg = stt_config.get("groq") or {}
|
||||
model_name = model or groq_cfg.get("model") or DEFAULT_GROQ_STT_MODEL
|
||||
return _transcribe_groq(file_path, model_name)
|
||||
|
||||
if provider == "openai":
|
||||
|
|
@ -1856,7 +1882,7 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
|
|||
# forwards ``language`` from there. Top-level ``model`` argument
|
||||
# overrides any config-set model.
|
||||
plugin_cfg = stt_config.get(provider, {}) if isinstance(stt_config.get(provider), dict) else {}
|
||||
plugin_language = plugin_cfg.get("language")
|
||||
plugin_language = _resolve_stt_language(provider, stt_config)
|
||||
plugin_model = model or plugin_cfg.get("model")
|
||||
plugin_result = _dispatch_to_plugin_provider(
|
||||
file_path,
|
||||
|
|
|
|||
|
|
@ -1713,17 +1713,21 @@ stt:
|
|||
enabled: true # Auto-transcribe inbound voice messages (default: true)
|
||||
echo_transcripts: true # Post raw transcripts back to the chat as 🎙️ "..." (default: true)
|
||||
provider: "local" # "local" | "groq" | "openai" | "mistral"
|
||||
language: "" # GLOBAL language hint for every provider (ISO-639-1, e.g. "en", "he", "uk"); blank = auto-detect
|
||||
local:
|
||||
model: "base" # tiny, base, small, medium, large-v3
|
||||
language: "" # optional ISO-639-1 hint; blank = use HERMES_LOCAL_STT_LANGUAGE if set, else auto-detect
|
||||
language: "" # per-provider override of stt.language
|
||||
initial_prompt: "" # optional whisper prompt to bias vocabulary/script (e.g. Simplified Chinese)
|
||||
groq:
|
||||
language: "" # optional ISO-639-1 hint; blank = use HERMES_LOCAL_STT_LANGUAGE if set, else auto-detect
|
||||
language: "" # per-provider override of stt.language
|
||||
openai:
|
||||
model: "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
language: "" # auto-detect; set to "en", "es", "fr", etc. to force
|
||||
language: "" # per-provider override of stt.language
|
||||
# model: "whisper-1" # Legacy fallback key still respected
|
||||
```
|
||||
|
||||
Language resolution is the same for **every** STT provider (local, groq, openai, mistral, xai, elevenlabs, deepinfra, command providers, and plugins): `stt.<provider>.language` → `stt.language` → `HERMES_LOCAL_STT_LANGUAGE` env var → provider auto-detect. Setting `stt.language` once fixes the common "my voice notes get transcribed in the wrong language" problem regardless of which provider is active.
|
||||
|
||||
Set `stt.echo_transcripts: false` when the gateway should transcribe voice notes for the agent but must not post the raw transcript back to the chat (for example, customer-facing WhatsApp bots).
|
||||
|
||||
Provider behavior:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue