Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom/BulkActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
adminforth.list.refresh();
props.clearCheckboxes();
if (res.ok) {
adminforth.alert({ message: res.successMessage, variant: 'success' });
adminforth.alert({ message: `${res.successMessage}. ${res.failedToTranslate.length > 0 ? `${t('Failed to translate')}: ${res.failedToTranslate.length}` : ''}`, variant: 'success' });
} else {
adminforth.alert({ message: res.errorMessage || t('Failed to translate selected items. Please, try again.'), variant: 'danger' });
}
Expand Down
217 changes: 163 additions & 54 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import chokidar from 'chokidar';
import { AsyncQueue } from '@sapphire/async-queue';
import getFlagEmoji from 'country-flag-svg';
import { parse } from 'bcp-47';
import pLimit from 'p-limit';
import { randomUUID } from 'crypto';

const processFrontendMessagesQueue = new AsyncQueue();

Expand Down Expand Up @@ -68,6 +70,12 @@ function isValidSupportedLanguageTag(langCode: SupportedLanguage): boolean {
}
}

interface IFailedTranslation {
lang: SupportedLanguage;
en_string: string;
failedReason: string;
}


interface ICachingAdapter {
get(key: string): Promise<any>;
Expand Down Expand Up @@ -494,51 +502,17 @@ export default class I18nPlugin extends AdminForthPlugin {
});
}

async translateToLang (
langIsoCode: SupportedLanguage,
strings: { en_string: string, category: string }[],
plurals=false,
translations: any[],
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {}
): Promise<string[]> {
const maxKeysInOneReq = 10;
if (strings.length === 0) {
return [];
}
async generateAndSaveBunch (
prompt: string,
strings: { en_string: string, category: string }[],
translations: any[],
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {},
lang: string,
failedToTranslate: IFailedTranslation[],
): Promise<void>{

const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;
if (strings.length > maxKeysInOneReq) {
let totalTranslated = [];
for (let i = 0; i < strings.length; i += maxKeysInOneReq) {
const slicedStrings = strings.slice(i, i + maxKeysInOneReq);
process.env.HEAVY_DEBUG && console.log('🪲🔪slicedStrings len', slicedStrings.length);
const madeKeys = await this.translateToLang(langIsoCode, slicedStrings, plurals, translations, updateStrings);
totalTranslated = totalTranslated.concat(madeKeys);
}
return totalTranslated;
}
const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
const lang = langIsoCode;
const primaryLang = getPrimaryLanguageCode(lang);
const langName = iso6391.getName(primaryLang);
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
const region = String(lang).split('-')[1]?.toUpperCase() || '';
const prompt = `
I need to translate strings in JSON to ${langName} language ${replacedLanguageCodeForTranslations || lang.length > 2 ? `BCP-47 code ${langCode}` : `ISO 639-1 code ${langIsoCode}`} from English for my web app.
${region ? `Use the regional conventions for ${langCode} (region ${region}), including spelling, punctuation, and formatting.` : ''}
${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:

\`\`\`json
${
JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object => {
acc[s.en_string] = '';
return acc;
}, {}), null, 2)
}
\`\`\`
`;

// return [];
const jsonSchemaProperties = {};
strings.forEach(s => {
jsonSchemaProperties[s.en_string] = {
Expand All @@ -548,7 +522,6 @@ export default class I18nPlugin extends AdminForthPlugin {
});

const jsonSchemaRequired = strings.map(s => s.en_string);

// call OpenAI
const resp = await this.options.completeAdapter.complete(
prompt,
Expand Down Expand Up @@ -582,14 +555,28 @@ export default class I18nPlugin extends AdminForthPlugin {
res = resp.content//.split("```json")[1].split("```")[0];
} catch (e) {
console.error(`Error in parsing LLM resp: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
return [];
strings.forEach(s => {
failedToTranslate.push({
lang: lang as SupportedLanguage,
en_string: s.en_string,
failedReason: "Error in parsing LLM response"
});
});
return null;
}

try {
res = JSON.parse(res);
} catch (e) {
console.error(`Error in parsing LLM resp json: ${resp}\n Prompt was: ${prompt}\n Resp was: ${JSON.stringify(resp)}`, );
return [];
strings.forEach(s => {
failedToTranslate.push({
lang: lang as SupportedLanguage,
en_string: s.en_string,
failedReason: "Error in parsing LLM response JSON"
});
});
return null;
}


Expand Down Expand Up @@ -619,13 +606,130 @@ export default class I18nPlugin extends AdminForthPlugin {
].updates[this.trFieldNames[lang]] = translatedStr;
}
}
}

async translateToLang (
langIsoCode: SupportedLanguage,
strings: { en_string: string, category: string }[],
plurals=false,
translations: any[],
updateStrings: Record<string, { updates: any, category: string, strId: string, enStr: string, translatedStr: string }> = {}
): Promise<{ updatedKeys: string[], failedToTranslate: IFailedTranslation[] }> {

const maxInputTokens = this.options.inputTokensPerBatch ?? 30000;

const limit = pLimit(10);
const enStringsTokenLengthCache: Record<string, any>[] = [];


const tokenLengthPerString = async (str: string): Promise<void> => {
const objectToPush = {
en_string: str,
numOfTokens: await this.options.completeAdapter.measureTokensCount(`"${str}":"",`)
};
enStringsTokenLengthCache.push(objectToPush);
}
const promises = strings.map(s => limit(() => tokenLengthPerString(s.en_string)));

await Promise.all(promises);

if (strings.length === 0) {
return { updatedKeys: [], failedToTranslate: [] };
}

const replacedLanguageCodeForTranslations = this.options.translateLangAsBCP47Code && langIsoCode.length === 2 ? this.options.translateLangAsBCP47Code[langIsoCode as any] : null;

const langCode = replacedLanguageCodeForTranslations ? replacedLanguageCodeForTranslations : langIsoCode;
const lang = langIsoCode;
const primaryLang = getPrimaryLanguageCode(lang);
const langName = iso6391.getName(primaryLang);
const requestSlavicPlurals = Object.keys(SLAVIC_PLURAL_EXAMPLES).includes(primaryLang) && plurals;
const region = String(lang).split('-')[1]?.toUpperCase() || '';
const basePrompt = `
I need to translate strings in JSON to ${langName} language ${replacedLanguageCodeForTranslations || lang.length > 2 ? `BCP-47 code ${langCode}` : `ISO 639-1 code ${langIsoCode}`} from English for my web app.
${region ? `Use the regional conventions for ${langCode} (region ${region}), including spelling, punctuation, and formatting.` : ''}
${requestSlavicPlurals ? `You should provide 4 slavic forms (in format "zero count | singular count | 2-4 | 5+") e.g. "apple | apples" should become "${SLAVIC_PLURAL_EXAMPLES[lang]}"` : ''}
Keep keys, as is, write translation into values! If keys have variables (in curly brackets), then translated strings should have them as well (variables itself should not be translated). Here are the strings:
\`\`\`json

\`\`\`
`;

const failedToTranslate: IFailedTranslation[] = [];
const basePromptTokenLength = await this.options.completeAdapter.measureTokensCount(basePrompt);
const allowedTokensAmountForFields = maxInputTokens - basePromptTokenLength;
const stringsToTranslate = strings.map( s => s.en_string);
const generationTasks = []
while (stringsToTranslate.length !== 0) {
const stringBanch = [];
let banchTokens = 0;
for (const string of stringsToTranslate) {
const numberOfTokensForString = enStringsTokenLengthCache.find(cache => cache.en_string === string)?.numOfTokens || 0;
if( banchTokens + numberOfTokensForString <= allowedTokensAmountForFields ) {
stringBanch.push(string);
banchTokens += numberOfTokensForString;
} else {
continue;
}
}
if ( stringBanch.length === 0 ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yaroslav8765 but if there is some one string which is larger itself then allowedTokensAmountForFields , there will be no warning for user right? So he will not understand why some string was not translated

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've improved notifications system

stringsToTranslate.forEach(s => {
failedToTranslate.push({
lang,
en_string: s,
failedReason: "Not enough input generation tokens"
});
});
break;
}
for ( const string of stringBanch) {
const index = stringsToTranslate.indexOf(string);
if (index !== -1) {
stringsToTranslate.splice(index, 1);
}
}
const promptToGenerate = basePrompt.split(`\`\`\`json`)[0] +
`\`\`\`json${
stringBanch.map(s => `"${s}": ""`)
}
\`\`\``;
// await new Promise(resolve => setTimeout(resolve, 1000));
const stringBanchCopy = [...stringBanch];
generationTasks.push(
this.generateAndSaveBunch(
promptToGenerate,
strings.filter(s => stringBanchCopy.includes(s.en_string)),
translations.filter(t => stringBanchCopy.includes(t.en_string)),
updateStrings,
lang,
failedToTranslate
)
)
}

await Promise.all(generationTasks);




return Object.keys(updateStrings);
if (allowedTokensAmountForFields < 0) {
throw new AiTranslateError("Not enought input generation tokens")
}
return { updatedKeys: Object.keys(updateStrings), failedToTranslate };
}

// returns translated count
async bulkTranslate({ selectedIds, selectedLanguages }: { selectedIds: string[], selectedLanguages?: SupportedLanguage[] }): Promise<number> {

// returns translated count
async bulkTranslate({ selectedIds, selectedLanguages }:
{
selectedIds: string[],
selectedLanguages?: SupportedLanguage[]
}):
Promise<{
totalTranslated: number,
failedToTranslate: IFailedTranslation[]
}>
{
const needToTranslateByLang : Partial<
Record<
SupportedLanguage,
Expand Down Expand Up @@ -668,20 +772,22 @@ export default class I18nPlugin extends AdminForthPlugin {
const langsInvolved = new Set(Object.keys(needToTranslateByLang));

let totalTranslated = [];
let failedToTranslate: IFailedTranslation[] = [];

await Promise.all(
Object.entries(needToTranslateByLang).map(
async ([lang, strings]: [SupportedLanguage, { en_string: string, category: string }[]]) => {
// first translate without plurals
const stringsWithoutPlurals = strings.filter(s => !s.en_string.includes('|'));
const noPluralKeys = await this.translateToLang(lang, stringsWithoutPlurals, false, translations, updateStrings);
const { updatedKeys: noPluralKeys, failedToTranslate: failedNoPlural } = await this.translateToLang(lang, stringsWithoutPlurals, false, translations, updateStrings);


const stringsWithPlurals = strings.filter(s => s.en_string.includes('|'));

const pluralKeys = await this.translateToLang(lang, stringsWithPlurals, true, translations, updateStrings);
const { updatedKeys: pluralKeys, failedToTranslate: failedPlural } = await this.translateToLang(lang, stringsWithPlurals, true, translations, updateStrings);

totalTranslated = totalTranslated.concat(noPluralKeys, pluralKeys);
failedToTranslate = failedToTranslate.concat(failedNoPlural, failedPlural);
}
)
);
Expand Down Expand Up @@ -713,8 +819,7 @@ export default class I18nPlugin extends AdminForthPlugin {
await this.cache.clear(`${this.resourceConfig.resourceId}:${category}:${lang}`);
}
}

return new Set(totalTranslated).size;
return { totalTranslated: new Set(totalTranslated).size, failedToTranslate: failedToTranslate };
}

async processExtractedMessages(adminforth: IAdminForth, filePath: string) {
Expand Down Expand Up @@ -1076,8 +1181,11 @@ export default class I18nPlugin extends AdminForthPlugin {
const selectedIds = body.selectedIds;

let translatedCount = 0;
let failedToTranslate: IFailedTranslation[] = [];
try {
translatedCount = await this.bulkTranslate({ selectedIds, selectedLanguages });
const result = await this.bulkTranslate({ selectedIds, selectedLanguages });
translatedCount = result.totalTranslated;
failedToTranslate = result.failedToTranslate;
} catch (e) {
process.env.HEAVY_DEBUG && console.error('🪲⛔ bulkTranslate error', e);
if (e instanceof AiTranslateError) {
Expand All @@ -1092,6 +1200,7 @@ export default class I18nPlugin extends AdminForthPlugin {
successMessage: await tr(`Translated {count} items`, 'backend', {
count: translatedCount,
}),
failedToTranslate,
};
}
});
Expand Down
42 changes: 35 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading