@braze/sanity-plugin-ai-translation 0.2.0 → 0.2.1
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 +1859 -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++;
|
|
1813
|
+
}), hasChanges = !0);
|
|
1359
1814
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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));
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages) => {
|
|
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,441 @@ 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.forEach((spanNode, idx) => {
|
|
2182
|
+
const childItem = children[idx];
|
|
2183
|
+
spanNode.type === "span" && childItem && applySpanChanges(childItem, spanNode);
|
|
2184
|
+
});
|
|
2185
|
+
}, applySpanChanges = (span, spanNode) => {
|
|
2186
|
+
if (spanNode.attributes._key && span._key !== spanNode.attributes._key) {
|
|
2187
|
+
devLog.warn("Span key mismatch, skipping", {
|
|
2188
|
+
expected: span._key,
|
|
2189
|
+
found: spanNode.attributes._key,
|
|
2190
|
+
component: "deserializeDocument"
|
|
2191
|
+
});
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
spanNode.text !== void 0 && (span.text = spanNode.text);
|
|
2195
|
+
}, serializeDocument = (doc) => {
|
|
2196
|
+
const lines = [], serialize = (value, path = [], indent = 0) => {
|
|
2197
|
+
const indentStr = " ".repeat(indent);
|
|
2198
|
+
if (path.length === 0) {
|
|
2199
|
+
if (isPlainObject(value))
|
|
2200
|
+
for (const [key, val] of Object.entries(value))
|
|
2201
|
+
!key.startsWith("_") && key !== "language" && serialize(val, [key], indent);
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
const fieldName = path[path.length - 1];
|
|
2205
|
+
if (Array.isArray(value)) {
|
|
2206
|
+
value.some((item) => item?._type === "block") ? (lines.push(`${indentStr}<field name="${fieldName}">`), value.forEach((block, idx) => {
|
|
2207
|
+
serializeBlock(block, [...path, idx], indent + 1, lines);
|
|
2208
|
+
}), lines.push(`${indentStr}</field>`)) : (lines.push(`${indentStr}<field name="${fieldName}" type="array">`), value.forEach((item, idx) => {
|
|
2209
|
+
serialize(item, [...path, idx], indent + 1);
|
|
2210
|
+
}), lines.push(`${indentStr}</field>`));
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (isPlainObject(value)) {
|
|
2214
|
+
lines.push(`${indentStr}<field name="${fieldName}" type="object">`);
|
|
2215
|
+
for (const [key, val] of Object.entries(value))
|
|
2216
|
+
key.startsWith("_") || serialize(val, [...path, key], indent + 1);
|
|
2217
|
+
lines.push(`${indentStr}</field>`);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (typeof value == "string") {
|
|
2221
|
+
value.length > 0 && lines.push(`${indentStr}<field name="${fieldName}">${escapeXml(value)}</field>`);
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
(typeof value == "number" || typeof value == "boolean") && lines.push(`${indentStr}<field name="${fieldName}" type="${typeof value}" readonly="true">${value}</field>`);
|
|
2225
|
+
};
|
|
2226
|
+
return serialize(doc), lines.join(`
|
|
2227
|
+
`);
|
|
2228
|
+
}, serializeBlock = (block, path, indent, lines) => {
|
|
2229
|
+
const indentStr = " ".repeat(indent), metadata = {};
|
|
2230
|
+
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);
|
|
2231
|
+
const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(typeof v == "string" ? v : JSON.stringify(v))}"`).join(" ");
|
|
2232
|
+
lines.push(`${indentStr}<block ${metaStr}>`), Array.isArray(block.children) && block.children.forEach((child, idx) => {
|
|
2233
|
+
serializeSpan(child, [...path, "children", idx], indent + 1, lines);
|
|
2234
|
+
}), lines.push(`${indentStr}</block>`);
|
|
2235
|
+
}, serializeSpan = (span, _path, indent, lines) => {
|
|
2236
|
+
const indentStr = " ".repeat(indent);
|
|
2237
|
+
if (!span || typeof span != "object") return;
|
|
2238
|
+
const metadata = {};
|
|
2239
|
+
span._type && (metadata._type = span._type), span._key && (metadata._key = span._key), span.marks && Array.isArray(span.marks) && (metadata.marks = span.marks.join(","));
|
|
2240
|
+
const metaStr = Object.entries(metadata).map(([k, v]) => `${k}="${escapeXml(String(v))}"`).join(" "), text = typeof span.text == "string" ? span.text : "";
|
|
2241
|
+
metaStr ? lines.push(`${indentStr}<span ${metaStr}>${escapeXml(text)}</span>`) : lines.push(`${indentStr}<span>${escapeXml(text)}</span>`);
|
|
2242
|
+
}, 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) => {
|
|
2243
|
+
const errors = [], criticalFields = ["_id", "_type", "_rev", "_createdAt", "_updatedAt"];
|
|
2244
|
+
for (const field of criticalFields)
|
|
2245
|
+
original[field] !== modified[field] && errors.push({
|
|
2246
|
+
path: [field],
|
|
2247
|
+
expected: original[field],
|
|
2248
|
+
actual: modified[field],
|
|
2249
|
+
type: "metadata_changed"
|
|
2250
|
+
});
|
|
2251
|
+
return validateStructureRecursive(original, modified, [], errors), {
|
|
2252
|
+
valid: errors.length === 0,
|
|
2253
|
+
errors
|
|
2254
|
+
};
|
|
2255
|
+
}, validateStructureRecursive = (original, modified, path, errors) => {
|
|
2256
|
+
if (typeof original != typeof modified) {
|
|
2257
|
+
errors.push({
|
|
2258
|
+
path,
|
|
2259
|
+
expected: typeof original,
|
|
2260
|
+
actual: typeof modified,
|
|
2261
|
+
type: "type_mismatch"
|
|
2262
|
+
});
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
if (Array.isArray(original)) {
|
|
2266
|
+
if (!Array.isArray(modified)) {
|
|
2267
|
+
errors.push({
|
|
2268
|
+
path,
|
|
2269
|
+
expected: "array",
|
|
2270
|
+
actual: typeof modified,
|
|
2271
|
+
type: "type_mismatch"
|
|
2272
|
+
});
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
if (original.length !== modified.length) {
|
|
2276
|
+
errors.push({
|
|
2277
|
+
path,
|
|
2278
|
+
expected: `length ${original.length}`,
|
|
2279
|
+
actual: `length ${modified.length}`,
|
|
2280
|
+
type: "type_mismatch"
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
original.forEach((item, idx) => {
|
|
2285
|
+
validateStructureRecursive(item, modified[idx], [...path, String(idx)], errors);
|
|
2286
|
+
});
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
if (typeof original == "object" && original !== null && typeof modified == "object" && modified !== null) {
|
|
2290
|
+
const origKeys = Object.keys(original).sort(), modKeys = Object.keys(modified).sort();
|
|
2291
|
+
for (const key of origKeys)
|
|
2292
|
+
key in modified || errors.push({
|
|
2293
|
+
path: [...path, key],
|
|
2294
|
+
expected: "present",
|
|
2295
|
+
actual: "missing",
|
|
2296
|
+
type: "missing"
|
|
2297
|
+
});
|
|
2298
|
+
for (const key of modKeys)
|
|
2299
|
+
key in original || errors.push({
|
|
2300
|
+
path: [...path, key],
|
|
2301
|
+
expected: "absent",
|
|
2302
|
+
actual: "present",
|
|
2303
|
+
type: "extra"
|
|
2304
|
+
});
|
|
2305
|
+
const origObj = original, modObj = modified;
|
|
2306
|
+
for (const key of origKeys)
|
|
2307
|
+
key in modObj && (key.startsWith("_") || key === "marks" || key === "style" || key === "markDefs" ? JSON.stringify(origObj[key]) !== JSON.stringify(modObj[key]) && errors.push({
|
|
2308
|
+
path: [...path, key],
|
|
2309
|
+
expected: origObj[key],
|
|
2310
|
+
actual: modObj[key],
|
|
2311
|
+
type: "metadata_changed"
|
|
2312
|
+
}) : key !== "text" && validateStructureRecursive(origObj[key], modObj[key], [...path, key], errors));
|
|
2313
|
+
}
|
|
2314
|
+
}, assembleTranslationPrompt = (context, components) => {
|
|
2315
|
+
const sections = [
|
|
2316
|
+
{
|
|
2317
|
+
id: "agentDirectives",
|
|
2318
|
+
content: buildAgentDirectivesSection(context),
|
|
2319
|
+
required: !0,
|
|
2320
|
+
priority: 0
|
|
2321
|
+
},
|
|
2322
|
+
{
|
|
2323
|
+
id: "criticalRules",
|
|
2324
|
+
content: buildCriticalRulesSection(context),
|
|
2325
|
+
required: !0,
|
|
2326
|
+
priority: 1
|
|
2327
|
+
}
|
|
2328
|
+
];
|
|
2329
|
+
components.glossaryTerms?.trim() && sections.push({
|
|
2330
|
+
id: "glossaryTerms",
|
|
2331
|
+
content: buildGlossarySection(components.glossaryTerms),
|
|
2332
|
+
required: !1,
|
|
2333
|
+
priority: 3
|
|
2334
|
+
});
|
|
2335
|
+
const styleSection = buildStyleGuideSection(components.globalPrompt, components.localePrompt);
|
|
2336
|
+
styleSection && sections.push({
|
|
2337
|
+
id: "styleGuide",
|
|
2338
|
+
content: styleSection,
|
|
2339
|
+
required: !1,
|
|
2340
|
+
priority: 4
|
|
2341
|
+
}), sections.sort((a, b) => a.priority - b.priority);
|
|
2342
|
+
let styleGuide = "";
|
|
2343
|
+
const omittedSections = [], truncatedSections = [];
|
|
2344
|
+
for (const section of sections) {
|
|
2345
|
+
const prefix = styleGuide.length > 0 ? `
|
|
2346
|
+
|
|
2347
|
+
---
|
|
2348
|
+
|
|
2349
|
+
` : "", availableCharacters = 2e3 - (styleGuide.length + prefix.length);
|
|
2350
|
+
if (availableCharacters <= 0) {
|
|
2351
|
+
if (section.required) {
|
|
2352
|
+
const truncatedContent = truncateSection(section.content, 0);
|
|
2353
|
+
truncatedContent && (styleGuide += prefix + truncatedContent, truncatedSections.push(section.id));
|
|
2354
|
+
} else
|
|
2355
|
+
omittedSections.push(section.id);
|
|
2356
|
+
continue;
|
|
2357
|
+
}
|
|
2358
|
+
if (section.content.length <= availableCharacters) {
|
|
2359
|
+
styleGuide += prefix + section.content;
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
if (section.required) {
|
|
2363
|
+
const truncatedContent = truncateSection(section.content, availableCharacters);
|
|
2364
|
+
styleGuide += prefix + truncatedContent, truncatedSections.push(section.id);
|
|
2365
|
+
} else
|
|
2366
|
+
omittedSections.push(section.id);
|
|
2367
|
+
}
|
|
2368
|
+
return {
|
|
2369
|
+
styleGuide,
|
|
2370
|
+
omittedSections,
|
|
2371
|
+
truncatedSections
|
|
2372
|
+
};
|
|
2373
|
+
}, truncateSection = (content, limit) => {
|
|
2374
|
+
if (limit <= 0)
|
|
2375
|
+
return "";
|
|
2376
|
+
if (content.length <= limit)
|
|
2377
|
+
return content;
|
|
2378
|
+
if (limit <= 1)
|
|
2379
|
+
return content.slice(0, limit);
|
|
2380
|
+
const ellipsis = "\u2026";
|
|
2381
|
+
if (limit <= ellipsis.length)
|
|
2382
|
+
return content.slice(0, limit);
|
|
2383
|
+
const sliceEnd = limit - ellipsis.length, sliced = content.slice(0, sliceEnd), trimmed = sliced.replace(/\s+\S*$/, "").trimEnd();
|
|
2384
|
+
return `${trimmed.length >= sliceEnd / 2 ? trimmed : sliced}${ellipsis}`;
|
|
2385
|
+
}, buildAgentDirectivesSection = (context) => {
|
|
2386
|
+
const { targetLanguage, sourceLanguage } = context;
|
|
2387
|
+
return `# AGENT WORKFLOW DIRECTIVES
|
|
2388
|
+
|
|
2389
|
+
- Use only the provided ${sourceLanguage || "the source language"} source; never fetch outside context.
|
|
2390
|
+
- Scan each field once, then move directly into translation work.
|
|
2391
|
+
- Plan updates before editing so document structure remains intact.
|
|
2392
|
+
- Default to medium reasoning effort and escalate only for ambiguous passages.
|
|
2393
|
+
- Output just the ${targetLanguage || "the target language"} translation and mirror the source formatting exactly.`;
|
|
2394
|
+
}, buildCriticalRulesSection = (context) => {
|
|
2395
|
+
const { sourceLanguage, targetLanguage } = context, fromLanguage = sourceLanguage || "the source language", toLanguage = targetLanguage || "the target language";
|
|
2396
|
+
return `# CRITICAL TRANSLATION RULES
|
|
2397
|
+
|
|
2398
|
+
Follow these without exception:
|
|
2399
|
+
|
|
2400
|
+
1. Translate from ${fromLanguage} into ${toLanguage}; every output token must be ${toLanguage} unless flagged as protected.
|
|
2401
|
+
2. Preserve meaning exactly\u2014no additions, deletions, or tone shifts.
|
|
2402
|
+
3. Maintain all layout, markdown, rich-text marks, and data structure.
|
|
2403
|
+
4. Never invent metadata or field values that are absent in the source.
|
|
2404
|
+
5. Leave technical tokens (code, IDs, filenames) untouched.
|
|
2405
|
+
6. Keep punctuation, casing, and spacing unless ${toLanguage} grammar forces a change.`;
|
|
2406
|
+
}, buildGlossarySection = (glossaryTerms) => `# TERMINOLOGY RULES
|
|
2407
|
+
|
|
2408
|
+
Apply these mappings immediately after protected phrases and before style preferences.
|
|
2409
|
+
|
|
2410
|
+
${glossaryTerms}`, buildStyleGuideSection = (globalPrompt, localePrompt) => {
|
|
2411
|
+
const components = [];
|
|
2412
|
+
return (globalPrompt || localePrompt) && (components.push("# TRANSLATION STYLE GUIDE (LOWEST PRIORITY)"), components.push(
|
|
2413
|
+
"Apply these directions only after honoring critical rules, protected phrases, and glossary terminology."
|
|
2414
|
+
), 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(`
|
|
2415
|
+
`) : null;
|
|
1531
2416
|
}, translateEntireDocument = async ({
|
|
1532
2417
|
dispatch,
|
|
1533
2418
|
client,
|
|
@@ -1535,17 +2420,175 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1535
2420
|
sourceDocument,
|
|
1536
2421
|
targetLanguage,
|
|
1537
2422
|
languageFieldName,
|
|
1538
|
-
translationTarget,
|
|
2423
|
+
translationTarget,
|
|
2424
|
+
translationPrompts,
|
|
2425
|
+
translationMode,
|
|
2426
|
+
openaiConfig,
|
|
2427
|
+
releaseId,
|
|
2428
|
+
isNewTranslation
|
|
2429
|
+
}) => {
|
|
2430
|
+
const logContext = {
|
|
2431
|
+
documentId: sourceDocument._id,
|
|
2432
|
+
targetLanguage,
|
|
2433
|
+
schemaId,
|
|
2434
|
+
operation: translationTarget.operation,
|
|
2435
|
+
targetId: translationTarget._id,
|
|
2436
|
+
translationMode,
|
|
2437
|
+
releaseId,
|
|
2438
|
+
isNewTranslation
|
|
2439
|
+
};
|
|
2440
|
+
devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
|
|
2441
|
+
try {
|
|
2442
|
+
let versionedDocumentId, baseDocumentId;
|
|
2443
|
+
if (isNewTranslation) {
|
|
2444
|
+
const { _id, _createdAt, _updatedAt, _rev, ...docWithoutMeta } = sourceDocument, newTranslationDoc = {
|
|
2445
|
+
...docWithoutMeta,
|
|
2446
|
+
_type: sourceDocument._type,
|
|
2447
|
+
[languageFieldName]: targetLanguage
|
|
2448
|
+
};
|
|
2449
|
+
devLog.info("Creating new translation document in release", logContext), baseDocumentId = translationTarget._id || `new-translation-${Date.now()}`, await client.action({
|
|
2450
|
+
actionType: "sanity.action.document.version.create",
|
|
2451
|
+
publishedId: baseDocumentId,
|
|
2452
|
+
document: {
|
|
2453
|
+
...newTranslationDoc,
|
|
2454
|
+
_type: sourceDocument._type,
|
|
2455
|
+
_id: `versions.${releaseId}.${baseDocumentId}`
|
|
2456
|
+
}
|
|
2457
|
+
}), versionedDocumentId = `versions.${releaseId}.${baseDocumentId}`, devLog.info("New translation version created in release", {
|
|
2458
|
+
...logContext,
|
|
2459
|
+
versionedDocumentId,
|
|
2460
|
+
baseDocumentId
|
|
2461
|
+
});
|
|
2462
|
+
} else {
|
|
2463
|
+
const existingTranslationId = translationTarget._id;
|
|
2464
|
+
if (!existingTranslationId)
|
|
2465
|
+
throw new Error("Translation target ID not found for update operation");
|
|
2466
|
+
devLog.info("Creating version of existing translation in release", {
|
|
2467
|
+
...logContext,
|
|
2468
|
+
existingTranslationId
|
|
2469
|
+
});
|
|
2470
|
+
const existingDoc = await client.getDocument(existingTranslationId);
|
|
2471
|
+
if (!existingDoc)
|
|
2472
|
+
throw new Error(`Existing translation document not found: ${existingTranslationId}`);
|
|
2473
|
+
await client.action({
|
|
2474
|
+
actionType: "sanity.action.document.version.create",
|
|
2475
|
+
publishedId: existingTranslationId,
|
|
2476
|
+
document: {
|
|
2477
|
+
...existingDoc,
|
|
2478
|
+
_id: `versions.${releaseId}.${existingTranslationId}`
|
|
2479
|
+
}
|
|
2480
|
+
}), versionedDocumentId = `versions.${releaseId}.${existingTranslationId}`, baseDocumentId = existingTranslationId, devLog.info("Existing translation version created", {
|
|
2481
|
+
...logContext,
|
|
2482
|
+
versionedDocumentId
|
|
2483
|
+
});
|
|
2484
|
+
const { _id, _createdAt, _updatedAt, _rev, ...sourceContent } = sourceDocument;
|
|
2485
|
+
await client.createOrReplace({
|
|
2486
|
+
...sourceContent,
|
|
2487
|
+
_id: versionedDocumentId,
|
|
2488
|
+
[languageFieldName]: targetLanguage
|
|
2489
|
+
}), devLog.info("Source content cloned onto version", {
|
|
2490
|
+
...logContext,
|
|
2491
|
+
versionedDocumentId
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
let translatedDocument;
|
|
2495
|
+
switch (translationMode) {
|
|
2496
|
+
case "openai-only": {
|
|
2497
|
+
if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
|
|
2498
|
+
throw new Error("OpenAI is not configured but openai-only mode was selected");
|
|
2499
|
+
devLog.info("Using OpenAI-only translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating_with_openai");
|
|
2500
|
+
const versionedDoc = await client.getDocument(versionedDocumentId);
|
|
2501
|
+
if (!versionedDoc)
|
|
2502
|
+
throw new Error(`Versioned document not found: ${versionedDocumentId}`);
|
|
2503
|
+
const translatedDoc = await translateInPlaceWithOpenAI({
|
|
2504
|
+
dispatch,
|
|
2505
|
+
sourceDocument,
|
|
2506
|
+
targetDocument: versionedDoc,
|
|
2507
|
+
targetLanguage,
|
|
2508
|
+
languageFieldName,
|
|
2509
|
+
translationPrompts,
|
|
2510
|
+
openaiConfig,
|
|
2511
|
+
isReview: !1
|
|
2512
|
+
});
|
|
2513
|
+
translatedDoc && (translatedDocument = await client.createOrReplace({
|
|
2514
|
+
...translatedDoc,
|
|
2515
|
+
_id: versionedDocumentId
|
|
2516
|
+
}));
|
|
2517
|
+
break;
|
|
2518
|
+
}
|
|
2519
|
+
case "sanity-with-review":
|
|
2520
|
+
if (!openaiConfig?.enabled || !openaiConfig?.apiKey)
|
|
2521
|
+
throw new Error("OpenAI is not configured but sanity-with-review mode was selected");
|
|
2522
|
+
if (devLog.info("Using Sanity + OpenAI review", logContext), translatedDocument = await translateWithSanityAgent({
|
|
2523
|
+
dispatch,
|
|
2524
|
+
client,
|
|
2525
|
+
schemaId,
|
|
2526
|
+
sourceDocument,
|
|
2527
|
+
targetLanguage,
|
|
2528
|
+
languageFieldName,
|
|
2529
|
+
versionedDocumentId,
|
|
2530
|
+
translationPrompts
|
|
2531
|
+
}), translatedDocument) {
|
|
2532
|
+
setJobStatus(dispatch, sourceDocument._id, targetLanguage, "reviewing_with_openai");
|
|
2533
|
+
const reviewedDoc = await translateInPlaceWithOpenAI({
|
|
2534
|
+
dispatch,
|
|
2535
|
+
sourceDocument,
|
|
2536
|
+
targetDocument: translatedDocument,
|
|
2537
|
+
targetLanguage,
|
|
2538
|
+
languageFieldName,
|
|
2539
|
+
translationPrompts,
|
|
2540
|
+
openaiConfig,
|
|
2541
|
+
isReview: !0
|
|
2542
|
+
});
|
|
2543
|
+
reviewedDoc && (translatedDocument = await client.createOrReplace({
|
|
2544
|
+
...reviewedDoc,
|
|
2545
|
+
_id: versionedDocumentId
|
|
2546
|
+
}));
|
|
2547
|
+
}
|
|
2548
|
+
break;
|
|
2549
|
+
default:
|
|
2550
|
+
devLog.info("Using Sanity-only translation", logContext), translatedDocument = await translateWithSanityAgent({
|
|
2551
|
+
dispatch,
|
|
2552
|
+
client,
|
|
2553
|
+
schemaId,
|
|
2554
|
+
sourceDocument,
|
|
2555
|
+
targetLanguage,
|
|
2556
|
+
languageFieldName,
|
|
2557
|
+
versionedDocumentId,
|
|
2558
|
+
translationPrompts
|
|
2559
|
+
});
|
|
2560
|
+
break;
|
|
2561
|
+
}
|
|
2562
|
+
return devLog.timeEnd("translateEntireDocument"), devLog.groupEnd(), translatedDocument ? {
|
|
2563
|
+
...translatedDocument,
|
|
2564
|
+
_id: baseDocumentId
|
|
2565
|
+
} : void 0;
|
|
2566
|
+
} catch (error) {
|
|
2567
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2568
|
+
devLog.error(
|
|
2569
|
+
"translateEntireDocument failed",
|
|
2570
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
2571
|
+
logContext
|
|
2572
|
+
), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
|
|
2573
|
+
errorMessage
|
|
2574
|
+
}), devLog.timeEnd("translateEntireDocument"), devLog.groupEnd();
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
}, translateWithSanityAgent = async ({
|
|
2578
|
+
dispatch,
|
|
2579
|
+
client,
|
|
2580
|
+
schemaId,
|
|
2581
|
+
sourceDocument,
|
|
2582
|
+
targetLanguage,
|
|
2583
|
+
languageFieldName,
|
|
2584
|
+
versionedDocumentId,
|
|
1539
2585
|
translationPrompts
|
|
1540
2586
|
}) => {
|
|
1541
2587
|
const logContext = {
|
|
1542
2588
|
documentId: sourceDocument._id,
|
|
1543
2589
|
targetLanguage,
|
|
1544
|
-
|
|
1545
|
-
operation: translationTarget.operation,
|
|
1546
|
-
targetId: translationTarget._id
|
|
2590
|
+
versionedDocumentId
|
|
1547
2591
|
};
|
|
1548
|
-
devLog.group("translateEntireDocument", logContext), devLog.time("translateEntireDocument");
|
|
1549
2592
|
try {
|
|
1550
2593
|
const { toLanguage, fromLanguage } = assembleLanguageSpecs({
|
|
1551
2594
|
sourceDocument,
|
|
@@ -1557,104 +2600,263 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1557
2600
|
toLanguage,
|
|
1558
2601
|
fromLanguage
|
|
1559
2602
|
});
|
|
1560
|
-
|
|
1561
|
-
|
|
2603
|
+
const documentAnalysis = analyzeDocumentContent(sourceDocument);
|
|
2604
|
+
devLog.debug("Document analysis completed", {
|
|
1562
2605
|
...logContext,
|
|
1563
|
-
|
|
1564
|
-
}),
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2606
|
+
analysis: documentAnalysis
|
|
2607
|
+
}), devLog.info("Starting Sanity agent translation", logContext), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "translating");
|
|
2608
|
+
const { styleGuide, omittedSections, truncatedSections } = assembleTranslationPrompt(
|
|
2609
|
+
{
|
|
2610
|
+
documentAnalysis,
|
|
2611
|
+
isUpdate: !1,
|
|
2612
|
+
// Always fresh translation in a version
|
|
2613
|
+
targetLanguage,
|
|
2614
|
+
sourceLanguage: sourceDocument[languageFieldName]
|
|
2615
|
+
},
|
|
2616
|
+
{
|
|
2617
|
+
globalPrompt: translationPrompts.globalPrompt,
|
|
2618
|
+
localePrompt: translationPrompts.localeInstructions[targetLanguage]?.prompt,
|
|
2619
|
+
glossaryTerms: translationPrompts.localeInstructions[targetLanguage]?.glossaryTerms,
|
|
2620
|
+
protectedPhrases: translationPrompts.protectedPhrases
|
|
2621
|
+
}
|
|
2622
|
+
);
|
|
2623
|
+
devLog.info("Structured style guide generated", {
|
|
1569
2624
|
...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", {
|
|
2625
|
+
styleGuideLength: styleGuide.length,
|
|
2626
|
+
omittedSections,
|
|
2627
|
+
truncatedSections
|
|
2628
|
+
}), (omittedSections.length > 0 || truncatedSections.length > 0) && devLog.warn("Style guide adjusted to fit character limit", {
|
|
1581
2629
|
...logContext,
|
|
1582
|
-
|
|
2630
|
+
omittedSections,
|
|
2631
|
+
truncatedSections
|
|
1583
2632
|
});
|
|
1584
2633
|
const newDocument = await client.agent.action.translate({
|
|
1585
2634
|
schemaId,
|
|
1586
2635
|
toLanguage,
|
|
1587
2636
|
fromLanguage,
|
|
1588
|
-
documentId:
|
|
1589
|
-
|
|
2637
|
+
documentId: versionedDocumentId,
|
|
2638
|
+
// Use versioned document ID
|
|
1590
2639
|
languageFieldPath: [languageFieldName],
|
|
1591
2640
|
styleGuide,
|
|
1592
|
-
styleGuideParams: {
|
|
1593
|
-
extantTranslation
|
|
1594
|
-
},
|
|
1595
2641
|
protectedPhrases: translationPrompts.protectedPhrases || [],
|
|
1596
2642
|
conditionalPaths: {
|
|
1597
2643
|
defaultHidden: !1,
|
|
1598
2644
|
defaultReadOnly: !1
|
|
1599
2645
|
}
|
|
1600
2646
|
});
|
|
1601
|
-
return devLog.info("
|
|
2647
|
+
return devLog.info("Sanity agent translation completed", {
|
|
1602
2648
|
...logContext,
|
|
1603
2649
|
newDocumentId: newDocument._id,
|
|
1604
2650
|
newDocumentRev: newDocument._rev
|
|
1605
|
-
}),
|
|
2651
|
+
}), newDocument;
|
|
1606
2652
|
} catch (error) {
|
|
1607
2653
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1608
2654
|
devLog.error(
|
|
1609
|
-
"
|
|
2655
|
+
"Sanity agent translation failed",
|
|
1610
2656
|
error instanceof Error ? error : new Error(String(error)),
|
|
1611
2657
|
logContext
|
|
1612
2658
|
), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "error", {
|
|
1613
2659
|
errorMessage
|
|
1614
|
-
})
|
|
2660
|
+
});
|
|
1615
2661
|
return;
|
|
1616
2662
|
}
|
|
1617
|
-
},
|
|
1618
|
-
|
|
1619
|
-
targetLanguage,
|
|
2663
|
+
}, translateInPlaceWithOpenAI = async ({
|
|
2664
|
+
dispatch,
|
|
1620
2665
|
sourceDocument,
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2666
|
+
targetDocument,
|
|
2667
|
+
targetLanguage,
|
|
2668
|
+
languageFieldName,
|
|
2669
|
+
translationPrompts,
|
|
2670
|
+
openaiConfig,
|
|
2671
|
+
isReview
|
|
1625
2672
|
}) => {
|
|
2673
|
+
const sourceLanguage = sourceDocument[languageFieldName], logContext = {
|
|
2674
|
+
sourceId: sourceDocument._id,
|
|
2675
|
+
targetId: targetDocument._id,
|
|
2676
|
+
targetLanguage,
|
|
2677
|
+
sourceLanguage,
|
|
2678
|
+
isReview
|
|
2679
|
+
};
|
|
1626
2680
|
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,
|
|
2681
|
+
const sourceText = serializeDocument(sourceDocument), targetText = serializeDocument(targetDocument);
|
|
2682
|
+
devLog.debug("Documents serialized for OpenAI", {
|
|
2683
|
+
...logContext,
|
|
2684
|
+
sourceLength: sourceText.length,
|
|
2685
|
+
targetLength: targetText.length
|
|
2686
|
+
});
|
|
2687
|
+
const prompt = isReview ? buildReviewPrompt({
|
|
2688
|
+
sourceText,
|
|
2689
|
+
translatedText: targetText,
|
|
1644
2690
|
sourceLanguage,
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2691
|
+
targetLanguage,
|
|
2692
|
+
translationPrompts
|
|
2693
|
+
}) : buildTranslationPrompt({
|
|
2694
|
+
sourceText,
|
|
2695
|
+
sourceLanguage,
|
|
2696
|
+
targetLanguage,
|
|
2697
|
+
translationPrompts
|
|
2698
|
+
});
|
|
2699
|
+
devLog.debug("Calling GPT-5", {
|
|
2700
|
+
...logContext,
|
|
2701
|
+
promptLength: prompt.length
|
|
2702
|
+
});
|
|
2703
|
+
const response = await callGPT5(openaiConfig.apiKey, {
|
|
2704
|
+
messages: [
|
|
2705
|
+
{
|
|
2706
|
+
role: "system",
|
|
2707
|
+
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."
|
|
2708
|
+
},
|
|
2709
|
+
{ role: "user", content: prompt }
|
|
2710
|
+
],
|
|
2711
|
+
model: openaiConfig.model,
|
|
2712
|
+
reasoningEffort: openaiConfig.reasoningEffort,
|
|
2713
|
+
verbosity: openaiConfig.verbosity,
|
|
2714
|
+
onReasoningChunk: (reasoning) => {
|
|
2715
|
+
const status2 = isReview ? "reviewing_with_openai" : "translating_with_openai";
|
|
2716
|
+
setJobStatus(dispatch, sourceDocument._id, targetLanguage, status2, {
|
|
2717
|
+
reasoningProgress: reasoning
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
});
|
|
2721
|
+
devLog.info("OpenAI operation completed", {
|
|
2722
|
+
...logContext,
|
|
2723
|
+
tokensUsed: response.tokensUsed.total,
|
|
2724
|
+
cost: response.cost
|
|
2725
|
+
});
|
|
2726
|
+
const result = deserializeDocument(targetDocument, response.content), validation = validateStructureIntegrity(targetDocument, result);
|
|
2727
|
+
if (!validation.valid) {
|
|
2728
|
+
devLog.error("Structure validation failed", {
|
|
2729
|
+
...logContext,
|
|
2730
|
+
errors: validation.errors
|
|
2731
|
+
});
|
|
2732
|
+
const unexpectedErrors = validation.errors.filter(
|
|
2733
|
+
(err) => !(err.path.length === 1 && err.path[0] === languageFieldName)
|
|
2734
|
+
);
|
|
2735
|
+
if (unexpectedErrors.length > 0)
|
|
2736
|
+
throw new Error(
|
|
2737
|
+
`Structure validation failed: ${unexpectedErrors.length} errors. First: ${unexpectedErrors[0].type} at ${unexpectedErrors[0].path.join(".")}`
|
|
2738
|
+
);
|
|
2739
|
+
}
|
|
2740
|
+
devLog.info("Structure validation passed", logContext);
|
|
2741
|
+
const status = isReview ? "reviewing_with_openai" : "translating_with_openai";
|
|
2742
|
+
return setJobStatus(dispatch, sourceDocument._id, targetLanguage, status, {
|
|
2743
|
+
tokensUsed: response.tokensUsed.total,
|
|
2744
|
+
estimatedCost: response.cost,
|
|
2745
|
+
reasoningSummary: response.reasoningSummary
|
|
2746
|
+
}), result;
|
|
1655
2747
|
} catch (error) {
|
|
1656
|
-
throw
|
|
2748
|
+
throw devLog.error("OpenAI translation failed", error instanceof Error ? error : new Error(String(error)), logContext), error;
|
|
1657
2749
|
}
|
|
2750
|
+
}, buildTranslationPrompt = ({
|
|
2751
|
+
sourceText,
|
|
2752
|
+
sourceLanguage,
|
|
2753
|
+
targetLanguage,
|
|
2754
|
+
translationPrompts
|
|
2755
|
+
}) => {
|
|
2756
|
+
const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
|
|
2757
|
+
return `# TRANSLATION TASK
|
|
2758
|
+
|
|
2759
|
+
Translate this ${sourceLanguage} document into ${targetLanguage}.
|
|
2760
|
+
|
|
2761
|
+
## Guidelines
|
|
2762
|
+
|
|
2763
|
+
${globalPrompt ? `### General Guidelines
|
|
2764
|
+
${globalPrompt}
|
|
2765
|
+
` : ""}
|
|
2766
|
+
${localePrompt ? `### ${targetLanguage} Guidelines
|
|
2767
|
+
${localePrompt}
|
|
2768
|
+
` : ""}
|
|
2769
|
+
${glossaryTerms ? `### Required Terminology
|
|
2770
|
+
${glossaryTerms}
|
|
2771
|
+
` : ""}
|
|
2772
|
+
${protectedPhrases?.length ? `### Protected Phrases (do not translate)
|
|
2773
|
+
${protectedPhrases.join(", ")}
|
|
2774
|
+
` : ""}
|
|
2775
|
+
|
|
2776
|
+
## Source Document (${sourceLanguage})
|
|
2777
|
+
|
|
2778
|
+
\`\`\`
|
|
2779
|
+
${sourceText}
|
|
2780
|
+
\`\`\`
|
|
2781
|
+
|
|
2782
|
+
## Instructions
|
|
2783
|
+
|
|
2784
|
+
1. Translate all text content into ${targetLanguage}
|
|
2785
|
+
2. Follow the guidelines and terminology requirements above
|
|
2786
|
+
3. Preserve protected phrases exactly as they appear
|
|
2787
|
+
4. Return the document in THE EXACT SAME FORMAT
|
|
2788
|
+
5. **CRITICAL**: Only modify text content between tags
|
|
2789
|
+
6. Never add, remove, or change any tags, attributes, or structure
|
|
2790
|
+
|
|
2791
|
+
If you used significant reasoning to make translation decisions, you may include a brief summary:
|
|
2792
|
+
<reasoning>
|
|
2793
|
+
Brief summary of key translation decisions and reasoning
|
|
2794
|
+
</reasoning>
|
|
2795
|
+
|
|
2796
|
+
Then return the translated document:`;
|
|
2797
|
+
}, buildReviewPrompt = ({
|
|
2798
|
+
sourceText,
|
|
2799
|
+
translatedText,
|
|
2800
|
+
sourceLanguage,
|
|
2801
|
+
targetLanguage,
|
|
2802
|
+
translationPrompts
|
|
2803
|
+
}) => {
|
|
2804
|
+
const { globalPrompt, localeInstructions, protectedPhrases } = translationPrompts, localePrompt = localeInstructions[targetLanguage]?.prompt, glossaryTerms = localeInstructions[targetLanguage]?.glossaryTerms;
|
|
2805
|
+
return `# TRANSLATION QUALITY REVIEW
|
|
2806
|
+
|
|
2807
|
+
Review this ${targetLanguage} translation and return a corrected version.
|
|
2808
|
+
|
|
2809
|
+
## Review Criteria
|
|
2810
|
+
|
|
2811
|
+
1. **Accuracy**: Does it accurately convey the ${sourceLanguage} source meaning?
|
|
2812
|
+
2. **Terminology**: Are glossary terms used correctly?
|
|
2813
|
+
3. **Protected Phrases**: Are protected phrases preserved?
|
|
2814
|
+
4. **Fluency**: Is it natural and fluent in ${targetLanguage}?
|
|
2815
|
+
5. **Style**: Does it follow the style guide?
|
|
2816
|
+
6. **Completeness**: Is all content translated?
|
|
2817
|
+
|
|
2818
|
+
## Guidelines
|
|
2819
|
+
|
|
2820
|
+
${globalPrompt ? `### General Guidelines
|
|
2821
|
+
${globalPrompt}
|
|
2822
|
+
` : ""}
|
|
2823
|
+
${localePrompt ? `### ${targetLanguage} Guidelines
|
|
2824
|
+
${localePrompt}
|
|
2825
|
+
` : ""}
|
|
2826
|
+
${glossaryTerms ? `### Required Terminology
|
|
2827
|
+
${glossaryTerms}
|
|
2828
|
+
` : ""}
|
|
2829
|
+
${protectedPhrases?.length ? `### Protected Phrases (must preserve)
|
|
2830
|
+
${protectedPhrases.join(", ")}
|
|
2831
|
+
` : ""}
|
|
2832
|
+
|
|
2833
|
+
## Source Document (${sourceLanguage})
|
|
2834
|
+
|
|
2835
|
+
\`\`\`
|
|
2836
|
+
${sourceText}
|
|
2837
|
+
\`\`\`
|
|
2838
|
+
|
|
2839
|
+
## Current Translation (${targetLanguage})
|
|
2840
|
+
|
|
2841
|
+
\`\`\`
|
|
2842
|
+
${translatedText}
|
|
2843
|
+
\`\`\`
|
|
2844
|
+
|
|
2845
|
+
## Instructions
|
|
2846
|
+
|
|
2847
|
+
1. Review the translation for quality issues
|
|
2848
|
+
2. Make corrections where needed to improve accuracy, fluency, or adherence to guidelines
|
|
2849
|
+
3. Keep good translations as-is - only fix what needs fixing
|
|
2850
|
+
4. Return the corrected translation in THE EXACT SAME FORMAT
|
|
2851
|
+
5. **CRITICAL**: Only modify text content between tags
|
|
2852
|
+
6. Never add, remove, or change any tags, attributes, or structure
|
|
2853
|
+
|
|
2854
|
+
If you made corrections or have insights about the translation quality, include a brief summary:
|
|
2855
|
+
<reasoning>
|
|
2856
|
+
Brief summary of review findings and corrections made
|
|
2857
|
+
</reasoning>
|
|
2858
|
+
|
|
2859
|
+
Then return the corrected translation document:`;
|
|
1658
2860
|
}, translateDocument = async ({
|
|
1659
2861
|
dispatch,
|
|
1660
2862
|
client,
|
|
@@ -1663,15 +2865,17 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1663
2865
|
languageFieldName,
|
|
1664
2866
|
shouldOverwriteFreshTranslations,
|
|
1665
2867
|
schemaId,
|
|
1666
|
-
|
|
1667
|
-
|
|
2868
|
+
translationPrompts,
|
|
2869
|
+
translationMode,
|
|
2870
|
+
openaiConfig,
|
|
2871
|
+
releaseId
|
|
1668
2872
|
}) => {
|
|
1669
2873
|
const logContext = {
|
|
1670
2874
|
documentId: sourceDocument._id,
|
|
1671
2875
|
targetLanguage,
|
|
1672
2876
|
schemaId,
|
|
1673
|
-
|
|
1674
|
-
|
|
2877
|
+
shouldOverwriteFreshTranslations,
|
|
2878
|
+
translationMode
|
|
1675
2879
|
};
|
|
1676
2880
|
devLog.group("translateDocument", logContext), devLog.time("translateDocument"), setJobStatus(dispatch, sourceDocument._id, targetLanguage, "preparing_translation");
|
|
1677
2881
|
try {
|
|
@@ -1711,48 +2915,20 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1711
2915
|
operation: translationTarget.operation,
|
|
1712
2916
|
isNewTranslation
|
|
1713
2917
|
});
|
|
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
|
-
});
|
|
2918
|
+
const newDocument = await translateEntireDocument({
|
|
2919
|
+
dispatch,
|
|
2920
|
+
client,
|
|
2921
|
+
schemaId,
|
|
2922
|
+
sourceDocument,
|
|
2923
|
+
targetLanguage,
|
|
2924
|
+
languageFieldName,
|
|
2925
|
+
translationTarget,
|
|
2926
|
+
translationPrompts,
|
|
2927
|
+
translationMode,
|
|
2928
|
+
openaiConfig,
|
|
2929
|
+
releaseId,
|
|
2930
|
+
isNewTranslation
|
|
2931
|
+
});
|
|
1756
2932
|
if (!newDocument)
|
|
1757
2933
|
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
2934
|
return devLog.debug("Translation document created", {
|
|
@@ -1779,30 +2955,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1779
2955
|
}), devLog.timeEnd("translateDocument"), devLog.groupEnd();
|
|
1780
2956
|
return;
|
|
1781
2957
|
}
|
|
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
2958
|
}, translationSemaphore = new Semaphore(2), translateDocumentSet = async ({
|
|
1807
2959
|
client,
|
|
1808
2960
|
sourceDocument,
|
|
@@ -1813,17 +2965,19 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1813
2965
|
schemaId,
|
|
1814
2966
|
references = [],
|
|
1815
2967
|
maxConcurrentTranslationJobs,
|
|
1816
|
-
|
|
2968
|
+
translationMode,
|
|
2969
|
+
openaiConfig
|
|
1817
2970
|
}) => {
|
|
1818
2971
|
const logContext = {
|
|
1819
2972
|
documentId: sourceDocument._id,
|
|
1820
2973
|
targetLanguages,
|
|
1821
2974
|
referencesCount: references.length,
|
|
1822
2975
|
maxConcurrentJobs: maxConcurrentTranslationJobs,
|
|
1823
|
-
|
|
2976
|
+
translationMode,
|
|
1824
2977
|
shouldOverwriteFreshTranslations
|
|
1825
2978
|
};
|
|
1826
2979
|
devLog.group("translateDocumentSet", logContext), devLog.time("translateDocumentSet");
|
|
2980
|
+
let releaseId;
|
|
1827
2981
|
try {
|
|
1828
2982
|
const sourceLanguage = sourceDocument[languageFieldName];
|
|
1829
2983
|
if (!sourceLanguage)
|
|
@@ -1835,6 +2989,22 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1835
2989
|
type: "SET_OVERALL_STATUS",
|
|
1836
2990
|
payload: "translating"
|
|
1837
2991
|
});
|
|
2992
|
+
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()}`;
|
|
2993
|
+
devLog.info("Creating Content Release for translation batch", {
|
|
2994
|
+
...logContext,
|
|
2995
|
+
releaseTitle
|
|
2996
|
+
});
|
|
2997
|
+
const { releaseId: newReleaseId } = await client.releases.create({
|
|
2998
|
+
metadata: {
|
|
2999
|
+
title: releaseTitle,
|
|
3000
|
+
description: releaseDescription,
|
|
3001
|
+
releaseType: "asap"
|
|
3002
|
+
}
|
|
3003
|
+
});
|
|
3004
|
+
releaseId = newReleaseId, devLog.info("Content Release created", {
|
|
3005
|
+
...logContext,
|
|
3006
|
+
releaseId
|
|
3007
|
+
});
|
|
1838
3008
|
const sourceDocuments = [
|
|
1839
3009
|
{
|
|
1840
3010
|
_id: sourceDocument._id,
|
|
@@ -1866,7 +3036,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1866
3036
|
}).fetch("*[_id == $id][0]", {
|
|
1867
3037
|
id: documentId
|
|
1868
3038
|
});
|
|
1869
|
-
if (document) {
|
|
3039
|
+
if (document && releaseId) {
|
|
1870
3040
|
const targetDocumentId = await translateDocument({
|
|
1871
3041
|
dispatch,
|
|
1872
3042
|
client,
|
|
@@ -1875,8 +3045,10 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1875
3045
|
languageFieldName,
|
|
1876
3046
|
shouldOverwriteFreshTranslations,
|
|
1877
3047
|
schemaId,
|
|
1878
|
-
|
|
1879
|
-
|
|
3048
|
+
translationPrompts,
|
|
3049
|
+
translationMode,
|
|
3050
|
+
openaiConfig,
|
|
3051
|
+
releaseId
|
|
1880
3052
|
});
|
|
1881
3053
|
targetDocumentId && targetDocumentIds.push(targetDocumentId);
|
|
1882
3054
|
}
|
|
@@ -1924,11 +3096,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1924
3096
|
failed
|
|
1925
3097
|
}), 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
3098
|
} catch (error) {
|
|
1927
|
-
|
|
3099
|
+
if (devLog.error(
|
|
1928
3100
|
"translateDocumentSet failed with unexpected error",
|
|
1929
3101
|
error instanceof Error ? error : new Error(String(error)),
|
|
1930
3102
|
logContext
|
|
1931
|
-
),
|
|
3103
|
+
), releaseId)
|
|
3104
|
+
try {
|
|
3105
|
+
await client.releases.delete({ releaseId }), devLog.warn("Deleted release due to fatal error", {
|
|
3106
|
+
...logContext,
|
|
3107
|
+
releaseId
|
|
3108
|
+
});
|
|
3109
|
+
} catch (cleanupError) {
|
|
3110
|
+
devLog.error(
|
|
3111
|
+
"Failed to cleanup release",
|
|
3112
|
+
cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)),
|
|
3113
|
+
logContext
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
3116
|
+
throw devLog.timeEnd("translateDocumentSet"), devLog.groupEnd(), dispatch({ type: "SET_OVERALL_STATUS", payload: "error" }), error;
|
|
1932
3117
|
}
|
|
1933
3118
|
}, ActionButtonBar = ({ setIsDialogOpen }) => {
|
|
1934
3119
|
const {
|
|
@@ -1939,22 +3124,50 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1939
3124
|
references,
|
|
1940
3125
|
selectedTargetLanguages,
|
|
1941
3126
|
shouldOverwriteFreshTranslations,
|
|
1942
|
-
|
|
1943
|
-
} = useTranslationContext(), client = useClient({ apiVersion: "vX" })
|
|
3127
|
+
translationMode
|
|
3128
|
+
} = useTranslationContext(), client = useClient({ apiVersion: "vX" }), handleTranslate = useCallback(() => {
|
|
3129
|
+
translateDocumentSet({
|
|
3130
|
+
sourceDocument: document,
|
|
3131
|
+
targetLanguages: selectedTargetLanguages,
|
|
3132
|
+
client,
|
|
3133
|
+
dispatch,
|
|
3134
|
+
languageFieldName: config.languageFieldName,
|
|
3135
|
+
schemaId: config.schemaId,
|
|
3136
|
+
references,
|
|
3137
|
+
shouldOverwriteFreshTranslations,
|
|
3138
|
+
maxConcurrentTranslationJobs: config.maxConcurrentTranslationJobs,
|
|
3139
|
+
maxReferenceDepth: config.maxReferenceDepth,
|
|
3140
|
+
translationMode,
|
|
3141
|
+
openaiConfig: config.openai
|
|
3142
|
+
});
|
|
3143
|
+
}, [
|
|
3144
|
+
document,
|
|
3145
|
+
selectedTargetLanguages,
|
|
3146
|
+
client,
|
|
3147
|
+
dispatch,
|
|
3148
|
+
config.languageFieldName,
|
|
3149
|
+
config.schemaId,
|
|
3150
|
+
config.maxConcurrentTranslationJobs,
|
|
3151
|
+
config.maxReferenceDepth,
|
|
3152
|
+
config.openai,
|
|
3153
|
+
references,
|
|
3154
|
+
shouldOverwriteFreshTranslations,
|
|
3155
|
+
translationMode
|
|
3156
|
+
]), handleSelectAll = useCallback(() => {
|
|
3157
|
+
dispatch({
|
|
3158
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
3159
|
+
payload: config.supportedLanguages.map((lang) => lang.id).filter((id) => id !== document.language)
|
|
3160
|
+
});
|
|
3161
|
+
}, [dispatch, config.supportedLanguages, document.language]), handleSelectNone = useCallback(() => {
|
|
3162
|
+
dispatch({
|
|
3163
|
+
type: "SET_SELECTED_TARGET_LANGUAGES",
|
|
3164
|
+
payload: []
|
|
3165
|
+
});
|
|
3166
|
+
}, [dispatch]);
|
|
1944
3167
|
return /* @__PURE__ */ jsxs(Flex, { justify: "space-between", gap: 2, children: [
|
|
1945
3168
|
/* @__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" })
|
|
3169
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectAll, children: "Select All" }),
|
|
3170
|
+
/* @__PURE__ */ jsx(Button, { mode: "ghost", size: 2, onClick: handleSelectNone, children: "Select None" })
|
|
1958
3171
|
] }),
|
|
1959
3172
|
/* @__PURE__ */ jsxs(Flex, { justify: "flex-end", gap: 2, children: [
|
|
1960
3173
|
/* @__PURE__ */ jsx(Button, { onClick: () => setIsDialogOpen(!1), mode: "ghost", size: 2, children: "Cancel" }),
|
|
@@ -1964,21 +3177,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
1964
3177
|
mode: "ghost",
|
|
1965
3178
|
tone: "positive",
|
|
1966
3179
|
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
|
-
},
|
|
3180
|
+
onClick: handleTranslate,
|
|
1982
3181
|
disabled: selectedTargetLanguages.length === 0 || !["idle", "completed"].includes(overallStatus),
|
|
1983
3182
|
children: "Translate"
|
|
1984
3183
|
}
|
|
@@ -2023,6 +3222,88 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2023
3222
|
documentId: document._id
|
|
2024
3223
|
});
|
|
2025
3224
|
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)) }) });
|
|
3225
|
+
}, TranslationModePanel = () => {
|
|
3226
|
+
const { config, translationMode, openaiValidated, dispatch } = useTranslationContext();
|
|
3227
|
+
if (!config?.openai?.enabled || !config?.openai?.apiKey)
|
|
3228
|
+
return null;
|
|
3229
|
+
if (!openaiValidated)
|
|
3230
|
+
return /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3231
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Configuration" }),
|
|
3232
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Validating API key..." })
|
|
3233
|
+
] }) });
|
|
3234
|
+
const handleModeChange = (mode) => {
|
|
3235
|
+
dispatch({
|
|
3236
|
+
type: "SET_TRANSLATION_MODE",
|
|
3237
|
+
payload: mode
|
|
3238
|
+
});
|
|
3239
|
+
};
|
|
3240
|
+
return /* @__PURE__ */ jsx(Card, { padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [
|
|
3241
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3242
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "Translation Mode" }),
|
|
3243
|
+
/* @__PURE__ */ jsx(Badge, { tone: "positive", children: "GPT-5 Available" })
|
|
3244
|
+
] }),
|
|
3245
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3246
|
+
/* @__PURE__ */ jsx(
|
|
3247
|
+
Button,
|
|
3248
|
+
{
|
|
3249
|
+
type: "button",
|
|
3250
|
+
mode: translationMode === "sanity-only" ? "ghost" : "bleed",
|
|
3251
|
+
tone: translationMode === "sanity-only" ? "primary" : "default",
|
|
3252
|
+
onClick: () => handleModeChange("sanity-only"),
|
|
3253
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3254
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-only", readOnly: !0 }),
|
|
3255
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3256
|
+
/* @__PURE__ */ jsx(Text, { children: "Sanity Agent Only" }),
|
|
3257
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Fast, reliable, and included with Sanity." })
|
|
3258
|
+
] })
|
|
3259
|
+
] })
|
|
3260
|
+
}
|
|
3261
|
+
),
|
|
3262
|
+
/* @__PURE__ */ jsx(
|
|
3263
|
+
Button,
|
|
3264
|
+
{
|
|
3265
|
+
type: "button",
|
|
3266
|
+
mode: translationMode === "openai-only" ? "ghost" : "bleed",
|
|
3267
|
+
tone: translationMode === "openai-only" ? "primary" : "default",
|
|
3268
|
+
onClick: () => handleModeChange("openai-only"),
|
|
3269
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3270
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "openai-only", readOnly: !0 }),
|
|
3271
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3272
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3273
|
+
/* @__PURE__ */ jsx(Text, { children: "GPT-5 Only" }),
|
|
3274
|
+
/* @__PURE__ */ jsx(Badge, { tone: "caution", children: "Beta" })
|
|
3275
|
+
] }),
|
|
3276
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Full style guide capacity with 1M token context window." })
|
|
3277
|
+
] })
|
|
3278
|
+
] })
|
|
3279
|
+
}
|
|
3280
|
+
),
|
|
3281
|
+
/* @__PURE__ */ jsx(
|
|
3282
|
+
Button,
|
|
3283
|
+
{
|
|
3284
|
+
type: "button",
|
|
3285
|
+
mode: translationMode === "sanity-with-review" ? "ghost" : "bleed",
|
|
3286
|
+
tone: translationMode === "sanity-with-review" ? "primary" : "default",
|
|
3287
|
+
onClick: () => handleModeChange("sanity-with-review"),
|
|
3288
|
+
children: /* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3289
|
+
/* @__PURE__ */ jsx(Radio, { checked: translationMode === "sanity-with-review", readOnly: !0 }),
|
|
3290
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3291
|
+
/* @__PURE__ */ jsxs(Inline, { space: 2, children: [
|
|
3292
|
+
/* @__PURE__ */ jsx(Text, { children: "Sanity + GPT-5 Review" }),
|
|
3293
|
+
/* @__PURE__ */ jsx(Badge, { tone: "primary", children: "Highest Quality" })
|
|
3294
|
+
] }),
|
|
3295
|
+
/* @__PURE__ */ jsx(Text, { size: 1, muted: !0, children: "Sanity translation refined by GPT-5. Takes longer but best results." })
|
|
3296
|
+
] })
|
|
3297
|
+
] })
|
|
3298
|
+
}
|
|
3299
|
+
)
|
|
3300
|
+
] }),
|
|
3301
|
+
translationMode !== "sanity-only" && /* @__PURE__ */ jsx(Card, { tone: "caution", padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3302
|
+
"\u{1F4B0} ",
|
|
3303
|
+
/* @__PURE__ */ jsx("strong", { children: "OpenAI costs apply:" }),
|
|
3304
|
+
" You'll be charged for API usage based on tokens processed."
|
|
3305
|
+
] }) })
|
|
3306
|
+
] }) });
|
|
2026
3307
|
}, assembleReferences = async ({
|
|
2027
3308
|
client,
|
|
2028
3309
|
sourceDocument,
|
|
@@ -2047,7 +3328,14 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2047
3328
|
typeof refId == "string" && refId.length > 0 && !references.has(refId) && !documentCache.has(refId) && allRefIds.add(refId);
|
|
2048
3329
|
}
|
|
2049
3330
|
} catch (error) {
|
|
2050
|
-
|
|
3331
|
+
devLog.error(
|
|
3332
|
+
"Error finding references in document",
|
|
3333
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
3334
|
+
{
|
|
3335
|
+
documentId: document._id,
|
|
3336
|
+
component: "assembleReferences"
|
|
3337
|
+
}
|
|
3338
|
+
);
|
|
2051
3339
|
}
|
|
2052
3340
|
if (allRefIds.size === 0)
|
|
2053
3341
|
break;
|
|
@@ -2066,16 +3354,27 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2066
3354
|
}
|
|
2067
3355
|
documentCache.set(refId, fetchedDoc), references.add(refId), nextDepthDocuments.push(fetchedDoc);
|
|
2068
3356
|
} else
|
|
2069
|
-
documentCache.set(refId, null),
|
|
3357
|
+
documentCache.set(refId, null), devLog.warn(`Document with _id "${refId}" not found`, {
|
|
3358
|
+
refId,
|
|
3359
|
+
component: "assembleReferences"
|
|
3360
|
+
});
|
|
2070
3361
|
}
|
|
2071
3362
|
currentDepthDocuments = nextDepthDocuments;
|
|
2072
3363
|
} catch (fetchError) {
|
|
2073
3364
|
const error = fetchError instanceof Error ? fetchError : new Error(String(fetchError));
|
|
2074
|
-
errors.push({ refId: `batch(${refIdArray.join(",")})`, error }),
|
|
3365
|
+
errors.push({ refId: `batch(${refIdArray.join(",")})`, error }), devLog.error("Failed to batch fetch documents", error, {
|
|
3366
|
+
refIdCount: refIdArray.length,
|
|
3367
|
+
depth,
|
|
3368
|
+
component: "assembleReferences"
|
|
3369
|
+
});
|
|
2075
3370
|
break;
|
|
2076
3371
|
}
|
|
2077
3372
|
}
|
|
2078
|
-
errors.length > 0 &&
|
|
3373
|
+
errors.length > 0 && devLog.warn(`Encountered ${errors.length} error(s) while fetching references`, {
|
|
3374
|
+
errorCount: errors.length,
|
|
3375
|
+
errors: errors.map((e) => ({ refId: e.refId, message: e.error.message })),
|
|
3376
|
+
component: "assembleReferences"
|
|
3377
|
+
});
|
|
2079
3378
|
const result = [];
|
|
2080
3379
|
for (const refId of references) {
|
|
2081
3380
|
const cachedDoc = documentCache.get(refId);
|
|
@@ -2160,7 +3459,7 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2160
3459
|
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
3460
|
] });
|
|
2162
3461
|
}, WriteSettingsPanel = () => {
|
|
2163
|
-
const { dispatch, shouldOverwriteFreshTranslations
|
|
3462
|
+
const { dispatch, shouldOverwriteFreshTranslations } = useTranslationContext(), handleShouldOverwriteFreshTranslationsChange = () => {
|
|
2164
3463
|
dispatch({
|
|
2165
3464
|
type: "SET_SHOULD_OVERWRITE_FRESH_TRANSLATIONS",
|
|
2166
3465
|
payload: !shouldOverwriteFreshTranslations
|
|
@@ -2191,38 +3490,6 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2191
3490
|
] })
|
|
2192
3491
|
] })
|
|
2193
3492
|
}
|
|
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
3493
|
) })
|
|
2227
3494
|
] });
|
|
2228
3495
|
}, TranslationConfigView = ({ setIsDialogOpen }) => /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
@@ -2230,16 +3497,24 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2230
3497
|
/* @__PURE__ */ jsx(Label, { children: "Languages" }),
|
|
2231
3498
|
/* @__PURE__ */ jsx(LanguageSelectionPanel, {})
|
|
2232
3499
|
] }),
|
|
3500
|
+
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3501
|
+
/* @__PURE__ */ jsx(Label, { children: "Translation Method" }),
|
|
3502
|
+
/* @__PURE__ */ jsx(TranslationModePanel, {})
|
|
3503
|
+
] }),
|
|
2233
3504
|
/* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
2234
3505
|
/* @__PURE__ */ jsx(Label, { children: "Settings" }),
|
|
2235
3506
|
/* @__PURE__ */ jsx(Card, { padding: 4, border: !0, muted: !0, children: /* @__PURE__ */ jsx(WriteSettingsPanel, {}) })
|
|
2236
3507
|
] }),
|
|
2237
3508
|
/* @__PURE__ */ jsx(ActionButtonBar, { setIsDialogOpen })
|
|
2238
3509
|
] }), TERMINAL_STATUSES = ["completed", "skipped", "error"], TranslationStatusPanel = () => {
|
|
2239
|
-
const { overallStatus, translationJobs } = useTranslationContext(), headline = useMemo(
|
|
3510
|
+
const { overallStatus, translationJobs, translationMode } = useTranslationContext(), headline = useMemo(
|
|
2240
3511
|
() => overallStatus === "completed" ? "Translation completed" : "Translation in progress",
|
|
2241
3512
|
[overallStatus]
|
|
2242
|
-
), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs])
|
|
3513
|
+
), jobs = useMemo(() => Array.from(translationJobs.values()), [translationJobs]), openaiStats = useMemo(() => {
|
|
3514
|
+
if (translationMode === "sanity-only") return null;
|
|
3515
|
+
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);
|
|
3516
|
+
return { totalTokens, totalCost, jobsWithUsage: completedJobs.filter((j) => j.tokensUsed).length };
|
|
3517
|
+
}, [jobs, translationMode]);
|
|
2243
3518
|
return /* @__PURE__ */ jsxs(Stack, { space: 4, children: [
|
|
2244
3519
|
/* @__PURE__ */ jsx(Heading, { size: 2, children: headline }),
|
|
2245
3520
|
/* @__PURE__ */ jsx(ProgressBar, { progress: jobs.filter((job) => TERMINAL_STATUSES.includes(job.status)).length, max: jobs.length }),
|
|
@@ -2259,6 +3534,25 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2259
3534
|
" \u2014 ",
|
|
2260
3535
|
job._id.slice(-6)
|
|
2261
3536
|
] }),
|
|
3537
|
+
!["completed", "skipped", "error"].includes(job.status) && /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3538
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: getStatusLabel(job.status) }),
|
|
3539
|
+
job.reasoningProgress && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 3, radius: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3540
|
+
/* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "\u{1F914} GPT-5 Reasoning" }),
|
|
3541
|
+
/* @__PURE__ */ jsx(Text, { size: 1, style: { fontStyle: "italic" }, children: job.reasoningProgress })
|
|
3542
|
+
] }) })
|
|
3543
|
+
] }),
|
|
3544
|
+
job.status === "completed" && job.tokensUsed ? /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3545
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, muted: !0, children: [
|
|
3546
|
+
job.tokensUsed.toLocaleString(),
|
|
3547
|
+
" tokens (~$",
|
|
3548
|
+
job.estimatedCost?.toFixed(4),
|
|
3549
|
+
")"
|
|
3550
|
+
] }),
|
|
3551
|
+
job.reasoningSummary && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3552
|
+
"\u{1F4A1} ",
|
|
3553
|
+
job.reasoningSummary
|
|
3554
|
+
] }) })
|
|
3555
|
+
] }) : null,
|
|
2262
3556
|
job.status === "error" && job.errorMessage ? /* @__PURE__ */ jsx(Card, { tone: "critical", padding: 2, radius: 2, children: /* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
2263
3557
|
"Error: ",
|
|
2264
3558
|
job.errorMessage
|
|
@@ -2279,7 +3573,23 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2279
3573
|
) : null
|
|
2280
3574
|
] })
|
|
2281
3575
|
] }) }) }, job._id)) }) }) })
|
|
2282
|
-
] })
|
|
3576
|
+
] }),
|
|
3577
|
+
openaiStats && openaiStats.jobsWithUsage > 0 && /* @__PURE__ */ jsx(Card, { tone: "primary", padding: 4, border: !0, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [
|
|
3578
|
+
/* @__PURE__ */ jsx(Text, { weight: "semibold", children: "OpenAI Usage Summary" }),
|
|
3579
|
+
/* @__PURE__ */ jsxs(Inline, { space: 4, children: [
|
|
3580
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3581
|
+
"\u{1F4CA} Total Tokens: ",
|
|
3582
|
+
/* @__PURE__ */ jsx("strong", { children: openaiStats.totalTokens.toLocaleString() })
|
|
3583
|
+
] }),
|
|
3584
|
+
/* @__PURE__ */ jsxs(Text, { size: 1, children: [
|
|
3585
|
+
"\u{1F4B0} Total Cost: ",
|
|
3586
|
+
/* @__PURE__ */ jsxs("strong", { children: [
|
|
3587
|
+
"$",
|
|
3588
|
+
openaiStats.totalCost.toFixed(4)
|
|
3589
|
+
] })
|
|
3590
|
+
] })
|
|
3591
|
+
] })
|
|
3592
|
+
] }) })
|
|
2283
3593
|
] });
|
|
2284
3594
|
}, ProgressBar = ({ progress, max = 100 }) => {
|
|
2285
3595
|
const percentage = Math.min(progress / max * 100, 100);
|
|
@@ -2338,7 +3648,18 @@ const setupTranslationJobStatuses = (dispatch, sourceDocuments, targetLanguages)
|
|
|
2338
3648
|
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
3649
|
children: /* @__PURE__ */ jsx(Badge, { tone: "critical", children: "Failed" })
|
|
2340
3650
|
}
|
|
2341
|
-
),
|
|
3651
|
+
), getStatusLabel = (status) => ({
|
|
3652
|
+
idle: "Idle",
|
|
3653
|
+
queued: "Queued...",
|
|
3654
|
+
preparing_translation: "Preparing translation...",
|
|
3655
|
+
translating: "Translating with Sanity Agent...",
|
|
3656
|
+
translating_with_openai: "Translating with GPT-5...",
|
|
3657
|
+
reviewing_with_openai: "Reviewing translation with GPT-5...",
|
|
3658
|
+
updating_metadata: "Saving metadata...",
|
|
3659
|
+
error: "Error",
|
|
3660
|
+
skipped: "Skipped",
|
|
3661
|
+
completed: "Completed"
|
|
3662
|
+
})[status] || status, STATES_IN_WHICH_TO_SHOW_CONFIG = ["fetching_metadata", "idle"], STATES_IN_WHICH_TO_SHOW_TRANSLATION_PROGRESS = [
|
|
2342
3663
|
"translating",
|
|
2343
3664
|
"linking_references",
|
|
2344
3665
|
"completed"
|