- 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
260 lines
12 KiB
Python
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
|