From b466ac62114d8a1a413551ad9eaf2b82d1b1962f Mon Sep 17 00:00:00 2001 From: busya Date: Sat, 16 May 2026 00:42:07 +0300 Subject: [PATCH] feat(translate): add translation cache with source_hash dedup + enforce dictionary post-processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add source_hash column (SHA256 of source_text+source_data+dict_hash+config_hash) to TranslationRecord for cache dedup - Add Alembic migration b1c2d3e4f5a6 for the new column and composite index (source_hash, status) - Implement _compute_source_hash / _check_translation_cache in executor.py — before LLM call, check if same source+dict+config combo was already translated successfully - Implement cache-aware routing in _process_batch: full cache hit → skip LLM, partial hit → route to LLM - Store source_hash on all new TranslationRecord rows for future cache hits - Strengthen dictionary prompt instructions from 'use when applicable' to 'MUST use — mandatory' - Add _enforce_dictionary() post-processing: force-replace dictionary terms in LLM output if LLM ignored them - Fix CRITICAL: use run.dict_snapshot_hash/config_hash (exist on TranslationRun, not TranslationJob) - Fix MAJOR: verify cached languages cover ALL target_languages before accepting cache hit - Fix MAJOR: prevent regex back-reference injection in dictionary enforcement (use lambda) - Fix: add joinedload to cache lookup to avoid N+1 queries - Fix: remove redundant single-column index (composite index is sufficient) - Fix(frontend): parse paginated /translate/dictionaries response correctly (result.items) --- backend/alembic/env.py | 5 + ..._add_source_hash_to_translation_records.py | 60 ++++++ backend/src/models/translate.py | 3 + backend/src/plugins/translate/executor.py | 193 +++++++++++++++++- backend/src/plugins/translate/preview.py | 8 + .../lib/components/layout/Breadcrumbs.svelte | 5 + .../translate/ScheduleConfig.svelte | 18 +- .../translate/TranslationPreview.svelte | 48 ++++- .../translate/TranslationRunResult.svelte | 86 +++----- .../src/lib/i18n/locales/en/translate.json | 22 +- .../src/lib/i18n/locales/ru/translate.json | 21 +- .../src/routes/translate/[id]/+page.svelte | 102 +++++---- 12 files changed, 458 insertions(+), 113 deletions(-) create mode 100644 backend/alembic/versions/b1c2d3e4f5a6_add_source_hash_to_translation_records.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 40fd0091..21cb57b8 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,3 +1,4 @@ +import os import sys from logging.config import fileConfig from pathlib import Path @@ -14,6 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # access to the values within the .ini file in use. config = context.config +# Allow overriding sqlalchemy.url via DATABASE_URL env var +if "DATABASE_URL" in os.environ: + config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"]) + # Interpret the config file for Python logging. # This line sets up loggers basically. if config.config_file_name is not None: diff --git a/backend/alembic/versions/b1c2d3e4f5a6_add_source_hash_to_translation_records.py b/backend/alembic/versions/b1c2d3e4f5a6_add_source_hash_to_translation_records.py new file mode 100644 index 00000000..aa606cf3 --- /dev/null +++ b/backend/alembic/versions/b1c2d3e4f5a6_add_source_hash_to_translation_records.py @@ -0,0 +1,60 @@ +"""Add source_hash column to translation_records for cache dedup + +Adds source_hash (SHA256 of source_text + source_data + dict/config hashes) +enabling cache-hit lookups: if the same source row + dictionary + config +combination has already been successfully translated, the LLM call is skipped. + +Revision ID: b1c2d3e4f5a6 +Revises: aa1b2c3d4e5f +Create Date: 2026-05-15 23:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b1c2d3e4f5a6" +down_revision: str | Sequence[str] | None = "aa1b2c3d4e5f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add source_hash column with indices.""" + bind = op.get_bind() + if bind.engine.name == "sqlite": + # SQLite — check existence first + inspector = sa.inspect(bind) + columns = [c["name"] for c in inspector.get_columns("translation_records")] + if "source_hash" not in columns: + op.add_column("translation_records", + sa.Column("source_hash", sa.String(), nullable=True, + comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup")) + op.create_index("ix_translation_records_source_hash_status", + "translation_records", ["source_hash", "status"]) + else: + # PostgreSQL and others + op.add_column("translation_records", + sa.Column("source_hash", sa.String(), nullable=True, + comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup")) + op.create_index("ix_translation_records_source_hash_status", + "translation_records", ["source_hash", "status"]) + + +def downgrade() -> None: + """Drop source_hash column and index.""" + bind = op.get_bind() + if bind.engine.name == "sqlite": + inspector = sa.inspect(bind) + columns = [c["name"] for c in inspector.get_columns("translation_records")] + if "source_hash" in columns: + op.drop_index("ix_translation_records_source_hash_status", + table_name="translation_records") + op.drop_column("translation_records", "source_hash") + else: + op.drop_index("ix_translation_records_source_hash_status", + table_name="translation_records") + op.drop_column("translation_records", "source_hash") diff --git a/backend/src/models/translate.py b/backend/src/models/translate.py index 92cd33d4..c71e4c18 100644 --- a/backend/src/models/translate.py +++ b/backend/src/models/translate.py @@ -132,12 +132,15 @@ class TranslationRecord(Base): token_count_input = Column(Integer, nullable=True) token_count_output = Column(Integer, nullable=True) translation_duration_ms = Column(Integer, nullable=True) + source_hash = Column(String, nullable=True, + comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup") created_at = Column(DateTime, default=lambda: datetime.now(UTC)) languages = relationship("TranslationLanguage", back_populates="record") __table_args__ = ( Index("ix_translation_records_run_status", "run_id", "status"), + Index("ix_translation_records_source_hash_status", "source_hash", "status"), ) # #endregion TranslationRecord diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index 5c55e55d..a4e4d86d 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -15,7 +15,9 @@ # @RATIONALE: Batch processing with retry — independent batches allow partial recovery. # @REJECTED: Single monolithic LLM call — would lose all progress on any failure. +import hashlib import json +import re import time import uuid from collections.abc import Callable @@ -56,6 +58,127 @@ MAX_RETRIES_PER_BATCH = 3 MAX_ROWS_PER_RUN = 10000 # #endregion MAX_ROWS_PER_RUN +# #region _enforce_dictionary [TYPE Function] +# @BRIEF Post-processing: enforce dictionary entries in LLM output. +# If a source term from the dictionary appears in the original text, +# but the target term is missing from the translation, replace occurrences +# of the source term in the translated output with the target term. +# @PRE: dict_matches is a list of dict entries with source_term/target_term. +# @POST: per_lang_values may be mutated to include forced dictionary replacements. +# @SIDE_EFFECT: Logs when enforcement is applied. +def _enforce_dictionary( + source_text: str, + per_lang_values: dict[str, str], + dict_matches: list[dict[str, Any]], + batch_id: str, + row_id: str, +) -> None: + """Post-processing: enforce dictionary terms in LLM translation output. + + For each dictionary entry whose source_term appears in the source_text, + verify that the target_term is present in each language's translation. + If missing, replace any occurrence of the source term (which the LLM + may have left untranslated) with the dictionary target term. + """ + if not dict_matches or not source_text: + return + + text_lower = source_text.lower() + + for dm in dict_matches: + src_term = dm.get("source_term", "") + tgt_term = dm.get("target_term", "") + if not src_term or not tgt_term: + continue + + # Only process if source term actually appears in the original text + if src_term.lower() not in text_lower: + continue + + # Try to replace in each language + for lang_code in list(per_lang_values.keys()): + val = per_lang_values[lang_code] + if not val: + continue + + # Check if target term is already present (case-insensitive) + if tgt_term.lower() in val.lower(): + continue + + # Try to find the source term (or a close variant) in the output + # This catches cases where the LLM left the source term untranslated + # Use lambda to prevent regex back-reference injection from tgt_term + src_pattern = re.compile(re.escape(src_term), re.IGNORECASE) + if src_pattern.search(val): + new_val = src_pattern.sub(lambda _: tgt_term, val) + if new_val != val: + logger.reason("Dictionary enforcement applied", { + "batch_id": batch_id, + "row_id": row_id, + "language_code": lang_code, + "source_term": src_term, + "target_term": tgt_term, + "before": val[:200], + "after": new_val[:200], + }) + per_lang_values[lang_code] = new_val +# #endregion _enforce_dictionary + + +# #region _compute_source_hash [TYPE Function] +# @BRIEF Compute deterministic cache key for a source row. +# SHA256 of (source_text + source_data + dict_config_ctx) so that changing +# the text, context, dictionary snapshot, or job config produces a new key. +def _compute_source_hash( + source_text: str, + source_data: dict | None, + dict_snapshot_hash: str | None, + config_hash: str | None, +) -> str: + """Deterministic cache key for a translation source row.""" + payload = json.dumps({ + "text": source_text, + "data": source_data, + "dict_hash": dict_snapshot_hash or "", + "config_hash": config_hash or "", + }, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() +# #endregion _compute_source_hash + + +# #region _check_translation_cache [TYPE Function] +# @BRIEF Look up a previously successful translation by source_hash. +# Returns per-language dict {lang_code: final_value} or None. +def _check_translation_cache( + db: Session, + source_hash: str, +) -> dict[str, str] | None: + """Check if this source_hash was already translated successfully.""" + from sqlalchemy.orm import joinedload + from ...models.translate import TranslationRecord, TranslationLanguage + + cached = ( + db.query(TranslationRecord) + .options(joinedload(TranslationRecord.languages)) + .filter( + TranslationRecord.source_hash == source_hash, + TranslationRecord.status == "SUCCESS", + ) + .order_by(TranslationRecord.created_at.desc()) + .first() + ) + if not cached: + return None + + lang_values: dict[str, str] = {} + for lang in cached.languages: + if lang.status == "translated" and lang.final_value: + lang_values[lang.language_code] = lang.final_value + + return lang_values if lang_values else None +# #endregion _check_translation_cache + + # #region TranslationExecutor [C:4] [TYPE Class] # @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results. # @PRE: DB session and config manager available. @@ -161,6 +284,8 @@ class TranslationExecutor: run_id=run.id, batch_index=batch_idx, batch_rows=batch_rows, + dict_snapshot_hash=run.dict_snapshot_hash, + config_hash=run.config_hash, ) successful_records += batch_result["successful"] failed_records += batch_result["failed"] @@ -551,6 +676,8 @@ class TranslationExecutor: run_id: str, batch_index: int, batch_rows: list[dict[str, Any]], + dict_snapshot_hash: str | None = None, + config_hash: str | None = None, ) -> dict[str, int]: with belief_scope("TranslationExecutor._process_batch"): batch_start = time.monotonic() @@ -584,7 +711,28 @@ class TranslationExecutor: row_context=row_context, ) - # For each row, determine if we need LLM translation or can use approved/preview edit + # ── Translation cache check: skip LLM for rows translated before ── + for row in batch_rows: + if row.get("approved_translation"): + continue + source_text = row.get("source_text", "") + if not source_text: + continue + source_data = row.get("source_data") + source_hash = _compute_source_hash( + source_text, source_data, + dict_snapshot_hash, config_hash, + ) + row["_source_hash"] = source_hash + cached = _check_translation_cache(self.db, source_hash) + if cached: + row["_cached_lang_values"] = cached + logger.reason("Translation cache hit", { + "source_hash": source_hash[:12], + "langs": list(cached.keys()), + }) + + # For each row, determine if we need LLM translation or can use approved/preview edit/cache rows_for_llm = [] pre_translated = [] @@ -593,6 +741,26 @@ class TranslationExecutor: pre_translated.append(row) continue + # Translation cache hit → treat as pre-translated + # BUT only if cached langs cover ALL target languages + cached_langs = row.get("_cached_lang_values") + if cached_langs: + target_langs_for_check = job.target_languages or [job.target_dialect or "en"] + if not isinstance(target_langs_for_check, list): + target_langs_for_check = [str(target_langs_for_check)] + cached_covers_all = all( + lang_code in cached_langs + for lang_code in target_langs_for_check + ) + if cached_covers_all: + pre_translated.append(row) + continue + else: + logger.reason("Partial cache hit — missing languages, routing to LLM", { + "cached_langs": list(cached_langs.keys()), + "target_langs": target_langs_for_check, + }) + # Check for preview edits carry-forward source_data = row.get("source_data") or {} if source_data and self._preview_edits_cache: @@ -618,29 +786,35 @@ class TranslationExecutor: target_languages = [str(target_languages)] for row in pre_translated: + cached_langs = row.get("_cached_lang_values") # None for approved, dict for cache hits record = TranslationRecord( id=str(uuid.uuid4()), batch_id=batch_id, run_id=run_id, source_sql=row.get("source_text", ""), - target_sql=row.get("approved_translation"), + target_sql=row.get("approved_translation", ""), 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(record) - # Create per-language entry for each target language (pre-approved) + # Create per-language entry for each target language for lang_code in target_languages: + if cached_langs and lang_code in cached_langs: + final_val = cached_langs[lang_code] + else: + final_val = row.get("approved_translation", "") lang_entry = TranslationLanguage( id=str(uuid.uuid4()), record_id=record.id, language_code=lang_code, source_language_detected="und", - translated_value=row.get("approved_translation"), - final_value=row.get("approved_translation"), + translated_value=final_val, + final_value=final_val, status="translated", needs_review=False, ) @@ -1130,6 +1304,14 @@ class TranslationExecutor: per_lang_values[target_languages[0]] = translation_text has_any_translation = True + # ── Dictionary post-processing enforcement ── + # If a dictionary entry matches the source text but the target term + # is missing from the LLM output, apply forced replacement. + if dict_matches and source_text: + _enforce_dictionary(source_text, per_lang_values, dict_matches, + batch_id, row_id) + # ── End dictionary enforcement ── + if not has_any_translation: # Empty/all-empty translations — skip skipped += 1 @@ -1163,6 +1345,7 @@ class TranslationExecutor: 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(record) diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 5f86350a..fac4ce08 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -49,6 +49,10 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = ( "Target dialect(s): {target_dialect}\n" "Column to translate: {translation_column}\n\n" "{dictionary_section}" + "IMPORTANT — You MUST use the terminology dictionary above. " + "For any source term listed in the dictionary, you MUST use its exact target translation. " + "Do not translate dictionary terms differently. " + "This is mandatory, not optional.\n\n" "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" @@ -67,6 +71,10 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = ( "Source dialect: {source_dialect}\n" "Column to translate: {translation_column}\n\n" "{dictionary_section}" + "IMPORTANT — You MUST use the terminology dictionary above. " + "For any source term listed in the dictionary, you MUST use its exact target translation. " + "Do not translate dictionary terms differently. " + "This is mandatory, not optional.\n\n" "Translate to the following languages: {target_languages}\n\n" "For each row, provide an accurate translation of the '{translation_column}' value into each language.\n" "Consider the context columns when determining the meaning of the text.\n\n" diff --git a/frontend/src/lib/components/layout/Breadcrumbs.svelte b/frontend/src/lib/components/layout/Breadcrumbs.svelte index bc0da25f..954538e1 100644 --- a/frontend/src/lib/components/layout/Breadcrumbs.svelte +++ b/frontend/src/lib/components/layout/Breadcrumbs.svelte @@ -81,12 +81,17 @@ admin: "nav.admin", settings: "nav.settings", git: "nav.git", + translate: "translate.jobs.title", }; if (specialCases[segment]) { return _(specialCases[segment]) || segment; } + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) { + return _('translate.config.breadcrumb_job') || 'Job'; + } + return segment .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) diff --git a/frontend/src/lib/components/translate/ScheduleConfig.svelte b/frontend/src/lib/components/translate/ScheduleConfig.svelte index 882277bf..0f8eb024 100644 --- a/frontend/src/lib/components/translate/ScheduleConfig.svelte +++ b/frontend/src/lib/components/translate/ScheduleConfig.svelte @@ -39,6 +39,7 @@ let nextExecutions = $state([]); let hasPriorRun = $state(true); let scheduleExists = $state(false); + let isEditing = $derived(uxState === 'editing'); $effect(() => { if (jobId) loadSchedule(); @@ -190,11 +191,13 @@ type="text" bind:value={cronExpression} placeholder="0 2 * * *" - class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500" + disabled={!isEditing} + class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500" /> {#each commonTimezones as tz} @@ -226,15 +230,15 @@
+
+ {$t.translate?.run?.status_label || 'Статус'} + {getRunStatusLabel(status.status)} +
{$t.translate?.run?.insert_status} @@ -434,45 +441,23 @@ -
- - -
+ {/each} @@ -500,11 +485,4 @@ onClose={() => showBulkReplace = false} onApplied={(count) => { showBulkReplace = false; loadData(); onRefresh(); }} /> - - bulkCorrectionItems = []} - onSubmitted={() => { bulkCorrectionItems = []; loadData(); onRefresh(); }} -/> diff --git a/frontend/src/lib/i18n/locales/en/translate.json b/frontend/src/lib/i18n/locales/en/translate.json index 8857d9e1..4e769652 100644 --- a/frontend/src/lib/i18n/locales/en/translate.json +++ b/frontend/src/lib/i18n/locales/en/translate.json @@ -55,8 +55,15 @@ "target_schema_placeholder": "e.g. public", "target_table_placeholder": "e.g. users_translated", "target_language": "Target Language", + "target_language_search_placeholder": "Search languages...", + "target_language_required": "Select at least one target language", "status": "Status", + "status_draft": "Draft", + "status_ready": "Ready", + "status_active": "Active", "target_column_placeholder": "Target Column", + "target_column_mapping_title": "Target Column Mapping", + "target_column_mapping_description": "Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.", "target_column_label": "Target Column (translated text)", "target_column_hint": "Column where translated text will be inserted", "target_language_column_label": "Language Column", @@ -108,7 +115,10 @@ "include_source_reference": "Include source language in translations", "include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language", "disable_reasoning": "Disable reasoning (save tokens)", - "disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning" + "disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning", + "mark_ready": "Mark as READY", + "save_job_first": "Save the job first", + "breadcrumb_job": "Job" }, "preview": { "title": "Preview", @@ -170,7 +180,10 @@ "detected_language": "Detected Lang", "languages_count": "Languages: {count}", "across_languages": "Across {count} language(s)", - "language_label": "Language" + "language_label": "Language", + "status_approved": "Approved", + "status_rejected": "Rejected", + "status_pending": "Pending" }, "jobs": { "title": "Translation Jobs", @@ -256,6 +269,7 @@ "next_executions": "Next Executions", "enabled": "Enabled", "disabled": "Disabled", + "tab_label": "Schedule", "edit": "Edit", "update": "Update", "create": "Create", @@ -450,7 +464,9 @@ "failed_label": "{count} failed", "skipped_label": "{count} skipped", "tokens_label": "{count} tokens", - "load_failed": "Failed to load run status" + "load_failed": "Failed to load run status", + "bulk_replace": "Bulk Replace", + "status_label": "Status" }, "corrections": { "title": "Bulk Corrections", diff --git a/frontend/src/lib/i18n/locales/ru/translate.json b/frontend/src/lib/i18n/locales/ru/translate.json index cf2ddc3d..20e5b866 100644 --- a/frontend/src/lib/i18n/locales/ru/translate.json +++ b/frontend/src/lib/i18n/locales/ru/translate.json @@ -55,8 +55,15 @@ "target_schema_placeholder": "например, public", "target_table_placeholder": "например, users_translated", "target_language": "Язык перевода", + "target_language_search_placeholder": "Поиск языков...", + "target_language_required": "Выберите хотя бы один язык перевода", "status": "Статус", + "status_draft": "Черновик", + "status_ready": "Готово", + "status_active": "Активно", "target_column_placeholder": "Целевая колонка", + "target_column_mapping_title": "Маппинг целевых колонок", + "target_column_mapping_description": "Настройте, в какие колонки писать перевод, коды языков, исходный текст и определённый язык источника при INSERT.", "target_column_label": "Целевая колонка (переведённый текст)", "target_column_hint": "Колонка, в которую будет вставлен переведённый текст", "target_language_column_label": "Колонка языка", @@ -108,7 +115,10 @@ "include_source_reference": "Сохранять исходный текст как эталонную копию", "include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке", "disable_reasoning": "Отключить рассуждения (экономия токенов)", - "disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)" + "disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)", + "mark_ready": "Пометить как готово", + "save_job_first": "Сначала сохраните задание", + "breadcrumb_job": "Задание" }, "preview": { "title": "Предпросмотр", @@ -171,6 +181,10 @@ "languages_count": "Языков: {count}", "across_languages": "Для {count} язык(ов)", "language_label": "Язык" + , + "status_approved": "Одобрено", + "status_rejected": "Отклонено", + "status_pending": "Ожидает" }, "jobs": { "title": "Задания перевода", @@ -256,6 +270,7 @@ "next_executions": "Следующие выполнения", "enabled": "Включено", "disabled": "Отключено", + "tab_label": "Расписание", "edit": "Редактировать", "update": "Обновить", "create": "Создать", @@ -450,7 +465,9 @@ "failed_label": "{count} с ошибками", "skipped_label": "{count} пропущено", "tokens_label": "{count} токенов", - "load_failed": "Не удалось загрузить статус запуска" + "load_failed": "Не удалось загрузить статус запуска", + "bulk_replace": "Массовая замена", + "status_label": "Статус" }, "corrections": { "title": "Массовые исправления", diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index 820cc860..fb768296 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -58,33 +58,35 @@ clearOnCompleteCallback, } from '$lib/stores/translationRun.js'; - const LANGUAGES = [ - { code: 'ru', name: 'Russian' }, - { code: 'en', name: 'English' }, - { code: 'de', name: 'German' }, - { code: 'fr', name: 'French' }, - { code: 'es', name: 'Spanish' }, - { code: 'it', name: 'Italian' }, - { code: 'pt', name: 'Portuguese' }, - { code: 'zh', name: 'Chinese' }, - { code: 'ja', name: 'Japanese' }, - { code: 'ko', name: 'Korean' }, - { code: 'ar', name: 'Arabic' }, - { code: 'tr', name: 'Turkish' }, - { code: 'nl', name: 'Dutch' }, - { code: 'pl', name: 'Polish' }, - { code: 'sv', name: 'Swedish' }, - { code: 'da', name: 'Danish' }, - { code: 'fi', name: 'Finnish' }, - { code: 'cs', name: 'Czech' }, - { code: 'hu', name: 'Hungarian' }, - { code: 'ro', name: 'Romanian' }, - { code: 'vi', name: 'Vietnamese' }, - { code: 'th', name: 'Thai' }, - { code: 'he', name: 'Hebrew' }, - { code: 'id', name: 'Indonesian' }, - { code: 'ms', name: 'Malay' }, - ]; + const LANGUAGE_LABELS = { + ru: 'Русский', + en: 'English', + de: 'Deutsch', + fr: 'Français', + es: 'Español', + it: 'Italiano', + pt: 'Português', + zh: '中文', + ja: '日本語', + ko: '한국어', + ar: 'العربية', + tr: 'Türkçe', + nl: 'Nederlands', + pl: 'Polski', + sv: 'Svenska', + da: 'Dansk', + fi: 'Suomi', + cs: 'Čeština', + hu: 'Magyar', + ro: 'Română', + vi: 'Tiếng Việt', + th: 'ไทย', + he: 'עברית', + id: 'Bahasa Indonesia', + ms: 'Bahasa Melayu', + }; + + const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name })); /** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */ let uxState = $state('idle'); @@ -147,7 +149,7 @@ { id: 'run', label: $t.translate?.config?.run_translation || 'Run' }, ]; if (!isNewJob && existingJob) { - base.push({ id: 'schedule', label: 'Schedule' }); + base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' }); } return base; }); @@ -160,6 +162,12 @@ let isSaving = $state(false); let validationErrors = $state({}); let warnings = $state([]); + let pageTitle = $derived.by(() => { + if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job'; + const jobName = existingJob?.name || name; + const base = $t.translate?.config?.edit_title || 'Edit Translation Job'; + return jobName ? `${jobName} | ${base}` : base; + }); // Run state — using global store to survive page navigation let runState = fromStore(translationRunStore); @@ -272,8 +280,8 @@ // Load dictionaries (from available translate dictionaries) try { - const dicts = await api.fetchApi('/translate/dictionaries').catch(() => []); - availableDictionaries = Array.isArray(dicts) ? dicts : []; + const result = await api.fetchApi('/translate/dictionaries').catch(() => ({})); + availableDictionaries = Array.isArray(result) ? result : (result?.items || []); } catch (_e) { availableDictionaries = []; } @@ -610,8 +618,25 @@ function isVirtual(col) { return virtualColumnNames.includes(col); } + + function getJobStatusLabel(value) { + const labels = { + DRAFT: $t.translate?.config?.status_draft || 'Draft', + READY: $t.translate?.config?.status_ready || 'Ready', + ACTIVE: $t.translate?.config?.status_active || 'Active', + RUNNING: $t.translate?.jobs?.status_running || 'Running', + COMPLETED: $t.translate?.jobs?.status_completed || 'Completed', + FAILED: $t.translate?.jobs?.status_failed || 'Failed', + CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled', + }; + return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft'); + } + + {pageTitle} + +
@@ -1031,8 +1056,8 @@
-

Target Column Mapping

-

Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.

+

{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}

+

{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}

@@ -1225,7 +1250,7 @@ {:else}
-

{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}

+

{$t.translate?.config?.save_job_first || 'Save the job first'}

{/if}
@@ -1242,10 +1267,11 @@ - {status || 'DRAFT'} + {getJobStatusLabel(status)} {#if status === 'DRAFT'} {/if}
@@ -1350,7 +1376,7 @@ {:else}
-

{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}

+

{$t.translate?.config?.save_job_first || 'Save the job first'}

{/if}