diff --git a/backend/src/models/translate.py b/backend/src/models/translate.py index 812bbcbe4..6edb828be 100644 --- a/backend/src/models/translate.py +++ b/backend/src/models/translate.py @@ -31,7 +31,7 @@ class TranslationJob(Base): source_dialect = Column(String, nullable=False) target_dialect = Column(String, nullable=False) database_dialect = Column(String, nullable=True, comment="Detected dialect from Superset connection at save time") - status = Column(String, nullable=False, default="DRAFT") # DRAFT, READY, RUNNING, COMPLETED, FAILED, CANCELLED + status = Column(String, nullable=False, default="DRAFT") # DRAFT, READY, RUNNING, COMPLETED, FAILED, CANCELLED — auto-transition DRAFT→READY via frontend preflight # Datasource & target table configuration source_datasource_id = Column(String, nullable=True, comment="Superset datasource ID") @@ -48,6 +48,7 @@ class TranslationJob(Base): target_source_column = Column(String, nullable=True, comment="Target column for source/original text") target_source_language_column = Column(String, nullable=True, comment="Target column for detected source language (BCP-47)") context_columns = Column(JSON, nullable=True, comment="Context column names included in LLM prompt") + include_source_reference = Column(Boolean, default=True, nullable=False, comment="If true, insert original/source rows alongside translated rows") # LLM & processing settings # source_language removed — deprecated, auto-detected per row by LLM; use TranslationLanguage.source_language_detected instead diff --git a/backend/src/plugins/translate/__tests__/test_orchestrator.py b/backend/src/plugins/translate/__tests__/test_orchestrator.py index fd2ed7dff..251a3d329 100644 --- a/backend/src/plugins/translate/__tests__/test_orchestrator.py +++ b/backend/src/plugins/translate/__tests__/test_orchestrator.py @@ -233,21 +233,6 @@ class TestTranslationOrchestrator: with pytest.raises(ValueError, match="no accepted preview"): orch.start_run(job_id="job-123") - # region test_start_run_draft_job_raises [TYPE Function] - # @PURPOSE: Draft job cannot be run. - def test_start_run_draft_job_raises(self) -> None: - db = MagicMock() - config_manager = MagicMock() - - draft_job = MagicMock(spec=TranslationJob) - draft_job.status = "DRAFT" - - db.query.return_value.filter.return_value.first.return_value = draft_job - - orch = TranslationOrchestrator(db, config_manager, "test-user") - with pytest.raises(ValueError, match="DRAFT"): - orch.start_run(job_id="job-123") - # region test_execute_run_invalid_status [TYPE Function] # @PURPOSE: Cannot execute a run that is not in PENDING status. async def test_execute_run_invalid_status(self, mock_job: MagicMock) -> None: diff --git a/backend/src/plugins/translate/orchestrator_validation.py b/backend/src/plugins/translate/orchestrator_validation.py index f845ffcaa..012d862ac 100644 --- a/backend/src/plugins/translate/orchestrator_validation.py +++ b/backend/src/plugins/translate/orchestrator_validation.py @@ -19,11 +19,6 @@ def validate_job_preconditions( Raises: ValueError: If any precondition fails. """ - if job.status in ("DRAFT",): - raise ValueError( - f"Cannot run job '{job.id}' in status '{job.status}'. " - f"Job must be READY, ACTIVE, or COMPLETED." - ) if not job.target_table: raise ValueError( f"Job '{job.id}' has no target table configured. " diff --git a/backend/src/plugins/translate/service.py b/backend/src/plugins/translate/service.py index 03a14f35e..2011ab599 100644 --- a/backend/src/plugins/translate/service.py +++ b/backend/src/plugins/translate/service.py @@ -77,18 +77,26 @@ class TranslateJobService: f"Must be one of: {', '.join(sorted(valid_strategies))}" ) + # Auto-fill source/target dialect from database_dialect if not provided + source_dialect = payload.source_dialect or payload.database_dialect or "" + target_dialect = payload.target_dialect or payload.database_dialect or "" + dialect = payload.database_dialect - if payload.source_datasource_id and (payload.environment_id or payload.source_dialect): + if payload.source_datasource_id and (payload.environment_id or source_dialect): if not dialect: try: - env_id = payload.environment_id or payload.source_dialect + env_id = payload.environment_id or source_dialect _, detected_dialect = await fetch_datasource_metadata( int(payload.source_datasource_id), env_id, self.config_manager, ) dialect = detected_dialect + if not source_dialect: + source_dialect = detected_dialect + if not target_dialect: + target_dialect = detected_dialect except Exception as e: logger.warning(f"[TranslateJobService] Dialect detection failed: {e}") - dialect = payload.source_dialect + dialect = source_dialect target_languages = payload.target_languages if not target_languages and payload.target_language: @@ -101,7 +109,7 @@ class TranslateJobService: job = TranslationJob( id=str(uuid.uuid4()), name=payload.name, description=payload.description, - source_dialect=payload.source_dialect, target_dialect=payload.target_dialect, + source_dialect=source_dialect, target_dialect=target_dialect, database_dialect=dialect, source_datasource_id=payload.source_datasource_id, source_table=payload.source_table, target_schema=payload.target_schema, target_table=payload.target_table, source_key_cols=payload.source_key_cols or [], @@ -110,6 +118,7 @@ class TranslateJobService: target_language_column=payload.target_language_column, target_source_column=payload.target_source_column, target_source_language_column=payload.target_source_language_column, + include_source_reference=payload.include_source_reference, context_columns=payload.context_columns or [], target_languages=target_languages, provider_id=payload.provider_id, batch_size=payload.batch_size, upsert_strategy=payload.upsert_strategy, environment_id=payload.environment_id, @@ -212,6 +221,7 @@ class TranslateJobService: target_language_column=source.target_language_column, target_source_column=source.target_source_column, target_source_language_column=source.target_source_language_column, + include_source_reference=source.include_source_reference, context_columns=source.context_columns, target_languages=source.target_languages, provider_id=source.provider_id, batch_size=source.batch_size, upsert_strategy=source.upsert_strategy, status="DRAFT", diff --git a/frontend/src/lib/components/translate/PreflightChecklist.svelte b/frontend/src/lib/components/translate/PreflightChecklist.svelte new file mode 100644 index 000000000..b45c6a3df --- /dev/null +++ b/frontend/src/lib/components/translate/PreflightChecklist.svelte @@ -0,0 +1,119 @@ + + + + + + + + + +{#if runReadiness.length > 0} +
+ + + + + {#if showChecklist} +
+
+ {#each runReadiness as item} +
!item.ok && onItemClick(item.key, item.tab)} onkeydown={(e) => { if (!item.ok && (e.key === 'Enter' || e.key === ' ')) onItemClick(item.key, item.tab); }}> + {#if item.ok} + + {tLabel(item.key, item.label)} + {:else} + + {tLabel(item.key, item.label)} + {#if item.required} + * + {/if} + {/if} +
+ {/each} +
+ {#if missingRequired.length > 0} +

* — {rr?.required_before_run || 'required before run'}

+ {/if} +
+ {/if} +
+{/if} + diff --git a/frontend/src/lib/components/translate/RunTabContent.svelte b/frontend/src/lib/components/translate/RunTabContent.svelte index 06e780296..038de6209 100644 --- a/frontend/src/lib/components/translate/RunTabContent.svelte +++ b/frontend/src/lib/components/translate/RunTabContent.svelte @@ -21,7 +21,7 @@
@@ -192,23 +188,9 @@
{_t.translate?.config?.status}: - + {getJobStatusLabel(status)} - {#if status === 'DRAFT'} - - {/if}
@@ -228,17 +210,12 @@

{_t.translate?.config?.run_incremental_desc}

- {#if !schemaValidated} -
- -
- {/if}
@@ -259,17 +236,12 @@

{_t.translate?.config?.run_full_desc}

- {#if !schemaValidated} -
- -
- {/if} diff --git a/frontend/src/lib/i18n/locales/en/translate.json b/frontend/src/lib/i18n/locales/en/translate.json index b1d844613..40f723c81 100644 --- a/frontend/src/lib/i18n/locales/en/translate.json +++ b/frontend/src/lib/i18n/locales/en/translate.json @@ -13,6 +13,7 @@ "previous": "Previous", "unknown_error": "An unknown error occurred", "from": "From", + "to": "To", "unknown": "Unknown", "error": "Error", "success": "Success", @@ -58,7 +59,6 @@ "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", @@ -130,11 +130,10 @@ "select_target_database": "Select database for INSERT...", "target_database_hint": "Database connection used for writing translated data via SQL Lab", "loading_databases": "Loading databases...", - "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", + "include_source_reference": "Add the original as a separate record", + "include_source_reference_hint": "The target table will receive a copy of the source text with its detected language", "disable_reasoning": "Disable reasoning (save tokens)", "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", "help_name": "A unique job name for quick identification in the list.", @@ -148,7 +147,7 @@ "help_target_language": "Languages to translate the text into. You can select multiple — a single LLM call translates each row into ALL selected languages at once. ⚠️ Important: the system auto-detects each source row's language (lingua detector). If the detected language matches one of the target languages, translation for THAT language is SKIPPED — the original text is saved as-is. Example: source 'Hello world' is detected as 'en'. If 'English (en)' is selected as a target, this row is skipped for en (stays 'Hello world') but translated into other selected languages (e.g., 'Привет мир' for ru).", "help_batch_size": "Number of rows sent to the LLM per request. Smaller batches = higher quality but more tokens spent on overhead.", "help_upsert_strategy": "Write strategy for the target table: UPSERT (MERGE) — insert or update; INSERT — new rows only; UPDATE — existing rows only.", - "help_include_source_reference": "Save the original text in a separate column as a reference copy. Useful for verification and auditing.", + "help_include_source_reference": "When enabled, the result includes both translations and a separate record with the original text and src_lang. This is useful for audit and comparison. When disabled, rows without an actual translation are not inserted.", "help_disable_reasoning": "Disable LLM Chain-of-Thought reasoning. Saves tokens (~20-30%) but may reduce quality for complex translations.", "help_dictionaries": "Terminology dictionaries for forced term mapping. Improves translation consistency within the subject domain.", "help_target_schema": "The database schema (namespace) where translated data will be written. E.g., public.", @@ -253,7 +252,6 @@ "subtitle": "Manage your translation jobs", "new_job": "New Job", "create_job": "Create Job", - "status_draft": "Draft", "status_ready": "Ready", "status_running": "Running", "status_completed": "Completed", @@ -274,7 +272,30 @@ "duplicate_failed": "Failed to duplicate job", "job_deleted": "Job deleted", "delete_failed": "Failed to delete job", - "jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history." + "jobs_flow_hint": "Flow: create a job → configure datasource and LLM → run translation → check run history.", + "run_readiness": { + "label_name": "Job name", + "hint_name": "Enter a job name", + "label_datasource": "Datasource selected", + "hint_datasource": "Select a source datasource", + "label_translation_column": "Translation column", + "hint_translation_column": "Select a column to translate", + "label_target_languages": "Target languages", + "hint_target_languages": "Select at least one target language", + "label_provider": "LLM provider", + "hint_provider": "Select an LLM provider", + "label_target_schema": "Target schema", + "hint_target_schema": "Target schema not set — insert may fail", + "label_target_table": "Target table", + "hint_target_table": "Target table not set — insert may fail", + "label_schema_validated": "Schema validated", + "hint_schema_validated": "Validate target schema before first run", + "label_connection_id": "DB connection", + "hint_connection_id": "Select a Direct DB connection", + "missing_required": "required items missing", + "ready_to_run": "Ready to run", + "required_before_run": "required before run" + } }, "history": { "title": "Translation History", @@ -579,7 +600,6 @@ "help_sum_total_tokens": "Cumulative LLM token consumption across all runs. Tokens are the unit of input/output processed by the language model.", "help_sum_total_cost": "Estimated cumulative cost of all LLM API calls for this job, calculated from per-run token usage.", "help_sum_avg_duration": "Average duration of a translation run across all runs. Measured from run start to completion (wall-clock time, including LLM calls and DB inserts).", - "disabled_draft": "Mark the job as READY first", "disabled_running": "Translation is already running", "disabled_schema": "Validate target schema first", "load_more_runs": "Load more runs", diff --git a/frontend/src/lib/i18n/locales/ru/translate.json b/frontend/src/lib/i18n/locales/ru/translate.json index f94f83770..092266421 100644 --- a/frontend/src/lib/i18n/locales/ru/translate.json +++ b/frontend/src/lib/i18n/locales/ru/translate.json @@ -13,6 +13,7 @@ "previous": "Назад", "unknown_error": "Произошла неизвестная ошибка", "from": "От", + "to": "До", "unknown": "Неизвестно", "error": "Ошибка", "success": "Успешно", @@ -58,7 +59,6 @@ "target_language_search_placeholder": "Поиск языков...", "target_language_required": "Выберите хотя бы один язык перевода", "status": "Статус", - "status_draft": "Черновик", "status_ready": "Готово", "status_active": "Активно", "target_column_placeholder": "Целевая колонка", @@ -130,11 +130,10 @@ "select_target_database": "Выберите базу данных для INSERT...", "target_database_hint": "Подключение к БД для записи переведённых данных через SQL Lab", "loading_databases": "Загрузка баз данных...", - "include_source_reference": "Сохранять исходный текст как эталонную копию", - "include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке", + "include_source_reference": "Добавлять оригинал отдельной записью", + "include_source_reference_hint": "В целевую таблицу будет записана копия исходного текста с его обнаруженным языком", "disable_reasoning": "Отключить рассуждения (экономия токенов)", "disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)", - "mark_ready": "Пометить как готово", "save_job_first": "Сначала сохраните задание", "breadcrumb_job": "Задание", "help_name": "Уникальное название задания для быстрой идентификации в списке.", @@ -148,7 +147,7 @@ "help_target_language": "Языки, на которые нужно перевести текст. Можно выбрать несколько — за один проход LLM переведёт строку на все языки сразу. ⚠️ Важно: система автоматически определяет язык каждой исходной строки (lingua-детектор). Если обнаруженный язык совпадает с одним из целевых — для этой строки перевод на данный язык НЕ выполняется, а исходный текст сохраняется как есть. Пример: исходная строка 'Hello world' определена как 'en'. Если 'English (en)' выбран как целевой язык, эта строка будет пропущена для en-перевода (останется 'Hello world'), но будет переведена на остальные выбранные языки (например, 'Привет мир' для ru).", "help_batch_size": "Количество строк, отправляемых в LLM за один запрос. Меньше пакет = выше качество, но больше токенов на оверхед.", "help_upsert_strategy": "Стратегия записи в целевую таблицу: UPSERT (MERGE) — вставка или обновление; INSERT — только новые строки; UPDATE — только обновление существующих.", - "help_include_source_reference": "Сохранить оригинальный текст в отдельной колонке как эталонную копию. Полезно для верификации и аудита.", + "help_include_source_reference": "Если включено, система добавит в результат не только переводы, но и отдельную запись с исходным текстом и src_lang. Это полезно для аудита и сверки. Если выключить, строки без фактического перевода не будут вставляться.", "help_disable_reasoning": "Отключить Chain-of-Thought рассуждения LLM. Экономит токены (~20-30%), но может снизить качество сложных переводов.", "help_dictionaries": "Терминологические словари для принудительного сопоставления терминов. Повышают консистентность перевода в рамках предметной области.", "help_target_schema": "Схема (namespace) базы данных, в которую будут записаны переведённые данные. Например: public.", @@ -254,7 +253,6 @@ "subtitle": "Управление заданиями перевода", "new_job": "Новое задание", "create_job": "Создать задание", - "status_draft": "Черновик", "status_ready": "Готово", "status_running": "Выполняется", "status_completed": "Завершено", @@ -275,7 +273,30 @@ "duplicate_failed": "Не удалось дублировать задание", "job_deleted": "Задание удалено", "delete_failed": "Не удалось удалить задание", - "jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков." + "jobs_flow_hint": "Flow: создайте задание → настройте источник данных и LLM → запустите перевод → проверьте историю запусков.", + "run_readiness": { + "label_name": "Название", + "hint_name": "Введите название задания", + "label_datasource": "Источник данных", + "hint_datasource": "Выберите источник данных", + "label_translation_column": "Колонка для перевода", + "hint_translation_column": "Выберите колонку для перевода", + "label_target_languages": "Языки перевода", + "hint_target_languages": "Выберите хотя бы один целевой язык", + "label_provider": "LLM провайдер", + "hint_provider": "Выберите LLM провайдера", + "label_target_schema": "Целевая схема", + "hint_target_schema": "Не указана целевая схема — вставка может не сработать", + "label_target_table": "Целевая таблица", + "hint_target_table": "Не указана целевая таблица — вставка может не сработать", + "label_schema_validated": "Схема проверена", + "hint_schema_validated": "Проверьте целевую схему перед первым запуском", + "label_connection_id": "Подключение к БД", + "hint_connection_id": "Выберите Direct DB подключение", + "missing_required": "обязательных полей не заполнено", + "ready_to_run": "Готово к запуску", + "required_before_run": "обязательно для запуска" + } }, "history": { "title": "История переводов", @@ -580,7 +601,6 @@ "help_sum_total_tokens": "Суммарное потребление токенов LLM во всех запусках. Токены — единица входных/выходных данных, обрабатываемых языковой моделью.", "help_sum_total_cost": "Оценка суммарной стоимости всех вызовов LLM API для этого задания, рассчитанная на основе использования токенов в каждом запуске.", "help_sum_avg_duration": "Средняя продолжительность запуска перевода по всем запускам. Измеряется от начала до завершения (реальное время, включая вызовы LLM и вставки в БД).", - "disabled_draft": "Сначала переведите задание в статус ГОТОВО", "disabled_running": "Перевод уже выполняется", "disabled_schema": "Сначала проверьте схему целевой таблицы", "load_more_runs": "Загрузить ещё запуски", diff --git a/frontend/src/lib/models/TranslationJobModel.svelte.ts b/frontend/src/lib/models/TranslationJobModel.svelte.ts index 1373ec6a0..383c10ddf 100644 --- a/frontend/src/lib/models/TranslationJobModel.svelte.ts +++ b/frontend/src/lib/models/TranslationJobModel.svelte.ts @@ -18,10 +18,6 @@ // in arrow functions: onTriggerRun={(full) => m.handleTriggerRun(full)}. // @REJECTED Converting methods to arrow class fields rejected — it would conflict with the Svelte 5 // `$state` rune initialization order for non-primitive state atoms in the constructor. -// @REJECTED includeSourceReference ($state at line 52) has no backend column or schema field. -// It is a UI-only checkbox; the value always resets to `true` on page load. -// The backend TranslateJobCreate/Update/Response schemas lack `include_source_reference`. -// Requires a DB migration + Pydantic schema update to persist. Not implemented as of 2026-06-03. import { api } from '$lib/api.js'; import { addToast } from '$lib/toasts.svelte.js'; import { _, getT } from '$lib/i18n/index.svelte.js'; @@ -30,6 +26,15 @@ import { startTranslationRun, resetTranslationRun, translationRunStore } from '$ type UxState = 'idle' | 'loading' | 'configured' | 'saving' | 'validation_error' | 'datasource_unavailable'; +type ReadinessItem = { + key: string; + label: string; + ok: boolean; + required: boolean; + message: string; + tab: string; // 'config' | 'target' — which tab to navigate to on click +}; + export class TranslationJobModel { // ── Context ─────────────────────────────────────────────────── uxState: UxState = $state('idle'); @@ -99,7 +104,35 @@ export class TranslationJobModel { isSaving: boolean = $state(false); isDirty: boolean = $state(false); validationErrors: Record = $state({}); - warnings: string[] = $state([]); + + /** Run readiness checklist — derived from current model state */ + runReadiness: ReadinessItem[] = $derived.by(() => { + const items: ReadinessItem[] = [ + { key: 'name', label: 'Job name', ok: !!this.name, required: true, message: 'Enter a job name', tab: 'config' }, + { key: 'datasource', label: 'Datasource selected', ok: !!this.datasourceId, required: true, message: 'Select a source datasource', tab: 'config' }, + { key: 'translationColumn', label: 'Translation column', ok: !!this.translationColumn, required: true, message: 'Select a column to translate', tab: 'config' }, + { key: 'targetLanguages', label: 'Target languages', ok: this.targetLanguages.length > 0, required: true, message: 'Select at least one target language', tab: 'config' }, + { key: 'provider', label: 'LLM provider', ok: !!this.providerId, required: true, message: 'Select an LLM provider', tab: 'config' }, + { key: 'targetSchema', label: 'Target schema', ok: !!this.targetSchema, required: false, message: 'Target schema not set — insert may fail', tab: 'target' }, + { key: 'targetTable', label: 'Target table', ok: !!this.targetTable, required: false, message: 'Target table not set — insert may fail', tab: 'target' }, + { key: 'schemaValidated', label: 'Target schema validated', ok: this.schemaValidated, required: false, message: 'Validate target schema before first run', tab: 'target' }, + ]; + if (this.insertMethod === 'direct_db') { + items.push({ key: 'connectionId', label: 'DB connection', ok: !!this.connectionId, required: true, message: 'Select a Direct DB connection', tab: 'target' }); + } + return items; + }); + + /** True when all REQUIRED readiness items pass */ + runReady: boolean = $derived(this.runReadiness.filter(i => i.required).every(i => i.ok)); + + /** Warnings derived from readiness items */ + warnings: string[] = $derived.by(() => { + const w: string[] = []; + const missing = this.runReadiness.filter(i => !i.ok && !i.required); + for (const item of missing) w.push(item.message); + return w; + }); configValid = $derived( !!this.translationColumn && !!this.datasourceId && this.targetLanguages.length > 0 && !!this.providerId @@ -127,15 +160,22 @@ export class TranslationJobModel { // ── Actions: Run ───────────────────────────────────────────── async handleTriggerRun(full = false): Promise { + this.runError = ''; + // Auto-save any unsaved config changes before triggering a run + if (!this.isNewJob) { + try { + await this.saveJob(); + } catch (err: unknown) { + this.runError = err instanceof Error ? err.message : _('translate.config.run_failed'); + this.isRunning = false; + addToast(this.runError, 'error'); + return; // don't proceed if auto-save fails + } + } this.isRunning = true; this.runComplete = false; this.isFullRun = full; - this.runError = ''; try { - // Auto-save any unsaved config changes before triggering a run - if (!this.isNewJob) { - await this.saveJob(); - } const run = await triggerRun(this.jobId, full); startTranslationRun(run.id, { jobId: this.jobId, isFullRun: full, onComplete: this._onRunComplete.bind(this) }); addToast(full ? 'Полный перевод запущен (все строки)' : _('translate.config.run_started'), 'success'); @@ -252,10 +292,16 @@ export class TranslationJobModel { this.upsertStrategy = (job.upsert_strategy as string) || 'MERGE'; this.insertMethod = (job.insert_method as string) || 'sqllab'; this.connectionId = (job.connection_id as string) || null; + this.includeSourceReference = (job.include_source_reference as boolean) ?? true; this.disableReasoning = (job.disable_reasoning as boolean) ?? false; this.databaseDialect = (job.database_dialect as string) || ''; this.datasourceId = (job.source_datasource_id as string) || ''; this.status = (job.status as string) || 'DRAFT'; + if (this.runReady && this.status === 'DRAFT') { + this.status = 'READY'; + } else if (!this.runReady && this.status === 'READY') { + this.status = 'DRAFT'; + } this.targetSchema = (job.target_schema as string) || ''; this.targetTable = (job.target_table as string) || ''; this.targetDatabaseId = (job.target_database_id as string) || ''; @@ -320,6 +366,11 @@ export class TranslationJobModel { this.validationErrors = {}; this.isDirty = false; try { + if (this.runReady && this.status === 'DRAFT') { + this.status = 'READY'; + } else if (!this.runReady && this.status === 'READY') { + this.status = 'DRAFT'; + } const payload = { name: this.name, description: this.description, @@ -337,6 +388,7 @@ export class TranslationJobModel { upsert_strategy: this.upsertStrategy, insert_method: this.insertMethod, connection_id: this.connectionId || undefined, + include_source_reference: this.includeSourceReference, disable_reasoning: this.disableReasoning, database_dialect: this.databaseDialect && this.databaseDialect !== 'unknown' ? this.databaseDialect : undefined, target_schema: this.targetSchema || undefined, @@ -360,6 +412,38 @@ export class TranslationJobModel { this.uxState = 'configured'; } catch (err: unknown) { this.error = err instanceof Error ? err.message : 'Failed to save'; + // Parse structured Pydantic 422 errors into per-field validationErrors + const apiErr = err as Record; + if (Array.isArray(apiErr.detail)) { + const FIELD_MAP: Record = { + 'name': 'name', + 'translation_column': 'translationColumn', + 'source_datasource_id': 'datasourceId', + 'target_languages': 'targetLanguages', + 'provider_id': 'providerId', + 'target_schema': 'targetSchema', + 'target_table': 'targetTable', + 'source_table': 'sourceTable', + 'target_column': 'targetColumn', + 'batch_size': 'batchSize', + 'upsert_strategy': 'upsertStrategy', + 'insert_method': 'insertMethod', + 'connection_id': 'connectionId', + 'environment_id': 'environmentId', + 'target_database_id': 'targetDatabaseId', + 'target_language_column': 'targetLanguageColumn', + 'target_source_column': 'targetSourceColumn', + 'target_source_language_column': 'targetSourceLanguageColumn', + }; + for (const detail of apiErr.detail as Array>) { + const loc = detail.loc as string[] | undefined; + if (loc && loc.length > 0) { + const snakeField = loc[loc.length - 1]; + const camelField = FIELD_MAP[snakeField] || snakeField; + this.validationErrors[camelField] = detail.msg as string; + } + } + } addToast(this.error, 'error'); this.uxState = 'validation_error'; } diff --git a/frontend/src/routes/translate/+page.svelte b/frontend/src/routes/translate/+page.svelte index e78c00eed..e3f3308b5 100644 --- a/frontend/src/routes/translate/+page.svelte +++ b/frontend/src/routes/translate/+page.svelte @@ -24,7 +24,7 @@ // Count jobs by status for filter pills let statusCounts = $derived.by(() => { - const counts = { DRAFT: 0, READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0, CANCELLED: 0 }; + const counts = { READY: 0, RUNNING: 0, COMPLETED: 0, FAILED: 0 }; for (const job of jobs) { if (counts[job.status] !== undefined) counts[job.status]++; } @@ -43,7 +43,6 @@ let statusPills = $derived([ { label: 'All', value: '', count: jobs.length }, - { label: $t.translate?.jobs?.status_draft, value: 'DRAFT', count: statusCounts.DRAFT }, { label: $t.translate?.jobs?.status_ready, value: 'READY', count: statusCounts.READY }, { label: $t.translate?.jobs?.status_running, value: 'RUNNING', count: statusCounts.RUNNING }, { label: $t.translate?.jobs?.status_completed, value: 'COMPLETED', count: statusCounts.COMPLETED }, @@ -196,7 +195,7 @@ {:else if uxState === 'populated'}
{#each jobs as job} - {@const statusVariant = ({ DRAFT: "muted", READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"} + {@const statusVariant = ({ READY: "primary", RUNNING: "warning", COMPLETED: "success", FAILED: "destructive", CANCELLED: "muted" })[job.status] || "muted"}
navigateToConfig(job.id)} class="bg-surface-card border border-border rounded-lg p-4 hover:shadow-md hover:border-border-strong transition-all cursor-pointer" diff --git a/frontend/src/routes/translate/[id]/+page.svelte b/frontend/src/routes/translate/[id]/+page.svelte index 7bb729bf1..45af8b8b6 100644 --- a/frontend/src/routes/translate/[id]/+page.svelte +++ b/frontend/src/routes/translate/[id]/+page.svelte @@ -39,6 +39,7 @@ import ConfigTabForm from '$lib/components/translate/ConfigTabForm.svelte'; import TargetTabForm from '$lib/components/translate/TargetTabForm.svelte'; import RunTabContent from '$lib/components/translate/RunTabContent.svelte'; + import PreflightChecklist from '$lib/components/translate/PreflightChecklist.svelte'; const m = new TranslationJobModel(); // @RATIONALE Svelte 5 compiler fails to detect `t` as store in deeply nested @@ -101,18 +102,6 @@
- - {#if m.warnings.length > 0} -
-

{_t.translate?.config?.warnings_title}

-
    - {#each m.warnings as w} -
  • {w}
  • - {/each} -
-
- {/if} - {#if m.uxState === 'loading'}
{#each Array(6) as _}
{/each}
@@ -132,6 +121,9 @@ {:else if m.uxState === 'configured' || m.uxState === 'saving' || m.uxState === 'validation_error' || m.uxState === 'idle'} + + { m.activeTab = tab; }} /> +