feat(translate): language detection, async HTTP LLM, history model, agent improvements

- Add async HTTP-based LLM transport (_llm_async_http.py)
- Add orthogonal LLM call tests
- Improve language detection (_lang_detect.py) and batch insert
- Update translate schemas, service utils, preview constants/prompts
- Add TranslateHistoryModel with pagination and filtering
- Update agent confirmation, persistence, langgraph setup, run, tools
- Improve LLM health checking in shared module
- Update translate runs API, history route
This commit is contained in:
2026-07-08 19:35:49 +03:00
parent 97a64ce056
commit c488a63dc0
30 changed files with 1148 additions and 550 deletions

View File

@@ -17,6 +17,7 @@ from src.plugins.translate._lang_detect import (
get_detector,
detect_language,
_character_block_fallback,
_mark_suspicious_detections_und,
batch_detect,
)
@@ -184,6 +185,39 @@ class TestCharacterBlockFallback:
assert results[0] == "und"
class TestSuspiciousDetections:
"""Suspicious short Cyrillic detections are routed to LLM as und."""
def test_short_cyrillic_uk_for_ru_target_becomes_und(self):
results = _mark_suspicious_detections_und(
["Оплачено 22.06"],
["uk"],
["ru"],
)
assert results == ["und"]
def test_short_cyrillic_it_for_ru_target_becomes_und(self):
results = _mark_suspicious_detections_und(
["Нет"],
["it"],
["ru"],
)
assert results == ["und"]
def test_long_mixed_text_keeps_detection(self):
text = "Contract with KZ Trading for Financing in China Необходимо подтверждение депозита"
results = _mark_suspicious_detections_und([text], ["en"], ["ru"])
assert results == ["en"]
def test_non_ru_target_keeps_detection(self):
results = _mark_suspicious_detections_und(
["Оплачено 22.06"],
["uk"],
["en"],
)
assert results == ["uk"]
class TestBatchDetect:
"""batch_detect."""

View File

@@ -19,7 +19,7 @@ import pytest
@pytest.mark.asyncio
async def test_llm_rate_limit_uses_asyncio_sleep():
"""Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep."""
from src.plugins.translate._llm_async_http import call_openai_compatible
from ss_tools.shared._llm_http import call_openai_compatible
call_count = 0
@@ -30,6 +30,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep():
resp = MagicMock(spec=httpx.Response)
resp.status_code = 429
resp.ok = False
resp.is_success = False
resp.text = "Rate limited"
resp.headers = {"Retry-After": "1"}
resp.json.return_value = {}
@@ -37,6 +38,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep():
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.ok = True
resp.is_success = True
resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}'
resp.json.return_value = {
"choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}]
@@ -46,7 +48,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep():
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mock_post_side_effect)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client):
# Patch asyncio.sleep to verify it's called (not time.sleep)
sleep_calls = []
original_sleep = asyncio.sleep
@@ -55,7 +57,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep():
sleep_calls.append(seconds)
await original_sleep(0) # Don't actually wait
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", side_effect=tracking_sleep):
with patch("ss_tools.shared._llm_http.asyncio.sleep", side_effect=tracking_sleep):
content, finish_reason = await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
@@ -75,7 +77,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep():
@pytest.mark.asyncio
async def test_call_openai_compatible_no_base_url():
"""Verify empty base_url raises ValueError immediately."""
from src.plugins.translate._llm_async_http import call_openai_compatible
from ss_tools.shared._llm_http import call_openai_compatible
with pytest.raises(ValueError, match="no base_url"):
await call_openai_compatible(
@@ -84,6 +86,8 @@ async def test_call_openai_compatible_no_base_url():
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_no_base_url
@@ -93,24 +97,29 @@ async def test_call_openai_compatible_no_base_url():
@pytest.mark.asyncio
async def test_call_openai_compatible_empty_choices():
"""Verify LLM response with empty choices raises ValueError."""
from src.plugins.translate._llm_async_http import call_openai_compatible
from ss_tools.shared._llm_http import call_openai_compatible
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.ok = True
mock_response.is_success = True
mock_response.json.return_value = {"choices": []}
mock_response.text = '{"choices": []}'
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with pytest.raises(ValueError, match="no choices"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="no choices"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_empty_choices
@@ -120,26 +129,31 @@ async def test_call_openai_compatible_empty_choices():
@pytest.mark.asyncio
async def test_call_openai_compatible_refusal():
"""Verify LLM refusal response raises ValueError."""
from src.plugins.translate._llm_async_http import call_openai_compatible
from ss_tools.shared._llm_http import call_openai_compatible
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.ok = True
mock_response.is_success = True
mock_response.json.return_value = {
"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]
}
mock_response.text = '{"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]}'
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with pytest.raises(ValueError, match="refused"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="refused"):
await call_openai_compatible(
base_url="http://fake-llm.local",
api_key="test-key",
model="gpt-4",
prompt="hello",
)
# #endregion test_call_openai_compatible_refusal
@@ -149,7 +163,7 @@ async def test_call_openai_compatible_refusal():
@pytest.mark.asyncio
async def test_retry_does_not_block_event_loop():
"""Verify that 429 retry backoff allows other coroutines to run."""
from src.plugins.translate._llm_async_http import _do_http_request
from ss_tools.shared._llm_http import _do_http_request
concurrent_ran = False
@@ -166,25 +180,24 @@ async def test_retry_does_not_block_event_loop():
if call_count == 1:
resp = MagicMock(spec=httpx.Response)
resp.status_code = 429
resp.ok = False
resp.is_success = False
resp.text = "Rate limited"
resp.headers = {"Retry-After": "1"}
return resp
resp = MagicMock(spec=httpx.Response)
resp.status_code = 200
resp.ok = True
resp.is_success = True
resp.text = "ok"
return resp
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mock_post)
with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client):
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
# Run the HTTP request alongside a concurrent task
http_task = asyncio.create_task(_do_http_request("http://fake", {}, {}))
conc_task = asyncio.create_task(concurrent_task())
await asyncio.gather(http_task, conc_task)
with patch("ss_tools.shared._llm_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
# Run the HTTP request alongside a concurrent task
http_task = asyncio.create_task(_do_http_request(mock_client, "http://fake", {}, {}))
conc_task = asyncio.create_task(concurrent_task())
await asyncio.gather(http_task, conc_task)
assert concurrent_ran, "Concurrent task was blocked by 429 retry"
assert mock_sleep.called, "asyncio.sleep was not called during 429 retry"

View File

@@ -17,9 +17,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from src.plugins.translate._llm_async_http import (
_get_verify,
_get_http_client,
from ss_tools.shared.ssl import httpx_verify, system_ssl_context
from ss_tools.shared._llm_http import get_shared_http_client
from ss_tools.shared._llm_http import (
call_openai_compatible,
_do_http_request,
_handle_response_format_fallback,
@@ -27,34 +27,37 @@ from src.plugins.translate._llm_async_http import (
class TestGetVerify:
"""_get_verify — SSL verification context."""
"""SSL verification context (now from shared module)."""
def test_returns_ssl_context(self):
"""Always returns SSLContext (no env-based disable)."""
result = _get_verify()
"""httpx_verify returns SSLContext (no env-based disable)."""
result = httpx_verify()
assert isinstance(result, ssl.SSLContext)
def test_system_context_not_false(self):
"""system_ssl_context never returns False."""
result = system_ssl_context()
assert result is not False
def test_ignores_llm_ssl_verify_env(self):
"""LLM_SSL_VERIFY env is no longer read — central ssl helper is used."""
with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}):
result = _get_verify()
result = system_ssl_context()
assert isinstance(result, ssl.SSLContext), "must not return False"
class TestGetHttpClient:
"""_get_http_client — module-level singleton."""
"""get_shared_http_client — module-level singleton."""
@pytest.mark.asyncio
async def test_creates_client(self):
def test_creates_client(self):
"""Creates client on first call."""
client = await _get_http_client()
client = get_shared_http_client(timeout=180)
assert isinstance(client, httpx.AsyncClient)
@pytest.mark.asyncio
async def test_reuses_client(self):
def test_reuses_client(self):
"""Returns same client on second call."""
client1 = await _get_http_client()
client2 = await _get_http_client()
client1 = get_shared_http_client(timeout=180)
client2 = get_shared_http_client(timeout=180)
assert client1 is client2
@@ -84,8 +87,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "Hello, world!"}}]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
content, finish_reason = await call_openai_compatible(
"https://api.openai.com",
"sk-test",
@@ -104,8 +107,8 @@ class TestCallOpenaiCompatible:
mock_response.json.return_value = {"choices": []}
mock_response.text = '{"choices": []}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="no choices"):
await call_openai_compatible(
"https://api.openai.com",
@@ -130,8 +133,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": ""}}]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="empty content"):
await call_openai_compatible(
"https://api.openai.com",
@@ -151,8 +154,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [null]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="LLM response processing failed"):
await call_openai_compatible(
"https://api.openai.com",
@@ -177,8 +180,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": null}}]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(ValueError, match="refused"):
await call_openai_compatible(
"https://api.openai.com",
@@ -200,8 +203,8 @@ class TestCallOpenaiCompatible:
)
mock_response.text = "Internal Server Error"
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
with pytest.raises(httpx.HTTPStatusError):
await call_openai_compatible(
"https://api.openai.com",
@@ -222,8 +225,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
# disable_reasoning=False, so response_format should be set
content, _ = await call_openai_compatible(
"https://api.openai.com",
@@ -247,8 +250,8 @@ class TestCallOpenaiCompatible:
}
mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}'
with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()):
with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))):
with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()):
content, _ = await call_openai_compatible(
"https://api.openai.com",
"sk-test",
@@ -262,28 +265,29 @@ class TestCallOpenaiCompatible:
class TestDoHttpRequest:
"""_do_http_request — async HTTP POST with rate-limit retry."""
@pytest.fixture
def mock_client(self):
return AsyncMock(spec=httpx.AsyncClient)
@pytest.mark.asyncio
async def test_success(self):
async def test_success(self, mock_client):
"""Single successful request."""
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.text = "OK"
mock_client.post.return_value = mock_response
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.return_value = mock_response
response, text = await _do_http_request(
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
assert text == "OK"
response, text = await _do_http_request(
mock_client,
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
assert text == "OK"
@pytest.mark.asyncio
async def test_rate_limit_retry(self):
async def test_rate_limit_retry(self, mock_client):
"""429 triggers retry."""
mock_429 = MagicMock(spec=httpx.Response)
mock_429.status_code = 429
@@ -294,23 +298,21 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.side_effect = [mock_429, mock_200]
mock_client.post.side_effect = [mock_429, mock_200]
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()):
response, text = await _do_http_request(
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
assert text == "OK"
assert mock_client.post.call_count == 2
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()):
response, text = await _do_http_request(
mock_client,
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
assert text == "OK"
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_rate_limit_with_retry_after(self):
async def test_rate_limit_with_retry_after(self, mock_client):
"""Retry-After header is respected."""
mock_429 = MagicMock(spec=httpx.Response)
mock_429.status_code = 429
@@ -321,46 +323,41 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.side_effect = [mock_429, mock_200]
mock_client.post.side_effect = [mock_429, mock_200]
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep:
response, _ = await _do_http_request(
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
# Verify sleep was called with retry-after value
mock_sleep.assert_called_once_with(2)
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep:
response, _ = await _do_http_request(
mock_client,
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
mock_sleep.assert_called_once_with(2)
@pytest.mark.asyncio
async def test_rate_limit_exhausted(self):
async def test_rate_limit_exhausted(self, mock_client):
"""All 429 retries exhausted, returns last 429."""
mock_429 = MagicMock(spec=httpx.Response)
mock_429.status_code = 429
mock_429.headers = {}
mock_429.text = "Rate limited"
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.return_value = mock_429 # Always 429
mock_client.post.return_value = mock_429 # Always 429
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()):
response, _ = await _do_http_request(
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 429
assert mock_client.post.call_count == 3 # max 3 retries
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()):
response, _ = await _do_http_request(
mock_client,
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 429
assert mock_client.post.call_count == 3 # max 3 retries
@pytest.mark.asyncio
async def test_rate_limit_invalid_retry_after(self):
"""Retry-After with non-int value falls back to exponential backoff (lines 215-216)."""
async def test_rate_limit_invalid_retry_after(self, mock_client):
"""Retry-After with non-int value falls back to exponential backoff."""
mock_429 = MagicMock(spec=httpx.Response)
mock_429.status_code = 429
mock_429.headers = {"Retry-After": "abc"}
@@ -370,27 +367,29 @@ class TestDoHttpRequest:
mock_200.status_code = 200
mock_200.text = "OK"
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.side_effect = [mock_429, mock_200]
mock_client.post.side_effect = [mock_429, mock_200]
with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep:
response, _ = await _do_http_request(
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
# Should fall back to exponential backoff: 2^1 = 2
mock_sleep.assert_called_once_with(2)
with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep:
response, _ = await _do_http_request(
mock_client,
"https://api.openai.com/chat/completions",
{"Authorization": "Bearer test"},
{"model": "gpt-4o"},
)
assert response.status_code == 200
# Should fall back to exponential backoff: 2^1 = 2
mock_sleep.assert_called_once_with(2)
class TestHandleResponseFormatFallback:
"""_handle_response_format_fallback — structured output fallback."""
@pytest.fixture
def mock_client(self):
return AsyncMock(spec=httpx.AsyncClient)
@pytest.mark.asyncio
async def test_400_response_format_error(self):
async def test_400_response_format_error(self, mock_client):
"""400 error with response_format pattern triggers retry."""
mock_400 = MagicMock(spec=httpx.Response)
mock_400.status_code = 400
@@ -404,27 +403,25 @@ class TestHandleResponseFormatFallback:
mock_200.encoding = "utf-8"
mock_200.headers = {}
mock_client.post.return_value = mock_200
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
mock_client.post.return_value = mock_200
await _handle_response_format_fallback(
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
# Verify response_format was popped
assert "response_format" not in payload
# Verify status_code was updated
assert mock_400.status_code == 200
await _handle_response_format_fallback(
mock_client,
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
# Verify response_format was popped
assert "response_format" not in payload
# Verify status_code was updated
assert mock_400.status_code == 200
@pytest.mark.asyncio
async def test_non_400_noop(self):
async def test_non_400_noop(self, mock_client):
"""Non-400 error does nothing."""
mock_500 = MagicMock(spec=httpx.Response)
mock_500.status_code = 500
@@ -433,24 +430,21 @@ class TestHandleResponseFormatFallback:
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
await _handle_response_format_fallback(
mock_500,
mock_500.text,
payload,
"https://api.openai.com",
{},
)
# Payload unchanged
assert "response_format" in payload
# No retry call
mock_client.post.assert_not_called()
await _handle_response_format_fallback(
mock_client,
mock_500,
mock_500.text,
payload,
"https://api.openai.com",
{},
)
# Payload unchanged
assert "response_format" in payload
# No retry call
mock_client.post.assert_not_called()
@pytest.mark.asyncio
async def test_400_no_pattern_noop(self):
async def test_400_no_pattern_noop(self, mock_client):
"""400 error without response_format pattern does nothing."""
mock_400 = MagicMock(spec=httpx.Response)
mock_400.status_code = 400
@@ -459,19 +453,16 @@ class TestHandleResponseFormatFallback:
payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}}
with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get:
mock_client = AsyncMock()
mock_get.return_value = mock_client
await _handle_response_format_fallback(
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
assert "response_format" in payload
mock_client.post.assert_not_called()
await _handle_response_format_fallback(
mock_client,
mock_400,
mock_400.text,
payload,
"https://api.openai.com",
{},
)
assert "response_format" in payload
mock_client.post.assert_not_called()
# #endregion Test.LLMAsyncHttpClient

View File

@@ -0,0 +1,259 @@
# #region Test.LLMTranslationService.BuildPrompt [C:3] [TYPE Module] [SEMANTICS test, llm, translate, prompt, context]
# @BRIEF Orthogonal verification of _build_prompt context column changes:
# rows_json enrichment, context_hint computation, template dialect/source-language removal.
# @RELATION BINDS_TO -> [LLMTranslationService._build_prompt]
# @TEST_EDGE: no_context_columns -> No 'context' key in rows_json, empty context_hint, no deprecated dialect markers
# @TEST_EDGE: context_columns_present -> 'context' key with source_data values, non-empty context_hint
# @TEST_EDGE: context_columns_missing_source_data -> Graceful fallback with empty string values per column
import json
from unittest.mock import MagicMock
from src.models.translate import TranslationLanguage
from src.models.translate import TranslationJob
from src.plugins.translate._llm_call import LLMTranslationService
from .conftest import JOB_ID
def _make_job(session, context_columns=None, translation_column="title"):
"""Create or update a TranslationJob for _build_prompt testing.
Each test invokes this against the function-scoped db_session fixture,
so job state is isolated per test.
"""
job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
if job is None:
job = TranslationJob(
id=JOB_ID, name="BuildPrompt Test",
source_dialect="en", target_dialect="fr",
status="ACTIVE", translation_column=translation_column,
)
job.context_columns = context_columns or []
session.add(job)
else:
job.context_columns = context_columns or []
job.translation_column = translation_column
session.commit()
return job
def _batch_rows(count=2, *, source_data_present=True):
"""Build batch rows; when source_data_present=False, omit the source_data key entirely."""
rows = []
for i in range(count):
row: dict = {
"row_index": str(i),
"source_text": f"Hello world {i}",
}
if source_data_present:
row["source_data"] = {
"author": f"Author_{i}",
"date": f"2024-01-0{i+1}",
"table": f"tbl_{i}",
}
rows.append(row)
return rows
class TestBuildPromptContextColumns:
"""Orthogonal verification of _build_prompt context column behaviour.
Covers the three orthogonal projections of context column handling:
(1) absent → no enrichment, (2) present → full enrichment,
(3) partially absent source_data → graceful fallback.
"""
# #region test_no_context_columns [C:2] [TYPE Function]
# @BRIEF Without context_columns: rows_json has no 'context' key, context_hint absent, no legacy dialect markers.
def test_no_context_columns(self, db_session):
"""Job has no context_columns → prompt stays lean, template changes verified."""
job = _make_job(db_session, context_columns=[])
rows = _batch_rows(2)
prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"])
# Rows JSON: no "context" key injected
assert '"context"' not in prompt, (
"rows_json must NOT contain 'context' key when context_columns is empty; "
"found the key in rendered prompt"
)
# Context hint: absent from the rendered prompt
assert "Context columns:" not in prompt, (
"context_hint section must be absent when no context_columns configured"
)
# Regression: removed source_dialect / target_dialect / source_language from template
assert "Source dialect" not in prompt, (
"Prompt must NOT contain removed 'Source dialect' marker"
)
assert "Translate from" not in prompt, (
"Prompt must NOT contain removed 'Translate from' marker"
)
# Sanity: row data still present
assert "Hello world 0" in prompt, "Row text must be present"
assert "Hello world 1" in prompt, "Row text must be present"
assert '"detected_source_language": "und"' in prompt, (
"rows_json must carry local detected_source_language for LLM arbitration"
)
assert "Include detected_source_language for EVERY row" in prompt, (
"prompt must require LLM to return detected_source_language"
)
# #endregion test_no_context_columns
# #region test_with_context_columns [C:2] [TYPE Function]
# @BRIEF With context_columns=["author","date"]: rows_json includes 'context' dict with source_data values, context_hint lists columns.
def test_with_context_columns(self, db_session):
"""Job has context_columns=['author','date'] → prompt enriched with context fields."""
job = _make_job(db_session, context_columns=["author", "date"])
rows = _batch_rows(2)
prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"])
# Rows JSON: must contain "context" key with correct source_data values
assert '"context"' in prompt, (
"rows_json must contain 'context' key when context_columns configured"
)
assert '"author": "Author_0"' in prompt, (
"context.author must match source_data value for row 0"
)
assert '"date": "2024-01-01"' in prompt, (
"context.date must match source_data value for row 0"
)
assert '"author": "Author_1"' in prompt, (
"context.author must match source_data value for row 1"
)
# Context hint: must mention the configured column names
assert "Context columns: author, date" in prompt, (
"context_hint must list configured context columns"
)
assert "Consider these context fields" in prompt, (
"context_hint must include guidance phrase about context fields"
)
# #endregion test_with_context_columns
# #region test_context_columns_missing_source_data [C:2] [TYPE Function]
# @BRIEF Context columns set but row has no source_data key → graceful fallback with empty-string values.
def test_context_columns_missing_source_data(self, db_session):
"""When source_data key is entirely absent, context values fall back to empty strings."""
job = _make_job(db_session, context_columns=["author", "date"])
rows = _batch_rows(1, source_data_present=False)
prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"])
# "context" key must still be present (structure is maintained)
assert '"context"' in prompt, (
"rows_json must still include 'context' key when source_data is missing"
)
# Each column falls back to an empty string (not null, not missing)
assert '"author": ""' in prompt, (
"context.author must fallback to empty string when source_data absent"
)
assert '"date": ""' in prompt, (
"context.date must fallback to empty string when source_data absent"
)
# Row text must still be present (basic structure intact)
assert "Hello world 0" in prompt, (
"Row text must be present even when source_data is missing"
)
# table field from _batch_rows(source_data_present=False) should NOT leak
assert '"table"' not in prompt, (
"Only context_columns fields should appear in context, not all source_data fields"
)
# #endregion test_context_columns_missing_source_data
# #region test_context_columns_empty_list [C:2] [TYPE Function]
# @BRIEF Edge case: empty context_columns list (same as None / not set).
def test_context_columns_empty_list(self, db_session):
"""Empty list is treated identically to None — no context enrichment."""
job = _make_job(db_session, context_columns=[])
rows = _batch_rows(1)
prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"])
assert '"context"' not in prompt, (
"Empty context_columns must behave like None — no context key injected"
)
assert "Hello world 0" in prompt, "Row text must be present"
# #endregion test_context_columns_empty_list
# #region test_context_columns_single_column [C:2] [TYPE Function]
# @BRIEF Single context column: correct context dict with exactly one key.
def test_context_columns_single_column(self, db_session):
"""A single context column produces a context dict with one key."""
job = _make_job(db_session, context_columns=["author"])
rows = _batch_rows(1)
prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"])
assert '"context"' in prompt, "Single context column must produce context key"
assert '"author": "Author_0"' in prompt, "Value must match source_data"
assert '"date"' not in prompt, "Unlisted column must not appear in context"
assert "Context columns: author" in prompt, "context_hint must list the single column"
# #endregion test_context_columns_single_column
# #region test_context_columns_still_passes_other_placeholders [C:2] [TYPE Function]
# @BRIEF Context column changes must not break other template placeholders.
def test_context_columns_still_passes_other_placeholders(self, db_session):
"""Verify target_languages, translation_column, and dictionary still render correctly."""
job = _make_job(db_session, context_columns=["author"], translation_column="description")
rows = _batch_rows(1)
prompt = LLMTranslationService._build_prompt(job, rows, "DICT_SECTION\n", ["fr", "de"])
# Target languages
assert "fr, de" in prompt, "target_languages must be present"
# Translation column
assert "description" in prompt, "translation_column must be present"
# Dictionary section
assert "DICT_SECTION" in prompt, "dictionary_section must be present"
# Row count
assert "Return exactly 1 entries" in prompt, "row_count must be correct"
# Context hint and context data still render
assert "Context columns: author" in prompt, "context_hint must appear with context columns"
assert '"context"' in prompt, "context key must appear with context columns"
# #endregion test_context_columns_still_passes_other_placeholders
class TestCreateRecordsLanguageArbitration:
"""Regression tests for LLM-assisted source-language detection."""
# #region test_llm_detected_same_language_creates_skip_marker [C:2] [TYPE Function]
# @BRIEF und local detection + LLM-detected same target creates marker language entry.
def test_llm_detected_same_language_creates_skip_marker(self):
"""When LLM detects ru for target ru, insert stage must skip translation row."""
db = MagicMock()
service = LLMTranslationService(db)
rows = [{
"row_index": "0",
"source_text": "Оплачено 22.06",
"_detected_lang": "und",
"source_data": {"id": "1"},
"_source_hash": "hash",
}]
translations = {
"0": {
"detected_source_language": "ru",
"ru": "Оплачено 22.06",
}
}
result = service._create_records_from_translations(
rows, "run-1", "batch-1", ["ru"], translations, [], 0,
)
assert result["successful"] == 1
language_entries = [
call.args[0] for call in db.add.call_args_list
if isinstance(call.args[0], TranslationLanguage)
]
assert len(language_entries) == 1
assert language_entries[0].language_code == "ru"
assert language_entries[0].source_language_detected == "ru"
assert language_entries[0].final_value == "Оплачено 22.06"
# #endregion test_llm_detected_same_language_creates_skip_marker
# #endregion Test.LLMTranslationService.BuildPrompt

View File

@@ -31,6 +31,7 @@ def mock_job():
job.target_language_column = "lang"
job.target_source_column = "source_text"
job.target_source_language_column = "src_lang"
job.include_source_reference = True
job.context_columns = ["category", "brand"]
job.translation_column = "name"
return job
@@ -322,6 +323,44 @@ class TestBuildRows:
translated_langs = [r["lang"] for r in rows if r["is_original"] == 0]
assert translated_langs == ["ru"]
def test_no_source_reference_skips_src_language_entirely(self, mock_job):
"""No reference row + source language matching target -> no insert rows."""
mock_job.include_source_reference = False
lang_ru = self._make_lang("ru", final="Оплачено 22.06", src_lang="ru")
rec = self._make_record(
source_data={"product_id": 1, "store_id": "S1"},
source_sql="Оплачено 22.06",
languages=[lang_ru],
)
rows = build_rows(
records=[rec],
job=mock_job,
effective_target="translated_name",
primary_language="ru",
context_keys=[],
)
assert rows == []
def test_no_source_reference_keeps_non_source_translation(self, mock_job):
"""No reference row still inserts real target-language translations."""
mock_job.include_source_reference = False
lang_ru = self._make_lang("ru", final="Привет", src_lang="en")
rec = self._make_record(
source_data={"product_id": 1, "store_id": "S1"},
source_sql="Hello",
languages=[lang_ru],
)
rows = build_rows(
records=[rec],
job=mock_job,
effective_target="translated_name",
primary_language="ru",
context_keys=[],
)
assert len(rows) == 1
assert rows[0]["is_original"] == 0
assert rows[0]["lang"] == "ru"
# ── Line 100: final_value vs translated_value fallback ──
def test_translation_uses_final_value_then_translated_value(self, mock_job):