feat: add deduplicate + metrics to Run tab

Backend:
- deduplicate param in GET /runs/{id}/records — NOT EXISTS subquery
  with (created_at, id) tiebreaker for one row per source_hash
- source_data and source_hash added to records JSON response
- fix missing status import in _run_history_routes.py (NameError)
- fix tab/space mixup in orchestrator_aggregator.py
- remove duplicated @RELATION edges in metrics.py
- fix #region/#endregion style and @PURPOSE→@BRIEF in metrics.py

Frontend:
- RunTabContent: summary metrics bar (fetchJobMetrics) with HelpTooltips
- RunTabContent: trigger_type badge, duration, cache rate in run rows
- TranslationRunResult: deduplicate=true by default, paginated Load more
- TranslationRunResult: per-language token_count and estimated_cost
- TranslationRunResult: collapsible batch breakdown with timing
- TranslationRunResult: Bulk Replace button in header and records table
- TranslationRunResult: source_data key values shown under source text

i18n: all new keys in EN + RU (load_more_records, showing_records,
sum_*, help_sum_*, trigger_*, batch_*, duration_label, cost_label,
cache_rate, load_more_records)
This commit is contained in:
2026-06-04 13:23:10 +03:00
parent f8474498f8
commit 15d47450a3
10 changed files with 519 additions and 32 deletions

View File

@@ -84,12 +84,13 @@ export async function fetchRunHistory<T = unknown>(jobId: string, options: RunHi
// @PRE runId is a non-empty string.
// @POST Returns paginated record list with translations.
// @RELATION DEPENDS_ON -> [fetchApi]
export async function fetchRunRecords<T = unknown>(runId: string, options: RunQueryOptions & { status?: string } = {}): Promise<T> {
export async function fetchRunRecords<T = unknown>(runId: string, options: RunQueryOptions & { status?: string; deduplicate?: boolean } = {}): Promise<T> {
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);
if (options.deduplicate != null) params.append('deduplicate', String(options.deduplicate));
const query = params.toString();
return await api.fetchApi<T>(`/translate/runs/${runId}/records${query ? `?${query}` : ''}`);
} catch (error) {

View File

@@ -1,22 +1,26 @@
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress] -->
<!-- #region RunTabContent [C:3] [TYPE Component] [SEMANTICS translate, run, execution, progress, metrics] -->
<!-- @BRIEF Run tab — translation execution with incremental/full mode selection, progress
tracking via TranslationRunProgress, and run history with expandable details. -->
tracking via TranslationRunProgress, job metrics summary, and run history with
trigger type, duration, cache rate, and expandable details. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [TranslationRunProgress] -->
<!-- @RELATION DEPENDS_ON -> [TranslationRunResult] -->
<!-- @RELATION DEPENDS_ON -> [TranslateMetricsRoutesModule] -->
<!-- @RELATION BINDS_TO -> [translationRunStore] -->
<!-- @UX_STATE Idle — no run active, run cards visible -->
<!-- @UX_STATE Running — TranslationRunProgress bar visible, cards show active state -->
<!-- @UX_STATE Completed — run history list with expandable details -->
<!-- @UX_STATE Completed — run history list with expandable details, summary metrics bar -->
<!-- @UX_STATE Error — error banner above run history -->
<!-- @UX_FEEDBACK Toast on status change / run error / run complete -->
<!-- @UX_FEEDBACK BulkReplace modal button in recent runs header -->
<!-- @UX_FEEDBACK Summary metrics bar with success rate, tokens, cost, avg duration -->
<!-- @UX_RECOVERY Retry button on TranslationRunProgress for failed runs -->
<!-- @UX_REACTIVITY Props -> $props() for config/state, callbacks for actions -->
<!-- @UX_REACTIVITY LocalState -> $state(jobMetrics, metricsLoading) from fetchJobMetrics -->
<script lang="ts">
import { getT } from '$lib/i18n/index.svelte.js';
import { addToast } from '$lib/toasts.svelte.js';
import { updateJob } from '$lib/api/translate.js';
import { updateJob, fetchJobMetrics } from '$lib/api/translate.js';
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
import TranslationRunProgress from './TranslationRunProgress.svelte';
import TranslationRunResult from './TranslationRunResult.svelte';
@@ -40,6 +44,54 @@
getJobStatusLabel = (s) => s,
} = $props();
// Job metrics state (from fetchJobMetrics)
let jobMetrics = $state(null);
let metricsLoading = $state(false);
let metricsError = $state(null);
$effect(() => {
if (!jobId) return;
loadJobMetrics();
});
async function loadJobMetrics() {
metricsLoading = true;
metricsError = null;
try {
const data = await fetchJobMetrics(jobId);
jobMetrics = data;
} catch (e) {
metricsError = e?.message || 'Failed to load metrics';
jobMetrics = null;
} finally {
metricsLoading = false;
}
}
/** Compute duration seconds from started_at / completed_at. */
function calcDuration(seconds) {
if (!seconds && seconds !== 0) return null;
if (seconds < 1) return '<1';
return seconds.toFixed(1);
}
function getRunDuration(run) {
if (!run.started_at) return null;
const start = new Date(run.started_at).getTime();
const end = run.completed_at ? new Date(run.completed_at).getTime() : Date.now();
return calcDuration((end - start) / 1000);
}
function getTriggerLabel(trigger) {
const labels = {
manual: _t.translate?.run?.trigger_manual,
scheduled: _t.translate?.run?.trigger_scheduled,
retry: _t.translate?.run?.trigger_retry,
baseline_expired: _t.translate?.run?.trigger_baseline_expired,
};
return labels[trigger] || trigger || _t.translate?.run?.trigger_manual;
}
async function handleMarkReady() {
try {
await updateJob(jobId, { status: 'READY' });
@@ -148,6 +200,73 @@
/>
{/if}
<!-- Summary metrics bar -->
{#if jobMetrics}
<div class="bg-surface-card border border-border rounded-lg p-4">
<h4 class="text-xs text-text-muted uppercase font-medium mb-3">{_t.translate?.run?.sum_metrics_title}</h4>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="bg-surface-muted rounded p-2.5">
<p class="text-xs text-text-muted flex items-center gap-1">
{_t.translate?.run?.sum_total_runs}
<HelpTooltip text={_t.translate?.run?.help_sum_total_runs || ''} />
</p>
<p class="text-lg font-bold text-text">{jobMetrics.total_runs || 0}</p>
</div>
<div class="bg-success-light rounded p-2.5">
<p class="text-xs text-success flex items-center gap-1">
{_t.translate?.run?.sum_success_rate}
<HelpTooltip text={_t.translate?.run?.help_sum_success_rate || ''} />
</p>
<p class="text-lg font-bold text-success">
{jobMetrics.total_runs > 0 ? Math.round(((jobMetrics.successful_runs || 0) / jobMetrics.total_runs) * 100) : 0}%
</p>
</div>
<div class="bg-surface-muted rounded p-2.5">
<p class="text-xs text-text-muted flex items-center gap-1">
{_t.translate?.run?.sum_total_records}
<HelpTooltip text={_t.translate?.run?.help_sum_total_records || ''} />
</p>
<p class="text-lg font-bold text-text">{(jobMetrics.total_records || 0).toLocaleString()}</p>
</div>
<div class="bg-info-light rounded p-2.5">
<p class="text-xs text-info flex items-center gap-1">
{_t.translate?.run?.sum_total_tokens}
<HelpTooltip text={_t.translate?.run?.help_sum_total_tokens || ''} />
</p>
<p class="text-lg font-bold text-info">{(jobMetrics.cumulative_tokens || 0).toLocaleString()}</p>
</div>
<div class="bg-surface-muted rounded p-2.5">
<p class="text-xs text-text-muted flex items-center gap-1">
{_t.translate?.run?.sum_total_cost}
<HelpTooltip text={_t.translate?.run?.help_sum_total_cost || ''} />
</p>
<p class="text-lg font-bold text-text">${(jobMetrics.cumulative_cost || 0).toFixed(2)}</p>
</div>
<div class="bg-primary-light rounded p-2.5">
<p class="text-xs text-primary flex items-center gap-1">
{_t.translate?.run?.sum_avg_duration}
<HelpTooltip text={_t.translate?.run?.help_sum_avg_duration || ''} />
</p>
<p class="text-lg font-bold text-primary">
{jobMetrics.avg_duration_ms != null ? (jobMetrics.avg_duration_ms / 1000).toFixed(1) + 's' : '—'}
</p>
</div>
</div>
</div>
{:else if metricsLoading}
<div class="bg-surface-card border border-border rounded-lg p-4">
<div class="grid grid-cols-6 gap-3">
{#each Array(6) as _}
<div class="h-16 bg-surface-muted rounded animate-pulse" />
{/each}
</div>
</div>
{:else if metricsError}
<div class="bg-destructive-light border border-destructive-light rounded-lg p-3">
<p class="text-sm text-destructive">{metricsError}</p>
</div>
{/if}
{#if completedRuns.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-2">
@@ -175,12 +294,25 @@
>
{run.status}
</span>
{#if run.trigger_type}
<span class="text-[11px] text-text-subtle bg-surface-muted px-1.5 py-0.5 rounded">
{getTriggerLabel(run.trigger_type)}
</span>
{/if}
{#if run.started_at}
<span class="text-[11px] text-text-subtle">{_t.translate?.run?.duration_label?.replace('{seconds}', getRunDuration(run))}</span>
{/if}
</div>
<div class="mt-2 flex flex-wrap gap-3 text-xs text-text-muted">
<span>{_t.translate?.run?.total}: {run.total_records || 0}</span>
<span>{_t.translate?.run?.success}: {run.successful_records || 0}</span>
<span>{_t.translate?.run?.failed}: {run.failed_records || 0}</span>
<span>{_t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
<span class="text-success">{_t.translate?.run?.success}: {run.successful_records || 0}</span>
<span class="text-destructive">{_t.translate?.run?.failed}: {run.failed_records || 0}</span>
<span class="text-warning">{_t.translate?.run?.skipped}: {run.skipped_records || 0}</span>
{#if run.cache_hits}
<span class="text-info">
{_t.translate?.run?.cache_rate?.replace('{pct}', Math.round((run.cache_hits / Math.max(run.total_records || 1, 1)) * 100))}
</span>
{/if}
</div>
{#if run.error_message}
<p class="mt-2 text-xs text-destructive break-words">{run.error_message}</p>

View File

@@ -1,12 +1,15 @@
<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit] -->
<!-- @BRIEF Component component: lib/components/translate/TranslationRunResult.svelte -->
<!-- #region TranslationRunResult [C:3] [TYPE Component] [SEMANTICS translate, result, stats, retry, sql-audit, metrics, bulk-replace] -->
<!-- @BRIEF Run result detail panel: statistics grid, per-language breakdown with token/cost, collapsible batch history with per-batch duration, collapsible records table with load-all and inline bulk-replace, SQL audit, retry actions, and correction cells. -->
<!-- @RELATION DEPENDS_ON -> [TranslateApi] -->
<!-- @RELATION DEPENDS_ON -> [BulkReplaceModal] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api_module] -->
<!-- @UX_STATE completed -> All phases completed, full stats displayed -->
<!-- @UX_STATE partial -> Translation completed with failures, retry available -->
<!-- @UX_STATE failed -> Translation failed, retry available -->
<!-- @UX_STATE insert_failed -> Insert phase failed, retry-insert available -->
<!-- @UX_FEEDBACK Tabular statistics; click to copy Superset query ID; collapsible SQL block. -->
<!-- @UX_FEEDBACK Tabular statistics; per-language token count and estimated cost; collapsible batch breakdown with per-batch duration -->
<!-- @UX_FEEDBACK click to copy Superset query ID; collapsible SQL block. -->
<!-- @UX_FEEDBACK Records table with deduplicate by source_hash (NOT EXISTS subquery), "Load more" button (page_size: 50, paginated), and "Bulk Replace" button. Source cell shows text + key values from source_data. -->
<!-- @UX_RECOVERY Retry failed batches; retry insert. -->
<script lang="ts">
import { SvelteSet } from "svelte/reactivity";
@@ -15,6 +18,7 @@ import { SvelteSet } from "svelte/reactivity";
import {
fetchRunStatus,
fetchRunRecords,
fetchRunBatches,
retryFailedBatches,
retryInsert,
downloadFailedCsv
@@ -44,6 +48,13 @@ import { SvelteSet } from "svelte/reactivity";
let correctionPopupData = $state(null);
let showBulkReplace = $state(false);
let loadedRunId = $state(null);
let showBatches = $state(false);
let batches = $state([]);
let batchesLoading = $state(false);
let recordsTotal = $state(0);
let recordsPage = $state(1);
let recordsLoadingMore = $state(false);
let allRecordsLoaded = $state(false);
function getRunStatusLabel(value) {
const labels = {
@@ -75,10 +86,13 @@ import { SvelteSet } from "svelte/reactivity";
try {
const [statusData, recordsData] = await Promise.all([
fetchRunStatus(runId),
fetchRunRecords(runId, { page_size: 10 }),
fetchRunRecords(runId, { page_size: 50, deduplicate: true }),
]);
status = statusData;
records = recordsData?.items || [];
recordsTotal = recordsData?.total || records.length;
recordsPage = 1;
allRecordsLoaded = records.length >= recordsTotal;
// Extract target languages from language_stats or records
if (statusData.language_stats && statusData.language_stats.length > 0) {
@@ -152,6 +166,23 @@ import { SvelteSet } from "svelte/reactivity";
});
}
/** Load next page of unique rows and append to records list. */
async function loadMoreRecords() {
recordsLoadingMore = true;
const nextPage = recordsPage + 1;
try {
const data = await fetchRunRecords(runId, { page: nextPage, page_size: 50, deduplicate: true });
const newItems = data?.items || [];
records = [...records, ...newItems];
recordsPage = nextPage;
allRecordsLoaded = records.length >= (data?.total || recordsTotal);
} catch (e) {
addToast(e?.message || 'Failed to load more records', 'error');
} finally {
recordsLoadingMore = false;
}
}
/** @returns {Promise<void>} */
async function toggleSqlAudit() {
showSqlAudit = !showSqlAudit;
@@ -210,6 +241,12 @@ import { SvelteSet } from "svelte/reactivity";
{isRetryingInsert ? _t.translate?.run?.retrying_insert : _t.translate?.run?.retry_insert}
</button>
{/if}
<button
onclick={() => showBulkReplace = true}
class="px-3 py-1.5 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
>
{_t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
</div>
@@ -245,15 +282,21 @@ import { SvelteSet } from "svelte/reactivity";
{#each status.language_stats as lang}
<div class="px-3 py-2 bg-surface-muted rounded border border-border min-w-[140px]">
<p class="text-sm font-semibold text-text">{lang.language_code}</p>
<div class="flex gap-3 mt-1 text-xs">
<span class="text-success" title="Translated">{lang.translated_rows}</span>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs">
<span class="text-success" title="Translated">{lang.translated_rows}</span>
{#if lang.failed_rows > 0}
<span class="text-destructive" title="Failed">{lang.failed_rows}</span>
<span class="text-destructive" title="Failed">{lang.failed_rows}</span>
{/if}
{#if lang.skipped_rows > 0}
<span class="text-warning" title="Skipped">{lang.skipped_rows}</span>
<span class="text-warning" title="Skipped">{lang.skipped_rows}</span>
{/if}
<span class="text-text-subtle" title="Total">{lang.total_rows}</span>
{#if lang.token_count}
<span class="text-info" title="Tokens">{_t.translate?.run?.tokens_label?.replace('{count}', lang.token_count.toLocaleString())}</span>
{/if}
{#if lang.estimated_cost}
<span class="text-text-muted" title="Cost">{_t.translate?.run?.cost_label?.replace('{count}', lang.estimated_cost.toFixed(4))}</span>
{/if}
<span class="text-text-subtle" title="Total">{lang.total_rows}</span>
</div>
</div>
{/each}
@@ -336,6 +379,67 @@ import { SvelteSet } from "svelte/reactivity";
{/if}
</div>
<!-- Batch Breakdown (collapsible) -->
<div class="border-t border-border pt-3">
<button
onclick={async () => {
showBatches = !showBatches;
if (showBatches && batches.length === 0) {
batchesLoading = true;
try {
const data = await fetchRunBatches(runId);
batches = Array.isArray(data) ? data : [];
} catch { /* ignore */ }
batchesLoading = false;
}
}}
class="flex items-center gap-2 text-sm text-text hover:text-text"
>
<svg
class="w-4 h-4 transition-transform {showBatches ? 'rotate-90' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{_t.translate?.run?.batches_title?.replace('{count}', status.batch_count || batches.length || 0)}
</button>
{#if showBatches}
<div class="mt-2 space-y-1">
{#if batchesLoading}
<div class="text-xs text-text-subtle">{_t.translate?.common?.loading}</div>
{:else if batches.length === 0}
<p class="text-xs text-text-subtle">{_t.translate?.common?.unknown}</p>
{:else}
{#each batches as batch, idx}
<div class="flex items-center gap-3 bg-surface-muted rounded px-3 py-1.5 text-xs">
<span class="font-medium text-text-muted w-8 shrink-0">
{_t.translate?.run?.batch_number?.replace('{n}', batch.batch_index ?? idx + 1)}
</span>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium
{batch.status === 'COMPLETED' ? 'bg-success-light text-success' : ''}
{batch.status === 'FAILED' ? 'bg-destructive-light text-destructive' : ''}
{batch.status === 'RUNNING' ? 'bg-primary-light text-primary' : ''}
{!['COMPLETED','FAILED','RUNNING'].includes(batch.status) ? 'bg-surface-muted text-text-subtle' : ''}">
{batch.status}
</span>
<span class="text-text-muted">
{_t.translate?.run?.batch_records?.replace('{ok}', batch.successful_records || 0).replace('{total}', batch.total_records || 0)}
</span>
{#if batch.failed_records > 0}
<span class="text-destructive">✗{batch.failed_records}</span>
{/if}
{#if batch.started_at && batch.completed_at}
{@const bDur = ((new Date(batch.completed_at).getTime() - new Date(batch.started_at).getTime()) / 1000).toFixed(1)}
<span class="text-text-subtle ml-auto">⏱ {_t.translate?.run?.batch_duration?.replace('{seconds}', bDur)}</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<!-- Event Invariants -->
{#if status.event_invariants}
<div class="border-t border-border pt-3">
@@ -366,8 +470,27 @@ import { SvelteSet } from "svelte/reactivity";
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{_t.translate?.run?.records_title || 'Translation Records'} ({records.length})
{_t.translate?.run?.records_title || 'Translation Records'} ({records.length}{#if recordsTotal > records.length}/{recordsTotal}{/if})
</button>
<div class="flex items-center gap-2">
{#if !allRecordsLoaded && recordsTotal > records.length}
<button
onclick={loadMoreRecords}
disabled={recordsLoadingMore}
class="px-2.5 py-1 text-xs border border-border-strong text-text-muted rounded hover:bg-surface-muted disabled:opacity-50 transition-colors"
>
{recordsLoadingMore
? _t.translate?.common?.loading
: (_t.translate?.run?.load_more_records || 'Load more') + ' (' + (recordsTotal - records.length) + ')'}
</button>
{/if}
<button
onclick={() => showBulkReplace = true}
class="px-2.5 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover transition-colors"
>
{_t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
</div>
{#if showRecords}
@@ -395,8 +518,13 @@ import { SvelteSet } from "svelte/reactivity";
{#each records as row, idx}
<tr class="{row.status === 'FAILED' ? 'bg-destructive-light' : idx % 2 === 0 ? 'bg-surface-card' : 'bg-surface-muted/50'}">
<td class="px-3 py-2 text-xs text-text-subtle align-top">{idx + 1}</td>
<td class="px-3 py-2 align-top max-w-[200px]">
<code class="text-xs text-text break-all">{row.source_sql || row.source_object_name || '—'}</code>
<td class="px-3 py-2 align-top max-w-[240px]">
<div class="text-xs text-text break-all">{row.source_sql || row.source_object_name || '—'}</div>
{#if row.source_data && typeof row.source_data === 'object'}
<div class="text-[10px] text-text-subtle mt-0.5 font-mono truncate" title={JSON.stringify(row.source_data)}>
{Object.values(row.source_data).filter(Boolean).join(' · ')}
</div>
{/if}
</td>
{#if targetLanguages.length > 0}
{#each targetLanguages as lang}
@@ -458,6 +586,13 @@ import { SvelteSet } from "svelte/reactivity";
</tbody>
</table>
</div>
{#if !allRecordsLoaded && recordsTotal > records.length}
<p class="text-xs text-text-subtle mt-1">
{(_t.translate?.run?.showing_records || 'Showing {count} of {total} unique rows.')
.replace('{count}', records.length)
.replace('{total}', recordsTotal)}
</p>
{/if}
{/if}
</div>
{/if}

View File

@@ -508,9 +508,35 @@
"failed_label": "{count} failed",
"skipped_label": "{count} skipped",
"tokens_label": "{count} tokens",
"cost_label": "${count}",
"duration_label": "{seconds}s",
"cache_rate": "Cache: {pct}%",
"trigger_manual": "Manual",
"trigger_scheduled": "Scheduled",
"trigger_retry": "Retry",
"trigger_baseline_expired": "Baseline Expired",
"load_failed": "Failed to load run status",
"bulk_replace": "Bulk Replace",
"status_label": "Status"
"status_label": "Status",
"batches_title": "Batches ({count})",
"batch_number": "#{n}",
"batch_records": "{ok}/{total}",
"batch_duration": "{seconds}s",
"sum_metrics_title": "Job Metrics",
"sum_total_runs": "Total Runs",
"sum_success_rate": "Success Rate",
"sum_total_records": "Records",
"sum_total_tokens": "Tokens",
"sum_total_cost": "Cost",
"load_more_records": "Load more",
"showing_records": "Showing {count} of {total} unique rows.",
"sum_avg_duration": "Avg Duration",
"help_sum_total_runs": "Total number of translation runs executed for this job, including completed, failed, and cancelled.",
"help_sum_success_rate": "Percentage of runs that completed successfully relative to total runs.",
"help_sum_total_records": "Total number of database records processed across all runs of this job.",
"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)."
},
"corrections": {
"title": "Bulk Corrections",

View File

@@ -509,9 +509,35 @@
"failed_label": "{count} с ошибками",
"skipped_label": "{count} пропущено",
"tokens_label": "{count} токенов",
"cost_label": "${count}",
"duration_label": "{seconds}с",
"cache_rate": "Кэш: {pct}%",
"trigger_manual": "Вручную",
"trigger_scheduled": "По расписанию",
"trigger_retry": "Повтор",
"trigger_baseline_expired": "Истёк Baseline",
"load_failed": "Не удалось загрузить статус запуска",
"bulk_replace": "Массовая замена",
"status_label": "Статус"
"status_label": "Статус",
"batches_title": "Пакеты ({count})",
"batch_number": "№{n}",
"batch_records": "{ok}/{total}",
"batch_duration": "{seconds}с",
"sum_metrics_title": "Метрики задания",
"sum_total_runs": "Всего запусков",
"sum_success_rate": "Успешность",
"sum_total_records": "Записей",
"sum_total_tokens": "Токенов",
"sum_total_cost": "Стоимость",
"load_more_records": "Загрузить ещё",
"showing_records": "Показано {count} из {total} уникальных строк.",
"sum_avg_duration": "Ср. длительность",
"help_sum_total_runs": "Общее количество запусков перевода для этого задания, включая завершённые, с ошибками и отменённые.",
"help_sum_success_rate": "Процент запусков, завершившихся успешно, от общего числа запусков.",
"help_sum_total_records": "Общее количество записей БД, обработанных во всех запусках этого задания.",
"help_sum_total_tokens": "Суммарное потребление токенов LLM во всех запусках. Токены — единица входных/выходных данных, обрабатываемых языковой моделью.",
"help_sum_total_cost": "Оценка суммарной стоимости всех вызовов LLM API для этого задания, рассчитанная на основе использования токенов в каждом запуске.",
"help_sum_avg_duration": "Средняя продолжительность запуска перевода по всем запускам. Измеряется от начала до завершения (реальное время, включая вызовы LLM и вставки в БД)."
},
"corrections": {
"title": "Массовые исправления",