aws, event-driven, serverless

Como eu estendo meu blog com aprendizado gamificado

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

Este arquivo foi traduzido automaticamente por IA, erros podem ocorrer

Uma das principais razões pelas quais escrevo todos esses posts é ajudar as pessoas a aprender sobre cloud e AWS. Como você saberia se entendeu o que leu e aprendeu com isso? Recebi uma sugestão para adicionar um quiz ao final do blog, baseado no conteúdo do post. Foi uma ótima sugestão e comecei a trabalhar nisso.

kvist.ai é uma solução de quiz alimentada por IA Generativa, impulsionada pelo AWS, tecnologias Serverless e Amazon Bedrock. Ao alimentar esta solução com meu blog, ela cria automaticamente um quiz com base no conteúdo, que é exatamente o que eu precisava. Agora, conhecendo um dos fundadores, Lars Jacobsson, consegui acesso a alguns recursos antecipados que ajudaram a automatizar o processo.

Neste post, discutirei como estendi minha solução CI/CD para adicionar uma etapa extra para criar o quiz e adicioná-lo ao blog. Minha pipeline é executada no AWS e é serverless, orientada a eventos e alimentada pelo AWS StepFunctions, Lambda, EventBridge e muito mais.

Visão geral do blog

Para garantir que todos entendam como meu blog é criado e distribuído, isso facilitará a compreensão da configuração mais tarde. Meu blog consiste em HTML estático puro, sem React, Vue ou algo semelhante. O blog é distribuído pelo CloudFront e S3. Eu uso Lambda@Edge e funções do CloudFront para manipular a resposta e coletar estatísticas. Confira meu post "Solução de estatísticas serverless com Lambda@Edge" sobre esse assunto.

Eu escrevo e crio meus posts usando markdown, que é convertido para html com o motor 11ty. O layout da página é decidido pelo metadado na seção front matter, e o 11ty usa os layouts que criei usando Nunjucks. Dessa forma, posso adicionar metadados e controlar como a página é renderizada, posso injetar seções e links.

Minha pipeline de build e deploy é baseada no GitHub e nas ações do GitHub, que constroem o site na fusão para a branch principal e sincronizam os arquivos html para o bucket S3.

Imagem mostrando visão geral do blog

Visão geral do CI/CD

Minha configuração CI/CD consiste em duas partes principais: a parte de build e deploy que é invocada em uma fusão para a branch principal. Esta parte é 100% baseada em ações do GitHub e se parece com a parte acima.

A segunda parte é onde eu performo duas coisas principais: gero o quiz para o aprendizado gamificado e uso o Polly para gerar a voz que lê meu post. Isso é uma mistura de ações do GitHub, que construirão a página e farão upload para um bucket de staging no S3. Esta parte é invocada quando eu abro, fecho e modifico uma pull-request. Após o upload para o bucket de staging ser concluído, as ações do GitHub postarão um evento em um event-bus do EventBridge e é aí que minha parte baseada no AWS entra em ação. Neste blog, nos concentraremos nesta parte baseada no AWS.

A pipeline CI/CD baseada no AWS é orientada a eventos e serverless, os principais serviços usados são StepFunctions, Lambda e EventBridge. O fluxo é baseado em um padrão saga onde os serviços de domínio entregam eventos de domínio no event-bus, o que moverá a saga para a próxima etapa.

Imagem mostrando visão geral do cicd

Para resumir um pouco a imagem, a ação do GitHub invocará o serviço de Informações que contatará o GitHub para coletar informações sobre a pull-request, isso então invocará o serviço de Voz que gerará a ação de texto para fala do Polly, seguido pelo serviço de Quiz criando um quiz no Kvist.ai, finalmente o serviço de atualização editará o front matter do arquivo markdown e criará um novo commit na branch da pull-request.

Agora, por que implemento esta pipeline no AWS com StepFunctions e Lambda? Por que não apenas implementá-la nas ações do GitHub? A resposta é que eu poderia fazer isso, mas preciso chamar repetidamente diferentes APIs de Serviço no AWS e com a integração com kvist.ai agora era simplesmente mais fácil implementá-lo diretamente no AWS.

Mergulho técnico

Chegamos agora à parte mais divertida deste post, o mergulho técnico. Nesta parte, tentarei explicar e mostrar como cada um dos serviços funciona. Se criarmos a imagem de visão geral novamente, mas agora com mais detalhes e serviços do AWS, ela será assim.

Imagem mostrando visão geral do cicd

Ainda há muita coisa acontecendo nesta imagem, mas não se preocupe, vamos entrar em cada um dos serviços um por um e discutir a arquitetura e o fluxo de dados.

Estrutura de eventos

Primeiro, vamos dar uma rápida olhada na estrutura do evento, para que você entenda como os dados são adicionados. Optei por usar o padrão de dados de metadados onde cada serviço adicionará informações e postará um novo evento no bus, para que o próximo serviço na saga possa executar sua parte.

No final, um evento assim será postado, ele contém todas as informações necessárias para o serviço de atualização criar um novo commit.

{
    "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 você não manteria dados do evento de invocação, mas como toda a cadeia é construída como um padrão saga e em vez de cada serviço buscar as mesmas informações repetidamente, foi mais fácil quebrar um pouco as regras.

Serviço de Informações

O primeiro passo é coletar informações sobre a pull-request. Vou buscar qual arquivo markdown foi atualizado, onde o arquivo html renderizado pode ser encontrado no bucket de staging, de qual branch a pull-request se originou. Isso é feito com algumas funções Lambda executando em sequência em um StepFunction.

Imagem mostrando o Gráfico do StepFunctions de Informações

Para me comunicar com o GitHub, uso Octokit. Abaixo estão trechos do Template CloudFormation e do código usado para chamar a API do 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;
  }
};

A lógica nesta primeira parte não é tão complexa, é principalmente sobre buscar as informações corretas.

Serviço de Voz

A parte mais complexa de toda esta configuração é a geração de voz com o Polly. Vou começar com uma função Lambda extract, que buscará o arquivo HTML que será usado. Na parte transform, crio o SSML usado pelo Polly ao ler o post.

Imagem mostrando o Gráfico do StepFunctions de Voz

Agora, não vou entrar em detalhes sobre esta parte, pois ela é realmente um blog por si só. Para saber mais sobre isso, confira meu post Voz serverless com Amazon Polly ou assista minha conversa com Johannes Koch no YouTube

Serviço de Quiz

Agora é hora de gerar o quiz. Isso é feito chamando uma API fornecida pelo kvist.ai, isso é chamado usando uma tarefa de ponto de extremidade HTTP no StepFunctions. Esta é uma ótima maneira de chamar uma API sem ter que escrever nenhum tipo de código.

Imagem mostrando o Gráfico do StepFunctions de Quiz

Na função Lambda extract, eu removo todas as tags html do blog e crio o prompt que é enviado para a API. O código para esta etapa usará BeautifulSoup em Python para tornar o processo suave.


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 criar um Ponto de Extremidade HTTP, você usa o estado http:invoke, abaixo está um trecho do arquivo asl para a 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

Olhando no Workflow Studio, terminaremos com uma configuração assim.

Imagem mostrando a configuração da tarefa

Para um mergulho mais profundo no Ponto de Extremidade HTTP do StepFunctions, recomendo meu post Ponto de Extremidade HTTP do AWS StepFunctions desmistificado

Serviço de Atualização

O último passo nesta saga é atualizar o arquivo markdown, adicionar o front matter correto e criar um novo commit.

Imagem mostrando o Gráfico do StepFunctions de Atualização

Na primeira parte do StepFunction, copio o arquivo mp3 gerado pelo Polly para o local correto. Não vou verificar os arquivos mp3 no repositório, pois são blobs enormes e podem ser reproduzidos. Em vez disso, copio-os para um bucket S3 e durante o processo de build, os arquivos serão puxados deste bucket e colocados no bucket de produção.

Ao atualizar o front matter, uso gray-matter para tornar o processo mais fácil. O código verificará se há uma seção de voz e quiz no evento e, se houver, adicionará novas tags na parte do front matter.

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);
};

Isso criará uma seção de front matter como esta.

---
title: TÍTULO DO BLOG
description: DESCRIÇÃO
audio: /assets/audio/FILE_SLUG/en-US/Joanna.mp3
quiz: https://kvist.ai/12345
---

As partes importantes aqui são audio e quiz, pois serão usadas para renderizar seções especiais no post do blog.

A última parte criará um novo commit no repositório, isso é feito novamente com o Octokit, envolvendo várias etapas como criar o commit, enviar arquivos para o repositório, etc. Abaixo está uma versão simplificada do 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,
  });

Estendendo a solução

Como toda a solução CI/CD é construída como um sistema orientado a eventos, usando o padrão saga, a extensão com uma nova etapa é muito simples. É aqui que uma solução orientada a eventos realmente brilha. Eu poderia desenvolver todo o serviço de quiz ao lado e apenas invocá-lo no mesmo evento que o serviço de voz. Quando estivesse satisfeito com o resultado, atualizaria a saga e alteraria quais eventos invocam quais partes.

O tempo geral para estender esta solução foi menos de uma hora. Não precisei atualizar fluxos complexos, apenas adicionar um serviço e alterar um pouco a saga. Há uma razão pela qual eu realmente gosto de trabalhar com sistemas serverless e orientados a eventos.

Renderizar o post

Como mencionado antes, uso o 11TY para renderizar os posts do blog de markdown para html. Durante esse processo, o 11TY usará o layout que criei para posts de blog.

Neste layout, tenho seções '% if audio %' e '% if quiz %' para verificar se isso está disponível no front matter. Se estiver, adicionará as tags html dentro dos blocos if. Adicionando automaticamente áudio e o quiz com base nos dados do front matter.

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

Palavras Finais

Este foi um post onde explico como adicionei uma etapa na minha pipeline CI/CD orientada a eventos para criar uma experiência de aprendizado gamificada para você, meus leitores. Se você gostou da parte do quiz, vá conferir kvist.ai

Confira meu Handbook Serverless para alguns dos conceitos mencionados neste post.

Não se esqueça de me seguir no LinkedIn e X para mais conteúdo, e leia o restante dos meus Blogs

Como Werner diz! Agora vá construir!

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