aws, event-driven, serverless

Wie ich meinen Blog mit gamifiziertem Lernen erweitere

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

Diese Datei wurde automatisch von KI ubersetzt, es konnen Fehler auftreten

Einer der Hauptgründe, warum ich all diese Blog-Posts schreibe, ist, Menschen dabei zu helfen, mehr über Cloud und AWS zu lernen. Woher wüssten Sie, dass Sie verstanden haben, was Sie gelesen und daraus gelernt haben? Ich habe einen Vorschlag bekommen, am Ende des Blogs einen Quiz hinzuzufügen, der auf dem Inhalt des Blogs basiert. Das war ein großartiger Vorschlag und ich habe damit angefangen, daran zu arbeiten.

kvist.ai ist eine Generative KI-gestützte Quiz-Lösung, die von AWS, Serverless-Technologien und Amazon Bedrock angetrieben wird. Wenn ich diese Lösung mit meinem Blog füttere, erstellt sie automatisch einen Quiz basierend auf dem Inhalt, was genau das ist, was ich brauchte. Da ich einen der Gründer, Lars Jacobsson, kenne, hatte ich Zugang zu einigen frühen Funktionen, die mir geholfen haben, den Prozess zu automatisieren.

In diesem Beitrag werde ich besprechen, wie ich meine CI/CD-Lösung erweitert habe, um einen zusätzlichen Schritt hinzuzufügen, der den Quiz erstellt und ihn zum Blog hinzufügt. Meine Pipeline läuft in AWS und ist serverless, ereignisgesteuert und wird von AWS StepFunctions, Lambda, EventBridge und mehr angetrieben.

Blog-Übersicht

Um sicherzustellen, dass Sie alle verstehen, wie mein Blog erstellt und verteilt wird, wird dies es einfacher machen, die Einrichtung später zu verstehen. Mein Blog besteht aus rein altem statischem HTML, kein React, Vue oder ähnliches. Der Blog wird über CloudFront und S3 verteilt, ich verwende Lambda@Edge und CloudFront-Funktionen, um die Antwort zu manipulieren und Statistiken zu sammeln, schauen Sie sich meinen Beitrag "Serverless-Statistiken-Lösung mit Lambda@Edge" zu diesem Thema an.

Ich schreibe und erstelle meine Beiträge mit Markdown, dies wird dann mit der 11ty-Engine in HTML konvertiert. Das Layout der Seite wird durch die Metadaten im Frontmatter-Abschnitt entschieden, 11ty verwendet die Layouts, die ich mit Nunjucks erstellt habe. Auf diese Weise kann ich Metadaten hinzufügen und steuern, wie die Seite gerendert wird, ich kann Abschnitte und Links einfügen.

Meine Build- und Deploy-Pipeline basiert auf GitHub und GitHub-Aktionen, die die Seite beim Merge in den Hauptbranch bauen und die HTML-Dateien mit dem S3-Bucket synchronisieren.

Bild zeigt Blog-Übersicht

CI/CD-Übersicht

Meine CI/CD-Einrichtung besteht aus zwei Hauptteilen, dem Build- und Deploy-Teil, der bei einem Merge in den Hauptbranch aufgerufen wird. Dieser Teil ist zu 100% GitHub-Aktionen-basiert und sieht wie der Teil oben aus.

Der zweite Teil ist, wo ich zwei Haupt Dinge ausführe, ich generiere den Quiz für das gamifizierte Lernen und ich verwende Polly, um eine Stimme zu generieren, die meinen Blog-Beitrag liest. Dies ist eine Mischung aus GitHub-Aktionen, die die Seite bauen und in einen Staging-Bucket in S3 hochladen, dieser Teil wird aufgerufen, wenn ich ein Pull-Request öffne, schließe und modifiziere. Nach dem Hochladen in den Staging-Bucket werden GitHub-Aktionen ein Ereignis an einen EventBridge-Ereignisbus senden, und hier übernimmt mein AWS-basierter Teil. In diesem Blog werden wir uns auf diesen AWS-basierten Teil konzentrieren.

Die AWS-basierte CI/CD-Pipeline ist ereignisgesteuert und serverless, die primären verwendeten Dienste sind StepFunctions, Lambda und EventBridge. Der Ablauf basiert auf einem Saga-Muster, bei dem die Domänendienste Domänenereignisse auf dem Ereignisbus weitergeben, was die Saga zum nächsten Schritt bewegt.

Bild zeigt cicd-Übersicht

Um das Bild ein wenig zusammenzufassen, wird GitHub-Aktion den Informationsdienst aufrufen, der GitHub kontaktiert, um Informationen über das Pull-Request zu sammeln, dies wird dann den Sprachedienst aufrufen, der die Polly-Text-zu-Sprache-Aktion generiert, gefolgt vom Quiz-Dienst, der einen Quiz auf Kvist.ai erstellt, schließlich wird der Update-Dienst den Frontmatter der Markdown-Datei bearbeiten und einen neuen Commit auf dem Pull-Request-Branch erstellen.

Nun, warum implementiere ich diese Pipeline in AWS mit StepFunctions und Lambda? Warum nicht einfach in GitHub-Aktionen implementieren? Die Antwort ist, dass ich das tun könnte, aber ich muss wiederholt verschiedene Service-APIs in AWS aufrufen, und mit der Integration mit kvist.ai war es jetzt einfach einfacher, es direkt in AWS zu implementieren.

Technisches Deep Dive

Wir sind jetzt beim unterhaltsamsten Teil dieses Beitrags angelangt, dem technischen Deep Dive. In diesem Teil werde ich versuchen zu erklären und zu zeigen, wie jeder der Dienste funktioniert. Wenn wir das Übersichtsbild noch einmal erstellen, aber jetzt mit mehr Details und AWS-Diensten, wird es so aussehen.

Bild zeigt cicd-Übersicht

Es ist immer noch viel los in diesem Bild, aber keine Sorge, wir werden jeden der Dienste einzeln durchgehen und die Architektur und den Datenfluss diskutieren.

Ereignisstruktur

Lassen Sie uns zunächst einen kurzen Blick auf die Ereignisstruktur werfen, damit Sie ein Verständnis dafür bekommen, wie Daten hinzugefügt werden. Ich habe mich dafür entschieden, das Metadaten-Daten-Muster zu verwenden, bei dem jeder Dienst Informationen hinzufügt und ein neues Ereignis auf den Bus postet, damit der nächste Dienst in der Saga seinen Teil ausführen kann.

Am Ende wird ein Ereignis wie dieses gepostet werden, es enthält alle Informationen, die der Update-Dienst benötigt, um einen neuen Commit zu erstellen.

{
    "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"
        }
    }
}

Normalerweise würden Sie keine Daten aus dem aufrufenden Ereignis behalten, aber da die gesamte Kette als Saga-Muster gebaut ist und jeder Dienst nicht immer wieder die gleichen Informationen abrufen muss, war es einfacher, die Regeln ein wenig zu brechen.

Informationsdienst

Der allererste Schritt besteht darin, Informationen über das Pull-Request zu sammeln. Ich werde abrufen, welche Markdown-Datei aktualisiert wurde, wo die gerenderte HTML-Datei im Staging-Bucket gefunden werden kann, aus welchem Branch das Pull-Request stammt. Dies wird mit ein paar Lambda-Funktionen durchgeführt, die in einer StepFunction sequenziell laufen.

Bild zeigt Information StepFunctions Graph

Um mit GitHub zu kommunizieren, verwende ich Octokit. Hier unten sind Auszüge aus der CloudFormation-Vorlage und dem Code, der verwendet wird, um die GitHub-API aufzurufen.


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

Die Logik in diesem ersten Teil ist nicht so komplex, es geht hauptsächlich darum, die richtigen Informationen abzurufen.

Sprachedienst

Der komplexeste Teil dieser gesamten Einrichtung ist die Spracherzeugung mit Polly. Ich werde mit einer extract-Lambda-Funktion beginnen, die die HTML-Datei abruft, die verwendet wird. Im transform-Teil erstelle ich die SSML, die Polly verwendet, um den Beitrag zu lesen.

Bild zeigt Voice StepFunctions Graph

Nun werde ich nicht ins Detail gehen, da dies eigentlich ein eigener Blog ist. Um mehr darüber zu erfahren, lesen Sie meinen Beitrag Serverless Voice mit Amazon Polly oder sehen Sie sich mein Gespräch mit Johannes Koch auf YouTube an

Quiz-Dienst

Jetzt ist es an der Zeit, den Quiz zu generieren. Dies geschieht, indem eine API von kvist.ai aufgerufen wird, dies wird mit einer HTTP-Endpoint-Aufgabe in StepFunctions aufgerufen. Dies ist eine großartige Möglichkeit, eine API aufzurufen, ohne irgendeine Form von Code schreiben zu müssen.

Bild zeigt Quiz StepFunctions Graph

In der extract-Lambda-Funktion entferne ich alle HTML-Tags aus dem Blog und erstelle das Prompt, das an die API gesendet wird. Der Code für diesen Schritt wird BeautifulSoup in Python verwenden, um den Prozess reibungslos zu gestalten.


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

Um einen HTTP-Endpoint zu erstellen, verwenden Sie den http:invoke-Status, unten ist ein Auszug aus der asl-Datei für die State Machine.

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

Wenn Sie sich Workflow Studie ansehen, werden wir mit einer Konfiguration wie dieser enden.

Bild zeigt Task-Konfiguration

Für einen tieferen Einblick in StepFunctions HTTP Endpoint empfehle ich meinen Beitrag AWS StepFunctions HTTP Endpoint entmystifiziert

Update-Dienst

Der letzte Schritt in dieser Saga besteht darin, die Markdown-Datei zu aktualisieren, den korrekten Frontmatter hinzuzufügen und einen neuen Commit zu erstellen.

Bild zeigt update StepFunctions Graph

Im ersten Teil der StepFunction kopiere ich die mp3-Datei, die Polly generiert hat, an den richtigen Ort, ich werde die mp3-Dateien nicht im Repo einchecken, da diese riesige Blobs sind und reproduziert werden können. Stattdessen kopiere ich sie in einen S3-Bucket und während des Build-Prozesses werden die Dateien aus diesem Bucket gezogen und in den Produktions-Bucket platziert.

Wenn ich den Frontmatter aktualisiere, verwende ich gray-matter, um es zu einem einfachen Prozess zu machen. Der Code wird überprüfen, ob es einen Voice- und Quiz-Abschnitt im Ereignis gibt, und wenn ja, neue Tags im Frontmatter-Teil hinzufügen.

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

Dies wird jetzt einen Frontmatter-Abschnitt erstellen, der wie dieser aussieht.

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

Die wichtigen Teile hier sind audio und quiz, da diese verwendet werden, um spezielle Abschnitte im Blog-Beitrag darzustellen.

Der letzte Teil wird einen neuen Commit im Repo erstellen, dies wird wieder mit Octokit getan, es werden mehrere Schritte benötigt, wie das Erstellen des Commits, das Hochladen von Dateien ins Repo, usw. Hier unten ist eine vereinfachte Version des Codes.


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

Erweiterung der Lösung

Da die gesamte CI/CD-Lösung als ereignisgesteuertes System mit dem Saga-Muster gebaut ist, ist die Erweiterung mit einem neuen Schritt sehr einfach. Hier glänzt eine ereignisgesteuerte Lösung wirklich. Ich könnte den gesamten Quiz-Dienst auf der Seite entwickeln und ihn nur beim gleichen Ereignis wie den Sprachedienst aufrufen. Als ich mit dem Ergebnis zufrieden war, aktualisierte ich die Saga und änderte, welche Ereignisse welche Teile aufrufen.

Die Gesamtzeit für die Erweiterung dieser Lösung war weniger als eine Stunde. Ich musste keine komplexen Flows aktualisieren, nur einen Dienst hinzufügen und die Saga ein wenig ändern. Es gibt einen Grund, warum ich es wirklich genieße, mit serverless und ereignisgesteuerten Systemen zu arbeiten.

Beitrag rendern

Wie bereits erwähnt, verwende ich 11TY, um die Blog-Beiträge von Markdown in HTML zu rendern. Während dieses Prozesses verwendet 11TY das Layout, das ich für Blog-Beiträge erstellt habe.

In diesem Layout habe ich Abschnitte '% if audio %' und '% if quiz %', um zu überprüfen, ob dies im Frontmatter verfügbar ist. Wenn es vorhanden ist, fügt es die HTML-Tags innerhalb der If-Blöcke hinzu. Automatische Hinzufügung von Audio und dem Quiz basierend auf den Daten aus dem Frontmatter.

---
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">
            Ihr Browser unterstützt das Audio-Element nicht.
        </audio>
        <div class="text-end text-blue-500 italic">Stimme bereitgestellt von 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">Testen Sie, was Sie gerade gelernt haben, mit diesem <a rel="noreferrer" target="_blank" href="">Fünf-Fragen-Quiz - .</a><br>
        Angetrieben von <a rel="noreferrer" target="_blank" href="https://kvist.ai">kvist.ai</a> Ihrer KI-generierten Quiz-Lösung!</p>
        </div>
        % endif %
    </div>

</article>

Abschließende Worte

Dies war ein Beitrag, in dem ich erkläre, wie ich einen Schritt in meiner ereignisgesteuerten CI/CD-Pipeline hinzugefügt habe, um ein gamifiziertes Lernerlebnis für Sie, meine Leser, zu erstellen. Wenn Ihnen der Quiz-Teil gefällt, schauen Sie sich kvist.ai an

Schauen Sie sich Mein Serverless-Handbuch für einige der in diesem Beitrag erwähnten Konzepte an.

Vergessen Sie nicht, mir auf LinkedIn und X zu folgen, um mehr Inhalte zu sehen, und lesen Sie den Rest meiner Blogs

Wie Werner sagt! Jetzt loslegen!

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