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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
259
backend/tests/plugins/translate/test_llm_call_orthogonal.py
Normal file
259
backend/tests/plugins/translate/test_llm_call_orthogonal.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user