Files
audiobook/backend/test_ollama_service.py
Paul Bailey 73dc5695ca Improve Ollama speaker detection with multi-sample strategy
- Sample up to 4 chunks of 5000 characters from different text positions
- Distribute samples evenly across beginning, middle, and end of text
- Deduplicate speakers across all samples for comprehensive detection
- Log individual sample results for debugging
- Handle short texts efficiently (single sample)
- Updated tests to verify multi-sample behavior

This ensures speakers introduced anywhere in long texts are detected,
not just those appearing in the first 2000 characters.
2025-12-09 10:40:59 +00:00

238 lines
7.7 KiB
Python

"""
Tests for Ollama integration service.
"""
import pytest
import json
from unittest.mock import AsyncMock, patch, MagicMock
from services.ollama_service import OllamaService
@pytest.mark.asyncio
async def test_ollama_service_initialization():
"""Test OllamaService initializes with correct defaults."""
service = OllamaService()
assert service.base_url == "http://localhost:11434"
assert service.model == "llama3:8b"
assert service.timeout == 30.0
@pytest.mark.asyncio
async def test_ollama_service_custom_base_url():
"""Test OllamaService accepts custom base URL."""
custom_url = "http://custom-ollama:11434"
service = OllamaService(base_url=custom_url)
assert service.base_url == custom_url
@pytest.mark.asyncio
async def test_sample_text_short():
"""Test sampling short text returns full text."""
service = OllamaService()
text = "Short text"
samples = service._sample_text(text, chunk_size=5000, num_samples=4)
assert len(samples) == 1
assert samples[0] == text
@pytest.mark.asyncio
async def test_sample_text_long():
"""Test sampling long text returns multiple samples."""
service = OllamaService()
# Create text with position markers so we can verify different samples
text = "".join([f"Position{i:05d} " for i in range(2500)]) # ~25k characters
samples = service._sample_text(text, chunk_size=5000, num_samples=4)
assert len(samples) == 4
assert all(len(s) <= 5000 for s in samples)
# Samples should be from different parts - check they contain different position markers
assert "Position00000" in samples[0] # First sample should have early positions
assert "Position00000" not in samples[-1] # Last sample should have later positions
@pytest.mark.asyncio
async def test_sample_text_medium():
"""Test sampling medium text returns appropriate number of samples."""
service = OllamaService()
text = "B" * 8000 # 8k characters
samples = service._sample_text(text, chunk_size=5000, num_samples=4)
# Should get 2 samples (8000 / 5000 = 1.6, so 2 samples)
assert len(samples) >= 1
assert all(len(s) <= 5000 for s in samples)
@pytest.mark.asyncio
async def test_build_prompt():
"""Test prompt builder creates correct format."""
service = OllamaService()
text = "Alice: Hello there!\nBob: Hi Alice!"
prompt = service._build_prompt(text)
assert "speakers" in prompt
assert "JSON" in prompt
assert text in prompt
@pytest.mark.asyncio
async def test_parse_response_with_speakers_key():
"""Test parsing JSON response with 'speakers' key."""
service = OllamaService()
response = '{"speakers": ["Alice", "Bob", "Narrator"]}'
speakers = service._parse_response(response)
assert speakers == ["Alice", "Bob", "Narrator"]
@pytest.mark.asyncio
async def test_parse_response_with_characters_key():
"""Test parsing JSON response with 'characters' key."""
service = OllamaService()
response = '{"characters": ["Alice", "Bob"]}'
speakers = service._parse_response(response)
assert speakers == ["Alice", "Bob"]
@pytest.mark.asyncio
async def test_parse_response_with_roles_key():
"""Test parsing JSON response with 'roles' key."""
service = OllamaService()
response = '{"roles": ["Narrator", "Alice"]}'
speakers = service._parse_response(response)
assert speakers == ["Narrator", "Alice"]
@pytest.mark.asyncio
async def test_parse_response_with_array():
"""Test parsing JSON response that is a direct array."""
service = OllamaService()
response = '["Alice", "Bob"]'
speakers = service._parse_response(response)
assert speakers == ["Alice", "Bob"]
@pytest.mark.asyncio
async def test_parse_response_empty():
"""Test parsing empty response."""
service = OllamaService()
speakers = service._parse_response("")
assert speakers == []
@pytest.mark.asyncio
async def test_parse_response_invalid_json():
"""Test parsing invalid JSON returns empty list."""
service = OllamaService()
response = "This is not JSON"
speakers = service._parse_response(response)
assert speakers == []
@pytest.mark.asyncio
async def test_detect_speakers_success():
"""Test successful speaker detection with short text."""
service = OllamaService()
text = "Alice: Hello!\nBob: Hi there!"
mock_response = MagicMock()
mock_response.json.return_value = {
"response": '{"speakers": ["Alice", "Bob"]}'
}
mock_response.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock(return_value=mock_response)
speakers = await service.detect_speakers(text)
assert set(speakers) == {"Alice", "Bob"}
@pytest.mark.asyncio
async def test_detect_speakers_multiple_samples():
"""Test speaker detection with long text requiring multiple samples."""
service = OllamaService()
text = "A" * 25000 # Long text requiring multiple samples
# Mock responses for different samples
responses = [
MagicMock(json=lambda: {"response": '{"speakers": ["Alice", "Bob"]}'}),
MagicMock(json=lambda: {"response": '{"speakers": ["Charlie", "Bob"]}'}),
MagicMock(json=lambda: {"response": '{"speakers": ["Alice", "David"]}'}),
MagicMock(json=lambda: {"response": '{"speakers": ["Eve"]}'}),
]
for r in responses:
r.raise_for_status = MagicMock()
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.post = AsyncMock(side_effect=responses)
speakers = await service.detect_speakers(text)
# Should deduplicate across all samples
assert set(speakers) == {"Alice", "Bob", "Charlie", "David", "Eve"}
@pytest.mark.asyncio
async def test_detect_speakers_timeout():
"""Test speaker detection handles timeout."""
service = OllamaService()
text = "Some text"
with patch('httpx.AsyncClient') as mock_client:
import httpx
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
side_effect=httpx.TimeoutException("Timeout")
)
with pytest.raises(Exception, match="timed out"):
await service.detect_speakers(text)
@pytest.mark.asyncio
async def test_detect_speakers_http_error():
"""Test speaker detection handles HTTP errors."""
service = OllamaService()
text = "Some text"
with patch('httpx.AsyncClient') as mock_client:
import httpx
mock_client.return_value.__aenter__.return_value.post = AsyncMock(
side_effect=httpx.HTTPError("Connection failed")
)
with pytest.raises(Exception, match="Ollama service error"):
await service.detect_speakers(text)
@pytest.mark.asyncio
async def test_is_available_success():
"""Test is_available returns True when service responds."""
service = OllamaService()
mock_response = MagicMock()
mock_response.status_code = 200
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
available = await service.is_available()
assert available is True
@pytest.mark.asyncio
async def test_is_available_failure():
"""Test is_available returns False when service is down."""
service = OllamaService()
with patch('httpx.AsyncClient') as mock_client:
import httpx
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=httpx.ConnectError("Connection refused")
)
available = await service.is_available()
assert available is False