Créer un pipeline de traduction IA avec garde-fous à l'aide des API IA natives de ColdFusion, partie 2 : décider ce qui doit être traduit

Le modèle peut traduire un champ. Maintenant, il faut que j'arrête de lui demander de traduire le même champ chaque fois que quelqu'un change une virgule.

Dans la première partie, j’ai traduit une phrase. Cela a prouvé que ColdFusion pouvait joindre un modèle, ce qui est à peine plus impressionnant que de prouver qu’un navigateur peut accéder à Internet. Ça ne me disait pas quel contenu devait être traduit. Je pourrais parcourir chaque chaîne sur une page et envoyer l’ensemble à chaque fois que quelqu’un clique sur Enregistrer. Cela traduirait du contenu qui n’a pas changé, écraserait le travail que quelqu’un a déjà révisé, et rendrait le service de facturation de mon fournisseur déraisonnablement attaché à moi. Leur affection ne serait pas réciproque.

J’ai besoin d’un inventaire : un enregistrement pour chaque champ traduisible et chaque langue cible. Chaque enregistrement devrait me dire ce que la source disait lors de ma dernière analyse, s’il y a une traduction, et si la source a changé depuis que cette traduction a été faite. Sans ça, je demande au modèle de compenser pour de l’information que je n’ai jamais pris la peine d’enregistrer. J’ai travaillé sur des projets qui fonctionnaient de cette façon. Ils ont eu des réunions à ce sujet.

Pour cet exemple, j’utiliserai une petite table de pages PostgreSQL avec deux champs de texte brut. Votre application aura ses propres tables de contenu. Ce qu’il vaut la peine d’emporter avec vous, c’est le scanner et ses règles d’état, pas mon choix profond de noms de colonnes.

Créez les tables :

CREATE TABLE site_page (
    site_id uuid NOT NULL,
    id uuid NOT NULL,
    source_locale varchar(20) NOT NULL,
    title text NOT NULL DEFAULT '',
    summary text NOT NULL DEFAULT '',
    PRIMARY KEY (site_id, id)
);

CREATE TABLE translation_item (
    site_id uuid NOT NULL,
    page_id uuid NOT NULL,
    field_name varchar(30) NOT NULL
        CHECK (field_name IN ('title', 'summary')),
    source_locale varchar(20) NOT NULL,
    target_locale varchar(20) NOT NULL,
    source_text text NOT NULL,
    source_hash char(64) NOT NULL,
    translated_text text,
    status varchar(20) NOT NULL DEFAULT 'missing'
        CHECK (status IN ('missing', 'draft', 'complete', 'stale')),
    is_active boolean NOT NULL DEFAULT true,
    updated_at timestamptz NOT NULL DEFAULT now(),
    PRIMARY KEY (site_id, page_id, field_name, target_locale),
    FOREIGN KEY (site_id, page_id)
        REFERENCES site_page (site_id, id)
        ON DELETE CASCADE
);

La paire site_id et page_id est intentionnelle. Un élément de traduction ne peut pas pointer vers une page appartenant à un autre site. Cela ne remplace pas l’autorisation dans l’application, mais ça donne à la base de données l’occasion de s’opposer avant qu’une erreur ne devienne un rapport d’incident avec mon nom en haut.

Les quatre statuts ont des significations précises. missing a besoin d’une traduction. draft contient du texte qui n’a pas été approuvé. complete a été révisé. stale a encore son ancienne traduction, mais la source a changé. La publication aura son propre verrou plus tard; je ne veux pas que « le modèle a renvoyé quelque chose » et « le public peut le voir » partagent un statut par accident. L’un est un résultat. L’autre est une décision que je vais devoir expliquer aux gens.

Créez ensuite components/TranslationInventory.cfc :

component {
    public translationInventory function init( required string datasource ) {
        variables.datasource = arguments.datasource;
        return this;
    }

    public void function scanPage(
        required string siteId,
        required string pageId,
        required string targetLocale
    ) {
        var pageParams = {
            siteId: { cfsqltype: "varchar", value: arguments.siteId },
            pageId: { cfsqltype: "varchar", value: arguments.pageId }
        };

        transaction {
            var pages = queryExecute(
                "
                    SELECT
                        source_locale,
                        title,
                        summary
                    FROM
                        site_page
                    WHERE
                        site_id = CAST(:siteId AS uuid)
                        AND id = CAST(:pageId AS uuid)
                    FOR SHARE
                ",
                pageParams,
                { datasource: variables.datasource, returnType: "array" }
            );

            if ( !arrayLen( pages ) ) {
                throw(
                    type="Translation.PageNotFound",
                    message="La page n'a pas été trouvée pour ce site."
                );
            }

            var page = pages[ 1 ];

            if ( compareNoCase( page.source_locale, arguments.targetLocale ) == 0 ) {
                throw(
                    type="Translation.InvalidLocale",
                    message="La langue cible doit être différente de la langue source."
                );
            }

            var fields = [
                { name: "title", text: page.title },
                { name: "summary", text: page.summary }
            ];

            for ( var field in fields ) {
                var sourceText = toString( field.text );
                var normalized = trim(
                    replace( sourceText, chr( 13 ) & chr( 10 ), chr( 10 ), "all" )
                );
                var sourceHash = lCase(
                    hash(
                        lCase( page.source_locale ) & ":" & normalized,
                        "SHA-256",
                        "UTF-8"
                    )
                );

                var itemParams = duplicate( pageParams );
                itemParams.fieldName = { cfsqltype: "varchar", value: field.name };
                itemParams.sourceLocale = { cfsqltype: "varchar", value: page.source_locale };
                itemParams.targetLocale = { cfsqltype: "varchar", value: arguments.targetLocale };
                itemParams.sourceText = { cfsqltype: "longvarchar", value: sourceText };
                itemParams.sourceHash = { cfsqltype: "varchar", value: sourceHash };
                itemParams.isActive = { cfsqltype: "bit", value: len( trim( sourceText ) ) > 0 };

                queryExecute(
                    sql = "
                        INSERT INTO translation_item (
                            site_id,
                            page_id,
                            field_name,
                            source_locale,
                            target_locale,
                            source_text,
                            source_hash,
                            is_active
                        ) VALUES (
                            CAST(:siteId AS uuid),
                            CAST(:pageId AS uuid),
                            :fieldName,
                            :sourceLocale,
                            :targetLocale,
                            :sourceText,
                            :sourceHash,
                            :isActive
                        )
                        ON CONFLICT (site_id, page_id, field_name, target_locale)
                        DO UPDATE SET
                            source_locale = EXCLUDED.source_locale,
                            source_text = EXCLUDED.source_text,
                            source_hash = EXCLUDED.source_hash,
                            is_active = EXCLUDED.is_active,
                            status = CASE
                                WHEN translation_item.source_hash = EXCLUDED.source_hash
                                    THEN translation_item.status
                                WHEN translation_item.translated_text IS NULL
                                    OR btrim( translation_item.translated_text ) = ''
                                    THEN 'missing'
                                ELSE 'stale'
                            END,
                            updated_at = now()
                    ",
                    params = itemParams,
                    options = { datasource: variables.datasource }
                );
            }
        }
    }

    public array function pendingForPage(
        required string siteId,
        required string pageId,
        required string targetLocale
    ) {
        return queryExecute(
            sql = "
                SELECT
                    field_name,
                    source_locale,
                    target_locale,
                    source_text,
                    source_hash,
                    status
                FROM
                    translation_item
                WHERE
                    site_id = CAST(:siteId AS uuid)
                    AND page_id = CAST(:pageId AS uuid)
                    AND target_locale = :targetLocale
                    AND is_active = true
                    AND status IN ('missing', 'stale')
                ORDER BY
                    field_name
            ",
            params = {
                siteId: { cfsqltype: "varchar", value: arguments.siteId },
                pageId: { cfsqltype: "varchar", value: arguments.pageId },
                targetLocale: { cfsqltype: "varchar", value: arguments.targetLocale }
            },
            options = { datasource: variables.datasource, returnType: "array" }
        );
    }
}

Le scanner lit la page à partir de la base de données. Il n’accepte pas un ensemble de chaînes fourni de façon pratique par le navigateur et ne suppose pas qu’elles appartiennent à la page demandée. Il nomme aussi les deux champs qu’il traduira. Une boucle générique sur chaque colonne enverrait éventuellement des identifiants, des adresses, des notes internes ou quelque chose d’encore plus gênant au modèle. J’ai déjà assez de moyens de créer des incidents sans ajouter une boucle for.

Pour chaque champ, j’enregistre une empreinte SHA-256 de sa langue source et du texte normalisé. Cette empreinte est un détecteur de changement, pas un chiffrement; le texte source original est toujours dans la table. Inclure la langue source est important parce que le même texte interprété dans une autre langue source peut nécessiter une nouvelle traduction. Appeler l’empreinte « chiffrement » pendant une revue de sécurité serait une excellente façon d’apprendre à quel point un réviseur peut juger silencieusement. La fonction hash() de ColdFusion prend en charge l’algorithme et l’encodage utilisés ici. Voir la référence de Adobe pour hash() pour plus de détails.

L’opération de base de données est un upsert. Vous ne savez pas ce qu’est un upsert? Ni mon correcteur orthographique. C’est un insert si la ligne n’existe pas, ou une mise à jour si elle existe. Un nouveau champ commence à missing. Un scan d’une source inchangée préserve son statut et toute traduction existante. Une source modifiée devient stale si un texte traduit existe, ou missing si ce n’est pas le cas. Le scanner ne modifie jamais translated_text. Cette vieille traduction peut maintenant être fausse, mais la conserver donne au réviseur quelque chose à comparer plutôt que de l’envoyer dans les sauvegardes avec une torche et une pelle. ON CONFLICT DO UPDATE de PostgreSQL rend chaque opération d’insertion ou de mise à jour atomique. Consultez la documentation de PostgreSQL sur INSERT.

Un champ vide reste dans l’inventaire, mais devient inactif, donc pendingForPage() ne l’enverra pas au modèle. Si quelqu’un remet du contenu plus tard, le prochain scan le réactivera. Conserver la ligne me permet de garder la traduction précédente pour la comparaison sans payer un modèle pour contempler la signification profonde d’un résumé vide.

Pour le tester, insérez une page d’exemple dans la source de données PostgreSQL que vous avez nommée translationDemo :

INSERT INTO site_page (
    site_id,
    id,
    source_locale,
    title,
    summary
) VALUES (
    '[redacted][redacted]',
    '[redacted][redacted]',
    'en-CA',
    'Welcome',
    'Find out what is happening in our community.'
);

Puis exécutez le scanner à partir d’une page de diagnostic locale :

<cfscript>
    inventory = new components.TranslationInventory( "translationDemo" );

    siteId = "[redacted][redacted]";
    pageId = "[redacted][redacted]";

    inventory.scanPage(
        siteId=siteId,
        pageId=pageId,
        targetLocale="fr-CA"
    );

    writeDump(
        inventory.pendingForPage(
            siteId=siteId,
            pageId=pageId,
            targetLocale="fr-CA"
        )
    );
</cfscript>

Vous devriez voir title et summary, tous deux marqués missing. Relancez le scan et vous devriez toujours avoir exactement deux éléments. Le modèle n’a pas été appelé, et rien n’a été traduit deux fois. C’est la partie excitante du travail d’infrastructure - faire les choses correctement produit presque rien à regarder.

Dans une vraie application, appelez scanPage() seulement après avoir confirmé que l’utilisateur actuel peut gérer le site et que la langue cible est activée pour celui-ci. Supprimez ou protégez la page de diagnostic après les tests. Une page publique qui affiche le contenu de votre inventaire est une fonctionnalité généreuse, mais pas pour vos utilisateurs. Les inconnus aiment aussi la documentation. Ça leur donne des choses avec lesquelles jouer.

J’ai maintenant une réponse répétable à « qu’est-ce qui a besoin d’être traduit? » Dans la troisième partie, j’enverrai ces champs admissibles au cadre d’IA natif de ColdFusion et je m’attaquerai à la question moins agréable : si la réponse qu’il me donne est une réponse que je peux utiliser en toute sécurité. Le modèle aura l’air confiant dans les deux cas. Je connais la technique; je l’ai utilisée tout au long de la vingtaine. Généralement quand j’essayais de m’en tirer en improvisant.