AI vertalingen: I18next & Deepl

14 juni 2023

Bij Bitstillery werken we aan een internationaal portaal voor sterke dranken. Onze klanten komen van over de hele wereld en zijn niet allemaal gewend aan Engels of hebben een sterkere culturele voorkeur voor hun moedertaal. Daarom streven we ernaar om minstens 6 van de populairste talen in onze producten te ondersteunen: Engels, Duits, Frans, Italiaans, Spaans en Nederlands. We hebben native speakers voor al deze talen die vertalingen kunnen leveren of vertalingen kunnen controleren op nauwkeurigheid. We zijn echter nog maar net klaar met het omzetten van de meeste tekst naar i18n-strings, dus vertalers zouden veel tekst in één keer moeten vertalen. Door het hoge tempo waarin we tekst en functionaliteit in onze producten veranderen, is het bovendien erg moeilijk om de vertalingen van meerdere vertalingen en vertalers synchroon te laten lopen.

Wat we dus nodig hebben is een manier om automatisch vertalingen van goede kwaliteit te leveren en een gemakkelijke manier voor vertalers om de paar vertalingen die mogelijk niet kloppen te corrigeren. Dankzij de vooruitgang in machine learning zijn automatische vertalingen veel beter geworden in het leveren van de meest voorkomende context waarin een vertaling zal worden gebruikt. Voor ons gebruik hebben we Deepl geprobeerd. Onze setup ziet er als volgt uit:

  • Een Engels basisbestand (en.json) en soortgelijke bestanden voor de andere talen (fr.json, de.json, …)
"checkout": {
    "booked": "Booked",
    "cart": "Cart",
    "comment_add": "Add Comment",
    "comment_add_tip": "Add a comment about this product",
    "comment_delete": "Delete Comment",
    "comment_delete_tip": "Delete Comment",
    "comment_title": "Comment",
    "comment_update": "Update Comment",
    "comment_update_delete_tip": "Update or delete your comment",
    "delivery": {
        "asap": "As soon as possible",
        "date": "On a specific date"
    }
}
  • Een I18Next configuratie die er als volgt uitziet:
i18next.init({
    debug: process.env.NODE_ENV === 'development',
    fallbackLng: 'en',
    lng: 'en',
    resources: {en: enJson, fr: frJson},
})

// Convention to call translations using $t for translations: $t('checkout.cart')
const $t = i18next.t

Om vertalingen te genereren, gebruiken we een aangepaste taakgebaseerde build-configuratie. Een vertaaltaak gebruikt de Engelse JSON als bronbestand en vergelijkt andere talen op overbodige of ontbrekende sleutels. Om vertalingen gemakkelijker te beheren, controleren we op:

  • Sleutels in de doeltaal die niet in en.json staan; deze worden verwijderd uit de doeltaal.
  • Sleutels die beschikbaar zijn in en.json, maar niet in de doeltaal; deze worden vertaald.

Een andere optimalisatie is om ook te controleren op verplaatste vertalingen; dit is een gebruikelijke actie bij het hergroeperen van het bronbestand en.json, maar daar zijn we nog niet aan toegekomen. De vertaal-API voor Deepl is vrij eenvoudig te gebruiken. Het enige wat nodig is, is om een xml-element om de string replacement tekens ({{}}) te wikkelen, zodat Deepl snapt welke delen het moet negeren bij het vertalen. Zo ziet de vertaalcode eruit:

function keyMod(reference, apply, refPath) {
    if (!refPath) refPath = []
    for (const key of Object.keys(reference)) {
        if (typeof reference[key] === 'object') {
            refPath.push(key)
            keyMod(reference[key], apply, refPath)
        } else {
            apply(reference, key, refPath)
        }
    }
    refPath.pop()
}

export async function translate(task, settings, packageName, targetLanguage, overwrite = false) {
    let sourcePath, targetPath, targetI18n
    const actions = {remove: [], update: []}
    const sourceI18n = JSON.parse(await fs.readFile(sourcePath, 'utf8'))
    const targetExists = await fs.pathExists(targetPath)

    if (targetExists && !overwrite) {
        targetI18n = JSON.parse(await fs.readFile(targetPath, 'utf8'))
        keyMod(targetI18n, (_, key, refPath) => {
            // The key in the target i18n does not exist in the source (e.g. obsolete)
            const sourceRef = keyPath(sourceI18n, refPath)
            if (!sourceRef[key]) {
                actions.remove.push([[...refPath], key])
            }
        })
    } else {
        // Use a copy of the en i18n scheme as blueprint for the new scheme.
        targetI18n = JSON.parse(JSON.stringify(sourceI18n))
    }

    const placeholderRegex = /{{[\w]*}}/g
    // Show a rough estimate of deepl translation costs...
    const stats = {total: {chars: 0, keys: 0}, costs: {chars: 0, keys: 0}}
    keyMod(sourceI18n, (sourceRef, key, refPath) => {
        const targetRef = keyPath(targetI18n, refPath)
        stats.total.keys += 1
        // Use xml tags to indicate placeholders for deepl.
        const preppedSource = sourceRef[key].replaceAll(placeholderRegex, (res) => {
            return res.replace('{{', '<x>').replace('}}', '</x>')
        })
        stats.total.chars += preppedSource.length
        if (overwrite || !targetRef || !targetRef[key]) {
            stats.costs.chars += preppedSource.length
            stats.costs.keys += 1
            actions.update.push([[...refPath], key, preppedSource])
        }
    })

    const costs= `(update: ${stats.costs.keys}/${stats.costs.chars})`
    const total = `(total: ${stats.total.keys}/${stats.total.chars})`

    for (const removeAction of actions.remove) {
        const targetRef = keyPath(targetI18n, removeAction[0])
        delete targetRef[removeAction[1]]
    }

    if (actions.update.length) {
        const authKey = process.env.TRANSLATOR_KEY
        if (!authKey) throw new Error('Deepl translator key required for auto-translate (process.env.MSI_TRANSLATOR_KEY)')

        const translator = new deepl.Translator(authKey)
        let res = await translator.translateText(actions.update.map((i) => i[2]), null, targetLanguage, {
            formality: 'prefer_less',
            ignoreTags: ['x'],
            tagHandling: 'xml',
        })

        const ignoreTagRegex = /<x>[\w]*<\/x>/g
        for (const [i, translated] of res.entries()) {
            // The results come back in the same order as they were submitted.
            // Restore the xml placeholders to the i18n format being use.
            const transformedText = translated.text.replaceAll(ignoreTagRegex, (res) => res.replace('<x>', '{{').replace('</x>', '}}'))
            const targetRef = keyPath(targetI18n, actions.update[i][0], true)
            // Deepl escapes html tags; e.g. < &lt; > &gt; We don't want to ignore
            // those, because its content must be translated as well. Instead,
            // decode these special html escape characters.
            targetRef[actions.update[i][1]] = decode(transformedText)
        }
    } 

    if (actions.update.length || actions.remove.length) {
        await fs.writeFile(targetPath, JSON.stringify(targetI18n, null, 4))
    }
}

Deze Deepl vertalingen werken tot nu toe erg goed! Het vertalen van honderden onvertaalde zinnen voor 6 doeltalen is een kwestie van seconden geworden in plaats van weken. Ook is onze workflow voor het toevoegen van een nieuwe vertaalstring veel eenvoudiger geworden. Voeg gewoon een nieuwe string toe aan en.json en voer de vertaalopdracht uit, bijvoorbeeld:

TRANSLATOR_KEY=<API_KEY> pnpm run i18n
iscream 1

Wat van belang is, is dat er eerst in de Engelse taal naar een geschikte zin wordt gezocht, waarvan de kans groot is dat de zin in de juiste context vertaald zal worden naar de doeltaal. Cart kan worden geinterpreteerd als iets waarmee je rijdt, of iets waarmee je een order afrond. Ook voegen we soortgelijke tests toe om er zeker van te zijn dat we geen onvertaalde of overbodige strings hebben vergeleken met de brontaal. Een extra test kijkt door de source bestanden en probeert $t strings te vergelijken met het basis en.json bestand. Dit werkt niet helemaal goed voor $t(variabele_naam), maar is een handige extra controle voor mogelijk missende vertalingen in het bronbestand. Dit ziet er als volgt uit:

test('missing $t tags in base i18n file', async() {
    const baseDir = path.resolve(path.join(path.dirname(new URL(import.meta.url).pathname), '..'))
    const missingKeys = []

    const translationMatch = /\$t\([\s]*'([a-zA-Z0-9_\s{}.,!?%\-:;"]+)'[(),)?]/g
    let globPattern = `${path.join(baseDir, 'src', 'code', '**', '{*.ts,*.tsx}')}`

    const files = await glob(globPattern)
    for (const filename of files) {
        const data = (await fs.readFile(filename)).toString('utf8')
        data.replace(translationMatch, function(pattern:any, $t:string) {
            let path = $t.replace(unescape, '')
            if (typeof path === 'string') {
                let i18nReference = keyPath(localeEn, path)
                if (!i18nReference) {
                    // Do a check whether this is a plural key
                    const oneTerm = keyPath(localeEn, `${path}_one`)
                    const otherTerm = keyPath(localeEn, `${path}_other`)
                    const pluralReference = oneTerm || otherTerm
                    if (!pluralReference) {
                        missingKeys.push(path)
                    }
                }
            }
        })
    }

    assert.equal(missingKeys.length, 0, `$t translations not in en scheme yet: ${missingKeys.join(' ')}`)
})

Voor onze tests gebruiken we de testrunner van Nodejs, samen met een Typescript-loader. Het test script wordt vanuit package.json aangeroepen:

"scripts": {
    "test": "node --no-warnings --loader=ts-node/esm --test ./test/i18n.ts"
}

Met een geautomatiseerde manier om vertalingen te beheren en de tests om ze synchroon te houden, hoeven we alleen nog maar te zorgen voor een vertalersworkflow voor handmatige vertalingen. Onze volgende post zal gaan over welke opties er zijn voor menselijke vertalers om automatisch gegenereerde vertalingen aan te passen, met behulp van tools zoals Weblate en hoe dit in een workflow te integreren.

Copyright © 2024 Bitstillery - All Rights Reserved
menu-circle