@braze/sanity-plugin-ai-translation 0.2.0 → 0.2.2
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/README.md +76 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1903 -538
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
package/dist/index.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { useClient, defineType, defineField, pathToString, definePlugin } from "sanity";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, ArrowLeftIcon, EditIcon,
|
|
4
|
-
import { createContext, useContext, useEffect, useState, useReducer, useMemo } from "react";
|
|
5
|
-
import { Card, Flex, Stack, Text, Spinner, Button, Box, Checkbox, Select, TextInput, TextArea, Heading, Inline,
|
|
3
|
+
import { ErrorOutlineIcon, CheckmarkCircleIcon, EmptyIcon, AddIcon, TrashIcon, WarningOutlineIcon, InfoOutlineIcon, ArrowLeftIcon, EditIcon, TranslateIcon, CheckmarkIcon, CircleIcon, ArrowRightIcon, PanelRightIcon } from "@sanity/icons";
|
|
4
|
+
import { createContext, useContext, useEffect, useState, useReducer, useCallback, useMemo } from "react";
|
|
5
|
+
import { Card, Flex, Stack, Text, Spinner, Button, Box, Tooltip, Checkbox, Select, TextInput, TextArea, Heading, Inline, Badge, Grid, Radio, Label, Code } from "@sanity/ui";
|
|
6
6
|
import { useListeningQuery, useOpenInNewPane } from "sanity-plugin-utils";
|
|
7
7
|
import defaults from "lodash.defaults";
|
|
8
8
|
import { toPlainText } from "@portabletext/react";
|
|
9
|
-
import
|
|
9
|
+
import cloneDeep from "lodash.clonedeep";
|
|
10
|
+
import get from "lodash.get";
|
|
11
|
+
import set from "lodash.set";
|
|
10
12
|
const TranslationContext = createContext({
|
|
11
13
|
document: null,
|
|
12
14
|
config: null,
|
|
13
15
|
overallStatus: "idle",
|
|
14
16
|
selectedTargetLanguages: [],
|
|
15
17
|
translationJobs: /* @__PURE__ */ new Map(),
|
|
16
|
-
shouldTranslateDiffs: !1,
|
|
17
18
|
shouldOverwriteFreshTranslations: !1,
|
|
18
19
|
shouldTranslateReferences: !1,
|
|
19
20
|
maxReferenceDepth: 1,
|
|
20
21
|
references: [],
|
|
21
22
|
translationMetadata: null,
|
|
23
|
+
translationMode: "sanity-only",
|
|
24
|
+
openaiValidated: !1,
|
|
22
25
|
dispatch: () => {
|
|
23
26
|
}
|
|
24
27
|
}), useTranslationContext = () => {
|
|
@@ -28,7 +31,7 @@ const TranslationContext = createContext({
|
|
|
28
31
|
"useTranslationContext must be used within a TranslationContextProvider with document and config props"
|
|
29
32
|
);
|
|
30
33
|
return context;
|
|
31
|
-
}, TRANSLATION_METADATA_TYPE = "translation.metadata",
|
|
34
|
+
}, TRANSLATION_METADATA_TYPE = "translation.metadata", undraftId = (id) => id.replace(/^drafts\./, ""), TRANSLATION_METADATA_QUERY = "*[_type == $translationMetadataType && references($documentId)][0]", useTranslationMetadata = ({ documentId }) => {
|
|
32
35
|
const { dispatch } = useTranslationContext(), { data, loading, error } = useListeningQuery(TRANSLATION_METADATA_QUERY, {
|
|
33
36
|
params: {
|
|
34
37
|
documentId: undraftId(documentId),
|
|
@@ -81,6 +84,102 @@ const TranslationContext = createContext({
|
|
|
81
84
|
isLoggingEnabled() && console.timeEnd(`[AI Translation] ${label}`);
|
|
82
85
|
}
|
|
83
86
|
};
|
|
87
|
+
async function validateSuggestion(client, suggestion) {
|
|
88
|
+
return suggestion.type === "glossaryTerm" ? validateGlossaryTerm(client, suggestion) : suggestion.type === "protectedPhrase" ? validateProtectedPhrase(client, suggestion) : suggestion.type === "globalPrompt" || suggestion.type === "localePrompt" ? validatePrompt(suggestion) : { valid: !0, status: "valid" };
|
|
89
|
+
}
|
|
90
|
+
async function validateGlossaryTerm(client, suggestion) {
|
|
91
|
+
const { sourceValue, targetValue, sourceLocale, targetLocale } = suggestion;
|
|
92
|
+
if (!sourceValue || !targetValue || !sourceLocale || !targetLocale)
|
|
93
|
+
return {
|
|
94
|
+
valid: !1,
|
|
95
|
+
status: "invalid",
|
|
96
|
+
reason: "Missing required fields for glossary term"
|
|
97
|
+
};
|
|
98
|
+
if (sourceValue.trim() === targetValue.trim())
|
|
99
|
+
return {
|
|
100
|
+
valid: !1,
|
|
101
|
+
status: "invalid",
|
|
102
|
+
reason: `Source and target are identical ("${sourceValue}") - this should be a protected phrase instead`
|
|
103
|
+
};
|
|
104
|
+
const protectedPhrases = await client.fetch('*[_id == "translation-settings"][0].protectedPhrases');
|
|
105
|
+
if (protectedPhrases?.some((phrase) => phrase.toLowerCase() === sourceValue.toLowerCase()))
|
|
106
|
+
return {
|
|
107
|
+
valid: !1,
|
|
108
|
+
status: "invalid",
|
|
109
|
+
reason: `Cannot add "${sourceValue}" to glossary because it is already a protected phrase. Protected phrases are never translated. If you want to create a translation mapping for this term, please remove it from protected phrases first by editing Translation Settings.`
|
|
110
|
+
};
|
|
111
|
+
if (targetValue && protectedPhrases?.some((phrase) => phrase.toLowerCase() === targetValue.toLowerCase()))
|
|
112
|
+
return {
|
|
113
|
+
valid: !1,
|
|
114
|
+
status: "invalid",
|
|
115
|
+
reason: `Cannot use "${targetValue}" as a translation target because it is a protected phrase. Protected phrases should not appear as translation targets.`
|
|
116
|
+
};
|
|
117
|
+
const existingTerm = await client.fetch(
|
|
118
|
+
`*[_type == "translationGlossaryTerm" && count(translations[_key == $sourceLocale && value == $sourceValue]) > 0][0] {
|
|
119
|
+
translations
|
|
120
|
+
}`,
|
|
121
|
+
{ sourceLocale, sourceValue }
|
|
122
|
+
);
|
|
123
|
+
if (existingTerm) {
|
|
124
|
+
const existingTarget = existingTerm.translations.find((t) => t._key === targetLocale);
|
|
125
|
+
if (existingTarget?.value === targetValue)
|
|
126
|
+
return {
|
|
127
|
+
valid: !0,
|
|
128
|
+
status: "warning",
|
|
129
|
+
reason: `This translation already exists in the glossary: "${sourceValue}" \u2192 "${targetValue}"`
|
|
130
|
+
};
|
|
131
|
+
if (existingTarget && existingTarget.value !== targetValue)
|
|
132
|
+
return {
|
|
133
|
+
valid: !0,
|
|
134
|
+
status: "warning",
|
|
135
|
+
reason: `"${sourceValue}" already has a different translation in the glossary: "${existingTarget.value}". Applying this will update it to "${targetValue}".`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { valid: !0, status: "valid" };
|
|
139
|
+
}
|
|
140
|
+
async function validateProtectedPhrase(client, suggestion) {
|
|
141
|
+
const phrase = suggestion.description.trim();
|
|
142
|
+
if (!phrase)
|
|
143
|
+
return {
|
|
144
|
+
valid: !1,
|
|
145
|
+
status: "invalid",
|
|
146
|
+
reason: "Protected phrase cannot be empty"
|
|
147
|
+
};
|
|
148
|
+
if ((await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.some((p) => p.toLowerCase() === phrase.toLowerCase()))
|
|
149
|
+
return {
|
|
150
|
+
valid: !0,
|
|
151
|
+
status: "warning",
|
|
152
|
+
reason: `"${phrase}" is already in protected phrases`
|
|
153
|
+
};
|
|
154
|
+
const glossaryTermWithPhrase = await client.fetch(
|
|
155
|
+
`*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0][0] {
|
|
156
|
+
_id,
|
|
157
|
+
translations
|
|
158
|
+
}`,
|
|
159
|
+
{ phrase }
|
|
160
|
+
);
|
|
161
|
+
if (glossaryTermWithPhrase) {
|
|
162
|
+
const matchingTranslations = glossaryTermWithPhrase.translations.filter((t) => t.value.toLowerCase() === phrase.toLowerCase()).map((t) => t._key).join(", ");
|
|
163
|
+
return {
|
|
164
|
+
valid: !1,
|
|
165
|
+
status: "invalid",
|
|
166
|
+
reason: `Cannot add "${phrase}" as a protected phrase because it exists in the glossary as a translation (${matchingTranslations}). If you want to prevent this term from being translated, please remove it from the glossary first by editing Translation Settings.`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { valid: !0, status: "valid" };
|
|
170
|
+
}
|
|
171
|
+
function validatePrompt(suggestion) {
|
|
172
|
+
const update = suggestion.description.trim();
|
|
173
|
+
return update ? update.toLowerCase().includes("has been") || update.toLowerCase().includes("was changed") || update.toLowerCase().includes("corrected to") ? {
|
|
174
|
+
valid: !0,
|
|
175
|
+
status: "warning",
|
|
176
|
+
reason: "This update may be describing past changes rather than adding new rules"
|
|
177
|
+
} : { valid: !0, status: "valid" } : {
|
|
178
|
+
valid: !1,
|
|
179
|
+
status: "invalid",
|
|
180
|
+
reason: "Prompt update cannot be empty"
|
|
181
|
+
};
|
|
182
|
+
}
|
|
84
183
|
async function analyzeFeedback({
|
|
85
184
|
client,
|
|
86
185
|
sourceLanguage,
|
|
@@ -108,8 +207,25 @@ async function analyzeFeedback({
|
|
|
108
207
|
currentDocument
|
|
109
208
|
}), analysis = await analyzeWithAI(client, analysisPrompt);
|
|
110
209
|
devLog.info("AI analysis complete", { analysis });
|
|
111
|
-
const suggestions = convertAnalysisToSuggestions(analysis);
|
|
112
|
-
|
|
210
|
+
const suggestions = convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage);
|
|
211
|
+
devLog.info("Generated suggestions", { count: suggestions.length }), onProgress?.({ stage: "analyzing", message: "Validating suggestions..." });
|
|
212
|
+
const validatedSuggestions = await Promise.all(
|
|
213
|
+
suggestions.map(async (suggestion) => {
|
|
214
|
+
const validation = await validateSuggestion(client, suggestion);
|
|
215
|
+
return {
|
|
216
|
+
...suggestion,
|
|
217
|
+
warning: validation.reason,
|
|
218
|
+
validationStatus: validation.status,
|
|
219
|
+
// Only auto-approve valid suggestions; invalid ones cannot be approved
|
|
220
|
+
approved: validation.status === "valid"
|
|
221
|
+
};
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
return devLog.info("Suggestions validated", {
|
|
225
|
+
valid: validatedSuggestions.filter((s) => s.validationStatus === "valid").length,
|
|
226
|
+
warnings: validatedSuggestions.filter((s) => s.validationStatus === "warning").length,
|
|
227
|
+
invalid: validatedSuggestions.filter((s) => s.validationStatus === "invalid").length
|
|
228
|
+
}), validatedSuggestions;
|
|
113
229
|
}
|
|
114
230
|
async function applySuggestions({
|
|
115
231
|
client,
|
|
@@ -217,11 +333,67 @@ function buildAnalysisPrompt({
|
|
|
217
333
|
"",
|
|
218
334
|
"Be conservative: only suggest changes when there is clear evidence.",
|
|
219
335
|
"If in doubt, prefer locale-specific updates over global updates.",
|
|
220
|
-
"Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts."
|
|
336
|
+
"Avoid redundancy - if you add a protected phrase, do NOT mention it in prompts.",
|
|
337
|
+
"",
|
|
338
|
+
"CONCRETE EXAMPLES:",
|
|
339
|
+
"",
|
|
340
|
+
"Example 1 - Brand name protection:",
|
|
341
|
+
'User feedback: "Braze should never be translated"',
|
|
342
|
+
"Correct output:",
|
|
343
|
+
"{",
|
|
344
|
+
' "protectedPhrasesToAdd": ["Braze"],',
|
|
345
|
+
' "reasoning": "User explicitly stated Braze is a brand name that should not be translated"',
|
|
346
|
+
"}",
|
|
347
|
+
"WRONG: Do not add this to glossary or prompts.",
|
|
348
|
+
"",
|
|
349
|
+
"Example 2 - Terminology correction:",
|
|
350
|
+
'Edit analysis: User changed "discussion" to "chat" consistently in French translation',
|
|
351
|
+
"Correct output:",
|
|
352
|
+
"{",
|
|
353
|
+
` "glossaryTermsToAdd": [{"sourceLocale": "${sourceLanguage}", "sourceValue": "discussion", "targetLocale": "${targetLanguage}", "targetValue": "chat"}],`,
|
|
354
|
+
' "reasoning": "User consistently changed discussion\u2192chat, indicating preferred terminology"',
|
|
355
|
+
"}",
|
|
356
|
+
"WRONG: Do not add to protected phrases (values differ) or prompts (specific mapping).",
|
|
357
|
+
"",
|
|
358
|
+
"Example 3 - Style guidance:",
|
|
359
|
+
'User feedback: "Translation is too formal, use casual tone"',
|
|
360
|
+
"Correct output:",
|
|
361
|
+
"{",
|
|
362
|
+
` "localePromptUpdates": "Use a casual, conversational tone when translating to ${targetLanguage}. Avoid overly formal language.",`,
|
|
363
|
+
' "reasoning": "This is style guidance specific to this language pair"',
|
|
364
|
+
"}",
|
|
365
|
+
"WRONG: Do not make global (applies only to this target language).",
|
|
366
|
+
"",
|
|
367
|
+
"Example 4 - Technical term in English:",
|
|
368
|
+
`User feedback: "Keep technical terms like 'API' and 'webhook' in English"`,
|
|
369
|
+
"Correct output:",
|
|
370
|
+
"{",
|
|
371
|
+
` "localePromptUpdates": "When translating to ${targetLanguage}, keep technical terms like 'API', 'webhook', and similar technical jargon in English.",`,
|
|
372
|
+
' "reasoning": "This is language-pair-specific guidance about handling technical terms"',
|
|
373
|
+
"}",
|
|
374
|
+
'WRONG: Do not add "API" or "webhook" to protected phrases (already covered by prompt guidance).',
|
|
375
|
+
"",
|
|
376
|
+
"Example 5 - Multiple issues:",
|
|
377
|
+
'User feedback: "MyApp is our product name. Also, use more concise language."',
|
|
378
|
+
"Correct output:",
|
|
379
|
+
"{",
|
|
380
|
+
' "protectedPhrasesToAdd": ["MyApp"],',
|
|
381
|
+
` "localePromptUpdates": "Use concise language when translating to ${targetLanguage}. Prefer shorter phrases over lengthy explanations.",`,
|
|
382
|
+
' "reasoning": "MyApp is a product name (protected). Conciseness is a style preference for this language pair (locale prompt)."',
|
|
383
|
+
"}",
|
|
384
|
+
"",
|
|
385
|
+
"Example 6 - Identical source and target:",
|
|
386
|
+
'Edit analysis: User kept "dashboard" as "dashboard" in Spanish',
|
|
387
|
+
"Correct output:",
|
|
388
|
+
"{",
|
|
389
|
+
' "protectedPhrasesToAdd": ["dashboard"],',
|
|
390
|
+
' "reasoning": "User kept the term unchanged, indicating it should not be translated"',
|
|
391
|
+
"}",
|
|
392
|
+
"WRONG: Do not add to glossary when source === target."
|
|
221
393
|
), parts.join(`
|
|
222
394
|
`);
|
|
223
395
|
}
|
|
224
|
-
function convertAnalysisToSuggestions(analysis) {
|
|
396
|
+
function convertAnalysisToSuggestions(analysis, sourceLanguage, targetLanguage) {
|
|
225
397
|
const suggestions = [];
|
|
226
398
|
if (analysis.globalPromptUpdates && suggestions.push({
|
|
227
399
|
id: crypto.randomUUID(),
|
|
@@ -248,9 +420,10 @@ function convertAnalysisToSuggestions(analysis) {
|
|
|
248
420
|
type: "glossaryTerm",
|
|
249
421
|
description: `${term.sourceValue} \u2192 ${term.targetValue}`,
|
|
250
422
|
approved: !0,
|
|
251
|
-
|
|
423
|
+
// Use the provided source/target languages instead of relying on AI to provide them
|
|
424
|
+
sourceLocale: sourceLanguage,
|
|
252
425
|
sourceValue: term.sourceValue,
|
|
253
|
-
targetLocale:
|
|
426
|
+
targetLocale: targetLanguage,
|
|
254
427
|
targetValue: term.targetValue
|
|
255
428
|
});
|
|
256
429
|
return suggestions;
|
|
@@ -297,13 +470,35 @@ function parseAIResponse(responseText) {
|
|
|
297
470
|
}
|
|
298
471
|
}
|
|
299
472
|
async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage, suggestions, onProgress) {
|
|
300
|
-
devLog.info("Applying improvements from suggestions", {
|
|
473
|
+
devLog.info("Applying improvements from suggestions", {
|
|
474
|
+
count: suggestions.length,
|
|
475
|
+
targetLanguage,
|
|
476
|
+
suggestions: suggestions.map((s) => ({
|
|
477
|
+
type: s.type,
|
|
478
|
+
approved: s.approved,
|
|
479
|
+
sourceLocale: s.sourceLocale,
|
|
480
|
+
targetLocale: s.targetLocale,
|
|
481
|
+
sourceValue: s.sourceValue,
|
|
482
|
+
targetValue: s.targetValue
|
|
483
|
+
}))
|
|
484
|
+
});
|
|
301
485
|
const settingsId = "translation-settings";
|
|
302
486
|
await client.fetch("*[_id == $id][0]", { id: settingsId }) || await client.createIfNotExists({
|
|
303
487
|
_id: settingsId,
|
|
304
488
|
_type: "translationSettings"
|
|
305
489
|
});
|
|
306
|
-
const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()),
|
|
490
|
+
const updates = [], globalPrompts = suggestions.filter((s) => s.type === "globalPrompt" && s.description.trim()), localePrompts = suggestions.filter((s) => s.type === "localePrompt" && s.description.trim()), protectedPhrases = suggestions.filter((s) => s.type === "protectedPhrase" && s.description.trim()), glossaryTermsBeforeFilter = suggestions.filter((s) => s.type === "glossaryTerm");
|
|
491
|
+
devLog.info("Glossary terms before filtering", {
|
|
492
|
+
count: glossaryTermsBeforeFilter.length,
|
|
493
|
+
terms: glossaryTermsBeforeFilter.map((s) => ({
|
|
494
|
+
sourceLocale: s.sourceLocale,
|
|
495
|
+
sourceValue: s.sourceValue,
|
|
496
|
+
targetLocale: s.targetLocale,
|
|
497
|
+
targetValue: s.targetValue,
|
|
498
|
+
approved: s.approved
|
|
499
|
+
}))
|
|
500
|
+
});
|
|
501
|
+
const glossaryTerms = suggestions.filter(
|
|
307
502
|
(s) => s.type === "glossaryTerm" && s.sourceLocale?.trim() && s.sourceValue?.trim() && s.targetLocale?.trim() && s.targetValue?.trim()
|
|
308
503
|
);
|
|
309
504
|
if (devLog.info("Filtered suggestions by type", {
|
|
@@ -314,7 +509,7 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
|
|
|
314
509
|
}), (globalPrompts.length > 0 || localePrompts.length > 0) && (onProgress?.({
|
|
315
510
|
stage: "applying",
|
|
316
511
|
message: globalPrompts.length > 0 && localePrompts.length > 0 ? "Updating global and locale translation prompts..." : globalPrompts.length > 0 ? "Updating global translation prompt..." : `Updating ${targetLanguage} translation prompt...`
|
|
317
|
-
}), updates.push(
|
|
512
|
+
}), updates.push(updatePromptsWithTemplate(client, schemaId, settingsId, targetLanguage, globalPrompts, localePrompts))), protectedPhrases.length > 0) {
|
|
318
513
|
onProgress?.({ stage: "applying", message: "Adding protected phrases..." });
|
|
319
514
|
const phrases = protectedPhrases.map((s) => s.description);
|
|
320
515
|
devLog.debug("Applying protected phrases", { phrases }), updates.push(addProtectedPhrases(client, settingsId, phrases));
|
|
@@ -331,14 +526,17 @@ async function applyImprovementsFromSuggestions(client, schemaId, targetLanguage
|
|
|
331
526
|
}));
|
|
332
527
|
devLog.debug("Applying glossary terms", { terms }), updates.push(addGlossaryTerms(client, terms));
|
|
333
528
|
}
|
|
334
|
-
updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates),
|
|
529
|
+
updates.length === 0 && devLog.warn("No valid updates to apply - all suggestions may have been empty"), await Promise.all(updates), onProgress?.({ stage: "applying", message: "Recording changes..." }), await recordChangeHistory(
|
|
530
|
+
client,
|
|
531
|
+
settingsId,
|
|
532
|
+
targetLanguage,
|
|
533
|
+
suggestions.filter((s) => s.approved)
|
|
534
|
+
), devLog.info("All improvements applied successfully");
|
|
335
535
|
}
|
|
336
|
-
async function
|
|
337
|
-
if (devLog.info("Updating prompts with
|
|
536
|
+
async function updatePromptsWithTemplate(client, _schemaId, settingsId, targetLanguage, globalPrompts, localePrompts) {
|
|
537
|
+
if (devLog.info("Updating prompts with deterministic template", {
|
|
338
538
|
settingsId,
|
|
339
539
|
targetLanguage,
|
|
340
|
-
targetLanguageType: typeof targetLanguage,
|
|
341
|
-
targetLanguageValue: targetLanguage,
|
|
342
540
|
hasGlobalUpdates: globalPrompts.length > 0,
|
|
343
541
|
hasLocaleUpdates: localePrompts.length > 0
|
|
344
542
|
}), !targetLanguage)
|
|
@@ -347,91 +545,56 @@ async function updatePromptsWithTransform(client, schemaId, settingsId, targetLa
|
|
|
347
545
|
globalPrompts: globalPrompts.length,
|
|
348
546
|
localePrompts: localePrompts.length
|
|
349
547
|
}), new Error("targetLanguage is required but was undefined");
|
|
350
|
-
const
|
|
548
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().split("T")[0], currentSettings = await client.fetch("*[_id == $settingsId][0]{ globalPrompt, localePrompts }", { settingsId });
|
|
351
549
|
if (globalPrompts.length > 0) {
|
|
352
|
-
const
|
|
353
|
-
|
|
550
|
+
const currentGlobalPrompt = currentSettings?.globalPrompt || "", updates = globalPrompts.map((s) => s.description.trim()).filter(Boolean), newGlobalPrompt = [
|
|
551
|
+
currentGlobalPrompt,
|
|
552
|
+
currentGlobalPrompt ? `
|
|
553
|
+
` : "",
|
|
554
|
+
`## User Feedback (${timestamp})`,
|
|
555
|
+
...updates.map((update) => `- ${update}`)
|
|
556
|
+
].filter(Boolean).join(`
|
|
354
557
|
`);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
Your task: Merge the new guidance below into the existing prompt text. Treat the new guidance as rules that will guide future translations, not as transformations to apply to the current text.
|
|
362
|
-
|
|
363
|
-
Integration guidelines:
|
|
364
|
-
- Add the new guidance as additional instructions/rules in the prompt
|
|
365
|
-
- If it conflicts with existing guidance, update or replace the conflicting parts
|
|
366
|
-
- If it's already covered, don't add redundant text
|
|
367
|
-
- Maintain clear structure and consistent tone
|
|
368
|
-
- Preserve all existing guidance that is still relevant
|
|
369
|
-
- Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
|
|
370
|
-
|
|
371
|
-
New guidance to incorporate:
|
|
372
|
-
|
|
373
|
-
\`\`\`
|
|
374
|
-
${combinedGlobalUpdate}
|
|
375
|
-
\`\`\``
|
|
376
|
-
}), devLog.debug("Added global prompt target", { combinedUpdate: combinedGlobalUpdate });
|
|
558
|
+
await client.patch(settingsId).set({ globalPrompt: newGlobalPrompt }).commit(), devLog.info("Global prompt updated", {
|
|
559
|
+
previousLength: currentGlobalPrompt.length,
|
|
560
|
+
newLength: newGlobalPrompt.length,
|
|
561
|
+
addedUpdates: updates.length
|
|
562
|
+
});
|
|
377
563
|
}
|
|
378
564
|
if (localePrompts.length > 0) {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
- If it's already covered, don't add redundant text
|
|
411
|
-
- Maintain clear structure and consistent tone
|
|
412
|
-
- Preserve all existing guidance that is still relevant
|
|
413
|
-
- Keep the prompt written as instructions for a translator (e.g., "When translating, you should..." not "The translation has been...")
|
|
414
|
-
|
|
415
|
-
New guidance to incorporate:
|
|
416
|
-
|
|
417
|
-
\`\`\`
|
|
418
|
-
${combinedLocaleUpdate}
|
|
419
|
-
\`\`\``
|
|
420
|
-
}), devLog.debug("Added locale prompt target", {
|
|
421
|
-
targetLanguage,
|
|
422
|
-
localePromptPath,
|
|
423
|
-
combinedUpdate: combinedLocaleUpdate
|
|
424
|
-
});
|
|
565
|
+
const updates = localePrompts.map((s) => s.description.trim()).filter(Boolean), existingLocalePrompts = currentSettings?.localePrompts || [], existingLocalePrompt = existingLocalePrompts.find((lp) => lp._key === targetLanguage);
|
|
566
|
+
if (existingLocalePrompt) {
|
|
567
|
+
const currentValue = existingLocalePrompt.value || "", newValue = [
|
|
568
|
+
currentValue,
|
|
569
|
+
currentValue ? `
|
|
570
|
+
` : "",
|
|
571
|
+
`## User Feedback (${timestamp})`,
|
|
572
|
+
...updates.map((update) => `- ${update}`)
|
|
573
|
+
].filter(Boolean).join(`
|
|
574
|
+
`), localeIndex = existingLocalePrompts.findIndex((lp) => lp._key === targetLanguage);
|
|
575
|
+
await client.patch(settingsId).set({ [`localePrompts[${localeIndex}].value`]: newValue }).commit(), devLog.info("Locale prompt updated", {
|
|
576
|
+
targetLanguage,
|
|
577
|
+
previousLength: currentValue.length,
|
|
578
|
+
newLength: newValue.length,
|
|
579
|
+
addedUpdates: updates.length
|
|
580
|
+
});
|
|
581
|
+
} else {
|
|
582
|
+
const newValue = [`## User Feedback (${timestamp})`, ...updates.map((update) => `- ${update}`)].filter(Boolean).join(`
|
|
583
|
+
`);
|
|
584
|
+
await client.patch(settingsId).setIfMissing({ localePrompts: [] }).append("localePrompts", [
|
|
585
|
+
{
|
|
586
|
+
_type: "internationalizedArrayTextValue",
|
|
587
|
+
_key: targetLanguage,
|
|
588
|
+
value: newValue
|
|
589
|
+
}
|
|
590
|
+
]).commit(), devLog.info("Locale prompt created", {
|
|
591
|
+
targetLanguage,
|
|
592
|
+
length: newValue.length,
|
|
593
|
+
addedUpdates: updates.length
|
|
594
|
+
});
|
|
595
|
+
}
|
|
425
596
|
}
|
|
426
|
-
|
|
427
|
-
schemaId,
|
|
428
|
-
documentId: settingsId,
|
|
429
|
-
instruction: "Update translation prompts based on user feedback to improve future translations.",
|
|
430
|
-
target: targets,
|
|
431
|
-
forcePublishedWrite: !0
|
|
432
|
-
});
|
|
433
|
-
devLog.info("Prompts updated via transform action", {
|
|
434
|
-
resultId: result._id,
|
|
597
|
+
devLog.info("Prompts updated successfully", {
|
|
435
598
|
globalUpdated: globalPrompts.length > 0,
|
|
436
599
|
localeUpdated: localePrompts.length > 0
|
|
437
600
|
});
|
|
@@ -440,8 +603,14 @@ async function addProtectedPhrases(client, settingsId, phrases) {
|
|
|
440
603
|
devLog.info("Adding protected phrases", { settingsId, phrasesCount: phrases.length, phrases });
|
|
441
604
|
const currentPhrases = await client.fetch("*[_id == $settingsId][0].protectedPhrases", { settingsId });
|
|
442
605
|
devLog.debug("Fetched current protected phrases", { currentCount: currentPhrases?.length });
|
|
443
|
-
|
|
444
|
-
|
|
606
|
+
let newPhrases = phrases.filter((phrase) => !currentPhrases?.includes(phrase));
|
|
607
|
+
const conflictingPhrases = [];
|
|
608
|
+
for (const phrase of newPhrases)
|
|
609
|
+
await client.fetch(
|
|
610
|
+
'count(*[_type == "translationGlossaryTerm" && count(translations[value == $phrase]) > 0]) > 0',
|
|
611
|
+
{ phrase }
|
|
612
|
+
) && (conflictingPhrases.push(phrase), devLog.warn("Skipping protected phrase due to glossary conflict", { phrase }));
|
|
613
|
+
if (newPhrases = newPhrases.filter((phrase) => !conflictingPhrases.includes(phrase)), newPhrases.length === 0) {
|
|
445
614
|
devLog.info("No new protected phrases to add (all already exist)");
|
|
446
615
|
return;
|
|
447
616
|
}
|
|
@@ -449,7 +618,13 @@ async function addProtectedPhrases(client, settingsId, phrases) {
|
|
|
449
618
|
devLog.info("Protected phrases added", { count: newPhrases.length, phrases: newPhrases, resultId: result._id });
|
|
450
619
|
}
|
|
451
620
|
async function addGlossaryTerms(client, terms) {
|
|
452
|
-
const validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
|
|
621
|
+
const protectedPhrasesLower = (await client.fetch('*[_id == "translation-settings"][0].protectedPhrases'))?.map((p) => p.toLowerCase()) || [], validTerms = terms.filter(({ sourceValue, targetValue }) => sourceValue === targetValue ? (devLog.warn("Skipping glossary term where source === target (should be a protected phrase)", {
|
|
622
|
+
sourceValue,
|
|
623
|
+
targetValue
|
|
624
|
+
}), !1) : protectedPhrasesLower.includes(sourceValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because source value is a protected phrase", {
|
|
625
|
+
sourceValue,
|
|
626
|
+
targetValue
|
|
627
|
+
}), !1) : protectedPhrasesLower.includes(targetValue.toLowerCase()) ? (devLog.warn("Skipping glossary term because target value is a protected phrase", {
|
|
453
628
|
sourceValue,
|
|
454
629
|
targetValue
|
|
455
630
|
}), !1) : !0);
|
|
@@ -466,32 +641,47 @@ async function addGlossaryTerms(client, terms) {
|
|
|
466
641
|
}`,
|
|
467
642
|
{ sourceLocale, sourceValue }
|
|
468
643
|
);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
targetLocale,
|
|
473
|
-
targetValue
|
|
474
|
-
}) : (await client.patch(existingTerm._id).append("translations", [
|
|
475
|
-
{
|
|
476
|
-
_type: "internationalizedArrayStringValue",
|
|
477
|
-
_key: targetLocale,
|
|
478
|
-
value: targetValue
|
|
479
|
-
}
|
|
480
|
-
]).commit(), devLog.info("Updated existing glossary term", { sourceLocale, sourceValue, targetLocale, targetValue })) : (await client.create({
|
|
481
|
-
_type: "translationGlossaryTerm",
|
|
482
|
-
translations: [
|
|
483
|
-
{
|
|
484
|
-
_type: "internationalizedArrayStringValue",
|
|
485
|
-
_key: sourceLocale,
|
|
486
|
-
value: sourceValue
|
|
487
|
-
},
|
|
644
|
+
if (existingTerm) {
|
|
645
|
+
const existingTargetIndex = existingTerm.translations.findIndex((t) => t._key === targetLocale), existingTargetValue = existingTargetIndex >= 0 ? existingTerm.translations[existingTargetIndex].value : null;
|
|
646
|
+
existingTargetIndex === -1 ? (await client.patch(existingTerm._id).append("translations", [
|
|
488
647
|
{
|
|
489
648
|
_type: "internationalizedArrayStringValue",
|
|
490
649
|
_key: targetLocale,
|
|
491
650
|
value: targetValue
|
|
492
651
|
}
|
|
493
|
-
]
|
|
494
|
-
|
|
652
|
+
]).commit(), devLog.info("Added target language to existing glossary term", {
|
|
653
|
+
sourceLocale,
|
|
654
|
+
sourceValue,
|
|
655
|
+
targetLocale,
|
|
656
|
+
targetValue
|
|
657
|
+
})) : existingTargetValue !== targetValue ? (await client.patch(existingTerm._id).set({ [`translations[${existingTargetIndex}].value`]: targetValue }).commit(), devLog.info("Updated existing glossary term translation", {
|
|
658
|
+
sourceLocale,
|
|
659
|
+
sourceValue,
|
|
660
|
+
targetLocale,
|
|
661
|
+
oldValue: existingTargetValue,
|
|
662
|
+
newValue: targetValue
|
|
663
|
+
})) : devLog.info("Glossary term already has this exact translation", {
|
|
664
|
+
sourceLocale,
|
|
665
|
+
sourceValue,
|
|
666
|
+
targetLocale,
|
|
667
|
+
targetValue
|
|
668
|
+
});
|
|
669
|
+
} else
|
|
670
|
+
await client.create({
|
|
671
|
+
_type: "translationGlossaryTerm",
|
|
672
|
+
translations: [
|
|
673
|
+
{
|
|
674
|
+
_type: "internationalizedArrayStringValue",
|
|
675
|
+
_key: sourceLocale,
|
|
676
|
+
value: sourceValue
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
_type: "internationalizedArrayStringValue",
|
|
680
|
+
_key: targetLocale,
|
|
681
|
+
value: targetValue
|
|
682
|
+
}
|
|
683
|
+
]
|
|
684
|
+
}), devLog.info("Created new glossary term", { sourceLocale, sourceValue, targetLocale, targetValue });
|
|
495
685
|
}
|
|
496
686
|
devLog.info("Glossary terms processed", {
|
|
497
687
|
totalRequested: terms.length,
|
|
@@ -499,6 +689,20 @@ async function addGlossaryTerms(client, terms) {
|
|
|
499
689
|
skipped: terms.length - validTerms.length
|
|
500
690
|
});
|
|
501
691
|
}
|
|
692
|
+
async function recordChangeHistory(client, settingsId, targetLanguage, suggestions) {
|
|
693
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString(), historyEntries = suggestions.map((suggestion) => ({
|
|
694
|
+
_type: "object",
|
|
695
|
+
_key: crypto.randomUUID(),
|
|
696
|
+
timestamp,
|
|
697
|
+
changeType: suggestion.type,
|
|
698
|
+
description: suggestion.description,
|
|
699
|
+
locale: suggestion.type === "localePrompt" ? targetLanguage : void 0
|
|
700
|
+
}));
|
|
701
|
+
historyEntries.length !== 0 && (await client.patch(settingsId).setIfMissing({ changeHistory: [] }).append("changeHistory", historyEntries).commit(), devLog.info("Change history recorded", {
|
|
702
|
+
count: historyEntries.length,
|
|
703
|
+
timestamp
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
502
706
|
const ICON_SIZE = 64, progressIconMap = {
|
|
503
707
|
in_progress: /* @__PURE__ */ jsx(Spinner, {}),
|
|
504
708
|
completed: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: ICON_SIZE }),
|
|
@@ -521,7 +725,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
521
725
|
localePrompt: "Locale Prompt",
|
|
522
726
|
protectedPhrase: "Protected Phrase",
|
|
523
727
|
glossaryTerm: "Glossary Term"
|
|
524
|
-
}, createNewSuggestion = (type = "protectedPhrase") => {
|
|
728
|
+
}, createNewSuggestion = (type = "protectedPhrase", sourceLanguage, targetLanguage) => {
|
|
525
729
|
const baseSuggestion = {
|
|
526
730
|
id: crypto.randomUUID(),
|
|
527
731
|
type,
|
|
@@ -531,11 +735,18 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
531
735
|
return type === "glossaryTerm" ? {
|
|
532
736
|
...baseSuggestion,
|
|
533
737
|
sourceValue: "",
|
|
534
|
-
targetValue: ""
|
|
738
|
+
targetValue: "",
|
|
739
|
+
sourceLocale: sourceLanguage,
|
|
740
|
+
targetLocale: targetLanguage
|
|
535
741
|
} : baseSuggestion;
|
|
536
|
-
}, SuggestionReviewList = ({
|
|
742
|
+
}, SuggestionReviewList = ({
|
|
743
|
+
suggestions,
|
|
744
|
+
onSuggestionsChange,
|
|
745
|
+
sourceLanguage,
|
|
746
|
+
targetLanguage
|
|
747
|
+
}) => {
|
|
537
748
|
const [editingId, setEditingId] = useState(null), handleToggleApproval = (id) => {
|
|
538
|
-
const updated = suggestions.map((s) => s.id === id ? { ...s, approved: !s.approved } : s);
|
|
749
|
+
const updated = suggestions.map((s) => s.id === id && s.validationStatus !== "invalid" ? { ...s, approved: !s.approved } : s);
|
|
539
750
|
onSuggestionsChange(updated);
|
|
540
751
|
}, handleTypeChange = (id, newType) => {
|
|
541
752
|
const updated = suggestions.map((s) => {
|
|
@@ -547,7 +758,9 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
547
758
|
...s,
|
|
548
759
|
type: newType,
|
|
549
760
|
sourceValue: match ? match[1].trim() : "",
|
|
550
|
-
targetValue: match ? match[2].trim() : ""
|
|
761
|
+
targetValue: match ? match[2].trim() : "",
|
|
762
|
+
sourceLocale: sourceLanguage,
|
|
763
|
+
targetLocale: targetLanguage
|
|
551
764
|
};
|
|
552
765
|
}
|
|
553
766
|
return newType !== "glossaryTerm" && s.type === "glossaryTerm" ? {
|
|
@@ -568,6 +781,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
568
781
|
...s,
|
|
569
782
|
sourceValue: newSource,
|
|
570
783
|
description: `${newSource} \u2192 ${s.targetValue || ""}`
|
|
784
|
+
// Preserve sourceLocale and targetLocale
|
|
571
785
|
} : s
|
|
572
786
|
);
|
|
573
787
|
onSuggestionsChange(updated);
|
|
@@ -577,6 +791,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
577
791
|
...s,
|
|
578
792
|
targetValue: newTarget,
|
|
579
793
|
description: `${s.sourceValue || ""} \u2192 ${newTarget}`
|
|
794
|
+
// Preserve sourceLocale and targetLocale
|
|
580
795
|
} : s
|
|
581
796
|
);
|
|
582
797
|
onSuggestionsChange(updated);
|
|
@@ -584,7 +799,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
584
799
|
const updated = suggestions.filter((s) => s.id !== id);
|
|
585
800
|
onSuggestionsChange(updated);
|
|
586
801
|
}, handleAddSuggestion = (type) => {
|
|
587
|
-
const newSuggestion = createNewSuggestion(type);
|
|
802
|
+
const newSuggestion = createNewSuggestion(type, sourceLanguage, targetLanguage);
|
|
588
803
|
onSuggestionsChange([...suggestions, newSuggestion]), setEditingId(newSuggestion.id);
|
|
589
804
|
};
|
|
590
805
|
return suggestions.length === 0 ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
@@ -611,16 +826,24 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
611
826
|
) })
|
|
612
827
|
] }) : /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
613
828
|
suggestions.map((suggestion) => {
|
|
614
|
-
const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt";
|
|
829
|
+
const isEditing = editingId === suggestion.id, isGlossaryTerm = suggestion.type === "glossaryTerm", isPrompt = suggestion.type === "globalPrompt" || suggestion.type === "localePrompt", hasWarning = suggestion.warning && suggestion.validationStatus !== "valid", isInvalid = suggestion.validationStatus === "invalid";
|
|
615
830
|
return /* @__PURE__ */ jsx(
|
|
616
831
|
Card,
|
|
617
832
|
{
|
|
618
833
|
padding: 4,
|
|
619
834
|
border: !0,
|
|
620
|
-
tone: suggestion.approved ? "default" : "transparent",
|
|
835
|
+
tone: isInvalid ? "critical" : hasWarning ? "caution" : suggestion.approved ? "default" : "transparent",
|
|
621
836
|
style: { opacity: suggestion.approved ? 1 : 0.5 },
|
|
622
837
|
children: /* @__PURE__ */ jsx(Stack, { space: 3, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 3, children: [
|
|
623
|
-
/* @__PURE__ */ jsx(Box, { paddingTop: 1, children: /* @__PURE__ */ jsx(
|
|
838
|
+
/* @__PURE__ */ jsx(Box, { paddingTop: 1, children: isInvalid ? /* @__PURE__ */ jsx(
|
|
839
|
+
Tooltip,
|
|
840
|
+
{
|
|
841
|
+
content: /* @__PURE__ */ jsx(Box, { padding: 2, style: { maxWidth: "280px" }, children: /* @__PURE__ */ jsx(Text, { size: 1, children: "This suggestion cannot be applied because it conflicts with existing settings. See the error message below for details." }) }),
|
|
842
|
+
placement: "top",
|
|
843
|
+
portal: !0,
|
|
844
|
+
children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Checkbox, { checked: !1, disabled: !0, readOnly: !0 }) })
|
|
845
|
+
}
|
|
846
|
+
) : /* @__PURE__ */ jsx(Checkbox, { checked: suggestion.approved, onChange: () => handleToggleApproval(suggestion.id) }) }),
|
|
624
847
|
/* @__PURE__ */ jsxs(Stack, { space: 3, flex: 1, children: [
|
|
625
848
|
/* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", wrap: "wrap", children: [
|
|
626
849
|
/* @__PURE__ */ jsx(Box, { flex: 1, style: { minWidth: "200px" }, children: /* @__PURE__ */ jsx(
|
|
@@ -629,6 +852,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
629
852
|
fontSize: 1,
|
|
630
853
|
value: suggestion.type,
|
|
631
854
|
onChange: (event) => handleTypeChange(suggestion.id, event.currentTarget.value),
|
|
855
|
+
"aria-label": "Suggestion type",
|
|
632
856
|
children: Object.entries(IMPROVEMENT_TYPE_LABELS).map(([value, label]) => /* @__PURE__ */ jsx("option", { value, children: label }, value))
|
|
633
857
|
}
|
|
634
858
|
) }),
|
|
@@ -640,31 +864,42 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
640
864
|
icon: TrashIcon,
|
|
641
865
|
fontSize: 1,
|
|
642
866
|
padding: 2,
|
|
643
|
-
onClick: () => handleRemove(suggestion.id)
|
|
867
|
+
onClick: () => handleRemove(suggestion.id),
|
|
868
|
+
"aria-label": "Remove suggestion",
|
|
869
|
+
title: "Remove suggestion"
|
|
644
870
|
}
|
|
645
871
|
)
|
|
646
872
|
] }),
|
|
647
|
-
isGlossaryTerm ? /* @__PURE__ */
|
|
648
|
-
/* @__PURE__ */
|
|
649
|
-
|
|
650
|
-
{
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
873
|
+
isGlossaryTerm ? /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
874
|
+
(suggestion.sourceLocale || suggestion.targetLocale) && /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
|
|
875
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.sourceLocale ? `Source (${suggestion.sourceLocale})` : "Source" }) }),
|
|
876
|
+
/* @__PURE__ */ jsx(Box, { style: { width: "20px" } }),
|
|
877
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(Text, { size: 0, muted: !0, weight: "medium", children: suggestion.targetLocale ? `Target (${suggestion.targetLocale})` : "Target" }) })
|
|
878
|
+
] }),
|
|
879
|
+
/* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", children: [
|
|
880
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
|
|
881
|
+
TextInput,
|
|
882
|
+
{
|
|
883
|
+
fontSize: 1,
|
|
884
|
+
placeholder: "Source term",
|
|
885
|
+
value: suggestion.sourceValue || "",
|
|
886
|
+
onChange: (event) => handleGlossarySourceChange(suggestion.id, event.currentTarget.value),
|
|
887
|
+
"aria-label": `Source term in ${suggestion.sourceLocale || "source language"}`
|
|
888
|
+
}
|
|
889
|
+
) }),
|
|
890
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, "aria-hidden": "true", children: "\u2192" }),
|
|
891
|
+
/* @__PURE__ */ jsx(Box, { flex: 1, children: /* @__PURE__ */ jsx(
|
|
892
|
+
TextInput,
|
|
893
|
+
{
|
|
894
|
+
fontSize: 1,
|
|
895
|
+
placeholder: "Target term",
|
|
896
|
+
value: suggestion.targetValue || "",
|
|
897
|
+
onChange: (event) => handleGlossaryTargetChange(suggestion.id, event.currentTarget.value),
|
|
898
|
+
"aria-label": `Target term in ${suggestion.targetLocale || "target language"}`
|
|
899
|
+
}
|
|
900
|
+
) })
|
|
901
|
+
] })
|
|
902
|
+
] }) : isPrompt ? /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(
|
|
668
903
|
TextArea,
|
|
669
904
|
{
|
|
670
905
|
fontSize: 1,
|
|
@@ -673,7 +908,8 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
673
908
|
onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
|
|
674
909
|
onFocus: () => setEditingId(suggestion.id),
|
|
675
910
|
onBlur: () => setEditingId(null),
|
|
676
|
-
placeholder: "Enter prompt guidance..."
|
|
911
|
+
placeholder: "Enter prompt guidance...",
|
|
912
|
+
"aria-label": `${IMPROVEMENT_TYPE_LABELS[suggestion.type]} content`
|
|
677
913
|
}
|
|
678
914
|
) }) : /* @__PURE__ */ jsx(
|
|
679
915
|
TextInput,
|
|
@@ -681,9 +917,17 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
681
917
|
fontSize: 1,
|
|
682
918
|
value: suggestion.description,
|
|
683
919
|
onChange: (event) => handleDescriptionChange(suggestion.id, event.currentTarget.value),
|
|
684
|
-
placeholder: "Enter protected phrase..."
|
|
920
|
+
placeholder: "Enter protected phrase...",
|
|
921
|
+
"aria-label": "Protected phrase"
|
|
685
922
|
}
|
|
686
|
-
)
|
|
923
|
+
),
|
|
924
|
+
hasWarning && /* @__PURE__ */ jsx(Card, { padding: 3, tone: isInvalid ? "critical" : "caution", border: !0, radius: 2, children: /* @__PURE__ */ jsxs(Flex, { align: "flex-start", gap: 2, children: [
|
|
925
|
+
/* @__PURE__ */ jsx(Text, { size: 1, children: isInvalid ? /* @__PURE__ */ jsx(WarningOutlineIcon, {}) : /* @__PURE__ */ jsx(InfoOutlineIcon, {}) }),
|
|
926
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, flex: 1, children: [
|
|
927
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "medium", children: isInvalid ? "Invalid Suggestion" : "Warning" }),
|
|
928
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: suggestion.warning })
|
|
929
|
+
] })
|
|
930
|
+
] }) })
|
|
687
931
|
] })
|
|
688
932
|
] }) })
|
|
689
933
|
},
|
|
@@ -705,7 +949,7 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
705
949
|
}, GiveFeedbackDialog = ({ setIsDialogOpen }) => {
|
|
706
950
|
const client = useClient({ apiVersion: "vX" }), { document, config, translationMetadata } = useTranslationContext(), { loading } = useTranslationMetadata({
|
|
707
951
|
documentId: document._id
|
|
708
|
-
}), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), handleShouldIncludeEditsChange = () => {
|
|
952
|
+
}), [feedback, setFeedback] = useState(""), [shouldIncludeEdits, setShouldIncludeEdits] = useState(!1), [processingState, setProcessingState] = useState("idle"), [suggestions, setSuggestions] = useState([]), [changeSummary, setChangeSummary] = useState(""), [error, setError] = useState(""), [sourceLanguage, setSourceLanguage] = useState(""), [targetLanguage, setTargetLanguage] = useState(""), handleShouldIncludeEditsChange = () => {
|
|
709
953
|
setShouldIncludeEdits(!shouldIncludeEdits);
|
|
710
954
|
}, handleCancel = () => {
|
|
711
955
|
setIsDialogOpen(!1);
|
|
@@ -727,14 +971,16 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
727
971
|
throw new Error(
|
|
728
972
|
"No translation history found for this document. Please ensure this document was translated using the AI Translation plugin."
|
|
729
973
|
);
|
|
730
|
-
const { sourceSnapshot, targetSnapshot, sourceLanguage } = translationHistory2;
|
|
974
|
+
const { sourceSnapshot, targetSnapshot, sourceLanguage: srcLang } = translationHistory2;
|
|
731
975
|
if (!sourceSnapshot || !targetSnapshot)
|
|
732
976
|
throw new Error("Translation snapshots are missing from history.");
|
|
977
|
+
const srcLanguage = srcLang || "en-us", tgtLanguage = document[config.languageFieldName];
|
|
978
|
+
setSourceLanguage(srcLanguage), setTargetLanguage(tgtLanguage);
|
|
733
979
|
const generatedSuggestions = await analyzeFeedback({
|
|
734
980
|
client,
|
|
735
981
|
schemaId: config.schemaId,
|
|
736
|
-
sourceLanguage:
|
|
737
|
-
targetLanguage:
|
|
982
|
+
sourceLanguage: srcLanguage,
|
|
983
|
+
targetLanguage: tgtLanguage,
|
|
738
984
|
userFeedback: feedback || void 0,
|
|
739
985
|
shouldIncludeEdits,
|
|
740
986
|
sourceSnapshot,
|
|
@@ -788,7 +1034,15 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
788
1034
|
/* @__PURE__ */ jsx(Heading, { children: "Review Suggestions" }),
|
|
789
1035
|
/* @__PURE__ */ jsx(Text, { muted: !0, children: "The AI has analyzed your feedback and generated the following suggestions. Review and edit them before applying." })
|
|
790
1036
|
] }),
|
|
791
|
-
/* @__PURE__ */ jsx(
|
|
1037
|
+
/* @__PURE__ */ jsx(
|
|
1038
|
+
SuggestionReviewList,
|
|
1039
|
+
{
|
|
1040
|
+
suggestions,
|
|
1041
|
+
onSuggestionsChange: setSuggestions,
|
|
1042
|
+
sourceLanguage,
|
|
1043
|
+
targetLanguage
|
|
1044
|
+
}
|
|
1045
|
+
),
|
|
792
1046
|
error && /* @__PURE__ */ jsx(Card, { padding: 3, tone: "critical", border: !0, children: /* @__PURE__ */ jsx(Text, { size: 1, children: error }) }),
|
|
793
1047
|
/* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, paddingTop: 2, children: [
|
|
794
1048
|
/* @__PURE__ */ jsx(Flex, { gap: 2, children: /* @__PURE__ */ jsx(
|
|
@@ -896,6 +1150,33 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
896
1150
|
)
|
|
897
1151
|
] })
|
|
898
1152
|
] });
|
|
1153
|
+
}, validateOpenAIApiKey = async (apiKey, model = "gpt-5") => {
|
|
1154
|
+
try {
|
|
1155
|
+
const response = await fetch("https://api.openai.com/v1/models", {
|
|
1156
|
+
method: "GET",
|
|
1157
|
+
headers: {
|
|
1158
|
+
Authorization: `Bearer ${apiKey}`
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
if (!response.ok)
|
|
1162
|
+
return {
|
|
1163
|
+
valid: !1,
|
|
1164
|
+
error: (await response.json()).error?.message || "Invalid API key"
|
|
1165
|
+
};
|
|
1166
|
+
const availableModels = (await response.json()).data.map((m) => m.id);
|
|
1167
|
+
return availableModels.includes(model) ? {
|
|
1168
|
+
valid: !0,
|
|
1169
|
+
modelAvailable: availableModels
|
|
1170
|
+
} : {
|
|
1171
|
+
valid: !1,
|
|
1172
|
+
error: `Model ${model} not available. Try: ${availableModels.slice(0, 5).join(", ")}`
|
|
1173
|
+
};
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
return {
|
|
1176
|
+
valid: !1,
|
|
1177
|
+
error: error instanceof Error ? error.message : "Network error"
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
899
1180
|
}, translationReducer = (context, action) => {
|
|
900
1181
|
const logContext = {
|
|
901
1182
|
documentId: context.document?._id,
|
|
@@ -915,10 +1196,15 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
915
1196
|
...context,
|
|
916
1197
|
selectedTargetLanguages: action.payload
|
|
917
1198
|
};
|
|
918
|
-
case "
|
|
1199
|
+
case "SET_TRANSLATION_MODE":
|
|
1200
|
+
return {
|
|
1201
|
+
...context,
|
|
1202
|
+
translationMode: action.payload
|
|
1203
|
+
};
|
|
1204
|
+
case "SET_OPENAI_VALIDATED":
|
|
919
1205
|
return {
|
|
920
1206
|
...context,
|
|
921
|
-
|
|
1207
|
+
openaiValidated: action.payload
|
|
922
1208
|
};
|
|
923
1209
|
case "SET_SHOULD_TRANSLATE_REFERENCES":
|
|
924
1210
|
return {
|
|
@@ -963,18 +1249,44 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
963
1249
|
overallStatus: "idle",
|
|
964
1250
|
translationMetadata: null,
|
|
965
1251
|
selectedTargetLanguages: [],
|
|
966
|
-
shouldTranslateDiffs: !1,
|
|
967
1252
|
shouldOverwriteFreshTranslations: !1,
|
|
968
1253
|
shouldTranslateReferences: !1,
|
|
969
1254
|
maxReferenceDepth: 1,
|
|
970
1255
|
references: [],
|
|
971
|
-
translationJobs: /* @__PURE__ */ new Map()
|
|
1256
|
+
translationJobs: /* @__PURE__ */ new Map(),
|
|
1257
|
+
translationMode: config?.openai?.defaultMode || "sanity-only",
|
|
1258
|
+
openaiValidated: !1
|
|
972
1259
|
}, [context, dispatch] = useReducer(translationReducer, initialContext);
|
|
973
|
-
return
|
|
1260
|
+
return useEffect(() => {
|
|
1261
|
+
(async () => {
|
|
1262
|
+
if (config?.openai?.enabled && config?.openai?.apiKey)
|
|
1263
|
+
try {
|
|
1264
|
+
const validation = await validateOpenAIApiKey(config.openai.apiKey, config.openai.model || "gpt-5");
|
|
1265
|
+
dispatch({
|
|
1266
|
+
type: "SET_OPENAI_VALIDATED",
|
|
1267
|
+
payload: validation.valid
|
|
1268
|
+
}), validation.valid ? devLog.info("OpenAI API key validated successfully", {
|
|
1269
|
+
model: config.openai.model || "gpt-5",
|
|
1270
|
+
component: "TranslationContextProvider"
|
|
1271
|
+
}) : devLog.error("OpenAI API key validation failed", {
|
|
1272
|
+
error: validation.error,
|
|
1273
|
+
component: "TranslationContextProvider"
|
|
1274
|
+
});
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
devLog.error("OpenAI validation error", error instanceof Error ? error : new Error(String(error)), {
|
|
1277
|
+
component: "TranslationContextProvider"
|
|
1278
|
+
}), dispatch({
|
|
1279
|
+
type: "SET_OPENAI_VALIDATED",
|
|
1280
|
+
payload: !1
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
})();
|
|
1284
|
+
}, [config?.openai?.apiKey, config?.openai?.enabled, config?.openai?.model]), devLog.debug("Translation context state initialized", {
|
|
974
1285
|
documentId: document._id,
|
|
975
1286
|
overallStatus: context.overallStatus,
|
|
976
1287
|
selectedLanguages: context.selectedTargetLanguages,
|
|
977
|
-
|
|
1288
|
+
translationMode: context.translationMode,
|
|
1289
|
+
openaiEnabled: config?.openai?.enabled,
|
|
978
1290
|
component: "TranslationContextProvider"
|
|
979
1291
|
}), /* @__PURE__ */ jsx(TranslationContext.Provider, { value: { ...context, dispatch }, children });
|
|
980
1292
|
}, getGiveTranslationFeedbackAction = (config) => ({ draft, published }) => {
|
|
@@ -1077,26 +1389,98 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
1077
1389
|
title: "Translation Settings",
|
|
1078
1390
|
icon: TranslateIcon,
|
|
1079
1391
|
type: "document",
|
|
1392
|
+
groups: [
|
|
1393
|
+
{
|
|
1394
|
+
name: "general",
|
|
1395
|
+
title: "General",
|
|
1396
|
+
default: !0
|
|
1397
|
+
},
|
|
1398
|
+
{
|
|
1399
|
+
name: "history",
|
|
1400
|
+
title: "History"
|
|
1401
|
+
}
|
|
1402
|
+
],
|
|
1080
1403
|
fields: [
|
|
1081
1404
|
defineField({
|
|
1082
1405
|
name: "globalPrompt",
|
|
1083
1406
|
title: "Global Translation Prompt",
|
|
1084
1407
|
description: "A prompt that will given to the Sanity AI Agent for all translation tasks.",
|
|
1085
1408
|
type: "text",
|
|
1086
|
-
rows: 5
|
|
1409
|
+
rows: 5,
|
|
1410
|
+
group: "general"
|
|
1087
1411
|
}),
|
|
1088
1412
|
defineField({
|
|
1089
1413
|
name: "localePrompts",
|
|
1090
1414
|
title: "Locale Prompts",
|
|
1091
1415
|
description: "Prompts that will be given to the Sanity AI Agent for each locale.",
|
|
1092
|
-
type: "internationalizedArrayText"
|
|
1416
|
+
type: "internationalizedArrayText",
|
|
1417
|
+
group: "general"
|
|
1093
1418
|
}),
|
|
1094
1419
|
defineField({
|
|
1095
1420
|
name: "protectedPhrases",
|
|
1096
1421
|
title: "Protected Phrases",
|
|
1097
1422
|
description: "Phrases that will never be translated.",
|
|
1098
1423
|
type: "array",
|
|
1099
|
-
of: [{ type: "string" }]
|
|
1424
|
+
of: [{ type: "string" }],
|
|
1425
|
+
group: "general"
|
|
1426
|
+
}),
|
|
1427
|
+
defineField({
|
|
1428
|
+
name: "changeHistory",
|
|
1429
|
+
title: "Change History",
|
|
1430
|
+
description: "History of changes made via user feedback.",
|
|
1431
|
+
type: "array",
|
|
1432
|
+
readOnly: !0,
|
|
1433
|
+
group: "history",
|
|
1434
|
+
of: [
|
|
1435
|
+
{
|
|
1436
|
+
type: "object",
|
|
1437
|
+
fields: [
|
|
1438
|
+
defineField({
|
|
1439
|
+
name: "timestamp",
|
|
1440
|
+
title: "Timestamp",
|
|
1441
|
+
type: "datetime"
|
|
1442
|
+
}),
|
|
1443
|
+
defineField({
|
|
1444
|
+
name: "changeType",
|
|
1445
|
+
title: "Change Type",
|
|
1446
|
+
type: "string",
|
|
1447
|
+
options: {
|
|
1448
|
+
list: [
|
|
1449
|
+
{ title: "Global Prompt", value: "globalPrompt" },
|
|
1450
|
+
{ title: "Locale Prompt", value: "localePrompt" },
|
|
1451
|
+
{ title: "Protected Phrase", value: "protectedPhrase" },
|
|
1452
|
+
{ title: "Glossary Term", value: "glossaryTerm" }
|
|
1453
|
+
]
|
|
1454
|
+
}
|
|
1455
|
+
}),
|
|
1456
|
+
defineField({
|
|
1457
|
+
name: "description",
|
|
1458
|
+
title: "Description",
|
|
1459
|
+
type: "text",
|
|
1460
|
+
rows: 3
|
|
1461
|
+
}),
|
|
1462
|
+
defineField({
|
|
1463
|
+
name: "locale",
|
|
1464
|
+
title: "Locale",
|
|
1465
|
+
type: "string",
|
|
1466
|
+
description: "Target locale for locale-specific changes"
|
|
1467
|
+
})
|
|
1468
|
+
],
|
|
1469
|
+
preview: {
|
|
1470
|
+
select: {
|
|
1471
|
+
timestamp: "timestamp",
|
|
1472
|
+
changeType: "changeType",
|
|
1473
|
+
description: "description"
|
|
1474
|
+
},
|
|
1475
|
+
prepare({ timestamp, changeType, description }) {
|
|
1476
|
+
return {
|
|
1477
|
+
title: `${changeType} - ${new Date(timestamp).toLocaleDateString()}`,
|
|
1478
|
+
subtitle: description
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
]
|
|
1100
1484
|
})
|
|
1101
1485
|
],
|
|
1102
1486
|
preview: {
|
|
@@ -1104,7 +1488,81 @@ const ICON_SIZE = 64, progressIconMap = {
|
|
|
1104
1488
|
title: "Translation Settings"
|
|
1105
1489
|
})
|
|
1106
1490
|
}
|
|
1107
|
-
})
|
|
1491
|
+
});
|
|
1492
|
+
class Semaphore {
|
|
1493
|
+
permits;
|
|
1494
|
+
queue = [];
|
|
1495
|
+
constructor(permits) {
|
|
1496
|
+
this.permits = permits;
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Acquire a permit from the semaphore
|
|
1500
|
+
* If no permits are available, the caller will wait until one becomes available
|
|
1501
|
+
*/
|
|
1502
|
+
async acquire() {
|
|
1503
|
+
return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
|
|
1504
|
+
this.queue.push(resolve);
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Release a permit back to the semaphore
|
|
1509
|
+
* If there are waiting callers, one will be woken up
|
|
1510
|
+
*/
|
|
1511
|
+
release() {
|
|
1512
|
+
const next = this.queue.shift();
|
|
1513
|
+
next ? next() : this.permits++;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Execute a function with semaphore protection
|
|
1517
|
+
* Automatically acquires and releases the permit
|
|
1518
|
+
*/
|
|
1519
|
+
async withPermit(fn) {
|
|
1520
|
+
await this.acquire();
|
|
1521
|
+
try {
|
|
1522
|
+
return await fn();
|
|
1523
|
+
} finally {
|
|
1524
|
+
this.release();
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Get the number of available permits
|
|
1529
|
+
*/
|
|
1530
|
+
availablePermits() {
|
|
1531
|
+
return this.permits;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Get the number of waiting callers
|
|
1535
|
+
*/
|
|
1536
|
+
queueLength() {
|
|
1537
|
+
return this.queue.length;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Update the number of permits
|
|
1541
|
+
* If increasing permits, waiting callers will be notified
|
|
1542
|
+
* If decreasing permits below current available, available permits will be reduced
|
|
1543
|
+
*/
|
|
1544
|
+
updatePermits(newPermits) {
|
|
1545
|
+
const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
|
|
1546
|
+
if (difference > 0) {
|
|
1547
|
+
for (let i = 0; i < difference && this.queue.length > 0; i++) {
|
|
1548
|
+
const next = this.queue.shift();
|
|
1549
|
+
next && next();
|
|
1550
|
+
}
|
|
1551
|
+
const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
|
|
1552
|
+
remainingIncrease > 0 && (this.permits += remainingIncrease);
|
|
1553
|
+
} else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function documentToPlainText(document) {
|
|
1557
|
+
if (!document) return "";
|
|
1558
|
+
const result = {};
|
|
1559
|
+
for (const [key, value] of Object.entries(document))
|
|
1560
|
+
Array.isArray(value) && value.some(
|
|
1561
|
+
(item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
|
|
1562
|
+
) ? result[key] = toPlainText(value) : result[key] = value;
|
|
1563
|
+
return JSON.stringify(result);
|
|
1564
|
+
}
|
|
1565
|
+
const DEFAULTS = {
|
|
1108
1566
|
windowSlack: 2,
|
|
1109
1567
|
minTokenMatchRatio: 1,
|
|
1110
1568
|
stopwords: [
|
|
@@ -1154,7 +1612,7 @@ function freqMap(tokens) {
|
|
|
1154
1612
|
for (const t of tokens) m.set(t, (m.get(t) ?? 0) + 1);
|
|
1155
1613
|
return m;
|
|
1156
1614
|
}
|
|
1157
|
-
function
|
|
1615
|
+
function matchTermInDocument(term, source, options) {
|
|
1158
1616
|
const opts = { ...DEFAULTS, ...options ?? {} }, termTokens = prepareTermTokens(term, opts);
|
|
1159
1617
|
if (termTokens.length === 0)
|
|
1160
1618
|
return { matched: !1, term, reason: "No substantive tokens after filtering." };
|
|
@@ -1197,15 +1655,6 @@ function matchGlossaryTerm(term, source, options) {
|
|
|
1197
1655
|
}
|
|
1198
1656
|
return { matched: !1, term, reason: "No window satisfied the token threshold." };
|
|
1199
1657
|
}
|
|
1200
|
-
function documentToPlainText(document) {
|
|
1201
|
-
if (!document) return "";
|
|
1202
|
-
const result = {};
|
|
1203
|
-
for (const [key, value] of Object.entries(document))
|
|
1204
|
-
Array.isArray(value) && value.some(
|
|
1205
|
-
(item) => typeof item == "object" && item !== null && "_type" in item && item._type === "block"
|
|
1206
|
-
) ? result[key] = toPlainText(value) : result[key] = value;
|
|
1207
|
-
return JSON.stringify(result);
|
|
1208
|
-
}
|
|
1209
1658
|
const fetchTranslationPrompts = async ({
|
|
1210
1659
|
client,
|
|
1211
1660
|
sourceLanguage,
|
|
@@ -1235,12 +1684,15 @@ const fetchTranslationPrompts = async ({
|
|
|
1235
1684
|
return !from || !to ? null : { from, to };
|
|
1236
1685
|
}).filter((item) => item !== null);
|
|
1237
1686
|
sourceDocument && (relevantTranslations = relevantTranslations.filter(
|
|
1238
|
-
({ from }) =>
|
|
1687
|
+
({ from }) => glossaryTermAppearsInDocument(from, sourceDocumentString)
|
|
1239
1688
|
)), glossaryTermsByLocale[targetLanguage] = relevantTranslations;
|
|
1240
1689
|
}
|
|
1241
|
-
|
|
1690
|
+
let filteredProtectedPhrases = translationSettings2?.protectedPhrases;
|
|
1691
|
+
return sourceDocument && filteredProtectedPhrases && (filteredProtectedPhrases = filteredProtectedPhrases.filter(
|
|
1692
|
+
(phrase) => protectedPhraseAppearsInDocument(phrase, sourceDocumentString)
|
|
1693
|
+
)), {
|
|
1242
1694
|
globalPrompt: translationSettings2?.globalPrompt,
|
|
1243
|
-
protectedPhrases:
|
|
1695
|
+
protectedPhrases: filteredProtectedPhrases,
|
|
1244
1696
|
localeInstructions: Object.fromEntries(
|
|
1245
1697
|
targetLanguages.map((locale) => [
|
|
1246
1698
|
locale,
|
|
@@ -1251,9 +1703,14 @@ const fetchTranslationPrompts = async ({
|
|
|
1251
1703
|
])
|
|
1252
1704
|
)
|
|
1253
1705
|
};
|
|
1254
|
-
},
|
|
1706
|
+
}, glossaryTermAppearsInDocument = (term, documentString) => {
|
|
1255
1707
|
const baseTerm = term.split(":")[0].trim(), source = preprocessSource(documentString);
|
|
1256
|
-
return
|
|
1708
|
+
return matchTermInDocument(baseTerm, source, {
|
|
1709
|
+
windowSlack: 10
|
|
1710
|
+
}).matched;
|
|
1711
|
+
}, protectedPhraseAppearsInDocument = (phrase, documentString) => {
|
|
1712
|
+
const source = preprocessSource(documentString);
|
|
1713
|
+
return matchTermInDocument(phrase, source, {
|
|
1257
1714
|
windowSlack: 10
|
|
1258
1715
|
}).matched;
|
|
1259
1716
|
}, formatGlossaryTermInstruction = (terms) => {
|
|
@@ -1268,10 +1725,14 @@ const fetchTranslationPrompts = async ({
|
|
|
1268
1725
|
"- Match the source casing when possible; only adjust when required for correct grammar.",
|
|
1269
1726
|
"- If the source term meaning is absent, do not introduce a translation from this list.",
|
|
1270
1727
|
"- When a term includes a colon, translate the text before the colon for general use; use the text after the colon when the feminine form is required.",
|
|
1271
|
-
"- Do not remove important words or phrases from the source text when they are essential to the meaning."
|
|
1728
|
+
"- Do not remove important words or phrases from the source text when they are essential to the meaning.",
|
|
1729
|
+
"- Never add punctuation or symbols that are not present in the source text.",
|
|
1730
|
+
"- Never apply glossary terms in code examples."
|
|
1272
1731
|
];
|
|
1273
1732
|
return terms.forEach(({ from, to }) => {
|
|
1274
|
-
instructions.push(
|
|
1733
|
+
instructions.push(
|
|
1734
|
+
`When text conveys the same meaning as "${from}", translate it as "${to}" unless the term is used in a code example.`
|
|
1735
|
+
);
|
|
1275
1736
|
}), instructions.join(`
|
|
1276
1737
|
`);
|
|
1277
1738
|
}, findKeyValues = (obj, targetKey) => {
|
|
@@ -1298,12 +1759,19 @@ const fetchTranslationPrompts = async ({
|
|
|
1298
1759
|
try {
|
|
1299
1760
|
const doc = await client.fetch("*[_id == $documentId][0]", { documentId });
|
|
1300
1761
|
if (!doc) {
|
|
1301
|
-
|
|
1762
|
+
devLog.error("Document not found", new Error("Document not found"), {
|
|
1763
|
+
documentId,
|
|
1764
|
+
component: "localizeReferences"
|
|
1765
|
+
});
|
|
1302
1766
|
return;
|
|
1303
1767
|
}
|
|
1304
1768
|
const targetLanguage = doc[languageFieldName];
|
|
1305
1769
|
if (!targetLanguage) {
|
|
1306
|
-
|
|
1770
|
+
devLog.error("No value found for language field", new Error("Missing language field"), {
|
|
1771
|
+
documentId,
|
|
1772
|
+
languageFieldName,
|
|
1773
|
+
component: "localizeReferences"
|
|
1774
|
+
});
|
|
1307
1775
|
return;
|
|
1308
1776
|
}
|
|
1309
1777
|
const refs = findKeyValues(doc, "_ref");
|
|
@@ -1311,94 +1779,46 @@ const fetchTranslationPrompts = async ({
|
|
|
1311
1779
|
return;
|
|
1312
1780
|
await updateReferences(documentId, client, refs, targetLanguage);
|
|
1313
1781
|
} catch (error) {
|
|
1314
|
-
|
|
1782
|
+
devLog.error("Error localizing references", error instanceof Error ? error : new Error(String(error)), {
|
|
1783
|
+
documentId,
|
|
1784
|
+
component: "localizeReferences"
|
|
1785
|
+
});
|
|
1315
1786
|
}
|
|
1316
1787
|
}, updateReferences = async (documentId, client, refs, targetLanguage) => {
|
|
1317
|
-
const patch = client.patch(documentId);
|
|
1318
1788
|
if (!refs?.length)
|
|
1319
1789
|
return [];
|
|
1790
|
+
const refIds = refs.map((r) => undraftId(r.value)), translations = await client.fetch(
|
|
1791
|
+
`*[_type == $translationMetadataSchemaType && count(translations[value._ref in $refIds]) > 0]
|
|
1792
|
+
.translations[value._ref in $refIds]{
|
|
1793
|
+
"sourceId": value._ref,
|
|
1794
|
+
"targetId": ^.translations[_key == $targetLanguage][0].value._ref
|
|
1795
|
+
}[defined(targetId)]`,
|
|
1796
|
+
{
|
|
1797
|
+
translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
|
|
1798
|
+
refIds,
|
|
1799
|
+
targetLanguage
|
|
1800
|
+
}
|
|
1801
|
+
), translationMap = /* @__PURE__ */ new Map();
|
|
1802
|
+
for (const t of translations)
|
|
1803
|
+
t.sourceId && t.targetId && translationMap.set(t.sourceId, t.targetId);
|
|
1804
|
+
const patch = client.patch(documentId);
|
|
1805
|
+
let hasChanges = !1;
|
|
1320
1806
|
for (const ref of refs) {
|
|
1321
|
-
const newId =
|
|
1322
|
-
"(*[_type == $translationMetadataSchemaType && references($sourceId)].translations[_key == $targetLanguage].value._ref)[0]",
|
|
1323
|
-
{
|
|
1324
|
-
translationMetadataSchemaType: TRANSLATION_METADATA_TYPE,
|
|
1325
|
-
sourceId: undraftId(ref.value),
|
|
1326
|
-
targetLanguage
|
|
1327
|
-
}
|
|
1328
|
-
);
|
|
1807
|
+
const sourceId = undraftId(ref.value), newId = translationMap.get(sourceId);
|
|
1329
1808
|
newId && (patch.set({ [pathToString(ref.path)]: undraftId(newId) }), patch.set({ [`${pathToString(ref.path.slice(0, -1))}._weak`]: !0 }), patch.set({
|
|
1330
1809
|
[`${pathToString(ref.path.slice(0, -1))}._strengthenOnPublish`]: {
|
|
1331
1810
|
_type: "reference",
|
|
1332
1811
|
_weak: !1
|
|
1333
1812
|
}
|
|
1334
|
-
}),
|
|
1335
|
-
}
|
|
1336
|
-
};
|
|
1337
|
-
class Semaphore {
|
|
1338
|
-
permits;
|
|
1339
|
-
queue = [];
|
|
1340
|
-
constructor(permits) {
|
|
1341
|
-
this.permits = permits;
|
|
1342
|
-
}
|
|
1343
|
-
/**
|
|
1344
|
-
* Acquire a permit from the semaphore
|
|
1345
|
-
* If no permits are available, the caller will wait until one becomes available
|
|
1346
|
-
*/
|
|
1347
|
-
async acquire() {
|
|
1348
|
-
return this.permits > 0 ? (this.permits--, Promise.resolve()) : new Promise((resolve) => {
|
|
1349
|
-
this.queue.push(resolve);
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
/**
|
|
1353
|
-
* Release a permit back to the semaphore
|
|
1354
|
-
* If there are waiting callers, one will be woken up
|
|
1355
|
-
*/
|
|
1356
|
-
release() {
|
|
1357
|
-
const next = this.queue.shift();
|
|
1358
|
-
next ? next() : this.permits++;
|
|
1359
|
-
}
|
|
1360
|
-
/**
|
|
1361
|
-
* Execute a function with semaphore protection
|
|
1362
|
-
* Automatically acquires and releases the permit
|
|
1363
|
-
*/
|
|
1364
|
-
async withPermit(fn) {
|
|
1365
|
-
await this.acquire();
|
|
1366
|
-
try {
|
|
1367
|
-
return await fn();
|
|
1368
|
-
} finally {
|
|
1369
|
-
this.release();
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
|
-
* Get the number of available permits
|
|
1374
|
-
*/
|
|
1375
|
-
availablePermits() {
|
|
1376
|
-
return this.permits;
|
|
1377
|
-
}
|
|
1378
|
-
/**
|
|
1379
|
-
* Get the number of waiting callers
|
|
1380
|
-
*/
|
|
1381
|
-
queueLength() {
|
|
1382
|
-
return this.queue.length;
|
|
1383
|
-
}
|
|
1384
|
-
/**
|
|
1385
|
-
* Update the number of permits
|
|
1386
|
-
* If increasing permits, waiting callers will be notified
|
|
1387
|
-
* If decreasing permits below current available, available permits will be reduced
|
|
1388
|
-
*/
|
|
1389
|
-
updatePermits(newPermits) {
|
|
1390
|
-
const currentTotal = this.permits + this.queue.length, difference = newPermits - currentTotal;
|
|
1391
|
-
if (difference > 0) {
|
|
1392
|
-
for (let i = 0; i < difference && this.queue.length > 0; i++) {
|
|
1393
|
-
const next = this.queue.shift();
|
|
1394
|
-
next && next();
|
|
1395
|
-
}
|
|
1396
|
-
const remainingIncrease = difference - (currentTotal - this.permits - this.queue.length);
|
|
1397
|
-
remainingIncrease > 0 && (this.permits += remainingIncrease);
|
|
1398
|
-
} else difference < 0 && (this.permits = Math.max(0, this.permits + difference));
|
|
1813
|
+
}), hasChanges = !0);
|
|
1399
1814
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1815
|
+
hasChanges && (await patch.commit(), devLog.info("Updated references in batch", {
|
|
1816
|
+
documentId,
|
|
1817
|
+
count: refs.length,
|
|
1818
|
+
translatedCount: translationMap.size,
|
|
1819
|
+
component: "updateReferences"
|
|
1820
|
+
}));
|
|
1821
|
+
}, setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
|
|
1402
1822
|
for (const sourceDocument of sourceDocuments)
|
|
1403
1823
|
for (const lang of targetLanguages) {
|
|
1404
1824
|
const payload = {
|
|
@@ -1413,21 +1833,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1413
1833
|
payload
|
|
1414
1834
|
});
|
|
1415
1835
|
}
|
|
1416
|
-
}, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
|
|
1417
|
-
(translation) => translation._key === targetLanguage
|
|
1418
|
-
)?.value._ref || null, determineTranslationTarget = ({
|
|
1419
|
-
translationMetadata,
|
|
1420
|
-
targetLanguage
|
|
1421
|
-
}) => {
|
|
1422
|
-
const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
|
|
1423
|
-
return existingTranslationId ? {
|
|
1424
|
-
// Update existing translation
|
|
1425
|
-
operation: "createOrReplace",
|
|
1426
|
-
_id: existingTranslationId
|
|
1427
|
-
} : {
|
|
1428
|
-
// Create new translation
|
|
1429
|
-
operation: "create"
|
|
1430
|
-
};
|
|
1431
1836
|
}, getTranslationMetadata = async ({
|
|
1432
1837
|
client,
|
|
1433
1838
|
sourceDocument,
|
|
@@ -1462,6 +1867,52 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1462
1867
|
} catch (error) {
|
|
1463
1868
|
throw console.error(sourceDocument._id, "Error creating translation metadata:", error), error;
|
|
1464
1869
|
}
|
|
1870
|
+
}, updateTranslationMetadata = async ({
|
|
1871
|
+
client,
|
|
1872
|
+
targetLanguage,
|
|
1873
|
+
sourceDocument,
|
|
1874
|
+
sourceLanguage,
|
|
1875
|
+
newDocument,
|
|
1876
|
+
translationMetadata,
|
|
1877
|
+
isNewTranslation
|
|
1878
|
+
}) => {
|
|
1879
|
+
try {
|
|
1880
|
+
const refToNewTranslation = {
|
|
1881
|
+
_key: targetLanguage,
|
|
1882
|
+
_type: "internationalizedArrayReferenceValue",
|
|
1883
|
+
value: {
|
|
1884
|
+
_type: "reference",
|
|
1885
|
+
_ref: undraftId(newDocument._id),
|
|
1886
|
+
_weak: !0,
|
|
1887
|
+
_strengthenOnPublish: {
|
|
1888
|
+
_type: "reference",
|
|
1889
|
+
_weak: !1
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}, newTranslationHistory = {
|
|
1893
|
+
_key: targetLanguage,
|
|
1894
|
+
language: targetLanguage,
|
|
1895
|
+
sourceId: undraftId(sourceDocument._id),
|
|
1896
|
+
sourceRev: sourceDocument._rev,
|
|
1897
|
+
sourceLanguage,
|
|
1898
|
+
sourceSnapshot: JSON.stringify(sourceDocument),
|
|
1899
|
+
targetId: newDocument._id,
|
|
1900
|
+
targetRev: newDocument._rev,
|
|
1901
|
+
targetSnapshot: JSON.stringify(newDocument)
|
|
1902
|
+
}, translationPatch = client.patch(translationMetadata._id).setIfMissing({ translations: [] });
|
|
1903
|
+
isNewTranslation ? await translationPatch.insert("after", "translations[-1]", [refToNewTranslation]).commit() : await translationPatch.insert("replace", `translations[_key == "${targetLanguage}"]`, [refToNewTranslation]).commit();
|
|
1904
|
+
const historyPatch = client.patch(translationMetadata._id).setIfMissing({ translationHistory: [] });
|
|
1905
|
+
translationMetadata.translationHistory?.some(
|
|
1906
|
+
(history) => history.language === targetLanguage
|
|
1907
|
+
) ? await historyPatch.insert("replace", `translationHistory[_key == "${targetLanguage}"]`, [newTranslationHistory]).commit() : await historyPatch.insert("after", "translationHistory[-1]", [newTranslationHistory]).commit();
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
throw devLog.error("Error updating translation metadata", error instanceof Error ? error : new Error(String(error)), {
|
|
1910
|
+
sourceDocumentId: sourceDocument._id,
|
|
1911
|
+
targetLanguage,
|
|
1912
|
+
newDocumentId: newDocument._id,
|
|
1913
|
+
component: "updateTranslationMetadata"
|
|
1914
|
+
}), error;
|
|
1915
|
+
}
|
|
1465
1916
|
}, setJobStatus = (dispatch, _id, language, status, payload) => {
|
|
1466
1917
|
dispatch({
|
|
1467
1918
|
type: "SET_TRANSLATION_JOB_STATUS",
|
|
@@ -1471,45 +1922,44 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1471
1922
|
status
|
|
1472
1923
|
})
|
|
1473
1924
|
});
|
|
1474
|
-
},
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
languageFieldName,
|
|
1480
|
-
targetLanguage,
|
|
1481
|
-
targetId,
|
|
1482
|
-
patch
|
|
1925
|
+
}, getExistingTranslationId = (translationMetadata, targetLanguage) => translationMetadata?.translations?.length && translationMetadata.translations.find(
|
|
1926
|
+
(translation) => translation._key === targetLanguage
|
|
1927
|
+
)?.value._ref || null, determineTranslationTarget = ({
|
|
1928
|
+
translationMetadata,
|
|
1929
|
+
targetLanguage
|
|
1483
1930
|
}) => {
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1931
|
+
const existingTranslationId = getExistingTranslationId(translationMetadata, targetLanguage);
|
|
1932
|
+
return existingTranslationId ? {
|
|
1933
|
+
// Update existing translation
|
|
1934
|
+
operation: "createOrReplace",
|
|
1935
|
+
_id: existingTranslationId
|
|
1936
|
+
} : {
|
|
1937
|
+
// Create new translation
|
|
1938
|
+
operation: "create"
|
|
1939
|
+
};
|
|
1940
|
+
}, analyzeDocumentContent = (document) => {
|
|
1941
|
+
let hasCodeBlocks = !1, hasInlineCode = !1, codeBlockCount = 0, inlineCodeCount = 0;
|
|
1942
|
+
const hasMarkdown = !1;
|
|
1943
|
+
let hasPortableText = !1, estimatedFieldCount = 0;
|
|
1944
|
+
const traverse = (value, depth = 0) => {
|
|
1945
|
+
value != null && (depth > 0 && typeof value == "object" && estimatedFieldCount++, Array.isArray(value) ? value.forEach((item) => {
|
|
1946
|
+
item && typeof item == "object" && "_type" in item && item._type === "code" && (hasCodeBlocks = !0, codeBlockCount++, hasPortableText = !0), item && typeof item == "object" && "_type" in item && item._type === "block" && "children" in item && Array.isArray(item.children) && (hasPortableText = !0, item.children.forEach((child) => {
|
|
1947
|
+
child && typeof child == "object" && "marks" in child && Array.isArray(child.marks) && child.marks.includes("code") && (hasInlineCode = !0, inlineCodeCount++);
|
|
1948
|
+
})), traverse(item, depth + 1);
|
|
1949
|
+
}) : typeof value == "object" && Object.values(value).forEach((val) => traverse(val, depth + 1)));
|
|
1950
|
+
};
|
|
1951
|
+
traverse(document);
|
|
1952
|
+
let complexity = "simple";
|
|
1953
|
+
return estimatedFieldCount > 50 ? complexity = "complex" : estimatedFieldCount > 20 && (complexity = "moderate"), {
|
|
1954
|
+
hasCodeBlocks,
|
|
1955
|
+
hasInlineCode,
|
|
1956
|
+
codeBlockCount,
|
|
1957
|
+
inlineCodeCount,
|
|
1958
|
+
hasMarkdown,
|
|
1959
|
+
hasPortableText,
|
|
1960
|
+
complexity,
|
|
1961
|
+
estimatedFieldCount
|
|
1962
|
+
};
|
|
1513
1963
|
}, assembleLanguageSpecs = ({
|
|
1514
1964
|
sourceDocument,
|
|
1515
1965
|
languageFieldName,
|
|
@@ -1528,6 +1978,485 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1528
1978
|
title: targetLanguage
|
|
1529
1979
|
}
|
|
1530
1980
|
};
|
|
1981
|
+
}, callGPT5 = async (apiKey, request) => {
|
|
1982
|
+
const model = request.model || "gpt-5", isGPT5 = model === "gpt-5", useStreaming = !!request.onReasoningChunk, requestBody = {
|
|
1983
|
+
model,
|
|
1984
|
+
messages: request.messages,
|
|
1985
|
+
stream: useStreaming
|
|
1986
|
+
};
|
|
1987
|
+
isGPT5 || (requestBody.temperature = request.temperature ?? 0.3), isGPT5 && (requestBody.reasoning_effort = request.reasoningEffort || "medium", requestBody.verbosity = request.verbosity || "medium");
|
|
1988
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1989
|
+
method: "POST",
|
|
1990
|
+
headers: {
|
|
1991
|
+
"Content-Type": "application/json",
|
|
1992
|
+
Authorization: `Bearer ${apiKey}`
|
|
1993
|
+
},
|
|
1994
|
+
body: JSON.stringify(requestBody)
|
|
1995
|
+
});
|
|
1996
|
+
if (!response.ok) {
|
|
1997
|
+
const error = await response.json();
|
|
1998
|
+
throw new Error(`GPT-5 API error: ${error.error?.message || "Unknown error"}`);
|
|
1999
|
+
}
|
|
2000
|
+
if (useStreaming && response.body && request.onReasoningChunk)
|
|
2001
|
+
return await handleStreamingResponse(response, request.onReasoningChunk, model);
|
|
2002
|
+
const result = await response.json(), usage = result.usage || {}, tokensUsed = {
|
|
2003
|
+
prompt: usage.prompt_tokens || 0,
|
|
2004
|
+
completion: usage.completion_tokens || 0,
|
|
2005
|
+
reasoning: usage.reasoning_tokens || 0,
|
|
2006
|
+
// New in GPT-5
|
|
2007
|
+
total: usage.total_tokens || 0
|
|
2008
|
+
}, cost = calculateGPT5Cost(tokensUsed, request.model || "gpt-5"), messageContent = result.choices[0].message.content;
|
|
2009
|
+
let reasoningSummary, actualContent = messageContent;
|
|
2010
|
+
const reasoningMatch = messageContent.match(/^<reasoning>([\s\S]*?)<\/reasoning>\n\n([\s\S]*)$/i);
|
|
2011
|
+
return reasoningMatch && (reasoningSummary = reasoningMatch[1].trim(), actualContent = reasoningMatch[2]), {
|
|
2012
|
+
content: actualContent,
|
|
2013
|
+
tokensUsed,
|
|
2014
|
+
cost,
|
|
2015
|
+
reasoningSummary
|
|
2016
|
+
};
|
|
2017
|
+
}, handleStreamingResponse = async (response, onReasoningChunk, model) => {
|
|
2018
|
+
if (!response.body)
|
|
2019
|
+
throw new Error("Response body is null");
|
|
2020
|
+
const reader = response.body.getReader(), decoder = new TextDecoder();
|
|
2021
|
+
let fullContent = "", inReasoning = !1, reasoningBuffer = "", contentBuffer = "", tokensUsed = { prompt: 0, completion: 0, reasoning: 0, total: 0 };
|
|
2022
|
+
try {
|
|
2023
|
+
for (; ; ) {
|
|
2024
|
+
const { done, value } = await reader.read();
|
|
2025
|
+
if (done) break;
|
|
2026
|
+
const lines = decoder.decode(value, { stream: !0 }).split(`
|
|
2027
|
+
`).filter((line) => line.trim() !== "");
|
|
2028
|
+
for (const line of lines)
|
|
2029
|
+
if (line.startsWith("data: ")) {
|
|
2030
|
+
const data = line.slice(6);
|
|
2031
|
+
if (data === "[DONE]") continue;
|
|
2032
|
+
try {
|
|
2033
|
+
const parsed = JSON.parse(data);
|
|
2034
|
+
parsed.usage && (tokensUsed = {
|
|
2035
|
+
prompt: parsed.usage.prompt_tokens || 0,
|
|
2036
|
+
completion: parsed.usage.completion_tokens || 0,
|
|
2037
|
+
reasoning: parsed.usage.reasoning_tokens || 0,
|
|
2038
|
+
total: parsed.usage.total_tokens || 0
|
|
2039
|
+
});
|
|
2040
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
2041
|
+
if (delta)
|
|
2042
|
+
if (fullContent += delta, delta.includes("<reasoning>")) {
|
|
2043
|
+
inReasoning = !0;
|
|
2044
|
+
const parts = delta.split("<reasoning>");
|
|
2045
|
+
reasoningBuffer += parts[1] || "";
|
|
2046
|
+
} else if (delta.includes("</reasoning>")) {
|
|
2047
|
+
inReasoning = !1;
|
|
2048
|
+
const parts = delta.split("</reasoning>");
|
|
2049
|
+
reasoningBuffer += parts[0] || "", contentBuffer += parts[1] || "";
|
|
2050
|
+
} else inReasoning ? (reasoningBuffer += delta, onReasoningChunk(reasoningBuffer)) : contentBuffer += delta;
|
|
2051
|
+
} catch {
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
} finally {
|
|
2056
|
+
reader.releaseLock();
|
|
2057
|
+
}
|
|
2058
|
+
const cost = calculateGPT5Cost(tokensUsed, model);
|
|
2059
|
+
return {
|
|
2060
|
+
content: contentBuffer || fullContent,
|
|
2061
|
+
tokensUsed,
|
|
2062
|
+
cost,
|
|
2063
|
+
reasoningSummary: reasoningBuffer || void 0
|
|
2064
|
+
};
|
|
2065
|
+
}, calculateGPT5Cost = (tokens, model) => {
|
|
2066
|
+
const pricing = {
|
|
2067
|
+
"gpt-5": {
|
|
2068
|
+
input: 2e-6,
|
|
2069
|
+
// $0.002 per 1k input tokens
|
|
2070
|
+
output: 1e-5,
|
|
2071
|
+
// $0.010 per 1k output tokens
|
|
2072
|
+
reasoning: 5e-6
|
|
2073
|
+
// $0.005 per 1k reasoning tokens
|
|
2074
|
+
},
|
|
2075
|
+
"gpt-4o": {
|
|
2076
|
+
input: 5e-6,
|
|
2077
|
+
output: 14999999999999999e-21,
|
|
2078
|
+
reasoning: 0
|
|
2079
|
+
// No reasoning tokens in GPT-4
|
|
2080
|
+
},
|
|
2081
|
+
"gpt-4-turbo": {
|
|
2082
|
+
input: 1e-5,
|
|
2083
|
+
output: 29999999999999997e-21,
|
|
2084
|
+
reasoning: 0
|
|
2085
|
+
},
|
|
2086
|
+
"gpt-4": {
|
|
2087
|
+
input: 29999999999999997e-21,
|
|
2088
|
+
output: 59999999999999995e-21,
|
|
2089
|
+
reasoning: 0
|
|
2090
|
+
}
|
|
2091
|
+
}, modelPricing = pricing[model] || pricing["gpt-5"];
|
|
2092
|
+
return tokens.prompt * modelPricing.input + tokens.completion * modelPricing.output + tokens.reasoning * modelPricing.reasoning;
|
|
2093
|
+
}, deserializeDocument = (originalDoc, reviewedText) => {
|
|
2094
|
+
const parsed = parseReviewFormat(reviewedText), result = cloneDeep(originalDoc);
|
|
2095
|
+
return applyTextChanges(result, parsed), result;
|
|
2096
|
+
}, parseReviewFormat = (text) => {
|
|
2097
|
+
const root = [], stack = [], lines = text.split(`
|
|
2098
|
+
`);
|
|
2099
|
+
for (const line of lines) {
|
|
2100
|
+
const trimmed = line.trim();
|
|
2101
|
+
if (!trimmed) continue;
|
|
2102
|
+
const openMatch = trimmed.match(/^<(field|block|span)([^>]*)>(.*)$/);
|
|
2103
|
+
if (openMatch) {
|
|
2104
|
+
const [, tagName, attrsStr, content] = openMatch, attributes = parseAttributes(attrsStr), node = {
|
|
2105
|
+
type: tagName,
|
|
2106
|
+
name: attributes.name,
|
|
2107
|
+
text: content ? unescapeXml(content) : void 0,
|
|
2108
|
+
attributes,
|
|
2109
|
+
children: []
|
|
2110
|
+
}, closingTag = `</${tagName}>`;
|
|
2111
|
+
if (content.endsWith(closingTag.replace(/[<>]/g, ""))) {
|
|
2112
|
+
const textContent = content.substring(0, content.lastIndexOf("<"));
|
|
2113
|
+
if (node.text = unescapeXml(textContent), stack.length > 0) {
|
|
2114
|
+
const parent = stack[stack.length - 1];
|
|
2115
|
+
parent && parent.children.push(node);
|
|
2116
|
+
} else
|
|
2117
|
+
root.push(node);
|
|
2118
|
+
} else if (content.includes(closingTag))
|
|
2119
|
+
if (node.text = unescapeXml(content.replace(closingTag, "")), stack.length > 0) {
|
|
2120
|
+
const parent = stack[stack.length - 1];
|
|
2121
|
+
parent && parent.children.push(node);
|
|
2122
|
+
} else
|
|
2123
|
+
root.push(node);
|
|
2124
|
+
else
|
|
2125
|
+
stack.push(node);
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
if (trimmed.match(/^<\/(field|block|span)>$/) && stack.length > 0) {
|
|
2129
|
+
const node = stack.pop();
|
|
2130
|
+
if (node)
|
|
2131
|
+
if (stack.length > 0) {
|
|
2132
|
+
const parent = stack[stack.length - 1];
|
|
2133
|
+
parent && parent.children.push(node);
|
|
2134
|
+
} else
|
|
2135
|
+
root.push(node);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
return root;
|
|
2139
|
+
}, parseAttributes = (attrStr) => {
|
|
2140
|
+
const attrs = {}, attrRegex = /(\w+)="([^"]*)"/g;
|
|
2141
|
+
let match = attrRegex.exec(attrStr);
|
|
2142
|
+
for (; match !== null; )
|
|
2143
|
+
attrs[match[1]] = unescapeXml(match[2]), match = attrRegex.exec(attrStr);
|
|
2144
|
+
return attrs;
|
|
2145
|
+
}, unescapeXml = (str) => str.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&"), applyTextChanges = (doc, parsedNodes) => {
|
|
2146
|
+
for (const node of parsedNodes)
|
|
2147
|
+
if (node.type === "field" && node.name) {
|
|
2148
|
+
const path = [node.name];
|
|
2149
|
+
applyFieldChanges(doc, path, node);
|
|
2150
|
+
}
|
|
2151
|
+
}, applyFieldChanges = (doc, path, node) => {
|
|
2152
|
+
if (node.attributes.type === "array") {
|
|
2153
|
+
node.children.forEach((child, idx) => {
|
|
2154
|
+
applyFieldChanges(doc, [...path, idx], child);
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
if (node.attributes.type === "object") {
|
|
2159
|
+
for (const child of node.children)
|
|
2160
|
+
child.name && applyFieldChanges(doc, [...path, child.name], child);
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
if (node.children.some((c) => c.type === "block")) {
|
|
2164
|
+
const fieldValue = get(doc, path);
|
|
2165
|
+
Array.isArray(fieldValue) && node.children.forEach((blockNode, idx) => {
|
|
2166
|
+
blockNode.type === "block" && applyBlockChanges(fieldValue[idx], blockNode);
|
|
2167
|
+
});
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
node.text !== void 0 && !node.attributes.readonly && set(doc, path, node.text);
|
|
2171
|
+
}, applyBlockChanges = (block, blockNode) => {
|
|
2172
|
+
if (blockNode.attributes._key && block._key !== blockNode.attributes._key) {
|
|
2173
|
+
devLog.warn("Block key mismatch, skipping", {
|
|
2174
|
+
expected: block._key,
|
|
2175
|
+
found: blockNode.attributes._key,
|
|
2176
|
+
component: "deserializeDocument"
|
|
2177
|
+
});
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
const children = block.children;
|
|
2181
|
+
Array.isArray(children) && blockNode.children.some((c) => c.type === "span") ? blockNode.children.forEach((spanNode, idx) => {
|
|
2182
|
+
const childItem = children[idx];
|
|
2183
|
+
spanNode.type === "span" && childItem && applySpanChanges(childItem, spanNode);
|
|
2184
|
+
}) : blockNode.children.some((c) => c.type === "field") && blockNode.children.forEach((fieldNode) => {
|
|
2185
|
+
fieldNode.type === "field" && fieldNode.name && applyBlockFieldChanges(block, fieldNode.name, fieldNode);
|
|
2186
|
+
});
|
|
2187
|
+
}, applyBlockFieldChanges = (block, fieldName, fieldNode) => {
|
|
2188
|
+
const currentValue = block[fieldName];
|
|
2189
|
+
if (fieldNode.attributes.type === "array") {
|
|
2190
|
+
Array.isArray(currentValue) && fieldNode.children.forEach((childNode, idx) => {
|
|
2191
|
+
childNode.type === "block" && currentValue[idx] ? applyBlockChanges(currentValue[idx], childNode) : childNode.type === "field" && applyBlockFieldChanges(currentValue[idx], childNode.name || String(idx), childNode);
|
|
2192
|
+
});
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
if (fieldNode.attributes.type === "object") {
|
|
2196
|
+
currentValue && typeof currentValue == "object" && fieldNode.children.forEach((childNode) => {
|
|
2197
|
+
childNode.name && applyBlockFieldChanges(currentValue, childNode.name, childNode);
|
|
2198
|
+
});
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
fieldNode.text !== void 0 && !fieldNode.attributes.readonly && (block[fieldName] = fieldNode.text);
|
|
2202
|
+
}, applySpanChanges = (span, spanNode) => {
|
|
2203
|
+
if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
|
|
2204
|
+
devLog.warn("Span key mismatch, skipping", {
|
|
2205
|
+
expected: span._key,
|
|
2206
|
+
found: spanNode.attributes._key,
|
|
2207
|
+
component: "deserializeDocument"
|
|
2208
|
+
});
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
spanNode.text !== void 0 && (span.text = spanNode.text);
|
|
2212
|
+
}, serializeDocument = (doc) => {
|
|
2213
|
+
const lines = [], serialize = (value, path = [], indent = 0) => {
|
|
2214
|
+
const indentStr = " ".repeat(indent);
|
|
2215
|
+
if (path.length === 0) {
|
|
2216
|
+
if (isPlainObject(value))
|
|
2217
|
+
for (const [key, val] of Object.entries(value))
|
|
2218
|
+
!key.startsWith("_") && key !== "language" && serialize(val, [key], indent);
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
const fieldName = path[path.length - 1];
|
|
2222
|
+
if (Array.isArray(value)) {
|
|
2223
|
+
value.some((item) => item?._type === "block") ? (lines.push(`${indentStr}<field name="${fieldName}">`), value.forEach((block, idx) => {
|
|
2224
|
+
serializeBlock(block, [...path, idx], indent + 1, lines);
|
|
2225
|
+
}), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
|
|
2226
|
+
serialize(item, [...path, idx], indent + 1);
|
|
2227
|
+
}), lines.push(`${indentStr}</field>`));
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (isPlainObject(value)) {
|
|
2231
|
+
lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
|
|
2232
|
+
for (const [key, val] of Object.entries(value))
|
|
2233
|
+
key.startsWith("_") || serialize(val, [...path, key], indent + 1);
|
|
2234
|
+
lines.push(`${indentStr}</field>`);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (typeof value == "string") {
|
|
2238
|
+
value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
(typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
|
|
2242
|
+
};
|
|
2243
|
+
return serialize(doc), lines.join(`
|
|
2244
|
+
`);
|
|
2245
|
+
}, serializeBlock = (block, path, indent, lines) => {
|
|
2246
|
+
const indentStr = " ".repeat(indent), metadata = {};
|
|
2247
|
+
block._type && (metadata._type = block._type), block._key && (metadata._key = block._key), block.style && (metadata.style = block.style), block.listItem && (metadata.listItem = block.listItem), block.level && (metadata.level = block.level), block.markDefs && (metadata.markDefs = block.markDefs);
|
|
2248
|
+
const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(typeof v == "string" ? v : JSON.stringify(v))}"`).join(" ");
|
|
2249
|
+
if (lines.push(`${indentStr}<block ${metaStr}>`), Array.isArray(block.children) && block._type === "block")
|
|
2250
|
+
block.children.forEach((child, idx) => {
|
|
2251
|
+
serializeSpan(child, [...path, "children", idx], indent + 1, lines);
|
|
2252
|
+
});
|
|
2253
|
+
else
|
|
2254
|
+
for (const [key, value] of Object.entries(block))
|
|
2255
|
+
key.startsWith("_") || key === "style" || key === "listItem" || key === "level" || key === "markDefs" || serializeBlockField(value, key, [...path, key], indent + 1, lines);
|
|
2256
|
+
lines.push(`${indentStr}</block>`);
|
|
2257
|
+
}, serializeBlockField = (value, fieldName, path, indent, lines) => {
|
|
2258
|
+
const indentStr = " ".repeat(indent);
|
|
2259
|
+
if (Array.isArray(value)) {
|
|
2260
|
+
value.some((item) => item?._type) ? (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
|
|
2261
|
+
item?._type === "block" && item?.children ? serializeBlock(item, [...path, idx], indent + 1, lines) : item?._type ? serializeBlock(item, [...path, idx], indent + 1, lines) : serializeBlockField(item, String(idx), [...path, idx], indent + 1, lines);
|
|
2262
|
+
}), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
|
|
2263
|
+
serializeBlockField(item, String(idx), [...path, idx], indent + 1, lines);
|
|
2264
|
+
}), lines.push(`${indentStr}</field>`));
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
if (isPlainObject(value)) {
|
|
2268
|
+
lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
|
|
2269
|
+
for (const [key, val] of Object.entries(value))
|
|
2270
|
+
key.startsWith("_") || serializeBlockField(val, key, [...path, key], indent + 1, lines);
|
|
2271
|
+
lines.push(`${indentStr}</field>`);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (typeof value == "string" && value.length > 0) {
|
|
2275
|
+
lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
(typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
|
|
2279
|
+
}, serializeSpan = (span, _path, indent, lines) => {
|
|
2280
|
+
const indentStr = " ".repeat(indent);
|
|
2281
|
+
if (!span || typeof span != "object") return;
|
|
2282
|
+
const metadata = {};
|
|
2283
|
+
span._type && (metadata._type = span._type), span._key && (metadata._key = span._key), span.marks && Array.isArray(span.marks) && (metadata.marks = span.marks.join(","));
|
|
2284
|
+
const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(String(v))}"`).join(" "), text = typeof span.text == "string" ? span.text : "";
|
|
2285
|
+
metaStr ? lines.push(`${indentStr}<span ${metaStr}>${escapeXml(text)}</span>`) : lines.push(`${indentStr}<span>${escapeXml(text)}</span>`);
|
|
2286
|
+
}, escapeXml = (str) => str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"), isPlainObject = (val) => typeof val == "object" && val !== null && !Array.isArray(val), validateStructureIntegrity = (original, modified) => {
|
|
2287
|
+
const errors = [], criticalFields = ["_id", "_type", "_rev", "_createdAt", "_updatedAt"];
|
|
2288
|
+
for (const field of criticalFields)
|
|
2289
|
+
original[field] !== modified[field] && errors.push({
|
|
2290
|
+
path: [field],
|
|
2291
|
+
expected: original[field],
|
|
2292
|
+
actual: modified[field],
|
|
2293
|
+
type: "metadata_changed"
|
|
2294
|
+
});
|
|
2295
|
+
return validateStructureRecursive(original, modified, [], errors), {
|
|
2296
|
+
valid: errors.length === 0,
|
|
2297
|
+
errors
|
|
2298
|
+
};
|
|
2299
|
+
}, validateStructureRecursive = (original, modified, path, errors) => {
|
|
2300
|
+
if (typeof original != typeof modified) {
|
|
2301
|
+
errors.push({
|
|
2302
|
+
path,
|
|
2303
|
+
expected: typeof original,
|
|
2304
|
+
actual: typeof modified,
|
|
2305
|
+
type: "type_mismatch"
|
|
2306
|
+
});
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
if (Array.isArray(original)) {
|
|
2310
|
+
if (!Array.isArray(modified)) {
|
|
2311
|
+
errors.push({
|
|
2312
|
+
path,
|
|
2313
|
+
expected: "array",
|
|
2314
|
+
actual: typeof modified,
|
|
2315
|
+
type: "type_mismatch"
|
|
2316
|
+
});
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
if (original.length !== modified.length) {
|
|
2320
|
+
errors.push({
|
|
2321
|
+
path,
|
|
2322
|
+
expected: `length ${original.length}`,
|
|
2323
|
+
actual: `length ${modified.length}`,
|
|
2324
|
+
type: "type_mismatch"
|
|
2325
|
+
});
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
original.forEach((item, idx) => {
|
|
2329
|
+
validateStructureRecursive(item, modified[idx], [...path, String(idx)], errors);
|
|
2330
|
+
});
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
if (typeof original == "object" && original !== null && typeof modified == "object" && modified !== null) {
|
|
2334
|
+
const origKeys = Object.keys(original).sort(), modKeys = Object.keys(modified).sort();
|
|
2335
|
+
for (const key of origKeys)
|
|
2336
|
+
key in modified || errors.push({
|
|
2337
|
+
path: [...path, key],
|
|
2338
|
+
expected: "present",
|
|
2339
|
+
actual: "missing",
|
|
2340
|
+
type: "missing"
|
|
2341
|
+
});
|
|
2342
|
+
for (const key of modKeys)
|
|
2343
|
+
key in original || errors.push({
|
|
2344
|
+
path: [...path, key],
|
|
2345
|
+
expected: "absent",
|
|
2346
|
+
actual: "present",
|
|
2347
|
+
type: "extra"
|
|
2348
|
+
});
|
|
2349
|
+
const origObj = original, modObj = modified;
|
|
2350
|
+
for (const key of origKeys)
|
|
2351
|
+
key in modObj && (key.startsWith("_") || key === "marks" || key === "style" || key === "markDefs" ? JSON.stringify(origObj[key]) !== JSON.stringify(modObj[key]) && errors.push({
|
|
2352
|
+
path: [...path, key],
|
|
2353
|
+
expected: origObj[key],
|
|
2354
|
+
actual: modObj[key],
|
|
2355
|
+
type: "metadata_changed"
|
|
2356
|
+
}) : key !== "text" && validateStructureRecursive(origObj[key], modObj[key], [...path, key], errors));
|
|
2357
|
+
}
|
|
2358
|
+
}, assembleTranslationPrompt = (context, components) => {
|
|
2359
|
+
const sections = [
|
|
2360
|
+
{
|
|
2361
|
+
id: "agentDirectives",
|
|
2362
|
+
content: buildAgentDirectivesSection(context),
|
|
2363
|
+
required: !0,
|
|
2364
|
+
priority: 0
|
|
2365
|
+
},
|
|
2366
|
+
{
|
|
2367
|
+
id: "criticalRules",
|
|
2368
|
+
content: buildCriticalRulesSection(context),
|
|
2369
|
+
required: !0,
|
|
2370
|
+
priority: 1
|
|
2371
|
+
}
|
|
2372
|
+
];
|
|
2373
|
+
components.glossaryTerms?.trim() && sections.push({
|
|
2374
|
+
id: "glossaryTerms",
|
|
2375
|
+
content: buildGlossarySection(components.glossaryTerms),
|
|
2376
|
+
required: !1,
|
|
2377
|
+
priority: 3
|
|
2378
|
+
});
|
|
2379
|
+
const styleSection = buildStyleGuideSection(components.globalPrompt, components.localePrompt);
|
|
2380
|
+
styleSection && sections.push({
|
|
2381
|
+
id: "styleGuide",
|
|
2382
|
+
content: styleSection,
|
|
2383
|
+
required: !1,
|
|
2384
|
+
priority: 4
|
|
2385
|
+
}), sections.sort((a, b) => a.priority - b.priority);
|
|
2386
|
+
let styleGuide = "";
|
|
2387
|
+
const omittedSections = [], truncatedSections = [];
|
|
2388
|
+
for (const section of sections) {
|
|
2389
|
+
const prefix = styleGuide.length > 0 ? `
|
|
2390
|
+
|
|
2391
|
+
---
|
|
2392
|
+
|
|
2393
|
+
` : "", availableCharacters = 2e3 - (styleGuide.length + prefix.length);
|
|
2394
|
+
if (availableCharacters <= 0) {
|
|
2395
|
+
if (section.required) {
|
|
2396
|
+
const truncatedContent = truncateSection(section.content, 0);
|
|
2397
|
+
truncatedContent && (styleGuide += prefix + truncatedContent, truncatedSections.push(section.id));
|
|
2398
|
+
} else
|
|
2399
|
+
omittedSections.push(section.id);
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (section.content.length <= availableCharacters) {
|
|
2403
|
+
styleGuide += prefix + section.content;
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
if (section.required) {
|
|
2407
|
+
const truncatedContent = truncateSection(section.content, availableCharacters);
|
|
2408
|
+
styleGuide += prefix + truncatedContent, truncatedSections.push(section.id);
|
|
2409
|
+
} else
|
|
2410
|
+
omittedSections.push(section.id);
|
|
2411
|
+
}
|
|
2412
|
+
return {
|
|
2413
|
+
styleGuide,
|
|
2414
|
+
omittedSections,
|
|
2415
|
+
truncatedSections
|
|
2416
|
+
};
|
|
2417
|
+
}, truncateSection = (content, limit) => {
|
|
2418
|
+
if (limit <= 0)
|
|
2419
|
+
return "";
|
|
2420
|
+
if (content.length <= limit)
|
|
2421
|
+
return content;
|
|
2422
|
+
if (limit <= 1)
|
|
2423
|
+
return content.slice(0, limit);
|
|
2424
|
+
const ellipsis = "\u2026";
|
|
2425
|
+
if (limit <= ellipsis.length)
|
|
2426
|
+
return content.slice(0, limit);
|
|
2427
|
+
const sliceEnd = limit - ellipsis.length, sliced = content.slice(0, sliceEnd), trimmed = sliced.replace(/\s+\S*$/, "").trimEnd();
|
|
2428
|
+
return `${trimmed.length >= sliceEnd / 2 ? trimmed : sliced}${ellipsis}`;
|
|
2429
|
+
}, buildAgentDirectivesSection = (context) => {
|
|
2430
|
+
const { targetLanguage, sourceLanguage } = context;
|
|
2431
|
+
return `# AGENT WORKFLOW DIRECTIVES
|
|
2432
|
+
|
|
2433
|
+
- Use only the provided ${sourceLanguage || "the source language"} source; never fetch outside context.
|
|
2434
|
+
- Scan each field once, then move directly into translation work.
|
|
2435
|
+
- Plan updates before editing so document structure remains intact.
|
|
2436
|
+
- Default to medium reasoning effort and escalate only for ambiguous passages.
|
|
2437
|
+
- Output just the ${targetLanguage || "the target language"} translation and mirror the source formatting exactly.`;
|
|
2438
|
+
}, buildCriticalRulesSection = (context) => {
|
|
2439
|
+
const { sourceLanguage, targetLanguage } = context, fromLanguage = sourceLanguage || "the source language", toLanguage = targetLanguage || "the target language";
|
|
2440
|
+
return `# CRITICAL TRANSLATION RULES
|
|
2441
|
+
|
|
2442
|
+
Follow these without exception:
|
|
2443
|
+
|
|
2444
|
+
1. Translate from ${fromLanguage} into ${toLanguage}; every output token must be ${toLanguage} unless flagged as protected.
|
|
2445
|
+
2. Preserve meaning exactly\u2014no additions, deletions, or tone shifts.
|
|
2446
|
+
3. Maintain all layout, markdown, rich-text marks, and data structure.
|
|
2447
|
+
4. Never invent metadata or field values that are absent in the source.
|
|
2448
|
+
5. Leave technical tokens (code, IDs, filenames) untouched.
|
|
2449
|
+
6. Keep punctuation, casing, and spacing unless ${toLanguage} grammar forces a change.`;
|
|
2450
|
+
}, buildGlossarySection = (glossaryTerms) => `# TERMINOLOGY RULES
|
|
2451
|
+
|
|
2452
|
+
Apply these mappings immediately after protected phrases and before style preferences.
|
|
2453
|
+
|
|
2454
|
+
${glossaryTerms}`, buildStyleGuideSection = (globalPrompt, localePrompt) => {
|
|
2455
|
+
const components = [];
|
|
2456
|
+
return (globalPrompt || localePrompt) && (components.push("# TRANSLATION STYLE GUIDE (LOWEST PRIORITY)"), components.push(
|
|
2457
|
+
"Apply these directions only after honoring critical rules, protected phrases, and glossary terminology."
|
|
2458
|
+
), components.push("")), globalPrompt && (components.push("## General Guidelines"), components.push(globalPrompt), components.push("")), localePrompt && (components.push("## Language-Specific Guidelines"), components.push(localePrompt)), components.length > 0 ? components.join(`
|
|
2459
|
+
`) : null;
|
|
1531
2460
|
}, translateEntireDocument = async ({
|
|
1532
2461
|
dispatch,
|
|
1533
2462
|
client,
|
|
@@ -1535,17 +2464,175 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1535
2464
|
sourceDocument,
|
|
1536
2465
|
targetLanguage,
|
|
1537
2466
|
languageFieldName,
|
|
1538
|
-
translationTarget,
|
|
2467
|
+
translationTarget,
|
|
2468
|
+
translationPrompts,
|
|
2469
|
+
translationMode,
|
|
2470
|
+
openaiConfig,
|
|
2471
|
+
releaseId,
|
|
2472
|
+
isNewTranslation
|
|
2473
|
+
}) => {
|
|
2474
|
+
const logContext = {
|
|
2475
|
+
documentId: sourceDocument._id,
|
|
2476
|
+
targetLanguage,
|
|
2477
|
+
schemaId,
|
|
2478
|
+
operation: translationTarget.operation,
|
|
2479
|
+
targetId: translationTarget._id,
|
|
2480
|
+
translationMode,
|
|
2481
|
+
releaseId,
|
|
2482
|
+
isNewTranslation
|
|
2483
|
+
};
|
|
2484
|
+
devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
|
|
2485
|
+
try {
|
|
2486
|
+
let versionedDocumentId, baseDocumentId;
|
|
2487
|
+
if (isNewTranslation) {
|
|
2488
|
+
const { _id, _createdAt, _updatedAt, _rev, ...docWithoutMeta } = sourceDocument, newTranslationDoc = {
|
|
2489
|
+
...docWithoutMeta,
|
|
2490
|
+
_type: sourceDocument._type,
|
|
2491
|
+
[languageFieldName]: targetLanguage
|
|
2492
|
+
};
|
|
2493
|
+
devLog.info("Creating new translation document in release", logContext), baseDocumentId = translationTarget._id || `new-translation-${Date.now()}`, await client.action({
|
|
2494
|
+
actionType: "sanity.action.document.version.create",
|
|
2495
|
+
publishedId: baseDocumentId,
|
|
2496
|
+
document: {
|
|
2497
|
+
...newTranslationDoc,
|
|
2498
|
+
_type: sourceDocument._type,
|
|
2499
|
+
_id: `versions.${releaseId}.${baseDocumentId}`
|
|
2500
|
+
}
|
|
2501
|
+
}), versionedDocumentId = `versions.${releaseId}.${baseDocumentId}`, devLog.info("New translation version created in release", {
|
|
2502
|
+
...logContext,
|
|
2503
|
+
versionedDocumentId,
|
|
2504
|
+
baseDocumentId
|
|
2505
|
+
});
|
|
2506
|
+
} else {
|
|
2507
|
+
const existingTranslationId = translationTarget._id;
|
|
2508
|
+
if (!existingTranslationId)
|
|
2509
|
+
throw new Error("Translation target ID not found for update operation");
|
|
2510
|
+
devLog.info("Creating version of existing translation in release", {
|
|
2511
|
+
...logContext,
|
|
2512
|
+
existingTranslationId
|
|
2513
|
+
});
|
|
2514
|
+
const existingDoc = await client.getDocument(existingTranslationId);
|
|
2515
|
+
if (!existingDoc)
|
|
2516
|
+
throw new Error(`Existing translation document not found: ${existingTranslationId}`);
|
|
2517
|
+
await client.action({
|
|
2518
|
+
actionType: "sanity.action.document.version.create",
|
|
2519
|
+
publishedId: existingTranslationId,
|
|
2520
|
+
document: {
|
|
2521
|
+
...existingDoc,
|
|
2522
|
+
_id: `versions.${releaseId}.${existingTranslationId}`
|
|
2523
|
+
}
|
|
2524
|
+
}), versionedDocumentId = `versions.${releaseId}.${existingTranslationId}`, baseDocumentId = existingTranslationId, devLog.info("Existing translation version created", {
|
|
2525
|
+
...logContext,
|
|
2526
|
+
versionedDocumentId
|
|
2527
|
+
});
|
|
2528
|
+
const { _id, _createdAt, _updatedAt, _rev, ...sourceContent } = sourceDocument;
|
|
2529
|
+
await client.createOrReplace({
|
|
2530
|
+
...sourceContent,
|
|
2531
|
+
_id: versionedDocumentId,
|
|
2532
|
+
[languageFieldName]: targetLanguage
|
|
2533
|
+
}), devLog.info("Source content cloned onto version", {
|
|
2534
|
+
...logContext,
|
|
2535
|
+
versionedDocumentId
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
let translatedDocument;
|
|
2539
|
+
switch (translationMode) {
|
|
2540
|
+
case "openai-only": {
|
|
2541
|
+
if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
|
|
2542
|
+
throw new Error("OpenAI is not configured but openai-only mode was selected");
|
|
2543
|
+
devLog.info("Using OpenAI-only translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai");
|
|
2544
|
+
const versionedDoc = await client.getDocument(versionedDocumentId);
|
|
2545
|
+
if (!versionedDoc)
|
|
2546
|
+
throw new Error(`Versioned document not found: ${versionedDocumentId}`);
|
|
2547
|
+
const translatedDoc = await translateInPlaceWithOpenAI({
|
|
2548
|
+
dispatch,
|
|
2549
|
+
sourceDocument,
|
|
2550
|
+
targetDocument: versionedDoc,
|
|
2551
|
+
targetLanguage,
|
|
2552
|
+
languageFieldName,
|
|
2553
|
+
translationPrompts,
|
|
2554
|
+
openaiConfig,
|
|
2555
|
+
isReview: !1
|
|
2556
|
+
});
|
|
2557
|
+
translatedDoc && (translatedDocument = await client.createOrReplace({
|
|
2558
|
+
...translatedDoc,
|
|
2559
|
+
_id: versionedDocumentId
|
|
2560
|
+
}));
|
|
2561
|
+
break;
|
|
2562
|
+
}
|
|
2563
|
+
case "sanity-with-review":
|
|
2564
|
+
if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
|
|
2565
|
+
throw new Error("OpenAI is not configured but sanity-with-review mode was selected");
|
|
2566
|
+
if (devLog.info("Using Sanity + OpenAI review", logContext), translatedDocument = await translateWithSanityAgent({
|
|
2567
|
+
dispatch,
|
|
2568
|
+
client,
|
|
2569
|
+
schemaId,
|
|
2570
|
+
sourceDocument,
|
|
2571
|
+
targetLanguage,
|
|
2572
|
+
languageFieldName,
|
|
2573
|
+
versionedDocumentId,
|
|
2574
|
+
translationPrompts
|
|
2575
|
+
}), translatedDocument) {
|
|
2576
|
+
setJobStatus(dispatch, sourceDocument._id, targetLanguage, "reviewing_with_openai");
|
|
2577
|
+
const reviewedDoc = await translateInPlaceWithOpenAI({
|
|
2578
|
+
dispatch,
|
|
2579
|
+
sourceDocument,
|
|
2580
|
+
targetDocument: translatedDocument,
|
|
2581
|
+
targetLanguage,
|
|
2582
|
+
languageFieldName,
|
|
2583
|
+
translationPrompts,
|
|
2584
|
+
openaiConfig,
|
|
2585
|
+
isReview: !0
|
|
2586
|
+
});
|
|
2587
|
+
reviewedDoc && (translatedDocument = await client.createOrReplace({
|
|
2588
|
+
...reviewedDoc,
|
|
2589
|
+
_id: versionedDocumentId
|
|
2590
|
+
}));
|
|
2591
|
+
}
|
|
2592
|
+
break;
|
|
2593
|
+
default:
|
|
2594
|
+
devLog.info("Using Sanity-only translation", logContext), translatedDocument = await translateWithSanityAgent({
|
|
2595
|
+
dispatch,
|
|
2596
|
+
client,
|
|
2597
|
+
schemaId,
|
|
2598
|
+
sourceDocument,
|
|
2599
|
+
targetLanguage,
|
|
2600
|
+
languageFieldName,
|
|
2601
|
+
versionedDocumentId,
|
|
2602
|
+
translationPrompts
|
|
2603
|
+
});
|
|
2604
|
+
break;
|
|
2605
|
+
}
|
|
2606
|
+
return devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), translatedDocument ? {
|
|
2607
|
+
...translatedDocument,
|
|
2608
|
+
_id: baseDocumentId
|
|
2609
|
+
} : void 0;
|
|
2610
|
+
} catch (error) {
|
|
2611
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2612
|
+
devLog.error(
|
|
2613
|
+
"translateEntireDocument failed",
|
|
2614
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
2615
|
+
logContext
|
|
2616
|
+
), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
|
|
2617
|
+
errorMessage
|
|
2618
|
+
}), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
}, translateWithSanityAgent = async ({
|
|
2622
|
+
dispatch,
|
|
2623
|
+
client,
|
|
2624
|
+
schemaId,
|
|
2625
|
+
sourceDocument,
|
|
2626
|
+
targetLanguage,
|
|
2627
|
+
languageFieldName,
|
|
2628
|
+
versionedDocumentId,
|
|
1539
2629
|
translationPrompts
|
|
1540
2630
|
}) => {
|
|
1541
2631
|
const logContext = {
|
|
1542
2632
|
documentId: sourceDocument._id,
|
|
1543
2633
|
targetLanguage,
|
|
1544
|
-
|
|
1545
|
-
operation: translationTarget.operation,
|
|
1546
|
-
targetId: translationTarget._id
|
|
2634
|
+
versionedDocumentId
|
|
1547
2635
|
};
|
|
1548
|
-
devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
|
|
1549
2636
|
try {
|
|
1550
2637
|
const { toLanguage, fromLanguage } = assembleLanguageSpecs({
|
|
1551
2638
|
sourceDocument,
|
|
@@ -1557,104 +2644,263 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1557
2644
|
toLanguage,
|
|
1558
2645
|
fromLanguage
|
|
1559
2646
|
});
|
|
1560
|
-
|
|
1561
|
-
|
|
2647
|
+
const documentAnalysis = analyzeDocumentContent(sourceDocument);
|
|
2648
|
+
devLog.debug("Document analysis completed", {
|
|
1562
2649
|
...logContext,
|
|
1563
|
-
|
|
1564
|
-
}),
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2650
|
+
analysis: documentAnalysis
|
|
2651
|
+
}), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
|
|
2652
|
+
const { styleGuide, omittedSections, truncatedSections } = assembleTranslationPrompt(
|
|
2653
|
+
{
|
|
2654
|
+
documentAnalysis,
|
|
2655
|
+
isUpdate: !1,
|
|
2656
|
+
// Always fresh translation in a version
|
|
2657
|
+
targetLanguage,
|
|
2658
|
+
sourceLanguage: sourceDocument[languageFieldName]
|
|
2659
|
+
},
|
|
2660
|
+
{
|
|
2661
|
+
globalPrompt: translationPrompts.globalPrompt,
|
|
2662
|
+
localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
|
|
2663
|
+
glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
|
|
2664
|
+
protectedPhrases: translationPrompts.protectedPhrases
|
|
2665
|
+
}
|
|
2666
|
+
);
|
|
2667
|
+
devLog.info("Structured style guide generated", {
|
|
1569
2668
|
...logContext,
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
translationPrompts.localeInstructions[targetLanguage]?.prompt,
|
|
1575
|
-
translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
|
|
1576
|
-
extantTranslation ? UPDATE_EXISTING_TRANSLATION_PROMPT : null
|
|
1577
|
-
].filter(Boolean).join(`
|
|
1578
|
-
|
|
1579
|
-
`);
|
|
1580
|
-
devLog.info("Style guide generated", {
|
|
2669
|
+
styleGuideLength: styleGuide.length,
|
|
2670
|
+
omittedSections,
|
|
2671
|
+
truncatedSections
|
|
2672
|
+
}), (omittedSections.length > 0 || truncatedSections.length > 0) && devLog.warn("Style guide adjusted to fit character limit", {
|
|
1581
2673
|
...logContext,
|
|
1582
|
-
|
|
2674
|
+
omittedSections,
|
|
2675
|
+
truncatedSections
|
|
1583
2676
|
});
|
|
1584
2677
|
const newDocument = await client.agent.action.translate({
|
|
1585
2678
|
schemaId,
|
|
1586
2679
|
toLanguage,
|
|
1587
2680
|
fromLanguage,
|
|
1588
|
-
documentId:
|
|
1589
|
-
|
|
2681
|
+
documentId: versionedDocumentId,
|
|
2682
|
+
// Use versioned document ID
|
|
1590
2683
|
languageFieldPath: [languageFieldName],
|
|
1591
2684
|
styleGuide,
|
|
1592
|
-
styleGuideParams: {
|
|
1593
|
-
extantTranslation
|
|
1594
|
-
},
|
|
1595
2685
|
protectedPhrases: translationPrompts.protectedPhrases || [],
|
|
1596
2686
|
conditionalPaths: {
|
|
1597
2687
|
defaultHidden: !1,
|
|
1598
2688
|
defaultReadOnly: !1
|
|
1599
2689
|
}
|
|
1600
2690
|
});
|
|
1601
|
-
return devLog.info("
|
|
2691
|
+
return devLog.info("Sanity agent translation completed", {
|
|
1602
2692
|
...logContext,
|
|
1603
2693
|
newDocumentId: newDocument._id,
|
|
1604
2694
|
newDocumentRev: newDocument._rev
|
|
1605
|
-
}),
|
|
2695
|
+
}), newDocument;
|
|
1606
2696
|
} catch (error) {
|
|
1607
2697
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1608
2698
|
devLog.error(
|
|
1609
|
-
"
|
|
2699
|
+
"Sanity agent translation failed",
|
|
1610
2700
|
error instanceof Error ? error : new Error(String(error)),
|
|
1611
2701
|
logContext
|
|
1612
2702
|
), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
|
|
1613
2703
|
errorMessage
|
|
1614
|
-
})
|
|
2704
|
+
});
|
|
1615
2705
|
return;
|
|
1616
2706
|
}
|
|
1617
|
-
},
|
|
1618
|
-
|
|
1619
|
-
targetLanguage,
|
|
2707
|
+
}, translateInPlaceWithOpenAI = async ({
|
|
2708
|
+
dispatch,
|
|
1620
2709
|
sourceDocument,
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2710
|
+
targetDocument,
|
|
2711
|
+
targetLanguage,
|
|
2712
|
+
languageFieldName,
|
|
2713
|
+
translationPrompts,
|
|
2714
|
+
openaiConfig,
|
|
2715
|
+
isReview
|
|
1625
2716
|
}) => {
|
|
2717
|
+
const sourceLanguage = sourceDocument[languageFieldName], logContext = {
|
|
2718
|
+
sourceId: sourceDocument._id,
|
|
2719
|
+
targetId: targetDocument._id,
|
|
2720
|
+
targetLanguage,
|
|
2721
|
+
sourceLanguage,
|
|
2722
|
+
isReview
|
|
2723
|
+
};
|
|
1626
2724
|
try {
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
_weak: !1
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
}, newTranslationHistory = {
|
|
1640
|
-
_key: targetLanguage,
|
|
1641
|
-
language: targetLanguage,
|
|
1642
|
-
sourceId: undraftId(sourceDocument._id),
|
|
1643
|
-
sourceRev: sourceDocument._rev,
|
|
2725
|
+
const sourceText = serializeDocument(sourceDocument), targetText = serializeDocument(targetDocument);
|
|
2726
|
+
devLog.debug("Documents serialized for OpenAI", {
|
|
2727
|
+
...logContext,
|
|
2728
|
+
sourceLength: sourceText.length,
|
|
2729
|
+
targetLength: targetText.length
|
|
2730
|
+
});
|
|
2731
|
+
const prompt = isReview ? buildReviewPrompt({
|
|
2732
|
+
sourceText,
|
|
2733
|
+
translatedText: targetText,
|
|
1644
2734
|
sourceLanguage,
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2735
|
+
targetLanguage,
|
|
2736
|
+
translationPrompts
|
|
2737
|
+
}) : buildTranslationPrompt({
|
|
2738
|
+
sourceText,
|
|
2739
|
+
sourceLanguage,
|
|
2740
|
+
targetLanguage,
|
|
2741
|
+
translationPrompts
|
|
2742
|
+
});
|
|
2743
|
+
devLog.debug("Calling GPT-5", {
|
|
2744
|
+
...logContext,
|
|
2745
|
+
promptLength: prompt.length
|
|
2746
|
+
});
|
|
2747
|
+
const response = await callGPT5(openaiConfig.apiKey, {
|
|
2748
|
+
messages: [
|
|
2749
|
+
{
|
|
2750
|
+
role: "system",
|
|
2751
|
+
content: isReview ? "You are a translation quality reviewer. Review and refine translations while preserving structure." : "You are a professional translator. Translate documents while preserving their exact structure."
|
|
2752
|
+
},
|
|
2753
|
+
{ role: "user", content: prompt }
|
|
2754
|
+
],
|
|
2755
|
+
model: openaiConfig.model,
|
|
2756
|
+
reasoningEffort: openaiConfig.reasoningEffort,
|
|
2757
|
+
verbosity: openaiConfig.verbosity,
|
|
2758
|
+
onReasoningChunk: (reasoning) => {
|
|
2759
|
+
const status2 = isReview ? "reviewing_with_openai" : "translating_with_openai";
|
|
2760
|
+
setJobStatus(dispatch, sourceDocument._id, targetLanguage, status2, {
|
|
2761
|
+
reasoningProgress: reasoning
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
devLog.info("OpenAI operation completed", {
|
|
2766
|
+
...logContext,
|
|
2767
|
+
tokensUsed: response.tokensUsed.total,
|
|
2768
|
+
cost: response.cost
|
|
2769
|
+
});
|
|
2770
|
+
const result = deserializeDocument(targetDocument, response.content), validation = validateStructureIntegrity(targetDocument, result);
|
|
2771
|
+
if (!validation.valid) {
|
|
2772
|
+
devLog.error("Structure validation failed", {
|
|
2773
|
+
...logContext,
|
|
2774
|
+
errors: validation.errors
|
|
2775
|
+
});
|
|
2776
|
+
const unexpectedErrors = validation.errors.filter(
|
|
2777
|
+
(err) => !(err.path.length === 1 && err.path[0] === languageFieldName)
|
|
2778
|
+
);
|
|
2779
|
+
if (unexpectedErrors.length > 0)
|
|
2780
|
+
throw new Error(
|
|
2781
|
+
`Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2784
|
+
devLog.info("Structure validation passed", logContext);
|
|
2785
|
+
const status = isReview ? "reviewing_with_openai" : "translating_with_openai";
|
|
2786
|
+
return setJobStatus(dispatch, sourceDocument._id, targetLanguage, status, {
|
|
2787
|
+
tokensUsed: response.tokensUsed.total,
|
|
2788
|
+
estimatedCost: response.cost,
|
|
2789
|
+
reasoningSummary: response.reasoningSummary
|
|
2790
|
+
}), result;
|
|
1655
2791
|
} catch (error) {
|
|
1656
|
-
throw
|
|
2792
|
+
throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
|
|
1657
2793
|
}
|
|
2794
|
+
}, buildTranslationPrompt = ({
|
|
2795
|
+
sourceText,
|
|
2796
|
+
sourceLanguage,
|
|
2797
|
+
targetLanguage,
|
|
2798
|
+
translationPrompts
|
|
2799
|
+
}) => {
|
|
2800
|
+
const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
|
|
2801
|
+
return `# TRANSLATION TASK
|
|
2802
|
+
|
|
2803
|
+
Translate this ${sourceLanguage} document into ${targetLanguage}.
|
|
2804
|
+
|
|
2805
|
+
## Guidelines
|
|
2806
|
+
|
|
2807
|
+
${globalPrompt ? `### General Guidelines
|
|
2808
|
+
${globalPrompt}
|
|
2809
|
+
` : ""}
|
|
2810
|
+
${localePrompt ? `### ${targetLanguage} Guidelines
|
|
2811
|
+
${localePrompt}
|
|
2812
|
+
` : ""}
|
|
2813
|
+
${glossaryTerms ? `### Required Terminology
|
|
2814
|
+
${glossaryTerms}
|
|
2815
|
+
` : ""}
|
|
2816
|
+
${protectedPhrases?.length ? `### Protected Phrases (do not translate)
|
|
2817
|
+
${protectedPhrases.join(", ")}
|
|
2818
|
+
` : ""}
|
|
2819
|
+
|
|
2820
|
+
## Source Document (${sourceLanguage})
|
|
2821
|
+
|
|
2822
|
+
\`\`\`
|
|
2823
|
+
${sourceText}
|
|
2824
|
+
\`\`\`
|
|
2825
|
+
|
|
2826
|
+
## Instructions
|
|
2827
|
+
|
|
2828
|
+
1. Translate all text content into ${targetLanguage}
|
|
2829
|
+
2. Follow the guidelines and terminology requirements above
|
|
2830
|
+
3. Preserve protected phrases exactly as they appear
|
|
2831
|
+
4. Return the document in THE EXACT SAME FORMAT
|
|
2832
|
+
5. **CRITICAL**: Only modify text content between tags
|
|
2833
|
+
6. Never add, remove, or change any tags, attributes, or structure
|
|
2834
|
+
|
|
2835
|
+
If you used significant reasoning to make translation decisions, you may include a brief summary:
|
|
2836
|
+
<reasoning>
|
|
2837
|
+
Brief summary of key translation decisions and reasoning
|
|
2838
|
+
</reasoning>
|
|
2839
|
+
|
|
2840
|
+
Then return the translated document:`;
|
|
2841
|
+
}, buildReviewPrompt = ({
|
|
2842
|
+
sourceText,
|
|
2843
|
+
translatedText,
|
|
2844
|
+
sourceLanguage,
|
|
2845
|
+
targetLanguage,
|
|
2846
|
+
translationPrompts
|
|
2847
|
+
}) => {
|
|
2848
|
+
const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
|
|
2849
|
+
return `# TRANSLATION QUALITY REVIEW
|
|
2850
|
+
|
|
2851
|
+
Review this ${targetLanguage} translation and return a corrected version.
|
|
2852
|
+
|
|
2853
|
+
## Review Criteria
|
|
2854
|
+
|
|
2855
|
+
1. **Accuracy**: Does it accurately convey the ${sourceLanguage} source meaning?
|
|
2856
|
+
2. **Terminology**: Are glossary terms used correctly?
|
|
2857
|
+
3. **Protected Phrases**: Are protected phrases preserved?
|
|
2858
|
+
4. **Fluency**: Is it natural and fluent in ${targetLanguage}?
|
|
2859
|
+
5. **Style**: Does it follow the style guide?
|
|
2860
|
+
6. **Completeness**: Is all content translated?
|
|
2861
|
+
|
|
2862
|
+
## Guidelines
|
|
2863
|
+
|
|
2864
|
+
${globalPrompt ? `### General Guidelines
|
|
2865
|
+
${globalPrompt}
|
|
2866
|
+
` : ""}
|
|
2867
|
+
${localePrompt ? `### ${targetLanguage} Guidelines
|
|
2868
|
+
${localePrompt}
|
|
2869
|
+
` : ""}
|
|
2870
|
+
${glossaryTerms ? `### Required Terminology
|
|
2871
|
+
${glossaryTerms}
|
|
2872
|
+
` : ""}
|
|
2873
|
+
${protectedPhrases?.length ? `### Protected Phrases (must preserve)
|
|
2874
|
+
${protectedPhrases.join(", ")}
|
|
2875
|
+
` : ""}
|
|
2876
|
+
|
|
2877
|
+
## Source Document (${sourceLanguage})
|
|
2878
|
+
|
|
2879
|
+
\`\`\`
|
|
2880
|
+
${sourceText}
|
|
2881
|
+
\`\`\`
|
|
2882
|
+
|
|
2883
|
+
## Current Translation (${targetLanguage})
|
|
2884
|
+
|
|
2885
|
+
\`\`\`
|
|
2886
|
+
${translatedText}
|
|
2887
|
+
\`\`\`
|
|
2888
|
+
|
|
2889
|
+
## Instructions
|
|
2890
|
+
|
|
2891
|
+
1. Review the translation for quality issues
|
|
2892
|
+
2. Make corrections where needed to improve accuracy, fluency, or adherence to guidelines
|
|
2893
|
+
3. Keep good translations as-is - only fix what needs fixing
|
|
2894
|
+
4. Return the corrected translation in THE EXACT SAME FORMAT
|
|
2895
|
+
5. **CRITICAL**: Only modify text content between tags
|
|
2896
|
+
6. Never add, remove, or change any tags, attributes, or structure
|
|
2897
|
+
|
|
2898
|
+
If you made corrections or have insights about the translation quality, include a brief summary:
|
|
2899
|
+
<reasoning>
|
|
2900
|
+
Brief summary of review findings and corrections made
|
|
2901
|
+
</reasoning>
|
|
2902
|
+
|
|
2903
|
+
Then return the corrected translation document:`;
|
|
1658
2904
|
}, translateDocument = async ({
|
|
1659
2905
|
dispatch,
|
|
1660
2906
|
client,
|
|
@@ -1663,15 +2909,17 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1663
2909
|
languageFieldName,
|
|
1664
2910
|
shouldOverwriteFreshTranslations,
|
|
1665
2911
|
schemaId,
|
|
1666
|
-
|
|
1667
|
-
|
|
2912
|
+
translationPrompts,
|
|
2913
|
+
translationMode,
|
|
2914
|
+
openaiConfig,
|
|
2915
|
+
releaseId
|
|
1668
2916
|
}) => {
|
|
1669
2917
|
const logContext = {
|
|
1670
2918
|
documentId: sourceDocument._id,
|
|
1671
2919
|
targetLanguage,
|
|
1672
2920
|
schemaId,
|
|
1673
|
-
|
|
1674
|
-
|
|
2921
|
+
shouldOverwriteFreshTranslations,
|
|
2922
|
+
translationMode
|
|
1675
2923
|
};
|
|
1676
2924
|
devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
|
|
1677
2925
|
try {
|
|
@@ -1711,48 +2959,20 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1711
2959
|
operation: translationTarget.operation,
|
|
1712
2960
|
isNewTranslation
|
|
1713
2961
|
});
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
devLog.debug("Patching translation target", {
|
|
1729
|
-
...logContext,
|
|
1730
|
-
currentTargetId,
|
|
1731
|
-
hasPatch: !!patch
|
|
1732
|
-
}), newDocument = await translateDocumentInPlace({
|
|
1733
|
-
client,
|
|
1734
|
-
schemaId,
|
|
1735
|
-
dispatch,
|
|
1736
|
-
sourceDocument,
|
|
1737
|
-
languageFieldName,
|
|
1738
|
-
targetLanguage,
|
|
1739
|
-
targetId: currentTargetId,
|
|
1740
|
-
patch
|
|
1741
|
-
});
|
|
1742
|
-
} else
|
|
1743
|
-
devLog.info("Starting full document translation", {
|
|
1744
|
-
...logContext,
|
|
1745
|
-
translationTarget: translationTarget.operation
|
|
1746
|
-
}), newDocument = await translateEntireDocument({
|
|
1747
|
-
dispatch,
|
|
1748
|
-
client,
|
|
1749
|
-
schemaId,
|
|
1750
|
-
sourceDocument,
|
|
1751
|
-
targetLanguage,
|
|
1752
|
-
languageFieldName,
|
|
1753
|
-
translationTarget,
|
|
1754
|
-
translationPrompts
|
|
1755
|
-
});
|
|
2962
|
+
const newDocument = await translateEntireDocument({
|
|
2963
|
+
dispatch,
|
|
2964
|
+
client,
|
|
2965
|
+
schemaId,
|
|
2966
|
+
sourceDocument,
|
|
2967
|
+
targetLanguage,
|
|
2968
|
+
languageFieldName,
|
|
2969
|
+
translationTarget,
|
|
2970
|
+
translationPrompts,
|
|
2971
|
+
translationMode,
|
|
2972
|
+
openaiConfig,
|
|
2973
|
+
releaseId,
|
|
2974
|
+
isNewTranslation
|
|
2975
|
+
});
|
|
1756
2976
|
if (!newDocument)
|
|
1757
2977
|
throw devLog.error("Failed to create translation document", void 0, logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error"), devLog.timeEnd("translateDocument"), devLog.groupEnd(), new Error("Failed to create translation document");
|
|
1758
2978
|
return devLog.debug("Translation document created", {
|
|
@@ -1779,30 +2999,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1779
2999
|
}), devLog.timeEnd("translateDocument"), devLog.groupEnd();
|
|
1780
3000
|
return;
|
|
1781
3001
|
}
|
|
1782
|
-
}, patchTranslationTarget = async ({
|
|
1783
|
-
client,
|
|
1784
|
-
currentSourceDocument,
|
|
1785
|
-
translationMetadata,
|
|
1786
|
-
targetLanguage
|
|
1787
|
-
}) => {
|
|
1788
|
-
const sourceSnapshotString = translationMetadata.translationHistory.find(
|
|
1789
|
-
(h) => undraftId(h.sourceId) === undraftId(currentSourceDocument._id)
|
|
1790
|
-
)?.sourceSnapshot;
|
|
1791
|
-
if (!sourceSnapshotString)
|
|
1792
|
-
return {};
|
|
1793
|
-
const sourceSnapshot = JSON.parse(sourceSnapshotString), targetId = translationMetadata.translations.find((t) => t._key === targetLanguage)?.value._ref;
|
|
1794
|
-
if (!targetId)
|
|
1795
|
-
return {};
|
|
1796
|
-
const currentTargetId = await client.fetch(
|
|
1797
|
-
'*[_id in $ids] | order((_id match "drafts*") desc)[0]._id',
|
|
1798
|
-
{ ids: [undraftId(targetId), draftId(targetId)] }
|
|
1799
|
-
);
|
|
1800
|
-
if (!currentTargetId)
|
|
1801
|
-
return console.log(`No document found for ${targetId}`), {};
|
|
1802
|
-
const patch = diffPatch(sourceSnapshot, currentSourceDocument, {
|
|
1803
|
-
id: draftId(currentTargetId)
|
|
1804
|
-
});
|
|
1805
|
-
return { currentTargetId, patch };
|
|
1806
3002
|
}, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
|
|
1807
3003
|
client,
|
|
1808
3004
|
sourceDocument,
|
|
@@ -1813,17 +3009,19 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1813
3009
|
schemaId,
|
|
1814
3010
|
references = [],
|
|
1815
3011
|
maxConcurrentTranslationJobs,
|
|
1816
|
-
|
|
3012
|
+
translationMode,
|
|
3013
|
+
openaiConfig
|
|
1817
3014
|
}) => {
|
|
1818
3015
|
const logContext = {
|
|
1819
3016
|
documentId: sourceDocument._id,
|
|
1820
3017
|
targetLanguages,
|
|
1821
3018
|
referencesCount: references.length,
|
|
1822
3019
|
maxConcurrentJobs: maxConcurrentTranslationJobs,
|
|
1823
|
-
|
|
3020
|
+
translationMode,
|
|
1824
3021
|
shouldOverwriteFreshTranslations
|
|
1825
3022
|
};
|
|
1826
3023
|
devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
|
|
3024
|
+
let releaseId;
|
|
1827
3025
|
try {
|
|
1828
3026
|
const sourceLanguage = sourceDocument[languageFieldName];
|
|
1829
3027
|
if (!sourceLanguage)
|
|
@@ -1835,6 +3033,22 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1835
3033
|
type: "SET_OVERALL_STATUS",
|
|
1836
3034
|
payload: "translating"
|
|
1837
3035
|
});
|
|
3036
|
+
const releaseTitle = `[AI Translation] ${sourceDocument.title || sourceDocument._id.split("-").slice(0, 2).join("-")} (${sourceDocument._type}) ${sourceLanguage} \u2192 ${targetLanguages.join(", ")}`, releaseDescription = `AI translation batch created on ${(/* @__PURE__ */ new Date()).toLocaleDateString()} at ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`;
|
|
3037
|
+
devLog.info("Creating Content Release for translation batch", {
|
|
3038
|
+
...logContext,
|
|
3039
|
+
releaseTitle
|
|
3040
|
+
});
|
|
3041
|
+
const { releaseId: newReleaseId } = await client.releases.create({
|
|
3042
|
+
metadata: {
|
|
3043
|
+
title: releaseTitle,
|
|
3044
|
+
description: releaseDescription,
|
|
3045
|
+
releaseType: "asap"
|
|
3046
|
+
}
|
|
3047
|
+
});
|
|
3048
|
+
releaseId = newReleaseId, devLog.info("Content Release created", {
|
|
3049
|
+
...logContext,
|
|
3050
|
+
releaseId
|
|
3051
|
+
});
|
|
1838
3052
|
const sourceDocuments = [
|
|
1839
3053
|
{
|
|
1840
3054
|
_id: sourceDocument._id,
|
|
@@ -1866,7 +3080,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1866
3080
|
}).fetch("*[_id == $id][0]", {
|
|
1867
3081
|
id: documentId
|
|
1868
3082
|
});
|
|
1869
|
-
if (document) {
|
|
3083
|
+
if (document && releaseId) {
|
|
1870
3084
|
const targetDocumentId = await translateDocument({
|
|
1871
3085
|
dispatch,
|
|
1872
3086
|
client,
|
|
@@ -1875,8 +3089,10 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1875
3089
|
languageFieldName,
|
|
1876
3090
|
shouldOverwriteFreshTranslations,
|
|
1877
3091
|
schemaId,
|
|
1878
|
-
|
|
1879
|
-
|
|
3092
|
+
translationPrompts,
|
|
3093
|
+
translationMode,
|
|
3094
|
+
openaiConfig,
|
|
3095
|
+
releaseId
|
|
1880
3096
|
});
|
|
1881
3097
|
targetDocumentId && targetDocumentIds.push(targetDocumentId);
|
|
1882
3098
|
}
|
|
@@ -1924,11 +3140,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1924
3140
|
failed
|
|
1925
3141
|
}), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })) : (devLog.info("Document set translation completed successfully", logContext), dispatch({ type: "SET_OVERALL_STATUS", payload: "completed" })), devLog.timeEnd("translateDocumentSet"), devLog.groupEnd();
|
|
1926
3142
|
} catch (error) {
|
|
1927
|
-
|
|
3143
|
+
if (devLog.error(
|
|
1928
3144
|
"translateDocumentSet failed with unexpected error",
|
|
1929
3145
|
error instanceof Error ? error : new Error(String(error)),
|
|
1930
3146
|
logContext
|
|
1931
|
-
),
|
|
3147
|
+
), releaseId)
|
|
3148
|
+
try {
|
|
3149
|
+
await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
|
|
3150
|
+
...logContext,
|
|
3151
|
+
releaseId
|
|
3152
|
+
});
|
|
3153
|
+
} catch (cleanupError) {
|
|
3154
|
+
devLog.error(
|
|
3155
|
+
"Failed to cleanup release",
|
|
3156
|
+
cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
|
|
3157
|
+
logContext
|
|
3158
|
+
);
|
|
3159
|
+
}
|
|
3160
|
+
throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
|
|
1932
3161
|
}
|
|
1933
3162
|
}, ActionButtonBar = ({ setIsDialogOpen }) => {
|
|
1934
3163
|
const {
|
|
@@ -1939,22 +3168,50 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1939
3168
|
references,
|
|
1940
3169
|
selectedTargetLanguages,
|
|
1941
3170
|
shouldOverwriteFreshTranslations,
|
|
1942
|
-
|
|
1943
|
-
} = useTranslationContext(), client = useClient({ apiVersion: "vX" })
|
|
3171
|
+
translationMode
|
|
3172
|
+
} = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
|
|
3173
|
+
translateDocumentSet({
|
|
3174
|
+
sourceDocument: document,
|
|
3175
|
+
targetLanguages: selectedTargetLanguages,
|
|
3176
|
+
client,
|
|
3177
|
+
dispatch,
|
|
3178
|
+
languageFieldName: config.languageFieldName,
|
|
3179
|
+
schemaId: config.schemaId,
|
|
3180
|
+
references,
|
|
3181
|
+
shouldOverwriteFreshTranslations,
|
|
3182
|
+
maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
|
|
3183
|
+
maxReferenceDepth: config.maxReferenceDepth,
|
|
3184
|
+
translationMode,
|
|
3185
|
+
openaiConfig: config.openai
|
|
3186
|
+
});
|
|
3187
|
+
}, [
|
|
3188
|
+
document,
|
|
3189
|
+
selectedTargetLanguages,
|
|
3190
|
+
client,
|
|
3191
|
+
dispatch,
|
|
3192
|
+
config.languageFieldName,
|
|
3193
|
+
config.schemaId,
|
|
3194
|
+
config.maxConcurrentTranslationJobs,
|
|
3195
|
+
config.maxReferenceDepth,
|
|
3196
|
+
config.openai,
|
|
3197
|
+
references,
|
|
3198
|
+
shouldOverwriteFreshTranslations,
|
|
3199
|
+
translationMode
|
|
3200
|
+
]), handleSelectAll = useCallback(() => {
|
|
3201
|
+
dispatch({
|
|
3202
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
3203
|
+
payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
|
|
3204
|
+
});
|
|
3205
|
+
}, [dispatch, config.supportedLanguages, document.language]), handleSelectNone = useCallback(() => {
|
|
3206
|
+
dispatch({
|
|
3207
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
3208
|
+
payload: []
|
|
3209
|
+
});
|
|
3210
|
+
}, [dispatch]);
|
|
1944
3211
|
return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
|
|
1945
3212
|
/* @__PURE__ */ jsxs(Flex, { justify: "flex-start", gap: 2, children: [
|
|
1946
|
-
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick:
|
|
1947
|
-
|
|
1948
|
-
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
1949
|
-
payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
|
|
1950
|
-
});
|
|
1951
|
-
}, children: "Select All" }),
|
|
1952
|
-
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: () => {
|
|
1953
|
-
dispatch({
|
|
1954
|
-
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
1955
|
-
payload: []
|
|
1956
|
-
});
|
|
1957
|
-
}, children: "Select None" })
|
|
3213
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
|
|
3214
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
|
|
1958
3215
|
] }),
|
|
1959
3216
|
/* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
|
|
1960
3217
|
/* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
|
|
@@ -1964,21 +3221,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1964
3221
|
mode: "ghost",
|
|
1965
3222
|
tone: "positive",
|
|
1966
3223
|
size: 2,
|
|
1967
|
-
onClick:
|
|
1968
|
-
translateDocumentSet({
|
|
1969
|
-
sourceDocument: document,
|
|
1970
|
-
targetLanguages: selectedTargetLanguages,
|
|
1971
|
-
client,
|
|
1972
|
-
dispatch,
|
|
1973
|
-
languageFieldName: config.languageFieldName,
|
|
1974
|
-
schemaId: config.schemaId,
|
|
1975
|
-
references,
|
|
1976
|
-
shouldOverwriteFreshTranslations,
|
|
1977
|
-
maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
|
|
1978
|
-
maxReferenceDepth: config.maxReferenceDepth,
|
|
1979
|
-
shouldTranslateDiffs
|
|
1980
|
-
});
|
|
1981
|
-
},
|
|
3224
|
+
onClick: handleTranslate,
|
|
1982
3225
|
disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
|
|
1983
3226
|
children: "Translate"
|
|
1984
3227
|
}
|
|
@@ -2023,6 +3266,88 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2023
3266
|
documentId: document._id
|
|
2024
3267
|
});
|
|
2025
3268
|
return /* @__PURE__ */ jsx(Card, { padding: 4, muted: !0, border: !0, children: /* @__PURE__ */ jsx(Grid, { columns: [1, 2], gap: 3, children: config?.supportedLanguages.map((lang) => /* @__PURE__ */ jsx(LanguageCheckbox, { lang, isLoading: loading }, lang.id)) }) });
|
|
3269
|
+
}, TranslationModePanel = () => {
|
|
3270
|
+
const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
|
|
3271
|
+
if (!config?.openai?.enabled || !config?.openai?.apiKey)
|
|
3272
|
+
return null;
|
|
3273
|
+
if (!openaiValidated)
|
|
3274
|
+
return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3275
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Configuration" }),
|
|
3276
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating API key..." })
|
|
3277
|
+
] }) });
|
|
3278
|
+
const handleModeChange = (mode) => {
|
|
3279
|
+
dispatch({
|
|
3280
|
+
type: "SET_TRANSLATION_MODE",
|
|
3281
|
+
payload: mode
|
|
3282
|
+
});
|
|
3283
|
+
};
|
|
3284
|
+
return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
3285
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3286
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
|
|
3287
|
+
/* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
|
|
3288
|
+
] }),
|
|
3289
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3290
|
+
/* @__PURE__ */ jsx(
|
|
3291
|
+
Button,
|
|
3292
|
+
{
|
|
3293
|
+
type: "button",
|
|
3294
|
+
mode: translationMode === "sanity-only" ? "ghost" : "bleed",
|
|
3295
|
+
tone: translationMode === "sanity-only" ? "primary" : "default",
|
|
3296
|
+
onClick: () => handleModeChange("sanity-only"),
|
|
3297
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3298
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-only", readOnly: !0 }),
|
|
3299
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3300
|
+
/* @__PURE__ */ jsx(Text, { children: "Sanity Agent Only" }),
|
|
3301
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Fast, reliable, and included with Sanity." })
|
|
3302
|
+
] })
|
|
3303
|
+
] })
|
|
3304
|
+
}
|
|
3305
|
+
),
|
|
3306
|
+
/* @__PURE__ */ jsx(
|
|
3307
|
+
Button,
|
|
3308
|
+
{
|
|
3309
|
+
type: "button",
|
|
3310
|
+
mode: translationMode === "openai-only" ? "ghost" : "bleed",
|
|
3311
|
+
tone: translationMode === "openai-only" ? "primary" : "default",
|
|
3312
|
+
onClick: () => handleModeChange("openai-only"),
|
|
3313
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3314
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
|
|
3315
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3316
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3317
|
+
/* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
|
|
3318
|
+
/* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
|
|
3319
|
+
] }),
|
|
3320
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
|
|
3321
|
+
] })
|
|
3322
|
+
] })
|
|
3323
|
+
}
|
|
3324
|
+
),
|
|
3325
|
+
/* @__PURE__ */ jsx(
|
|
3326
|
+
Button,
|
|
3327
|
+
{
|
|
3328
|
+
type: "button",
|
|
3329
|
+
mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
|
|
3330
|
+
tone: translationMode === "sanity-with-review" ? "primary" : "default",
|
|
3331
|
+
onClick: () => handleModeChange("sanity-with-review"),
|
|
3332
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3333
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
|
|
3334
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3335
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3336
|
+
/* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
|
|
3337
|
+
/* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
|
|
3338
|
+
] }),
|
|
3339
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
|
|
3340
|
+
] })
|
|
3341
|
+
] })
|
|
3342
|
+
}
|
|
3343
|
+
)
|
|
3344
|
+
] }),
|
|
3345
|
+
translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3346
|
+
"\u{1F4B0} ",
|
|
3347
|
+
/* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
|
|
3348
|
+
" You'll be charged for API usage based on tokens processed."
|
|
3349
|
+
] }) })
|
|
3350
|
+
] }) });
|
|
2026
3351
|
}, assembleReferences = async ({
|
|
2027
3352
|
client,
|
|
2028
3353
|
sourceDocument,
|
|
@@ -2047,7 +3372,14 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2047
3372
|
typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
|
|
2048
3373
|
}
|
|
2049
3374
|
} catch (error) {
|
|
2050
|
-
|
|
3375
|
+
devLog.error(
|
|
3376
|
+
"Error finding references in document",
|
|
3377
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
3378
|
+
{
|
|
3379
|
+
documentId: document._id,
|
|
3380
|
+
component: "assembleReferences"
|
|
3381
|
+
}
|
|
3382
|
+
);
|
|
2051
3383
|
}
|
|
2052
3384
|
if (allRefIds.size === 0)
|
|
2053
3385
|
break;
|
|
@@ -2066,16 +3398,27 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2066
3398
|
}
|
|
2067
3399
|
documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
|
|
2068
3400
|
} else
|
|
2069
|
-
documentCache.set(refId, null),
|
|
3401
|
+
documentCache.set(refId, null), devLog.warn(`Document with _id "${refId}" not found`, {
|
|
3402
|
+
refId,
|
|
3403
|
+
component: "assembleReferences"
|
|
3404
|
+
});
|
|
2070
3405
|
}
|
|
2071
3406
|
currentDepthDocuments = nextDepthDocuments;
|
|
2072
3407
|
} catch (fetchError) {
|
|
2073
3408
|
const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
|
|
2074
|
-
errors.push({ refId: `batch(${refIdArray.join(",")})`, error }),
|
|
3409
|
+
errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), devLog.error("Failed to batch fetch documents", error, {
|
|
3410
|
+
refIdCount: refIdArray.length,
|
|
3411
|
+
depth,
|
|
3412
|
+
component: "assembleReferences"
|
|
3413
|
+
});
|
|
2075
3414
|
break;
|
|
2076
3415
|
}
|
|
2077
3416
|
}
|
|
2078
|
-
errors.length > 0 &&
|
|
3417
|
+
errors.length > 0 && devLog.warn(`Encountered ${errors.length} error(s) while fetching references`, {
|
|
3418
|
+
errorCount: errors.length,
|
|
3419
|
+
errors: errors.map((e) => ({ refId: e.refId, message: e.error.message })),
|
|
3420
|
+
component: "assembleReferences"
|
|
3421
|
+
});
|
|
2079
3422
|
const result = [];
|
|
2080
3423
|
for (const refId of references) {
|
|
2081
3424
|
const cachedDoc = documentCache.get(refId);
|
|
@@ -2160,7 +3503,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2160
3503
|
shouldTranslateReferences ? /* @__PURE__ */ jsx(Flex, { padding: 2, justify: "flex-end", children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: isLoadingReferences ? "Loading references..." : references.length === 0 ? "No translatable references found." : `${references.length} translatable references found.` }) }) : null
|
|
2161
3504
|
] });
|
|
2162
3505
|
}, WriteSettingsPanel = () => {
|
|
2163
|
-
const { dispatch, shouldOverwriteFreshTranslations
|
|
3506
|
+
const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
|
|
2164
3507
|
dispatch({
|
|
2165
3508
|
type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
|
|
2166
3509
|
payload: !shouldOverwriteFreshTranslations
|
|
@@ -2191,38 +3534,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2191
3534
|
] })
|
|
2192
3535
|
] })
|
|
2193
3536
|
}
|
|
2194
|
-
) }),
|
|
2195
|
-
/* @__PURE__ */ jsx(Flex, { justify: "space-between", align: "center", children: /* @__PURE__ */ jsx(
|
|
2196
|
-
Button,
|
|
2197
|
-
{
|
|
2198
|
-
type: "button",
|
|
2199
|
-
mode: shouldTranslateDiffs ? "ghost" : "bleed",
|
|
2200
|
-
size: 2,
|
|
2201
|
-
onClick: () => {
|
|
2202
|
-
dispatch({
|
|
2203
|
-
type: "SET_SHOULD_TRANSLATE_DIFFS",
|
|
2204
|
-
payload: !shouldTranslateDiffs
|
|
2205
|
-
});
|
|
2206
|
-
},
|
|
2207
|
-
tone: shouldTranslateDiffs ? "primary" : "neutral",
|
|
2208
|
-
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
2209
|
-
/* @__PURE__ */ jsx(
|
|
2210
|
-
Checkbox,
|
|
2211
|
-
{
|
|
2212
|
-
checked: shouldTranslateDiffs,
|
|
2213
|
-
onChange: handleShouldOverwriteFreshTranslationsChange,
|
|
2214
|
-
label: shouldTranslateDiffs ? "Disable" : "Enable"
|
|
2215
|
-
}
|
|
2216
|
-
),
|
|
2217
|
-
/* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
2218
|
-
/* @__PURE__ */ jsxs(Inline, { space: 1, children: [
|
|
2219
|
-
/* @__PURE__ */ jsx(Text, { children: "Translate Diffs" }),
|
|
2220
|
-
/* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Experimental" })
|
|
2221
|
-
] }),
|
|
2222
|
-
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Only re-translate content that has changed in the source document." })
|
|
2223
|
-
] })
|
|
2224
|
-
] })
|
|
2225
|
-
}
|
|
2226
3537
|
) })
|
|
2227
3538
|
] });
|
|
2228
3539
|
}, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
@@ -2230,16 +3541,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2230
3541
|
/* @__PURE__ */ jsx(Label, { children: "Languages" }),
|
|
2231
3542
|
/* @__PURE__ */ jsx(LanguageSelectionPanel, {})
|
|
2232
3543
|
] }),
|
|
3544
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3545
|
+
/* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
|
|
3546
|
+
/* @__PURE__ */ jsx(TranslationModePanel, {})
|
|
3547
|
+
] }),
|
|
2233
3548
|
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2234
3549
|
/* @__PURE__ */ jsx(Label, { children: "Settings" }),
|
|
2235
3550
|
/* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
|
|
2236
3551
|
] }),
|
|
2237
3552
|
/* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
|
|
2238
3553
|
] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
|
|
2239
|
-
const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
|
|
3554
|
+
const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
|
|
2240
3555
|
() => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
|
|
2241
3556
|
[overallStatus]
|
|
2242
|
-
), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs])
|
|
3557
|
+
), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
|
|
3558
|
+
if (translationMode === "sanity-only") return null;
|
|
3559
|
+
const completedJobs = jobs.filter((j) => j.status === "completed"), totalTokens = completedJobs.reduce((sum, job) => sum + (job.tokensUsed || 0), 0), totalCost = completedJobs.reduce((sum, job) => sum + (job.estimatedCost || 0), 0);
|
|
3560
|
+
return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
|
|
3561
|
+
}, [jobs, translationMode]);
|
|
2243
3562
|
return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
2244
3563
|
/* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
|
|
2245
3564
|
/* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
|
|
@@ -2259,6 +3578,25 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2259
3578
|
" \u2014 ",
|
|
2260
3579
|
job._id.slice(-6)
|
|
2261
3580
|
] }),
|
|
3581
|
+
!["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3582
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
|
|
3583
|
+
job.reasoningProgress && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3584
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "\u{1F914} GPT-5 Reasoning" }),
|
|
3585
|
+
/* @__PURE__ */ jsx(Text, { size: 1, style: { fontStyle: "italic" }, children: job.reasoningProgress })
|
|
3586
|
+
] }) })
|
|
3587
|
+
] }),
|
|
3588
|
+
job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3589
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
|
|
3590
|
+
job.tokensUsed.toLocaleString(),
|
|
3591
|
+
" tokens (~$",
|
|
3592
|
+
job.estimatedCost?.toFixed(4),
|
|
3593
|
+
")"
|
|
3594
|
+
] }),
|
|
3595
|
+
job.reasoningSummary && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3596
|
+
"\u{1F4A1} ",
|
|
3597
|
+
job.reasoningSummary
|
|
3598
|
+
] }) })
|
|
3599
|
+
] }) : null,
|
|
2262
3600
|
job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
2263
3601
|
"Error: ",
|
|
2264
3602
|
job.errorMessage
|
|
@@ -2279,7 +3617,23 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2279
3617
|
) : null
|
|
2280
3618
|
] })
|
|
2281
3619
|
] }) }) }, job._id)) }) }) })
|
|
2282
|
-
] })
|
|
3620
|
+
] }),
|
|
3621
|
+
openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3622
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
|
|
3623
|
+
/* @__PURE__ */ jsxs(Inline, { space: 4, children: [
|
|
3624
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3625
|
+
"\u{1F4CA} Total Tokens: ",
|
|
3626
|
+
/* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
|
|
3627
|
+
] }),
|
|
3628
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3629
|
+
"\u{1F4B0} Total Cost: ",
|
|
3630
|
+
/* @__PURE__ */ jsxs("strong", { children: [
|
|
3631
|
+
"$",
|
|
3632
|
+
openaiStats.totalCost.toFixed(4)
|
|
3633
|
+
] })
|
|
3634
|
+
] })
|
|
3635
|
+
] })
|
|
3636
|
+
] }) })
|
|
2283
3637
|
] });
|
|
2284
3638
|
}, ProgressBar = ({ progress, max = 100 }) => {
|
|
2285
3639
|
const percentage = Math.min(progress / max * 100, 100);
|
|
@@ -2338,7 +3692,18 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2338
3692
|
content: /* @__PURE__ */ jsx(Box, { padding: 1, children: /* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "This translation failed. Check the error message for details." }) }),
|
|
2339
3693
|
children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
|
|
2340
3694
|
}
|
|
2341
|
-
),
|
|
3695
|
+
), getStatusLabel = (status) => ({
|
|
3696
|
+
idle: "Idle",
|
|
3697
|
+
queued: "Queued...",
|
|
3698
|
+
preparing_translation: "Preparing translation...",
|
|
3699
|
+
translating: "Translating with Sanity Agent...",
|
|
3700
|
+
translating_with_openai: "Translating with GPT-5...",
|
|
3701
|
+
reviewing_with_openai: "Reviewing translation with GPT-5...",
|
|
3702
|
+
updating_metadata: "Saving metadata...",
|
|
3703
|
+
error: "Error",
|
|
3704
|
+
skipped: "Skipped",
|
|
3705
|
+
completed: "Completed"
|
|
3706
|
+
})[status] || status, STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
|
|
2342
3707
|
"translating",
|
|
2343
3708
|
"linking_references",
|
|
2344
3709
|
"completed"
|