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, fera 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 avec l’Adaptateur Web Lambda (LWA) ;
  • copyfiles est une dépendance npm nécessaire pour 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 de packages/story_api/story_api/main.py par :

packages/story_api/story_api/main.py
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, 'x-query': True},
response_class=PlainTextResponse)
def generate_story(request: StoryRequest) -> str:
return StreamingResponse(bedrock_stream(request), media_type="text/plain")

Analyse du code ci-dessus :

  • Nous utilisons le paramètre x-streaming pour indiquer qu’il s’agit d’une API de streaming lors de la génération du client SDK. Cela permet de consommer cette API en streaming tout en conservant le typage fort !
  • Le paramètre x-query indique que bien qu’il s’agisse d’une requête POST, nous la traitons comme une query plutôt qu’une mutation, exploitant ainsi pleinement la gestion des flux par TanStack Query.
  • Notre API retourne simplement un flux texte brut, 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 API Gateway avec Lambda. Pour notre story_api, nous utilisons plutôt une URL de fonction Lambda avec streaming de réponse.

Mettons à jour nos constructs CDK comme suit :

packages/common/constructs/src/core/http-api.ts
import { Construct } from 'constructs';
import { CfnOutput, Duration, Stack } from 'aws-cdk-lib';
import {
CorsHttpMethod,
HttpApi as _HttpApi,
HttpMethod,
IHttpRouteAuthorizer,
} from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import {
Code,
Function,
FunctionUrl,
FunctionUrlAuthType,
InvokeMode,
LayerVersion,
Runtime,
Tracing,
} from 'aws-cdk-lib/aws-lambda';
import { Grant, IGrantable } from 'aws-cdk-lib/aws-iam';
import { RuntimeConfig } from './runtime-config.js';
export interface HttpApiProps {
readonly apiName: string;
readonly handler: string;
readonly handlerFilePath: string;
readonly runtime: Runtime;
readonly defaultAuthorizer: IHttpRouteAuthorizer;
readonly apiType?: 'api-gateway' | 'function-url-streaming';
readonly allowedOrigins?: string[];
}
export class HttpApi extends Construct {
public readonly api?: _HttpApi;
public readonly routerFunctionUrl?: FunctionUrl;
public readonly routerFunction: Function;
constructor(scope: Construct, id: string, props: HttpApiProps) {
super(scope, id);
this.routerFunction = new Function(this, `${id}Handler`, {
timeout: Duration.seconds(30),
runtime: props.runtime,
handler: props.handler,
code: Code.fromAsset(props.handlerFilePath),
tracing: Tracing.ACTIVE,
environment: {
AWS_CONNECTION_REUSE_ENABLED: '1',
},
});
let apiUrl;
if (props.apiType === 'function-url-streaming') {
const stack = Stack.of(this);
this.routerFunction.addLayers(
LayerVersion.fromLayerVersionArn(
this,
'LWALayer',
`arn:aws:lambda:${stack.region}:753240598075:layer:LambdaAdapterLayerX86:24`,
),
);
this.routerFunction.addEnvironment('PORT', '8000');
this.routerFunction.addEnvironment(
'AWS_LWA_INVOKE_MODE',
'response_stream',
);
this.routerFunction.addEnvironment(
'AWS_LAMBDA_EXEC_WRAPPER',
'/opt/bootstrap',
);
this.routerFunctionUrl = this.routerFunction.addFunctionUrl({
authType: FunctionUrlAuthType.AWS_IAM,
invokeMode: InvokeMode.RESPONSE_STREAM,
cors: {
allowedOrigins: props.allowedOrigins ?? ['*'],
allowedHeaders: [
'authorization',
'content-type',
'x-amz-content-sha256',
'x-amz-date',
'x-amz-security-token',
],
},
});
apiUrl = this.routerFunctionUrl.url;
} else {
this.api = new _HttpApi(this, id, {
corsPreflight: {
allowOrigins: props.allowedOrigins ?? ['*'],
allowMethods: [CorsHttpMethod.ANY],
allowHeaders: [
'authorization',
'content-type',
'x-amz-content-sha256',
'x-amz-date',
'x-amz-security-token',
],
},
defaultAuthorizer: props.defaultAuthorizer,
});
this.api.addRoutes({
path: '/{proxy+}',
methods: [
HttpMethod.GET,
HttpMethod.DELETE,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.PATCH,
HttpMethod.HEAD,
],
integration: new HttpLambdaIntegration(
'RouterIntegration',
this.routerFunction,
),
});
apiUrl = this.api.url;
}
new CfnOutput(this, `${props.apiName}Url`, { value: apiUrl! });
RuntimeConfig.ensure(this).config.httpApis = {
...RuntimeConfig.ensure(this).config.httpApis!,
[props.apiName]: apiUrl,
};
}
public grantInvokeAccess(grantee: IGrantable) {
if (this.api) {
Grant.addToPrincipal({
grantee,
actions: ['execute-api:Invoke'],
resourceArns: [this.api.arnForExecuteApi('*', '/*', '*')],
});
} else if (this.routerFunction) {
Grant.addToPrincipal({
grantee,
actions: ['lambda:InvokeFunctionUrl'],
resourceArns: [this.routerFunction.functionArn],
conditions: {
StringEquals: {
'lambda:FunctionUrlAuthType': 'AWS_IAM',
},
},
});
}
}
}

Maintenant adaptons le déploiement de la story_api pour supporter le Lambda Web Adapter :

packages/story_api/run.sh
#!/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, compilons le codebase :

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

Déployez maintenant l’application avec :

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 en une fois. Cliquez pour plus de détails.

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

Fenêtre de terminal
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/
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

Pour tester notre API :

  • Démarrer une instance locale du serveur FastAPI et l’invoquer avec curl
  • Appeler l'API déployée directement avec curl signé

Démarrez le serveur FastAPI local avec :

Terminal window
pnpm nx run dungeon_adventure.story_api:serve

Puis invoquez-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 stood tall, his cape billowing in the wind....

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