386 lines
16 KiB
Python
386 lines
16 KiB
Python
# #region Test.BatchProcessingService [C:3] [TYPE Module] [SEMANTICS test, batch, process, classify, llm]
|
|
# @BRIEF Verify BatchProcessingService contracts — process_batch, _classify, _detect_languages, _check_cache, _persist_pre.
|
|
# @RELATION BINDS_TO -> [BatchProcessingService]
|
|
# @TEST_EDGE: empty_batch -> Returns zeros
|
|
# @TEST_EDGE: all_same_language -> No LLM call, all in pre_rows
|
|
# @TEST_EDGE: all_cached -> No LLM call
|
|
# @TEST_EDGE: preview_edits_cache -> Uses approved translation
|
|
# @TEST_EDGE: llm_rows -> Calls LLM service
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.models.translate import (
|
|
TranslationBatch, TranslationJob, TranslationLanguage,
|
|
TranslationRecord,
|
|
)
|
|
from src.plugins.translate._batch_proc import BatchProcessingService
|
|
|
|
from .conftest import JOB_ID, RUN_ID
|
|
|
|
|
|
def _make_job(session, target_langs=None):
|
|
job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
|
|
if job is None:
|
|
job = TranslationJob(
|
|
id=JOB_ID, name="Batch Proc Test", source_dialect="en",
|
|
target_dialect=target_langs[0] if target_langs else "fr",
|
|
target_languages=target_langs or ["fr"],
|
|
status="ACTIVE", provider_id="test-provider",
|
|
)
|
|
session.add(job)
|
|
else:
|
|
if target_langs:
|
|
job.target_languages = target_langs
|
|
job.target_dialect = target_langs[0]
|
|
session.commit()
|
|
return job
|
|
|
|
|
|
def _batch_rows(count=2, detected_lang="en"):
|
|
return [
|
|
{
|
|
"row_index": str(i),
|
|
"source_text": f"Hello world {i}",
|
|
"source_data": {"table": "test", "row_id": i},
|
|
"_detected_lang": detected_lang,
|
|
}
|
|
for i in range(count)
|
|
]
|
|
|
|
|
|
class TestInit:
|
|
"""Verify initialization."""
|
|
|
|
def test_init(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
assert svc.db is db_session
|
|
assert svc.config_manager is config
|
|
assert svc._llm_service is not None
|
|
|
|
|
|
class TestCreateBatch:
|
|
"""Verify _create_batch."""
|
|
|
|
def test_creates_batch_record(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
rows = _batch_rows(3)
|
|
batch = svc._create_batch(run_id, 0, rows)
|
|
assert batch.run_id == run_id
|
|
assert batch.batch_index == 0
|
|
assert batch.total_records == 3
|
|
assert batch.status == "RUNNING"
|
|
fetched = session.query(TranslationBatch).filter(
|
|
TranslationBatch.id == batch.id
|
|
).first()
|
|
assert fetched is not None
|
|
|
|
|
|
class TestDetectLanguages:
|
|
"""Verify _detect_languages."""
|
|
|
|
def test_detects_languages(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = _batch_rows(2)
|
|
with patch('src.plugins.translate._batch_proc.batch_detect',
|
|
return_value=["en", "fr"]):
|
|
svc._detect_languages(rows, ["fr", "de"])
|
|
assert rows[0]["_detected_lang"] == "en"
|
|
assert rows[1]["_detected_lang"] == "fr"
|
|
|
|
|
|
class TestCheckCache:
|
|
"""Verify _check_cache."""
|
|
|
|
def test_cache_miss(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
job = _make_job(db_session)
|
|
rows = _batch_rows(1)
|
|
with patch('src.plugins.translate._batch_proc._check_translation_cache',
|
|
return_value=None):
|
|
svc._check_cache(job, rows, "hash1", "hash2")
|
|
assert "_cached_lang_values" not in rows[0]
|
|
assert "_source_hash" in rows[0]
|
|
|
|
def test_cache_hit(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
job = _make_job(db_session)
|
|
rows = _batch_rows(1)
|
|
with patch('src.plugins.translate._batch_proc._check_translation_cache',
|
|
return_value={"fr": "Bonjour"}):
|
|
svc._check_cache(job, rows, "hash1", "hash2")
|
|
assert rows[0]["_cached_lang_values"] == {"fr": "Bonjour"}
|
|
|
|
def test_approved_translation_skips_cache(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
job = _make_job(db_session)
|
|
rows = [{"row_index": "0", "source_text": "hello", "approved_translation": "Bonjour"}]
|
|
svc._check_cache(job, rows, "hash1", "hash2")
|
|
assert "_cached_lang_values" not in rows[0]
|
|
|
|
|
|
class TestClassify:
|
|
"""Verify _classify rows into llm_rows and pre_rows."""
|
|
|
|
def test_same_language_shortcut(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = _batch_rows(1, detected_lang="fr")
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
|
|
assert len(pre_rows) == 1
|
|
assert len(llm_rows) == 0
|
|
assert rows[0].get("_same_language") is True
|
|
|
|
def test_partial_language_match_still_llm(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = _batch_rows(1, detected_lang="fr")
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr", "de"])
|
|
assert len(pre_rows) == 0
|
|
assert len(llm_rows) == 1
|
|
|
|
def test_approved_translation_pre_row(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello",
|
|
"_detected_lang": "en", "approved_translation": "Bonjour"}]
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
|
|
assert len(pre_rows) == 1
|
|
assert len(llm_rows) == 0
|
|
|
|
def test_cached_values_pre_row(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello",
|
|
"_detected_lang": "en",
|
|
"_cached_lang_values": {"fr": "Bonjour"}}]
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
|
|
assert len(pre_rows) == 1
|
|
assert len(llm_rows) == 0
|
|
|
|
def test_preview_edits_cache(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "en",
|
|
"source_data": {"table": "test", "key": "val"}}]
|
|
preview_cache = {"hash_of_val": {"fr": "Bonjour"}}
|
|
with patch('src.plugins.translate._batch_proc._compute_key_hash',
|
|
return_value="hash_of_val"):
|
|
llm_rows, pre_rows = svc._classify(rows, preview_cache, ["fr"])
|
|
assert len(pre_rows) == 1
|
|
assert len(llm_rows) == 0
|
|
assert rows[0].get("approved_translation") is not None
|
|
|
|
def test_llm_row_default(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = _batch_rows(1, detected_lang="en")
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
|
|
assert len(pre_rows) == 0
|
|
assert len(llm_rows) == 1
|
|
|
|
def test_und_language_goes_to_llm(self, db_session):
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(db_session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello", "_detected_lang": "und"}]
|
|
llm_rows, pre_rows = svc._classify(rows, None, ["fr"])
|
|
assert len(llm_rows) == 1
|
|
|
|
|
|
class TestPersistPre:
|
|
"""Verify _persist_pre."""
|
|
|
|
def test_persist_same_language(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
rows = [{"row_index": "0", "source_text": "Bonjour le monde",
|
|
"_detected_lang": "fr", "_same_language": True}]
|
|
count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"])
|
|
assert count == 1
|
|
records = session.query(TranslationRecord).all()
|
|
assert len(records) == 1
|
|
assert records[0].target_sql == "Bonjour le monde"
|
|
langs = session.query(TranslationLanguage).all()
|
|
lang_codes = [l.language_code for l in langs]
|
|
assert "fr" in lang_codes
|
|
assert "de" not in lang_codes
|
|
|
|
def test_persist_cached_values(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello",
|
|
"_detected_lang": "en",
|
|
"_cached_lang_values": {"fr": "Bonjour", "de": "Hallo"}}]
|
|
count = svc._persist_pre(rows, "batch-1", run_id, ["fr", "de"])
|
|
assert count == 1
|
|
langs = session.query(TranslationLanguage).all()
|
|
lang_dict = {l.language_code: l.final_value for l in langs}
|
|
assert lang_dict["fr"] == "Bonjour"
|
|
assert lang_dict["de"] == "Hallo"
|
|
|
|
def test_persist_approved_translation(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
rows = [{"row_index": "0", "source_text": "hello",
|
|
"_detected_lang": "en",
|
|
"approved_translation": "Bonjour le monde"}]
|
|
count = svc._persist_pre(rows, "batch-1", run_id, ["fr"])
|
|
assert count == 1
|
|
records = session.query(TranslationRecord).all()
|
|
assert records[0].target_sql == "Bonjour le monde"
|
|
|
|
|
|
class TestProcessLlm:
|
|
"""Verify _process_llm."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_llm_calls_service(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session)
|
|
rows = _batch_rows(1)
|
|
|
|
with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv:
|
|
mock_prov = MockProv.return_value
|
|
mock_prov.get_provider_token_config.return_value = {
|
|
"model": "gpt-4", "context_window": 8192, "max_output_tokens": 4096,
|
|
}
|
|
with patch('src.plugins.translate._batch_proc.estimate_token_budget',
|
|
return_value={
|
|
"max_output_needed": 1000, "max_rows_by_output": 10,
|
|
"estimated_input_tokens": 500, "warning": None,
|
|
}):
|
|
with patch.object(svc._llm_service, 'call_llm_for_batch',
|
|
new=AsyncMock(return_value={"successful": 1, "failed": 0,
|
|
"skipped": 0, "retries": 0})):
|
|
result = await svc._process_llm(
|
|
job, run_id, rows, [], "batch-1", ["fr"],
|
|
)
|
|
assert result["successful"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_llm_token_warning(self, db_with_run):
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session)
|
|
rows = _batch_rows(1)
|
|
|
|
with patch('src.plugins.translate._batch_proc.LLMProviderService') as MockProv:
|
|
mock_prov = MockProv.return_value
|
|
mock_prov.get_provider_token_config.return_value = {
|
|
"model": "gpt-4", "context_window": 100, "max_output_tokens": 50,
|
|
}
|
|
with patch('src.plugins.translate._batch_proc.estimate_token_budget',
|
|
return_value={
|
|
"max_output_needed": 2000, "max_rows_by_output": 1,
|
|
"estimated_input_tokens": 5000, "warning": "High token usage",
|
|
}):
|
|
with patch.object(svc._llm_service, 'call_llm_for_batch',
|
|
new=AsyncMock(return_value={"successful": 1, "failed": 0,
|
|
"skipped": 0, "retries": 0})):
|
|
result = await svc._process_llm(
|
|
job, run_id, rows, [], "batch-1", ["fr"],
|
|
)
|
|
assert result["successful"] == 1
|
|
|
|
|
|
class TestProcessBatch:
|
|
"""Verify process_batch orchestration."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_batch_flow(self, db_with_run):
|
|
"""Happy: full process_batch flow with LLM calls."""
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session, target_langs=["fr"])
|
|
rows = _batch_rows(2, detected_lang="en")
|
|
|
|
with patch.object(svc, '_process_llm',
|
|
new=AsyncMock(return_value={"successful": 2, "failed": 0,
|
|
"skipped": 0, "retries": 0})):
|
|
with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch',
|
|
return_value=[]):
|
|
result = await svc.process_batch(
|
|
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
|
|
)
|
|
assert result["successful"] == 2
|
|
assert "batch_id" in result
|
|
batch = session.query(TranslationBatch).filter(
|
|
TranslationBatch.id == result["batch_id"]
|
|
).first()
|
|
assert batch is not None
|
|
assert batch.status == "COMPLETED"
|
|
assert batch.successful_records == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_all_same_language(self, db_with_run):
|
|
"""All rows are same-language — no LLM calls."""
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session, target_langs=["fr"])
|
|
rows = _batch_rows(2, detected_lang="fr")
|
|
|
|
# Mock _detect_languages to preserve pre-set _detected_lang values
|
|
with patch.object(svc, '_detect_languages', side_effect=lambda rows, tls: None):
|
|
result = await svc.process_batch(
|
|
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
|
|
)
|
|
assert result["successful"] == 2
|
|
assert result.get("cache_hits", 0) == 0
|
|
records = session.query(TranslationRecord).all()
|
|
assert len(records) == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_with_errors(self, db_with_run):
|
|
"""Some LLM rows fail."""
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session, target_langs=["fr"])
|
|
rows = _batch_rows(2, detected_lang="en")
|
|
|
|
with patch.object(svc, '_process_llm',
|
|
new=AsyncMock(return_value={"successful": 0, "failed": 2,
|
|
"skipped": 0, "retries": 3})):
|
|
with patch('src.plugins.translate._batch_proc.DictionaryManager.filter_for_batch',
|
|
return_value=[]):
|
|
result = await svc.process_batch(
|
|
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
|
|
)
|
|
assert result["failed"] == 2
|
|
batch = session.query(TranslationBatch).filter(
|
|
TranslationBatch.id == result["batch_id"]
|
|
).first()
|
|
assert batch.status == "COMPLETED_WITH_ERRORS"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_with_cache_hits(self, db_with_run):
|
|
"""Cache hits counted."""
|
|
session, run_id = db_with_run
|
|
config = MagicMock()
|
|
svc = BatchProcessingService(session, config)
|
|
job = _make_job(session, target_langs=["fr"])
|
|
rows = _batch_rows(2, detected_lang="en")
|
|
for r in rows:
|
|
r["_cached_lang_values"] = {"fr": "Bonjour"}
|
|
|
|
result = await svc.process_batch(
|
|
job=job, run_id=run_id, batch_index=0, batch_rows=rows,
|
|
)
|
|
assert result["successful"] == 2
|
|
assert result.get("cache_hits", 0) == 2
|
|
# #endregion Test.BatchProcessingService
|