feat(translate): add translation cache with source_hash dedup + enforce dictionary post-processing

- Add source_hash column (SHA256 of source_text+source_data+dict_hash+config_hash) to TranslationRecord for cache dedup
- Add Alembic migration b1c2d3e4f5a6 for the new column and composite index (source_hash, status)
- Implement _compute_source_hash / _check_translation_cache in executor.py — before LLM call, check if same source+dict+config combo was already translated successfully
- Implement cache-aware routing in _process_batch: full cache hit → skip LLM, partial hit → route to LLM
- Store source_hash on all new TranslationRecord rows for future cache hits
- Strengthen dictionary prompt instructions from 'use when applicable' to 'MUST use — mandatory'
- Add _enforce_dictionary() post-processing: force-replace dictionary terms in LLM output if LLM ignored them
- Fix CRITICAL: use run.dict_snapshot_hash/config_hash (exist on TranslationRun, not TranslationJob)
- Fix MAJOR: verify cached languages cover ALL target_languages before accepting cache hit
- Fix MAJOR: prevent regex back-reference injection in dictionary enforcement (use lambda)
- Fix: add joinedload to cache lookup to avoid N+1 queries
- Fix: remove redundant single-column index (composite index is sufficient)
- Fix(frontend): parse paginated /translate/dictionaries response correctly (result.items)
This commit is contained in:
2026-05-16 00:42:07 +03:00
parent 30ba70933d
commit b466ac6211
12 changed files with 458 additions and 113 deletions

View File

@@ -1,3 +1,4 @@
import os
import sys
from logging.config import fileConfig
from pathlib import Path
@@ -14,6 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# access to the values within the .ini file in use.
config = context.config
# Allow overriding sqlalchemy.url via DATABASE_URL env var
if "DATABASE_URL" in os.environ:
config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:

View File

@@ -0,0 +1,60 @@
"""Add source_hash column to translation_records for cache dedup
Adds source_hash (SHA256 of source_text + source_data + dict/config hashes)
enabling cache-hit lookups: if the same source row + dictionary + config
combination has already been successfully translated, the LLM call is skipped.
Revision ID: b1c2d3e4f5a6
Revises: aa1b2c3d4e5f
Create Date: 2026-05-15 23:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b1c2d3e4f5a6"
down_revision: str | Sequence[str] | None = "aa1b2c3d4e5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add source_hash column with indices."""
bind = op.get_bind()
if bind.engine.name == "sqlite":
# SQLite — check existence first
inspector = sa.inspect(bind)
columns = [c["name"] for c in inspector.get_columns("translation_records")]
if "source_hash" not in columns:
op.add_column("translation_records",
sa.Column("source_hash", sa.String(), nullable=True,
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup"))
op.create_index("ix_translation_records_source_hash_status",
"translation_records", ["source_hash", "status"])
else:
# PostgreSQL and others
op.add_column("translation_records",
sa.Column("source_hash", sa.String(), nullable=True,
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup"))
op.create_index("ix_translation_records_source_hash_status",
"translation_records", ["source_hash", "status"])
def downgrade() -> None:
"""Drop source_hash column and index."""
bind = op.get_bind()
if bind.engine.name == "sqlite":
inspector = sa.inspect(bind)
columns = [c["name"] for c in inspector.get_columns("translation_records")]
if "source_hash" in columns:
op.drop_index("ix_translation_records_source_hash_status",
table_name="translation_records")
op.drop_column("translation_records", "source_hash")
else:
op.drop_index("ix_translation_records_source_hash_status",
table_name="translation_records")
op.drop_column("translation_records", "source_hash")

View File

@@ -132,12 +132,15 @@ class TranslationRecord(Base):
token_count_input = Column(Integer, nullable=True)
token_count_output = Column(Integer, nullable=True)
translation_duration_ms = Column(Integer, nullable=True)
source_hash = Column(String, nullable=True,
comment="SHA256(source_text+source_data+dict_snapshot_hash+config_hash) for cache dedup")
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
languages = relationship("TranslationLanguage", back_populates="record")
__table_args__ = (
Index("ix_translation_records_run_status", "run_id", "status"),
Index("ix_translation_records_source_hash_status", "source_hash", "status"),
)
# #endregion TranslationRecord

View File

@@ -15,7 +15,9 @@
# @RATIONALE: Batch processing with retry — independent batches allow partial recovery.
# @REJECTED: Single monolithic LLM call — would lose all progress on any failure.
import hashlib
import json
import re
import time
import uuid
from collections.abc import Callable
@@ -56,6 +58,127 @@ MAX_RETRIES_PER_BATCH = 3
MAX_ROWS_PER_RUN = 10000
# #endregion MAX_ROWS_PER_RUN
# #region _enforce_dictionary [TYPE Function]
# @BRIEF Post-processing: enforce dictionary entries in LLM output.
# If a source term from the dictionary appears in the original text,
# but the target term is missing from the translation, replace occurrences
# of the source term in the translated output with the target term.
# @PRE: dict_matches is a list of dict entries with source_term/target_term.
# @POST: per_lang_values may be mutated to include forced dictionary replacements.
# @SIDE_EFFECT: Logs when enforcement is applied.
def _enforce_dictionary(
source_text: str,
per_lang_values: dict[str, str],
dict_matches: list[dict[str, Any]],
batch_id: str,
row_id: str,
) -> None:
"""Post-processing: enforce dictionary terms in LLM translation output.
For each dictionary entry whose source_term appears in the source_text,
verify that the target_term is present in each language's translation.
If missing, replace any occurrence of the source term (which the LLM
may have left untranslated) with the dictionary target term.
"""
if not dict_matches or not source_text:
return
text_lower = source_text.lower()
for dm in dict_matches:
src_term = dm.get("source_term", "")
tgt_term = dm.get("target_term", "")
if not src_term or not tgt_term:
continue
# Only process if source term actually appears in the original text
if src_term.lower() not in text_lower:
continue
# Try to replace in each language
for lang_code in list(per_lang_values.keys()):
val = per_lang_values[lang_code]
if not val:
continue
# Check if target term is already present (case-insensitive)
if tgt_term.lower() in val.lower():
continue
# Try to find the source term (or a close variant) in the output
# This catches cases where the LLM left the source term untranslated
# Use lambda to prevent regex back-reference injection from tgt_term
src_pattern = re.compile(re.escape(src_term), re.IGNORECASE)
if src_pattern.search(val):
new_val = src_pattern.sub(lambda _: tgt_term, val)
if new_val != val:
logger.reason("Dictionary enforcement applied", {
"batch_id": batch_id,
"row_id": row_id,
"language_code": lang_code,
"source_term": src_term,
"target_term": tgt_term,
"before": val[:200],
"after": new_val[:200],
})
per_lang_values[lang_code] = new_val
# #endregion _enforce_dictionary
# #region _compute_source_hash [TYPE Function]
# @BRIEF Compute deterministic cache key for a source row.
# SHA256 of (source_text + source_data + dict_config_ctx) so that changing
# the text, context, dictionary snapshot, or job config produces a new key.
def _compute_source_hash(
source_text: str,
source_data: dict | None,
dict_snapshot_hash: str | None,
config_hash: str | None,
) -> str:
"""Deterministic cache key for a translation source row."""
payload = json.dumps({
"text": source_text,
"data": source_data,
"dict_hash": dict_snapshot_hash or "",
"config_hash": config_hash or "",
}, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
# #endregion _compute_source_hash
# #region _check_translation_cache [TYPE Function]
# @BRIEF Look up a previously successful translation by source_hash.
# Returns per-language dict {lang_code: final_value} or None.
def _check_translation_cache(
db: Session,
source_hash: str,
) -> dict[str, str] | None:
"""Check if this source_hash was already translated successfully."""
from sqlalchemy.orm import joinedload
from ...models.translate import TranslationRecord, TranslationLanguage
cached = (
db.query(TranslationRecord)
.options(joinedload(TranslationRecord.languages))
.filter(
TranslationRecord.source_hash == source_hash,
TranslationRecord.status == "SUCCESS",
)
.order_by(TranslationRecord.created_at.desc())
.first()
)
if not cached:
return None
lang_values: dict[str, str] = {}
for lang in cached.languages:
if lang.status == "translated" and lang.final_value:
lang_values[lang.language_code] = lang.final_value
return lang_values if lang_values else None
# #endregion _check_translation_cache
# #region TranslationExecutor [C:4] [TYPE Class]
# @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results.
# @PRE: DB session and config manager available.
@@ -161,6 +284,8 @@ class TranslationExecutor:
run_id=run.id,
batch_index=batch_idx,
batch_rows=batch_rows,
dict_snapshot_hash=run.dict_snapshot_hash,
config_hash=run.config_hash,
)
successful_records += batch_result["successful"]
failed_records += batch_result["failed"]
@@ -551,6 +676,8 @@ class TranslationExecutor:
run_id: str,
batch_index: int,
batch_rows: list[dict[str, Any]],
dict_snapshot_hash: str | None = None,
config_hash: str | None = None,
) -> dict[str, int]:
with belief_scope("TranslationExecutor._process_batch"):
batch_start = time.monotonic()
@@ -584,7 +711,28 @@ class TranslationExecutor:
row_context=row_context,
)
# For each row, determine if we need LLM translation or can use approved/preview edit
# ── Translation cache check: skip LLM for rows translated before ──
for row in batch_rows:
if row.get("approved_translation"):
continue
source_text = row.get("source_text", "")
if not source_text:
continue
source_data = row.get("source_data")
source_hash = _compute_source_hash(
source_text, source_data,
dict_snapshot_hash, config_hash,
)
row["_source_hash"] = source_hash
cached = _check_translation_cache(self.db, source_hash)
if cached:
row["_cached_lang_values"] = cached
logger.reason("Translation cache hit", {
"source_hash": source_hash[:12],
"langs": list(cached.keys()),
})
# For each row, determine if we need LLM translation or can use approved/preview edit/cache
rows_for_llm = []
pre_translated = []
@@ -593,6 +741,26 @@ class TranslationExecutor:
pre_translated.append(row)
continue
# Translation cache hit → treat as pre-translated
# BUT only if cached langs cover ALL target languages
cached_langs = row.get("_cached_lang_values")
if cached_langs:
target_langs_for_check = job.target_languages or [job.target_dialect or "en"]
if not isinstance(target_langs_for_check, list):
target_langs_for_check = [str(target_langs_for_check)]
cached_covers_all = all(
lang_code in cached_langs
for lang_code in target_langs_for_check
)
if cached_covers_all:
pre_translated.append(row)
continue
else:
logger.reason("Partial cache hit — missing languages, routing to LLM", {
"cached_langs": list(cached_langs.keys()),
"target_langs": target_langs_for_check,
})
# Check for preview edits carry-forward
source_data = row.get("source_data") or {}
if source_data and self._preview_edits_cache:
@@ -618,29 +786,35 @@ class TranslationExecutor:
target_languages = [str(target_languages)]
for row in pre_translated:
cached_langs = row.get("_cached_lang_values") # None for approved, dict for cache hits
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql=row.get("approved_translation"),
target_sql=row.get("approved_translation", ""),
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
source_hash=row.get("_source_hash"),
status="SUCCESS",
)
self.db.add(record)
# Create per-language entry for each target language (pre-approved)
# Create per-language entry for each target language
for lang_code in target_languages:
if cached_langs and lang_code in cached_langs:
final_val = cached_langs[lang_code]
else:
final_val = row.get("approved_translation", "")
lang_entry = TranslationLanguage(
id=str(uuid.uuid4()),
record_id=record.id,
language_code=lang_code,
source_language_detected="und",
translated_value=row.get("approved_translation"),
final_value=row.get("approved_translation"),
translated_value=final_val,
final_value=final_val,
status="translated",
needs_review=False,
)
@@ -1130,6 +1304,14 @@ class TranslationExecutor:
per_lang_values[target_languages[0]] = translation_text
has_any_translation = True
# ── Dictionary post-processing enforcement ──
# If a dictionary entry matches the source text but the target term
# is missing from the LLM output, apply forced replacement.
if dict_matches and source_text:
_enforce_dictionary(source_text, per_lang_values, dict_matches,
batch_id, row_id)
# ── End dictionary enforcement ──
if not has_any_translation:
# Empty/all-empty translations — skip
skipped += 1
@@ -1163,6 +1345,7 @@ class TranslationExecutor:
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
source_hash=row.get("_source_hash"),
status="SUCCESS",
)
self.db.add(record)

View File

@@ -49,6 +49,10 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = (
"Target dialect(s): {target_dialect}\n"
"Column to translate: {translation_column}\n\n"
"{dictionary_section}"
"IMPORTANT — You MUST use the terminology dictionary above. "
"For any source term listed in the dictionary, you MUST use its exact target translation. "
"Do not translate dictionary terms differently. "
"This is mandatory, not optional.\n\n"
"For each row, provide an accurate translation of the text into each target language.\n\n"
"Rows to translate:\n{rows_json}\n\n"
"Respond with a JSON object in this exact format:\n"
@@ -67,6 +71,10 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
"Source dialect: {source_dialect}\n"
"Column to translate: {translation_column}\n\n"
"{dictionary_section}"
"IMPORTANT — You MUST use the terminology dictionary above. "
"For any source term listed in the dictionary, you MUST use its exact target translation. "
"Do not translate dictionary terms differently. "
"This is mandatory, not optional.\n\n"
"Translate to the following languages: {target_languages}\n\n"
"For each row, provide an accurate translation of the '{translation_column}' value into each language.\n"
"Consider the context columns when determining the meaning of the text.\n\n"

View File

@@ -81,12 +81,17 @@
admin: "nav.admin",
settings: "nav.settings",
git: "nav.git",
translate: "translate.jobs.title",
};
if (specialCases[segment]) {
return _(specialCases[segment]) || segment;
}
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
return _('translate.config.breadcrumb_job') || 'Job';
}
return segment
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))

View File

@@ -39,6 +39,7 @@
let nextExecutions = $state([]);
let hasPriorRun = $state(true);
let scheduleExists = $state(false);
let isEditing = $derived(uxState === 'editing');
$effect(() => {
if (jobId) loadSchedule();
@@ -190,11 +191,13 @@
type="text"
bind:value={cronExpression}
placeholder="0 2 * * *"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500"
disabled={!isEditing}
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-blue-500 disabled:bg-gray-50 disabled:text-gray-500"
/>
<select
bind:value={cronExpression}
class="px-3 py-2 border border-gray-300 rounded-lg text-sm"
disabled={!isEditing}
class="px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
>
<option value="0 2 * * *">{$t.translate?.schedule?.preset_daily || 'Daily at 02:00'}</option>
<option value="0 */6 * * *">{$t.translate?.schedule?.preset_every_6h || 'Every 6 hours'}</option>
@@ -213,7 +216,8 @@
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.timezone || 'Timezone'}</label>
<select
bind:value={timezone}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
disabled={!isEditing}
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm disabled:bg-gray-50 disabled:text-gray-500"
>
{#each commonTimezones as tz}
<option value={tz}>{tz}</option>
@@ -226,15 +230,15 @@
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.translate?.schedule?.execution_mode || 'Execution Mode'}</label>
<div class="flex gap-4">
<label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
<input type="radio" bind:group={executionMode} value="full" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_full || 'Full'}</div>
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_full_desc || 'Translate all rows every run'}</div>
</div>
</label>
<label class="inline-flex items-center cursor-pointer">
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400">
<input type="radio" bind:group={executionMode} value="new_key_only" class="sr-only peer" disabled={!isEditing} />
<div class="px-4 py-2 text-sm border border-gray-300 rounded-lg peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700 hover:border-gray-400 {isEditing ? '' : 'opacity-70'}">
<div class="font-medium">{$t.translate?.schedule?.mode_new_keys || 'New Keys Only'}</div>
<div class="text-xs text-gray-500 mt-0.5">{$t.translate?.schedule?.mode_new_keys_desc || 'Only translate rows with new key values'}</div>
</div>

View File

@@ -55,6 +55,15 @@
let pendingCount = $derived(records.filter(r => r.status === 'PENDING').length);
let isAllApproved = $derived(pendingCount === 0 && records.length > 0);
function getRowStatusLabel(status) {
const labels = {
APPROVED: $t.translate?.preview?.status_approved || 'Approved',
REJECTED: $t.translate?.preview?.status_rejected || 'Rejected',
PENDING: $t.translate?.preview?.status_pending || 'Pending',
};
return labels[status] || status;
}
/** Get language status for a record and language code */
function getLangStatus(record, langCode) {
const lang = (record.languages || []).find(l => l.language_code === langCode);
@@ -131,6 +140,37 @@
}
}
/** @param {string} rowId @param {string} langCode */
async function handleApproveLanguage(rowId, langCode) {
try {
const updated = await approveRow(jobId, rowId, langCode);
records = records.map(r => r.id === rowId ? {
...r,
status: updated.status || r.status,
target_sql: updated.target_sql || r.target_sql,
languages: updated.languages || r.languages,
_isEdited: false
} : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.approve_failed, 'error');
}
}
/** @param {string} rowId @param {string} langCode */
async function handleRejectLanguage(rowId, langCode) {
try {
const updated = await rejectRow(jobId, rowId, langCode);
records = records.map(r => r.id === rowId ? {
...r,
status: updated.status || r.status,
languages: updated.languages || r.languages,
_isEdited: false
} : r);
} catch (err) {
addToast(err?.message || $t.translate?.preview?.reject_failed, 'error');
}
}
/** @param {string} rowId @param {string} langCode */
function startLangEdit(rowId, langCode) {
const key = `${rowId}_${langCode}`;
@@ -427,7 +467,7 @@
<div class="flex gap-1 mt-1 flex-wrap">
{#if langStatus !== 'approved' && langStatus !== 'edited'}
<button
onclick={() => approveRow(jobId, row.id, lang)}
onclick={() => handleApproveLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors"
title={$t.translate?.preview?.approve}
>
@@ -443,7 +483,7 @@
</button>
{#if langStatus !== 'rejected'}
<button
onclick={() => rejectRow(jobId, row.id, lang)}
onclick={() => handleRejectLanguage(row.id, lang)}
class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
title={$t.translate?.preview?.reject}
>
@@ -461,7 +501,7 @@
{row.status === 'REJECTED' ? 'bg-red-100 text-red-700' : ''}
{row.status === 'PENDING' ? 'bg-yellow-100 text-yellow-700' : ''}
">
{row.status}
{getRowStatusLabel(row.status)}
</span>
<div class="flex gap-1">
{#if row.status !== 'APPROVED'}

View File

@@ -45,7 +45,6 @@
import CorrectionCell from './CorrectionCell.svelte';
import TermCorrectionPopup from './TermCorrectionPopup.svelte';
import BulkReplaceModal from './BulkReplaceModal.svelte';
import BulkCorrectionSidebar from './BulkCorrectionSidebar.svelte';
/** @type {{ runId: string, onRefresh: Function }} */
let { runId, onRefresh = () => {} } = $props();
@@ -65,7 +64,17 @@
let targetLanguages = $state([]);
let correctionPopupData = $state(null);
let showBulkReplace = $state(false);
let bulkCorrectionItems = $state([]);
function getRunStatusLabel(value) {
const labels = {
COMPLETED: $t.translate?.run?.completed || 'Completed',
FAILED: $t.translate?.run?.translation_failed || 'Failed',
RUNNING: $t.translate?.history?.status_running || 'Running',
PENDING: $t.translate?.history?.status_pending || 'Pending',
CANCELLED: $t.translate?.run?.cancelled || 'Cancelled',
};
return labels[value] || value;
}
// Derived
let insertStatusBadge = $derived.by(() => {
@@ -211,12 +220,6 @@
{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-indigo-600 text-white rounded hover:bg-indigo-700 transition-colors"
>
{$t.translate?.run?.bulk_replace || 'Bulk Replace'}
</button>
</div>
</div>
@@ -278,6 +281,10 @@
{$t.translate?.run?.copy_id}
</button>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.status_label || 'Статус'}</span>
<span class="ml-1 text-xs font-medium text-gray-700">{getRunStatusLabel(status.status)}</span>
</div>
<div>
<span class="text-gray-500">{$t.translate?.run?.insert_status}</span>
<span class="ml-1 inline-flex px-2 py-0.5 text-xs rounded-full font-medium {insertStatusBadge.class}">
@@ -434,7 +441,6 @@
</span>
</td>
<td class="px-3 py-2 text-center align-top">
<div class="flex flex-col gap-1">
<button
onclick={() => {
const firstLang = row.languages?.[0] || {};
@@ -452,27 +458,6 @@
>
{$t.translate?.run?.correct || 'Correct'}
</button>
<button
onclick={() => {
const firstLang = row.languages?.[0] || {};
const item = {
sourceTerm: row.source_sql || row.source_object_name || '',
incorrectTarget: firstLang?.final_value || firstLang?.translated_value || '',
rowKey: row.id
};
const exists = bulkCorrectionItems.some(
i => i.sourceTerm === item.sourceTerm && i.incorrectTarget === item.incorrectTarget
);
if (!exists) {
bulkCorrectionItems = [...bulkCorrectionItems, item];
}
}}
class="px-2 py-0.5 text-[10px] bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
title="Collect for bulk correction"
>
{$t.translate?.run?.collect || 'Collect'}
</button>
</div>
</td>
</tr>
{/each}
@@ -500,11 +485,4 @@
onClose={() => showBulkReplace = false}
onApplied={(count) => { showBulkReplace = false; loadData(); onRefresh(); }}
/>
<BulkCorrectionSidebar
{runId}
initialItems={bulkCorrectionItems}
onClose={() => bulkCorrectionItems = []}
onSubmitted={() => { bulkCorrectionItems = []; loadData(); onRefresh(); }}
/>
<!-- #endregion TranslationRunResult -->

View File

@@ -55,8 +55,15 @@
"target_schema_placeholder": "e.g. public",
"target_table_placeholder": "e.g. users_translated",
"target_language": "Target Language",
"target_language_search_placeholder": "Search languages...",
"target_language_required": "Select at least one target language",
"status": "Status",
"status_draft": "Draft",
"status_ready": "Ready",
"status_active": "Active",
"target_column_placeholder": "Target Column",
"target_column_mapping_title": "Target Column Mapping",
"target_column_mapping_description": "Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.",
"target_column_label": "Target Column (translated text)",
"target_column_hint": "Column where translated text will be inserted",
"target_language_column_label": "Language Column",
@@ -108,7 +115,10 @@
"include_source_reference": "Include source language in translations",
"include_source_reference_hint": "The original text will be stored as a verified reference copy in its detected language",
"disable_reasoning": "Disable reasoning (save tokens)",
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning"
"disable_reasoning_hint": "Saves output tokens by suppressing Chain of Thought reasoning",
"mark_ready": "Mark as READY",
"save_job_first": "Save the job first",
"breadcrumb_job": "Job"
},
"preview": {
"title": "Preview",
@@ -170,7 +180,10 @@
"detected_language": "Detected Lang",
"languages_count": "Languages: {count}",
"across_languages": "Across {count} language(s)",
"language_label": "Language"
"language_label": "Language",
"status_approved": "Approved",
"status_rejected": "Rejected",
"status_pending": "Pending"
},
"jobs": {
"title": "Translation Jobs",
@@ -256,6 +269,7 @@
"next_executions": "Next Executions",
"enabled": "Enabled",
"disabled": "Disabled",
"tab_label": "Schedule",
"edit": "Edit",
"update": "Update",
"create": "Create",
@@ -450,7 +464,9 @@
"failed_label": "{count} failed",
"skipped_label": "{count} skipped",
"tokens_label": "{count} tokens",
"load_failed": "Failed to load run status"
"load_failed": "Failed to load run status",
"bulk_replace": "Bulk Replace",
"status_label": "Status"
},
"corrections": {
"title": "Bulk Corrections",

View File

@@ -55,8 +55,15 @@
"target_schema_placeholder": "например, public",
"target_table_placeholder": "например, users_translated",
"target_language": "Язык перевода",
"target_language_search_placeholder": "Поиск языков...",
"target_language_required": "Выберите хотя бы один язык перевода",
"status": "Статус",
"status_draft": "Черновик",
"status_ready": "Готово",
"status_active": "Активно",
"target_column_placeholder": "Целевая колонка",
"target_column_mapping_title": "Маппинг целевых колонок",
"target_column_mapping_description": "Настройте, в какие колонки писать перевод, коды языков, исходный текст и определённый язык источника при INSERT.",
"target_column_label": "Целевая колонка (переведённый текст)",
"target_column_hint": "Колонка, в которую будет вставлен переведённый текст",
"target_language_column_label": "Колонка языка",
@@ -108,7 +115,10 @@
"include_source_reference": "Сохранять исходный текст как эталонную копию",
"include_source_reference_hint": "Исходный текст будет сохранён как верифицированная эталонная копия на его обнаруженном языке",
"disable_reasoning": "Отключить рассуждения (экономия токенов)",
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)"
"disable_reasoning_hint": "Экономит токены на выходе, отключая цепочку рассуждений (Chain of Thought)",
"mark_ready": "Пометить как готово",
"save_job_first": "Сначала сохраните задание",
"breadcrumb_job": "Задание"
},
"preview": {
"title": "Предпросмотр",
@@ -171,6 +181,10 @@
"languages_count": "Языков: {count}",
"across_languages": "Для {count} язык(ов)",
"language_label": "Язык"
,
"status_approved": "Одобрено",
"status_rejected": "Отклонено",
"status_pending": "Ожидает"
},
"jobs": {
"title": "Задания перевода",
@@ -256,6 +270,7 @@
"next_executions": "Следующие выполнения",
"enabled": "Включено",
"disabled": "Отключено",
"tab_label": "Расписание",
"edit": "Редактировать",
"update": "Обновить",
"create": "Создать",
@@ -450,7 +465,9 @@
"failed_label": "{count} с ошибками",
"skipped_label": "{count} пропущено",
"tokens_label": "{count} токенов",
"load_failed": "Не удалось загрузить статус запуска"
"load_failed": "Не удалось загрузить статус запуска",
"bulk_replace": "Массовая замена",
"status_label": "Статус"
},
"corrections": {
"title": "Массовые исправления",

View File

@@ -58,33 +58,35 @@
clearOnCompleteCallback,
} from '$lib/stores/translationRun.js';
const LANGUAGES = [
{ code: 'ru', name: 'Russian' },
{ code: 'en', name: 'English' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
{ code: 'tr', name: 'Turkish' },
{ code: 'nl', name: 'Dutch' },
{ code: 'pl', name: 'Polish' },
{ code: 'sv', name: 'Swedish' },
{ code: 'da', name: 'Danish' },
{ code: 'fi', name: 'Finnish' },
{ code: 'cs', name: 'Czech' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'ro', name: 'Romanian' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'th', name: 'Thai' },
{ code: 'he', name: 'Hebrew' },
{ code: 'id', name: 'Indonesian' },
{ code: 'ms', name: 'Malay' },
];
const LANGUAGE_LABELS = {
ru: 'Русский',
en: 'English',
de: 'Deutsch',
fr: 'Français',
es: 'Español',
it: 'Italiano',
pt: 'Português',
zh: '中文',
ja: '日本語',
ko: '한국어',
ar: 'العربية',
tr: 'Türkçe',
nl: 'Nederlands',
pl: 'Polski',
sv: 'Svenska',
da: 'Dansk',
fi: 'Suomi',
cs: 'Čeština',
hu: 'Magyar',
ro: 'Română',
vi: 'Tiếng Việt',
th: 'ไทย',
he: 'עברית',
id: 'Bahasa Indonesia',
ms: 'Bahasa Melayu',
};
const LANGUAGES = Object.entries(LANGUAGE_LABELS).map(([code, name]) => ({ code, name }));
/** @type {string} idle | loading | configured | saving | validation_error | datasource_unavailable */
let uxState = $state('idle');
@@ -147,7 +149,7 @@
{ id: 'run', label: $t.translate?.config?.run_translation || 'Run' },
];
if (!isNewJob && existingJob) {
base.push({ id: 'schedule', label: 'Schedule' });
base.push({ id: 'schedule', label: $t.translate?.schedule?.tab_label || 'Schedule' });
}
return base;
});
@@ -160,6 +162,12 @@
let isSaving = $state(false);
let validationErrors = $state({});
let warnings = $state([]);
let pageTitle = $derived.by(() => {
if (isNewJob) return $t.translate?.config?.new_title || 'New Translation Job';
const jobName = existingJob?.name || name;
const base = $t.translate?.config?.edit_title || 'Edit Translation Job';
return jobName ? `${jobName} | ${base}` : base;
});
// Run state — using global store to survive page navigation
let runState = fromStore(translationRunStore);
@@ -272,8 +280,8 @@
// Load dictionaries (from available translate dictionaries)
try {
const dicts = await api.fetchApi('/translate/dictionaries').catch(() => []);
availableDictionaries = Array.isArray(dicts) ? dicts : [];
const result = await api.fetchApi('/translate/dictionaries').catch(() => ({}));
availableDictionaries = Array.isArray(result) ? result : (result?.items || []);
} catch (_e) {
availableDictionaries = [];
}
@@ -610,8 +618,25 @@
function isVirtual(col) {
return virtualColumnNames.includes(col);
}
function getJobStatusLabel(value) {
const labels = {
DRAFT: $t.translate?.config?.status_draft || 'Draft',
READY: $t.translate?.config?.status_ready || 'Ready',
ACTIVE: $t.translate?.config?.status_active || 'Active',
RUNNING: $t.translate?.jobs?.status_running || 'Running',
COMPLETED: $t.translate?.jobs?.status_completed || 'Completed',
FAILED: $t.translate?.jobs?.status_failed || 'Failed',
CANCELLED: $t.translate?.history?.status_cancelled || 'Cancelled',
};
return labels[value] || value || ($t.translate?.config?.status_draft || 'Draft');
}
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<div class="container mx-auto px-4 py-6 max-w-full xl:max-w-7xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
@@ -1031,8 +1056,8 @@
<!-- Target Column Mapping -->
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Target Column Mapping</h3>
<p class="text-xs text-gray-400 mb-3">Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.</p>
<h3 class="text-sm font-semibold text-gray-900 mb-3">{$t.translate?.config?.target_column_mapping_title || 'Target Column Mapping'}</h3>
<p class="text-xs text-gray-400 mb-3">{$t.translate?.config?.target_column_mapping_description || 'Configure which columns receive translated data, language codes, source text, and detected source language during INSERT.'}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
@@ -1105,11 +1130,11 @@
options={LANGUAGES}
bind:selected={targetLanguages}
searchable={true}
placeholder="Search languages..."
placeholder={$t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={true}
/>
{#if targetLanguages.length === 0}
<p class="text-xs text-amber-600 mt-1">Select at least one target language</p>
<p class="text-xs text-amber-600 mt-1">{$t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if}
</div>
<div>
@@ -1225,7 +1250,7 @@
</section>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div>
{/if}
</div>
@@ -1242,10 +1267,11 @@
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{status === 'READY' ? 'bg-green-100 text-green-700' : ''}
{status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700' : ''}
{status === 'ACTIVE' ? 'bg-emerald-100 text-emerald-700' : ''}
{status === 'RUNNING' ? 'bg-blue-100 text-blue-700' : ''}
{status === 'COMPLETED' ? 'bg-green-100 text-green-700' : ''}
{status === 'FAILED' ? 'bg-red-100 text-red-700' : ''}">
{status || 'DRAFT'}
{getJobStatusLabel(status)}
</span>
{#if status === 'DRAFT'}
<button
@@ -1260,7 +1286,7 @@
}}
class="ml-auto px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Mark as READY
{$t.translate?.config?.mark_ready || 'Mark as READY'}
</button>
{/if}
</div>
@@ -1350,7 +1376,7 @@
</section>
{:else}
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-sm text-gray-500">{$t.translate?.config?.save} {$t.translate?.config?.basic_info?.toLowerCase?.() || 'Save the job first'}</p>
<p class="text-sm text-gray-500">{$t.translate?.config?.save_job_first || 'Save the job first'}</p>
</div>
{/if}
</div>