Aller au contenu

Jeu de Donjon IA

Module 3 : Implémentation de l’API Story

La StoryApi comprend une unique API generate_story qui, étant donné un Game et une liste d’Action comme contexte, fait progresser une histoire. Cette API sera implémentée comme une API de streaming en Python/FastAPI et démontrera également comment modifier le code généré pour l’adapter à son usage.

Implémentation de l’API

Pour créer notre API, nous devons d’abord installer quelques dépendances supplémentaires.

  • boto3 sera utilisé pour appeler Amazon Bedrock ;
  • uvicorn sera utilisé pour démarrer notre API en conjonction avec le Lambda Web Adapter (LWA).
  • copyfiles est une dépendance npm nécessaire pour supporter la copie multiplateforme de fichiers lors de la mise à jour de notre tâche bundle.

Pour installer ces dépendances, exécutez les commandes suivantes :

Terminal window
pnpm nx run dungeon_adventure.story_api:add --args boto3 uvicorn
Terminal window
pnpm add -Dw copyfiles

Maintenant remplaçons le contenu des fichiers suivants dans packages/story_api/story_api :

import json
from boto3 import client
from fastapi.responses import PlainTextResponse, StreamingResponse
from pydantic import BaseModel
from .init import app, lambda_handler
handler = lambda_handler
bedrock = client('bedrock-runtime')
class Action(BaseModel):
role: str
content: str
class StoryRequest(BaseModel):
genre: str
playerName: str
actions: list[Action]
async def bedrock_stream(request: StoryRequest):
messages = [
{"role": "user", "content": "Continue or create a new story..."}
]
for action in request.actions:
messages.append({"role": action.role, "content": action.content})
response = bedrock.invoke_model_with_response_stream(
modelId='anthropic.claude-3-sonnet-20240229-v1:0',
body=json.dumps({
"system":f"""
You are running an AI text adventure game in the {request.genre} genre.
Player: {request.playerName}. Return less than 200 characters of text.
""",
"messages": messages,
"max_tokens": 1000,
"temperature": 0.7,
"anthropic_version": "bedrock-2023-05-31"
})
)
stream = response.get('body')
if stream:
for event in stream:
chunk = event.get('chunk')
if chunk:
message = json.loads(chunk.get("bytes").decode())
if message['type'] == "content_block_delta":
yield message['delta']['text'] or ""
elif message['type'] == "message_stop":
yield "\n"
@app.post("/story/generate",
openapi_extra={'x-streaming': True},
response_class=PlainTextResponse)
def generate_story(request: StoryRequest) -> str:
return StreamingResponse(bedrock_stream(request), media_type="text/plain")

Analyse du code :

  • Nous utilisons le paramètre x-streaming pour indiquer qu’il s’agit d’une API de streaming lors de la génération de notre SDK client. Cela permet de consommer cette API en streaming tout en conservant la sécurité des types !
  • Notre API retourne simplement un flux texte comme défini par media_type="text/plain" et response_class=PlainTextResponse

Infrastructure

L’infrastructure configurée précédemment suppose que toutes les APIs utilisent une API Gateway intégrée à des fonctions Lambda. Pour notre story_api, nous ne voulons pas utiliser API Gateway car il ne supporte pas les réponses en streaming. À la place, nous utiliserons une URL de fonction Lambda configurée avec le streaming de réponse.

Pour supporter cela, nous allons d’abord mettre à jour nos constructs CDK comme suit :

import { Duration, Stack, CfnOutput } from 'aws-cdk-lib';
import { IGrantable, Grant } from 'aws-cdk-lib/aws-iam';
import {
Runtime,
Code,
Tracing,
LayerVersion,
FunctionUrlAuthType,
InvokeMode,
Function,
} from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import url from 'url';
import { RuntimeConfig } from '../../core/runtime-config.js';
export class StoryApi extends Construct {
public readonly handler: Function;
constructor(scope: Construct, id: string) {
super(scope, id);
this.handler = new Function(this, 'Handler', {
runtime: Runtime.PYTHON_3_12,
handler: 'run.sh',
code: Code.fromAsset(
url.fileURLToPath(
new URL(
'../../../../../../dist/packages/story_api/bundle',
import.meta.url,
),
),
),
timeout: Duration.seconds(30),
tracing: Tracing.ACTIVE,
environment: {
AWS_CONNECTION_REUSE_ENABLED: '1',
},
});
const stack = Stack.of(this);
this.handler.addLayers(
LayerVersion.fromLayerVersionArn(
this,
'LWALayer',
`arn:aws:lambda:${stack.region}:753240598075:layer:LambdaAdapterLayerX86:24`,
),
);
this.handler.addEnvironment('PORT', '8000');
this.handler.addEnvironment('AWS_LWA_INVOKE_MODE', 'response_stream');
this.handler.addEnvironment('AWS_LAMBDA_EXEC_WRAPPER', '/opt/bootstrap');
const functionUrl = this.handler.addFunctionUrl({
authType: FunctionUrlAuthType.AWS_IAM,
invokeMode: InvokeMode.RESPONSE_STREAM,
cors: {
allowedOrigins: ['*'],
allowedHeaders: [
'authorization',
'content-type',
'x-amz-content-sha256',
'x-amz-date',
'x-amz-security-token',
],
},
});
new CfnOutput(this, 'StoryApiUrl', { value: functionUrl.url });
// Register the API URL in runtime configuration for client discovery
RuntimeConfig.ensure(this).config.apis = {
...RuntimeConfig.ensure(this).config.apis!,
StoryApi: functionUrl.url,
};
}
public grantInvokeAccess(grantee: IGrantable) {
Grant.addToPrincipal({
grantee,
actions: ['lambda:InvokeFunctionUrl'],
resourceArns: [this.handler.functionArn],
conditions: {
StringEquals: {
'lambda:FunctionUrlAuthType': 'AWS_IAM',
},
},
});
}
}

Maintenant nous mettrons à jour la story_api pour supporter le déploiement du Lambda Web Adapter.

#!/bin/bash
PATH=$PATH:$LAMBDA_TASK_ROOT/bin \
PYTHONPATH=$PYTHONPATH:/opt/python:$LAMBDA_RUNTIME_DIR \
exec python -m uvicorn --port=$PORT story_api.main:app

Déploiement et tests

D’abord, construisons la base de code :

Terminal window
pnpm nx run-many --target build --all

Votre application peut maintenant être déployée avec la commande suivante :

Terminal window
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox

Ce déploiement prendra environ 2 minutes.

Vous pouvez aussi déployer toutes les stacks d'un coup. Cliquez ici pour plus de détails.

Une fois le déploiement terminé, vous devriez voir des sorties similaires à ceci (certaines valeurs ont été masquées) :

Fenêtre de terminal
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Temps de déploiement : 354s
Outputs:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-adventure-infra-sandbox.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.lambda-url.ap-southeast-2.on.aws/
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Nous pouvons tester notre API de deux manières :

  • Démarrer une instance locale du serveur FastAPI et appeler les APIs avec curl
  • Appeler l'API déployée directement avec curl activé pour Sigv4

Démarrez le serveur FastAPI local avec :

Terminal window
pnpm nx run dungeon_adventure.story_api:serve

Une fois le serveur démarré, appelez-le avec :

Fenêtre de terminal
curl -N -X POST http://127.0.0.1:8000/story/generate \
-d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \
-H "Content-Type: application/json"

Si la commande réussit, vous devriez voir une réponse en streaming similaire à :

UnnamedHero se tenait fier, sa cape flottant au vent....

Félicitations. Vous avez créé et déployé votre première API avec FastAPI ! 🎉🎉🎉