Cómo extendí mi blog con aprendizaje gamificado

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.

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.

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í.

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.

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.

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.

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: POSTMirando en Workflow Studie terminaremos con una configuración como esta.

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.

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!