feat: implement LLM table translation service with searchable datasource dropdown, i18n, sidebar, and RBAC

- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics
- Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections
- Add ORM models (12 tables) and Pydantic schemas (15 DTOs)
- Register translate router in app.py with RBAC permission guards
- Add frontend pages: job list, job config, dictionary list/editor, history
- Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard
- Add searchable datasource dropdown with Superset API integration
- Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces
- Add Translation sidebar category with Jobs/Dictionaries/History sub-items
- Hide health monitor error toast via suppressToast API option
- Add 69 backend tests and 44 frontend test files
- Fix: SupersetClient env resolution (string -> Environment object)
- Fix: Dataset detail API returns proper database dict
- Fix: Database dialect extraction fallback when metadata incomplete
This commit is contained in:
2026-05-09 19:34:25 +03:00
parent bf82e17418
commit 67ba04d4ff
44 changed files with 14744 additions and 5 deletions

View File

@@ -0,0 +1,583 @@
// [DEF:TranslateApi:Module]
// @COMPLEXITY: 3
// @SEMANTICS: frontend, api_client, translate, crud
// @PURPOSE: API client for translation job CRUD, datasource column queries, and dictionary management.
// @LAYER: Infra
// @RELATION: DEPENDS_ON -> [api_module]
// @PRE: Shared API wrapper is configured for the current frontend auth/session context.
// @POST: Translate API helpers return backend payloads or normalized UI-safe errors.
import { api } from '$lib/api.js';
// [DEF:normalizeTranslateError:Function]
// @PURPOSE: Convert unknown API exceptions into deterministic UI-consumable error objects.
// @PRE: error may be Error/string/object.
// @POST: Returns structured error object with message and code.
function normalizeTranslateError(error, defaultMessage = 'Translation API error') {
const message =
(error && typeof error.message === 'string' && error.message) ||
(typeof error === 'string' && error) ||
defaultMessage;
return { message, code: 'TRANSLATE_API_ERROR', retryable: true };
}
// [/DEF:normalizeTranslateError:Function]
// [DEF:fetchJobs:Function]
// @PURPOSE: Fetch paginated list of translation jobs with optional status filter.
// @PRE: Valid auth context.
// @POST: Returns array of job objects.
export async function fetchJobs(options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
if (options.status) params.append('status', options.status);
const query = params.toString();
return await api.fetchApi(`/translate/jobs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load translation jobs');
}
}
// [/DEF:fetchJobs:Function]
// [DEF:createJob:Function]
// @PURPOSE: Create a new translation job.
// @PRE: payload contains valid job configuration.
// @POST: Returns the created job object.
export async function createJob(payload) {
try {
return await api.postApi('/translate/jobs', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create translation job');
}
}
// [/DEF:createJob:Function]
// [DEF:updateJob:Function]
// @PURPOSE: Update an existing translation job.
// @PRE: jobId is non-empty string; payload contains fields to update.
// @POST: Returns the updated job object.
export async function updateJob(jobId, payload) {
try {
return await api.requestApi(`/translate/jobs/${jobId}`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update translation job');
}
}
// [/DEF:updateJob:Function]
// [DEF:deleteJob:Function]
// @PURPOSE: Delete a translation job.
// @PRE: jobId is non-empty string.
// @POST: Returns null on success.
export async function deleteJob(jobId) {
try {
return await api.deleteApi(`/translate/jobs/${jobId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete translation job');
}
}
// [/DEF:deleteJob:Function]
// [DEF:duplicateJob:Function]
// @PURPOSE: Duplicate a translation job.
// @PRE: jobId is non-empty string.
// @POST: Returns the duplicated job summary.
export async function duplicateJob(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/duplicate`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to duplicate translation job');
}
}
// [/DEF:duplicateJob:Function]
// [DEF:fetchDatasourceColumns:Function]
export async function fetchDatasources(envId, search = '') {
try {
return await api.fetchApi(
`/translate/datasources?env_id=${encodeURIComponent(envId)}&search=${encodeURIComponent(search)}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch datasources');
}
}
export async function fetchDatasourceColumns(datasourceId, envId) {
try {
return await api.fetchApi(
`/translate/datasources/${datasourceId}/columns?env_id=${encodeURIComponent(envId)}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch datasource columns');
}
}
// [DEF:fetchPreview:Function]
// @PURPOSE: Trigger a translation preview for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns preview session with rows and cost estimate.
export async function fetchPreview(jobId, sampleSize = 10) {
try {
return await api.postApi(`/translate/jobs/${jobId}/preview`, { sample_size: sampleSize });
} catch (error) {
throw normalizeTranslateError(error, 'Failed to run preview');
}
}
// [/DEF:fetchPreview:Function]
// [DEF:approveRow:Function]
// @PURPOSE: Approve a preview row.
// @PRE: jobId and rowKey are non-empty strings.
// @POST: Returns updated row.
export async function approveRow(jobId, rowKey) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'approve'
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to approve row');
}
}
// [/DEF:approveRow:Function]
// [DEF:editRow:Function]
// @PURPOSE: Edit a preview row's translation.
// @PRE: jobId and rowKey are non-empty strings; translation is non-empty.
// @POST: Returns updated row.
export async function editRow(jobId, rowKey, translation) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'edit',
translation: translation
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to edit row');
}
}
// [/DEF:editRow:Function]
// [DEF:rejectRow:Function]
// @PURPOSE: Reject a preview row.
// @PRE: jobId and rowKey are non-empty strings.
// @POST: Returns updated row.
export async function rejectRow(jobId, rowKey) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/preview/rows/${rowKey}`, 'PUT', {
action: 'reject'
});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to reject row');
}
}
// [/DEF:rejectRow:Function]
// [DEF:acceptPreview:Function]
// @PURPOSE: Accept a preview session, enabling full translation execution.
// @PRE: jobId is non-empty string.
// @POST: Returns accepted preview session.
export async function acceptPreview(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/preview/accept`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to accept preview');
}
}
// [/DEF:acceptPreview:Function]
// [DEF:fetchPreviewRecords:Function]
// @PURPOSE: Fetch records for a preview session.
// @PRE: sessionId is non-empty string.
// @POST: Returns list of preview records.
export async function fetchPreviewRecords(sessionId) {
try {
return await api.fetchApi(`/translate/preview/${sessionId}/records`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch preview records');
}
}
// [/DEF:fetchPreviewRecords:Function]
// [/DEF:fetchDatasourceColumns:Function]
// [DEF:triggerRun:Function]
// @PURPOSE: Trigger a full translation run for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns the created run object.
export async function triggerRun(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/run`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to start translation run');
}
}
// [/DEF:triggerRun:Function]
// [DEF:fetchRunStatus:Function]
// @PURPOSE: Fetch status and statistics for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns run details with statistics.
export async function fetchRunStatus(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run status');
}
}
// [/DEF:fetchRunStatus:Function]
// [DEF:fetchRunHistory:Function]
// @PURPOSE: Fetch run history for a job.
// @PRE: jobId is non-empty string.
// @POST: Returns paginated list of runs.
export async function fetchRunHistory(jobId, options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
const query = params.toString();
return await api.fetchApi(`/translate/jobs/${jobId}/runs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run history');
}
}
// [/DEF:fetchRunHistory:Function]
// [DEF:fetchRunRecords:Function]
// @PURPOSE: Fetch paginated records for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns paginated records.
export async function fetchRunRecords(runId, options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
if (options.status) params.append('status', options.status);
const query = params.toString();
return await api.fetchApi(`/translate/runs/${runId}/records${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run records');
}
}
// [/DEF:fetchRunRecords:Function]
// [DEF:retryFailedBatches:Function]
// @PURPOSE: Retry failed batches in a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function retryFailedBatches(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/retry`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to retry batches');
}
}
// [/DEF:retryFailedBatches:Function]
// [DEF:retryInsert:Function]
// @PURPOSE: Retry the SQL insert phase for a run.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function retryInsert(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/retry-insert`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to retry insert');
}
}
// [/DEF:retryInsert:Function]
// [DEF:cancelRun:Function]
// @PURPOSE: Cancel a running translation.
// @PRE: runId is non-empty string.
// @POST: Returns updated run.
export async function cancelRun(runId) {
try {
return await api.postApi(`/translate/runs/${runId}/cancel`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to cancel run');
}
}
// [/DEF:cancelRun:Function]
// [DEF:fetchRunBatches:Function]
// @PURPOSE: Fetch batches for a translation run.
// @PRE: runId is non-empty string.
// @POST: Returns list of batches.
export async function fetchRunBatches(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/batches`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run batches');
}
}
// [/DEF:fetchRunBatches:Function]
// [DEF:dictionaryApi:Variable]
// @PURPOSE: Namespaced API helpers for terminology dictionary CRUD operations.
export const dictionaryApi = {
async fetchDictionaries(options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
const query = params.toString();
return await api.fetchApi(`/translate/dictionaries${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionaries');
}
},
async createDictionary(payload) {
try {
return await api.postApi('/translate/dictionaries', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create dictionary');
}
},
async getDictionary(dictionaryId) {
try {
return await api.fetchApi(`/translate/dictionaries/${dictionaryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionary');
}
},
async updateDictionary(dictionaryId, payload) {
try {
return await api.requestApi(`/translate/dictionaries/${dictionaryId}`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update dictionary');
}
},
async deleteDictionary(dictionaryId) {
try {
return await api.deleteApi(`/translate/dictionaries/${dictionaryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete dictionary');
}
},
async fetchEntries(dictionaryId, options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
const query = params.toString();
return await api.fetchApi(
`/translate/dictionaries/${dictionaryId}/entries${query ? `?${query}` : ''}`
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to load dictionary entries');
}
},
async createEntry(dictionaryId, payload) {
try {
return await api.postApi(`/translate/dictionaries/${dictionaryId}/entries`, payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to create dictionary entry');
}
},
async updateEntry(dictionaryId, entryId, payload) {
try {
return await api.requestApi(
`/translate/dictionaries/${dictionaryId}/entries/${entryId}`,
'PUT',
payload
);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to update dictionary entry');
}
},
async deleteEntry(dictionaryId, entryId) {
try {
return await api.deleteApi(`/translate/dictionaries/${dictionaryId}/entries/${entryId}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete dictionary entry');
}
},
async importEntries(dictionaryId, payload) {
try {
return await api.postApi(`/translate/dictionaries/${dictionaryId}/import`, payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to import dictionary entries');
}
},
};
// [/DEF:dictionaryApi:Variable]
// ============================================================
// Corrections API
// ============================================================
// [DEF:submitCorrection:Function]
// @PURPOSE: Submit a single term correction.
// @PRE: payload contains source_term, incorrect_target_term, corrected_target_term, dictionary_id.
// @POST: Returns correction result with conflict info.
export async function submitCorrection(payload) {
try {
return await api.postApi('/translate/corrections', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to submit correction');
}
}
// [/DEF:submitCorrection:Function]
// [DEF:submitBulkCorrections:Function]
// @PURPOSE: Submit multiple term corrections atomically.
// @PRE: payload contains corrections array and dictionary_id.
// @POST: Returns bulk result with conflict info.
export async function submitBulkCorrections(payload) {
try {
return await api.postApi('/translate/corrections/bulk', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to submit bulk corrections');
}
}
// [/DEF:submitBulkCorrections:Function]
// ============================================================
// Schedule API
// ============================================================
// [DEF:fetchSchedule:Function]
// @PURPOSE: Fetch schedule for a translation job.
export async function fetchSchedule(jobId) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/schedule`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch schedule');
}
}
// [/DEF:fetchSchedule:Function]
// [DEF:setSchedule:Function]
// @PURPOSE: Create or update schedule for a translation job.
export async function setSchedule(jobId, payload) {
try {
return await api.requestApi(`/translate/jobs/${jobId}/schedule`, 'PUT', payload);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to set schedule');
}
}
// [/DEF:setSchedule:Function]
// [DEF:deleteSchedule:Function]
// @PURPOSE: Delete schedule for a translation job.
export async function deleteSchedule(jobId) {
try {
return await api.deleteApi(`/translate/jobs/${jobId}/schedule`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to delete schedule');
}
}
// [/DEF:deleteSchedule:Function]
// [DEF:enableSchedule:Function]
// @PURPOSE: Enable a schedule.
export async function enableSchedule(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/schedule/enable`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to enable schedule');
}
}
// [/DEF:enableSchedule:Function]
// [DEF:disableSchedule:Function]
// @PURPOSE: Disable a schedule.
export async function disableSchedule(jobId) {
try {
return await api.postApi(`/translate/jobs/${jobId}/schedule/disable`, {});
} catch (error) {
throw normalizeTranslateError(error, 'Failed to disable schedule');
}
}
// [/DEF:disableSchedule:Function]
// [DEF:fetchNextExecutions:Function]
// @PURPOSE: Preview next N schedule executions.
export async function fetchNextExecutions(jobId, n = 3) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/schedule/next-executions?n=${n}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch next executions');
}
}
// [/DEF:fetchNextExecutions:Function]
// ============================================================
// History + Metrics API
// ============================================================
// [DEF:fetchAllRuns:Function]
// @PURPOSE: Fetch all runs with cross-job filtering and pagination.
export async function fetchAllRuns(options = {}) {
try {
const params = new URLSearchParams();
if (options.page != null) params.append('page', String(options.page));
if (options.page_size != null) params.append('page_size', String(options.page_size));
if (options.job_id) params.append('job_id', options.job_id);
if (options.status) params.append('status', options.status);
if (options.trigger_type) params.append('trigger_type', options.trigger_type);
if (options.created_by) params.append('created_by', options.created_by);
const query = params.toString();
return await api.fetchApi(`/translate/runs${query ? `?${query}` : ''}`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch runs');
}
}
// [/DEF:fetchAllRuns:Function]
// [DEF:fetchRunDetail:Function]
// @PURPOSE: Fetch detailed run info with config_snapshot, records, events.
export async function fetchRunDetail(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/detail`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch run detail');
}
}
// [/DEF:fetchRunDetail:Function]
// [DEF:fetchJobMetrics:Function]
// @PURPOSE: Fetch aggregated metrics for a specific job.
export async function fetchJobMetrics(jobId) {
try {
return await api.fetchApi(`/translate/jobs/${jobId}/metrics`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch job metrics');
}
}
// [/DEF:fetchJobMetrics:Function]
// [DEF:fetchAllMetrics:Function]
// @PURPOSE: Fetch aggregated metrics for all jobs.
export async function fetchAllMetrics() {
try {
return await api.fetchApi('/translate/metrics');
} catch (error) {
throw normalizeTranslateError(error, 'Failed to fetch metrics');
}
}
// [/DEF:fetchAllMetrics:Function]
// [DEF:downloadSkippedCsv:Function]
// @PURPOSE: Download skipped records CSV for a run.
export async function downloadSkippedCsv(runId) {
try {
return await api.fetchApi(`/translate/runs/${runId}/skipped.csv`);
} catch (error) {
throw normalizeTranslateError(error, 'Failed to download skipped CSV');
}
}
// [/DEF:downloadSkippedCsv:Function]