Creare un flusso di lavoro human-in-the-loop utilizzando i callback

Questo tutorial mostra come creare un flusso di lavoro di traduzione che attende il tuo input (l'human-in-the-loop) e che collega un database Firestore, due funzioni Cloud Run, l'API Cloud Translation e una pagina web che utilizza l'SDK Firebase per aggiornarsi in tempo reale.

Con i workflow, puoi supportare un endpoint di callback (o webhook) che attende l'arrivo di richieste HTTP a quell'endpoint, riprendendo l'esecuzione del workflow in un secondo momento. In questo caso, il flusso di lavoro attende il tuo input per rifiutare o convalidare la traduzione di un testo, ma potrebbe anche attendere un processo esterno. Per saperne di più, consulta Attesa tramite callback.

Architettura

Questo tutorial crea un'app web che ti consente di:

  1. Nella pagina web di traduzione, inserisci il testo che vuoi tradurre dall'inglese al francese. Fai clic su Traduci.
  2. Dalla pagina web viene chiamata una funzione Cloud Run che avvia l'esecuzione del flusso di lavoro. Il testo da tradurre viene passato come parametro sia alla funzione sia al flusso di lavoro.
  3. Il testo viene salvato in un database Cloud Firestore. Viene chiamata l'API Cloud Translation. La traduzione restituita viene memorizzata nel database. L'app web viene implementata utilizzando Firebase Hosting e si aggiorna in tempo reale per mostrare il testo tradotto.
  4. Il passaggio create_callback del flusso di lavoro crea un URL endpoint di callback che viene salvato anche nel database Firestore. La pagina web ora mostra sia un pulsante Convalida che un pulsante Rifiuta.
  5. Il flusso di lavoro è ora in pausa e attende una richiesta HTTP POST esplicita all'URL dell'endpoint di callback.
  6. Puoi decidere se convalidare o rifiutare la traduzione. Se fai clic su un pulsante, viene chiamata una seconda funzione Cloud Run che a sua volta chiama l'endpoint di callback creato dal flusso di lavoro, trasmettendo lo stato di approvazione. Il flusso di lavoro riprende l'esecuzione e salva uno stato di approvazione true o false nel database Firestore.

Questo diagramma fornisce una panoramica della procedura:

Flusso di lavoro con callback

Esegui il deployment della prima funzione Cloud Run

Questa funzione Cloud Run avvia l'esecuzione del flusso di lavoro. Il testo da tradurre viene passato come parametro sia alla funzione sia al workflow.

  1. Crea una directory denominata callback-translation con sottodirectory denominate invokeTranslationWorkflow,translationCallbackCall e public:

    mkdir -p ~/callback-translation/{invokeTranslationWorkflow,translationCallbackCall,public}
  2. Passa alla directory invokeTranslationWorkflow:

    cd ~/callback-translation/invokeTranslationWorkflow
  3. Crea un file di testo con il nome file index.js che contenga il seguente codice Node.js:

    const cors = require('cors')({origin: true});
    const {ExecutionsClient} = require('@google-cloud/workflows');
    const client = new ExecutionsClient();
    
    exports.invokeTranslationWorkflow = async (req, res) => {
      cors(req, res, async () => {
        const text = req.body.text;
        console.log(`Translation request for "${text}"`);
    
        const PROJECT_ID = process.env.PROJECT_ID;
        const CLOUD_REGION = process.env.CLOUD_REGION;
        const WORKFLOW_NAME = process.env.WORKFLOW_NAME;
    
        const execResponse = await client.createExecution({
          parent: client.workflowPath(PROJECT_ID, CLOUD_REGION, WORKFLOW_NAME),
          execution: {
            argument: JSON.stringify({text})
          }
        });
        console.log(`Translation workflow execution request: ${JSON.stringify(execResponse)}`);
    
        const execName = execResponse[0].name;
        console.log(`Created translation workflow execution: ${execName}`);
    
        res.set('Access-Control-Allow-Origin', '*');
        res.status(200).json({executionId: execName});
      });
    };
  4. Crea un file di testo con il nome file package.json che contenga i seguenti metadati npm:

    {
      "name": "launch-translation-workflow",
      "version": "0.0.1",
      "dependencies": {
        "@google-cloud/workflows": "^1.2.5",
        "cors": "^2.8.5"
      }
    }
    
  5. Esegui il deployment della funzione con un trigger HTTP e consenti l'accesso non autenticato:

    gcloud functions deploy invokeTranslationWorkflow \
    --region=${REGION} \
    --runtime nodejs14 \
    --entry-point=invokeTranslationWorkflow \
    --set-env-vars PROJECT_ID=${GOOGLE_CLOUD_PROJECT},CLOUD_REGION=${REGION},WORKFLOW_NAME=translation_validation \
    --trigger-http \
    --allow-unauthenticated

    Il deployment della funzione potrebbe richiedere alcuni minuti. In alternativa, puoi utilizzare l'interfaccia Cloud Run Functions nella console Google Cloud per eseguire il deployment della funzione.

  6. Una volta eseguito il deployment della funzione, puoi confermare la proprietà httpsTrigger.url:

    gcloud functions describe invokeTranslationWorkflow

    Prendi nota dell'URL restituito per poterlo utilizzare in un passaggio successivo.

Esegui il deployment della seconda funzione Cloud Run

Questa funzione Cloud Run invia una richiesta HTTP POST all'endpoint di callback creato dal flusso di lavoro, passando uno stato di approvazione che indica se la traduzione è convalidata o rifiutata.

  1. Passa alla directory translationCallbackCall:

    cd ../translationCallbackCall
  2. Crea un file di testo con il nome file index.js che contenga il seguente codice Node.js:

    const cors = require('cors')({origin: true});
    const fetch = require('node-fetch');
    
    exports.translationCallbackCall = async (req, res) => {
      cors(req, res, async () => {
        res.set('Access-Control-Allow-Origin', '*');
    
        const {url, approved} = req.body;
        console.log("Approved? ", approved);
        console.log("URL = ", url);
        const {GoogleAuth} = require('google-auth-library');
        const auth = new GoogleAuth();
        const token = await auth.getAccessToken();
        console.log("Token", token);
    
        try {
          const resp = await fetch(url, {
              method: 'POST',
              headers: {
                  'accept': 'application/json',
                  'content-type': 'application/json',
                  'authorization': `Bearer ${token}`
              },
              body: JSON.stringify({ approved })
          });
          console.log("Response = ", JSON.stringify(resp));
    
          const result = await resp.json();
          console.log("Outcome = ", JSON.stringify(result));
    
          res.status(200).json({status: 'OK'});
        } catch(e) {
          console.error(e);
    
          res.status(200).json({status: 'error'});
        }
      });
    };
  3. Crea un file di testo con il nome file package.json che contenga i seguenti metadati npm:

    {
      "name": "approve-translation-workflow",
      "version": "0.0.1",
      "dependencies": {
        "cors": "^2.8.5",
        "node-fetch": "^2.6.1",
        "google-auth-library": "^7.1.1"
      }
    }
    
  4. Esegui il deployment della funzione con un trigger HTTP e consenti l'accesso non autenticato:

    gcloud functions deploy translationCallbackCall \
    --region=${REGION} \
    --runtime nodejs14 \
    --entry-point=translationCallbackCall \
    --trigger-http \
    --allow-unauthenticated

    Il deployment della funzione potrebbe richiedere alcuni minuti. In alternativa, puoi utilizzare l'interfaccia Cloud Run Functions nella console Google Cloud per eseguire il deployment della funzione.

  5. Una volta eseguito il deployment della funzione, puoi confermare la proprietà httpsTrigger.url:

    gcloud functions describe translationCallbackCall

    Prendi nota dell'URL restituito per poterlo utilizzare in un passaggio successivo.

Esegui il deployment del workflow

Un flusso di lavoro è costituito da una serie di passaggi descritti utilizzando la sintassi di Workflows, che può essere scritta in formato YAML o JSON. Questa è la definizione del flusso di lavoro. Dopo aver creato un flusso di lavoro, lo esegui per renderlo disponibile per l'esecuzione.

  1. Passa alla directory callback-translation:

    cd ..
  2. Crea un file di testo con il nome file translation-validation.yaml e con il seguente contenuto:

    main:
        params: [translation_request]
        steps:
            - log_request:
                call: sys.log
                args:
                    text: ${translation_request}
            - vars:
                assign:
                    - exec_id: ${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}
                    - text_to_translate: ${translation_request.text}
                    - database_root: ${"projects/" + sys.get_env("GOOGLE_CLOUD_PROJECT_ID") + "/databases/(default)/documents/translations/"}
            - log_translation_request:
                call: sys.log
                args:
                    text: ${text_to_translate}
    
            - store_translation_request:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['text']
                    body:
                        fields:
                            text:
                                stringValue: ${text_to_translate}
                result: store_translation_request_result
    
            - translate:
                call: googleapis.translate.v2.translations.translate
                args:
                    query:
                        q: ${text_to_translate}
                        target: "FR"
                        format: "text"
                        source: "EN"
                result: translation_result
            - assign_translation:
                assign:
                    - translation: ${translation_result.data.translations[0].translatedText} 
            - log_translation_result:
                call: sys.log
                args:
                    text: ${translation}
    
            - store_translated_text:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['translation']
                    body:
                        fields:
                            translation:
                                stringValue: ${translation}
                result: store_translation_request_result   
    
            - create_callback:
                call: events.create_callback_endpoint
                args:
                    http_callback_method: "POST"
                result: callback_details
            - log_callback_details:
                call: sys.log
                args:
                    text: ${callback_details}
    
            - store_callback_details:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['callback']
                    body:
                        fields:
                            callback:
                                stringValue: ${callback_details.url}
                result: store_callback_details_result
    
            - await_callback:
                call: events.await_callback
                args:
                    callback: ${callback_details}
                    timeout: 3600
                result: callback_request
            - assign_approval:
                assign:
                    - approved: ${callback_request.http_request.body.approved}
    
            - store_approval:
                call: googleapis.firestore.v1.projects.databases.documents.patch
                args:
                    name: ${database_root + exec_id}
                    updateMask:
                        fieldPaths: ['approved']
                    body:
                        fields:
                            approved:
                                booleanValue: ${approved}
                result: store_approval_result
    
            - return_outcome:
                return:
                    text: ${text_to_translate}
                    translation: ${translation}
                    approved: ${approved}
  3. Dopo aver creato il flusso di lavoro, puoi eseguirne il deployment, ma non eseguirlo:

    gcloud workflows deploy translation_validation --source=translation-validation.yaml

Crea la tua app web

Crea un'app web che chiama una Cloud Function che avvia l'esecuzione del flusso di lavoro. La pagina web si aggiorna in tempo reale per mostrare il risultato della richiesta di traduzione.

  1. Passa alla directory public:

    cd public
  2. Crea un file di testo con il nome file index.html che contenga il seguente markup HTML. In un passaggio successivo, modificherai il file index.html e aggiungerai gli script dell'SDK Firebase.

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width">
    
        <title>Frenglish translation — Feature Workflows callbacks</title>
    
        <link rel="stylesheet"
            href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.42/dist/themes/base.css">
        <script type="module"
            src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.42/dist/shoelace.js"></script>
        <link rel="stylesheet" href="./style.css">
    </head>
    
    <body>
        <h1>Translate from English to French</h1>
    
        <sl-form class="form-overview">
            <sl-textarea id="text" placeholder="The quick brown fox jumps over the lazy dog."
                label="English text to translate"></sl-textarea>
            <p></p>
            <sl-button id="translateBtn" type="primary">Translate</sl-button>
            <p></p>
            <sl-alert id="translation" type="primary">
                Le rapide renard brun saute au dessus du chien paresseux.
            </sl-alert>
            <p></p>
            <div id="buttonRow" style="display: none;">
                <sl-button id="validateBtn" type="success">Validate</sl-button>
                <sl-button id="rejectBtn" type="danger">Reject</sl-button>
            </div>
            <p></p>
            <sl-alert id="validationAlert" type="success">
                <sl-icon slot="icon" name="check2-circle"></sl-icon>
                <strong>The translation has been validated</strong><br>
                Glad that you liked our translation! We'll save it in our database.
            </sl-alert>
            <sl-alert id="rejectionAlert" type="danger">
                <sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
                <strong>The translation has been rejected</strong><br>
                A pity the translation isn't good! We'll do better next time!
            </sl-alert>
            <p></p>
            <sl-button id="newBtn" style="display: none;" type="primary">New translation</sl-button>
        </sl-form>
    
        <script src="https://www.gstatic.com/firebasejs/8.6.3/firebase-app.js"></script>
        <script src="https://www.gstatic.com/firebasejs/8.6.3/firebase-firestore.js"></script>
    
        <script>
            var firebaseConfig = {
                apiKey: "XXXX",
                authDomain: "XXXX",
                projectId: "XXXX",
                storageBucket: "XXXX",
                messagingSenderId: "XXXX",
                appId: "XXXX",
                measurementId: "XXXX"
            };
            // Initialize Firebase
            firebase.initializeApp(firebaseConfig);
        </script>
        <script src="./script.js" type="module"></script>
    </body>
    
    </html>
    
  3. Crea un file di testo con il nome file script.js contenente il seguente codice JavaScript:

    document.addEventListener("DOMContentLoaded", async function (event) {
        const textArea = document.getElementById("text");
        textArea.focus();
    
        const newBtn = document.getElementById("newBtn");
        newBtn.addEventListener("sl-focus", event => {
            event.target.blur();
            window.location.reload();
        });
    
        const translationAlert = document.getElementById("translation");
        const buttonRow = document.getElementById("buttonRow");
    
        var callbackUrl = "";
    
        const validationAlert = document.getElementById("validationAlert");
        const rejectionAlert = document.getElementById("rejectionAlert");
        const validateBtn = document.getElementById("validateBtn");
        const rejectBtn = document.getElementById("rejectBtn");
    
        const translateBtn = document.getElementById("translateBtn");
        translateBtn.addEventListener("sl-focus", async event => {
            event.target.disabled = true;
            event.target.loading = true;
            textArea.disabled = true;
    
            console.log("Text to translate = ", textArea.value);
    
            const fnUrl = UPDATE_ME;
    
            try {
                console.log("Calling workflow executor function...");
                const resp = await fetch(fnUrl, {
                    method: "POST",
                    headers: {
                        "accept": "application/json",
                        "content-type": "application/json"
                    },
                    body: JSON.stringify({ text: textArea.value })
                });
                const executionResp = await resp.json();
                const executionId = executionResp.executionId.slice(-36);
                console.log("Execution ID = ", executionId);
    
                const db = firebase.firestore();
                const translationDoc = db.collection("translations").doc(executionId);
    
                var translationReceived = false;
                var callbackReceived =  false;
                var approvalReceived = false;
                translationDoc.onSnapshot((doc) => {
                    console.log("Firestore update", doc.data());
                    if (doc.data()) {
                        if ("translation" in doc.data()) {
                            if (!translationReceived) {
                                console.log("Translation = ", doc.data().translation);
                                translationReceived = true;
                                translationAlert.innerText = doc.data().translation;
                                translationAlert.open = true;
                            }
                        }
                        if ("callback" in doc.data()) {
                            if (!callbackReceived) {
                                console.log("Callback URL = ", doc.data().callback);
                                callbackReceived = true;
                                callbackUrl = doc.data().callback;
                                buttonRow.style.display = "block";
                            }
                        }
                        if ("approved" in doc.data()) {
                            if (!approvalReceived) {
                                const approved = doc.data().approved;
                                console.log("Approval received = ", approved);
                                if (approved) {
                                    validationAlert.open = true;
                                    buttonRow.style.display = "none";
                                    newBtn.style.display = "inline-block";   
                                } else {
                                    rejectionAlert.open = true;
                                    buttonRow.style.display = "none";
                                    newBtn.style.display = "inline-block";
                                }
                                approvalReceived = true;
                            }
                        }
                    }
                });
            } catch (e) {
                console.log(e);
            }
            event.target.loading = false;
        });
    
        validateBtn.addEventListener("sl-focus", async event => {
            validateBtn.disabled = true;
            rejectBtn.disabled = true;
            validateBtn.loading = true;
            validateBtn.blur();
    
            // call callback
            await callCallbackUrl(callbackUrl, true);
        });
    
        rejectBtn.addEventListener("sl-focus", async event => {
            rejectBtn.disabled = true;
            validateBtn.disabled = true;
            rejectBtn.loading = true;
            rejectBtn.blur();
    
            // call callback
            await callCallbackUrl(callbackUrl, false);
        });
    
    });
    
    async function callCallbackUrl(url, approved) {
        console.log("Calling callback URL with status = ", approved);
    
        const fnUrl = UPDATE_ME;
        try {
            const resp = await fetch(fnUrl, {
                method: "POST",
                headers: {
                    "accept": "application/json",
                    "content-type": "application/json"
                },
                body: JSON.stringify({ url, approved })
            });
            const result = await resp.json();
            console.log("Callback answer = ", result);
        } catch(e) {
            console.log(e);
        }
    }
  4. Modifica il file script.js, sostituendo i segnaposto UPDATE_ME con gli URL delle funzioni Cloud Run che hai annotato in precedenza.

    1. Nel metodo translateBtn.addEventListener, sostituisci const fnUrl = UPDATE_ME; con:

      const fnUrl = "https://REGION-PROJECT_ID.cloudfunctions.net/invokeTranslationWorkflow";

    2. Nella funzione callCallbackUrl, sostituisci const fnUrl = UPDATE_ME; con:

      const fnUrl = "https://REGION-PROJECT_ID.cloudfunctions.net/translationCallbackCall";

  5. Crea un file di testo con il nome file style.css contenente i seguenti stili in cascata:

    * {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
    }
    
    body {
        margin: 20px;
    }
    
    h1, h2, h3, h4 {
        color: #0ea5e9;
    }
    

Aggiungi Firebase all'applicazione web

In questo tutorial, la pagina HTML, lo script JavaScript e il foglio di stile CSS vengono implementati come asset statici utilizzando Firebase Hosting, ma possono essere ospitati ovunque e pubblicati localmente sul tuo computer a scopo di test.

Crea un progetto Firebase

Prima di poter aggiungere Firebase alla tua app, devi creare un progetto Firebase a cui connetterti.

  1. Nella console Firebase, fai clic su Crea un progetto, quindi seleziona il progetto Google Cloud esistente dal menu a discesa per aggiungere risorse Firebase.

  2. Fai clic su Continua finché non vedi l'opzione per aggiungere Firebase.

  3. Salta la configurazione di Google Analytics per il tuo progetto.

  4. Fai clic su Aggiungi Firebase.

Firebase esegue automaticamente il provisioning delle risorse per il tuo progetto Firebase. Al termine della procedura, verrà visualizzata la pagina Panoramica del progetto nella Console Firebase.

Registrare l'app con Firebase

Una volta creato un progetto Firebase, puoi aggiungervi la tua app web.

  1. Al centro della pagina di riepilogo del progetto, fai clic sull'icona Web (</>) per avviare il flusso di lavoro della configurazione.

  2. Inserisci un nickname per l'app.

    Questi dati sono visibili solo a te nella console Firebase.

  3. Salta la configurazione di Firebase Hosting per ora.

  4. Fai clic su Registra app e continua nella console.

Attiva Cloud Firestore

L'app web utilizza Cloud Firestore per ricevere e salvare i dati. Devi abilitare Cloud Firestore.

  1. Nella sezione Build della console Firebase, fai clic su Firestore Database.

    Potrebbe essere necessario espandere il riquadro di navigazione a sinistra per visualizzare la sezione Crea.

  2. Nel riquadro Cloud Firestore, fai clic su Crea database.

  3. Seleziona Avvia in modalità test utilizzando una regola di sicurezza come la seguente:

    rules_version = '2';
    service cloud.firestore {
    match /databases/{database}/documents {
      match /{document=**} {
        allow read, write;
      }
    }
    }
  4. Fai clic su Avanti dopo aver letto l'esclusione di responsabilità relativa alle regole di sicurezza.

  5. Imposta la località in cui vengono archiviati i tuoi dati di Cloud Firestore. Puoi accettare il valore predefinito o scegliere una regione vicina a te.

  6. Fai clic su Attiva per eseguire il provisioning di Firestore.

Aggiungi l'SDK Firebase e inizializza Firebase

Firebase fornisce librerie JavaScript per la maggior parte dei prodotti Firebase. Prima di utilizzare Firebase Hosting, devi aggiungere gli SDK Firebase alla tua app web.

  1. Per inizializzare Firebase nella tua app, devi fornire la configurazione del progetto Firebase della tua app.
    1. Nella console Firebase, vai alle Impostazioni progetto .
    2. Nel riquadro Le tue app, seleziona la tua app.
    3. Nel riquadro Configurazione e configurazione dell'SDK, per caricare le librerie dell'SDK Firebase dalla CDN, seleziona CDN.
    4. Copia lo snippet nel file index.html in fondo al tag <body>, sostituendo i valori segnaposto XXXX.
  2. Installa l'SDK Firebase JavaScript.

    1. Se non hai già un file package.json, creane uno eseguendo il seguente comando dalla directory callback-translation:

      npm init
    2. Installa il pacchetto npm firebase e salvalo nel file package.json eseguendo:

      npm install firebase

Inizializza ed esegui il deployment del progetto

Per collegare i file di progetto locali al progetto Firebase, devi inizializzare il progetto. A questo punto, puoi eseguire il deployment della tua app web.

  1. Dalla directory callback-translation, esegui questo comando:

    firebase init
  2. Seleziona l'opzione Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys.

  3. Scegli di utilizzare un progetto esistente e inserisci il relativo ID.

  4. Accetta public come directory principale pubblica predefinita.

  5. Scegli di configurare un'app a una sola pagina.

  6. Salta la configurazione di build e deployment automatici con GitHub.

  7. Al prompt File public/index.html already exists. Overwrite?, digita No.

  8. Passa alla directory public:

    cd public
  9. Dalla directory public, esegui questo comando per eseguire il deployment del progetto sul tuo sito:

    firebase deploy --only hosting

Testa l'app web in locale

Firebase Hosting ti consente di visualizzare e testare le modifiche localmente e interagire con le risorse del progetto di backend emulate. Quando utilizzi firebase serve, la tua app interagisce con un backend emulato per i contenuti e la configurazione dell'hosting, ma con il backend reale per tutte le altre risorse del progetto. Per questo tutorial, puoi utilizzare firebase serve, ma non è consigliato per test più estesi.

  1. Dalla directory public, esegui questo comando:

    firebase serve
  2. Apri l'app web all'URL locale restituito (di solito http://localhost:5000).

  3. Inserisci un testo in inglese e fai clic su Traduci.

    Dovrebbe essere visualizzata una traduzione del testo in francese.

  4. Ora puoi fare clic su Convalida o Rifiuta.

    Nel database Firestore, puoi verificare i contenuti. Dovrebbe essere simile a questo:

    approved: true
    callback: "https://workflowexecutions.googleapis.com/v1/projects/26811016474/locations/us-central1/workflows/translation_validation/executions/68bfce75-5f62-445f-9cd5-eda23e6fa693/callbacks/72851c97-6bb2-45e3-9816-1e3dcc610662_1a16697f-6d90-478d-9736-33190bbe222b"
    text: "The quick brown fox jumps over the lazy dog."
    translation: "Le renard brun rapide saute par-dessus le chien paresseux."
    

    Lo stato di approved è true o false a seconda che tu convalida o rifiuti la traduzione.

Complimenti! Hai creato un workflow di traduzione human-in-the-loop che utilizza i callback di Workflows.