Jogo de Dungeons com IA
Módulo 3: Implementação da API de história
A StoryApi consiste em uma única API generate_story
que, dado um Game
e uma lista de Action
s como contexto, irá progredir uma história. Esta API será implementada como uma API de streaming em Python/FastAPI e também demonstrará como fazer ajustes no código gerado para adequá-lo ao propósito.
Implementação da API
Para criar nossa API, primeiro precisamos instalar algumas dependências adicionais:
boto3
será usado para chamar o Amazon Bedrock;uvicorn
será usado para iniciar nossa API em conjunto com o Lambda Web Adapter (LWA);copyfiles
é uma dependência npm necessária para suportar a cópia multiplataforma de arquivos ao atualizar nossa tarefabundle
.
Para instalar essas dependências, execute os seguintes comandos:
pnpm nx run dungeon_adventure.story_api:add --args boto3 uvicorn
yarn nx run dungeon_adventure.story_api:add --args boto3 uvicorn
npx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
bunx nx run dungeon_adventure.story_api:add --args boto3 uvicorn
pnpm add -Dw copyfiles
yarn add -D copyfiles
npm install --legacy-peer-deps -D copyfiles
bun install -D copyfiles
Agora vamos substituir o conteúdo de packages/story_api/story_api/main.py
pelo seguinte:
import json
from boto3 import clientfrom fastapi.responses import PlainTextResponse, StreamingResponsefrom 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")
Analisando o código acima:
- Usamos a configuração
x-streaming
para indicar que esta é uma API de streaming ao gerar nosso client SDK. Isso permitirá consumir esta API de forma streaming mantendo a segurança de tipos! - Usamos
x-query
para indicar que, embora seja uma requisição POST, trataremos comoquery
ao invés demutation
, permitindo aproveitar totalmente o gerenciamento de estado streaming do TanStack Query - Nossa API simplesmente retorna um fluxo de texto conforme definido por
media_type="text/plain"
eresponse_class=PlainTextResponse
Infraestrutura
A infraestrutura configurada anteriormente assume que todas as APIs usam API Gateway integrado com Lambda. Para nossa story_api
, queremos usar uma URL de Função Lambda configurada com streaming de resposta.
Para isso, primeiro atualizaremos nossos constructs CDK:
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', }, }, }); } }}
import { Construct } from 'constructs';import * as url from 'url';import { HttpApi } from '../../core/http-api.js';import { HttpIamAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';import { Runtime } from 'aws-cdk-lib/aws-lambda';import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
export class StoryApi extends HttpApi { constructor(scope: Construct, id: string) { super(scope, id, { defaultAuthorizer: new HttpIamAuthorizer(), apiName: 'StoryApi', runtime: Runtime.PYTHON_3_12, handler: 'story_api.main.handler', apiType: 'function-url-streaming', handler: 'run.sh', handlerFilePath: url.fileURLToPath( new URL( '../../../../../../dist/packages/story_api/bundle', import.meta.url, ), ), });
this.routerFunction.addToRolePolicy( new PolicyStatement({ effect: Effect.ALLOW, actions: ['bedrock:InvokeModelWithResponseStream'], resources: [ 'arn:aws:bedrock:*::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0', ], }), ); }}
Agora atualizaremos o story_api
para suportar a implantação com 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
{ "name": "dungeon_adventure.story_api", "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "packages/story_api/story_api", "targets": { ... "bundle": { "cache": true, "executor": "nx:run-commands", "outputs": ["{workspaceRoot}/dist/packages/story_api/bundle"], "options": { "commands": [ "uv export --frozen --no-dev --no-editable --project story_api -o dist/packages/story_api/bundle/requirements.txt", "uv pip install -n --no-installer-metadata --no-compile-bytecode --python-platform x86_64-manylinux2014 --python `uv python pin` --target dist/packages/story_api/bundle -r dist/packages/story_api/bundle/requirements.txt", "copyfiles -f packages/story_api/run.sh dist/packages/story_api/bundle" ], "parallel": false }, "dependsOn": ["compile"] }, ... }}
Implantação e testes
Primeiro, vamos construir a base de código:
pnpm nx run-many --target build --all
yarn nx run-many --target build --all
npx nx run-many --target build --all
bunx nx run-many --target build --all
Sua aplicação pode ser implantada executando:
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
yarn nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
npx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
bunx nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox
Esta implantação levará aproximadamente 2 minutos.
Comando de implantação
Você pode implantar todas as stacks do aplicativo CDK executando:
pnpm nx run @dungeon-adventure/infra:deploy --all
yarn nx run @dungeon-adventure/infra:deploy --all
npx nx run @dungeon-adventure/infra:deploy --all
bunx nx run @dungeon-adventure/infra:deploy --all
Não recomendamos esta abordagem pois você pode querer separar estágios de implantação em stacks diferentes (ex: infra-prod). Neste caso, o flag --all
tentará implantar todas as stacks podendo causar implantações indesejadas!
Após a implantação, você verá outputs similares a estes (alguns valores foram omitidos):
dungeon-adventure-infra-sandboxdungeon-adventure-infra-sandbox: deploying... [2/2]
✅ dungeon-adventure-infra-sandbox
✨ Deployment time: 354s
Outputs:dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYYdungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.netdungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.lambda-url.ap-southeast-2.on.aws/dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx
Podemos testar nossa API de duas formas:
- Iniciando uma instância local do servidor FastAPI e invocando as APIs com
curl
- Chamar a API implantada usando curl com Sigv4
Curl com Sigv4
Adicione este script ao seu
.bashrc
(e executesource
) ou cole diretamente no terminal:~/.bashrc acurl () {REGION=$1SERVICE=$2shift; shift;curl --aws-sigv4 "aws:amz:$REGION:$SERVICE" --user "$(aws configure get aws_access_key_id):$(aws configure get aws_secret_access_key)" -H "X-Amz-Security-Token: $(aws configure get aws_session_token)" "$@"}Exemplos de uso:
API Gateway
Terminal window acurl ap-southeast-2 execute-api -X GET https://xxxURL de Lambda streaming
Terminal window acurl ap-southeast-2 lambda -N -X POST https://xxx
Inicie o servidor FastAPI local com:
pnpm nx run dungeon_adventure.story_api:serve
yarn nx run dungeon_adventure.story_api:serve
npx nx run dungeon_adventure.story_api:serve
bunx nx run dungeon_adventure.story_api:serve
Chame a API com:
curl -N -X POST http://127.0.0.1:8000/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
acurl ap-southeast-2 lambda -N -X POST \ https://xxx.lambda-url.ap-southeast-2.on.aws/story/generate \ -d '{"genre":"superhero", "actions":[], "playerName":"UnnamedHero"}' \ -H "Content-Type: application/json"
Se executado com sucesso, você verá uma resposta em streaming similar a:
UnnamedHero stood tall, his cape billowing in the wind....
Parabéns. Você construiu e implantou sua primeira API usando o FastAPI! 🎉🎉🎉