@purposeinplay/payload-ai-translate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +714 -0
- package/dist/alerts-collection.d.ts +21 -0
- package/dist/alerts-collection.js +159 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +918 -0
- package/dist/bulk-translate-batches-collection.d.ts +29 -0
- package/dist/bulk-translate-batches-collection.js +404 -0
- package/dist/bulk-translate-units-collection.d.ts +35 -0
- package/dist/bulk-translate-units-collection.js +310 -0
- package/dist/client/estimated-cost-cell.d.ts +6 -0
- package/dist/client/estimated-cost-cell.js +12 -0
- package/dist/client/excluded-fields-field.d.ts +45 -0
- package/dist/client/excluded-fields-field.js +553 -0
- package/dist/client/field-translate-button.d.ts +6 -0
- package/dist/client/field-translate-button.js +199 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +6 -0
- package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
- package/dist/client/lib/use-global-kill-switches.js +58 -0
- package/dist/client/translate-button.d.ts +2 -0
- package/dist/client/translate-button.js +228 -0
- package/dist/client/translate-modal.d.ts +16 -0
- package/dist/client/translate-modal.js +549 -0
- package/dist/client/translation-progress.d.ts +10 -0
- package/dist/client/translation-progress.js +297 -0
- package/dist/components/TranslationNavGroup.d.ts +45 -0
- package/dist/components/TranslationNavGroup.js +104 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.js +16 -0
- package/dist/endpoints/client-config.d.ts +44 -0
- package/dist/endpoints/client-config.js +145 -0
- package/dist/endpoints/estimate.d.ts +5 -0
- package/dist/endpoints/estimate.js +237 -0
- package/dist/endpoints/progress.d.ts +2 -0
- package/dist/endpoints/progress.js +314 -0
- package/dist/endpoints/translate.d.ts +11 -0
- package/dist/endpoints/translate.js +376 -0
- package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
- package/dist/endpoints/translation-hub/_helpers.js +297 -0
- package/dist/endpoints/translation-hub/active.d.ts +21 -0
- package/dist/endpoints/translation-hub/active.js +220 -0
- package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
- package/dist/endpoints/translation-hub/cancel.js +233 -0
- package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
- package/dist/endpoints/translation-hub/enqueue.js +529 -0
- package/dist/endpoints/translation-hub/failures.d.ts +12 -0
- package/dist/endpoints/translation-hub/failures.js +67 -0
- package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
- package/dist/endpoints/translation-hub/force-reset.js +144 -0
- package/dist/endpoints/translation-hub/index.d.ts +21 -0
- package/dist/endpoints/translation-hub/index.js +20 -0
- package/dist/endpoints/translation-hub/list.d.ts +40 -0
- package/dist/endpoints/translation-hub/list.js +182 -0
- package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
- package/dist/endpoints/translation-hub/preflight.js +141 -0
- package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
- package/dist/endpoints/translation-hub/retry-failed.js +235 -0
- package/dist/endpoints/translation-hub/revert.d.ts +88 -0
- package/dist/endpoints/translation-hub/revert.js +405 -0
- package/dist/endpoints/translation-hub/status.d.ts +45 -0
- package/dist/endpoints/translation-hub/status.js +391 -0
- package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
- package/dist/endpoints/translation-hub/usage-summary.js +481 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/components.d.ts +6 -0
- package/dist/exports/components.js +5 -0
- package/dist/exports/index.d.ts +8 -0
- package/dist/exports/index.js +7 -0
- package/dist/exports/providers.d.ts +9 -0
- package/dist/exports/providers.js +5 -0
- package/dist/exports/views-client.d.ts +23 -0
- package/dist/exports/views-client.js +22 -0
- package/dist/exports/views.d.ts +30 -0
- package/dist/exports/views.js +29 -0
- package/dist/hooks/after-change-global.d.ts +4 -0
- package/dist/hooks/after-change-global.js +109 -0
- package/dist/hooks/after-change.d.ts +16 -0
- package/dist/hooks/after-change.js +205 -0
- package/dist/hooks/after-delete.d.ts +30 -0
- package/dist/hooks/after-delete.js +95 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jobs-collection.d.ts +17 -0
- package/dist/jobs-collection.js +139 -0
- package/dist/lexical/classifier.d.ts +3 -0
- package/dist/lexical/classifier.js +108 -0
- package/dist/lexical/deserializer.d.ts +4 -0
- package/dist/lexical/deserializer.js +263 -0
- package/dist/lexical/placeholder-integrity.d.ts +6 -0
- package/dist/lexical/placeholder-integrity.js +21 -0
- package/dist/lexical/placeholders.d.ts +21 -0
- package/dist/lexical/placeholders.js +117 -0
- package/dist/lexical/serializer.d.ts +21 -0
- package/dist/lexical/serializer.js +233 -0
- package/dist/lexical/types.d.ts +32 -0
- package/dist/lexical/types.js +1 -0
- package/dist/lib/auth-diagnostics.d.ts +14 -0
- package/dist/lib/auth-diagnostics.js +19 -0
- package/dist/lib/batch-counts.d.ts +58 -0
- package/dist/lib/batch-counts.js +105 -0
- package/dist/lib/bulk-translate-migrations.d.ts +92 -0
- package/dist/lib/bulk-translate-migrations.js +153 -0
- package/dist/lib/coalescing-queue.d.ts +38 -0
- package/dist/lib/coalescing-queue.js +69 -0
- package/dist/lib/content-extractor.d.ts +16 -0
- package/dist/lib/content-extractor.js +410 -0
- package/dist/lib/content-hash.d.ts +1 -0
- package/dist/lib/content-hash.js +19 -0
- package/dist/lib/content-patcher.d.ts +15 -0
- package/dist/lib/content-patcher.js +293 -0
- package/dist/lib/cost-guards.d.ts +2 -0
- package/dist/lib/cost-guards.js +18 -0
- package/dist/lib/daily-spend-cap.d.ts +58 -0
- package/dist/lib/daily-spend-cap.js +233 -0
- package/dist/lib/effective-locales.d.ts +181 -0
- package/dist/lib/effective-locales.js +302 -0
- package/dist/lib/error-messages.d.ts +245 -0
- package/dist/lib/error-messages.js +626 -0
- package/dist/lib/events.d.ts +39 -0
- package/dist/lib/events.js +146 -0
- package/dist/lib/exclude-fields.d.ts +3 -0
- package/dist/lib/exclude-fields.js +64 -0
- package/dist/lib/field-breadcrumb.d.ts +31 -0
- package/dist/lib/field-breadcrumb.js +227 -0
- package/dist/lib/field-diff.d.ts +1 -0
- package/dist/lib/field-diff.js +25 -0
- package/dist/lib/field-empty.d.ts +2 -0
- package/dist/lib/field-empty.js +68 -0
- package/dist/lib/field-resolver.d.ts +3 -0
- package/dist/lib/field-resolver.js +164 -0
- package/dist/lib/group-soft-skips.d.ts +39 -0
- package/dist/lib/group-soft-skips.js +45 -0
- package/dist/lib/locale-merge.d.ts +44 -0
- package/dist/lib/locale-merge.js +357 -0
- package/dist/lib/locale-row-check.d.ts +30 -0
- package/dist/lib/locale-row-check.js +64 -0
- package/dist/lib/logger.d.ts +74 -0
- package/dist/lib/logger.js +97 -0
- package/dist/lib/manual-edit-guard.d.ts +128 -0
- package/dist/lib/manual-edit-guard.js +393 -0
- package/dist/lib/output-validation.d.ts +48 -0
- package/dist/lib/output-validation.js +148 -0
- package/dist/lib/payload-read.d.ts +16 -0
- package/dist/lib/payload-read.js +51 -0
- package/dist/lib/per-doc-claim.d.ts +90 -0
- package/dist/lib/per-doc-claim.js +140 -0
- package/dist/lib/per-doc-lock.d.ts +94 -0
- package/dist/lib/per-doc-lock.js +119 -0
- package/dist/lib/persist-usage.d.ts +91 -0
- package/dist/lib/persist-usage.js +116 -0
- package/dist/lib/progress-store.d.ts +103 -0
- package/dist/lib/progress-store.js +314 -0
- package/dist/lib/rate-limiter.d.ts +3 -0
- package/dist/lib/rate-limiter.js +53 -0
- package/dist/lib/snapshot-select.d.ts +43 -0
- package/dist/lib/snapshot-select.js +108 -0
- package/dist/lib/translate-prompt.d.ts +31 -0
- package/dist/lib/translate-prompt.js +66 -0
- package/dist/lib/translation-token-bucket.d.ts +57 -0
- package/dist/lib/translation-token-bucket.js +365 -0
- package/dist/lib/truncate-source-value.d.ts +1 -0
- package/dist/lib/truncate-source-value.js +27 -0
- package/dist/manual-edit-collection.d.ts +22 -0
- package/dist/manual-edit-collection.js +124 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +934 -0
- package/dist/providers/ai-sdk-adapter.d.ts +35 -0
- package/dist/providers/ai-sdk-adapter.js +100 -0
- package/dist/providers/anthropic.d.ts +31 -0
- package/dist/providers/anthropic.js +66 -0
- package/dist/providers/custom.d.ts +36 -0
- package/dist/providers/custom.js +24 -0
- package/dist/providers/gemini.d.ts +20 -0
- package/dist/providers/gemini.js +48 -0
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.js +29 -0
- package/dist/providers/openai.d.ts +28 -0
- package/dist/providers/openai.js +69 -0
- package/dist/settings-global.d.ts +74 -0
- package/dist/settings-global.js +216 -0
- package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
- package/dist/tasks/bulk-translate-coordinator.js +708 -0
- package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
- package/dist/tasks/bulk-translate-doc-task.js +1000 -0
- package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
- package/dist/tasks/bulk-translate-janitor.js +311 -0
- package/dist/tasks/translate-job-task.d.ts +51 -0
- package/dist/tasks/translate-job-task.js +154 -0
- package/dist/translate.d.ts +113 -0
- package/dist/translate.js +911 -0
- package/dist/translation-daily-spend-collection.d.ts +24 -0
- package/dist/translation-daily-spend-collection.js +133 -0
- package/dist/translation-rate-limits-collection.d.ts +30 -0
- package/dist/translation-rate-limits-collection.js +144 -0
- package/dist/types.d.ts +672 -0
- package/dist/types.js +1 -0
- package/dist/usage-collection.d.ts +14 -0
- package/dist/usage-collection.js +377 -0
- package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
- package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
- package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
- package/dist/views/BulkRunsHub/BucketRow.js +982 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
- package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
- package/dist/views/BulkRunsHub/EmptyState.js +64 -0
- package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
- package/dist/views/BulkRunsHub/FilterBar.js +284 -0
- package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
- package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
- package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
- package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
- package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
- package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
- package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
- package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
- package/dist/views/BulkRunsHub/index.d.ts +17 -0
- package/dist/views/BulkRunsHub/index.js +80 -0
- package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
- package/dist/views/BulkRunsHub/urlFilters.js +50 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
- package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
- package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
- package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
- package/dist/views/TranslationHub/ActiveJobs.js +320 -0
- package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
- package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
- package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
- package/dist/views/TranslationHub/AlertBanner.js +568 -0
- package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
- package/dist/views/TranslationHub/AuditPanel.js +1367 -0
- package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
- package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
- package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
- package/dist/views/TranslationHub/Hub.client.js +357 -0
- package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
- package/dist/views/TranslationHub/ModelCombobox.js +415 -0
- package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
- package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
- package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
- package/dist/views/TranslationHub/SettingsRail.js +382 -0
- package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
- package/dist/views/TranslationHub/StatusStrip.js +451 -0
- package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
- package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
- package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
- package/dist/views/TranslationHub/UsageTable.js +1240 -0
- package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
- package/dist/views/TranslationHub/alertGrouping.js +99 -0
- package/dist/views/TranslationHub/index.d.ts +20 -0
- package/dist/views/TranslationHub/index.js +109 -0
- package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
- package/dist/views/TranslationHub/tabNavigation.js +74 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
- package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
- package/dist/views/TranslationHub/useFocusTrap.js +81 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
- package/dist/views/shared/EditorError.d.ts +97 -0
- package/dist/views/shared/EditorError.js +205 -0
- package/dist/views/shared/ModelCell.d.ts +18 -0
- package/dist/views/shared/ModelCell.js +31 -0
- package/dist/views/shared/docHref.d.ts +16 -0
- package/dist/views/shared/docHref.js +26 -0
- package/dist/views/shared/fetch-error-body.d.ts +25 -0
- package/dist/views/shared/fetch-error-body.js +42 -0
- package/dist/views/shared/filterPillStyle.d.ts +35 -0
- package/dist/views/shared/filterPillStyle.js +40 -0
- package/dist/views/shared/format.d.ts +75 -0
- package/dist/views/shared/format.js +131 -0
- package/package.json +141 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { DEFAULT_RETRY, REENTRY_FLAG } from './defaults.js';
|
|
2
|
+
import { checkPerCallLimit } from './lib/cost-guards.js';
|
|
3
|
+
import { emitAlert, emitEvent } from './lib/events.js';
|
|
4
|
+
import { isFieldEmpty } from './lib/field-empty.js';
|
|
5
|
+
import { mergeTranslationsIntoTargetDoc } from './lib/locale-merge.js';
|
|
6
|
+
import { checkLocaleRowExists } from './lib/locale-row-check.js';
|
|
7
|
+
import { createScopedLogger } from './lib/logger.js';
|
|
8
|
+
import { applyHashSkip, hashFieldValue, loadFieldHashes, pickManualEditedFields, recordHashesAfterWrite } from './lib/manual-edit-guard.js';
|
|
9
|
+
import { validateTranslation } from './lib/output-validation.js';
|
|
10
|
+
import { findByIdNoFallback } from './lib/payload-read.js';
|
|
11
|
+
import { truncateSourceValue } from './lib/truncate-source-value.js';
|
|
12
|
+
import { DEFAULT_MANUAL_EDIT_COLLECTION_SLUG } from './manual-edit-collection.js';
|
|
13
|
+
export async function translateForLocale(params) {
|
|
14
|
+
const { payload, config, sourceDoc, documentId, collection, targetLocale, fields, allLocalizedTopFields, writeMode, targetPolicy, signal, req, force } = params;
|
|
15
|
+
let { units } = params;
|
|
16
|
+
const succeeded = [];
|
|
17
|
+
const preserved = [];
|
|
18
|
+
const failed = [];
|
|
19
|
+
const totalUsage = {
|
|
20
|
+
inputTokens: 0,
|
|
21
|
+
outputTokens: 0,
|
|
22
|
+
estimatedCostUsd: 0
|
|
23
|
+
};
|
|
24
|
+
let providerLatencyMs = 0;
|
|
25
|
+
// Same source and target locale → no-op
|
|
26
|
+
if (targetLocale === config.sourceLocale) {
|
|
27
|
+
console.warn(`[ai-translate] Source and target locale are the same ("${targetLocale}") — skipping.`);
|
|
28
|
+
return {
|
|
29
|
+
succeeded,
|
|
30
|
+
preserved,
|
|
31
|
+
failed,
|
|
32
|
+
usage: totalUsage,
|
|
33
|
+
providerLatencyMs
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Preserve policy: skip fields that already have content in the target locale
|
|
37
|
+
if (targetPolicy === 'preserve') {
|
|
38
|
+
units = await filterPreservedFields(payload, collection, documentId, targetLocale, units, fields);
|
|
39
|
+
}
|
|
40
|
+
if (units.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
succeeded,
|
|
43
|
+
preserved,
|
|
44
|
+
failed,
|
|
45
|
+
usage: totalUsage,
|
|
46
|
+
providerLatencyMs
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// BUG-21 fix: hash-skip on unchanged-content re-translate.
|
|
50
|
+
//
|
|
51
|
+
// If every translatable top-level field in this surface has stored
|
|
52
|
+
// source+target hashes matching the current source+target, the
|
|
53
|
+
// editor clicked Translate but nothing changed since the last write.
|
|
54
|
+
// Sending those units to the LLM would just spend tokens to produce
|
|
55
|
+
// identical output. Skip the LLM call AND report the units as
|
|
56
|
+
// 'skipped' with reason 'unchanged since last translate' so the
|
|
57
|
+
// editor sees clearly that nothing was re-translated.
|
|
58
|
+
//
|
|
59
|
+
// Requires `preserveManualEdits` — that's where the hashes are
|
|
60
|
+
// stored (and where they're recorded post-write). Without it, the
|
|
61
|
+
// plugin has no per-field hash history to compare against.
|
|
62
|
+
//
|
|
63
|
+
// `force` bypass: when the caller signals that the editor explicitly
|
|
64
|
+
// asked for a re-translate regardless of cached state (bulk-translate
|
|
65
|
+
// `mode: 'force'`), we skip the hash-skip path entirely so every
|
|
66
|
+
// translatable field hits the LLM. Hash bookkeeping after the write
|
|
67
|
+
// still runs so the next non-force call benefits from fresh hashes.
|
|
68
|
+
if (config.preserveManualEdits && !force) {
|
|
69
|
+
const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
|
|
70
|
+
const skip = await applyHashSkip({
|
|
71
|
+
payload,
|
|
72
|
+
metaCollection: metaSlug,
|
|
73
|
+
surfaceSlug: collection,
|
|
74
|
+
documentId,
|
|
75
|
+
targetLocale,
|
|
76
|
+
units,
|
|
77
|
+
sourceDoc,
|
|
78
|
+
loadTargetDoc: ()=>findByIdNoFallback(payload, collection, documentId, targetLocale, {
|
|
79
|
+
draft: true
|
|
80
|
+
})
|
|
81
|
+
});
|
|
82
|
+
units = skip.remainingUnits;
|
|
83
|
+
succeeded.push(...skip.hashSkipped);
|
|
84
|
+
if (units.length === 0) {
|
|
85
|
+
return {
|
|
86
|
+
succeeded,
|
|
87
|
+
preserved,
|
|
88
|
+
failed,
|
|
89
|
+
usage: totalUsage,
|
|
90
|
+
providerLatencyMs
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Batch units by perCallCharLimit + perCallItemLimit
|
|
95
|
+
const batches = batchUnits(units, config.costLimits.perCallCharLimit, config.costLimits.perCallItemLimit);
|
|
96
|
+
const retryConfig = {
|
|
97
|
+
...DEFAULT_RETRY,
|
|
98
|
+
...config.retry ?? {}
|
|
99
|
+
};
|
|
100
|
+
// Translate each batch
|
|
101
|
+
const translatedMap = new Map();
|
|
102
|
+
for (const batch of batches){
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
for (const unit of batch){
|
|
105
|
+
failed.push({
|
|
106
|
+
fieldPath: unit.fieldPath,
|
|
107
|
+
locale: targetLocale,
|
|
108
|
+
status: 'failed',
|
|
109
|
+
error: 'Aborted'
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const batchResult = await translateBatch({
|
|
115
|
+
batch,
|
|
116
|
+
config,
|
|
117
|
+
collection,
|
|
118
|
+
targetLocale,
|
|
119
|
+
signal,
|
|
120
|
+
retryConfig,
|
|
121
|
+
payload
|
|
122
|
+
});
|
|
123
|
+
// Merge usage
|
|
124
|
+
totalUsage.inputTokens += batchResult.usage.inputTokens;
|
|
125
|
+
totalUsage.outputTokens += batchResult.usage.outputTokens;
|
|
126
|
+
totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (batchResult.usage.estimatedCostUsd ?? 0);
|
|
127
|
+
totalUsage.model = totalUsage.model ?? batchResult.usage.model;
|
|
128
|
+
providerLatencyMs += batchResult.latencyMs;
|
|
129
|
+
// Process results
|
|
130
|
+
for (const unit of batch){
|
|
131
|
+
const translated = batchResult.translations.get(unit.id);
|
|
132
|
+
if (translated !== undefined) {
|
|
133
|
+
translatedMap.set(unit.id, translated);
|
|
134
|
+
succeeded.push({
|
|
135
|
+
fieldPath: unit.fieldPath,
|
|
136
|
+
locale: targetLocale,
|
|
137
|
+
status: 'success',
|
|
138
|
+
characterCount: translated.length,
|
|
139
|
+
durationMs: batchResult.latencyMs,
|
|
140
|
+
// Only return raw text for `plain` units — for inline/block the
|
|
141
|
+
// structured patch (richText tree, blocks array) can't be safely
|
|
142
|
+
// applied via a flat setValue on the client.
|
|
143
|
+
...unit.kind === 'plain' ? {
|
|
144
|
+
translatedText: translated
|
|
145
|
+
} : {}
|
|
146
|
+
});
|
|
147
|
+
} else if (batchResult.skipped.has(unit.id)) {
|
|
148
|
+
// Verbatim echo or other soft validation. Don't write the value
|
|
149
|
+
// (we never overwrite target with source) but mark as skipped so
|
|
150
|
+
// the caller doesn't surface it as a real failure. Capture the
|
|
151
|
+
// source text (truncated) so the Hub can show editors WHAT was
|
|
152
|
+
// kept instead of just a field path.
|
|
153
|
+
failed.push({
|
|
154
|
+
fieldPath: unit.fieldPath,
|
|
155
|
+
locale: targetLocale,
|
|
156
|
+
status: 'skipped',
|
|
157
|
+
error: batchResult.skipped.get(unit.id),
|
|
158
|
+
errorCode: batchResult.skippedCodes.get(unit.id),
|
|
159
|
+
sourceValue: truncateSourceValue(unit.text)
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
const err = batchResult.errors.get(unit.id);
|
|
163
|
+
failed.push({
|
|
164
|
+
fieldPath: unit.fieldPath,
|
|
165
|
+
locale: targetLocale,
|
|
166
|
+
status: 'failed',
|
|
167
|
+
error: err ?? 'No translation returned',
|
|
168
|
+
errorCode: batchResult.errorCodes.get(unit.id) ?? 'bulk.transient.provider'
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Read the existing target-locale doc (without source fallback). We use
|
|
174
|
+
// it as the merge base so required-localized siblings the user hasn't
|
|
175
|
+
// asked us to translate stay populated, and Payload-internal columns
|
|
176
|
+
// never round-trip from a source-locale read into the update body.
|
|
177
|
+
let targetDoc = null;
|
|
178
|
+
try {
|
|
179
|
+
// Read draft state for target locale too — if the user has staged
|
|
180
|
+
// partial translations as a draft, we want to preserve those.
|
|
181
|
+
targetDoc = await findByIdNoFallback(payload, collection, documentId, targetLocale, {
|
|
182
|
+
draft: true
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
targetDoc = null;
|
|
186
|
+
}
|
|
187
|
+
// 2026-06-04 fix: Payload's `findByIdNoFallback` returns source-locale
|
|
188
|
+
// values for SHARED block-parent tables when the target locale has no
|
|
189
|
+
// row in `<collection>_locales` yet. The merge then treats those
|
|
190
|
+
// shared values as "real target data" and writes them back with
|
|
191
|
+
// their source-locale ids — which collides with the source's own
|
|
192
|
+
// rows in `<collection>_blocks_*` (PK violation). Fix: probe the
|
|
193
|
+
// per-locale row directly via the pg pool. If the locale row
|
|
194
|
+
// doesn't exist, force targetDoc to null so the merge falls back to
|
|
195
|
+
// the source-clone branch, where `stripArrayItemIds` strips the
|
|
196
|
+
// source ids and lets Payload generate fresh ones.
|
|
197
|
+
if (targetDoc !== null) {
|
|
198
|
+
const localeRowExists = await checkLocaleRowExists(payload, collection, documentId, targetLocale);
|
|
199
|
+
if (!localeRowExists) {
|
|
200
|
+
payload.logger?.info?.(`[ai-translate] No ${collection}_locales row for ${String(documentId)}/${targetLocale} yet — forcing source-fallback merge so Payload generates fresh per-locale ids on the first write.`);
|
|
201
|
+
targetDoc = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// BUG-28 fix: detect structural drift between source and target on any
|
|
205
|
+
// localized array field (layout blocks, nested arrays). If the editor
|
|
206
|
+
// reordered, inserted, or deleted blocks in source WITHOUT changing
|
|
207
|
+
// any text, `translatedMap` is empty — the legacy gate skipped the
|
|
208
|
+
// merge+write entirely and target locales retained the old structure
|
|
209
|
+
// forever. We force the structural-sync write when drift is detected.
|
|
210
|
+
const structuralDrift = hasStructuralDrift(sourceDoc, targetDoc, fields);
|
|
211
|
+
// Patch translated content and write when EITHER we have translations
|
|
212
|
+
// to apply, OR structural sync is needed to align target shape to source.
|
|
213
|
+
if (translatedMap.size > 0 || structuralDrift) {
|
|
214
|
+
const translatedTopFields = new Set();
|
|
215
|
+
for (const unit of units){
|
|
216
|
+
if (translatedMap.has(unit.id)) {
|
|
217
|
+
translatedTopFields.add(unit.fieldPath.split('.')[0]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const writeData = mergeTranslationsIntoTargetDoc({
|
|
221
|
+
sourceDoc,
|
|
222
|
+
targetDoc,
|
|
223
|
+
translatedMap,
|
|
224
|
+
fields,
|
|
225
|
+
translatedTopFields,
|
|
226
|
+
allLocalizedTopFields,
|
|
227
|
+
writeMode
|
|
228
|
+
});
|
|
229
|
+
// `preserveManualEdits` opt-in: drop any top-level fields whose
|
|
230
|
+
// current target value diverges from the hash the plugin recorded
|
|
231
|
+
// on its last write. Detection is per-field on the top-level path
|
|
232
|
+
// so a translator can manually fix `de.title` while still letting
|
|
233
|
+
// automated re-translation refresh `de.content` on the next
|
|
234
|
+
// publish. Storage lives in the auto-registered
|
|
235
|
+
// `ai-translate-meta` sidecar collection — see
|
|
236
|
+
// `lib/manual-edit-guard.ts`.
|
|
237
|
+
//
|
|
238
|
+
// `force` bypass: when the caller signals an intentional override
|
|
239
|
+
// (bulk-translate `mode: 'force'`), we skip this guard and let the
|
|
240
|
+
// new translation overwrite whatever's in the target — that's the
|
|
241
|
+
// contract the editor agreed to by checking "Force re-translate
|
|
242
|
+
// (includes docs already up to date)" in the UI. Without this
|
|
243
|
+
// bypass, force-mode bulk runs silently preserve every prior
|
|
244
|
+
// translation and pay for tokens without writing anything.
|
|
245
|
+
let manualEditSkipReasons = null;
|
|
246
|
+
if (config.preserveManualEdits && targetDoc && !force) {
|
|
247
|
+
const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
|
|
248
|
+
const storedHashes = await loadFieldHashes(payload, metaSlug, collection, documentId, targetLocale);
|
|
249
|
+
const { fieldsToDrop, skipReasons } = pickManualEditedFields(writeData, targetDoc, storedHashes);
|
|
250
|
+
if (fieldsToDrop.length > 0) {
|
|
251
|
+
manualEditSkipReasons = skipReasons;
|
|
252
|
+
for (const field of fieldsToDrop){
|
|
253
|
+
delete writeData[field];
|
|
254
|
+
payload.logger?.info?.(`[ai-translate] Skipping ${collection}/${String(documentId)} locale=${targetLocale} field=${field}: ${skipReasons.get(field)}`);
|
|
255
|
+
// BUG-22 fix: move the corresponding `succeeded` entry to
|
|
256
|
+
// `preserved` so callers can tell "wrote" from "kept editor's
|
|
257
|
+
// hand-edit." The translation itself succeeded; the WRITE
|
|
258
|
+
// didn't happen. Pre-1.2.0 these stayed in `succeeded` and
|
|
259
|
+
// misled downstream automation (audit logs, change feeds,
|
|
260
|
+
// editor notifications).
|
|
261
|
+
for(let i = succeeded.length - 1; i >= 0; i--){
|
|
262
|
+
const entry = succeeded[i];
|
|
263
|
+
if (!entry) continue;
|
|
264
|
+
// Match by top-level field path. Inline / block units use
|
|
265
|
+
// dotted paths so we compare on the top segment.
|
|
266
|
+
const entryTop = entry.fieldPath.split('.')[0];
|
|
267
|
+
if (entryTop === field) {
|
|
268
|
+
preserved.push({
|
|
269
|
+
...entry,
|
|
270
|
+
status: 'skipped',
|
|
271
|
+
error: skipReasons.get(field)
|
|
272
|
+
});
|
|
273
|
+
succeeded.splice(i, 1);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// BUG-27 fix: concurrent-edit stale-source check.
|
|
280
|
+
// Compare a HASH of the translated source-locale fields between
|
|
281
|
+
// dispatch and just-before-write. If those values changed mid-flight
|
|
282
|
+
// (editor's autosave landed during our LLM call), abort and let the
|
|
283
|
+
// next save fire a fresh pass — otherwise the target gets a stale
|
|
284
|
+
// translation the editor has already moved past.
|
|
285
|
+
//
|
|
286
|
+
// Previous implementation compared the parent doc's `updatedAt`, but
|
|
287
|
+
// that field is bumped by EVERY locale write — including SIBLING
|
|
288
|
+
// locales translated concurrently in the same job. When two locales
|
|
289
|
+
// ran in parallel, whichever wrote first bumped `updatedAt`, and the
|
|
290
|
+
// second one then falsely detected "source advanced" and aborted.
|
|
291
|
+
// Hashing the source-locale field values sidesteps that: a sibling
|
|
292
|
+
// locale's write doesn't change the source-locale row's content, only
|
|
293
|
+
// the parent doc's timestamp.
|
|
294
|
+
const stalenessCheckFields = new Set();
|
|
295
|
+
for (const u of units){
|
|
296
|
+
const top = u.fieldPath.split('.')[0];
|
|
297
|
+
if (top) stalenessCheckFields.add(top);
|
|
298
|
+
}
|
|
299
|
+
const computeSourceHash = (doc)=>{
|
|
300
|
+
const sub = {};
|
|
301
|
+
for (const k of stalenessCheckFields)sub[k] = doc[k];
|
|
302
|
+
return hashFieldValue(sub);
|
|
303
|
+
};
|
|
304
|
+
const sourceHashAtDispatch = stalenessCheckFields.size > 0 ? computeSourceHash(sourceDoc) : null;
|
|
305
|
+
if (sourceHashAtDispatch !== null) {
|
|
306
|
+
try {
|
|
307
|
+
const freshSource = await findByIdNoFallback(payload, collection, documentId, config.sourceLocale, {
|
|
308
|
+
draft: true
|
|
309
|
+
});
|
|
310
|
+
const freshHash = freshSource ? computeSourceHash(freshSource) : null;
|
|
311
|
+
if (freshHash !== null && freshHash !== sourceHashAtDispatch) {
|
|
312
|
+
payload.logger?.warn?.(`[ai-translate] Source content advanced during translation of ${collection}/${String(documentId)} locale=${targetLocale}. Aborting this write — the next save will fire a fresh pass.`);
|
|
313
|
+
for (const result of succeeded){
|
|
314
|
+
failed.push({
|
|
315
|
+
...result,
|
|
316
|
+
status: 'skipped',
|
|
317
|
+
error: 'Source advanced during translation; write aborted to avoid stale-source target content.'
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
succeeded.length = 0;
|
|
321
|
+
return {
|
|
322
|
+
succeeded,
|
|
323
|
+
preserved,
|
|
324
|
+
failed,
|
|
325
|
+
usage: totalUsage,
|
|
326
|
+
providerLatencyMs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
// If we can't read the fresh source, fall through to write —
|
|
331
|
+
// staleness check is best-effort; transient DB errors shouldn't
|
|
332
|
+
// block a translation that otherwise succeeded.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// BUG-25 fix: skip the DB write entirely when manual-edit-guard
|
|
336
|
+
// filtering left writeData empty. Pre-fix, calling `payload.update`
|
|
337
|
+
// with `data: {}` (after every field was preserved as manual-edit)
|
|
338
|
+
// tripped required-field validation on collections that have a
|
|
339
|
+
// required top-level field, producing noisy 'failed' Jobs entries
|
|
340
|
+
// even though every field was correctly preserved. An empty write
|
|
341
|
+
// also touches `updated_at` and causes a downstream cache flush for
|
|
342
|
+
// no semantic change.
|
|
343
|
+
const writeKeys = Object.keys(writeData);
|
|
344
|
+
if (writeKeys.length === 0) {
|
|
345
|
+
payload.logger?.info?.(`[ai-translate] No fields to write for ${collection}/${String(documentId)} locale=${targetLocale} — all fields were preserved as manual edits. Skipping payload.update.`);
|
|
346
|
+
// Skip the write but keep the manualEditSkipReasons / succeeded
|
|
347
|
+
// tally so the per-locale response shape stays consistent.
|
|
348
|
+
} else {
|
|
349
|
+
try {
|
|
350
|
+
// Shallow-copy req with `transactionID: undefined` so each locale
|
|
351
|
+
// write gets its own postgres transaction. Sharing the parent
|
|
352
|
+
// request's transactionID across N concurrent locale writes
|
|
353
|
+
// serialises Payload's update flow on a single txn — N parallel
|
|
354
|
+
// delete-then-insert plans on the same `<col>_blocks_*` junction
|
|
355
|
+
// tables collide and Payload bails with errors like
|
|
356
|
+
// `delete from "<col>_blocks_p_ban" where "_parent_id" = $1` or
|
|
357
|
+
// `field is invalid: _locale, _parent_id`. Issuing a fresh txn per
|
|
358
|
+
// write isolates them. We keep `req.user` (and the rest) so the
|
|
359
|
+
// audit-log afterChange hook can still attribute the write to the
|
|
360
|
+
// human editor who triggered the publish.
|
|
361
|
+
const writeReq = req ? {
|
|
362
|
+
...req,
|
|
363
|
+
transactionID: undefined
|
|
364
|
+
} : undefined;
|
|
365
|
+
// Allowlist context forward: a naive `...writeReq.context` would
|
|
366
|
+
// propagate audit-log's AUDIT_CONTEXT_KEY (and any future
|
|
367
|
+
// request-scoped flags) into the locale write, causing
|
|
368
|
+
// cascade-guard to mis-attribute or suppress audit entries for
|
|
369
|
+
// the translated row. Only known-safe keys flow through.
|
|
370
|
+
const forwardedContext = {};
|
|
371
|
+
if (writeReq?.context && 'disableRevalidate' in writeReq.context) {
|
|
372
|
+
forwardedContext.disableRevalidate = writeReq.context.disableRevalidate;
|
|
373
|
+
}
|
|
374
|
+
await payload.update({
|
|
375
|
+
collection: collection,
|
|
376
|
+
id: documentId,
|
|
377
|
+
locale: targetLocale,
|
|
378
|
+
data: writeData,
|
|
379
|
+
context: {
|
|
380
|
+
...forwardedContext,
|
|
381
|
+
[REENTRY_FLAG]: true
|
|
382
|
+
},
|
|
383
|
+
overrideAccess: true,
|
|
384
|
+
draft: false,
|
|
385
|
+
...writeReq ? {
|
|
386
|
+
req: writeReq
|
|
387
|
+
} : {}
|
|
388
|
+
});
|
|
389
|
+
// Persist per-field source+target hashes for next-translation
|
|
390
|
+
// hash-skip. Best-effort — failure here doesn't roll back the
|
|
391
|
+
// successful translation write above (helper logs internally).
|
|
392
|
+
if (config.preserveManualEdits) {
|
|
393
|
+
const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
|
|
394
|
+
const { written, failed: failedHashes } = await recordHashesAfterWrite({
|
|
395
|
+
payload,
|
|
396
|
+
metaCollection: metaSlug,
|
|
397
|
+
surfaceSlug: collection,
|
|
398
|
+
documentId,
|
|
399
|
+
targetLocale,
|
|
400
|
+
writeData,
|
|
401
|
+
sourceDoc
|
|
402
|
+
});
|
|
403
|
+
if (failedHashes > 0) {
|
|
404
|
+
const localeLog = createScopedLogger(payload, {
|
|
405
|
+
component: 'translation',
|
|
406
|
+
collection,
|
|
407
|
+
documentId: String(documentId),
|
|
408
|
+
locale: targetLocale
|
|
409
|
+
});
|
|
410
|
+
localeLog.event('warn', 'translation.hash-record.partial', {
|
|
411
|
+
written,
|
|
412
|
+
failed: failedHashes
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
const localeLog = createScopedLogger(payload, {
|
|
418
|
+
component: 'translation',
|
|
419
|
+
collection,
|
|
420
|
+
documentId: String(documentId),
|
|
421
|
+
locale: targetLocale
|
|
422
|
+
});
|
|
423
|
+
localeLog.event('error', 'translation.locale-write.failed', {
|
|
424
|
+
err: error
|
|
425
|
+
});
|
|
426
|
+
const errMsg = error instanceof Error ? error.message : 'Unknown write error';
|
|
427
|
+
// Move all succeeded to failed
|
|
428
|
+
for (const result of succeeded){
|
|
429
|
+
failed.push({
|
|
430
|
+
...result,
|
|
431
|
+
status: 'failed',
|
|
432
|
+
error: `Write failed: ${errMsg}`
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
succeeded.length = 0;
|
|
436
|
+
}
|
|
437
|
+
} // end else (write was non-empty)
|
|
438
|
+
}
|
|
439
|
+
const isCanary = targetLocale === config.quality?.canaryLocale;
|
|
440
|
+
// Emit per-field events for debugging
|
|
441
|
+
for (const result of [
|
|
442
|
+
...succeeded,
|
|
443
|
+
...failed
|
|
444
|
+
]){
|
|
445
|
+
emitEvent(config, {
|
|
446
|
+
type: 'translation.field',
|
|
447
|
+
documentId,
|
|
448
|
+
collection,
|
|
449
|
+
sourceLocale: config.sourceLocale,
|
|
450
|
+
targetLocales: [
|
|
451
|
+
targetLocale
|
|
452
|
+
],
|
|
453
|
+
timestamp: new Date(),
|
|
454
|
+
fields: [
|
|
455
|
+
result
|
|
456
|
+
],
|
|
457
|
+
canary: isCanary || undefined
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// Random sampling: log source + translated pairs for quality spot-checks
|
|
461
|
+
if (config.quality?.sampling && translatedMap.size > 0) {
|
|
462
|
+
const { rate, onSample } = config.quality.sampling;
|
|
463
|
+
if (onSample && Math.random() < rate) {
|
|
464
|
+
for (const unit of units){
|
|
465
|
+
const translated = translatedMap.get(unit.id);
|
|
466
|
+
if (translated) {
|
|
467
|
+
try {
|
|
468
|
+
const result = onSample({
|
|
469
|
+
documentId,
|
|
470
|
+
collection,
|
|
471
|
+
fieldPath: unit.fieldPath,
|
|
472
|
+
sourceLocale: config.sourceLocale,
|
|
473
|
+
targetLocale,
|
|
474
|
+
sourceText: unit.text,
|
|
475
|
+
translatedText: translated,
|
|
476
|
+
model: 'unknown',
|
|
477
|
+
timestamp: new Date()
|
|
478
|
+
});
|
|
479
|
+
if (result && typeof result.catch === 'function') {
|
|
480
|
+
result.catch(()=>{});
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
// Sampling errors never break the main flow
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
succeeded,
|
|
491
|
+
preserved,
|
|
492
|
+
failed,
|
|
493
|
+
usage: totalUsage,
|
|
494
|
+
providerLatencyMs
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
export async function translateBatch(params) {
|
|
498
|
+
const { batch, config, collection, targetLocale, signal, retryConfig, payload } = params;
|
|
499
|
+
const translations = new Map();
|
|
500
|
+
const errors = new Map();
|
|
501
|
+
const errorCodes = new Map();
|
|
502
|
+
const skipped = new Map();
|
|
503
|
+
const skippedCodes = new Map();
|
|
504
|
+
const usage = {
|
|
505
|
+
inputTokens: 0,
|
|
506
|
+
outputTokens: 0,
|
|
507
|
+
estimatedCostUsd: 0
|
|
508
|
+
};
|
|
509
|
+
let latencyMs = 0;
|
|
510
|
+
// Enforce per-call character limit
|
|
511
|
+
const batchChars = batch.reduce((sum, u)=>sum + u.text.length, 0);
|
|
512
|
+
try {
|
|
513
|
+
checkPerCallLimit(batchChars, config.costLimits.perCallCharLimit);
|
|
514
|
+
} catch {
|
|
515
|
+
// Oversized batch — mark all items as failed
|
|
516
|
+
for (const unit of batch){
|
|
517
|
+
errors.set(unit.id, `Batch exceeds per-call character limit (${batchChars} > ${config.costLimits.perCallCharLimit})`);
|
|
518
|
+
errorCodes.set(unit.id, 'cost-guard.per-call');
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
translations,
|
|
522
|
+
errors,
|
|
523
|
+
errorCodes,
|
|
524
|
+
skipped,
|
|
525
|
+
skippedCodes,
|
|
526
|
+
usage,
|
|
527
|
+
latencyMs
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// BUG-30 fix: dedup identical (text, kind) units before sending to the
|
|
531
|
+
// provider. Real-world content (CTAs, taglines, section headers, repeated
|
|
532
|
+
// disclaimers) often repeats the same source string across multiple
|
|
533
|
+
// fields. Pre-fix, every duplicate was a separate provider call → wasted
|
|
534
|
+
// tokens. Now we send each unique (text, kind) once, then fan the response
|
|
535
|
+
// back to every original unit id.
|
|
536
|
+
//
|
|
537
|
+
// Why key on (text, kind): the kind ('plain' vs 'inline' vs 'block') changes
|
|
538
|
+
// how the provider treats the source (markup-aware vs not). Don't dedup
|
|
539
|
+
// across kinds — a 'plain' "Hello" and an 'inline' "<b>Hello</b>" are
|
|
540
|
+
// different units even if the raw text matches after tag-stripping.
|
|
541
|
+
const dedupKey = (u)=>`${u.kind}\u0000${u.text}`;
|
|
542
|
+
const representativeByKey = new Map(); // dedupKey -> representative unit.id
|
|
543
|
+
const aliasMap = new Map(); // alias unit.id -> representative unit.id
|
|
544
|
+
const items = [];
|
|
545
|
+
for (const unit of batch){
|
|
546
|
+
const key = dedupKey(unit);
|
|
547
|
+
const existing = representativeByKey.get(key);
|
|
548
|
+
if (existing) {
|
|
549
|
+
aliasMap.set(unit.id, existing);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
representativeByKey.set(key, unit.id);
|
|
553
|
+
items.push({
|
|
554
|
+
id: unit.id,
|
|
555
|
+
text: unit.text,
|
|
556
|
+
kind: unit.kind
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
const request = {
|
|
560
|
+
items,
|
|
561
|
+
sourceLocale: config.sourceLocale,
|
|
562
|
+
targetLocale,
|
|
563
|
+
context: {
|
|
564
|
+
collectionSlug: collection,
|
|
565
|
+
fieldPath: batch[0]?.fieldPath ?? ''
|
|
566
|
+
},
|
|
567
|
+
signal
|
|
568
|
+
};
|
|
569
|
+
let attempts = 0;
|
|
570
|
+
let lastError;
|
|
571
|
+
while(attempts <= retryConfig.attempts){
|
|
572
|
+
try {
|
|
573
|
+
// Rate limit before calling provider
|
|
574
|
+
if (config._rateLimiter) {
|
|
575
|
+
await config._rateLimiter.acquire();
|
|
576
|
+
}
|
|
577
|
+
const response = await config.provider.translate(request);
|
|
578
|
+
usage.inputTokens += response.usage.inputTokens;
|
|
579
|
+
usage.outputTokens += response.usage.outputTokens;
|
|
580
|
+
usage.estimatedCostUsd = (usage.estimatedCostUsd ?? 0) + (response.usage.estimatedCostUsd ?? 0);
|
|
581
|
+
// Capture the model on first response. Provider can switch on retry
|
|
582
|
+
// (rare), but the analytics value is "what billed the work" — first
|
|
583
|
+
// value is canonical for this batch.
|
|
584
|
+
usage.model = usage.model ?? response.model;
|
|
585
|
+
latencyMs = response.latencyMs;
|
|
586
|
+
// Handle item count mismatches
|
|
587
|
+
const responseItems = response.items.length > items.length ? response.items.slice(0, items.length) // trim extras
|
|
588
|
+
: response.items;
|
|
589
|
+
if (responseItems.length < items.length && attempts < retryConfig.attempts) {
|
|
590
|
+
// Retry — provider returned fewer items
|
|
591
|
+
attempts++;
|
|
592
|
+
lastError = `Provider returned ${responseItems.length} items, expected ${items.length}`;
|
|
593
|
+
await sleep(retryConfig.backoffMs * attempts);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// Validate each translated item
|
|
597
|
+
const retryUnits = [];
|
|
598
|
+
for (const item of responseItems){
|
|
599
|
+
const sourceUnit = batch.find((u)=>u.id === item.id);
|
|
600
|
+
if (!sourceUnit) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const validation = validateTranslation(sourceUnit.text, item.text, config.quality?.validation, {
|
|
604
|
+
sourceLocale: config.sourceLocale,
|
|
605
|
+
targetLocale
|
|
606
|
+
});
|
|
607
|
+
if (validation.valid) {
|
|
608
|
+
translations.set(item.id, item.text);
|
|
609
|
+
} else if (validation.soft) {
|
|
610
|
+
// Soft fail (verbatim echo) — don't retry, don't error. Surface
|
|
611
|
+
// as `skipped` to the caller so the UI shows it as info-level.
|
|
612
|
+
skipped.set(item.id, validation.reason ?? 'Skipped');
|
|
613
|
+
if (validation.code) skippedCodes.set(item.id, validation.code);
|
|
614
|
+
} else if (attempts < retryConfig.attempts) {
|
|
615
|
+
retryUnits.push(sourceUnit);
|
|
616
|
+
} else {
|
|
617
|
+
errors.set(item.id, validation.reason ?? 'Validation failed');
|
|
618
|
+
// Hard validation failure after retries — fold into the soft-skip
|
|
619
|
+
// code family. UI treats both as "AI output couldn't be saved",
|
|
620
|
+
// varying only in whether retries were attempted.
|
|
621
|
+
if (validation.code) errorCodes.set(item.id, validation.code);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// Mark items not in response as errors.
|
|
625
|
+
//
|
|
626
|
+
// The `!skipped.has(item.id)` guard is load-bearing (1.2.8): when a
|
|
627
|
+
// batch contains a mix of soft-skip (echo / validator soft fail)
|
|
628
|
+
// and hard-fail items, a retry sends only the hard-fails — the
|
|
629
|
+
// soft-skipped ids are absent from the next response by design.
|
|
630
|
+
// Without the guard, this loop saw the missing soft-skipped id
|
|
631
|
+
// and either re-pushed it to retryUnits (wasting a retry on a
|
|
632
|
+
// unit we'd already decided to skip) or, on retry-exhaustion,
|
|
633
|
+
// promoted it to `errors` while ALSO leaving it in `skipped`.
|
|
634
|
+
// The downstream effects were both visible in the Hub:
|
|
635
|
+
// - The `persistent-failure` alert (line 959 — fires when
|
|
636
|
+
// `errors.size > 0`) counted those soft-skipped ids as
|
|
637
|
+
// hard failures, producing red "Translation failed
|
|
638
|
+
// repeatedly · N fields couldn't be translated to <locale>"
|
|
639
|
+
// banners.
|
|
640
|
+
// - api.ts:910-940 (translateGlobalForLocale) routes via
|
|
641
|
+
// `skipped.has(unit.id)` BEFORE `errors.has(unit.id)`, so the
|
|
642
|
+
// same units landed in `failed[]` with status:'skipped' →
|
|
643
|
+
// `realFailed.length === 0` → `failedCount === 0` on the
|
|
644
|
+
// usage row → `deriveStatus` returned `'needs-review'` (yellow)
|
|
645
|
+
// per `UsageTable.helpers.ts:111-113`.
|
|
646
|
+
// Net: a red "failed repeatedly" alert above the same row that
|
|
647
|
+
// showed yellow "needs review", for the same fields, from the
|
|
648
|
+
// same run. Excluding already-skipped ids from this loop is the
|
|
649
|
+
// minimal correct fix: skip status is final, don't churn it.
|
|
650
|
+
for (const item of items){
|
|
651
|
+
if (!translations.has(item.id) && !skipped.has(item.id) && !errors.has(item.id) && !retryUnits.find((u)=>u.id === item.id)) {
|
|
652
|
+
if (attempts < retryConfig.attempts) {
|
|
653
|
+
const unit = batch.find((u)=>u.id === item.id);
|
|
654
|
+
if (unit) retryUnits.push(unit);
|
|
655
|
+
} else {
|
|
656
|
+
errors.set(item.id, lastError ?? 'No translation returned');
|
|
657
|
+
// Missing-response items fall through to provider error
|
|
658
|
+
// bucket — UI shows generic "Translation service" copy.
|
|
659
|
+
errorCodes.set(item.id, 'bulk.transient.provider');
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (retryUnits.length === 0) {
|
|
664
|
+
break; // All items handled
|
|
665
|
+
}
|
|
666
|
+
// Retry with the failed units
|
|
667
|
+
attempts++;
|
|
668
|
+
request.items = retryUnits.map((u)=>({
|
|
669
|
+
id: u.id,
|
|
670
|
+
text: u.text,
|
|
671
|
+
kind: u.kind
|
|
672
|
+
}));
|
|
673
|
+
await sleep(retryConfig.backoffMs * attempts);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
lastError = error instanceof Error ? error.message : 'Provider error';
|
|
676
|
+
const batchLog = payload ? createScopedLogger(payload, {
|
|
677
|
+
component: 'translation.batch',
|
|
678
|
+
collection,
|
|
679
|
+
locale: targetLocale
|
|
680
|
+
}) : undefined;
|
|
681
|
+
if (attempts < retryConfig.attempts) {
|
|
682
|
+
batchLog?.event('warn', 'translation.batch.retry', {
|
|
683
|
+
err: error,
|
|
684
|
+
attempt: attempts + 1,
|
|
685
|
+
maxAttempts: retryConfig.attempts,
|
|
686
|
+
batchSize: items.length,
|
|
687
|
+
locale: targetLocale
|
|
688
|
+
});
|
|
689
|
+
attempts++;
|
|
690
|
+
await sleep(retryConfig.backoffMs * attempts);
|
|
691
|
+
} else {
|
|
692
|
+
batchLog?.event('error', 'translation.batch.exhausted', {
|
|
693
|
+
err: error,
|
|
694
|
+
attempts,
|
|
695
|
+
batchSize: items.length,
|
|
696
|
+
locale: targetLocale
|
|
697
|
+
});
|
|
698
|
+
// All retries exhausted — classify the provider error so the UI
|
|
699
|
+
// shows the right friendly message (rate-limited vs config vs
|
|
700
|
+
// schema vs generic transient).
|
|
701
|
+
const exhaustedCode = classifyProviderErrorToEditorCode(error);
|
|
702
|
+
for (const item of items){
|
|
703
|
+
if (!translations.has(item.id)) {
|
|
704
|
+
errors.set(item.id, lastError);
|
|
705
|
+
errorCodes.set(item.id, exhaustedCode);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// BUG-30 fan-out: replicate each representative's result to every alias
|
|
713
|
+
// id so downstream patching sees a complete translation map.
|
|
714
|
+
for (const [aliasId, representativeId] of aliasMap.entries()){
|
|
715
|
+
if (translations.has(representativeId)) {
|
|
716
|
+
translations.set(aliasId, translations.get(representativeId));
|
|
717
|
+
} else if (skipped.has(representativeId)) {
|
|
718
|
+
skipped.set(aliasId, skipped.get(representativeId));
|
|
719
|
+
const code = skippedCodes.get(representativeId);
|
|
720
|
+
if (code) skippedCodes.set(aliasId, code);
|
|
721
|
+
} else if (errors.has(representativeId)) {
|
|
722
|
+
errors.set(aliasId, errors.get(representativeId));
|
|
723
|
+
const code = errorCodes.get(representativeId);
|
|
724
|
+
if (code) errorCodes.set(aliasId, code);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// Fire alert if too many persistent failures
|
|
728
|
+
if (errors.size > 0) {
|
|
729
|
+
emitAlert(config, {
|
|
730
|
+
type: 'translation.persistent-failure',
|
|
731
|
+
code: 'alert.persistent-failure',
|
|
732
|
+
context: {
|
|
733
|
+
count: errors.size,
|
|
734
|
+
locale: targetLocale,
|
|
735
|
+
maxAttempts: retryConfig.attempts
|
|
736
|
+
},
|
|
737
|
+
message: `${errors.size} translation(s) failed after ${retryConfig.attempts} attempt${retryConfig.attempts === 1 ? '' : 's'} for locale "${targetLocale}"`,
|
|
738
|
+
collection,
|
|
739
|
+
timestamp: new Date(),
|
|
740
|
+
metadata: {
|
|
741
|
+
errorCount: errors.size,
|
|
742
|
+
locale: targetLocale
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
translations,
|
|
748
|
+
errors,
|
|
749
|
+
errorCodes,
|
|
750
|
+
skipped,
|
|
751
|
+
skippedCodes,
|
|
752
|
+
usage,
|
|
753
|
+
latencyMs
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Map a provider exception (network error, AI SDK throw, etc.) to an
|
|
758
|
+
* editor-facing code. Stays in sync with bulk-translate-doc-task's
|
|
759
|
+
* `classifyError` heuristics so the same provider failure surfaces
|
|
760
|
+
* the same friendly copy whether it's hit in a single-doc translate
|
|
761
|
+
* or a bulk worker.
|
|
762
|
+
*/ function classifyProviderErrorToEditorCode(err) {
|
|
763
|
+
if (err == null) return 'bulk.unknown';
|
|
764
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
765
|
+
const name = err instanceof Error ? err.name : '';
|
|
766
|
+
const codeProp = err?.code;
|
|
767
|
+
const codeStr = typeof codeProp === 'string' ? codeProp : '';
|
|
768
|
+
if (codeStr === 'PER_DOC_CEILING' || codeStr === 'PER_CALL_LIMIT') {
|
|
769
|
+
return 'bulk.permanent.too_large';
|
|
770
|
+
}
|
|
771
|
+
if (name === 'NoObjectGeneratedError' || /noobjectgeneratederror/i.test(message)) {
|
|
772
|
+
return 'bulk.permanent.schema';
|
|
773
|
+
}
|
|
774
|
+
if (/\b401\b|\b403\b|unauthorized|forbidden|api[_-]?key/i.test(message)) {
|
|
775
|
+
return 'bulk.permanent.config';
|
|
776
|
+
}
|
|
777
|
+
if (/\b429\b|rate[_-]?limit/i.test(message)) {
|
|
778
|
+
return 'bulk.transient.rate_limited';
|
|
779
|
+
}
|
|
780
|
+
return 'bulk.transient.provider';
|
|
781
|
+
}
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// Preserve policy: filter out non-empty target fields
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
async function filterPreservedFields(payload, collection, documentId, targetLocale, units, fields) {
|
|
786
|
+
try {
|
|
787
|
+
const targetDoc = await findByIdNoFallback(payload, collection, documentId, targetLocale);
|
|
788
|
+
return units.filter((unit)=>{
|
|
789
|
+
// Find the matching field for this unit
|
|
790
|
+
const field = fields.find((f)=>unit.fieldPath === f.path || unit.fieldPath.startsWith(`${f.path}.`));
|
|
791
|
+
if (!field) return true; // Can't determine — keep it
|
|
792
|
+
const targetValue = resolvePathValue(targetDoc, field.path);
|
|
793
|
+
return isFieldEmpty(targetValue, field.type);
|
|
794
|
+
});
|
|
795
|
+
} catch {
|
|
796
|
+
// If we can't read the target doc, proceed with all units
|
|
797
|
+
return units;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function resolvePathValue(obj, path) {
|
|
801
|
+
const segments = path.split('.');
|
|
802
|
+
let current = obj;
|
|
803
|
+
for (const segment of segments){
|
|
804
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
805
|
+
current = current[segment];
|
|
806
|
+
}
|
|
807
|
+
return current;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Returns `true` when any localized top-level array field shape in
|
|
811
|
+
* `sourceDoc` diverges from `targetDoc`. Used by the BUG-28 fix to
|
|
812
|
+
* decide whether to force a structural-sync write even when no text
|
|
813
|
+
* units changed (block reorder / insert / delete with unchanged text).
|
|
814
|
+
*
|
|
815
|
+
* Detection is shallow at the top level — `alignBaseShapeToSource` in
|
|
816
|
+
* `locale-merge.ts` handles nested-array drift once we decide to write.
|
|
817
|
+
* The check covers:
|
|
818
|
+
* - length mismatch (insert / delete)
|
|
819
|
+
* - per-index `blockType` mismatch (reorder of different block types)
|
|
820
|
+
* - per-index nested-array length mismatch (e.g. block's `columns` array changed)
|
|
821
|
+
*/ function hasStructuralDrift(sourceDoc, targetDoc, fields) {
|
|
822
|
+
if (!targetDoc) return false; // Fresh target → first-write path covers it
|
|
823
|
+
const topArrayFields = new Set();
|
|
824
|
+
for (const f of fields){
|
|
825
|
+
if (!f.localized) continue;
|
|
826
|
+
const top = f.path.split('.')[0];
|
|
827
|
+
if (!top) continue;
|
|
828
|
+
if (Array.isArray(sourceDoc[top])) {
|
|
829
|
+
topArrayFields.add(top);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
for (const top of topArrayFields){
|
|
833
|
+
const src = sourceDoc[top];
|
|
834
|
+
const tgt = targetDoc[top];
|
|
835
|
+
if (!Array.isArray(src) || !Array.isArray(tgt)) continue;
|
|
836
|
+
if (src.length !== tgt.length) return true;
|
|
837
|
+
for(let i = 0; i < src.length; i++){
|
|
838
|
+
const s = src[i];
|
|
839
|
+
const t = tgt[i];
|
|
840
|
+
if (typeof s === 'object' && s !== null && typeof t === 'object' && t !== null) {
|
|
841
|
+
const sb = s.blockType;
|
|
842
|
+
const tb = t.blockType;
|
|
843
|
+
if (sb !== undefined && tb !== undefined && sb !== tb) return true;
|
|
844
|
+
} else if (typeof s !== typeof t || Array.isArray(s) !== Array.isArray(t)) {
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
// Batching
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
/**
|
|
855
|
+
* Default items-per-batch cap. Independent of `perCallCharLimit`. Exists
|
|
856
|
+
* because some structured-output upstreams (Gemini 2.5 Flash via OpenRouter
|
|
857
|
+
* is the canonical case, surfaced 2026-06-04 on wild-payload-cms) silently
|
|
858
|
+
* stall on response generation when the response items array grows past
|
|
859
|
+
* a few hundred entries — HTTP 200 + headers arrive in <2s, then the body
|
|
860
|
+
* never finishes streaming and the per-call timeout aborts the whole
|
|
861
|
+
* batch. The char cap doesn't catch this because short strings (nav
|
|
862
|
+
* labels, JSON keys) pack many items into modest byte counts.
|
|
863
|
+
*
|
|
864
|
+
* 250 is chosen empirically: a `loyalty` JSON sub-object on
|
|
865
|
+
* wild-payload-cms (286 short keys, ~25k chars) completed in ~30s and
|
|
866
|
+
* 100% success; a 500-item batch on the same provider timed out roughly
|
|
867
|
+
* 3 of every 4 attempts. 250 puts us under the observed stall threshold
|
|
868
|
+
* with margin. Consumers running larger-context models (Claude Sonnet,
|
|
869
|
+
* GPT-4o) can override with a higher value via `costLimits.perCallItemLimit`.
|
|
870
|
+
*/ export const DEFAULT_PER_CALL_ITEM_LIMIT = 250;
|
|
871
|
+
export function batchUnits(units, perCallCharLimit, perCallItemLimit = DEFAULT_PER_CALL_ITEM_LIMIT) {
|
|
872
|
+
const batches = [];
|
|
873
|
+
let currentBatch = [];
|
|
874
|
+
let currentChars = 0;
|
|
875
|
+
for (const unit of units){
|
|
876
|
+
const unitChars = unit.text.length;
|
|
877
|
+
// Single unit exceeds char limit — send it solo
|
|
878
|
+
if (unitChars > perCallCharLimit) {
|
|
879
|
+
if (currentBatch.length > 0) {
|
|
880
|
+
batches.push(currentBatch);
|
|
881
|
+
currentBatch = [];
|
|
882
|
+
currentChars = 0;
|
|
883
|
+
}
|
|
884
|
+
batches.push([
|
|
885
|
+
unit
|
|
886
|
+
]);
|
|
887
|
+
console.warn(`[ai-translate] Single translation unit exceeds perCallCharLimit (${unitChars} > ${perCallCharLimit}). Sending solo.`);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Would exceed either cap — close the current batch and start a new one
|
|
891
|
+
const wouldExceedChars = currentChars + unitChars > perCallCharLimit;
|
|
892
|
+
const wouldExceedItems = currentBatch.length >= perCallItemLimit;
|
|
893
|
+
if (wouldExceedChars || wouldExceedItems) {
|
|
894
|
+
batches.push(currentBatch);
|
|
895
|
+
currentBatch = [];
|
|
896
|
+
currentChars = 0;
|
|
897
|
+
}
|
|
898
|
+
currentBatch.push(unit);
|
|
899
|
+
currentChars += unitChars;
|
|
900
|
+
}
|
|
901
|
+
if (currentBatch.length > 0) {
|
|
902
|
+
batches.push(currentBatch);
|
|
903
|
+
}
|
|
904
|
+
return batches;
|
|
905
|
+
}
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Helpers
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
function sleep(ms) {
|
|
910
|
+
return new Promise((resolve)=>setTimeout(resolve, ms));
|
|
911
|
+
}
|