@braze/sanity-plugin-ai-translation 0.2.3 → 0.2.4

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/dist/index.js CHANGED
@@ -1409,11 +1409,21 @@ const fetchTranslationPrompts = async ({
1409
1409
  const sourceDocumentString = documentToPlainText(sourceDocument), translationSettings2 = await client.fetch(
1410
1410
  `*[_id == "translation-settings"][0] {
1411
1411
  globalPrompt,
1412
- localePrompts[locale in $targetLanguages],
1412
+ localePrompts[_key in $targetLanguages],
1413
1413
  protectedPhrases
1414
1414
  }`,
1415
1415
  { targetLanguages }
1416
- ), translationGlossaryTerms = await client.fetch(
1416
+ );
1417
+ devLog.debug("Fetched translation settings", {
1418
+ hasSettings: !!translationSettings2,
1419
+ hasGlobalPrompt: !!translationSettings2?.globalPrompt,
1420
+ globalPromptLength: translationSettings2?.globalPrompt?.length,
1421
+ localePromptsCount: translationSettings2?.localePrompts?.length,
1422
+ localePrompts: translationSettings2?.localePrompts,
1423
+ protectedPhrasesCount: translationSettings2?.protectedPhrases?.length,
1424
+ targetLanguages
1425
+ });
1426
+ const translationGlossaryTerms = await client.fetch(
1417
1427
  `*[
1418
1428
  _type == "translationGlossaryTerm"
1419
1429
  && count(translations[_key in $targetLanguages]) > 0
@@ -1433,9 +1443,21 @@ const fetchTranslationPrompts = async ({
1433
1443
  )), glossaryTermsByLocale[targetLanguage] = relevantTranslations;
1434
1444
  }
1435
1445
  let filteredProtectedPhrases = translationSettings2?.protectedPhrases;
1436
- return sourceDocument && filteredProtectedPhrases && (filteredProtectedPhrases = filteredProtectedPhrases.filter(
1437
- (phrase) => protectedPhraseAppearsInDocument(phrase, sourceDocumentString)
1438
- )), {
1446
+ return sourceDocument && filteredProtectedPhrases && (devLog.debug("Filtering protected phrases", {
1447
+ totalPhrases: filteredProtectedPhrases.length,
1448
+ phrases: filteredProtectedPhrases,
1449
+ sourceDocumentStringLength: sourceDocumentString.length,
1450
+ sourceDocumentPreview: sourceDocumentString.substring(0, 500)
1451
+ }), filteredProtectedPhrases = filteredProtectedPhrases.filter((phrase) => {
1452
+ const appears = protectedPhraseAppearsInDocument(phrase, sourceDocumentString);
1453
+ return devLog.debug("Protected phrase match result", {
1454
+ phrase,
1455
+ appears
1456
+ }), appears;
1457
+ }), devLog.debug("Filtered protected phrases result", {
1458
+ remainingCount: filteredProtectedPhrases.length,
1459
+ remainingPhrases: filteredProtectedPhrases
1460
+ })), {
1439
1461
  globalPrompt: translationSettings2?.globalPrompt,
1440
1462
  protectedPhrases: filteredProtectedPhrases,
1441
1463
  localeInstructions: Object.fromEntries(
@@ -1583,7 +1605,109 @@ const fetchTranslationPrompts = async ({
1583
1605
  payload
1584
1606
  });
1585
1607
  }
1586
- }, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1608
+ }, DEFAULT_OPTIONS = {
1609
+ maxRetries: 3,
1610
+ initialDelayMs: 1e3,
1611
+ maxDelayMs: 3e4,
1612
+ backoffMultiplier: 2,
1613
+ jitter: !0
1614
+ };
1615
+ function isRetryableError(error) {
1616
+ if (error instanceof Error) {
1617
+ const message = error.message.toLowerCase(), name = error.name.toLowerCase(), nonRetryablePatterns = [
1618
+ "schema not found",
1619
+ "invalid schema",
1620
+ "not found",
1621
+ "unauthorized",
1622
+ "forbidden",
1623
+ "invalid api key",
1624
+ "missing api key",
1625
+ "validation failed",
1626
+ "invalid document",
1627
+ "source language not found",
1628
+ "invalid configuration",
1629
+ "type mismatch"
1630
+ ];
1631
+ for (const pattern of nonRetryablePatterns)
1632
+ if (message.includes(pattern) || name.includes(pattern))
1633
+ return !1;
1634
+ const retryablePatterns = [
1635
+ "failed to fetch",
1636
+ "network",
1637
+ "timeout",
1638
+ "econnreset",
1639
+ "econnrefused",
1640
+ "enotfound",
1641
+ "socket",
1642
+ "rate limit",
1643
+ "too many requests",
1644
+ "429",
1645
+ "500",
1646
+ "502",
1647
+ "503",
1648
+ "504",
1649
+ "internal server error",
1650
+ "bad gateway",
1651
+ "service unavailable",
1652
+ "gateway timeout",
1653
+ "temporarily unavailable",
1654
+ "try again"
1655
+ ];
1656
+ for (const pattern of retryablePatterns)
1657
+ if (message.includes(pattern) || name.includes(pattern))
1658
+ return !0;
1659
+ }
1660
+ if (typeof error == "object" && error !== null) {
1661
+ const statusCode = error.statusCode || error.status;
1662
+ if (statusCode) {
1663
+ if (statusCode === 429 || statusCode >= 500 && statusCode < 600)
1664
+ return !0;
1665
+ if (statusCode >= 400 && statusCode < 500)
1666
+ return !1;
1667
+ }
1668
+ }
1669
+ return !0;
1670
+ }
1671
+ function calculateBackoffDelay(attempt, options) {
1672
+ const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = options;
1673
+ let delay = initialDelayMs * backoffMultiplier ** attempt;
1674
+ if (delay = Math.min(delay, maxDelayMs), jitter) {
1675
+ const jitterFactor = 0.75 + Math.random() * 0.5;
1676
+ delay = Math.round(delay * jitterFactor);
1677
+ }
1678
+ return delay;
1679
+ }
1680
+ function sleep(ms) {
1681
+ return new Promise((resolve) => setTimeout(resolve, ms));
1682
+ }
1683
+ async function withRetry(fn, options = {}) {
1684
+ const opts = { ...DEFAULT_OPTIONS, ...options }, shouldRetry = opts.isRetryable ?? isRetryableError;
1685
+ let lastError, attempts = 0;
1686
+ for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
1687
+ attempts = attempt + 1;
1688
+ try {
1689
+ return { success: !0, result: await fn(), attempts };
1690
+ } catch (error) {
1691
+ if (lastError = error, attempt >= opts.maxRetries) {
1692
+ devLog.debug("Max retries exhausted", { attempt: attempts, error });
1693
+ break;
1694
+ }
1695
+ if (!shouldRetry(error)) {
1696
+ devLog.debug("Error is not retryable", { attempt: attempts, error });
1697
+ break;
1698
+ }
1699
+ const delay = calculateBackoffDelay(attempt, opts);
1700
+ devLog.debug("Scheduling retry", {
1701
+ attempt: attempts,
1702
+ nextAttempt: attempt + 2,
1703
+ delayMs: delay,
1704
+ error: error instanceof Error ? error.message : String(error)
1705
+ }), opts.onRetry && opts.onRetry(attempt + 1, error, delay), await sleep(delay);
1706
+ }
1707
+ }
1708
+ return { success: !1, error: lastError, attempts };
1709
+ }
1710
+ const getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
1587
1711
  (translation) => translation._key === targetLanguage
1588
1712
  )?.value._ref || null, getTranslationMetadata = async ({
1589
1713
  client,
@@ -1675,6 +1799,146 @@ const fetchTranslationPrompts = async ({
1675
1799
  })
1676
1800
  });
1677
1801
  };
1802
+ function shouldExcludeField(fieldPath, excludeFields) {
1803
+ for (const pattern of excludeFields) {
1804
+ if (fieldPath === pattern)
1805
+ return !0;
1806
+ if (pattern.startsWith("**.")) {
1807
+ const fieldName = pattern.substring(3), segments = fieldPath.split(".");
1808
+ if (segments[segments.length - 1]?.split("[")[0] === fieldName)
1809
+ return !0;
1810
+ } else if (!pattern.includes(".") && !pattern.includes("[")) {
1811
+ const segments = fieldPath.split(".");
1812
+ if (segments[segments.length - 1]?.split("[")[0] === pattern)
1813
+ return !0;
1814
+ } else if (pattern.includes("[*]")) {
1815
+ const regexPattern = pattern.replace(/\[/g, "\\[").replace(/\]/g, "\\]").replace(/\\\[\*\\\]/g, "\\[\\d+\\]");
1816
+ if (new RegExp(`^${regexPattern}$`).test(fieldPath))
1817
+ return !0;
1818
+ }
1819
+ }
1820
+ return !1;
1821
+ }
1822
+ function filterExcludedFields(document, excludeFields = [], currentPath = "") {
1823
+ if (!excludeFields.length)
1824
+ return {
1825
+ filteredDocument: document,
1826
+ excludedValues: {}
1827
+ };
1828
+ const filteredDocument = {}, excludedValues = {};
1829
+ let hasExclusions = !1;
1830
+ for (const [key, value] of Object.entries(document)) {
1831
+ const fieldPath = currentPath ? `${currentPath}.${key}` : key;
1832
+ if (shouldExcludeField(fieldPath, excludeFields)) {
1833
+ excludedValues[fieldPath] = value, hasExclusions = !0, devLog.debug("Excluding field from translation", {
1834
+ fieldPath,
1835
+ fieldType: typeof value,
1836
+ value: typeof value == "string" ? value : "[object]"
1837
+ });
1838
+ continue;
1839
+ }
1840
+ if (value && typeof value == "object" && !Array.isArray(value) && value._type !== "reference") {
1841
+ const nested = filterExcludedFields(value, excludeFields, fieldPath);
1842
+ filteredDocument[key] = nested.filteredDocument, Object.assign(excludedValues, nested.excludedValues), Object.keys(nested.excludedValues).length > 0 && (hasExclusions = !0);
1843
+ } else if (Array.isArray(value)) {
1844
+ const filteredArray = [];
1845
+ let arrayHasExclusions = !1;
1846
+ for (let i = 0; i < value.length; i++) {
1847
+ const arrayItem = value[i], itemPath = `${fieldPath}[${i}]`;
1848
+ if (arrayItem && typeof arrayItem == "object" && !Array.isArray(arrayItem) && arrayItem._type !== "reference") {
1849
+ const nested = filterExcludedFields(arrayItem, excludeFields, itemPath);
1850
+ filteredArray[i] = nested.filteredDocument, Object.assign(excludedValues, nested.excludedValues), Object.keys(nested.excludedValues).length > 0 && (arrayHasExclusions = !0);
1851
+ } else
1852
+ filteredArray[i] = arrayItem;
1853
+ }
1854
+ filteredDocument[key] = filteredArray, arrayHasExclusions && (hasExclusions = !0);
1855
+ } else
1856
+ filteredDocument[key] = value;
1857
+ }
1858
+ return hasExclusions && devLog.info("Filtered excluded fields from document", {
1859
+ documentId: document._id || "unknown",
1860
+ excludedFieldCount: Object.keys(excludedValues).length,
1861
+ excludedFields: Object.keys(excludedValues)
1862
+ }), {
1863
+ filteredDocument,
1864
+ excludedValues
1865
+ };
1866
+ }
1867
+ function restoreExcludedFields(translatedDocument, excludedValues) {
1868
+ if (!Object.keys(excludedValues).length)
1869
+ return translatedDocument;
1870
+ const result = { ...translatedDocument };
1871
+ for (const [fieldPath, value] of Object.entries(excludedValues))
1872
+ setNestedValue(result, fieldPath, value);
1873
+ return devLog.debug("Restored excluded fields to translated document", {
1874
+ documentId: result._id || "unknown",
1875
+ restoredFieldCount: Object.keys(excludedValues).length,
1876
+ restoredFields: Object.keys(excludedValues)
1877
+ }), result;
1878
+ }
1879
+ function setNestedValue(obj, path, value) {
1880
+ const segments = path.split(".");
1881
+ let current = obj;
1882
+ for (let i = 0; i < segments.length - 1; i++) {
1883
+ const segment = segments[i], arrayMatch2 = segment.match(/^(.+)\[(\d+)\]$/);
1884
+ if (arrayMatch2) {
1885
+ const [, fieldName, indexStr] = arrayMatch2, index = Number.parseInt(indexStr, 10);
1886
+ for ((!(fieldName in current) || !Array.isArray(current[fieldName])) && (current[fieldName] = []); current[fieldName].length <= index; )
1887
+ current[fieldName].push({});
1888
+ current = current[fieldName][index];
1889
+ } else
1890
+ (!(segment in current) || typeof current[segment] != "object") && (current[segment] = {}), current = current[segment];
1891
+ }
1892
+ const finalSegment = segments[segments.length - 1], arrayMatch = finalSegment.match(/^(.+)\[(\d+)\]$/);
1893
+ if (arrayMatch) {
1894
+ const [, fieldName, indexStr] = arrayMatch, index = Number.parseInt(indexStr, 10);
1895
+ for ((!(fieldName in current) || !Array.isArray(current[fieldName])) && (current[fieldName] = []); current[fieldName].length <= index; )
1896
+ current[fieldName].push(null);
1897
+ current[fieldName][index] = value;
1898
+ } else
1899
+ current[finalSegment] = value;
1900
+ }
1901
+ const COMMON_ENUM_FIELDS = [
1902
+ "status",
1903
+ "state",
1904
+ "type",
1905
+ "category",
1906
+ "priority",
1907
+ "visibility",
1908
+ "role",
1909
+ "permission",
1910
+ "level",
1911
+ "mode",
1912
+ "format",
1913
+ "variant",
1914
+ "size",
1915
+ "theme",
1916
+ "kind",
1917
+ "phase",
1918
+ "stage"
1919
+ ], PREDEFINED_EXCLUSIONS = {
1920
+ /** Common enum and status fields */
1921
+ enums: [...COMMON_ENUM_FIELDS],
1922
+ /** All metadata and system fields (starting with _) */
1923
+ metadata: ["_id", "_type", "_rev", "_createdAt", "_updatedAt"],
1924
+ /** Common technical and configuration fields */
1925
+ technical: [
1926
+ "slug",
1927
+ "handle",
1928
+ "key",
1929
+ "code",
1930
+ "id",
1931
+ "uuid",
1932
+ "token",
1933
+ "hash",
1934
+ "checksum",
1935
+ "version",
1936
+ "config",
1937
+ "settings"
1938
+ ],
1939
+ /** SEO and URL-related fields that often shouldn't be translated */
1940
+ seo: ["slug", "handle", "permalink", "canonical", "redirect", "robots", "sitemap"]
1941
+ };
1678
1942
  function applyTransforms({
1679
1943
  document,
1680
1944
  registry,
@@ -1837,9 +2101,10 @@ class TransformRegistry {
1837
2101
  }), matches[0];
1838
2102
  }
1839
2103
  }
1840
- const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKey, request) => (request.model || "gpt-5") === "gpt-5" ? await callGPT5ResponsesAPI(apiKey, request) : await callChatCompletionsAPI(apiKey, request), callGPT5ResponsesAPI = async (apiKey, request) => {
2104
+ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKey, request) => (request.model || "gpt-5").startsWith("gpt-5") ? await callGPT5ResponsesAPI(apiKey, request) : await callChatCompletionsAPI(apiKey, request), callGPT5ResponsesAPI = async (apiKey, request) => {
1841
2105
  const model = request.model || "gpt-5", requestBody = {
1842
- model: "gpt-5.1",
2106
+ model: model === "gpt-5" ? "gpt-5.1" : model,
2107
+ // Map gpt-5 to gpt-5.1, use others as-is
1843
2108
  input: request.messages
1844
2109
  // Uses 'input' instead of 'messages'
1845
2110
  };
@@ -1847,6 +2112,16 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1847
2112
  effort: request.reasoningEffort,
1848
2113
  summary: "auto"
1849
2114
  // Enable automatic reasoning summaries
2115
+ }), devLog.debug("OpenAI GPT-5 Request", {
2116
+ model: requestBody.model,
2117
+ reasoningEffort: request.reasoningEffort,
2118
+ messages: request.messages.map((m) => ({
2119
+ role: m.role,
2120
+ contentLength: m.content.length,
2121
+ contentPreview: m.content.substring(0, 500) + (m.content.length > 500 ? "..." : "")
2122
+ }))
2123
+ }), devLog.debug("OpenAI GPT-5 Full Prompt", {
2124
+ messages: request.messages
1850
2125
  });
1851
2126
  const response = await fetch("https://api.openai.com/v1/responses", {
1852
2127
  method: "POST",
@@ -1864,7 +2139,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1864
2139
  prompt: result.usage?.input_tokens || 0,
1865
2140
  completion: result.usage?.output_tokens || 0,
1866
2141
  reasoning: result.usage?.output_tokens_details?.reasoning_tokens || 0,
1867
- total: result.usage?.total_tokens || 0
2142
+ total: result.usage?.total_tokens || 0,
2143
+ cached: result.usage?.input_tokens_details?.cached_tokens || 0
1868
2144
  }, cost = calculateCost(tokensUsed, model);
1869
2145
  let content = "";
1870
2146
  if (result.output && Array.isArray(result.output)) {
@@ -1881,7 +2157,19 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1881
2157
  model,
1882
2158
  messages: request.messages,
1883
2159
  temperature: request.temperature ?? 0.3
1884
- }, response = await fetch("https://api.openai.com/v1/chat/completions", {
2160
+ };
2161
+ devLog.debug("OpenAI Chat Completions Request", {
2162
+ model,
2163
+ temperature: request.temperature ?? 0.3,
2164
+ messages: request.messages.map((m) => ({
2165
+ role: m.role,
2166
+ contentLength: m.content.length,
2167
+ contentPreview: m.content.substring(0, 500) + (m.content.length > 500 ? "..." : "")
2168
+ }))
2169
+ }), devLog.debug("OpenAI Chat Completions Full Prompt", {
2170
+ messages: request.messages
2171
+ });
2172
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
1885
2173
  method: "POST",
1886
2174
  headers: {
1887
2175
  "Content-Type": "application/json",
@@ -1897,7 +2185,8 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1897
2185
  prompt: usage.prompt_tokens || 0,
1898
2186
  completion: usage.completion_tokens || 0,
1899
2187
  reasoning: usage.reasoning_tokens || 0,
1900
- total: usage.total_tokens || 0
2188
+ total: usage.total_tokens || 0,
2189
+ cached: usage.prompt_tokens_details?.cached_tokens || 0
1901
2190
  }, cost = calculateCost(tokensUsed, model);
1902
2191
  return {
1903
2192
  content: result.choices[0].message.content || "",
@@ -1907,27 +2196,47 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1907
2196
  }, calculateCost = (tokens, model) => {
1908
2197
  const pricing = {
1909
2198
  "gpt-5": {
1910
- input: 2e-6,
2199
+ input: 125e-8,
1911
2200
  output: 1e-5,
1912
- reasoning: 5e-6
2201
+ reasoning: 0
2202
+ },
2203
+ "gpt-5.1": {
2204
+ input: 125e-8,
2205
+ output: 1e-5,
2206
+ reasoning: 0
2207
+ },
2208
+ "gpt-5-mini": {
2209
+ input: 25e-8,
2210
+ output: 2e-6,
2211
+ reasoning: 0
2212
+ },
2213
+ "gpt-5-nano": {
2214
+ input: 50000000000000004e-24,
2215
+ output: 40000000000000003e-23,
2216
+ reasoning: 0
2217
+ },
2218
+ "gpt-5-pro": {
2219
+ input: 15e-6,
2220
+ output: 12e-5,
2221
+ reasoning: 0
1913
2222
  },
1914
2223
  "gpt-4o": {
1915
- input: 5e-6,
1916
- output: 14999999999999999e-21,
2224
+ input: 25e-7,
2225
+ output: 1e-5,
1917
2226
  reasoning: 0
1918
2227
  },
1919
2228
  "gpt-4-turbo": {
1920
2229
  input: 1e-5,
1921
- output: 29999999999999997e-21,
2230
+ output: 3e-5,
1922
2231
  reasoning: 0
1923
2232
  },
1924
2233
  "gpt-4": {
1925
- input: 29999999999999997e-21,
1926
- output: 59999999999999995e-21,
2234
+ input: 3e-5,
2235
+ output: 6e-5,
1927
2236
  reasoning: 0
1928
2237
  }
1929
- }, modelPricing = pricing[model] || pricing["gpt-5"];
1930
- return tokens.prompt * modelPricing.input + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
2238
+ }, modelPricing = pricing[model] || pricing["gpt-5"], cachedInputRate = model === "gpt-5.1" ? 0.125 / 1e6 : modelPricing.input;
2239
+ return Math.max(0, tokens.prompt - tokens.cached) * modelPricing.input + tokens.cached * cachedInputRate + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
1931
2240
  }, deserializeDocument = (originalDoc, reviewedText) => {
1932
2241
  const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
1933
2242
  return applyTextChanges(result, parsed), result;
@@ -1943,7 +2252,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
1943
2252
  type: "field",
1944
2253
  name: fieldName,
1945
2254
  text: unescapeXml(text2),
1946
- attributes: {},
2255
+ attributes: { type: "string" },
1947
2256
  children: []
1948
2257
  };
1949
2258
  if (stack.length > 0) {
@@ -2016,7 +2325,13 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2016
2325
  parent && parent.children.push(node);
2017
2326
  } else
2018
2327
  root.push(node);
2019
- } else
2328
+ } else if (attributes.type === "string" && content && content.includes(closingTag))
2329
+ if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
2330
+ const parent = stack[stack.length - 1];
2331
+ parent && parent.children.push(node);
2332
+ } else
2333
+ root.push(node);
2334
+ else
2020
2335
  stack.push(node);
2021
2336
  continue;
2022
2337
  }
@@ -2072,7 +2387,10 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2072
2387
  });
2073
2388
  return;
2074
2389
  }
2075
- node.text !== void 0 && !node.attributes.readonly && set(doc, path, node.text);
2390
+ node.text !== void 0 && !node.attributes.readonly && (get(doc, path) !== void 0 ? set(doc, path, node.text) : devLog.debug("Skipping field not present in original document", {
2391
+ path: path.join("."),
2392
+ attemptedValue: node.text
2393
+ }));
2076
2394
  }, applyBlockChanges = (block, blockNode) => {
2077
2395
  if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
2078
2396
  devLog.warn("Block key mismatch, skipping", {
@@ -2103,7 +2421,10 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2103
2421
  });
2104
2422
  return;
2105
2423
  }
2106
- fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (block[fieldName] = fieldNode.text);
2424
+ fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (fieldName in block ? block[fieldName] = fieldNode.text : devLog.debug("Skipping block field not present in original", {
2425
+ fieldName,
2426
+ attemptedValue: fieldNode.text
2427
+ }));
2107
2428
  }, applySpanChanges = (span, spanNode) => {
2108
2429
  if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
2109
2430
  devLog.warn("Span key mismatch, skipping", {
@@ -2148,7 +2469,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2148
2469
  return;
2149
2470
  }
2150
2471
  if (typeof value == "string") {
2151
- value.length > 0 && lines.push(`${fieldName}: ${escapeXml(value)}`);
2472
+ value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}" type="string">${escapeXml(value)}</field>`);
2152
2473
  return;
2153
2474
  }
2154
2475
  (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
@@ -2185,7 +2506,7 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2185
2506
  return;
2186
2507
  }
2187
2508
  if (typeof value == "string" && value.length > 0) {
2188
- lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
2509
+ lines.push(`${indentStr}<field name="${fieldName}" type="string">${escapeXml(value)}</field>`);
2189
2510
  return;
2190
2511
  }
2191
2512
  (typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
@@ -2327,7 +2648,19 @@ const globalTransformRegistry = new TransformRegistry(), callGPT5 = async (apiKe
2327
2648
  `Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
2328
2649
  );
2329
2650
  }
2330
- return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2651
+ const translationValidation = validateTranslationCompleteness(sourceDocument, result, languageFieldName);
2652
+ return translationValidation.valid || (devLog.warn("Translation completeness warning", {
2653
+ ...logContext,
2654
+ skippedFields: translationValidation.skippedFields,
2655
+ warning: "GPT-5 may have skipped some important fields"
2656
+ }), devLog.info("Skipped field details", {
2657
+ ...logContext,
2658
+ skippedFields: translationValidation.skippedFields.map((field) => ({
2659
+ path: field.path,
2660
+ originalValue: field.originalValue,
2661
+ translatedValue: field.translatedValue
2662
+ }))
2663
+ })), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai", {
2331
2664
  tokensUsed: response.tokensUsed.total,
2332
2665
  estimatedCost: response.cost
2333
2666
  }), result;
@@ -2368,12 +2701,19 @@ ${sourceText}
2368
2701
 
2369
2702
  ## Instructions
2370
2703
 
2371
- 1. Translate all text content into ${targetLanguage}
2372
- 2. Follow the guidelines and terminology requirements above
2373
- 3. Preserve protected phrases exactly as they appear
2374
- 4. Return the document in THE EXACT SAME FORMAT
2375
- 5. **CRITICAL**: Only modify text content between tags
2376
- 6. Never add, remove, or change any tags, attributes, or structure
2704
+ 1. **TRANSLATE ALL TEXT FIELDS**: Every field marked with type="string" or containing text must be translated
2705
+ 2. **DO NOT SKIP SIMPLE FIELDS**: Pay special attention to simple fields like "title", "name", "description" - these are often the most important
2706
+ 3. Follow the guidelines and terminology requirements above
2707
+ 4. Preserve protected phrases exactly as they appear
2708
+ 5. Return the document in THE EXACT SAME FORMAT
2709
+ 6. **CRITICAL**: Only modify text content between tags
2710
+ 7. Never add, remove, or change any tags, attributes, or structure
2711
+
2712
+ **Common fields that must always be translated:**
2713
+ - title, name, heading, label
2714
+ - description, caption, alt text
2715
+ - All text within <field> tags
2716
+ - All text within <span> tags
2377
2717
 
2378
2718
  If you used significant reasoning to make translation decisions, you may include a brief summary:
2379
2719
  <reasoning>
@@ -2381,6 +2721,43 @@ Brief summary of key translation decisions and reasoning
2381
2721
  </reasoning>
2382
2722
 
2383
2723
  Then return the translated document:`;
2724
+ }, validateTranslationCompleteness = (sourceDoc, translatedDoc, languageFieldName) => {
2725
+ const skippedFields = [], checkField = (source, translated, path = []) => {
2726
+ if (path.length > 0) {
2727
+ const fieldName = path[path.length - 1];
2728
+ if (fieldName.startsWith("_") || fieldName === languageFieldName)
2729
+ return;
2730
+ }
2731
+ if (typeof source == "string" && typeof translated == "string") {
2732
+ if (source.length > 0 && source === translated) {
2733
+ const fieldName = path[path.length - 1] || "unknown";
2734
+ (["title", "name", "heading", "label", "description", "caption", "alt"].includes(
2735
+ fieldName
2736
+ ) || source.length > 10) && skippedFields.push({
2737
+ path: path.join("."),
2738
+ originalValue: source,
2739
+ translatedValue: translated
2740
+ });
2741
+ }
2742
+ return;
2743
+ }
2744
+ if (source && translated && typeof source == "object" && typeof translated == "object") {
2745
+ if (Array.isArray(source) && Array.isArray(translated)) {
2746
+ const maxLength = Math.min(source.length, translated.length);
2747
+ for (let i = 0; i < maxLength; i++)
2748
+ checkField(source[i], translated[i], [...path, String(i)]);
2749
+ } else if (!Array.isArray(source) && !Array.isArray(translated))
2750
+ for (const key of Object.keys(source))
2751
+ !key.startsWith("_") && key !== languageFieldName && checkField(source[key], translated[key], [
2752
+ ...path,
2753
+ key
2754
+ ]);
2755
+ }
2756
+ };
2757
+ return checkField(sourceDoc, translatedDoc), {
2758
+ valid: skippedFields.length === 0,
2759
+ skippedFields
2760
+ };
2384
2761
  }, assembleLanguageSpecs = ({
2385
2762
  sourceDocument,
2386
2763
  languageFieldName,
@@ -2501,14 +2878,36 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2501
2878
  translationPrompts,
2502
2879
  translationMode,
2503
2880
  openaiConfig,
2504
- releaseId
2881
+ releaseId,
2882
+ excludeFields = []
2505
2883
  }) => {
2506
- devLog.debug("Applying field transforms before translation", {
2884
+ const safeExcludeFields = excludeFields.filter((field) => field !== languageFieldName);
2885
+ excludeFields.length !== safeExcludeFields.length && devLog.warn("Language field was in excludeFields - removing it to prevent translation errors", {
2886
+ documentId: sourceDocument._id,
2887
+ languageFieldName,
2888
+ originalExcludeFields: excludeFields,
2889
+ safeExcludeFields
2890
+ }), devLog.info("Filtering excluded fields before translation", {
2891
+ documentId: sourceDocument._id,
2892
+ documentType: sourceDocument._type,
2893
+ excludeFields: safeExcludeFields,
2894
+ excludeFieldsLength: safeExcludeFields?.length ?? 0,
2895
+ sourceHasTitle: "title" in sourceDocument,
2896
+ sourceTitleValue: sourceDocument.title
2897
+ });
2898
+ const { filteredDocument, excludedValues } = filterExcludedFields(sourceDocument, safeExcludeFields);
2899
+ devLog.info("After filtering excluded fields", {
2900
+ documentId: sourceDocument._id,
2901
+ filteredHasTitle: "title" in filteredDocument,
2902
+ filteredTitleValue: filteredDocument.title,
2903
+ excludedValuesKeys: Object.keys(excludedValues),
2904
+ excludedValues
2905
+ }), devLog.debug("Applying field transforms before translation", {
2507
2906
  documentId: sourceDocument._id,
2508
2907
  documentType: sourceDocument._type
2509
2908
  });
2510
2909
  const { transformedDocument, originalStructure } = applyTransforms({
2511
- document: sourceDocument,
2910
+ document: filteredDocument,
2512
2911
  registry: globalTransformRegistry,
2513
2912
  schemaType: sourceDocument._type
2514
2913
  });
@@ -2549,12 +2948,27 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2549
2948
  documentId: sourceDocument._id,
2550
2949
  documentType: sourceDocument._type
2551
2950
  });
2552
- const finalDoc = restoreTransforms({
2951
+ const transformedResult = restoreTransforms({
2553
2952
  translatedDocument: translatedDoc,
2554
2953
  originalStructure,
2555
2954
  registry: globalTransformRegistry,
2556
2955
  schemaType: sourceDocument._type
2557
- }), targetDocumentPublishedId = existingTranslationId || uuid(), targetDocumentVersionId = `versions.${releaseId}.${targetDocumentPublishedId}`, newDoc = {
2956
+ }), excludedValuesToRestore = { ...excludedValues };
2957
+ delete excludedValuesToRestore[languageFieldName], devLog.info("Before restoring excluded fields", {
2958
+ documentId: sourceDocument._id,
2959
+ excludedFieldCount: Object.keys(excludedValues).length,
2960
+ excludedValuesKeys: Object.keys(excludedValues),
2961
+ languageFieldExcluded: languageFieldName in excludedValues,
2962
+ transformedResultHasTitle: "title" in transformedResult,
2963
+ transformedResultTitle: transformedResult.title
2964
+ });
2965
+ const finalDoc = restoreExcludedFields(transformedResult, excludedValuesToRestore);
2966
+ devLog.info("After restoring excluded fields", {
2967
+ documentId: sourceDocument._id,
2968
+ finalDocHasTitle: "title" in finalDoc,
2969
+ finalDocTitle: finalDoc.title
2970
+ });
2971
+ const targetDocumentPublishedId = existingTranslationId || uuid(), targetDocumentVersionId = `versions.${releaseId}.${targetDocumentPublishedId}`, newDoc = {
2558
2972
  ...finalDoc,
2559
2973
  _id: targetDocumentVersionId
2560
2974
  };
@@ -2575,7 +2989,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2575
2989
  translationPrompts,
2576
2990
  translationMode,
2577
2991
  openaiConfig,
2578
- releaseId
2992
+ releaseId,
2993
+ excludeFields
2579
2994
  }) => {
2580
2995
  const logContext = {
2581
2996
  documentId: sourceDocument._id,
@@ -2599,23 +3014,51 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2599
3014
  });
2600
3015
  return;
2601
3016
  }
2602
- const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), newDocument = await handleTranslation({
2603
- dispatch,
2604
- client,
2605
- schemaId,
2606
- sourceDocument,
2607
- targetLanguage,
2608
- existingTranslationId,
2609
- languageFieldName,
2610
- translationPrompts,
2611
- translationMode,
2612
- openaiConfig,
2613
- releaseId
2614
- });
2615
- if (!newDocument) {
2616
- setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd();
3017
+ const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage), translationResult = await withRetry(
3018
+ () => handleTranslation({
3019
+ dispatch,
3020
+ client,
3021
+ schemaId,
3022
+ sourceDocument,
3023
+ targetLanguage,
3024
+ existingTranslationId,
3025
+ languageFieldName,
3026
+ translationPrompts,
3027
+ translationMode,
3028
+ openaiConfig,
3029
+ releaseId,
3030
+ excludeFields
3031
+ }),
3032
+ {
3033
+ maxRetries: 3,
3034
+ initialDelayMs: 1e3,
3035
+ isRetryable: isRetryableError,
3036
+ onRetry: (attempt, error, delayMs) => {
3037
+ devLog.warn("Translation failed, scheduling retry", {
3038
+ ...logContext,
3039
+ attempt,
3040
+ delayMs,
3041
+ error: error instanceof Error ? error.message : String(error)
3042
+ }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "retrying", {
3043
+ retryCount: attempt,
3044
+ errorMessage: error instanceof Error ? error.message : String(error)
3045
+ });
3046
+ }
3047
+ }
3048
+ );
3049
+ if (!translationResult.success || !translationResult.result) {
3050
+ const errorMessage = translationResult.error instanceof Error ? translationResult.error.message : String(translationResult.error || "Translation failed");
3051
+ devLog.error("Translation failed after retries", translationResult.error, {
3052
+ ...logContext,
3053
+ attempts: translationResult.attempts
3054
+ }), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
3055
+ errorMessage,
3056
+ retryCount: translationResult.attempts - 1
3057
+ // -1 because attempts includes initial try
3058
+ }), devLog.timeEnd("translateDocument"), devLog.groupEnd();
2617
3059
  return;
2618
3060
  }
3061
+ const newDocument = translationResult.result;
2619
3062
  return setJobStatus(dispatch, sourceDocument._id, targetLanguage, "updating_metadata", {
2620
3063
  targetId: newDocument._id
2621
3064
  }), await updateTranslationMetadata({
@@ -2650,7 +3093,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2650
3093
  references = [],
2651
3094
  maxConcurrentTranslationJobs,
2652
3095
  translationMode,
2653
- openaiConfig
3096
+ openaiConfig,
3097
+ excludeFields
2654
3098
  }) => {
2655
3099
  const logContext = {
2656
3100
  documentId: sourceDocument._id,
@@ -2716,7 +3160,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2716
3160
  translationPrompts,
2717
3161
  translationMode,
2718
3162
  openaiConfig,
2719
- releaseId: releaseId2
3163
+ releaseId: releaseId2,
3164
+ excludeFields
2720
3165
  });
2721
3166
  versionedTargetDocumentId && versionedTargetDocumentIds.push(versionedTargetDocumentId);
2722
3167
  }
@@ -2789,7 +3234,8 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2789
3234
  maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
2790
3235
  maxReferenceDepth: config.maxReferenceDepth,
2791
3236
  translationMode,
2792
- openaiConfig: config.openai
3237
+ openaiConfig: config.openai,
3238
+ excludeFields: config.excludeFields
2793
3239
  });
2794
3240
  }, [
2795
3241
  document,
@@ -2801,6 +3247,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
2801
3247
  config.maxConcurrentTranslationJobs,
2802
3248
  config.maxReferenceDepth,
2803
3249
  config.openai,
3250
+ config.excludeFields,
2804
3251
  references,
2805
3252
  shouldOverwriteFreshTranslations,
2806
3253
  translationMode
@@ -3183,7 +3630,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3183
3630
  /* @__PURE__ */ jsxs(Code, { size: 1, children: [
3184
3631
  job._type,
3185
3632
  " \u2014 ",
3186
- job._id.slice(-6)
3633
+ job._id?.slice(-6) || "unknown"
3187
3634
  ] }),
3188
3635
  !["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
3189
3636
  job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
@@ -3195,12 +3642,17 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3195
3642
  job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3196
3643
  "Error: ",
3197
3644
  job.errorMessage
3645
+ ] }) }) : null,
3646
+ job.status === "retrying" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
3647
+ "Retrying after: ",
3648
+ job.errorMessage
3198
3649
  ] }) }) : null
3199
3650
  ] })
3200
3651
  ] }),
3201
3652
  /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
3202
3653
  job.status === "skipped" ? /* @__PURE__ */ jsx(SkippedBadge, {}) : null,
3203
- job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, {}) : null,
3654
+ job.status === "error" ? /* @__PURE__ */ jsx(ErrorBadge, { retryCount: job.retryCount }) : null,
3655
+ job.status === "retrying" ? /* @__PURE__ */ jsx(RetryingBadge, { retryCount: job.retryCount }) : null,
3204
3656
  job.targetId ? /* @__PURE__ */ jsx(
3205
3657
  TranslationOpenerButton,
3206
3658
  {
@@ -3280,12 +3732,25 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3280
3732
  content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "There was already a fresh translation for this document and language." }) }),
3281
3733
  children: /* @__PURE__ */ jsx(Badge, { tone: "neutral", children: "Skipped" })
3282
3734
  }
3283
- ), ErrorBadge = () => /* @__PURE__ */ jsx(
3735
+ ), ErrorBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3284
3736
  Tooltip,
3285
3737
  {
3286
3738
  portal: !0,
3287
- content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
3288
- children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
3739
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: retryCount && retryCount > 0 ? `This translation failed after ${retryCount} retry attempt${retryCount > 1 ? "s" : ""}.` : "This translation failed. Check the error message for details." }) }),
3740
+ children: /* @__PURE__ */ jsxs(Badge, { tone: "critical", children: [
3741
+ "Failed",
3742
+ retryCount && retryCount > 0 ? ` (${retryCount} retries)` : ""
3743
+ ] })
3744
+ }
3745
+ ), RetryingBadge = ({ retryCount }) => /* @__PURE__ */ jsx(
3746
+ Tooltip,
3747
+ {
3748
+ portal: !0,
3749
+ content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Retrying translation after a temporary failure." }) }),
3750
+ children: /* @__PURE__ */ jsxs(Badge, { tone: "caution", children: [
3751
+ "Retry ",
3752
+ retryCount || 1
3753
+ ] })
3289
3754
  }
3290
3755
  ), getStatusLabel = (status) => ({
3291
3756
  idle: "Idle",
@@ -3295,6 +3760,7 @@ ${protectedPhrases.join(", ")}`, buildStyleGuideSection = (globalPrompt, localeP
3295
3760
  translating_with_openai: "Translating with GPT-5...",
3296
3761
  reviewing_with_openai: "Reviewing translation with GPT-5...",
3297
3762
  updating_metadata: "Saving metadata...",
3763
+ retrying: "Retrying...",
3298
3764
  error: "Error",
3299
3765
  skipped: "Skipped",
3300
3766
  completed: "Completed"
@@ -3763,7 +4229,11 @@ const aiTranslationPlugin = definePlugin((config) => (config.fieldTransforms?.le
3763
4229
  }
3764
4230
  })), translationHistorySchema = translationHistory;
3765
4231
  export {
4232
+ COMMON_ENUM_FIELDS,
4233
+ PREDEFINED_EXCLUSIONS,
3766
4234
  aiTranslationPlugin,
4235
+ filterExcludedFields,
4236
+ restoreExcludedFields,
3767
4237
  richTextTransform,
3768
4238
  translationHistorySchema,
3769
4239
  vttCaptionTransform