- {/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'}