Files
ss-tools/backend/tests/plugins/translate/test_llm_call_orthogonal.py
busya c488a63dc0 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
2026-07-08 19:35:49 +03:00

260 lines
12 KiB
Python

# #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