Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,13 @@ def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
for line in f:
line = line.strip()
if line:
messages.append(json.loads(line))
try:
messages.append(json.loads(line))
except json.JSONDecodeError:
logger.warning(
"Skipping corrupt line in transcript %s: %s",
session_id, line[:120],
)

return messages

Expand Down
50 changes: 50 additions & 0 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,56 @@ def test_rewrite_with_empty_list(self, store):
assert reloaded == []


class TestLoadTranscriptCorruptLines:
"""Regression: corrupt JSONL lines (e.g. from mid-write crash) must be
skipped instead of crashing the entire transcript load. GH-1193."""

@pytest.fixture()
def store(self, tmp_path):
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None
s._loaded = True
return s

def test_corrupt_line_skipped(self, store, tmp_path):
session_id = "corrupt_test"
transcript_path = store.get_transcript_path(session_id)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
with open(transcript_path, "w") as f:
f.write('{"role": "user", "content": "hello"}\n')
f.write('{"role": "assistant", "content": "hi th') # truncated
f.write("\n")
f.write('{"role": "user", "content": "goodbye"}\n')

messages = store.load_transcript(session_id)
assert len(messages) == 2
assert messages[0]["content"] == "hello"
assert messages[1]["content"] == "goodbye"

def test_all_lines_corrupt_returns_empty(self, store, tmp_path):
session_id = "all_corrupt"
transcript_path = store.get_transcript_path(session_id)
transcript_path.parent.mkdir(parents=True, exist_ok=True)
with open(transcript_path, "w") as f:
f.write("not json at all\n")
f.write("{truncated\n")

messages = store.load_transcript(session_id)
assert messages == []

def test_valid_transcript_unaffected(self, store, tmp_path):
session_id = "valid_test"
store.append_to_transcript(session_id, {"role": "user", "content": "a"})
store.append_to_transcript(session_id, {"role": "assistant", "content": "b"})

messages = store.load_transcript(session_id)
assert len(messages) == 2
assert messages[0]["content"] == "a"
assert messages[1]["content"] == "b"


class TestWhatsAppDMSessionKeyConsistency:
"""Regression: all session-key construction must go through build_session_key
so DMs are isolated by chat_id across platforms."""
Expand Down
10 changes: 6 additions & 4 deletions tests/tools/test_transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ def test_local_when_available(self):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "local"

def test_local_fallback_to_openai(self, monkeypatch):
def test_explicit_local_no_cloud_fallback(self, monkeypatch):
"""Explicit local provider must not silently fall back to cloud."""
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "openai"
assert _get_provider({"provider": "local"}) == "none"

def test_local_nothing_available(self, monkeypatch):
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
Expand All @@ -47,12 +48,13 @@ def test_openai_when_key_set(self, monkeypatch):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "openai"}) == "openai"

def test_openai_fallback_to_local(self, monkeypatch):
def test_explicit_openai_no_key_returns_none(self, monkeypatch):
"""Explicit openai without key returns none — no cross-provider fallback."""
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "openai"}) == "local"
assert _get_provider({"provider": "openai"}) == "none"

def test_default_provider_is_local(self):
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
Expand Down
138 changes: 88 additions & 50 deletions tests/tools/test_transcription_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,12 @@ def test_groq_when_key_set(self, monkeypatch):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "groq"}) == "groq"

def test_groq_fallback_to_local(self, monkeypatch):
def test_groq_explicit_no_fallback(self, monkeypatch):
"""Explicit groq with no key returns none — no cross-provider fallback."""
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "groq"}) == "local"

def test_groq_fallback_to_openai(self, monkeypatch):
monkeypatch.delenv("GROQ_API_KEY", raising=False)
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "groq"}) == "openai"
assert _get_provider({"provider": "groq"}) == "none"

def test_groq_nothing_available(self, monkeypatch):
monkeypatch.delenv("GROQ_API_KEY", raising=False)
Expand All @@ -90,36 +83,25 @@ def test_groq_nothing_available(self, monkeypatch):


class TestGetProviderFallbackPriority:
"""Cross-provider fallback priority tests."""

def test_local_fallback_prefers_groq_over_openai(self, monkeypatch):
"""When local unavailable, groq (free) is preferred over openai (paid)."""
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "groq"
"""Auto-detect fallback priority and explicit provider behaviour."""

def test_local_fallback_to_groq_only(self, monkeypatch):
"""When only groq key available, falls back to groq."""
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
def test_auto_detect_prefers_local(self):
"""Auto-detect prefers local over any cloud provider."""
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "local"}) == "groq"
assert _get_provider({}) == "local"

def test_openai_fallback_to_groq(self, monkeypatch):
"""When openai key missing but groq available, falls back to groq."""
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
def test_auto_detect_prefers_groq_over_openai(self, monkeypatch):
"""Auto-detect: groq (free) is preferred over openai (paid)."""
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "openai"}) == "groq"
assert _get_provider({}) == "groq"

def test_openai_nothing_available(self, monkeypatch):
"""When no openai key and no local, returns none."""
def test_explicit_openai_no_key_returns_none(self, monkeypatch):
"""Explicit openai with no key returns none — no cross-provider fallback."""
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
Expand All @@ -136,18 +118,83 @@ def test_empty_config_defaults_to_local(self):
from tools.transcription_tools import _get_provider
assert _get_provider({}) == "local"

def test_openai_fallback_to_local_command(self, monkeypatch):
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

# ============================================================================
# Explicit provider config respected (GH-1774)
# ============================================================================

class TestExplicitProviderRespected:
"""When stt.provider is explicitly set, that choice is authoritative.
No silent fallback to a different cloud provider."""

def test_explicit_local_no_fallback_to_openai(self, monkeypatch):
"""GH-1774: provider=local must not silently fall back to openai
even when an OpenAI API key is set."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key-here")
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "local"})
assert result == "none", f"Expected 'none' but got {result!r}"

def test_explicit_local_no_fallback_to_groq(self, monkeypatch):
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "local"})
assert result == "none"

def test_explicit_local_uses_local_command_fallback(self, monkeypatch):
"""Local-to-local_command fallback is fine — both are local."""
monkeypatch.setenv(
"HERMES_LOCAL_STT_COMMAND",
"whisper {input_path} --output_dir {output_dir} --language {language}",
)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "local"})
assert result == "local_command"

def test_explicit_groq_no_fallback_to_openai(self, monkeypatch):
monkeypatch.delenv("GROQ_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "groq"})
assert result == "none"

def test_explicit_openai_no_fallback_to_groq(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
result = _get_provider({"provider": "openai"})
assert result == "none"

def test_auto_detect_still_falls_back_to_cloud(self, monkeypatch):
"""When no provider is explicitly set, auto-detect cloud fallback works."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
monkeypatch.delenv("GROQ_API_KEY", raising=False)
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
# Empty dict = no explicit provider, uses DEFAULT_PROVIDER auto-detect
result = _get_provider({})
assert result == "openai"

def test_auto_detect_prefers_groq_over_openai(self, monkeypatch):
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import _get_provider
assert _get_provider({"provider": "openai"}) == "local_command"
result = _get_provider({})
assert result == "groq"


# ============================================================================
Expand Down Expand Up @@ -686,28 +733,19 @@ def test_no_provider_returns_error(self, sample_ogg):
assert "faster-whisper" in result["error"]
assert "GROQ_API_KEY" in result["error"]

def test_openai_provider_falls_back_to_local_command(self, monkeypatch, sample_ogg):
def test_explicit_openai_no_key_returns_error(self, monkeypatch, sample_ogg):
"""Explicit provider=openai with no key returns an error, not a fallback."""
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv(
"HERMES_LOCAL_STT_COMMAND",
"whisper {input_path} --model {model} --output_dir {output_dir} --language {language}",
)

with patch("tools.transcription_tools._load_stt_config", return_value={"provider": "openai"}), \
patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
patch("tools.transcription_tools._HAS_OPENAI", True), \
patch("tools.transcription_tools._transcribe_local_command", return_value={
"success": True,
"transcript": "hello from fallback",
"provider": "local_command",
}) as mock_local_command:
patch("tools.transcription_tools._HAS_OPENAI", True):
from tools.transcription_tools import transcribe_audio
result = transcribe_audio(sample_ogg)

assert result["success"] is True
assert result["transcript"] == "hello from fallback"
mock_local_command.assert_called_once_with(sample_ogg, "base")
assert result["success"] is False
assert "No STT provider" in result["error"]

def test_invalid_file_short_circuits(self):
from tools.transcription_tools import transcribe_audio
Expand Down
Loading
Loading