aws, event-driven, serverless

Cómo extendí mi blog con aprendizaje gamificado

2024-07-25
This post cover image
aws cloud event-driven serverless lambda step functions eventbridge learning gamification

Este archivo ha sido traducido automaticamente por IA, pueden ocurrir errores

Una de las principales razones por las que escribo todas estas publicaciones en el blog es ayudar a las personas a aprender sobre la nube y AWS. ¿Cómo sabrías que entendiste lo que leíste y aprendiste de ello? Recibí una sugerencia para agregar un cuestionario al final del blog, que se basara en el contenido del blog. Esta fue una gran sugerencia y comencé a trabajar en ello.

kvist.ai es una solución de cuestionarios impulsada por IA generativa y potenciado por AWS, tecnologías Serverless y Amazon Bedrock. Al alimentar esta solución con mi blog, crea automáticamente un cuestionario basado en el contenido, que es exactamente lo que necesitaba. Ahora, conociendo a uno de los fundadores, Lars Jacobsson, tuve acceso a algunas características anticipadas que me ayudaron a automatizar el proceso.

En esta publicación, hablaré sobre cómo extendí mi solución de CI/CD para agregar un paso adicional para crear el cuestionario y agregarlo al blog. Mi pipeline se ejecuta en AWS y es serverless, impulsado por eventos y está potenciado por AWS StepFunctions, Lambda, EventBridge y más.

Visión general del blog

Para asegurarme de que todos entiendan cómo se crea y distribuye mi blog, esto facilitará la comprensión de la configuración más adelante. Mi blog consiste en HTML estático puro y antiguo, sin React, Vue o algo similar. El blog se distribuye a través de CloudFront y S3, uso Lambda@Edge y funciones de CloudFront para manipular la respuesta y recopilar estadísticas, consulta mi publicación "Solución de estadísticas serverless con Lambda@Edge" al respecto.

Escribo y creo mis publicaciones usando markdown, que luego se convierte a html con el motor 11ty. El diseño de la página está decidido por el metadato en la sección del frente, 11ty utiliza los diseños que he creado usando Nunjucks. De esta manera, puedo agregar metadatos y controlar cómo se representa la página, puedo inyectar secciones y enlaces.

Mi pipeline de construcción y despliegue se basa en GitHub y las acciones de GitHub, que construyen el sitio en la fusión con la rama principal y sincronizan los archivos html con el cubo S3.

Imagen mostrando la visión general del blog

Visión general de CI/CD

Mi configuración de CI/CD consta de dos partes principales, la parte de construcción y despliegue que se invoca en una fusión a la rama principal. Esta parte es 100% basada en acciones de GitHub y se ve así.

La segunda parte es donde realizo dos cosas principales, genero el cuestionario para el aprendizaje gamificado y uso Polly para generar voz que lee mi publicación en el blog. Esto es una mezcla de acciones de GitHub, que construirán la página y cargarán a un cubo de etapas en S3, esta parte se invoca cuando abro, cierro y modifico una solicitud de fusión. Después de la carga en el cubo de etapas, las acciones de GitHub publicarán un evento en un evento de EventBridge y aquí es donde mi parte basada en AWS toma el control. En este blog nos centraremos en esta parte basada en AWS.

La pipeline CI/CD basada en AWS es impulsada por eventos y serverless, los servicios principales utilizados son StepFunctions, Lambda y EventBridge. El flujo se basa en un patrón de saga donde los servicios de dominio entregan eventos de dominio en el bus de eventos, lo que moverá la saga al siguiente paso.

Imagen mostrando la visión general de cicd

Para resumir un poco la imagen, la acción de GitHub invocará el servicio de información que se comunicará con GitHub para recopilar información sobre la solicitud de fusión, esto luego invocará el servicio de voz que generará la acción de texto a voz de Polly, seguido por el servicio de cuestionario que creará un cuestionario en Kvist.ai, finalmente el servicio de actualización editará el frente del archivo markdown y creará un nuevo compromiso en la rama de la solicitud de fusión.

Ahora, ¿por qué implemento esta pipeline en AWS con StepFunctions y Lambda? ¿Por qué no simplemente implementarlo en las acciones de GitHub? La respuesta es que podría hacerlo, pero necesito llamar repetidamente diferentes API de servicio en AWS y con la integración con kvist.ai ahora era más fácil implementarlo directamente en AWS.

Profundización técnica

Hemos llegado a la parte más divertida de esta publicación, la profundización técnica. En esta parte intentaré explicar y mostrar cómo funciona cada uno de los servicios. Si creamos la imagen de visión general nuevamente pero ahora con más detalles y servicios de AWS, se verá así.

Imagen mostrando la visión general de cicd

Todavía hay mucho en esta imagen, pero no se preocupen, analizaremos cada uno de los servicios uno por uno y discutiremos la arquitectura y el flujo de datos.

Estructura del evento

Primero echemos un vistazo rápido a la estructura del evento, para que entiendan cómo se agregan los datos. He optado por usar el patrón de datos de metadatos donde cada servicio agregará información y publicará un nuevo evento en el bus, para que el siguiente servicio en la saga pueda realizar su parte.

Al final, se publicará un evento como este, tiene toda la información necesaria para que el servicio de actualización cree un nuevo compromiso.

{
    "metadata": {
        "traceid": "UUID"
    },
    "data": {
        "PullRequestInfo": {
            "PullRequestCommitSha": "SHA",
            "PullRequestBranch": "BRANCH",
            "PullRequestNumber": "XYZ"
        },
        "MarkdownFile": {
            "path": "FILENAME.md",
            "fileSlug": "FILE_SLUG"
        },
        "quiz": {
            "gameCode": "123456",
            "url": "https://kvist.ai/123456"
        },
        "Voice": {
            "LanguageCode": "en-US",
            "OutputFormat": "mp3",
            "OutputUri": "S3_URI",
            "RequestCharacters": 6637,
            "TaskStatus": "completed",
            "VoiceId": "Joanna"
        }
    }
}

Normalmente no mantendrías datos del evento de invocación, pero como toda la cadena está construida como un patrón de saga y en lugar de que cada servicio obtenga la misma información una y otra vez, fue más fácil romper un poco las reglas.

Servicio de información

El primer paso es recopilar información sobre la solicitud de fusión. Obtendré qué archivo markdown se actualizó, dónde se puede encontrar el archivo html renderizado en el cubo de etapas, de qué rama se originó la solicitud de fusión. Esto se hace con un par de funciones Lambda que se ejecutan en secuencia en un StepFunction.

Imagen mostrando el gráfico de Information StepFunctions

Para comunicarme con GitHub uso Octokit. A continuación se muestran fragmentos de la plantilla de CloudFormation y el código utilizado para llamar a la API de GitHub.


CollectPullRequestInfoStateMachineStandard:
  Type: AWS::Serverless::StateMachine
  Properties:
    DefinitionUri: statemachine/collect-info.asl.yaml
    Tracing:
      Enabled: true
    DefinitionSubstitutions:
      FetchPullRequestInfoFunctionArn: !GetAtt FetchPullRequestInfoFunction.Arn
      FetchMarkdownFilePathFunctionArn: !GetAtt FetchMarkdownFilePathFunction.Arn
      FetchHtmlFilePathFunctionArn: !GetAtt FetchHtmlFilePathFunction.Arn
      EventBridgeBusName:
        Fn::ImportValue: !Sub ${InfraStackName}:eventbridge-bus-name
    Policies:
      - Statement:
          - Effect: Allow
            Action:
              - logs:*
            Resource: "*"
      - LambdaInvokePolicy:
          FunctionName: !Ref FetchMarkdownFilePathFunction
      - LambdaInvokePolicy:
          FunctionName: !Ref FetchPullRequestInfoFunction
      - LambdaInvokePolicy:
          FunctionName: !Ref FetchHtmlFilePathFunction
      - EventBridgePutEventsPolicy:
          EventBusName:
            Fn::ImportValue: !Sub ${InfraStackName}:eventbridge-bus-name
    Type: STANDARD

FetchPullRequestInfoFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: lambda/FetchPullRequestInfo
    Handler: app.handler
    Runtime: nodejs14.x
    Policies:
      - AWSLambdaBasicExecutionRole
      - SecretsManagerReadWrite
    Environment:
      Variables:
        REPO: !Ref Repo
        OWNER: !Ref RepoOwner
        APP_SECRETS: !Ref AppSecrets

exports.handler = async (event) => {
  pullRequestNumber = event.detail.pr_number;
  if (pullRequestNumber == -1) {
    throw new Error("Pull Request Info not available!");
  }

  await initializeOctokit();
  const pullRequestInfo = await getPullRequest(pullRequestNumber);
  return pullRequestInfo;
};

const getPullRequest = async (pullRequestNumber) => {
  if (octokit) {
    const result = await octokit.rest.pulls.get({
      owner: process.env.OWNER,
      repo: process.env.REPO,
      pull_number: pullRequestNumber,
    });

    prData = {};
    prData["PullRequestCommitSha"] = result.data.head.sha;
    prData["PullRequestBranch"] = result.data.head.ref;
    prData["PullRequestNumber"] = pullRequestNumber;
    return prData;
  }
};

La lógica en esta primera parte no es tan compleja, se trata principalmente de obtener la información correcta.

Servicio de voz

La parte más compleja de toda esta configuración es la generación de voz con Polly. Comenzaré con una función Lambda extract, que obtendrá el archivo HTML que se utilizará. En la parte transform creo el SSML utilizado por Polly al leer la publicación.

Imagen mostrando el gráfico de Voice StepFunctions

Ahora, no entraré en detalles sobre esta parte, ya que en realidad es un blog propio. Para obtener más información, consulta mi publicación Voz serverless con Amazon Polly o mira mi conversación con Johannes Koch en YouTube

Servicio de cuestionario

Ahora es el momento de generar el cuestionario. Esto se hace llamando a una API proporcionada por kvist.ai, esto se llama usando una tarea de punto final HTTP en StepFunctions. Esta es una excelente manera de llamar a una API sin tener que escribir ningún tipo de código.

Imagen mostrando el gráfico de Quiz StepFunctions

En la función Lambda extract elimino todas las etiquetas html del blog y creo la solicitud que se envía a la API. El código para este paso usará BeautifulSoup en Python para que el proceso sea fluido.


import json
import os
import boto3
from bs4 import BeautifulSoup


def handler(event, context):
    bucket = event["HtmlFile"]["Bucket"]
    key = event["HtmlFile"]["Key"]
    s3 = boto3.client("s3")

    content = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
    extractedContent = extract(content)

    etlBucket = os.environ["ETL_BUCKET"]
    base = os.path.splitext(key)[0]

    txt_key = base + ".txt"
    s3.put_object(Body=extractedContent, Bucket=etlBucket, Key=txt_key)

    return {"Bucket": etlBucket, "Key": txt_key}


def extract(contents):
    soup = BeautifulSoup(contents)
    return soup.get_text("\n")

Para crear un punto final HTTP, usa el estado http:invoke, a continuación se muestra un fragmento del archivo asl para la máquina de estados.

Call kvist.ai API:
  Type: Task
  Resource: arn:aws:states:::http:invoke
  Parameters:
    ApiEndpoint: ${ApiEndPoint}
    Authentication:
      ConnectionArn: ${ConnectionArn}
    Headers:
      Content-Type: application/json
    RequestBody:
      prompt.$: $.Prompt
      numberOfQuestions: 5
      language: English
    Method: POST

Mirando en Workflow Studie terminaremos con una configuración como esta.

Imagen mostrando la configuración de la tarea

Para una profundización más detallada sobre el punto final HTTP de StepFunctions, recomiendo mi publicación Punto final HTTP de AWS StepFunctions desmitificado

Servicio de actualización

El último paso en esta saga es actualizar el archivo markdown, agregar el frente correcto y crear un nuevo compromiso.

Imagen mostrando el gráfico de actualización de StepFunctions

En la primera parte del StepFunction copio el archivo mp3 que Polly generó a la ubicación correcta, no haré el seguimiento de los archivos mp3 en el repositorio ya que son blobs enormes y pueden reproducirse. En su lugar, los copio a un cubo S3 y durante el proceso de construcción, los archivos se extraerán de este cubo y se colocarán en el cubo de producción.

Al actualizar el frente, uso gray-matter para que sea un proceso fácil. El código verificará si hay una sección de voz y cuestionario en el evento, y de ser así, agregará nuevas etiquetas en la parte del frente.

const updateFrontMatter = async (filePath, event) => {
  const fileContent = fs.readFileSync("/tmp/" + filePath, "utf8");
  const { data, content } = matter(fileContent);
  if (event.Voice) {
    data.audio = "CREATE_THE_AUDIO_PATH";
  } else if (event.Quiz) {
    data.quiz = event.Quiz.url;
  }
  const updatedContents = matter.stringify(content, data);
  fs.writeFileSync("/tmp/" + filePath, updatedContents);
};

Esto ahora creará una sección frontal que se verá así.

---
title: BLOG TITEL
description: DESCRIPTION
audio: /assets/audio/FILE_SLUG/en-US/Joanna.mp3
quiz: https://kvist.ai/12345
---

Las partes importantes aquí son audio y quiz, ya que se utilizarán para representar secciones especiales en la publicación del blog.

La última parte creará un nuevo compromiso en el repositorio, esto una vez más se hace con Octokit, implicará varios pasos como crear el compromiso, cargar archivos al repositorio, etc. A continuación se muestra una versión simplificada del código.


const AWS = require("aws-sdk");
const { Octokit } = require("@octokit/rest");
const path = require("path");
const fs = require("fs");
const https = require("https");
const fg = require("fast-glob");
const { readFile } = require("fs").promises;

let octokit;
const owner = process.env.OWNER;
const repo = process.env.REPO;
let pullRequestNumber = -1;

exports.handler = async (event) => {
  pullRequestNumber = event.PullRequestInfo.PullRequestNumber;
  if (pullRequestNumber == -1) {
    throw new Error("Pull Request Info not available!");
  }

  await initializeOctokit();
  const fileSlug = event.MarkdownFile.fileSlug;
  const fileName = path.basename(event.MarkdownFile.path);

  await uploadToRepo(`/tmp/${fileSlug}`, event.PullRequestInfo);
};

const initializeOctokit = async () => {
  if (!octokit) {
    const gitHubSecret = await getSecretValue(
      process.env.APP_SECRETS,
      "github-token"
    );
    octokit = new Octokit({ auth: gitHubSecret });
  }
};

const uploadToRepo = async (coursePath, pullRequestInfo) => {
  const currentCommit = pullRequestInfo.PullRequestCommitSha;
  const branch = pullRequestInfo.PullRequestBranch;
  const filesPaths = await fg(coursePath + "/**/*.md");
  const filesBlobs = await Promise.all(filesPaths.map(createBlobForFile()));

  const pathsForBlobs = filesPaths.map((fullPath) =>
    path.relative(coursePath, fullPath)
  );

  const newTree = await createNewTree(
    filesBlobs,
    pathsForBlobs,
    pullRequestInfo.PullRequestCommitSha
  );
  const commitMessage = "Added quiz and audio file";
  const newCommit = await createNewCommit(
    commitMessage,
    newTree.sha,
    pullRequestInfo.PullRequestCommitSha
  );
  await setBranchToCommit(newCommit.sha, pullRequestInfo);
};

const getFileAsUTF8 = (filePath) => readFile(filePath, "utf8");

const createBlobForFile = () => async (filePath) => {
  const utf8Content = await getFileAsUTF8(filePath);
  const blobData = await octokit.rest.git.createBlob({
    owner: owner,
    repo: repo,
    content: utf8Content,
    encoding: "utf-8",
  });
  return blobData.data;
};

const createNewTree = async (blobs, paths, parentTreeSha) => {
  const tree = blobs.map(({ sha }, index) => ({
    path: paths[index],
    mode: `100644`,
    type: `blob`,
    sha,
  }));
  const { data } = await octokit.rest.git.createTree({
    owner: owner,
    repo: repo,
    tree,
    base_tree: parentTreeSha,
  });
  return data;
};

const createNewCommit = async (message, currentTreeSha, currentCommitSha) =>
  (
    await octokit.rest.git.createCommit({
      owner: owner,
      repo: repo,
      message,
      tree: currentTreeSha,
      parents: [currentCommitSha],
    })
  ).data;

const setBranchToCommit = (commitSha, pullRequestInfo) =>
  octokit.rest.git.updateRef({
    owner: owner,
    repo: repo,
    ref: `heads/${pullRequestInfo.PullRequestBranch}`,
    sha: commitSha,
  });

Extendiendo la solución

Como toda la solución de CI/CD está construida como un sistema impulsado por eventos, utilizando el patrón de saga, la extensión con un nuevo paso es muy sencillo. Aquí es donde una solución impulsada por eventos realmente brilla. Podría desarrollar todo el servicio de cuestionarios por separado y simplemente invocarlo en el mismo evento que el servicio de voz. Cuando estuviera satisfecho con el resultado, actualizaría la saga y cambiaría qué eventos invocan qué partes.

El tiempo total para extender esta solución fue menos de una hora. No tuve que actualizar flujos complejos, solo agregar un servicio y cambiar un poco la saga. Hay una razón por la que realmente disfruto trabajar con sistemas serverless e impulsados ​​por eventos.

Representar la publicación

Como mencioné antes, uso 11TY para representar las publicaciones del blog de markdown a html. Durante este proceso, 11TY usará el diseño que he creado para las publicaciones del blog.

En este diseño tengo secciones '% if audio %' y '% if quiz %' para verificar si esto está disponible en el frente. Si lo está, agregará las etiquetas html dentro de los bloques if. Agregando automáticamente audio y el cuestionario basados ​​en los datos del frente.

---
layout: default
---

<article class="max-w-5xl mx-auto">

    <div id="content" class="prose text-gray-800 max-w-none">
        % if audio %
        <audio id="audio" controls="true" class="flex w-full">
            <source src= type="audio/mpeg">
            Your browser does not support the audio element.
        </audio>
        <div class="text-end text-blue-500 italic">Voice provided by Amazon Polly</div>
        % endif %


        % if quiz %
        <br>
        <div class="prose text-gray-800 max-w-none font-mono">
        <p class="text-2xl font-extrabold ">Post Quiz</p>
        
        <p class="font-normal font-mono">Test what you just learned by doing this <a rel="noreferrer" target="_blank" href="">five question quiz - .</a><br>
        Powered by <a rel="noreferrer" target="_blank" href="https://kvist.ai">kvist.ai</a> your AI generated quiz solution!</p>
        </div>
        % endif %
    </div>

</article>

Palabras finales

Esta fue una publicación donde explico cómo agregué un paso en mi pipeline de CI/CD impulsado por eventos para crear una experiencia de aprendizaje gamificada para ti, mis lectores. Si disfrutas la parte del cuestionario, visita kvist.ai

Consulta Mi manual serverless para algunos de los conceptos mencionados en esta publicación.

No olvides seguirme en LinkedIn y X para más contenido, y lee el resto de mis Blogs

Como dice Werner! ¡Ahora ve a construir!

If this saved you an afternoon, you can buy me a coffee.