aws, event-driven, serverless

Comment j'étends mon blog avec un apprentissage gamifié

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

Ce fichier a ete traduit automatiquement par IA, des erreurs peuvent survenir

L'une des principales raisons pour lesquelles j'écris tous ces articles de blog est d'aider les gens à apprendre sur le cloud et AWS. Comment savez-vous que vous avez compris ce que vous avez lu et en avez appris ? J'ai reçu une suggestion pour ajouter un quiz à la fin du blog, basé sur le contenu du blog. C'était une excellente suggestion et je me suis mis au travail.

kvist.ai est une solution de quiz alimentée par l'IA générative, puissance par AWS, les technologies Serverless et Amazon Bedrock. En alimentant cette solution avec mon blog, elle crée automatiquement un quiz basé sur le contenu, ce qui est exactement ce dont j'avais besoin. Maintenant, connaissant l'un des fondateurs, Lars Jacobsson, j'ai eu accès à certaines fonctionnalités précoces qui m'ont aidé à automatiser le processus.

Dans cet article, je vais discuter comment j'ai étendu ma solution CI/CD pour ajouter une étape supplémentaire pour créer le quiz et l'ajouter au blog. Mon pipeline fonctionne sur AWS et est serverless, piloté par événements, et alimenté par AWS StepFunctions, Lambda, EventBridge, et plus encore.

Aperçu du blog

Pour vous assurer que vous comprenez tous comment mon blog est créé et distribué, cela facilitera la compréhension de la configuration plus tard. Mon blog est composé de simples fichiers HTML statiques, pas de React, Vue, ou quoi que ce soit de similaire. Le blog est distribué via CloudFront et S3, j'utilise Lambda@Edge et les fonctions CloudFront pour manipuler la réponse et collecter des statistiques, consultez mon article "Solution de statistiques serverless avec Lambda@Edge" à ce sujet.

J'écris et crée mes articles en utilisant markdown, celui-ci est ensuite converti en html avec le moteur 11ty. La mise en page de la page est décidée par les métadonnées dans la section front matter, 11ty utilise les mises en page que j'ai créées avec Nunjucks. De cette façon, je peux ajouter des métadonnées et contrôler comment la page est rendue, je peux injecter des sections et des liens.

Mon pipeline de build et de déploiement est basé sur GitHub et les actions GitHub, qui construisent le site au merge sur la branche principale et synchronisent les fichiers html vers le bucket S3.

Image montrant l'aperçu du blog

Aperçu du CI/CD

Ma configuration CI/CD comprend deux parties principales, la partie de build et de déploiement qui est invoquée lors d'un merge sur la branche principale. Cette partie est 100% basée sur les actions GitHub et ressemble à la partie ci-dessus.

La deuxième partie est où j'effectue deux choses principales, je génère le quiz pour l'apprentissage gamifié et j'utilise Polly pour générer la voix qui lit mon article de blog. C'est un mélange d'actions GitHub, qui vont construire la page et télécharger vers un bucket de staging dans S3, cette partie est invoquée lorsque j'ouvre, ferme, et modifie une pull-request. Après le téléchargement vers le bucket de staging, les actions GitHub vont publier un événement sur un bus d'événements EventBridge et c'est là que ma partie basée sur AWS prend le relais. Dans ce blog, nous allons nous concentrer sur cette partie basée sur AWS.

Le pipeline CI/CD basé sur AWS est piloté par événements et serverless, les services principaux utilisés sont StepFunctions, Lambda et EventBridge. Le flux est basé sur un modèle saga où les services de domaine se transmettent en publiant des événements de domaine sur le bus d'événements, ce qui fera passer la saga à l'étape suivante.

Image montrant l'aperçu du cicd

Pour résumer un peu l'image, l'action GitHub va invoquer le service d'Information qui va contacter GitHub pour collecter des informations sur la pull-request, cela va ensuite invoquer le service Voice qui va générer l'action text to speech de Polly, suivi du service Quiz créant un quiz sur Kvist.ai, enfin le service de mise à jour va éditer le front matter du fichier markdown et créer un nouveau commit sur la branche de la pull-request.

Maintenant, pourquoi implémentez-je ce pipeline sur AWS avec StepFunctions et Lambda ? Pourquoi ne pas simplement l'implémenter dans les actions GitHub ? La réponse est que je pourrais le faire, mais j'ai besoin d'appeler de manière répétée différentes API de services dans AWS et avec l'intégration de kvist.ai maintenant c'était simplement plus facile de l'implémenter directement sur AWS.

Plongée technique

Nous sommes maintenant arrivés à la partie la plus amusante de cet article, la plongée technique. Dans cette partie, je vais essayer d'expliquer et de montrer comment chacun des services fonctionne. Si nous créons à nouveau l'image de synthèse mais maintenant avec plus de détails et des services AWS, elle ressemblera à ceci.

Image montrant l'aperçu du cicd

Il y a encore beaucoup de choses dans cette image, mais ne vous inquiétez pas, nous allons examiner chacun des services un par un et discuter de l'architecture et du flux de données.

Structure des événements

Tout d'abord, jetons un rapide coup d'œil à la structure des événements, afin que vous compreniez comment les données sont ajoutées. J'ai opté pour le modèle de données métadonnées où chaque service ajoutera des informations et publiera un nouvel événement sur le bus, de sorte que le service suivant dans la saga puisse effectuer sa partie.

À la fin, un événement ressemblant à ceci sera publié, il contient toutes les informations nécessaires pour que le service de mise à jour crée un nouveau 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"
        }
    }
}

Normalement, vous ne garderiez pas les données de l'événement d'invocation, mais comme toute la chaîne est construite comme un modèle saga et au lieu que chaque service récupère les mêmes informations encore et encore, il était plus facile de contourner un peu les règles.

Service d'Information

La toute première étape est de collecter des informations sur la pull-request. Je vais récupérer quel fichier markdown a été mis à jour, où le fichier html rendu peut être trouvé dans le bucket de staging, de quelle branche la pull-request provient. Cela est fait avec quelques fonctions Lambda exécutées séquentiellement dans un StepFunction.

Image montrant le graphique Information StepFunctions

Pour communiquer avec GitHub, j'utilise Octokit. Ci-dessous sont des extraits du modèle CloudFormation et du code utilisé pour appeler l'API 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 logique dans cette première partie n'est pas si complexe, il s'agit principalement de récupérer les bonnes informations.

Service Voice

La partie la plus complexe de toute cette configuration est la génération de la voix avec Polly. Je vais commencer par une fonction Lambda extract, qui va récupérer le fichier HTML qui sera utilisé. Dans la partie transform, je crée le SSML utilisé par Polly pour lire l'article.

Image montrant le graphique Voice StepFunctions

Maintenant, je ne vais pas entrer dans les détails de cette partie car c'est en fait un blog en soi. Pour en savoir plus, consultez mon article Serverless voice with Amazon Polly ou regardez ma conversation avec Johannes Koch sur YouTube

Service Quiz

Il est maintenant temps de générer le quiz. Cela se fait en appelant une API fournie par kvist.ai, celle-ci est appelée à l'aide d'une tâche HTTP Endpoint dans StepFunctions. C'est une excellente façon d'appeler une API sans avoir à écrire de code.

Image montrant le graphique Quiz StepFunctions

Dans la fonction Lambda extract, je supprime toutes les balises html du blog, et je crée le prompt qui est envoyé à l'API. Le code pour cette étape utilisera BeautifulSoup en Python pour rendre le processus fluide.


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

Pour créer un HTTP EndPoint, vous utilisez l'état http:invoke, ci-dessous est un extrait du fichier asl pour la machine d'état.

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

En regardant dans Workflow Studio, nous aboutirons à une configuration comme celle-ci.

Image montrant la configuration de la tâche

Pour une plongée plus approfondie dans StepFunctions HTTP Endpoint, je recommande mon article AWS StepFunctions HTTP Endpoint démystifié

Service de mise à jour

La dernière étape de cette saga est de mettre à jour le fichier markdown, d'ajouter le bon front matter, et de créer un nouveau commit.

Image montrant le graphique update StepFunctions

Dans la première partie du StepFunction, je copie le fichier mp3 généré par Polly à l'emplacement correct, je ne vais pas vérifier les fichiers mp3 dans le dépôt car ce sont des blobs énormes et peuvent être reproduits. Au lieu de cela, je les copie dans un bucket S3 et pendant le processus de build, les fichiers seront récupérés à partir de ce bucket et placés dans le bucket de production.

Lors de la mise à jour du front matter, j'utilise gray-matter pour en faire un processus facile. Le code vérifiera s'il y a une section voice et quiz dans l'événement, et si oui, ajoutera de nouvelles balises dans la partie 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);
};

Cela va maintenant créer une section front matter ressemblant à celle-ci.

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

Les parties importantes ici sont audio et quiz car elles seront utilisées pour rendre des sections spéciales dans l'article de blog.

La dernière partie va créer un nouveau commit dans le dépôt, cela est encore une fois fait avec Octokit, cela impliquera plusieurs étapes comme créer le commit, télécharger des fichiers vers le dépôt, etc. Ci-dessous est une version simplifiée du code.


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

Extension de la solution

Comme toute la solution CI/CD est construite comme un système piloté par événements, en utilisant le modèle saga, l'extension avec une nouvelle étape est très simple. C'est là qu'une solution pilotée par événements brille vraiment. J'ai pu développer tout le service de quiz sur le côté et l'invoquer sur le même événement que le service de voix. Lorsque j'étais satisfait du résultat, j'ai mis à jour la saga et changé les événements qui invoquaient les parties.

Le temps global pour étendre cette solution a été de moins d'une heure. Je n'ai pas eu à mettre à jour des flux complexes, juste ajouter un service et changer un peu la saga. Il y a une raison pour laquelle j'aime vraiment travailler avec des systèmes serverless et pilotés par événements.

Rendu de l'article

Comme mentionné précédemment, j'utilise 11TY pour rendre les articles de blog à partir de markdown vers html. Pendant ce processus, 11TY utilisera la mise en page que j'ai créée pour les articles de blog.

Dans cette mise en page, j'ai des sections '% if audio %' et '% if quiz %' pour vérifier si cela est disponible dans le front matter. Si c'est le cas, il ajoutera les balises html à l'intérieur des blocs if. Ajout automatique de l'audio et du quiz en fonction des données du 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>

Dernières paroles

Ceci était un article où j'explique comment j'ai ajouté une étape dans mon pipeline CI/CD piloté par événements pour créer une expérience d'apprentissage gamifiée pour vous, mes lecteurs. Si vous appréciez la partie quiz, allez voir kvist.ai

Consultez Mon handbook serverless pour certains des concepts mentionnés dans cet article.

N'oubliez pas de me suivre sur LinkedIn et X pour plus de contenu, et lisez le reste de mes Blogs

Comme le dit Werner ! Maintenant, allez construire !

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