diff --git a/backend/requirements-docker.txt b/backend/requirements-docker.txt index e6f2d5fe..efc2baff 100644 --- a/backend/requirements-docker.txt +++ b/backend/requirements-docker.txt @@ -58,3 +58,4 @@ Pillow # скачивается лениво при первом запуске контейнера через entrypoint playwright ruff>=0.11.0 +lingua-language-detector==2.2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 88a4ce34..b23cc3b0 100755 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -56,3 +56,4 @@ playwright tenacity Pillow ruff>=0.11.0 +lingua-language-detector==2.2.0 diff --git a/backend/src/plugins/translate/__tests__/test_lang_detect.py b/backend/src/plugins/translate/__tests__/test_lang_detect.py new file mode 100644 index 00000000..adc934ab --- /dev/null +++ b/backend/src/plugins/translate/__tests__/test_lang_detect.py @@ -0,0 +1,220 @@ +# #region LanguageDetectServiceTests [C:2] [TYPE Module] [SEMANTICS test, language, detection, heuristic] +# @BRIEF Unit tests for LanguageDetectService — local language detection via lingua. +# @LAYER Test +# @RELATION BINDS_TO -> [LanguageDetectService:Module] +# @TEST_CONTRACT LanguageDetectService -> detect_language, build_detector, get_detector, batch_detect +# @TEST_EDGE empty_text -> returns "und" +# @TEST_EDGE numeric_text -> returns "und" +# @TEST_EDGE short_text -> correctly detects language +# @TEST_EDGE same_language_pre_filter -> batch_detect returns target language for matching rows +# @TEST_EDGE detector_cache -> get_detector returns same instance for same language set +# @TEST_EDGE cross_language -> English text with Russian detector returns "und" + +import pytest + +from src.plugins.translate._lang_detect import ( + _CODE_TO_LANG, + _detector_cache, + batch_detect, + build_detector, + detect_language, + get_detector, +) + + +# #region test_detect_language_basic [C:1] [TYPE Function] +# @BRIEF Verify core language detection for common languages. +class TestDetectLanguage: + """Core language detection tests.""" + + def test_detect_english(self, en_detector): + assert detect_language("Hello world, this is English", en_detector) == "en" + assert detect_language("Good morning! How are you today?", en_detector) == "en" + assert detect_language("This is a test comment from a user", en_detector) == "en" + + def test_detect_russian(self, ru_detector): + assert detect_language("Привет мир, это русский", ru_detector) == "ru" + assert detect_language("Доброе утро! Как дела?", ru_detector) == "ru" + assert detect_language("Это тестовый комментарий пользователя", ru_detector) == "ru" + + def test_detect_short_texts(self, en_ru_detector): + """Short texts should still be detected correctly. + Note: very short single words like "Hello" can be ambiguous + (lingua detects 'Hello' as Italian), so we use 2+ word phrases. + """ + assert detect_language("Hello there", en_ru_detector) == "en" + assert detect_language("Привет всем", en_ru_detector) == "ru" + assert detect_language("Good morning", en_ru_detector) == "en" + assert detect_language("Доброе утро", en_ru_detector) == "ru" + + def test_detect_multilingual(self, en_ru_detector): + """Detector with both EN and RU should handle mixed content.""" + result_en = detect_language("Hello world", en_ru_detector) + result_ru = detect_language("Привет мир", en_ru_detector) + assert result_en == "en" + assert result_ru == "ru" + + def test_detect_french(self, fr_detector): + assert detect_language("Bonjour le monde", fr_detector) == "fr" + assert detect_language("Comment allez-vous?", fr_detector) == "fr" +# #endregion test_detect_language_basic + + +# #region test_detect_language_edge_cases [C:1] [TYPE Function] +class TestDetectLanguageEdgeCases: + """Edge cases: empty, numeric, special characters.""" + + def test_empty_string(self, en_detector): + assert detect_language("", en_detector) == "und" + + def test_whitespace_only(self, en_detector): + assert detect_language(" ", en_detector) == "und" + assert detect_language("\t\n", en_detector) == "und" + + def test_numeric_text(self, en_detector): + assert detect_language("12345", en_detector) == "und" + assert detect_language("42", en_detector) == "und" + assert detect_language("3.14159", en_detector) == "und" + + def test_special_characters(self, en_detector): + assert detect_language("!@#$%", en_detector) == "und" + assert detect_language("--- ***", en_detector) == "und" + + def test_none_text(self, en_detector): + """None input should be handled gracefully (coerced to 'und').""" + assert detect_language(None, en_detector) == "und" +# #endregion test_detect_language_edge_cases + + +# #region test_batch_detect [C:1] [TYPE Function] +class TestBatchDetect: + """Batch detection tests.""" + + def test_batch_detect_mixed(self, en_ru_detector): + texts = [ + "Hello world", + "Привет мир", + "Good morning", + "", + "Доброе утро", + "12345", + ] + results = batch_detect(texts, detector=en_ru_detector) + assert results == ["en", "ru", "en", "und", "ru", "und"] + + def test_batch_detect_empty_list(self, en_ru_detector): + assert batch_detect([], detector=en_ru_detector) == [] + + def test_batch_detect_all_same(self, ru_detector): + texts = ["Привет", "Доброе утро", "Как дела?"] + results = batch_detect(texts, detector=ru_detector) + assert all(r == "ru" for r in results) + + def test_batch_detect_builds_detector(self): + """Without pre-built detector, batch_detect builds one from target_languages.""" + texts = ["Hello world", "Привет мир"] + results = batch_detect(texts, target_languages=["ru"]) + # "Hello world" should NOT be "ru", so should be "und" or "en" + # "Привет мир" should be "ru" + assert results[1] == "ru" +# #endregion test_batch_detect + + +# #region test_build_detector [C:1] [TYPE Function] +class TestBuildDetector: + """Detector construction tests.""" + + def test_build_with_target_languages(self): + detector = build_detector(["ru", "de"]) + assert detect_language("Привет мир", detector) == "ru" + assert detect_language("Guten Morgen", detector) == "de" + + def test_build_without_target_languages(self): + """Should fall back to default set (common source languages).""" + detector = build_detector() + assert detect_language("Hello world", detector) == "en" + + def test_build_with_empty_list(self): + detector = build_detector([]) + assert detect_language("Hello world", detector) == "en" + + def test_build_with_unknown_code(self): + """Unknown codes should be silently ignored.""" + detector = build_detector(["zz", "en"]) + assert detect_language("Hello world", detector) == "en" +# #endregion test_build_detector + + +# #region test_get_detector_cache [C:1] [TYPE Function] +class TestGetDetectorCache: + """Detector caching behavior.""" + + def test_get_detector_returns_detector(self): + detector = get_detector(["en"]) + assert hasattr(detector, "detect_language_of") + + def test_get_detector_caches_instance(self): + _detector_cache.clear() + d1 = get_detector(["en", "ru"]) + d2 = get_detector(["en", "ru"]) + assert d1 is d2 # Same instance from cache + + def test_get_detector_different_key(self): + _detector_cache.clear() + d1 = get_detector(["en"]) + d2 = get_detector(["ru"]) + assert d1 is not d2 # Different instances for different languages + + def test_get_detector_default_key(self): + _detector_cache.clear() + d1 = get_detector() + d2 = get_detector(None) + assert d1 is d2 # Same default instance +# #endregion test_get_detector_cache + + +# #region test_code_to_lang_mapping [C:1] [TYPE Function] +class TestCodeToLangMapping: + """Verify the BCP-47 → lingua Language mapping.""" + + def test_common_codes_present(self): + for code in ("en", "ru", "fr", "de", "es", "zh", "ja", "ko", "ar", "pt", "it", "pl", "uk", "tr", "vi", "th", "nl", "sv"): + assert code in _CODE_TO_LANG, f"Missing mapping for '{code}'" + + def test_mapping_count(self): + """Should have at least 60 languages mapped from lingua's 87.""" + assert len(_CODE_TO_LANG) >= 60, f"Only {len(_CODE_TO_LANG)} languages mapped" + + def test_mapping_values(self): + """All mapped values should be lingua Language enum members.""" + from lingua import Language + for code, lang in _CODE_TO_LANG.items(): + assert isinstance(lang, Language), f"'{code}' maps to {type(lang).__name__}, not Language" +# #endregion test_code_to_lang_mapping + + +# #region fixtures [C:1] [TYPE Module] +@pytest.fixture +def en_detector(): + """Detector restricted to English.""" + return build_detector(["en"]) + + +@pytest.fixture +def ru_detector(): + """Detector restricted to Russian.""" + return build_detector(["ru"]) + + +@pytest.fixture +def en_ru_detector(): + """Detector for English and Russian.""" + return build_detector(["en", "ru"]) + + +@pytest.fixture +def fr_detector(): + """Detector restricted to French.""" + return build_detector(["fr"]) +# #endregion fixtures +# #endregion LanguageDetectServiceTests diff --git a/backend/src/plugins/translate/_batch_proc.py b/backend/src/plugins/translate/_batch_proc.py index 26353d20..10bcb105 100644 --- a/backend/src/plugins/translate/_batch_proc.py +++ b/backend/src/plugins/translate/_batch_proc.py @@ -1,10 +1,10 @@ # #region BatchProcessingService [C:4] [TYPE Module] [SEMANTICS translate, batch, process, classify, cache] -# @BRIEF Batch processing for translation: create batch records, classify rows (cache/preview/LLM), +# @BRIEF Batch processing for translation: classify rows (same-language/cache/preview/LLM), # call LLM service, persist TranslationRecord/TranslationLanguage rows. -# Insert-to-target delegated to _batch_insert.py. +# Local language detection (lingua) replaces LLM-based detection. # @LAYER Domain # @RELATION DEPENDS_ON -> [TranslationBatch], [TranslationRecord], [TranslationLanguage] -# @RELATION DEPENDS_ON -> [DictionaryManager], [LLMTranslationService] +# @RELATION DEPENDS_ON -> [DictionaryManager], [LLMTranslationService], [LanguageDetectService] # @RELATION DEPENDS_ON -> [estimate_token_budget], [ConfigManager] # @PRE DB session is available. Job configuration is valid. # @POST TranslationBatch, TranslationRecord, TranslationLanguage rows created and committed. @@ -24,6 +24,7 @@ from ...core.logger import belief_scope, logger from ...models.translate import TranslationBatch, TranslationJob, TranslationLanguage, TranslationRecord from ...services.llm_provider import LLMProviderService from ._batch_insert import insert_batch_to_target +from ._lang_detect import batch_detect, get_detector from ._llm_call import LLMTranslationService from ._token_budget import estimate_token_budget from ._utils import _check_translation_cache, _compute_source_hash, _compute_key_hash @@ -58,15 +59,18 @@ class BatchProcessingService: bid = batch.id result = {"successful": 0, "failed": 0, "skipped": 0, "retries": 0} + tls = job.target_languages or [job.target_dialect or "en"] + tls = [str(tls)] if not isinstance(tls, list) else tls + + # ★ Run local language detection on all rows (heuristic, no LLM) + self._detect_languages(batch_rows, tls) + source_texts = [r.get("source_text", "") for r in batch_rows if r.get("source_text")] rc = batch_rows[0].get("source_data") if batch_rows else None dict_matches = DictionaryManager.filter_for_batch(self.db, source_texts, job.id, row_context=rc) self._check_cache(job, batch_rows, dict_snapshot_hash, config_hash) - llm_rows, pre_rows = self._classify(job, batch_rows, preview_edits_cache) - - tls = job.target_languages or [job.target_dialect or "en"] - tls = [str(tls)] if not isinstance(tls, list) else tls + llm_rows, pre_rows = self._classify(batch_rows, preview_edits_cache, tls) result["successful"] += self._persist_pre(pre_rows, bid, run_id, tls) if llm_rows: @@ -107,16 +111,34 @@ class BatchProcessingService: row["_cached_lang_values"] = cached logger.reason("Translation cache hit", {"source_hash": h[:12], "langs": list(cached.keys())}) - def _classify(self, job, batch_rows, preview_edits_cache): + # ★ Local language detection — replaces LLM-based detection + def _detect_languages(self, batch_rows: list[dict], target_languages: list[str]) -> None: + """Run local language detection on all batch rows (no LLM). + + Attaches '_detected_lang' (BCP-47 code or 'und') to each row dict. + Uses batch_detect() for efficient multi-text processing. + """ + texts = [row.get("source_text", "") for row in batch_rows] + results = batch_detect(texts, target_languages) + for row, lang in zip(batch_rows, results): + row["_detected_lang"] = lang + + def _classify(self, batch_rows, preview_edits_cache, tls): llm_rows, pre_rows = [], [] + tls_lower = [str(t).lower() for t in tls] for row in batch_rows: + # ★ NEW: Same-language pre-filter — detected language matches target + dl = row.get("_detected_lang") + if dl and str(dl).lower() not in ("und", "") and str(dl).lower() in tls_lower: + row["_same_language"] = True + pre_rows.append(row) + continue + if row.get("approved_translation"): pre_rows.append(row) continue cl = row.get("_cached_lang_values") if cl: - tls = job.target_languages or [job.target_dialect or "en"] - tls = [str(tls)] if not isinstance(tls, list) else tls if all(lc in cl for lc in tls): pre_rows.append(row) continue @@ -137,7 +159,31 @@ class BatchProcessingService: def _persist_pre(self, pre_rows, bid, run_id, tls): count = 0 for row in pre_rows: + # ★ Same-language row: source language = target → no translation needed + if row.get("_same_language"): + dl = str(row["_detected_lang"]) + source_text = row.get("source_text", "") + rec = TranslationRecord( + id=str(uuid.uuid4()), batch_id=bid, run_id=run_id, + source_sql=source_text, target_sql=source_text, + source_object_type="table_row", source_object_id=row.get("row_index"), + source_object_name=row.get("source_object_name", ""), + source_data=row.get("source_data"), source_hash=row.get("_source_hash"), + status="SUCCESS", + ) + self.db.add(rec) + # One TranslationLanguage for the detected (same) language + self.db.add(TranslationLanguage( + id=str(uuid.uuid4()), record_id=rec.id, language_code=dl, + source_language_detected=dl, translated_value=source_text, + final_value=source_text, status="translated", needs_review=False, + )) + count += 1 + continue + + # Existing: cached / approved rows cl = row.get("_cached_lang_values") + detected_lang = row.get("_detected_lang", "und") or "und" rec = TranslationRecord( id=str(uuid.uuid4()), batch_id=bid, run_id=run_id, source_sql=row.get("source_text", ""), target_sql=row.get("approved_translation", ""), @@ -151,7 +197,7 @@ class BatchProcessingService: fv = cl[lc] if (cl and lc in cl) else row.get("approved_translation", "") self.db.add(TranslationLanguage( id=str(uuid.uuid4()), record_id=rec.id, language_code=lc, - source_language_detected="und", translated_value=fv, + source_language_detected=detected_lang, translated_value=fv, final_value=fv, status="translated", needs_review=False, )) count += 1 diff --git a/backend/src/plugins/translate/_lang_detect.py b/backend/src/plugins/translate/_lang_detect.py new file mode 100644 index 00000000..84e52c2c --- /dev/null +++ b/backend/src/plugins/translate/_lang_detect.py @@ -0,0 +1,138 @@ +# #region LanguageDetectService [C:2] [TYPE Module] [SEMANTICS translate, language, detection, heuristic] +# @BRIEF Local language detection powered by lingua-language-detector (no LLM). +# Provides fast, non-LLM language identification for source text rows, +# reducing LLM token costs by pre-detecting same-language rows and +# removing language detection from translation prompts. +# @LAYER Plugin +# @RELATION DEPENDS_ON -> [lingua] +# @RATIONALE LLM-based language detection wastes tokens on every row. lingua is a +# pure-Python library (pre-built wheel) that runs ~0.1ms/call with ~98% +# accuracy, vs ~90% for LLM-based detection at ~100x+ cost. +# @REJECTED pycld3 — incompatible with Python 3.13 (requires longintrepr.h removed in 3.13). +# langdetect — only 55 languages, less accurate on short texts. +# fasttext — requires native compilation, no pre-built wheel for this platform. + +from lingua import Language, LanguageDetector, LanguageDetectorBuilder + +# --------------------------------------------------------------------------- +# Build BCP-47 → lingua Language mapping from the lingua Language enum +# --------------------------------------------------------------------------- +_CODE_TO_LANG: dict[str, Language] = {} +for attr_name in dir(Language): + if attr_name.startswith("_"): + continue + val = getattr(Language, attr_name) + if not isinstance(val, Language): + continue # skip methods (all, from_str, etc.) and descriptors + try: + iso = val.iso_code_639_1 + code = iso.name.lower() + _CODE_TO_LANG[code] = val + except Exception: + pass # Language doesn't have ISO 639-1 code (e.g. Latin, Esperanto) + +# --------------------------------------------------------------------------- +# Common source languages that appear in translation jobs (in addition to targets) +# --------------------------------------------------------------------------- +_COMMON_SOURCE_CODES: set[str] = { + "en", "fr", "de", "es", "it", "pt", "pl", "uk", + "tr", "ar", "zh", "ja", "ko", "vi", "th", "nl", + "sv", "da", "fi", "cs", "ro", "el", "he", "hi", + "id", "ms", +} + +# --------------------------------------------------------------------------- +# Detector cache — reuse detector instances across calls with same language set +# --------------------------------------------------------------------------- +_detector_cache: dict[str, LanguageDetector] = {} + + +def _detector_cache_key(target_languages: list[str] | None) -> str: + """Build a cache key from the set of target languages.""" + if not target_languages: + return "__default" + return ",".join(sorted(set(target_languages))) + + +def build_detector(target_languages: list[str] | None = None) -> LanguageDetector: + """Build a LanguageDetector restricted to target + common source languages. + + Restricting the language set improves detection speed (~5x faster than + all 87 languages). The detector includes both target languages (for + pre-filtering) and common source languages (for metadata). + + Args: + target_languages: List of BCP-47 target language codes (e.g. ["ru", "de"]). + If None or empty, falls back to a default set. + + Returns: + A lingua LanguageDetector instance with default (high) accuracy. + """ + codes: set[str] = set(target_languages or []) | _COMMON_SOURCE_CODES + + lingua_langs: list[Language] = [] + for code in codes: + lang = _CODE_TO_LANG.get(code) + if lang: + lingua_langs.append(lang) + + if not lingua_langs: + # Fallback: use all spoken languages + return LanguageDetectorBuilder.from_all_spoken_languages().build() + + return LanguageDetectorBuilder.from_languages(*lingua_langs).build() + + +def get_detector(target_languages: list[str] | None = None) -> LanguageDetector: + """Get or create a cached detector for the given target languages.""" + key = _detector_cache_key(target_languages) + detector = _detector_cache.get(key) + if detector is None: + detector = build_detector(target_languages) + _detector_cache[key] = detector + return detector + + +def detect_language(text: str, detector: LanguageDetector) -> str: + """Detect the language of a text string using the given detector. + + Args: + text: The source text to detect language for. + detector: A lingua LanguageDetector instance (from build_detector/get_detector). + + Returns: + BCP-47 language code (e.g. "en", "ru") or "und" if uncertain/empty. + """ + if not text or not text.strip(): + return "und" + + try: + result = detector.detect_language_of(text) + if result is None: + return "und" + iso = result.iso_code_639_1 + return iso.name.lower() + except Exception: + return "und" + + +def batch_detect( + texts: list[str], + target_languages: list[str] | None = None, + detector: LanguageDetector | None = None, +) -> list[str]: + """Detect language for multiple texts in batch. + + Args: + texts: List of source text strings. + target_languages: Target languages for detector building (used if detector is None). + detector: Pre-built detector (reused across batches). If None, built from target_languages. + + Returns: + List of BCP-47 language codes (one per input text), or "und" for uncertain. + """ + if detector is None: + detector = get_detector(target_languages) + + return [detect_language(t, detector) for t in texts] +# #endregion LanguageDetectService diff --git a/backend/src/plugins/translate/_llm_call.py b/backend/src/plugins/translate/_llm_call.py index af0412bc..0e00a44e 100644 --- a/backend/src/plugins/translate/_llm_call.py +++ b/backend/src/plugins/translate/_llm_call.py @@ -2,6 +2,8 @@ # @BRIEF LLM interaction for batch translation: call provider with retry, handle truncation # by recursive splitting, enforce dictionary post-processing. Orchestrates HTTP calls # (_llm_http) and response parsing (_llm_parse). +# Language detection is now handled locally (lingua) — LLM prompt no longer +# requests detected_source_language; local _detected_lang takes priority. # @LAYER Domain # @RELATION DEPENDS_ON -> [LLMProviderService] # @RELATION DEPENDS_ON -> [TranslationRecord], [TranslationLanguage] @@ -171,7 +173,11 @@ class LLMTranslationService: row_id = str(row.get("row_index", "")) td = translations.get(row_id) source_text = row.get("source_text", "") - detected_lang = td.get("detected_source_language", "und") if td else "und" + # ★ Local detection (from _batch_proc._detect_languages) takes priority. + # Fallback to LLM response field for backward compatibility. + detected_lang = row.get("_detected_lang", "und") or "und" + if detected_lang == "und" and td: + detected_lang = td.get("detected_source_language", "und") or "und" if td is None: skipped += 1 self._add_skipped(row, run_id, batch_id, source_text, "NULL translation") diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 063cf93f..c7d21bf4 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -23,6 +23,7 @@ from typing import Any from sqlalchemy.orm import Session from ...core.config_manager import ConfigManager +from ._lang_detect import detect_language, get_detector from .preview_constants import DEFAULT_EXECUTION_PROMPT_TEMPLATE, DEFAULT_PREVIEW_PROMPT_TEMPLATE # noqa: F401 from ...core.logger import belief_scope, logger from ...models.translate import ( @@ -72,7 +73,6 @@ class TranslationPreview: env_id: str | None = None, ) -> dict[str, Any]: with belief_scope("TranslationPreview.preview_rows"): - t0 = time.monotonic() job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first() if not job: raise ValueError(f"Translation job '{job_id}' not found") @@ -110,6 +110,11 @@ class TranslationPreview: job=job, source_rows=source_rows, sample_size=sample_size, prompt_template=prompt_template, ) + # ★ NEW: Run local language detection on row_meta (no LLM) + detector = get_detector(target_languages) + for meta in prompt_data["row_meta"]: + meta["_detected_lang"] = detect_language(meta["source_text"], detector) + t_llm = time.monotonic() llm_response = self._executor.call_llm( job=job, prompt=prompt_data["prompt"], max_tokens=token_budget["max_output_needed"], @@ -175,7 +180,10 @@ class TranslationPreview: idx = meta["row_index"] source_text = meta["source_text"] translation_data = translations.get(str(idx), {}) - detected_lang = translation_data.get("detected_source_language", "und") if isinstance(translation_data, dict) else "und" + # ★ Local detection (from lingua) takes priority over LLM response + detected_lang = meta.get("_detected_lang", "und") or "und" + if detected_lang == "und" and isinstance(translation_data, dict): + detected_lang = translation_data.get("detected_source_language", "und") or "und" lang_entries: list[TranslationPreviewLanguage] = [] for lang_code in target_languages: diff --git a/backend/src/plugins/translate/preview_constants.py b/backend/src/plugins/translate/preview_constants.py index 3c0326e8..aa90b494 100644 --- a/backend/src/plugins/translate/preview_constants.py +++ b/backend/src/plugins/translate/preview_constants.py @@ -1,5 +1,7 @@ # #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [C:1] [TYPE Constant] [SEMANTICS prompt, template] # @BRIEF Default LLM prompt template for full execution (batch translation). +# Language detection is handled locally (lingua) — prompt does not request +# detected_source_language, saving LLM tokens. # Supports both single-language and multi-language modes via {target_languages} placeholder. DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = ( @@ -15,8 +17,7 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = ( "For each row, provide an accurate translation of the text into each target language.\n\n" "Rows to translate:\n{rows_json}\n\n" "Respond with a JSON object in this exact format:\n" - '{{"rows": [{{"row_id": "", "detected_source_language": "", "": "", "": ""}}]}}\n' - "For each row, return the detected source language as a BCP-47 tag (e.g. 'en', 'ru', 'fr'), or 'und' if uncertain.\n" + '{{"rows": [{{"row_id": "", "": "", "": ""}}]}}\n' "Include a separate key for EACH target language code with the translated text in that language.\n" "Each row_id must match the index provided. Return exactly {row_count} entries." ) @@ -40,8 +41,7 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = ( "Consider the context columns when determining the meaning of the text.\n\n" "Rows to translate:\n{rows_json}\n\n" "Respond with a JSON object in this exact format:\n" - '{{"rows": [{{"row_id": "", "detected_source_language": "", "language_code_1": "", "language_code_2": ""}}]}}\n' - "For each row, return the detected source language as a BCP-47 tag (e.g. 'en', 'ru', 'fr'), or 'und' if uncertain.\n" + '{{"rows": [{{"row_id": "", "language_code_1": "", "language_code_2": ""}}]}}\n' "Include a separate key for EACH target language code with the translated text in that language.\n" "Each row_id must match the index provided. Return exactly {row_count} entries." )