logo
Menu
Cómo Construí un CV con IA y Serverless en AWS

Cómo Construí un CV con IA y Serverless en AWS

El texto describe como combinar la IA de AWS Bedrock y otros servicios serverless para ofrecer experiencias de usuario únicas.

Published Feb 28, 2024

IA + Serverless en AWS usando Bedrock

Hace unas semanas empecé un pequeño proyecto donde trataba de hacer un CV como una página web estática utilizando AWS Cognito, Cloudfront/S3 y Route 53 con el fin de explorar diferentes servicios de AWS. Claro que para aprender es necesario ensuciarse las manos por lo que posteriormente agregué algo de complejidad al desarrollar un backend con AWS Lambda, API Gateway y un autorizador de lambda que valida tokens dentro de cookies.
Inicialmente monté el backend, sin embargo, no había pensado específicamente en cuál sería su funcionalidad. Se me ocurrieron ideas como mandar correros por email, un chat, tal vez un sistema de puntuación, pero no sentí que encajara con la temática. Al final, opté por utilizar AWS Bedrock, el servicio de AWS que expone modelos fundacionales para implementar una especie de chatbot donde el usuario pudiera realizar preguntas y el modelo pudiera responder por mí en base a mi experiencia profesional.
Al final del desarrollando terminé con la siguiente arquitectura, utilizando 9 servicios diferentes de AWS.
Arquitectura de la aplicación
Arquitectura de la aplicación
Entre otras cosas que implementé fue el uso de HTMX para el frontend y la configuración de una sencilla línea de despliegue del fronend con AWS CodePipeline y AWS CodeCommit con S3. A pesar de que fueron implementaciones muy sencillas planeo explorarlas más a fondo en otros proyectos de mayor complejidad.

AWS Bedrock con funciones lambda

AWS Bedrock es un servicio de AWS serverless que nos permite realizar inferencias a modelos fundacionales desarrollados por diversas startups como AI21, Anthropic, Stability AI, Meta y el mismo Amazon como llamadas a API’s de AWS, lo que facilita en gran medida su uso e incorporación a proyectos existentes y permite la integración de otros servicios como los roles de IAM para su protección.

Frontend con HTMX

La idea de implementar estos modelos en mi proyecto era que las personas pudieran escribir en un input de HTML las cualidades o habilidades que les interesara de un candidato y que a través de prompt engineering el modelo pudiera responder en base a mi experiencia cómo puedo yo ser de utilidad.
Imagen del frontend de la aplicación
Imagen del frontend de la aplicación
Para realizar estas llamadas el usuario debe haber iniciado sesión, ya que las peticiones están protegidas por un autorizador de lambda, con el fin de evitar cualquier abuso de la API.
Como mencionaba, AWS Bedrock expone varios modelos, por lo que en el backend hice un desarrollo para que acepte 3 diferentes y en mi experiencia el que mejor respuestas da es el de Titan, Llama 2 y Jurasic-2 alucinan mucho, sin embargo, AWS ya ha anunciado la pronta incorporación de los modelos Mixtral-8x7B, los cuales tienen un desempeño bueno y similar al usar una arquitectura de mezcla de expertos como se rumorea que utiliza ChatGPT.
El código del input implementa etiquetas de HTMX para manejar la lógica de los datos enviados y me permite definir en qué elemento será insertada la respuesta del backend. Cuando el usuario presiona el botón de enviar la información viaja al backend con los datos de las habilidades deseadas por el usuario, el nombre del usuario y el modelo de AWS Bedrock que responderá la pregunta, con la finalidad de hacer una respuesta personalizada.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.
.
.
<div class="col-md-4 offset-md-4">
<select name="modelo-seleccionado" id="modelo-seleccionado" class="form-select">
<option value="meta.llama2-70b-chat-v1" selected>Llama 2</option>
<option value="amazon.titan-text-express-v1">Titan</option>
<option value="ai21.j2-ultra-v1">Ultra</option>
</select>
</div>
.
.
.
<div class="form-group">
<button class="btn btn-primary" type="button" id="question-button"
hx-post="https://back.alexochoa.dev/api/questions"
hx-trigger="click"
hx-include="[name='habilidades'], [name='informacion-usuario'], #modelo-seleccionado"
hx-target="#resultado-question"
hx-swap="outerHTML"
placeholder="Search...">

Enviar
<img class="htmx-indicator" src="/images/bars.svg" />
</button>
</div>
.
.
.

Backend con API Gateway y AWS Lambda

En el backend tengo el siguiente handler para la función lambda. Notarás que fue necesario decodificar el body de la petición ya que esta viene codificada en Base64, sin embargo, desconozco si llega en este formato debido al HTMX ya que es la primera vez que en API Gateway/AWS Lambda tengo que realizar esta decodificación.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { questionService } from '../services/questionService.js';
import { Buffer } from 'buffer';

export async function handler(event) {
let bodyStr = '';
if (event.isBase64Encoded) {
bodyStr = Buffer.from(event.body, 'base64').toString('utf-8');
} else {
bodyStr = event.body;
}

const decodedBody = decodeURIComponent(bodyStr);
const NOMBRE_RECLUTADOR = decodedBody.split('informacion-usuario=')[1]?.split('&')[0] || 'Reclutador anonimo';
const HABILIDADES_DESEADAS = decodedBody.split('habilidades=')[1].split('&')[0];
const MODELO_SELECCIONADO = decodedBody.split('modelo-seleccionado=')[1].split('&')[0];
const resp = await questionService(HABILIDADES_DESEADAS, NOMBRE_RECLUTADOR, MODELO_SELECCIONADO);

return resp;
}
El questionsService(…) es la función que realiza la lógica de prompt engineering y la orquestación entre el formateo de las peticiones de AWS Bedrock, el formateo de su respuesta y el formateo de la respuesta de la función lambda, ya que requiere ser formateada debido a que HTMX espera que la respuesta sea HTML, lo cuál me parece genial ya que es posible incorporar herramientas como los partials de hanldebars y otras librerías para resolver las peticiones HTMX.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime';
import { templatePrompt } from './../utils/prompts.js';
import {
parseResponseModelInvokation,
getRequestToModelCommand,
} from './../utils/parsers.js';

const bedrockClient = new BedrockRuntimeClient({ region: 'us-east-1' });
export async function questionService(HABILIDADES_DESEADAS, NOMBRE_RECLUTADOR, MODELO_SELECCIONADO) {
const customPrompt = templatePrompt
.replaceAll('{{HABILIDADES_DESEADAS}}', HABILIDADES_DESEADAS)
.replaceAll('{{NOMBRE_RECLUTADOR}}', NOMBRE_RECLUTADOR);
const reqCommand = getRequestToModelCommand(customPrompt, MODELO_SELECCIONADO);

try {
const rawResponse = await bedrockClient.send(reqCommand);
const customRecreuiterResponseText =
parseResponseModelInvokation(rawResponse, MODELO_SELECCIONADO);

return {
statusCode: 200,
body: `<div class="text-center" id="resultado-question" >${customRecreuiterResponseText} </div>`,
headers: {"content-type": "text/html"},
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
body: JSON.stringify({ error: 'Error al procesar la solicitud' }),
};
}
}
Para poder trabajar con múltiples modelos tuve que crear funciones que pudieran darle un tratamiento especial a cada uno porque tanto las peticiones a la API como sus respuestas tienen formatos diferentes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export function getRequestToModelCommand(customPrompt, MODELO_SELECCIONADO) {
const modelName = MODELO_SELECCIONADO;
let request = {
modelId: modelName,
contentType: 'application/json',
accept: 'application/json',
body: '',
};

switch (modelName) {
case 'amazon.titan-text-express-v1':
request.body = JSON.stringify({
inputText: customPrompt,
textGenerationConfig: {
maxTokenCount: 300,
stopSequences: [],
temperature: 0,
topP: 1,
},
});
break;
case 'meta.llama2-70b-chat-v1':
request.body = JSON.stringify({
prompt: customPrompt,
max_gen_len: 512,
temperature: 0.5,
top_p: 0.9,
});
break;
case 'ai21.j2-ultra-v1':
request.body = JSON.stringify({
prompt: customPrompt,
maxTokens: 400,
temperature: 0.9,
topP: 0.9,
stopSequences: [],
countPenalty: { scale: 0 },
presencePenalty: { scale: 0 },
frequencyPenalty: { scale: 0 },
});
break;
default:
throw new Error('Modelo no soportado');
}

return new InvokeModelCommand(request);
}
Espero que en un futuro AWS implemente una interfaz más uniforme para cada uno de los modelos, facilitando su uso múltiple de forma más sencilla.
Para formatear la respuesta, utilicé este código, ya que igualmente la respuesta de cada modelo maneja su propio formato.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function parseEncodedBody(encodedBody, MODELO_SELECCIONADO) {
const modelName = MODELO_SELECCIONADO;
const decoder = new TextDecoder('utf-8');
const body = decoder.decode(encodedBody);
const parsedBody = JSON.parse(body);
switch (modelName) {
case 'amazon.titan-text-express-v1':
return parsedBody.results[0]?.outputText;
case 'meta.llama2-70b-chat-v1':
return parsedBody.generation;
case 'ai21.j2-ultra-v1':
return parsedBody.completions[0].data.text;
default:
throw new Error('Modelo no soportado');
}
}
En todos los modelos el body de la respuesta siempre viene codificada como una secuencia de bytes por lo que es necesario decodificarla en utf-8 para poder leerla en texto plano. Sin embargo, al momento de escribir esto, parece que hay una forma de indicar el formato del body mediante el header de Content-Type, pero no lo he intentado.

Puntos más importantes

A pesar de la sencillez del proyecto, considero que aprendí muchas cosas importantes durante el desarrollo y me gustaría poder compartirlas aunque sea a nivel general.

Seguridad de las API’s

Mientras empezaba con el proyecto quise integrar una funcionalidad de log in y sign up con AWS Cognito porque sabía que en un futuro iba a necesitar alguna forma de autenticación.
Inicialmente pensé en usar un flujo de autenticación implícito con AWS Cognito, sin embargo, encontré todas las desventajas y faltas de seguridad que este flujo implica. En mi búsqueda de alternativas más seguras, opté por utilizar el flujo de autenticación por código, una opción que permite a un backend autenticarse con el servidor y manejar los datos de los usuarios almacenados en el User Pool de Cognito.
Este método tiene la ventaja de emitir tokens de actualización con una duración de hasta 30 días de vida, mejorando la experiencia del usuario sin sacrificar la seguridad. Frente a la opción de almacenar estos tokens de actualización en el backend o en el front, opté por utilizar cookies seguras para almacenar los tokens, las cuales son gestionadas directamente por el navegador y no son accesibles a través de scripts maliciosos, lo que reduce el riesgo de robo de información. Adicionalmente, implementé políticas para las cookies que restringen su envío a dominios no autorizados, en mi caso, únicamente serán enviadas a mi back, asegurando así un control más estricto sobre la gestión de las credenciales de usuario.
Al final, esto generó una nueva problemática, y es que para autenticas al usuario desde el API Gateway, tuve que crear un autorizador de lambda que pudiera leer estas cookies y validar el token de acceso. En general estoy orgullo de esta parte de la autenticación porque me parece segura y he aprendido mucho. Comparto el código del autorizador.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import { CognitoJwtVerifier } from "aws-jwt-verify";

const USER_POOL_ID = process.env.USER_POOL_ID;
const CLIENT_ID = process.env.CLIENT_ID;

const verifier = CognitoJwtVerifier.create({
userPoolId: USER_POOL_ID,
clientId: CLIENT_ID,
tokenUse: "access",
});

export const handler = async function (event) {
const cookies = event['cookies'];
const tokens = extractTokensFromCookies(cookies);

if (!tokens) {
return unauthorizedResponse();
}

const { isValid, payload } = await verifyToken(tokens.accessToken);
return generateAuthResponse(isValid, payload);
};

function extractTokensFromCookies(cookies) {
const tokens = { accessToken: '', refreshToken: '' };
cookies.forEach(cookie => {
if (cookie.startsWith('access_token=')) {
tokens.accessToken = cookie.split('=')[1];
} else if (cookie.startsWith('refresh_token=')) {
tokens.refreshToken = cookie.split('=')[1];
}
});

return tokens.accessToken ? tokens : null;
}

async function verifyToken(accessToken) {
try {
const payload = await verifier.verify(accessToken);
return { isValid: true, payload };
} catch (error) {
console.error(`Error validating token: ${error.message}`);
return { isValid: false, payload: {} };
}
}

function generateAuthResponse(isValid, payload) {
if (!isValid) {
return unauthorizedResponse();
}

return {
"isAuthorized": true,
"context": { "group": payload.group || 'default' }
};
}

function unauthorizedResponse() {
return { "isAuthorized": false };
}
Creo que la parte más importante de este autorizador es que recupera las claves públicas con las que se pueden validar los tokens, de una forma sencilla, ya que la librería aws-jwt-verify hace toda esta tarea.

HTMX es una tecnología muy poderosa

A pesar de ser poco conocido y que yo acabo de empezar a utilizarlo debo admitir que me gustó mucho, me parece una tecnología muy novedosa y permite crear páginas web dinámicas sin tener que aprender a usar nuevos frameworks/librerías como React, Angular, Svelte, etc. Sin embargo, la comunidad todavía es muy pequeña y se me hizo difícil encontrar ejemplos para lo que quería hacer, por lo que fue muy importante leer con lupa la documentación. Aún así, recomiendo a cualquiera intentar usarlo, creo que para proyectos personales es de mucha ayuda.

Problemas de CORS entre API Gateway/HTMX/Cookies Seguras

Al tener el dominio de mi frontend bajo el nombre de dominio https://cv.alexochoa.dev y el backend como https://back.alexochoa.dev, fue muy importante configurar correctamente el CORS. Dos puntos principales aprendidos:
  • HTMX requiere que configures el envío de las credenciales con cada petición.
Cuando configuras el CORS en el backend, en mi caso en API Gateway, es importante tener en cuenta que al habilitar el uso de credenciales tanto en las cabeceras de CORS allow origins como el allow headers NO pueden ser definidos como un wildcard.
Para el primer punto tuve que implementar esta parte del código en mi index.html para habilitar el uso de las credenciales en cada petición al backend.
1
2
3
<script>
htmx.config.withCredentials = true;
</script>
Mientras que para el segundo punto definí esto en mi serverless.yml. Notarás que los headers permitidos tienen una nomenclatura extraña, estos son enviados en cada request de HTMX y es común que causen problemas. Otra alternativa, como encontré en algunos foros, en eliminar estos heades justo antes de que HTMX envié la solicitud, esto es posible ya que utiliza unos hooks que pueden permitirte formatear peticiones y respuestas.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
provider:
name: aws
runtime: nodejs18.x
environment:
...

httpApi:
cors:
allowedOrigins:
- "https://cv.alexochoa.dev"
- "https://back.alexochoa.dev"
- "http://localhost:3000"
allowedHeaders:
- Content-Type,Authorization
- X-Requested-With
- hx-current-url
- hx-request
- hx-target
- hx-trigger
allowedMethods: "*"
allowCredentials: true
exposedResponseHeaders: "*"

authorizers:
customAuthorizer:
type: request
functionArn: ${self:custom.secrets.AUTHORIZER_ARN}
payloadVersion: '2.0'
enableSimpleResponses: true
identitySource:
- $request.header.Cookie
IMPORTANTE, recuerda que al hacer una petición post cross origin se hace automáticamente una petición OPTIONS antes de envíar el POST, justamente para validar los origenes y headers que el backend espera recibir por lo que debe estar habilitada en tu allowedMethods. Igualmente, si tu lambda autorizadora falla en autenticar la petición, puede que veas problemas de CORS si no devuelves los headers correctos. Toma en cuenta esto para manejar estos errores y devolver en su defecto el código HTTP adecuando, por ejemplo, un 401, 403, etc y los headers correctos.

Integración continua en entorno dev y el IaC

Considero que el desarrollo de este proyecto hubiera sido mucho más lento, mucho!, si no hubiera utilizado Serverless framework para el despliegue de las lambdas (mencionadas en otros posts) y desarrollo local con Serverless framework offline y CodePipeline y CodeCommit para el despliegue del front a un bucket de S3 que era el origen de una distribución de Cloudfront.
Servlerless framework me permitió hacer los despliegues sencillos y de forma rápida en apoyo con scripts del package.json. A continuación les muestro el resultado de ambos.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
service: cv-alexochoa-dev-backend
frameworkVersion: '3'

provider:
name: aws
runtime: nodejs18.x
environment:
GRANT_TYPE: ${self:custom.secrets.GRANT_TYPE}
CLIENT_ID: ${self:custom.secrets.CLIENT_ID}
SCOPE: ${self:custom.secrets.SCOPE}
CLIENT_SECRET: ${self:custom.secrets.CLIENT_SECRET}
TOKEN_URL: ${self:custom.secrets.TOKEN_URL}
REDIRECT_URI: ${self:custom.secrets.REDIRECT_URI}
DOMAIN: ${self:custom.secrets.DOMAIN}

httpApi:
cors:
allowedOrigins:
- "https://cv.alexochoa.dev"
- "https://back.alexochoa.dev"
- "http://localhost:3000"
allowedHeaders:
- Content-Type,Authorization
- X-Requested-With
- hx-current-url
- hx-request
- hx-target
- hx-trigger
allowedMethods: "*"
allowCredentials: true
exposedResponseHeaders: "*"
authorizers:
customAuthorizer:
type: request
functionArn: ${self:custom.secrets.AUTHORIZER_ARN}
payloadVersion: '2.0'
enableSimpleResponses: true
identitySource:
- $request.header.Cookie

functions:
cvQuestionsHandler:
handler: ${self:custom.handlers.cvQuestionsHandler}/index.handler
timeout: 29
package:
patterns:
- ${self:custom.handlers.cvQuestionsHandler}/**
events:
- httpApi:
path: /api/questions
method: POST
authorizer:
name: customAuthorizer

custom:
secrets: ${ssm:/aws/reference/secretsmanager/${param:sman,'dev/cv/alexochoadev'}}
handlers:
cvQuestionsHandler: 'dist/cvQuestions/handlers/cvQuestions'

customDomain:
domainName: back.alexochoa.dev
certificateArn: ${self:custom.secrets.CERTIFICATE_ARN_BACKEND}
basePath: ''
apiType: http
endpointType: REGIONAL
createRoute53Record: true

serverless-offline:
httpPort: 4000
cors:
origin: '*'
headers: '*'
allowCredentials: true

package:
individually: true
patterns:
- '!node_modules/**'
- '!src/**'

plugins:
- serverless-domain-manager
- serverless-offline
serverless-offline y serverless-domain-manager fueron de gran ayuda, ya que me permitieron desarrollar en local y me facilitó la configuración del custom domain de API Gateway, el cuál no es muy difícil de configurar pero es mucho mejor tenerlo automatizado. Sin embargo, un tema que afronté es que serverless offline no admite el uso de autorizadores importados por su ARN, lo cual tiene todo el sentido del mundo, y lo que tuve que hacer es comentar todo lo relacionado al autorizador cuando realizaba pruebas en local.
El package.json contiene varios scripts. Notarán el uso de ncc de vercel, un bundler con integración por defecto con Typescript, el cuál es muy importante para poder optimizar tiempos de iniciación en frío de las funciones lambda.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"name": "cv-alexochoa-dev-backend",
"type": "module",
...,
"scripts": {
"clean": "npx rimraf dist",
"questionsBuild": "npx ncc build ./src/cvQuestions/handlers/cvQuestionsHandler.js -o dist/cvQuestions/handlers/cvQuestions",
"questionsDeploy": "npm run questionsBuild && sls deploy function -f cvQuestionsHandler && npm run clean"
},
...
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.504.0",
"axios": "^1.6.7"
}
}

Conclusiones

Implementar IA en proyectos Serverless en AWS, como he mostrado en el desarrollo de mi CV interactivo, no solo ha sido una oportunidad para explorar las capacidades de AWS Bedrock y otras tecnologías serverless, sino también una ventana para innovar en cómo presentamos nuestras habilidades y experiencias profesionales de una manera dinámica y personalizada. Este proyecto no solo ha sido un reto técnico y una oportunidad de aprendizaje, sino también una demostración de cómo la tecnología puede ser utilizada para crear experiencias únicas y significativas para los usuarios, manteniendo al mismo tiempo una arquitectura robusta, segura y escalable.