Passer au contenu principal

Créer un pipeline de traduction par IA avec garde-fous grâce aux API d’IA natives de ColdFusion - Cinquième partie : le modèle n’obtient pas de bouton de publication

Rédigez, révisez et publiez des traductions générées par l’IA sans laisser le modèle décider de ce qui est mis en ligne.

Les parties trois et quatre m'ont donné des traductions candidates. Le modèle a retourné les bons champs, a conservé les espaces réservés intacts et n'a pas inséré de balise HTML là où il devait y avoir une phrase. Cela lui vaut une place dans une file de révision, pas sur une page publique.

Je veux trois événements distincts :

  1. enregistrer la traduction candidate comme brouillon
  2. faire approuver le texte par une personne
  3. et publier un instantané de ce que cette personne a approuvé.

La distinction est importante. Si complete et « visible au public » veulent dire la même chose, une mise à jour de base de données trop enthousiaste peut contourner le processus de révision. J'ai écrit suffisamment de mises à jour de base de données enthousiastes pour savoir qu'il vaut mieux éviter ça.

Commencez par étendre translation_item :

ALTER TABLE translation_item
    ADD COLUMN source_revision bigint NOT NULL DEFAULT 1,
    ADD COLUMN reviewed_by varchar(100),
    ADD COLUMN reviewed_at timestamptz,
    ADD COLUMN published_text text,
    ADD COLUMN published_source_hash char(64),
    ADD COLUMN published_revision bigint,
    ADD COLUMN published_by varchar(100),
    ADD COLUMN published_at timestamptz;

L'empreinte de la source me dit toujours ce que dit la source. source_revision me dit si elle a changé puis si elle a été changée à nouveau pour revenir au même texte. Une traduction déjà publiée ne réapparaîtra pas silencieusement simplement parce que le texte source correspond de nouveau à une version plus ancienne.

Dans TranslationInventory.cfc, ajoutez ces affectations à la clause ON CONFLICT ... DO UPDATE SET existante. Placez-les avant updated_at = now() :

source_revision = CASE
    WHEN translation_item.source_hash = EXCLUDED.source_hash
        THEN translation_item.source_revision
    ELSE translation_item.source_revision + 1
END,
reviewed_by = CASE
    WHEN translation_item.source_hash = EXCLUDED.source_hash
        THEN translation_item.reviewed_by
    ELSE NULL
END,
reviewed_at = CASE
    WHEN translation_item.source_hash = EXCLUDED.source_hash
        THEN translation_item.reviewed_at
    ELSE NULL
END,

L'analyseur conserve toujours l'ancien translated_text pour la comparaison lorsque le contenu source change. Il efface les métadonnées de révision et fait avancer la révision. Il ne supprime pas l'ancien instantané publié, mais cet instantané ne passera pas les vérifications de publication ci-dessous.

Créez maintenant components/translationWorkflow.cfc :

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

    public void function saveDrafts(
        required string siteId,
        required string pageId,
        required array candidates
    ) {
        if ( !arrayLen( arguments.candidates ) || arrayLen( arguments.candidates ) > 3 ) {
            throw(
                type="Translation.InvalidDraft",
                message="Expected between one and three candidate fields."
            );
        }

        var seen = {};

        transaction {
            try {
                for ( var candidate in arguments.candidates ) {
                    if (
                        !isStruct( candidate ) ||
                        !structKeyExists( candidate, "field_name" ) ||
                        !structKeyExists( candidate, "source_locale" ) ||
                        !structKeyExists( candidate, "target_locale" ) ||
                        !structKeyExists( candidate, "source_hash" ) ||
                        !structKeyExists( candidate, "translated_text" ) ||
                        !isSimpleValue( candidate.translated_text )
                    ) {
                        throw(
                            type="Translation.InvalidDraft",
                            message="A candidate is incomplete."
                        );
                    }

                    var fieldName = toString( candidate.field_name );

                    if (
                        !listFind( "title,summary,body", fieldName ) ||
                        structKeyExists( seen, fieldName ) ||
                        !reFind( "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$", candidate.target_locale )
                    ) {
                        throw(
                            type="Translation.InvalidDraft",
                            message="A candidate has an invalid field or locale."
                        );
                    }

                    var source = this.currentSource(
                        siteId=arguments.siteId,
                        pageId=arguments.pageId,
                        fieldName=fieldName,
                        expectedHash=candidate.source_hash
                    );

                    if (
                        compareNoCase( source.locale, candidate.source_locale ) != 0 ||
                        compareNoCase( source.locale, candidate.target_locale ) == 0
                    ) {
                        throw(
                            type="Translation.InvalidDraft",
                            message="A candidate has the wrong source or target locale."
                        );
                    }

                    this.checkTranslation(
                        fieldName=fieldName,
                        sourceText=source.text,
                        translatedText=toString( candidate.translated_text )
                    );

                    var updated = queryExecute(
                        sql = "
                            UPDATE translation_item
                            SET
                                translated_text = :translatedText,
                                status = 'draft',
                                reviewed_by = NULL,
                                reviewed_at = NULL,
                                updated_at = now()
                            WHERE
                                site_id = CAST(:siteId AS uuid)
                                AND page_id = CAST(:pageId AS uuid)
                                AND field_name = :fieldName
                                AND source_locale = :sourceLocale
                                AND target_locale = :targetLocale
                                AND source_hash = :sourceHash
                                AND is_active = true
                                AND status IN ('missing', 'stale')
                            RETURNING
                                field_name
                        ",
                        params = {
                            siteId: { cfsqltype: "varchar", value: arguments.siteId },
                            pageId: { cfsqltype: "varchar", value: arguments.pageId },
                            fieldName: { cfsqltype: "varchar", value: fieldName },
                            sourceLocale: { cfsqltype: "varchar", value: source.locale },
                            targetLocale: { cfsqltype: "varchar", value: candidate.target_locale },
                            sourceHash: { cfsqltype: "varchar", value: candidate.source_hash },
                            translatedText: {
                                cfsqltype: "longvarchar",
                                value: candidate.translated_text
                            }
                        },
                        options = { datasource: variables.datasource, returnType: "array" }
                    );

                    if ( arrayLen( updated ) != 1 ) {
                        throw(
                            type="Translation.StaleDraft",
                            message="A candidate no longer matches an eligible inventory item."
                        );
                    }

                    seen[ fieldName ] = true;
                }
            } catch ( any error ) {
                transactionRollback();
                rethrow;
            }
        }
    }

    public void function approve(
        required string siteId,
        required string pageId,
        required string fieldName,
        required string targetLocale,
        required string sourceHash,
        required string reviewedText,
        required string reviewerId
    ) {
        if ( !len( trim( arguments.reviewerId ) ) || len( arguments.reviewerId ) > 100 ) {
            throw( type="Translation.InvalidReviewer", message="A reviewer is required." );
        }

        transaction {
            var source = this.currentSource(
                siteId=arguments.siteId,
                pageId=arguments.pageId,
                fieldName=arguments.fieldName,
                expectedHash=arguments.sourceHash
            );

            this.checkTranslation(
                fieldName=arguments.fieldName,
                sourceText=source.text,
                translatedText=arguments.reviewedText
            );

            var updated = queryExecute(
                sql = "
                    UPDATE translation_item
                    SET
                        translated_text = :reviewedText,
                        status = 'complete',
                        reviewed_by = :reviewerId,
                        reviewed_at = now(),
                        updated_at = now()
                    WHERE
                        site_id = CAST(:siteId AS uuid)
                        AND page_id = CAST(:pageId AS uuid)
                        AND field_name = :fieldName
                        AND target_locale = :targetLocale
                        AND source_hash = :sourceHash
                        AND is_active = true
                        AND status = 'draft'
                    RETURNING
                        field_name
                ",
                params = {
                    siteId: { cfsqltype: "varchar", value: arguments.siteId },
                    pageId: { cfsqltype: "varchar", value: arguments.pageId },
                    fieldName: { cfsqltype: "varchar", value: arguments.fieldName },
                    targetLocale: { cfsqltype: "varchar", value: arguments.targetLocale },
                    sourceHash: { cfsqltype: "varchar", value: arguments.sourceHash },
                    reviewedText: { cfsqltype: "longvarchar", value: arguments.reviewedText },
                    reviewerId: { cfsqltype: "varchar", value: arguments.reviewerId }
                },
                options = { datasource: variables.datasource, returnType: "array" }
            );

            if ( arrayLen( updated ) != 1 ) {
                throw(
                    type="Translation.StaleReview",
                    message="The draft changed or is no longer awaiting review."
                );
            }
        }
    }

    public void function publish(
        required string siteId,
        required string pageId,
        required string fieldName,
        required string targetLocale,
        required string sourceHash,
        required string publisherId
    ) {
        if ( !len( trim( arguments.publisherId ) ) || len( arguments.publisherId ) > 100 ) {
            throw( type="Translation.InvalidPublisher", message="A publisher is required." );
        }

        transaction {
            this.currentSource(
                siteId=arguments.siteId,
                pageId=arguments.pageId,
                fieldName=arguments.fieldName,
                expectedHash=arguments.sourceHash
            );

            var updated = queryExecute(
                sql = "
                    UPDATE translation_item
                    SET
                        published_text = translated_text,
                        published_source_hash = source_hash,
                        published_revision = source_revision,
                        published_by = :publisherId,
                        published_at = now(),
                        updated_at = now()
                    WHERE
                        site_id = CAST(:siteId AS uuid)
                        AND page_id = CAST(:pageId AS uuid)
                        AND field_name = :fieldName
                        AND target_locale = :targetLocale
                        AND source_hash = :sourceHash
                        AND is_active = true
                        AND status = 'complete'
                        AND reviewed_by IS NOT NULL
                        AND reviewed_at IS NOT NULL
                    RETURNING
                        field_name
                ",
                params = {
                    siteId: { cfsqltype: "varchar", value: arguments.siteId },
                    pageId: { cfsqltype: "varchar", value: arguments.pageId },
                    fieldName: { cfsqltype: "varchar", value: arguments.fieldName },
                    targetLocale: { cfsqltype: "varchar", value: arguments.targetLocale },
                    sourceHash: { cfsqltype: "varchar", value: arguments.sourceHash },
                    publisherId: { cfsqltype: "varchar", value: arguments.publisherId }
                },
                options = { datasource: variables.datasource, returnType: "array" }
            );

            if ( arrayLen( updated ) != 1 ) {
                throw(
                    type="Translation.NotPublishable",
                    message="The field is not a current, reviewed translation."
                );
            }
        }
    }


    private struct function currentSource(
        required string siteId,
        required string pageId,
        required string fieldName,
        required string expectedHash
    ) {
        if (
            !listFind( "title,summary,body", arguments.fieldName ) ||
            !reFind( "^[a-f0-9]{64}$", arguments.expectedHash )
        ) {
            throw(
                type="Translation.InvalidSource",
                message="The field or source fingerprint is invalid."
            );
        }

        var pages = queryExecute(
            sql = "
                SELECT
                    source_locale,
                    title,
                    summary,
                    body
                FROM
                    site_page
                WHERE
                    site_id = CAST(:siteId AS uuid)
                    AND id = CAST(:pageId AS uuid)
                FOR SHARE
            ",
            params = {
                siteId: { cfsqltype: "varchar", value: arguments.siteId },
                pageId: { cfsqltype: "varchar", value: arguments.pageId }
            },
            options = { datasource: variables.datasource, returnType: "array" }
        );

        if ( !arrayLen( pages ) ) {
            throw( type="Translation.PageNotFound", message="The page was not found." );
        }

        var page = pages[ 1 ];
        var sourceText = toString( page[ arguments.fieldName ] );
        var normalized = trim(
            replace( sourceText, chr( 13 ) & chr( 10 ), chr( 10 ), "all" )
        );
        var currentHash = lCase(
            hash( lCase( page.source_locale ) & ":" & normalized, "SHA-256", "UTF-8" )
        );

        if ( !len( normalized ) || compare( currentHash, arguments.expectedHash ) != 0 ) {
            throw(
                type="Translation.StaleSource",
                message="The source changed since this translation was generated."
            );
        }

        return { locale: page.source_locale, text: sourceText };
    }


    private void function checkTranslation(
        required string fieldName,
        required string sourceText,
        required string translatedText
    ) {
        var text = trim( arguments.translatedText );
        var limit = compare( arguments.fieldName, "body" ) == 0 ? 50000 : 2000;

        if (
            !len( text ) ||
            len( text ) > limit ||
            reFind( "<[A-Za-z/!]", text )
        ) {
            throw(
                type="Translation.InvalidText",
                message="La traduction est vide, trop longue ou contient du balisage."
            );
        }

        var pattern = "\{[A-Za-z][A-Za-z0-9_]*\}|https?://[^\s<>]+";
        var sourceTokens = reMatch( pattern, arguments.sourceText );
        var translatedTokens = reMatch( pattern, text );

        arraySort( sourceTokens, "text" );
        arraySort( translatedTokens, "text" );

        if ( compare( serializeJSON( sourceTokens ), serializeJSON( translatedTokens ) ) != 0 ) {
            throw(
                type="Translation.InvalidText",
                message="Un espace réservé ou une adresse Web a changé."
            );
        }
    }
}

saveDrafts() compare l’empreinte de chaque candidate avec la page actuelle, pas seulement avec la ligne d’inventaire. Cela détecte une modification de la source même si le scanner n’a pas encore été exécuté de nouveau. Il met ensuite à jour uniquement les lignes missing ou stale. Si une candidate échoue, la transaction annule le lot; je ne me retrouve pas avec une page contenant un brouillon frais et deux traductions laissées en plan dans le tractus digestif de la machine. La clause UPDATE ... RETURNING de PostgreSQL donne au code une façon directe de distinguer « une ligne modifiée » de « rien n’a correspondu ». Les fonctions de transaction de ColdFusion gèrent l’écriture tout ou rien.

Dans la tâche de génération protégée de la quatrième partie, remplacez writeDump( candidates ) par :

workflow = new components.TranslationWorkflow( "translationDemo" );
workflow.saveDrafts(
    siteId=siteId,
    pageId=pageId,
    candidates=candidates
);

Les candidates sont maintenant des brouillons. Elles ne sont pas approuvées, et aucun lecteur public ne devrait utiliser translated_text.

L’écran de révision doit afficher source_text à côté de translated_text, permettre à une personne de modifier la traduction, puis soumettre le résultat modifié à approve(). Je laisse délibérément l’authentification et les permissions du site à l’interface d’administration existante; un tutoriel ne devrait pas inventer une fausse vérification de permission et inviter quelqu’un à la copier en production. Le gestionnaire doit vérifier que le réviseur connecté peut gérer ce site précis, vérifier un jeton de requête et prendre reviewerId à partir de la session - pas d’un champ caché. ColdFusion fournit CSRFGenerateToken() et CSRFVerifyToken() pour cette vérification de requête.

Après ces vérifications, l’appel d’approbation est :

workflow.approve(
    siteId=siteId,
    pageId=pageId,
    fieldName=form.fieldName,
    targetLocale=form.targetLocale,
    sourceHash=form.sourceHash,
    reviewedText=form.reviewedText,
    reviewerId=session.user.id
);

Une approbation peut modifier le brouillon, mais elle ne peut pas supprimer un espace réservé protégé ni une adresse Web. Plus important encore, elle ne peut pas approuver un brouillon en fonction d’un texte source qui a changé. L’état dans la base de données passe de draft à complete; le public ne voit toujours rien.

Une action distincte de publication, autorisée, appelle :

workflow.publish(
    siteId=siteId,
    pageId=pageId,
    fieldName=form.fieldName,
    targetLocale=form.targetLocale,
    sourceHash=form.sourceHash,
    publisherId=session.user.id
);

Cela copie le texte approuvé dans published_text avec l’empreinte de la source et la révision auxquelles il appartient. Cela ne demande pas un autre avis au modèle. J’en ai déjà payé un.

La dernière protection se trouve du côté de la lecture. Une page publique ne doit jamais sélectionner translated_text parce que cette colonne peut contenir un brouillon non révisé. Elle doit lire uniquement les instantanés publiés actuels. Je ne veux pas non plus d’une page à moitié traduite qui promet le français puis retombe en anglais après l’en-tête. Créez components/PublishedTranslation.cfc :

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


    public struct function page(
        required string siteId,
        required string pageId,
        required string targetLocale
    ) {
        var result = { ready: false, locale: arguments.targetLocale };

        transaction {
            var pages = queryExecute(
                sql = "
                    SELECT
                        source_locale,
                        title,
                        summary,
                        body
                    FROM
                        site_page
                    WHERE
                        site_id = CAST(:siteId AS uuid)
                        AND id = CAST(:pageId AS uuid)
                    FOR SHARE
                ",
                params = {
                    siteId: { cfsqltype: "varchar", value: arguments.siteId },
                    pageId: { cfsqltype: "varchar", value: arguments.pageId }
                },
                options = { datasource: variables.datasource, returnType: "array" }
            );

            if ( !arrayLen( pages ) ) {
                throw( type="Translation.PageNotFound", message="The page was not found." );
            }

            var source = pages[ 1 ];

            if ( compareNoCase( source.source_locale, arguments.targetLocale ) == 0 ) {
                result = {
                    ready: true,
                    locale: source.source_locale,
                    title: source.title,
                    summary: source.summary,
                    body: source.body
                };
            } else {
                var rows = queryExecute(
                    sql = "
                        SELECT
                            field_name,
                            published_text,
                            published_source_hash
                        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 published_text IS NOT NULL
                            AND published_source_hash = source_hash
                            AND published_revision = source_revision
                    ",
                    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" }
                );

                var published = {};

                for ( var row in rows ) {
                    published[ row.field_name ] = row;
                }

                var fields = [ "title", "summary", "body" ];
                var ready = true;

                for ( var fieldName in fields ) {
                    var sourceText = toString( source[ fieldName ] );

                    if ( !len( trim( sourceText ) ) ) {
                        continue;
                    }

                    var normalized = trim(
                        replace( sourceText, chr( 13 ) & chr( 10 ), chr( 10 ), "all" )
                    );
                    var currentHash = lCase(
                        hash( lCase( source.source_locale ) & ":" & normalized, "SHA-256", "UTF-8" )
                    );

                    if (
                        !structKeyExists( published, fieldName ) ||
                        compare( published[ fieldName ].published_source_hash, currentHash ) != 0
                    ) {
                        ready = false;
                        break;
                    }
                }

                if ( ready ) {
                    result = {
                        ready: true,
                        locale: arguments.targetLocale,
                        title: len( trim( source.title ) ) ? published.title.published_text : "",
                        summary: len( trim( source.summary ) ) ? published.summary.published_text : "",
                        body: len( trim( source.body ) ) ? published.body.published_text : ""
                    };
                }
            }
        }

        return result;
    }
}

Le lecteur exige un instantané publié pour chaque champ non vide, puis recalcule chaque empreinte à partir de la page source en direct. Une exécution oubliée du balayage ne peut pas faire paraître actuelle une vieille traduction. Tant que chaque champ n'est pas validé, il renvoie ready: false et aucune page partiellement traduite. L'application peut afficher une page « non disponible dans cette langue » ou appliquer sa propre politique explicite de repli. Ce choix ne devrait pas se produire accidentellement dans un gabarit.

Pour une vérification locale :

<cfscript>
    reader = new components.PublishedTranslation( "translationDemo" );

    page = reader.page(
        siteId="[redacted][redacted]",
        pageId="[redacted][redacted]",
        targetLocale="fr-CA"
    );

    writeDump( page );
</cfscript>

Vous devriez voir ready: false tant qu'un champ non vide demeure non publié. Après que les trois champs ont été révisés et publiés, le résultat contient uniquement les instantanés approuvés. Modifiez le titre source et le lecteur renvoie de nouveau ready: false - même avant le prochain balayage d'inventaire.

Cela complète l'importante séparation : un modèle peut proposer, une personne peut approuver et une action autorisée peut publier. Le modèle n'a pas de bouton de publication. Compte tenu de certaines de mes décisions architecturales précédentes, j'envisage d'étendre cette politique à moi-même.

Dans le dernier article, je vais rendre le pipeline opérationnel : exécution en arrière-plan, tentatives, journalisation qui ne divulgue pas le contenu des pages, et un moyen de voir ce qui a échoué sans considérer la facture du fournisseur comme le tableau de bord de surveillance.